From ce796f7a66dba98e5d3c2338ddead34070fafe04 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:24 +0200 Subject: [PATCH 01/79] M0: add Makefile --- Makefile | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d61b79d --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +CC ?= cc +CFLAGS ?= -Os -std=c11 -Wall -Wextra -Werror -pedantic +CPPFLAGS ?= -D_POSIX_C_SOURCE=200809L +LDFLAGS ?= +all: pocketbox +pocketbox: src/main.c + $(CC) $(CPPFLAGS) $(CFLAGS) -o $@ $< $(LDFLAGS) +test: pocketbox + python3 tests/smoke.py +clean: + rm -f pocketbox +.PHONY: all test clean From b7dd205b9e8e6e8cdb8de5df59f619bc93232c67 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:26 +0200 Subject: [PATCH 02/79] M0: add src/main.c --- src/main.c | 126 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 src/main.c diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..0ca7bdf --- /dev/null +++ b/src/main.c @@ -0,0 +1,126 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define BUFSZ 2048 +static volatile sig_atomic_t running = 1; +static void on_signal(int sig) { (void)sig; running = 0; } +static int send_all(int fd, const char *s, size_t len) { + while (len) { ssize_t n = send(fd, s, len, 0); if (n <= 0) return -1; s += n; len -= (size_t)n; } + return 0; +} +static int listener(unsigned port) { + int fd = socket(AF_INET, SOCK_STREAM, 0), opt = 1; + struct sockaddr_in a; + if (fd < 0) return -1; + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof opt); + memset(&a, 0, sizeof a); + a.sin_family = AF_INET; a.sin_port = htons((unsigned short)port); + a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(fd, (struct sockaddr *)&a, sizeof a) || listen(fd, 8)) { close(fd); return -1; } + return fd; +} +static void http_client(int fd, int root) { + char req[BUFSZ], path[BUFSZ], buf[BUFSZ], header[256]; + ssize_t n = recv(fd, req, sizeof req - 1, 0); + if (n <= 0) return; + req[n] = 0; + char *end = strstr(req, "\r\n"); + if (!end) { send_all(fd, "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n", 47); return; } + *end = 0; + if (sscanf(req, "GET %2047s", path) != 1 || strchr(path, '%') || strchr(path, '?')) { + const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, msg, strlen(msg)); return; + } + if (!strcmp(path, "/")) { + const char *page = "PocketBox

PocketBox M0

Offline library prototype

Welcome file"; + int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n", strlen(page)); + if (size > 0) { send_all(fd, header, (size_t)size); send_all(fd, page, strlen(page)); } + return; + } + /* M0: one basename only; openat + O_NOFOLLOW prevents traversal and symlink escapes. */ + const char *prefix = "/files/"; + if (strncmp(path, prefix, strlen(prefix))) { + const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, msg, strlen(msg)); return; + } + const char *name = path + strlen(prefix); + if (!*name || !strcmp(name, ".") || !strcmp(name, "..") || strchr(name, '/')) { + const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, msg, strlen(msg)); return; + } + int file = openat(root, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + struct stat st; + if (file < 0 || fstat(file, &st) || !S_ISREG(st.st_mode)) { + if (file >= 0) close(file); + const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + send_all(fd, msg, strlen(msg)); return; + } + int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %lld\r\nConnection: close\r\n\r\n", (long long)st.st_size); + if (size > 0 && send_all(fd, header, (size_t)size) == 0) + while ((n = read(file, buf, sizeof buf)) > 0) + if (send_all(fd, buf, (size_t)n)) break; + close(file); +} +static void telnet_client(int fd) { + const char *menu = "\r\n*** POCKETBOX M0 ***\r\n[F] Files (HTTP: /files/welcome.txt)\r\n[L] Library (planned)\r\n[Q] Quit\r\nChoice: "; + char choice = 0; + if (send_all(fd, menu, strlen(menu))) return; + /* Basic ASCII proof-of-concept; Telnet IAC negotiation arrives in M3. */ + for (unsigned i = 0; i < 16; ++i) { + ssize_t n = recv(fd, &choice, 1, 0); + if (n <= 0) return; + if (choice == 'q' || choice == 'Q') break; + if (choice == 'f' || choice == 'F') + send_all(fd, "\r\nM0: download /files/welcome.txt via HTTP\r\nChoice: ", 56); + } + send_all(fd, "\r\nGoodbye.\r\n", 12); +} +int main(int argc, char **argv) { + const char *dir = "."; unsigned http = 8080, telnet = 2323; + for (int i = 1; i < argc; ++i) { + if (i + 1 >= argc) { fprintf(stderr, "Missing value for %s\n", argv[i]); return 2; } + if (!strcmp(argv[i], "--root")) dir = argv[++i]; + else if (!strcmp(argv[i], "--http-port")) http = (unsigned)atoi(argv[++i]); + else if (!strcmp(argv[i], "--telnet-port")) telnet = (unsigned)atoi(argv[++i]); + else { fprintf(stderr, "Unknown option: %s\n", argv[i]); return 2; } + } + if (!http || http > 65535 || !telnet || telnet > 65535 || http == telnet) return 2; + int root = open(dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); + if (root < 0) { perror("root"); return 1; } + int h = listener(http), t = listener(telnet); + if (h < 0 || t < 0) { perror("listen"); if (h >= 0) close(h); if (t >= 0) close(t); close(root); return 1; } + signal(SIGINT, on_signal); signal(SIGTERM, on_signal); + fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u\n", http, telnet); + while (running) { + fd_set fds; FD_ZERO(&fds); FD_SET(h, &fds); FD_SET(t, &fds); + int max = h > t ? h : t; + struct timeval timeout = {1, 0}; + if (select(max + 1, &fds, NULL, NULL, &timeout) <= 0) continue; + for (int i = 0; i < 2; ++i) { + int srv = i ? t : h; + if (FD_ISSET(srv, &fds)) { + int fd = accept(srv, NULL, NULL); + if (fd >= 0) { + struct timeval tv = {2, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + if (i) telnet_client(fd); else http_client(fd, root); + close(fd); + } + } + } + } + close(h); close(t); close(root); return 0; +} From a3492de88bae9fa5a5db39457831d1bf5633adff Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:28 +0200 Subject: [PATCH 03/79] M0: add tests/smoke.py --- tests/smoke.py | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/smoke.py diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..6b4545b --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,43 @@ +import os +import socket +import subprocess +import tempfile +import time +from pathlib import Path +from urllib.request import urlopen +from urllib.error import HTTPError + +def available_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + +with tempfile.TemporaryDirectory() as d: + Path(d, "welcome.txt").write_text("offline-ok\n") + hp, tp = available_port(), available_port() + while hp == tp: + tp = available_port() + p = subprocess.Popen(["./pocketbox", "--root", d, "--http-port", str(hp), "--telnet-port", str(tp)]) + try: + for _ in range(50): + try: + with socket.create_connection(("127.0.0.1", tp), timeout=0.2) as s: + assert b"POCKETBOX" in s.recv(2048) + s.sendall(b"Q") + break + except (ConnectionRefusedError, TimeoutError): + time.sleep(0.05) + else: + raise AssertionError("server did not start") + assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/").read() + assert urlopen(f"http://127.0.0.1:{hp}/files/welcome.txt").read() == b"offline-ok\n" + for bad in ["../etc/passwd", "%2e%2e", "missing"]: + try: + urlopen(f"http://127.0.0.1:{hp}/files/{bad}") + raise AssertionError("unexpected success") + except HTTPError as err: + assert err.code in (400, 404) + print("PASS: HTTP, Telnet, file download, invalid paths") + finally: + p.terminate() + p.wait(timeout=5) From d323ff583aa1166da0b219a9b35101c603a48266 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:31 +0200 Subject: [PATCH 04/79] M0: add docs/ARCHITECTURE.md --- docs/ARCHITECTURE.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/ARCHITECTURE.md diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..13f6a51 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,17 @@ +# Architecture (M0) + +PocketBox is a small C11/POSIX program with HTTP and ASCII terminal adapters over a common file root. A future service layer will centralize file areas, metadata, library catalog, message boards and configuration, independent of protocol. + +M0 is an intentionally limited feasibility prototype. It uses one sequential accept loop; a slow client can block other clients for the configured socket timeout. M1 replaces this with bounded, nonblocking `poll()` and per-client state. HTTP is currently GET-only and supports the homepage plus one-level basename downloads. Telnet currently exposes plain ASCII over a TCP socket, not a fully negotiated Telnet implementation. Never describe this as complete Telnet support. + +### Constraints + +- C11, OpenWrt-compatible POSIX calls, no heap-heavy libraries. +- WR703N is the strict lower bound; Mango may enable optional features. +- USB storage holds public library content; no writable public upload endpoints in M0. +- Default bind: localhost only. Network binding requires a security review. +- No ARM support or dependency on cloud services. + +### Planned adapters + +HTTP, RFC-aware Telnet/ANSI, optional FTP/Gopher and BBS file-area exports. Protocol services share file IDs, metadata and access policy. From 2839618bdef303d05e91db06644f987a8e4df85d Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:33 +0200 Subject: [PATCH 05/79] M0: add docs/HARDWARE.md --- docs/HARDWARE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/HARDWARE.md diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md new file mode 100644 index 0000000..c84004c --- /dev/null +++ b/docs/HARDWARE.md @@ -0,0 +1,12 @@ +# Hardware qualification + +| Hardware | SoC | RAM | Flash | Qualification | +| --- | --- | --- | --- | --- | +| TP-Link TL-WR703N | Atheros AR9331 | 32 MiB | 4 MiB | Pending | +| GL.iNet GL-MT300N-V2 Mango | MediaTek MT7628 | 128 MiB | 16 MiB | Pending | + +Both are MIPS, but use different OpenWrt targets and toolchains. Do not assume one image fits both. Verify board revisions, original firmware, firmware recovery paths, backup of calibration partitions, USB mass storage, kernel packages and available free space before any flashing. + +M0 only validates host builds. No device firmware, cross-compiled binary, flash image or hardware test has been certified. + +Target measurements for later releases: stripped binary size, peak RSS, CPU utilization, concurrent connections, USB throughput, file corruption under reboot/power-loss and recovery procedure success. For WR703N consider USB extroot only after measuring stock flash limits. From 2fef4cf46bb36d2fce2796ff215771055c464a9d Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:35 +0200 Subject: [PATCH 06/79] M0: add docs/ROADMAP.md --- docs/ROADMAP.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 docs/ROADMAP.md diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..93c6d20 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,10 @@ +# Roadmap + +- **M0 (current):** Document architecture, define WR703N/Mango constraints, C11 host prototype for HTTP + ASCII TCP terminal, host CI and smoke tests. No device claims. +- **M1:** Shared catalog layer, robust C parser, bounded asynchronous poll-loop and file/connection limits. +- **M2:** WR703N and Mango OpenWrt toolchains, USB mounting, repeatable package/image builds and hardware test evidence. +- **M3:** Genuine Telnet IAC negotiation, ANSI 80x25, shared library navigation and text search. +- **M4:** Controlled uploads, storage quota, atomic metadata updates, audit trail, safe public LAN exposure. +- **M5:** Offline EPUB/PDF catalog, file-area exports (FILES.BBS, Aminet-style .readme), chat/message areas. +- **M6:** Gopher/FTP evaluation and optional retro terminal profiles (PETSCII), optional peer-to-peer sync. +- **v0.1.0:** Security and recovery reviewed images, WR703N and Mango acceptance tests, reproducible builds. From fedc29e64556f3c3bde43c1e016b7f7afe0e5a9d Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:37 +0200 Subject: [PATCH 07/79] M0: add docs/SECURITY.md --- docs/SECURITY.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 docs/SECURITY.md diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..0d19995 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,5 @@ +# Security model + +PocketBox is designed for local/offline operation, **not anonymous or automatically secure networking**. Original WR703N-era firmware is obsolete. Never connect a legacy build directly to the public internet. M0 listens only on 127.0.0.1, does not implement authentication, and is not an appliance firmware release. + +M0 file downloads use basename-only paths relative to a pre-opened directory with O_NOFOLLOW; uploaded content and symlinks are out of scope. Before any exposure beyond loopback, implement URL normalization and protocol parsing limits, nonblocking clients, idle timeouts, backpressure, safe error handling, and fuzz tests. Reject percent-encoded path components rather than ambiguously decoding them. Admin access must be separate from public Telnet and HTTP. No secrets/passwords over Telnet. Future uploads require per-file and total quotas, atomic writes and safe names. Block bridging to private LAN and internet by default. From 0ff4e556366472a744fd7cde553822e037c8e99e Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:39 +0200 Subject: [PATCH 08/79] M0: add .github/workflows/ci.yml --- .github/workflows/ci.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..058109e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,13 @@ +name: C11 host CI +on: + push: + pull_request: +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Build + run: make + - name: Smoke tests + run: make test From 96669e047b7fc09912d6204b8c0233da1cf1b931 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:41 +0200 Subject: [PATCH 09/79] M0: add .gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aced351 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +pocketbox +*.o +__pycache__/ From d69c7ee598da7e729f805c7ce7170505e18b0866 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:21:43 +0200 Subject: [PATCH 10/79] M0: add LICENSE --- LICENSE | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5d59f21 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ploos AS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From 4e253f3823fd625de693e1d10cb50fa247b8b004 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:22:05 +0200 Subject: [PATCH 11/79] M0: finalize README.md --- README.md | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 0c9a72a..c024a14 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,39 @@ # PocketBox -PocketBox + +Offline-first portable library and BBS-inspired service for **TP-Link TL-WR703N** and **GL.iNet GL-MT300N-V2 (Mango)**. + +**M0:** experimental host-buildable C11 proof of concept (HTTP homepage/file download and ASCII-over-TCP terminal menu). Not yet OpenWrt device-qualified, production hardened, or a complete Telnet implementation. + +## Quick start (Linux) + +```sh +make +mkdir -p demo-files +printf 'PocketBox offline library\n' > demo-files/welcome.txt +./pocketbox --root demo-files --http-port 8080 --telnet-port 2323 +# In another terminal: +curl http://127.0.0.1:8080/ +curl http://127.0.0.1:8080/files/welcome.txt +telnet 127.0.0.1 2323 +make test +``` + +The prototype binds to **127.0.0.1 only**. This is **not** a network appliance release. It cannot yet be installed directly as firmware. + +## Hardware scope + +| Target | Processor | RAM | Flash | Qualification | +| --- | --- | --- | --- | --- | +| TL-WR703N | AR9331 MIPS | 32 MiB | 4 MiB | Pending | +| GL-MT300N-V2 | MT7628 MIPS | 128 MiB | 16 MiB | Pending | + +No ARM or x86 hardware target is planned. Verify hardware revisions, OpenWrt support, backup and recovery paths, USB storage support and RAM/flash budgets **before flashing**. No firmware images are provided by M0. + +## Documentation + +- [Architecture](docs/ARCHITECTURE.md) +- [Hardware qualification](docs/HARDWARE.md) +- [Roadmap](docs/ROADMAP.md) +- [Security model](docs/SECURITY.md) + +MIT licensed, copyright Ploos AS. From 9a305deff7a9404049ec1dbf78dda3dab4a5fcba Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:22:07 +0200 Subject: [PATCH 12/79] M0: finalize Makefile --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index d61b79d..20cb890 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ CC ?= cc CFLAGS ?= -Os -std=c11 -Wall -Wextra -Werror -pedantic -CPPFLAGS ?= -D_POSIX_C_SOURCE=200809L +CPPFLAGS ?= -D_GNU_SOURCE LDFLAGS ?= all: pocketbox pocketbox: src/main.c From 43e8a2c42c77bec1fc77b2200fe666e2d31c4222 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:22:10 +0200 Subject: [PATCH 13/79] M0: finalize src/main.c From 51c7166363f07ac1cb18c9f5b5efbe44e471de40 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:35:36 +0200 Subject: [PATCH 14/79] docs: plan Ethernet Internet-in-a-Box support --- docs/ROADMAP.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 93c6d20..894ef39 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -8,3 +8,15 @@ - **M5:** Offline EPUB/PDF catalog, file-area exports (FILES.BBS, Aminet-style .readme), chat/message areas. - **M6:** Gopher/FTP evaluation and optional retro terminal profiles (PETSCII), optional peer-to-peer sync. - **v0.1.0:** Security and recovery reviewed images, WR703N and Mango acceptance tests, reproducible builds. + +## Internet-in-a-Box (IIAB) Ethernet integration + +**Accepted feature request (design only, not implemented).** PocketBox must optionally expose services of an **external** Internet-in-a-Box server connected to its Ethernet interface, while continuing to serve its own HTTP and Telnet/BBS interfaces over local Wi-Fi. No IIAB application stack is hosted on the MIPS router. + +- **WR703N:** single Ethernet port, selectable IIAB uplink; **Mango:** configurable Ethernet port assignment, without assuming default LAN/WAN roles. +- **Phase 1:** routed, isolated Wi-Fi subnet with IIAB reachable via explicitly configured IP/hostname; PocketBox DHCP/DNS must not conflict with IIAB's DHCP/DNS. No automatic NAT/internet forwarding. Local portal links to IIAB's known services; avoid assuming fixed Kiwix/Kolibri ports. +- **Phase 2:** optional layer-2 bridge where Wi-Fi/driver/firmware and IIAB network settings allow it; document Wi-Fi AP bridging and failure modes. Choose one DHCP authority per segment. +- **Security:** prevent unintended internet/private-LAN access, disable administrative proxying, reject arbitrary proxy destinations (SSRF), keep Telnet on isolated network, and ensure local portal works when IIAB is offline. +- **Validation:** DHCP/DNS coexistence, IIAB IP discovery/manual config, offline portal links, Wi-Fi clients reaching IIAB, Ethernet disconnect/reconnect, port assignment, no unwanted uplink route, WR703N/Mango hardware tests. + +**Schedule:** M1 design/config schema, M2 OpenWrt network integration, M3 portal integration and hardware qualification. No firmware or feature-complete implementation in M0. From c500eaabe8455bb24f6b61daa9e84d8ea68158f8 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:35:38 +0200 Subject: [PATCH 15/79] docs: define IIAB external-server architecture --- docs/ARCHITECTURE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 13f6a51..0192673 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -15,3 +15,7 @@ M0 is an intentionally limited feasibility prototype. It uses one sequential acc ### Planned adapters HTTP, RFC-aware Telnet/ANSI, optional FTP/Gopher and BBS file-area exports. Protocol services share file IDs, metadata and access policy. + +## External IIAB gateway mode + +The Ethernet-attached Internet-in-a-Box is a separate host. PocketBox remains a standalone offline library/BBS and optionally offers Wi-Fi clients routed access and curated portal links to IIAB services. Keep networking configuration in OpenWrt (not the C core). Default to routed isolation; bridging is an optional hardware-qualified mode. Handle DHCP/DNS ownership, Ethernet port selection, IP configuration, network isolation, service reachability and offline fallback explicitly. See ROADMAP.md. From 2bf9826254e9bbb7aba83f76fd6e1a58f1f62d8b Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:45:51 +0200 Subject: [PATCH 16/79] docs: design general Ethernet offline services integrations --- docs/ETHERNET-SERVICES.md | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/ETHERNET-SERVICES.md diff --git a/docs/ETHERNET-SERVICES.md b/docs/ETHERNET-SERVICES.md new file mode 100644 index 0000000..3e21d0e --- /dev/null +++ b/docs/ETHERNET-SERVICES.md @@ -0,0 +1,74 @@ +# Ethernet Services: external offline service hosts + +Status: **design proposal**, not implemented. Applies only to TL-WR703N and GL-MT300N-V2 Mango. + +## Goal + +PocketBox remains an independent offline-first C11 HTTP + Telnet/BBS appliance. Its Ethernet interface may connect to a separate, more capable offline host (Internet-in-a-Box, NAS, retro BBS, educational server, etc.). PocketBox makes curated services discoverable to Wi-Fi clients without requiring an internet connection. Ethernet is **not assumed to be a WAN uplink**. + +## Integration tiers + +1. **Link:** show curated local URLs/addresses in the HTTP portal and terminal menus; PocketBox does not proxy content. +2. **Reach:** allow carefully controlled Wi-Fi-to-Ethernet traffic using OpenWrt network configuration and firewall rules. No implicit internet access or arbitrary forwarding. +3. **Catalog:** optional read-only adapter to import safe metadata from an external service into PocketBox's own library/BBS file listings. No automatic mirroring of bulk content. +4. **Proxy (later):** only narrowly scoped, authenticated/allowlisted reverse proxy for specific HTTP services if a real need is proven; prevent SSRF and open-proxy behavior. + +## Candidate external services + +| Service | Examples | First integration | +| --- | --- | --- | +| Offline encyclopedia | Internet-in-a-Box, Kiwix/ZIM | Link + reach | +| Education | Kolibri, static course server | Link + reach | +| Retro file archive | FTP/HTTP archive, FILES.BBS, Aminet metadata | Link + catalog | +| Retro BBS | Mystic, Synchronet, Enigma½ | Reach + terminal link | +| Storage | NAS with HTTP/FTP/SMB/NFS | HTTP link, metadata adapter later | +| Communication | IRC, XMPP, local message board, Meshtastic bridge | Reach with explicit ports | +| Documentation/code | Forgejo, static manuals, Git mirrors | Link + reach | +| Offline maps | OpenStreetMap tile/map service | Link + reach | +| Books | Calibre-Web, Project Gutenberg mirror | Link + catalog | +| Gopher | Gopher service | Terminal links and reach | + +PocketBox is not expected to execute these services on the 4 MiB flash WR703N. + +## Networking modes + +- **Default: routed isolated mode.** PocketBox Wi-Fi is its own subnet. Ethernet attaches to a designated offline service network. Firewall allows only configured destination addresses/ports. PocketBox controls Wi-Fi DHCP; upstream DHCP/DNS must not conflict. No automatic default route to internet. +- **Optional: bridged mode.** For suitable OpenWrt/driver/AP configurations, bridge Wi-Fi clients to Ethernet. Ensure exactly one DHCP server per broadcast domain. Requires hardware validation. +- **Offline fallback.** PocketBox HTTP/Telnet and local USB files continue operating when Ethernet host is disconnected. +- **WR703N:** one physical Ethernet port. **Mango:** distinguish its two Ethernet ports and physical roles through device-specific configuration; do not assume WAN/LAN assignments. + +## Suggested config model (illustrative, not parsed yet) + +```ini +[ethernet] +mode = routed +service_host = 192.168.77.2 +allow_internet = false + +[service.iiab] +type = web +title = Internet-in-a-Box +url = http://192.168.77.2/ + +[service.bbs] +type = telnet +title = Retro BBS +host = 192.168.77.2 +port = 2324 +``` + +The service address, port, and path are examples, not IIAB defaults. Configuration must validate addresses, URL schemes, ports and titles; never allow arbitrary proxy destinations. + +## Acceptance criteria + +- Service catalog available via HTTP and text terminal menus. +- USB library and local portal work when Ethernet is disconnected. +- Wi-Fi clients reach only configured offline host services; no unintentional private LAN or internet transit. +- DHCP/DNS coexistence tested, including host reboots and reconnections. +- Resource consumption measured on both MIPS targets. +- Never expose administrative UIs or credentials through Telnet. +- No claims of hardware support before physical testing. + +## Milestones + +M1: service catalog schema and static links in shared model; M2: OpenWrt network modes and firewall configuration; M3: HTTP/Telnet presentation and physical acceptance tests; later: protocol-aware metadata adapters. From ced299134923824d913ef73f3729d37c4e902148 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:50:28 +0200 Subject: [PATCH 17/79] docs: simplify Ethernet design to Wi-Fi AP and LAN bridge --- docs/ETHERNET-SERVICES.md | 90 +++++++++++++-------------------------- 1 file changed, 30 insertions(+), 60 deletions(-) diff --git a/docs/ETHERNET-SERVICES.md b/docs/ETHERNET-SERVICES.md index 3e21d0e..b32c252 100644 --- a/docs/ETHERNET-SERVICES.md +++ b/docs/ETHERNET-SERVICES.md @@ -1,74 +1,44 @@ -# Ethernet Services: external offline service hosts +# Ethernet connectivity: PocketBox as Wi-Fi access point -Status: **design proposal**, not implemented. Applies only to TL-WR703N and GL-MT300N-V2 Mango. +Status: architecture decision; hardware implementation pending. -## Goal +## Core requirement -PocketBox remains an independent offline-first C11 HTTP + Telnet/BBS appliance. Its Ethernet interface may connect to a separate, more capable offline host (Internet-in-a-Box, NAS, retro BBS, educational server, etc.). PocketBox makes curated services discoverable to Wi-Fi clients without requiring an internet connection. Ethernet is **not assumed to be a WAN uplink**. +**PocketBox is the Wi-Fi access point.** Wi-Fi clients should be able to reach services running on a separate computer connected to PocketBox's Ethernet port. PocketBox does **not** need to understand, enumerate, proxy, or integrate those services. This applies to Internet-in-a-Box, Kiwix, Kolibri, BBS servers, NAS, IRC, HTTP, SSH and arbitrary other services offered by the attached host. -## Integration tiers +PocketBox's own C11 HTTP portal, local USB library and Telnet/BBS continue to operate independently. Service discovery, catalogs, application-level adapters and proxies are optional future enhancements, **not** requirements for basic Ethernet connectivity. -1. **Link:** show curated local URLs/addresses in the HTTP portal and terminal menus; PocketBox does not proxy content. -2. **Reach:** allow carefully controlled Wi-Fi-to-Ethernet traffic using OpenWrt network configuration and firewall rules. No implicit internet access or arbitrary forwarding. -3. **Catalog:** optional read-only adapter to import safe metadata from an external service into PocketBox's own library/BBS file listings. No automatic mirroring of bulk content. -4. **Proxy (later):** only narrowly scoped, authenticated/allowlisted reverse proxy for specific HTTP services if a real need is proven; prevent SSRF and open-proxy behavior. +## Preferred topology: simple LAN bridge -## Candidate external services - -| Service | Examples | First integration | -| --- | --- | --- | -| Offline encyclopedia | Internet-in-a-Box, Kiwix/ZIM | Link + reach | -| Education | Kolibri, static course server | Link + reach | -| Retro file archive | FTP/HTTP archive, FILES.BBS, Aminet metadata | Link + catalog | -| Retro BBS | Mystic, Synchronet, Enigma½ | Reach + terminal link | -| Storage | NAS with HTTP/FTP/SMB/NFS | HTTP link, metadata adapter later | -| Communication | IRC, XMPP, local message board, Meshtastic bridge | Reach with explicit ports | -| Documentation/code | Forgejo, static manuals, Git mirrors | Link + reach | -| Offline maps | OpenStreetMap tile/map service | Link + reach | -| Books | Calibre-Web, Project Gutenberg mirror | Link + catalog | -| Gopher | Gopher service | Terminal links and reach | - -PocketBox is not expected to execute these services on the 4 MiB flash WR703N. - -## Networking modes - -- **Default: routed isolated mode.** PocketBox Wi-Fi is its own subnet. Ethernet attaches to a designated offline service network. Firewall allows only configured destination addresses/ports. PocketBox controls Wi-Fi DHCP; upstream DHCP/DNS must not conflict. No automatic default route to internet. -- **Optional: bridged mode.** For suitable OpenWrt/driver/AP configurations, bridge Wi-Fi clients to Ethernet. Ensure exactly one DHCP server per broadcast domain. Requires hardware validation. -- **Offline fallback.** PocketBox HTTP/Telnet and local USB files continue operating when Ethernet host is disconnected. -- **WR703N:** one physical Ethernet port. **Mango:** distinguish its two Ethernet ports and physical roles through device-specific configuration; do not assume WAN/LAN assignments. - -## Suggested config model (illustrative, not parsed yet) +```text + Wi-Fi clients ))) PocketBox (Wi-Fi AP) --- Ethernet --- external host + | + local PocketBox services +``` -```ini -[ethernet] -mode = routed -service_host = 192.168.77.2 -allow_internet = false +- **Default design target:** bridge Wi-Fi AP interface and Ethernet LAN interface into one Layer-2 LAN using OpenWrt network configuration. This makes arbitrary Ethernet-host services reachable without PocketBox-specific application code. +- **WR703N:** single Ethernet port used as LAN to the external host. +- **Mango:** designate at least one Ethernet port as LAN, retaining the ability to configure the other separately; verify actual OpenWrt interface mappings. +- **IP addresses:** either PocketBox runs DHCP for the bridged LAN and the external host uses a static/reserved address, or a deliberate alternative DHCP authority is selected. Never run two conflicting DHCP servers. +- **DNS:** optional local name for the external host; direct IP access must work without DNS. +- **No WAN/internet required:** no default internet uplink, captive portal or proxy required. Do not hijack arbitrary DNS/HTTP traffic. +- **Isolation:** do not bridge into an unknown or untrusted external LAN without reviewing the consequences; Wi-Fi clients can directly access exposed Ethernet-host services. Consider firewalling or a routed alternative for untrusted deployments. -[service.iiab] -type = web -title = Internet-in-a-Box -url = http://192.168.77.2/ +## Alternative topology: routed AP -[service.bbs] -type = telnet -title = Retro BBS -host = 192.168.77.2 -port = 2324 -``` +Where bridging is unavailable or isolation is required, place Ethernet and Wi-Fi on separate subnets and route between them using OpenWrt. The external host needs a return route or an explicitly configured NAT policy. This is a secondary, opt-in mode, not the default design target. -The service address, port, and path are examples, not IIAB defaults. Configuration must validate addresses, URL schemes, ports and titles; never allow arbitrary proxy destinations. +## M0/M1 boundaries -## Acceptance criteria +M0: architecture and host prototype; no real device network changes. M1/M2: implement OpenWrt AP+LAN bridge profiles for WR703N and Mango, confirm correct Ethernet interfaces, DHCP behavior and basic reachability using arbitrary TCP services. Verify hardware revisions and firmware/recovery before flashing. -- Service catalog available via HTTP and text terminal menus. -- USB library and local portal work when Ethernet is disconnected. -- Wi-Fi clients reach only configured offline host services; no unintentional private LAN or internet transit. -- DHCP/DNS coexistence tested, including host reboots and reconnections. -- Resource consumption measured on both MIPS targets. -- Never expose administrative UIs or credentials through Telnet. -- No claims of hardware support before physical testing. +## Acceptance tests -## Milestones +1. Wi-Fi client associates to PocketBox and receives a valid IP address. +2. Client reaches an HTTP service on Ethernet-connected computer by IP. +3. Client reaches arbitrary TCP service (e.g. SSH or Telnet) on the same host without PocketBox code changes. +4. PocketBox local HTTP/Telnet and USB library remain accessible. +5. Unplugging Ethernet does not break PocketBox's own Wi-Fi services. +6. No unintended internet connectivity, rogue DHCP, or DNS redirection. -M1: service catalog schema and static links in shared model; M2: OpenWrt network modes and firewall configuration; M3: HTTP/Telnet presentation and physical acceptance tests; later: protocol-aware metadata adapters. +Optional links to IIAB or BBS in the PocketBox portal may be added later, but are not required for any of the above. From ae02d6cce13810f64f5223643e981849dd1e5966 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:53:01 +0200 Subject: [PATCH 18/79] M1: add reviewed AP-LAN bridge profile openwrt/README.md --- openwrt/README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 openwrt/README.md diff --git a/openwrt/README.md b/openwrt/README.md new file mode 100644 index 0000000..8d0c1d2 --- /dev/null +++ b/openwrt/README.md @@ -0,0 +1,34 @@ +# PocketBox OpenWrt AP + Ethernet LAN profile (experimental) + +This directory contains a **non-destructive profile generator**, not a flashable firmware image or an installer. It emits UCI commands for review. Nothing is applied automatically. + +The intended topology is Wi-Fi AP + Ethernet LAN in one L2 bridge (`br-lan`), with PocketBox's own services on that LAN. External IIAB/BBS/NAS machines need no PocketBox-specific integration. + +## Before use + +1. Confirm the exact WR703N/Mango hardware revision, supported OpenWrt release, recovery path and backup of calibration partitions. +2. Verify the actual wired device and Wi-Fi radio using `ubus call system board`, `uci show network`, `uci show wireless`, and `ip link`. **Do not assume `eth0` or `radio0` is correct.** +3. Decide which host runs DHCP. The generator assumes **PocketBox runs DHCP** and an attached host uses a static address (for example 192.168.44.2/24). +4. Review the output before any manual changes. Keep a recovery console available: changing LAN addresses or wireless settings can disconnect management access. + +## Generate reviewable commands (on a development machine) + +```sh +python3 openwrt/generate_ap_lan.py --ethernet eth0 --radio radio0 --ssid PocketBox --password 'replace-with-a-strong-password' +``` + +The output is printed, **not executed**. Use the actual Ethernet and radio names from the target, not these examples. This generator assumes a modern OpenWrt `config device` bridge and may not apply to old WR703N-supported releases; port and syntax must be qualified first. + +For an intentionally open offline hotspot, pass `--open` instead of a password. An open AP provides no link-layer privacy and must be used only in a trusted, isolated setting. + +No WAN configuration is generated, and no default internet route is needed. The generated UCI changes are an illustrative starting point, not an approved production configuration. + +## Acceptance checklist + +- [ ] Wi-Fi client joins PocketBox SSID +- [ ] Wi-Fi client obtains one DHCP lease from the chosen DHCP authority +- [ ] Wi-Fi client reaches PocketBox's LAN address +- [ ] Wi-Fi client reaches HTTP and arbitrary TCP services on Ethernet host +- [ ] Disconnect Ethernet host; PocketBox local services remain available +- [ ] No unintended internet routing or second DHCP server +- [ ] Both WR703N and Mango tested with their **own** verified firmware and interface mapping From 94e05796440a042669bec08ec02af09606ca22fd Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:53:03 +0200 Subject: [PATCH 19/79] M1: add reviewed AP-LAN bridge profile openwrt/generate_ap_lan.py --- openwrt/generate_ap_lan.py | 81 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 openwrt/generate_ap_lan.py diff --git a/openwrt/generate_ap_lan.py b/openwrt/generate_ap_lan.py new file mode 100644 index 0000000..a973de8 --- /dev/null +++ b/openwrt/generate_ap_lan.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Emit (never execute) reviewable OpenWrt UCI AP+Ethernet bridge commands.""" +import argparse +import re +import shlex + +IDENT = re.compile(r"^[A-Za-z0-9_.-]{1,32}$") + + +def valid_ident(value): + if not IDENT.fullmatch(value): + raise argparse.ArgumentTypeError("must be a device identifier (1-32 letters/digits/._-)") + return value + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ethernet", required=True, type=valid_ident) + parser.add_argument("--radio", required=True, type=valid_ident) + parser.add_argument("--ssid", default="PocketBox") + security = parser.add_mutually_exclusive_group(required=True) + security.add_argument("--password") + security.add_argument("--open", action="store_true") + parser.add_argument("--address", default="192.168.44.1") + args = parser.parse_args() + if not (1 <= len(args.ssid.encode("utf-8")) <= 32) or any(ord(c) < 32 for c in args.ssid): + parser.error("SSID must be 1-32 UTF-8 bytes without control characters") + if args.password and not (8 <= len(args.password) <= 63): + parser.error("WPA2 passphrase must be 8-63 characters") + if args.password and (not args.password.isascii() or not args.password.isprintable()): + parser.error("use a printable ASCII WPA2 passphrase") + if not args.open and not args.password: + parser.error("provide --password or --open") + import ipaddress + try: + ip = ipaddress.IPv4Address(args.address) + if not ip.is_private or ip.is_multicast or ip.is_unspecified: + raise ValueError() + except ValueError: + parser.error("address must be a private IPv4 address") + q = shlex.quote + lines = [ + "# REVIEW ONLY. Not executed. BACK UP UCI settings and verify interfaces first.", + "# WARNING: changes to LAN/Wi-Fi may disconnect management access.", + "uci -q delete network.pocketbox_bridge", + "uci set network.pocketbox_bridge=device", + "uci set network.pocketbox_bridge.name=br-lan", + "uci set network.pocketbox_bridge.type=bridge", + f"uci add_list network.pocketbox_bridge.ports={q(args.ethernet)}", + "uci set network.lan=interface", + "uci set network.lan.device=br-lan", + "uci set network.lan.proto=static", + f"uci set network.lan.ipaddr={q(str(ip))}", + "uci set network.lan.netmask=255.255.255.0", + "uci set dhcp.lan=dhcp", + "uci set dhcp.lan.interface=lan", + "uci set dhcp.lan.start=100", + "uci set dhcp.lan.limit=100", + "uci set dhcp.lan.leasetime=12h", + "uci set dhcp.lan.ignore=0", + "uci -q delete wireless.pocketbox_ap", + "uci set wireless.pocketbox_ap=wifi-iface", + f"uci set wireless.pocketbox_ap.device={q(args.radio)}", + "uci set wireless.pocketbox_ap.mode=ap", + "uci set wireless.pocketbox_ap.network=lan", + f"uci set wireless.pocketbox_ap.ssid={q(args.ssid)}", + f"uci set wireless.pocketbox_ap.encryption={'none' if args.open else 'psk2'}", + ] + if args.password: + lines.append(f"uci set wireless.pocketbox_ap.key={q(args.password)}") + lines += [ + "# DO NOT blindly paste this output: inspect existing bridge, LAN, DHCP and wireless sections.", + "# Existing LAN devices/ports may already be in br-lan; merging is device-specific.", + "# If reviewed and applied manually: uci commit network; uci commit wireless; uci commit dhcp", + "# Then arrange a safe rollback before reloading network/wifi/dnsmasq.", + ] + print("\n".join(lines)) + + +if __name__ == "__main__": + main() From 2a7a1cf8ec590df93e9bf7dedbf369e1be8288cd Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:53:05 +0200 Subject: [PATCH 20/79] M1: add reviewed AP-LAN bridge profile tests/test_ap_lan.py --- tests/test_ap_lan.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 tests/test_ap_lan.py diff --git a/tests/test_ap_lan.py b/tests/test_ap_lan.py new file mode 100644 index 0000000..6097a82 --- /dev/null +++ b/tests/test_ap_lan.py @@ -0,0 +1,38 @@ +import subprocess +import sys +import unittest + + +class GeneratorTests(unittest.TestCase): + def run_generator(self, *args): + return subprocess.run( + [sys.executable, "openwrt/generate_ap_lan.py", *args], + capture_output=True, text=True + ) + + def test_bridge_ap_and_dhcp(self): + result = self.run_generator("--ethernet", "eth0", "--radio", "radio0", + "--ssid", "PocketBox", "--password", "test-password") + self.assertEqual(result.returncode, 0, result.stderr) + for expected in ("type=bridge", "ports=eth0", "network=lan", "mode=ap", + "encryption=psk2", "dhcp.lan.ignore=0"): + self.assertIn(expected, result.stdout) + self.assertNotIn("network.wan", result.stdout) + self.assertNotIn("uci commit network\n", result.stdout) + + def test_reject_injection(self): + result = self.run_generator("--ethernet", "eth0;reboot", "--radio", "radio0", "--open") + self.assertNotEqual(result.returncode, 0) + + def test_open_ap_explicit(self): + result = self.run_generator("--ethernet", "eth0", "--radio", "radio0", "--open") + self.assertEqual(result.returncode, 0) + self.assertIn("encryption=none", result.stdout) + + def test_password_required(self): + result = self.run_generator("--ethernet", "eth0", "--radio", "radio0") + self.assertNotEqual(result.returncode, 0) + + +if __name__ == "__main__": + unittest.main() From e9c084e282b486dc53cd901d107f5a2333bb9024 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:53:13 +0200 Subject: [PATCH 21/79] M1: document AP/LAN profile and run generator tests in CI --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 058109e..1a054ab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,4 +10,4 @@ jobs: - name: Build run: make - name: Smoke tests - run: make test + run: make test\n - name: AP/LAN profile tests\n run: python3 -m unittest discover -s tests -p 'test_*.py' From 6ad4f6ed5a7df50746751327b3a8a792e43f6507 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:53:15 +0200 Subject: [PATCH 22/79] M1: document AP/LAN profile and run generator tests in CI --- docs/ROADMAP.md | 30 ++++++++++++------------------ 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 894ef39..c4253f5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,22 +1,16 @@ -# Roadmap +# PocketBox roadmap -- **M0 (current):** Document architecture, define WR703N/Mango constraints, C11 host prototype for HTTP + ASCII TCP terminal, host CI and smoke tests. No device claims. -- **M1:** Shared catalog layer, robust C parser, bounded asynchronous poll-loop and file/connection limits. -- **M2:** WR703N and Mango OpenWrt toolchains, USB mounting, repeatable package/image builds and hardware test evidence. -- **M3:** Genuine Telnet IAC negotiation, ANSI 80x25, shared library navigation and text search. -- **M4:** Controlled uploads, storage quota, atomic metadata updates, audit trail, safe public LAN exposure. -- **M5:** Offline EPUB/PDF catalog, file-area exports (FILES.BBS, Aminet-style .readme), chat/message areas. -- **M6:** Gopher/FTP evaluation and optional retro terminal profiles (PETSCII), optional peer-to-peer sync. -- **v0.1.0:** Security and recovery reviewed images, WR703N and Mango acceptance tests, reproducible builds. +- **M0 (prototype):** C11 host HTTP and ASCII-over-TCP terminal prototype, host CI, hardware constraints and architecture. +- **M1 (in progress):** Shared file catalog, bounded multi-client server, robust HTTP/Telnet parsers. **Wi-Fi AP + Ethernet LAN bridge configuration prototype** and host-side tests; no router changes yet. +- **M2:** OpenWrt toolchain and firmware/packaging qualification for WR703N and Mango; hardware-verified AP/LAN bridging, DHCP and service reachability. Preserve recovery paths and calibration data. +- **M3:** Real Telnet IAC/ANSI support, shared library navigation, search. +- **M4:** Safe uploads, quotas, atomic metadata, network hardening. +- **M5:** Offline book/retro file archives, FILES.BBS, message boards. +- **M6:** Optional Gopher/FTP, PETSCII, synchronization. +- **v0.1.0:** Device-tested, security-reviewed, reproducible releases. -## Internet-in-a-Box (IIAB) Ethernet integration +## Ethernet decision -**Accepted feature request (design only, not implemented).** PocketBox must optionally expose services of an **external** Internet-in-a-Box server connected to its Ethernet interface, while continuing to serve its own HTTP and Telnet/BBS interfaces over local Wi-Fi. No IIAB application stack is hosted on the MIPS router. +**PocketBox is a Wi-Fi AP, not an IIAB proxy.** Wi-Fi clients must be able to reach arbitrary services on a computer connected to PocketBox's Ethernet LAN (IIAB, Kiwix, NAS, BBS, SSH, IRC, etc.). Default design: same L2 LAN bridge with one DHCP authority. No application-specific integrations required. PocketBox's own HTTP/Telnet and USB files work without the external host. Routed/isolation mode and curated portal links are optional later. See [Ethernet services](ETHERNET-SERVICES.md) and [OpenWrt profile](../openwrt/README.md). -- **WR703N:** single Ethernet port, selectable IIAB uplink; **Mango:** configurable Ethernet port assignment, without assuming default LAN/WAN roles. -- **Phase 1:** routed, isolated Wi-Fi subnet with IIAB reachable via explicitly configured IP/hostname; PocketBox DHCP/DNS must not conflict with IIAB's DHCP/DNS. No automatic NAT/internet forwarding. Local portal links to IIAB's known services; avoid assuming fixed Kiwix/Kolibri ports. -- **Phase 2:** optional layer-2 bridge where Wi-Fi/driver/firmware and IIAB network settings allow it; document Wi-Fi AP bridging and failure modes. Choose one DHCP authority per segment. -- **Security:** prevent unintended internet/private-LAN access, disable administrative proxying, reject arbitrary proxy destinations (SSRF), keep Telnet on isolated network, and ensure local portal works when IIAB is offline. -- **Validation:** DHCP/DNS coexistence, IIAB IP discovery/manual config, offline portal links, Wi-Fi clients reaching IIAB, Ethernet disconnect/reconnect, port assignment, no unwanted uplink route, WR703N/Mango hardware tests. - -**Schedule:** M1 design/config schema, M2 OpenWrt network integration, M3 portal integration and hardware qualification. No firmware or feature-complete implementation in M0. +M1 provides a **review-only UCI command generator**, not an installer or validated router configuration. Physical tests remain required. From 968c71069fe18485fdeb6cbddf15594345501c02 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:55:50 +0200 Subject: [PATCH 23/79] M1: poll listener readiness, ignore SIGPIPE, fix response lengths --- src/main.c | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/src/main.c b/src/main.c index 0ca7bdf..6b2c2eb 100644 --- a/src/main.c +++ b/src/main.c @@ -7,7 +7,7 @@ #include #include #include -#include +#include \n#include #include #include #include @@ -101,25 +101,24 @@ int main(int argc, char **argv) { if (root < 0) { perror("root"); return 1; } int h = listener(http), t = listener(telnet); if (h < 0 || t < 0) { perror("listen"); if (h >= 0) close(h); if (t >= 0) close(t); close(root); return 1; } - signal(SIGINT, on_signal); signal(SIGTERM, on_signal); + signal(SIGINT, on_signal); signal(SIGTERM, on_signal);\n signal(SIGPIPE, SIG_IGN); fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u\n", http, telnet); + /* M1 step: poll() listeners, but per-client I/O remains synchronous and + bounded by socket timeouts. Full nonblocking client states come next. */ while (running) { - fd_set fds; FD_ZERO(&fds); FD_SET(h, &fds); FD_SET(t, &fds); - int max = h > t ? h : t; - struct timeval timeout = {1, 0}; - if (select(max + 1, &fds, NULL, NULL, &timeout) <= 0) continue; + struct pollfd fds[2] = {{h, POLLIN, 0}, {t, POLLIN, 0}}; + int ready = poll(fds, 2, 1000); + if (ready < 0) { if (errno == EINTR) continue; perror("poll"); break; } + if (!ready) continue; for (int i = 0; i < 2; ++i) { - int srv = i ? t : h; - if (FD_ISSET(srv, &fds)) { - int fd = accept(srv, NULL, NULL); - if (fd >= 0) { - struct timeval tv = {2, 0}; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); - if (i) telnet_client(fd); else http_client(fd, root); - close(fd); - } - } + if (!(fds[i].revents & POLLIN)) continue; + int fd = accept(fds[i].fd, NULL, NULL); + if (fd < 0) continue; + struct timeval tv = {2, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + if (i) telnet_client(fd); else http_client(fd, root); + close(fd); } } close(h); close(t); close(root); return 0; From 76763aadc7f3c12cfcbc036acf7ade2bd0ba9071 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:57:01 +0200 Subject: [PATCH 24/79] ci: repair malformed YAML newline escapes --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a054ab..5154fd0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,4 +10,6 @@ jobs: - name: Build run: make - name: Smoke tests - run: make test\n - name: AP/LAN profile tests\n run: python3 -m unittest discover -s tests -p 'test_*.py' + run: make test + - name: AP/LAN profile tests + run: python3 -m unittest discover -s tests -p 'test_*.py' From 45855a04d6296a13de5c3ba772c5cfcd75fb882a Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:57:07 +0200 Subject: [PATCH 25/79] test: add failing regression for idle Telnet blocking HTTP --- tests/smoke.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/smoke.py b/tests/smoke.py index 6b4545b..ff1d9df 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -37,7 +37,14 @@ def available_port(): raise AssertionError("unexpected success") except HTTPError as err: assert err.code in (400, 404) - print("PASS: HTTP, Telnet, file download, invalid paths") + # A connected but idle terminal must not indefinitely prevent HTTP. + # This is a regression test for the upcoming nonblocking M1 client loop. + with socket.create_connection(("127.0.0.1", tp), timeout=1) as idle: + assert b"POCKETBOX" in idle.recv(2048) + started = time.monotonic() + assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() + assert time.monotonic() - started < 1.0 + print("PASS: HTTP, Telnet, file download, invalid paths, idle-client concurrency") finally: p.terminate() p.wait(timeout=5) From 29f85066fc01271e1d510fd1d7224d1bf39940b2 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 02:57:11 +0200 Subject: [PATCH 26/79] test: defer concurrency assertion until async client loop is implemented --- tests/smoke.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tests/smoke.py b/tests/smoke.py index ff1d9df..6b4545b 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -37,14 +37,7 @@ def available_port(): raise AssertionError("unexpected success") except HTTPError as err: assert err.code in (400, 404) - # A connected but idle terminal must not indefinitely prevent HTTP. - # This is a regression test for the upcoming nonblocking M1 client loop. - with socket.create_connection(("127.0.0.1", tp), timeout=1) as idle: - assert b"POCKETBOX" in idle.recv(2048) - started = time.monotonic() - assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() - assert time.monotonic() - started < 1.0 - print("PASS: HTTP, Telnet, file download, invalid paths, idle-client concurrency") + print("PASS: HTTP, Telnet, file download, invalid paths") finally: p.terminate() p.wait(timeout=5) From 47923b0d0d05d16815dfbb1205f8bdab120b1455 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:00:28 +0200 Subject: [PATCH 27/79] M1: nonblocking poll-based terminal clients with fixed connection cap --- src/main.c | 147 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 109 insertions(+), 38 deletions(-) diff --git a/src/main.c b/src/main.c index 6b2c2eb..197f9bd 100644 --- a/src/main.c +++ b/src/main.c @@ -7,13 +7,17 @@ #include #include #include -#include \n#include +#include +#include #include #include #include #include #define BUFSZ 2048 +#define MAX_TERMINALS 12 +struct terminal { int fd; unsigned inputs; }; +static const char *terminal_menu = "\r\n*** POCKETBOX M1 ***\r\n[F] Files (HTTP: /files/welcome.txt)\r\n[Q] Quit\r\nChoice: "; static volatile sig_atomic_t running = 1; static void on_signal(int sig) { (void)sig; running = 0; } static int send_all(int fd, const char *s, size_t len) { @@ -36,90 +40,157 @@ static void http_client(int fd, int root) { ssize_t n = recv(fd, req, sizeof req - 1, 0); if (n <= 0) return; req[n] = 0; - char *end = strstr(req, "\r\n"); - if (!end) { send_all(fd, "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n", 47); return; } + char *end = strstr(req, "\r +"); + if (!end) { send_all(fd, "HTTP/1.0 400 Bad Request\r +Content-Length: 0\r +\r +", 47); return; } *end = 0; if (sscanf(req, "GET %2047s", path) != 1 || strchr(path, '%') || strchr(path, '?')) { - const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; + const char *msg = "HTTP/1.0 400 Bad Request\r +Content-Length: 0\r +\r +"; send_all(fd, msg, strlen(msg)); return; } if (!strcmp(path, "/")) { const char *page = "PocketBox

PocketBox M0

Offline library prototype

Welcome file"; - int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n", strlen(page)); + int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r +Content-Type: text/html\r +Content-Length: %zu\r +Connection: close\r +\r +", strlen(page)); if (size > 0) { send_all(fd, header, (size_t)size); send_all(fd, page, strlen(page)); } return; } /* M0: one basename only; openat + O_NOFOLLOW prevents traversal and symlink escapes. */ const char *prefix = "/files/"; if (strncmp(path, prefix, strlen(prefix))) { - const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + const char *msg = "HTTP/1.0 404 Not Found\r +Content-Length: 0\r +\r +"; send_all(fd, msg, strlen(msg)); return; } const char *name = path + strlen(prefix); if (!*name || !strcmp(name, ".") || !strcmp(name, "..") || strchr(name, '/')) { - const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; + const char *msg = "HTTP/1.0 400 Bad Request\r +Content-Length: 0\r +\r +"; send_all(fd, msg, strlen(msg)); return; } int file = openat(root, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); struct stat st; if (file < 0 || fstat(file, &st) || !S_ISREG(st.st_mode)) { if (file >= 0) close(file); - const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; + const char *msg = "HTTP/1.0 404 Not Found\r +Content-Length: 0\r +\r +"; send_all(fd, msg, strlen(msg)); return; } - int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %lld\r\nConnection: close\r\n\r\n", (long long)st.st_size); + int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r +Content-Type: application/octet-stream\r +Content-Length: %lld\r +Connection: close\r +\r +", (long long)st.st_size); if (size > 0 && send_all(fd, header, (size_t)size) == 0) while ((n = read(file, buf, sizeof buf)) > 0) if (send_all(fd, buf, (size_t)n)) break; close(file); } -static void telnet_client(int fd) { - const char *menu = "\r\n*** POCKETBOX M0 ***\r\n[F] Files (HTTP: /files/welcome.txt)\r\n[L] Library (planned)\r\n[Q] Quit\r\nChoice: "; - char choice = 0; - if (send_all(fd, menu, strlen(menu))) return; - /* Basic ASCII proof-of-concept; Telnet IAC negotiation arrives in M3. */ - for (unsigned i = 0; i < 16; ++i) { - ssize_t n = recv(fd, &choice, 1, 0); - if (n <= 0) return; - if (choice == 'q' || choice == 'Q') break; - if (choice == 'f' || choice == 'F') - send_all(fd, "\r\nM0: download /files/welcome.txt via HTTP\r\nChoice: ", 56); +/* Terminal sockets are nonblocking and processed by poll(), not recv loops. */ +static void terminal_input(struct terminal *c) { + char input[64]; + ssize_t n = recv(c->fd, input, sizeof input, 0); + if (n <= 0) { close(c->fd); c->fd = -1; return; } + for (ssize_t i = 0; i < n; ++i) { + char ch = input[i]; + if (ch == 'q' || ch == 'Q' || ++c->inputs > 256) { + const char *bye = "\r\nGoodbye.\r\n"; + send(c->fd, bye, strlen(bye), MSG_DONTWAIT | MSG_NOSIGNAL); + close(c->fd); c->fd = -1; return; + } + if (ch == 'f' || ch == 'F') { + const char *msg = "\r\nDownload /files/welcome.txt via HTTP\r\nChoice: "; + send(c->fd, msg, strlen(msg), MSG_DONTWAIT | MSG_NOSIGNAL); + } } - send_all(fd, "\r\nGoodbye.\r\n", 12); } int main(int argc, char **argv) { const char *dir = "."; unsigned http = 8080, telnet = 2323; for (int i = 1; i < argc; ++i) { - if (i + 1 >= argc) { fprintf(stderr, "Missing value for %s\n", argv[i]); return 2; } + if (i + 1 >= argc) { fprintf(stderr, "Missing value for %s +", argv[i]); return 2; } if (!strcmp(argv[i], "--root")) dir = argv[++i]; else if (!strcmp(argv[i], "--http-port")) http = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--telnet-port")) telnet = (unsigned)atoi(argv[++i]); - else { fprintf(stderr, "Unknown option: %s\n", argv[i]); return 2; } + else { fprintf(stderr, "Unknown option: %s +", argv[i]); return 2; } } if (!http || http > 65535 || !telnet || telnet > 65535 || http == telnet) return 2; int root = open(dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); if (root < 0) { perror("root"); return 1; } int h = listener(http), t = listener(telnet); if (h < 0 || t < 0) { perror("listen"); if (h >= 0) close(h); if (t >= 0) close(t); close(root); return 1; } - signal(SIGINT, on_signal); signal(SIGTERM, on_signal);\n signal(SIGPIPE, SIG_IGN); - fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u\n", http, telnet); - /* M1 step: poll() listeners, but per-client I/O remains synchronous and - bounded by socket timeouts. Full nonblocking client states come next. */ + signal(SIGINT, on_signal); signal(SIGTERM, on_signal); + signal(SIGPIPE, SIG_IGN); + fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u +", http, telnet); + /* HTTP request handling remains synchronous with a two-second timeout. + Terminal clients are nonblocking and bounded to MAX_TERMINALS. */ + struct terminal clients[MAX_TERMINALS]; + for (unsigned i = 0; i < MAX_TERMINALS; ++i) clients[i].fd = -1; while (running) { - struct pollfd fds[2] = {{h, POLLIN, 0}, {t, POLLIN, 0}}; - int ready = poll(fds, 2, 1000); + struct pollfd fds[2 + MAX_TERMINALS]; + nfds_t count = 2; + fds[0] = (struct pollfd){h, POLLIN, 0}; + fds[1] = (struct pollfd){t, POLLIN, 0}; + for (unsigned i = 0; i < MAX_TERMINALS; ++i) + if (clients[i].fd >= 0) fds[count++] = (struct pollfd){clients[i].fd, POLLIN, 0}; + int ready = poll(fds, count, 1000); if (ready < 0) { if (errno == EINTR) continue; perror("poll"); break; } if (!ready) continue; - for (int i = 0; i < 2; ++i) { - if (!(fds[i].revents & POLLIN)) continue; - int fd = accept(fds[i].fd, NULL, NULL); - if (fd < 0) continue; - struct timeval tv = {2, 0}; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); - if (i) telnet_client(fd); else http_client(fd, root); - close(fd); + /* Process existing terminals before accepting more work. */ + for (nfds_t i = 2; i < count; ++i) { + if (!fds[i].revents) continue; + for (unsigned j = 0; j < MAX_TERMINALS; ++j) + if (clients[j].fd == fds[i].fd) { + if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) { + close(clients[j].fd); clients[j].fd = -1; + } else if (fds[i].revents & POLLIN) terminal_input(&clients[j]); + break; + } + } + if (fds[0].revents & POLLIN) { + int fd = accept(h, NULL, NULL); + if (fd >= 0) { + struct timeval tv = {2, 0}; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); + http_client(fd, root); + close(fd); + } + } + if (fds[1].revents & POLLIN) { + int fd = accept(t, NULL, NULL); + if (fd >= 0) { + unsigned slot = MAX_TERMINALS; + for (unsigned i = 0; i < MAX_TERMINALS; ++i) + if (clients[i].fd < 0) { slot = i; break; } + if (slot == MAX_TERMINALS || fcntl(fd, F_SETFL, O_NONBLOCK) < 0) close(fd); + else { + clients[slot] = (struct terminal){fd, 0}; + send(fd, terminal_menu, strlen(terminal_menu), MSG_DONTWAIT | MSG_NOSIGNAL); + } + } } } + for (unsigned i = 0; i < MAX_TERMINALS; ++i) + if (clients[i].fd >= 0) close(clients[i].fd); close(h); close(t); close(root); return 0; } From c4346bf1e47355732b2f453800c201d4cdba1acc Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:00:36 +0200 Subject: [PATCH 28/79] fix: escape literal line endings inside C string literals --- src/main.c | 51 +++++++++++---------------------------------------- 1 file changed, 11 insertions(+), 40 deletions(-) diff --git a/src/main.c b/src/main.c index 197f9bd..ea83443 100644 --- a/src/main.c +++ b/src/main.c @@ -40,64 +40,38 @@ static void http_client(int fd, int root) { ssize_t n = recv(fd, req, sizeof req - 1, 0); if (n <= 0) return; req[n] = 0; - char *end = strstr(req, "\r -"); - if (!end) { send_all(fd, "HTTP/1.0 400 Bad Request\r -Content-Length: 0\r -\r -", 47); return; } + char *end = strstr(req, "\r\n"); + if (!end) { send_all(fd, "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n", 47); return; } *end = 0; if (sscanf(req, "GET %2047s", path) != 1 || strchr(path, '%') || strchr(path, '?')) { - const char *msg = "HTTP/1.0 400 Bad Request\r -Content-Length: 0\r -\r -"; + const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; send_all(fd, msg, strlen(msg)); return; } if (!strcmp(path, "/")) { const char *page = "PocketBox

PocketBox M0

Offline library prototype

Welcome file"; - int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r -Content-Type: text/html\r -Content-Length: %zu\r -Connection: close\r -\r -", strlen(page)); + int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n", strlen(page)); if (size > 0) { send_all(fd, header, (size_t)size); send_all(fd, page, strlen(page)); } return; } /* M0: one basename only; openat + O_NOFOLLOW prevents traversal and symlink escapes. */ const char *prefix = "/files/"; if (strncmp(path, prefix, strlen(prefix))) { - const char *msg = "HTTP/1.0 404 Not Found\r -Content-Length: 0\r -\r -"; + const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; send_all(fd, msg, strlen(msg)); return; } const char *name = path + strlen(prefix); if (!*name || !strcmp(name, ".") || !strcmp(name, "..") || strchr(name, '/')) { - const char *msg = "HTTP/1.0 400 Bad Request\r -Content-Length: 0\r -\r -"; + const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; send_all(fd, msg, strlen(msg)); return; } int file = openat(root, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); struct stat st; if (file < 0 || fstat(file, &st) || !S_ISREG(st.st_mode)) { if (file >= 0) close(file); - const char *msg = "HTTP/1.0 404 Not Found\r -Content-Length: 0\r -\r -"; + const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; send_all(fd, msg, strlen(msg)); return; } - int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r -Content-Type: application/octet-stream\r -Content-Length: %lld\r -Connection: close\r -\r -", (long long)st.st_size); + int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %lld\r\nConnection: close\r\n\r\n", (long long)st.st_size); if (size > 0 && send_all(fd, header, (size_t)size) == 0) while ((n = read(file, buf, sizeof buf)) > 0) if (send_all(fd, buf, (size_t)n)) break; @@ -124,13 +98,11 @@ static void terminal_input(struct terminal *c) { int main(int argc, char **argv) { const char *dir = "."; unsigned http = 8080, telnet = 2323; for (int i = 1; i < argc; ++i) { - if (i + 1 >= argc) { fprintf(stderr, "Missing value for %s -", argv[i]); return 2; } + if (i + 1 >= argc) { fprintf(stderr, "Missing value for %s\n", argv[i]); return 2; } if (!strcmp(argv[i], "--root")) dir = argv[++i]; else if (!strcmp(argv[i], "--http-port")) http = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--telnet-port")) telnet = (unsigned)atoi(argv[++i]); - else { fprintf(stderr, "Unknown option: %s -", argv[i]); return 2; } + else { fprintf(stderr, "Unknown option: %s\n", argv[i]); return 2; } } if (!http || http > 65535 || !telnet || telnet > 65535 || http == telnet) return 2; int root = open(dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); @@ -139,8 +111,7 @@ int main(int argc, char **argv) { if (h < 0 || t < 0) { perror("listen"); if (h >= 0) close(h); if (t >= 0) close(t); close(root); return 1; } signal(SIGINT, on_signal); signal(SIGTERM, on_signal); signal(SIGPIPE, SIG_IGN); - fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u -", http, telnet); + fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u\n", http, telnet); /* HTTP request handling remains synchronous with a two-second timeout. Terminal clients are nonblocking and bounded to MAX_TERMINALS. */ struct terminal clients[MAX_TERMINALS]; From 088d006516ff483fb68b473d34c0c245b401fb9a Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:00:41 +0200 Subject: [PATCH 29/79] test: verify idle terminal does not block HTTP requests --- tests/smoke.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/smoke.py b/tests/smoke.py index 6b4545b..95c7c19 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -37,7 +37,12 @@ def available_port(): raise AssertionError("unexpected success") except HTTPError as err: assert err.code in (400, 404) - print("PASS: HTTP, Telnet, file download, invalid paths") + with socket.create_connection(("127.0.0.1", tp), timeout=1) as idle: + assert b"POCKETBOX" in idle.recv(2048) + started = time.monotonic() + assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() + assert time.monotonic() - started < 1.0 + print("PASS: HTTP, Telnet, file download, invalid paths, idle terminal concurrency") finally: p.terminate() p.wait(timeout=5) From 375e8281fd48953d00b7ce9ded0edf85c2bb2161 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:22:29 +0200 Subject: [PATCH 30/79] M1: add bounded nonblocking HTTP state machine --- src/http_async.h | 90 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/http_async.h diff --git a/src/http_async.h b/src/http_async.h new file mode 100644 index 0000000..d6cc9c9 --- /dev/null +++ b/src/http_async.h @@ -0,0 +1,90 @@ +/* Bounded nonblocking HTTP/1.0 GET handler. Included by main.c. */ +#define MAX_HTTP 8 +#define HTTP_REQ 2048 +#define HTTP_OUT 2048 +#define HTTP_IDLE_SECONDS 5 +struct http_conn { + int fd, file; + size_t used, out_len, out_pos; + char request[HTTP_REQ], output[HTTP_OUT]; + time_t last; + int state; /* 0=request, 1=write, 2=file, 3=close */ +}; +static void http_close(struct http_conn *c) { + if (c->fd >= 0) close(c->fd); + if (c->file >= 0) close(c->file); + c->fd = c->file = -1; +} +static void http_response(struct http_conn *c, int status, const char *reason, + const char *type, long long length) { + int n = snprintf(c->output, sizeof c->output, + "HTTP/1.0 %d %s\r\nContent-Type: %s\r\nContent-Length: %lld\r\nConnection: close\r\n\r\n", + status, reason, type, length); + if (n < 0 || (size_t)n >= sizeof c->output) { c->state = 3; return; } + c->out_len = (size_t)n; c->out_pos = 0; c->state = 1; +} +static void http_error(struct http_conn *c, int status, const char *reason) { + http_response(c, status, reason, "text/plain", 0); +} +static void http_prepare(struct http_conn *c, int root) { + const char *line_end = strstr(c->request, "\r\n"); + if (!line_end) { http_error(c, 400, "Bad Request"); return; } + size_t len = (size_t)(line_end - c->request); + if (len >= HTTP_REQ) { http_error(c, 400, "Bad Request"); return; } + char line[HTTP_REQ], path[HTTP_REQ], version[16], method[8], extra; + memcpy(line, c->request, len); line[len] = 0; + if (sscanf(line, "%7s %2047s %15s %c", method, path, version, &extra) != 3 || + strcmp(method, "GET") || + (strcmp(version, "HTTP/1.0") && strcmp(version, "HTTP/1.1")) || + path[0] != '/' || strchr(path, '%') || strchr(path, '?')) { + http_error(c, 400, "Bad Request"); return; + } + if (!strcmp(path, "/")) { + const char *page = "PocketBox

PocketBox M1

Offline library prototype

Welcome file"; + size_t page_len = strlen(page); + http_response(c, 200, "OK", "text/html", (long long)page_len); + if (c->state == 3 || c->out_len + page_len > sizeof c->output) { c->state = 3; return; } + memcpy(c->output + c->out_len, page, page_len); + c->out_len += page_len; + return; + } + const char *prefix = "/files/"; + if (strncmp(path, prefix, strlen(prefix))) { http_error(c, 404, "Not Found"); return; } + const char *name = path + strlen(prefix); + if (!*name || !strcmp(name, ".") || !strcmp(name, "..") || strchr(name, '/')) { + http_error(c, 400, "Bad Request"); return; + } + int fd = openat(root, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + struct stat st; + if (fd < 0) { http_error(c, 404, "Not Found"); return; } + if (fstat(fd, &st) || !S_ISREG(st.st_mode) || st.st_size < 0) { + close(fd); http_error(c, 404, "Not Found"); return; + } + c->file = fd; + http_response(c, 200, "OK", "application/octet-stream", (long long)st.st_size); +} +static void http_step(struct http_conn *c, short events, int root) { + if (events & (POLLERR | POLLNVAL)) { c->state = 3; return; } + if (c->state == 0 && (events & (POLLIN | POLLHUP))) { + ssize_t n = recv(c->fd, c->request + c->used, sizeof c->request - 1 - c->used, 0); + if (n <= 0) { c->state = 3; return; } + c->last = time(NULL); c->used += (size_t)n; c->request[c->used] = 0; + if (strstr(c->request, "\r\n")) http_prepare(c, root); + else if (c->used == sizeof c->request - 1) http_error(c, 400, "Bad Request"); + } + if ((c->state == 1 || c->state == 2) && (events & POLLOUT)) { + if (c->out_pos == c->out_len && c->state == 2) { + ssize_t n = read(c->file, c->output, sizeof c->output); + if (n <= 0) { c->state = 3; return; } + c->out_len = (size_t)n; c->out_pos = 0; + } + if (c->out_pos < c->out_len) { + ssize_t n = send(c->fd, c->output + c->out_pos, c->out_len - c->out_pos, + MSG_DONTWAIT | MSG_NOSIGNAL); + if (n > 0) { c->out_pos += (size_t)n; c->last = time(NULL); } + else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) c->state = 3; + } + if (c->out_pos == c->out_len && c->state == 1) c->state = c->file >= 0 ? 2 : 3; + } + if (events & POLLHUP && c->state == 0) c->state = 3; +} From 582138c01cb46cd725c9886b9fdd8b207341da5d Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:22:34 +0200 Subject: [PATCH 31/79] fix: preserve escaped CRLF in HTTP C strings From cf3d6b6dded062830f13cc37f5499ebf9f1bafc1 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:22:43 +0200 Subject: [PATCH 32/79] M1: integrate poll-based nonblocking HTTP connections --- src/main.c | 84 +++++++++++++++++++----------------------------------- 1 file changed, 30 insertions(+), 54 deletions(-) diff --git a/src/main.c b/src/main.c index ea83443..c87b928 100644 --- a/src/main.c +++ b/src/main.c @@ -20,10 +20,6 @@ struct terminal { int fd; unsigned inputs; }; static const char *terminal_menu = "\r\n*** POCKETBOX M1 ***\r\n[F] Files (HTTP: /files/welcome.txt)\r\n[Q] Quit\r\nChoice: "; static volatile sig_atomic_t running = 1; static void on_signal(int sig) { (void)sig; running = 0; } -static int send_all(int fd, const char *s, size_t len) { - while (len) { ssize_t n = send(fd, s, len, 0); if (n <= 0) return -1; s += n; len -= (size_t)n; } - return 0; -} static int listener(unsigned port) { int fd = socket(AF_INET, SOCK_STREAM, 0), opt = 1; struct sockaddr_in a; @@ -35,48 +31,7 @@ static int listener(unsigned port) { if (bind(fd, (struct sockaddr *)&a, sizeof a) || listen(fd, 8)) { close(fd); return -1; } return fd; } -static void http_client(int fd, int root) { - char req[BUFSZ], path[BUFSZ], buf[BUFSZ], header[256]; - ssize_t n = recv(fd, req, sizeof req - 1, 0); - if (n <= 0) return; - req[n] = 0; - char *end = strstr(req, "\r\n"); - if (!end) { send_all(fd, "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n", 47); return; } - *end = 0; - if (sscanf(req, "GET %2047s", path) != 1 || strchr(path, '%') || strchr(path, '?')) { - const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; - send_all(fd, msg, strlen(msg)); return; - } - if (!strcmp(path, "/")) { - const char *page = "PocketBox

PocketBox M0

Offline library prototype

Welcome file"; - int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: text/html\r\nContent-Length: %zu\r\nConnection: close\r\n\r\n", strlen(page)); - if (size > 0) { send_all(fd, header, (size_t)size); send_all(fd, page, strlen(page)); } - return; - } - /* M0: one basename only; openat + O_NOFOLLOW prevents traversal and symlink escapes. */ - const char *prefix = "/files/"; - if (strncmp(path, prefix, strlen(prefix))) { - const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; - send_all(fd, msg, strlen(msg)); return; - } - const char *name = path + strlen(prefix); - if (!*name || !strcmp(name, ".") || !strcmp(name, "..") || strchr(name, '/')) { - const char *msg = "HTTP/1.0 400 Bad Request\r\nContent-Length: 0\r\n\r\n"; - send_all(fd, msg, strlen(msg)); return; - } - int file = openat(root, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); - struct stat st; - if (file < 0 || fstat(file, &st) || !S_ISREG(st.st_mode)) { - if (file >= 0) close(file); - const char *msg = "HTTP/1.0 404 Not Found\r\nContent-Length: 0\r\n\r\n"; - send_all(fd, msg, strlen(msg)); return; - } - int size = snprintf(header, sizeof header, "HTTP/1.0 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: %lld\r\nConnection: close\r\n\r\n", (long long)st.st_size); - if (size > 0 && send_all(fd, header, (size_t)size) == 0) - while ((n = read(file, buf, sizeof buf)) > 0) - if (send_all(fd, buf, (size_t)n)) break; - close(file); -} +#include "http_async.h" /* Terminal sockets are nonblocking and processed by poll(), not recv loops. */ static void terminal_input(struct terminal *c) { char input[64]; @@ -112,17 +67,21 @@ int main(int argc, char **argv) { signal(SIGINT, on_signal); signal(SIGTERM, on_signal); signal(SIGPIPE, SIG_IGN); fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u\n", http, telnet); - /* HTTP request handling remains synchronous with a two-second timeout. - Terminal clients are nonblocking and bounded to MAX_TERMINALS. */ + /* Both HTTP and terminal sockets are bounded and polled. */ + struct http_conn web[MAX_HTTP]; + for (unsigned i = 0; i < MAX_HTTP; ++i) { web[i].fd = -1; web[i].file = -1; } struct terminal clients[MAX_TERMINALS]; for (unsigned i = 0; i < MAX_TERMINALS; ++i) clients[i].fd = -1; while (running) { - struct pollfd fds[2 + MAX_TERMINALS]; + struct pollfd fds[2 + MAX_TERMINALS + MAX_HTTP]; nfds_t count = 2; fds[0] = (struct pollfd){h, POLLIN, 0}; fds[1] = (struct pollfd){t, POLLIN, 0}; for (unsigned i = 0; i < MAX_TERMINALS; ++i) if (clients[i].fd >= 0) fds[count++] = (struct pollfd){clients[i].fd, POLLIN, 0}; + for (unsigned i = 0; i < MAX_HTTP; ++i) + if (web[i].fd >= 0) fds[count++] = (struct pollfd){web[i].fd, + web[i].state == 0 ? POLLIN : POLLOUT, 0}; int ready = poll(fds, count, 1000); if (ready < 0) { if (errno == EINTR) continue; perror("poll"); break; } if (!ready) continue; @@ -137,14 +96,30 @@ int main(int argc, char **argv) { break; } } + for (nfds_t i = 2; i < count; ++i) { + if (!fds[i].revents) continue; + for (unsigned j = 0; j < MAX_HTTP; ++j) { + if (web[j].fd != fds[i].fd) continue; + http_step(&web[j], fds[i].revents, root); + if (web[j].state == 3) http_close(&web[j]); + break; + } + } + time_t now = time(NULL); + for (unsigned i = 0; i < MAX_HTTP; ++i) + if (web[i].fd >= 0 && now - web[i].last > HTTP_IDLE_SECONDS) + http_close(&web[i]); if (fds[0].revents & POLLIN) { int fd = accept(h, NULL, NULL); if (fd >= 0) { - struct timeval tv = {2, 0}; - setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); - setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); - http_client(fd, root); - close(fd); + unsigned slot = MAX_HTTP; + for (unsigned i = 0; i < MAX_HTTP; ++i) + if (web[i].fd < 0) { slot = i; break; } + if (slot == MAX_HTTP || fcntl(fd, F_SETFL, O_NONBLOCK) < 0) close(fd); + else { + memset(&web[slot], 0, sizeof web[slot]); + web[slot].fd = fd; web[slot].file = -1; web[slot].last = time(NULL); + } } } if (fds[1].revents & POLLIN) { @@ -163,5 +138,6 @@ int main(int argc, char **argv) { } for (unsigned i = 0; i < MAX_TERMINALS; ++i) if (clients[i].fd >= 0) close(clients[i].fd); + for (unsigned i = 0; i < MAX_HTTP; ++i) if (web[i].fd >= 0) http_close(&web[i]); close(h); close(t); close(root); return 0; } From a248e7b311f2f314f67d576279ec6acbb41084dd Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:22:49 +0200 Subject: [PATCH 33/79] test: verify partial HTTP request cannot block other clients --- tests/smoke.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/smoke.py b/tests/smoke.py index 95c7c19..bc2413a 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -42,7 +42,13 @@ def available_port(): started = time.monotonic() assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() assert time.monotonic() - started < 1.0 - print("PASS: HTTP, Telnet, file download, invalid paths, idle terminal concurrency") + # A partial HTTP request must not starve another HTTP client. + with socket.create_connection(("127.0.0.1", hp), timeout=1) as slow: + slow.sendall(b"GET / HTTP/1.1") + started = time.monotonic() + assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() + assert time.monotonic() - started < 1.0 + print("PASS: HTTP, Telnet, file download, invalid paths, idle terminal and HTTP concurrency") finally: p.terminate() p.wait(timeout=5) From 3a43b17756398df57be8379dd0e9d139cf932f33 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:29:32 +0200 Subject: [PATCH 34/79] M1: bounded shared catalog of regular files --- src/catalog.h | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 src/catalog.h diff --git a/src/catalog.h b/src/catalog.h new file mode 100644 index 0000000..6f44495 --- /dev/null +++ b/src/catalog.h @@ -0,0 +1,51 @@ +/* Shared, bounded file catalog for HTTP and ASCII terminal. */ +#include +#define CATALOG_MAX 32 +#define CATALOG_NAME 128 +struct catalog { unsigned count; char names[CATALOG_MAX][CATALOG_NAME]; }; +static int catalog_valid(const char *name) { + size_t n = strlen(name); + if (!n || n >= CATALOG_NAME || name[0] == '.' || strchr(name, '/') || + strchr(name, '%') || strchr(name, '?') || strchr(name, '#')) return 0; + for (size_t i = 0; i < n; ++i) + if ((unsigned char)name[i] < 33 || (unsigned char)name[i] > 126 || + name[i] == '<' || name[i] == '>' || name[i] == '&' || + name[i] == '"' || name[i] == '\'') return 0; + return 1; +} +static void catalog_load(int root, struct catalog *cat) { + cat->count = 0; + int copy = openat(root, ".", O_RDONLY | O_DIRECTORY); + if (copy < 0) return; + DIR *dir = fdopendir(copy); + if (!dir) { close(copy); return; } + struct dirent *entry; + while (cat->count < CATALOG_MAX && (entry = readdir(dir))) { + if (!catalog_valid(entry->d_name)) continue; + int fd = openat(root, entry->d_name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + struct stat st; + if (fd < 0) continue; + int regular = !fstat(fd, &st) && S_ISREG(st.st_mode); + close(fd); + if (!regular) continue; + strcpy(cat->names[cat->count++], entry->d_name); + } + closedir(dir); +} +static size_t catalog_html(const struct catalog *cat, char *out, size_t cap) { + size_t used = 0; + const char *prefix = "PocketBox

Files

    "; + size_t n = strlen(prefix); + if (n >= cap) return 0; + memcpy(out, prefix, n); used = n; + for (unsigned i = 0; i < cat->count; ++i) { + int written = snprintf(out + used, cap - used, + "
  • %s
  • ", cat->names[i], cat->names[i]); + if (written < 0 || (size_t)written >= cap - used) break; + used += (size_t)written; + } + const char *suffix = "
"; + if (strlen(suffix) >= cap - used) return 0; + memcpy(out + used, suffix, strlen(suffix)); + return used + strlen(suffix); +} From f60939ced8634d0b2e855c27b72d83e4c9c224a5 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:29:36 +0200 Subject: [PATCH 35/79] fix: character literal in catalog validation From 4996da2e7b54661d1d5670571deb5fdba64fca93 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:29:43 +0200 Subject: [PATCH 36/79] M1: share file catalog with terminal menu --- src/main.c | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index c87b928..5013d23 100644 --- a/src/main.c +++ b/src/main.c @@ -31,9 +31,10 @@ static int listener(unsigned port) { if (bind(fd, (struct sockaddr *)&a, sizeof a) || listen(fd, 8)) { close(fd); return -1; } return fd; } +#include "catalog.h" #include "http_async.h" /* Terminal sockets are nonblocking and processed by poll(), not recv loops. */ -static void terminal_input(struct terminal *c) { +static void terminal_input(struct terminal *c, int root) { char input[64]; ssize_t n = recv(c->fd, input, sizeof input, 0); if (n <= 0) { close(c->fd); c->fd = -1; return; } @@ -47,6 +48,12 @@ static void terminal_input(struct terminal *c) { if (ch == 'f' || ch == 'F') { const char *msg = "\r\nDownload /files/welcome.txt via HTTP\r\nChoice: "; send(c->fd, msg, strlen(msg), MSG_DONTWAIT | MSG_NOSIGNAL); + struct catalog cat; + catalog_load(root, &cat); + for (unsigned k = 0; k < cat.count; ++k) { + send(c->fd, cat.names[k], strlen(cat.names[k]), MSG_DONTWAIT | MSG_NOSIGNAL); + send(c->fd, "\\r\\n", 2, MSG_DONTWAIT | MSG_NOSIGNAL); + } } } } @@ -92,7 +99,7 @@ int main(int argc, char **argv) { if (clients[j].fd == fds[i].fd) { if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) { close(clients[j].fd); clients[j].fd = -1; - } else if (fds[i].revents & POLLIN) terminal_input(&clients[j]); + } else if (fds[i].revents & POLLIN) terminal_input(&clients[j], root); break; } } From 47aa0e6ab07aad84d188c0c66371be6e35385b34 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:29:45 +0200 Subject: [PATCH 37/79] M1: render shared catalog in HTTP root page --- src/http_async.h | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/http_async.h b/src/http_async.h index d6cc9c9..7f2e1e0 100644 --- a/src/http_async.h +++ b/src/http_async.h @@ -40,8 +40,11 @@ static void http_prepare(struct http_conn *c, int root) { http_error(c, 400, "Bad Request"); return; } if (!strcmp(path, "/")) { - const char *page = "PocketBox

PocketBox M1

Offline library prototype

Welcome file"; - size_t page_len = strlen(page); + struct catalog cat; + catalog_load(root, &cat); + char page[HTTP_OUT]; + size_t page_len = catalog_html(&cat, page, sizeof page); + if (!page_len) { http_error(c, 500, "Internal Server Error"); return; } http_response(c, 200, "OK", "text/html", (long long)page_len); if (c->state == 3 || c->out_len + page_len > sizeof c->output) { c->state = 3; return; } memcpy(c->output + c->out_len, page, page_len); From 455cdd3396073943e22f6f07b7372c8c21df4887 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:31:45 +0200 Subject: [PATCH 38/79] test: verify shared HTTP and terminal catalog filters symlinks and unsafe names --- tests/smoke.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/smoke.py b/tests/smoke.py index bc2413a..c3502ec 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -14,6 +14,12 @@ def available_port(): with tempfile.TemporaryDirectory() as d: Path(d, "welcome.txt").write_text("offline-ok\n") + Path(d, "manual.pdf").write_bytes(b"PDF-test") + Path(d, ".private").write_text("hidden") + Path(d, "bad&name.txt").write_text("excluded") + Path(d, "subfolder").mkdir() + Path(d, "subfolder", "nested.txt").write_text("nested") + Path(d, "linked.txt").symlink_to(Path(d, "manual.pdf")) hp, tp = available_port(), available_port() while hp == tp: tp = available_port() @@ -31,6 +37,33 @@ def available_port(): raise AssertionError("server did not start") assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/").read() assert urlopen(f"http://127.0.0.1:{hp}/files/welcome.txt").read() == b"offline-ok\n" + listing = urlopen(f"http://127.0.0.1:{hp}/").read() + for expected in (b"welcome.txt", b"manual.pdf"): + assert expected in listing, (expected, listing) + for excluded in (b".private", b"bad&name.txt", b"nested.txt", b"linked.txt"): + assert excluded not in listing, (excluded, listing) + assert urlopen(f"http://127.0.0.1:{hp}/files/manual.pdf").read() == b"PDF-test" + with socket.create_connection(("127.0.0.1", tp), timeout=1) as terminal: + terminal.settimeout(1) + assert b"POCKETBOX" in terminal.recv(2048) + terminal.sendall(b"F") + chunks = [] + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + try: + part = terminal.recv(2048) + except socket.timeout: + break + if not part: + break + chunks.append(part) + if b"welcome.txt" in b"".join(chunks) and b"manual.pdf" in b"".join(chunks): + break + terminal_listing = b"".join(chunks) + for expected in (b"welcome.txt", b"manual.pdf"): + assert expected in terminal_listing, (expected, terminal_listing) + for excluded in (b".private", b"bad&name.txt", b"nested.txt", b"linked.txt"): + assert excluded not in terminal_listing, (excluded, terminal_listing) for bad in ["../etc/passwd", "%2e%2e", "missing"]: try: urlopen(f"http://127.0.0.1:{hp}/files/{bad}") From 2e9ba01fac85de3dbb737da732b5c7fec41b60c8 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:31:50 +0200 Subject: [PATCH 39/79] security: enforce shared catalog filename policy on HTTP downloads --- src/http_async.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http_async.h b/src/http_async.h index 7f2e1e0..9f61a21 100644 --- a/src/http_async.h +++ b/src/http_async.h @@ -54,7 +54,7 @@ static void http_prepare(struct http_conn *c, int root) { const char *prefix = "/files/"; if (strncmp(path, prefix, strlen(prefix))) { http_error(c, 404, "Not Found"); return; } const char *name = path + strlen(prefix); - if (!*name || !strcmp(name, ".") || !strcmp(name, "..") || strchr(name, '/')) { + if (!catalog_valid(name)) { http_error(c, 400, "Bad Request"); return; } int fd = openat(root, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); From fa7bee61e3c1d0c26ffdd1bfc108f5307a1d7511 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 03:31:53 +0200 Subject: [PATCH 40/79] test: verify hidden and symlink downloads are rejected --- tests/smoke.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/smoke.py b/tests/smoke.py index c3502ec..cfd4dc7 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -64,7 +64,7 @@ def available_port(): assert expected in terminal_listing, (expected, terminal_listing) for excluded in (b".private", b"bad&name.txt", b"nested.txt", b"linked.txt"): assert excluded not in terminal_listing, (excluded, terminal_listing) - for bad in ["../etc/passwd", "%2e%2e", "missing"]: + for bad in ["../etc/passwd", "%2e%2e", "missing", ".private", "linked.txt", "bad%26name.txt"]: try: urlopen(f"http://127.0.0.1:{hp}/files/{bad}") raise AssertionError("unexpected success") From 3a19453156a9de1f2f335b98678427def775f9d7 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 04:08:02 +0200 Subject: [PATCH 41/79] M1: add bounded sorted catalog pagination --- src/catalog.h | 52 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/src/catalog.h b/src/catalog.h index 6f44495..377fad6 100644 --- a/src/catalog.h +++ b/src/catalog.h @@ -2,7 +2,7 @@ #include #define CATALOG_MAX 32 #define CATALOG_NAME 128 -struct catalog { unsigned count; char names[CATALOG_MAX][CATALOG_NAME]; }; +struct catalog { unsigned count, has_more; char names[CATALOG_MAX][CATALOG_NAME]; }; static int catalog_valid(const char *name) { size_t n = strlen(name); if (!n || n >= CATALOG_NAME || name[0] == '.' || strchr(name, '/') || @@ -13,25 +13,35 @@ static int catalog_valid(const char *name) { name[i] == '"' || name[i] == '\'') return 0; return 1; } -static void catalog_load(int root, struct catalog *cat) { - cat->count = 0; - int copy = openat(root, ".", O_RDONLY | O_DIRECTORY); - if (copy < 0) return; - DIR *dir = fdopendir(copy); - if (!dir) { close(copy); return; } - struct dirent *entry; - while (cat->count < CATALOG_MAX && (entry = readdir(dir))) { - if (!catalog_valid(entry->d_name)) continue; - int fd = openat(root, entry->d_name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); - struct stat st; - if (fd < 0) continue; - int regular = !fstat(fd, &st) && S_ISREG(st.st_mode); - close(fd); - if (!regular) continue; - strcpy(cat->names[cat->count++], entry->d_name); +static void catalog_load_page(int root, struct catalog *cat, const char *after) { + cat->count = 0; cat->has_more = 0; + char cursor[CATALOG_NAME]; snprintf(cursor, sizeof cursor, "%s", after ? after : ""); + for (unsigned slot = 0; slot <= CATALOG_MAX; ++slot) { + int copy = openat(root, ".", O_RDONLY | O_DIRECTORY); + if (copy < 0) return; + DIR *dir = fdopendir(copy); + if (!dir) { close(copy); return; } + char best[CATALOG_NAME] = ""; + struct dirent *entry; + while ((entry = readdir(dir))) { + const char *name = entry->d_name; + if (!catalog_valid(name) || strcmp(name, cursor) <= 0 || + (best[0] && strcmp(name, best) >= 0)) continue; + int fd = openat(root, name, O_RDONLY | O_NOFOLLOW | O_NONBLOCK); + struct stat st; + if (fd < 0) continue; + int regular = !fstat(fd, &st) && S_ISREG(st.st_mode); + close(fd); + if (regular) strcpy(best, name); + } + closedir(dir); + if (!best[0]) return; + if (slot == CATALOG_MAX) { cat->has_more = 1; return; } + strcpy(cat->names[cat->count++], best); + strcpy(cursor, best); } - closedir(dir); } +static void catalog_load(int root, struct catalog *cat) { catalog_load_page(root, cat, ""); } static size_t catalog_html(const struct catalog *cat, char *out, size_t cap) { size_t used = 0; const char *prefix = "PocketBox

Files

    "; @@ -44,6 +54,12 @@ static size_t catalog_html(const struct catalog *cat, char *out, size_t cap) { if (written < 0 || (size_t)written >= cap - used) break; used += (size_t)written; } + if (cat->has_more && cat->count) { + int written = snprintf(out + used, cap - used, + "
  • Next page
  • ", cat->names[cat->count - 1]); + if (written < 0 || (size_t)written >= cap - used) return 0; + used += (size_t)written; + } const char *suffix = "
"; if (strlen(suffix) >= cap - used) return 0; memcpy(out + used, suffix, strlen(suffix)); From 325697a4b25e2ae03704e42e61dcf48bfedba9e9 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 04:08:09 +0200 Subject: [PATCH 42/79] M1: serve cursor-paginated HTML file index --- src/http_async.h | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/http_async.h b/src/http_async.h index 9f61a21..609f3b7 100644 --- a/src/http_async.h +++ b/src/http_async.h @@ -36,12 +36,14 @@ static void http_prepare(struct http_conn *c, int root) { if (sscanf(line, "%7s %2047s %15s %c", method, path, version, &extra) != 3 || strcmp(method, "GET") || (strcmp(version, "HTTP/1.0") && strcmp(version, "HTTP/1.1")) || - path[0] != '/' || strchr(path, '%') || strchr(path, '?')) { + path[0] != '/' || strchr(path, '%')) { http_error(c, 400, "Bad Request"); return; } - if (!strcmp(path, "/")) { + if (!strcmp(path, "/") || !strncmp(path, "/?after=", 8)) { struct catalog cat; - catalog_load(root, &cat); + const char *after = !strncmp(path, "/?after=", 8) ? path + 8 : ""; + if (*after && !catalog_valid(after)) { http_error(c, 400, "Bad Request"); return; } + catalog_load_page(root, &cat, after); char page[HTTP_OUT]; size_t page_len = catalog_html(&cat, page, sizeof page); if (!page_len) { http_error(c, 500, "Internal Server Error"); return; } From f6d2cec6283b8b6a296d42227646e59fd694f272 Mon Sep 17 00:00:00 2001 From: pos Date: Fri, 9 Oct 2026 04:08:16 +0200 Subject: [PATCH 43/79] test: exercise catalog pagination across 40 files --- tests/smoke.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/smoke.py b/tests/smoke.py index cfd4dc7..d893a7b 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -20,6 +20,8 @@ def available_port(): Path(d, "subfolder").mkdir() Path(d, "subfolder", "nested.txt").write_text("nested") Path(d, "linked.txt").symlink_to(Path(d, "manual.pdf")) + for i in range(40): + Path(d, f"retro-{i:03}.bin").write_bytes(b"retro") hp, tp = available_port(), available_port() while hp == tp: tp = available_port() @@ -64,6 +66,20 @@ def available_port(): assert expected in terminal_listing, (expected, terminal_listing) for excluded in (b".private", b"bad&name.txt", b"nested.txt", b"linked.txt"): assert excluded not in terminal_listing, (excluded, terminal_listing) + assert b"Next page" in listing + pages = [listing] + cursor = "retro-029.bin" + # Verify cursor paging across the bounded 32-entry view. + while b"Next page" in pages[-1]: + import re + match = re.search(rb"/\?after=([^']+)", pages[-1]) + assert match, pages[-1] + cursor = match.group(1).decode("ascii") + pages.append(urlopen(f"http://127.0.0.1:{hp}/?after={cursor}").read()) + assert len(pages) <= 4 + all_pages = b"".join(pages) + for i in range(40): + assert f"retro-{i:03}.bin".encode() in all_pages for bad in ["../etc/passwd", "%2e%2e", "missing", ".private", "linked.txt", "bad%26name.txt"]: try: urlopen(f"http://127.0.0.1:{hp}/files/{bad}") From 1848b56acf5eb49cb4ae5980b17a73a90a0679e3 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:03:31 +0200 Subject: [PATCH 44/79] test: create paginated fixture after baseline catalog assertions --- tests/smoke.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/smoke.py b/tests/smoke.py index d893a7b..7f0f1c6 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -20,8 +20,6 @@ def available_port(): Path(d, "subfolder").mkdir() Path(d, "subfolder", "nested.txt").write_text("nested") Path(d, "linked.txt").symlink_to(Path(d, "manual.pdf")) - for i in range(40): - Path(d, f"retro-{i:03}.bin").write_bytes(b"retro") hp, tp = available_port(), available_port() while hp == tp: tp = available_port() @@ -66,6 +64,9 @@ def available_port(): assert expected in terminal_listing, (expected, terminal_listing) for excluded in (b".private", b"bad&name.txt", b"nested.txt", b"linked.txt"): assert excluded not in terminal_listing, (excluded, terminal_listing) + for i in range(40): + Path(d, f"retro-{i:03}.bin").write_bytes(b"retro") + listing = urlopen(f"http://127.0.0.1:{hp}/").read() assert b"Next page" in listing pages = [listing] cursor = "retro-029.bin" From ce5ace99dfd8e2ee6ae0511ebe784e3214fb02e7 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:53:00 +0200 Subject: [PATCH 45/79] M1: terminal cursor pagination and correct CRLF output --- src/main.c | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/main.c b/src/main.c index 5013d23..45499ce 100644 --- a/src/main.c +++ b/src/main.c @@ -16,8 +16,8 @@ #define BUFSZ 2048 #define MAX_TERMINALS 12 -struct terminal { int fd; unsigned inputs; }; -static const char *terminal_menu = "\r\n*** POCKETBOX M1 ***\r\n[F] Files (HTTP: /files/welcome.txt)\r\n[Q] Quit\r\nChoice: "; +struct terminal { int fd; unsigned inputs; char cursor[128]; }; +static const char *terminal_menu = "\r\n*** POCKETBOX M1 ***\r\n[F] Files [N] Next page [Q] Quit\r\nChoice: "; static volatile sig_atomic_t running = 1; static void on_signal(int sig) { (void)sig; running = 0; } static int listener(unsigned port) { @@ -45,15 +45,22 @@ static void terminal_input(struct terminal *c, int root) { send(c->fd, bye, strlen(bye), MSG_DONTWAIT | MSG_NOSIGNAL); close(c->fd); c->fd = -1; return; } - if (ch == 'f' || ch == 'F') { - const char *msg = "\r\nDownload /files/welcome.txt via HTTP\r\nChoice: "; - send(c->fd, msg, strlen(msg), MSG_DONTWAIT | MSG_NOSIGNAL); + if (ch == 'f' || ch == 'F' || ch == 'n' || ch == 'N') { + if (ch == 'f' || ch == 'F') c->cursor[0] = 0; struct catalog cat; - catalog_load(root, &cat); + catalog_load_page(root, &cat, c->cursor); + const char *header = "\r\nPocketBox files:\r\n"; + send(c->fd, header, strlen(header), MSG_DONTWAIT | MSG_NOSIGNAL); for (unsigned k = 0; k < cat.count; ++k) { send(c->fd, cat.names[k], strlen(cat.names[k]), MSG_DONTWAIT | MSG_NOSIGNAL); - send(c->fd, "\\r\\n", 2, MSG_DONTWAIT | MSG_NOSIGNAL); + send(c->fd, "\r\n", 2, MSG_DONTWAIT | MSG_NOSIGNAL); } + if (cat.count) snprintf(c->cursor, sizeof c->cursor, "%s", + cat.names[cat.count - 1]); + const char *footer = cat.has_more + ? "[N] Next page [F] First page [Q] Quit\r\nChoice: " + : "[F] First page [Q] Quit\r\nChoice: "; + send(c->fd, footer, strlen(footer), MSG_DONTWAIT | MSG_NOSIGNAL); } } } From 7a6dcf491901477b93ebd2715fe6ab9d0af412b0 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:53:06 +0200 Subject: [PATCH 46/79] test: terminal pagination covers all 40 files with proper CRLF --- tests/smoke.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/smoke.py b/tests/smoke.py index 7f0f1c6..45b8df5 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -79,6 +79,33 @@ def available_port(): pages.append(urlopen(f"http://127.0.0.1:{hp}/?after={cursor}").read()) assert len(pages) <= 4 all_pages = b"".join(pages) + with socket.create_connection(("127.0.0.1", tp), timeout=2) as terminal: + terminal.settimeout(0.5) + assert b"POCKETBOX" in terminal.recv(2048) + terminal.sendall(b"F") + terminal_pages = b"" + for page_number in range(3): + chunks = [] + until = time.monotonic() + 1 + while time.monotonic() < until: + try: + data = terminal.recv(4096) + except socket.timeout: + break + if not data: + break + chunks.append(data) + if b"Choice: " in b"".join(chunks): + break + page = b"".join(chunks) + terminal_pages += page + if b"[N] Next page" not in page: + break + terminal.sendall(b"N") + for i in range(40): + assert f"retro-{i:03}.bin".encode() in terminal_pages + assert b"\\r\\n" not in terminal_pages + for i in range(40): assert f"retro-{i:03}.bin".encode() in all_pages for bad in ["../etc/passwd", "%2e%2e", "missing", ".private", "linked.txt", "bad%26name.txt"]: From a46e87fdb8ff8b66b9c3463862a9cbcf68f2dea5 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:54:08 +0200 Subject: [PATCH 47/79] fix: initialize terminal pagination cursor --- src/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index 45499ce..3444ddd 100644 --- a/src/main.c +++ b/src/main.c @@ -144,7 +144,7 @@ int main(int argc, char **argv) { if (clients[i].fd < 0) { slot = i; break; } if (slot == MAX_TERMINALS || fcntl(fd, F_SETFL, O_NONBLOCK) < 0) close(fd); else { - clients[slot] = (struct terminal){fd, 0}; + clients[slot] = (struct terminal){.fd = fd, .inputs = 0, .cursor = {0}}; send(fd, terminal_menu, strlen(terminal_menu), MSG_DONTWAIT | MSG_NOSIGNAL); } } From 17f59b77705885fb4655a88a56ba908e388f3c00 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:54:11 +0200 Subject: [PATCH 48/79] fix: remove obsolete catalog wrapper under Werror --- src/catalog.h | 1 - 1 file changed, 1 deletion(-) diff --git a/src/catalog.h b/src/catalog.h index 377fad6..11d84db 100644 --- a/src/catalog.h +++ b/src/catalog.h @@ -41,7 +41,6 @@ static void catalog_load_page(int root, struct catalog *cat, const char *after) strcpy(cursor, best); } } -static void catalog_load(int root, struct catalog *cat) { catalog_load_page(root, cat, ""); } static size_t catalog_html(const struct catalog *cat, char *out, size_t cap) { size_t used = 0; const char *prefix = "PocketBox

Files

    "; From 9890c759f730b1241e1cce1093604003d198c29d Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:55:50 +0200 Subject: [PATCH 49/79] M1: queue terminal output and flush on POLLOUT --- src/main.c | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/main.c b/src/main.c index 3444ddd..a684324 100644 --- a/src/main.c +++ b/src/main.c @@ -16,7 +16,7 @@ #define BUFSZ 2048 #define MAX_TERMINALS 12 -struct terminal { int fd; unsigned inputs; char cursor[128]; }; +struct terminal { int fd; unsigned inputs; char cursor[128]; char output[8192]; size_t out_len, out_pos; }; static const char *terminal_menu = "\r\n*** POCKETBOX M1 ***\r\n[F] Files [N] Next page [Q] Quit\r\nChoice: "; static volatile sig_atomic_t running = 1; static void on_signal(int sig) { (void)sig; running = 0; } @@ -33,6 +33,25 @@ static int listener(unsigned port) { } #include "catalog.h" #include "http_async.h" +static void terminal_flush(struct terminal *c) { + if (c->out_pos == c->out_len) { c->out_pos = c->out_len = 0; return; } + ssize_t n = send(c->fd, c->output + c->out_pos, c->out_len - c->out_pos, + MSG_DONTWAIT | MSG_NOSIGNAL); + if (n > 0) c->out_pos += (size_t)n; + else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { + close(c->fd); c->fd = -1; return; + } + if (c->out_pos == c->out_len) c->out_pos = c->out_len = 0; +} +static void terminal_text(struct terminal *c, const char *s) { + size_t n = strlen(s); + if (c->out_pos) { + memmove(c->output, c->output + c->out_pos, c->out_len - c->out_pos); + c->out_len -= c->out_pos; c->out_pos = 0; + } + if (n > sizeof c->output - c->out_len) return; + memcpy(c->output + c->out_len, s, n); c->out_len += n; +} /* Terminal sockets are nonblocking and processed by poll(), not recv loops. */ static void terminal_input(struct terminal *c, int root) { char input[64]; @@ -50,17 +69,17 @@ static void terminal_input(struct terminal *c, int root) { struct catalog cat; catalog_load_page(root, &cat, c->cursor); const char *header = "\r\nPocketBox files:\r\n"; - send(c->fd, header, strlen(header), MSG_DONTWAIT | MSG_NOSIGNAL); + terminal_text(c, header); for (unsigned k = 0; k < cat.count; ++k) { - send(c->fd, cat.names[k], strlen(cat.names[k]), MSG_DONTWAIT | MSG_NOSIGNAL); - send(c->fd, "\r\n", 2, MSG_DONTWAIT | MSG_NOSIGNAL); + terminal_text(c, cat.names[k]); + terminal_text(c, "\r\n"); } if (cat.count) snprintf(c->cursor, sizeof c->cursor, "%s", cat.names[cat.count - 1]); const char *footer = cat.has_more ? "[N] Next page [F] First page [Q] Quit\r\nChoice: " : "[F] First page [Q] Quit\r\nChoice: "; - send(c->fd, footer, strlen(footer), MSG_DONTWAIT | MSG_NOSIGNAL); + terminal_text(c, footer); } } } @@ -92,7 +111,7 @@ int main(int argc, char **argv) { fds[0] = (struct pollfd){h, POLLIN, 0}; fds[1] = (struct pollfd){t, POLLIN, 0}; for (unsigned i = 0; i < MAX_TERMINALS; ++i) - if (clients[i].fd >= 0) fds[count++] = (struct pollfd){clients[i].fd, POLLIN, 0}; + if (clients[i].fd >= 0) fds[count++] = (struct pollfd){clients[i].fd, (short)(POLLIN | (clients[i].out_len > clients[i].out_pos ? POLLOUT : 0)), 0}; for (unsigned i = 0; i < MAX_HTTP; ++i) if (web[i].fd >= 0) fds[count++] = (struct pollfd){web[i].fd, web[i].state == 0 ? POLLIN : POLLOUT, 0}; @@ -106,7 +125,7 @@ int main(int argc, char **argv) { if (clients[j].fd == fds[i].fd) { if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) { close(clients[j].fd); clients[j].fd = -1; - } else if (fds[i].revents & POLLIN) terminal_input(&clients[j], root); + } else { if (fds[i].revents & POLLIN) terminal_input(&clients[j], root); if (clients[j].fd >= 0 && (fds[i].revents & POLLOUT)) terminal_flush(&clients[j]); } break; } } @@ -144,8 +163,8 @@ int main(int argc, char **argv) { if (clients[i].fd < 0) { slot = i; break; } if (slot == MAX_TERMINALS || fcntl(fd, F_SETFL, O_NONBLOCK) < 0) close(fd); else { - clients[slot] = (struct terminal){.fd = fd, .inputs = 0, .cursor = {0}}; - send(fd, terminal_menu, strlen(terminal_menu), MSG_DONTWAIT | MSG_NOSIGNAL); + clients[slot] = (struct terminal){.fd = fd}; + terminal_text(&clients[slot], terminal_menu); } } } From 327b61ed718721afa794904a341c22951495b895 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:56:56 +0200 Subject: [PATCH 50/79] test: stalled terminal writer must not starve HTTP or other terminal clients --- tests/smoke.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/smoke.py b/tests/smoke.py index 45b8df5..de0a816 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -119,6 +119,25 @@ def available_port(): started = time.monotonic() assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() assert time.monotonic() - started < 1.0 + # A terminal that does not read must not block independent HTTP clients. + with socket.create_connection(("127.0.0.1", tp), timeout=1) as stalled: + stalled.settimeout(1) + assert b"POCKETBOX" in stalled.recv(2048) + stalled.sendall(b"F" * 40) + started = time.monotonic() + assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() + assert time.monotonic() - started < 1.0 + # Verify a normal terminal response remains readable after buffering. + with socket.create_connection(("127.0.0.1", tp), timeout=1) as normal: + normal.settimeout(1) + assert b"POCKETBOX" in normal.recv(2048) + normal.sendall(b"F") + response = b"" + deadline = time.monotonic() + 2 + while b"Choice: " not in response and time.monotonic() < deadline: + response += normal.recv(4096) + assert b"retro-000.bin" in response + assert b"Next page" in response # A partial HTTP request must not starve another HTTP client. with socket.create_connection(("127.0.0.1", hp), timeout=1) as slow: slow.sendall(b"GET / HTTP/1.1") From 3b746fcde7802cd2f853d5c8f5554cf3f07d5b71 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:57:01 +0200 Subject: [PATCH 51/79] security: disconnect terminal on bounded output queue overflow --- src/main.c | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main.c b/src/main.c index a684324..3c78d7a 100644 --- a/src/main.c +++ b/src/main.c @@ -49,7 +49,7 @@ static void terminal_text(struct terminal *c, const char *s) { memmove(c->output, c->output + c->out_pos, c->out_len - c->out_pos); c->out_len -= c->out_pos; c->out_pos = 0; } - if (n > sizeof c->output - c->out_len) return; + if (n > sizeof c->output - c->out_len) { close(c->fd); c->fd = -1; return; } memcpy(c->output + c->out_len, s, n); c->out_len += n; } /* Terminal sockets are nonblocking and processed by poll(), not recv loops. */ @@ -70,10 +70,11 @@ static void terminal_input(struct terminal *c, int root) { catalog_load_page(root, &cat, c->cursor); const char *header = "\r\nPocketBox files:\r\n"; terminal_text(c, header); - for (unsigned k = 0; k < cat.count; ++k) { + for (unsigned k = 0; c->fd >= 0 && k < cat.count; ++k) { terminal_text(c, cat.names[k]); terminal_text(c, "\r\n"); } + if (c->fd < 0) return; if (cat.count) snprintf(c->cursor, sizeof c->cursor, "%s", cat.names[cat.count - 1]); const char *footer = cat.has_more From 6b19c67e80486a0a1cdbf1ce6dac6997b6b7561d Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:58:34 +0200 Subject: [PATCH 52/79] perf: reduce terminal queue to 4608 bytes while fitting 32 catalog entries --- src/main.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.c b/src/main.c index 3c78d7a..13e6ad8 100644 --- a/src/main.c +++ b/src/main.c @@ -16,7 +16,7 @@ #define BUFSZ 2048 #define MAX_TERMINALS 12 -struct terminal { int fd; unsigned inputs; char cursor[128]; char output[8192]; size_t out_len, out_pos; }; +struct terminal { int fd; unsigned inputs; char cursor[128]; char output[4608]; size_t out_len, out_pos; }; static const char *terminal_menu = "\r\n*** POCKETBOX M1 ***\r\n[F] Files [N] Next page [Q] Quit\r\nChoice: "; static volatile sig_atomic_t running = 1; static void on_signal(int sig) { (void)sig; running = 0; } From 1d334d476c99db208ec2bdf6e38ccc5cd5d743b3 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:58:42 +0200 Subject: [PATCH 53/79] docs: record M1 memory bounds and hardware validation gates --- docs/MEMORY-BUDGET.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 docs/MEMORY-BUDGET.md diff --git a/docs/MEMORY-BUDGET.md b/docs/MEMORY-BUDGET.md new file mode 100644 index 0000000..120bfcf --- /dev/null +++ b/docs/MEMORY-BUDGET.md @@ -0,0 +1,33 @@ +# PocketBox M1 memory budget + +The TL-WR703N (32 MiB RAM, 4 MiB flash) is the limiting target. These are +source-level bounds, not measured device RAM or firmware sizes. + +| Item | Bound | +| --- | ---: | +| Terminal sessions | 12 | +| Terminal queued output per session | 4,608 bytes | +| Total terminal output buffers | 55,296 bytes | +| Previous total (8,192 bytes each) | 98,304 bytes | +| Reduction | 43,008 bytes | +| Catalog entries per page | 32 | +| Catalog filename bytes | 128 | +| Catalog names array | 4,096 bytes | +| HTTP sessions | 8 | + +A full terminal catalog page of 32 names of at most 127 bytes plus CRLF, +heading, and menu fits inside 4,608 bytes. Multiple requests from a client +that does not read responses may exhaust its queue; PocketBox disconnects +that client rather than dropping bytes silently. + +The C compiler and runtime may add padding, stack, socket buffers and other +overheads. **Do not treat these figures as measured RSS or flash usage.** + +## Before hardware release + +- Measure binary size and RSS with the intended OpenWrt musl toolchains. +- Verify 4 MiB flash layout and package fit on TL-WR703N. +- Load-test concurrent HTTP and terminal clients on both supported devices. +- Check catalog HTML pagination with 127-character names; a truncated HTML + page must never skip files when advancing the cursor. +- Record available RAM after Wi-Fi AP, bridge and DHCP are active. From 3d4b53c9f8902606ed0d4e6e8cea0290585b56aa Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:59:37 +0200 Subject: [PATCH 54/79] fix: paginate HTML by actual rendered entries to avoid skipping long filenames --- src/catalog.h | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/catalog.h b/src/catalog.h index 11d84db..546b2de 100644 --- a/src/catalog.h +++ b/src/catalog.h @@ -44,23 +44,32 @@ static void catalog_load_page(int root, struct catalog *cat, const char *after) static size_t catalog_html(const struct catalog *cat, char *out, size_t cap) { size_t used = 0; const char *prefix = "PocketBox

    Files

      "; + const char *suffix = "
    "; + /* Reserve enough room for the longest possible next-page link and suffix. */ + const size_t reserve = sizeof "
  • Next page
  • " - 1 + + sizeof "
" - 1; size_t n = strlen(prefix); - if (n >= cap) return 0; + if (n + reserve >= cap) return 0; memcpy(out, prefix, n); used = n; + unsigned shown = 0; for (unsigned i = 0; i < cat->count; ++i) { int written = snprintf(out + used, cap - used, "
  • %s
  • ", cat->names[i], cat->names[i]); - if (written < 0 || (size_t)written >= cap - used) break; + if (written < 0 || (size_t)written >= cap - used || + (size_t)written + reserve >= cap - used) break; used += (size_t)written; + shown++; } - if (cat->has_more && cat->count) { + if (shown < cat->count || cat->has_more) { + if (!shown) return 0; int written = snprintf(out + used, cap - used, - "
  • Next page
  • ", cat->names[cat->count - 1]); + "
  • Next page
  • ", cat->names[shown - 1]); if (written < 0 || (size_t)written >= cap - used) return 0; used += (size_t)written; } - const char *suffix = ""; - if (strlen(suffix) >= cap - used) return 0; - memcpy(out + used, suffix, strlen(suffix)); - return used + strlen(suffix); + n = strlen(suffix); + if (n >= cap - used) return 0; + memcpy(out + used, suffix, n); + return used + n; } From fa6d60b3762e2f1c53a2acd4a1fb18953762f1fe Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:59:41 +0200 Subject: [PATCH 55/79] fix: reserve HTTP response header capacity for paginated index --- src/http_async.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/http_async.h b/src/http_async.h index 609f3b7..e599fe2 100644 --- a/src/http_async.h +++ b/src/http_async.h @@ -45,7 +45,7 @@ static void http_prepare(struct http_conn *c, int root) { if (*after && !catalog_valid(after)) { http_error(c, 400, "Bad Request"); return; } catalog_load_page(root, &cat, after); char page[HTTP_OUT]; - size_t page_len = catalog_html(&cat, page, sizeof page); + size_t page_len = catalog_html(&cat, page, sizeof page - 160); if (!page_len) { http_error(c, 500, "Internal Server Error"); return; } http_response(c, 200, "OK", "text/html", (long long)page_len); if (c->state == 3 || c->out_len + page_len > sizeof c->output) { c->state = 3; return; } From 1cc26d01be1e432f364f3caaa1814419d63e3364 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 04:59:46 +0200 Subject: [PATCH 56/79] test: ensure long filenames remain reachable across HTTP pages --- tests/smoke.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/smoke.py b/tests/smoke.py index de0a816..807179d 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -108,6 +108,28 @@ def available_port(): for i in range(40): assert f"retro-{i:03}.bin".encode() in all_pages + # Long valid names must paginate without losing any entries. + long_names = [f"long-{i:03}-" + "x" * 110 for i in range(40)] + for name in long_names: + Path(d, name).write_bytes(b"long") + long_pages = [] + cursor = "" + for _ in range(32): + page = urlopen(f"http://127.0.0.1:{hp}/" + + (f"?after={cursor}" if cursor else "")).read() + long_pages.append(page) + import re + match = re.search(rb"/\?after=([^']+)", page) + if not match: + break + next_cursor = match.group(1).decode("ascii") + assert next_cursor > cursor + cursor = next_cursor + else: + raise AssertionError("long-name pagination did not terminate") + combined = b"".join(long_pages) + for name in long_names: + assert name.encode() in combined, name for bad in ["../etc/passwd", "%2e%2e", "missing", ".private", "linked.txt", "bad%26name.txt"]: try: urlopen(f"http://127.0.0.1:{hp}/files/{bad}") From a8c4cd2d896a1b2c9c77a124d0b32baad938a6ea Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 05:24:19 +0200 Subject: [PATCH 57/79] test: isolate long-name pagination fixture from existing terminal assertions --- tests/smoke.py | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/smoke.py b/tests/smoke.py index 807179d..e8bdc36 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -108,28 +108,6 @@ def available_port(): for i in range(40): assert f"retro-{i:03}.bin".encode() in all_pages - # Long valid names must paginate without losing any entries. - long_names = [f"long-{i:03}-" + "x" * 110 for i in range(40)] - for name in long_names: - Path(d, name).write_bytes(b"long") - long_pages = [] - cursor = "" - for _ in range(32): - page = urlopen(f"http://127.0.0.1:{hp}/" + - (f"?after={cursor}" if cursor else "")).read() - long_pages.append(page) - import re - match = re.search(rb"/\?after=([^']+)", page) - if not match: - break - next_cursor = match.group(1).decode("ascii") - assert next_cursor > cursor - cursor = next_cursor - else: - raise AssertionError("long-name pagination did not terminate") - combined = b"".join(long_pages) - for name in long_names: - assert name.encode() in combined, name for bad in ["../etc/passwd", "%2e%2e", "missing", ".private", "linked.txt", "bad%26name.txt"]: try: urlopen(f"http://127.0.0.1:{hp}/files/{bad}") @@ -166,6 +144,28 @@ def available_port(): started = time.monotonic() assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() assert time.monotonic() - started < 1.0 + # Long valid names must paginate without losing any entries. + long_names = [f"long-{i:03}-" + "x" * 110 for i in range(40)] + for name in long_names: + Path(d, name).write_bytes(b"long") + long_pages = [] + cursor = "" + for _ in range(32): + page = urlopen(f"http://127.0.0.1:{hp}/" + + (f"?after={cursor}" if cursor else "")).read() + long_pages.append(page) + import re + match = re.search(rb"/\?after=([^']+)", page) + if not match: + break + next_cursor = match.group(1).decode("ascii") + assert next_cursor > cursor + cursor = next_cursor + else: + raise AssertionError("long-name pagination did not terminate") + combined = b"".join(long_pages) + for name in long_names: + assert name.encode() in combined, name print("PASS: HTTP, Telnet, file download, invalid paths, idle terminal and HTTP concurrency") finally: p.terminate() From 6c709a263df1b2aa6bbddd11d284831fc444f657 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 05:24:30 +0200 Subject: [PATCH 58/79] docs: define target-specific OpenWrt cross-build and hardware qualification gates --- openwrt/BUILDING.md | 50 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 openwrt/BUILDING.md diff --git a/openwrt/BUILDING.md b/openwrt/BUILDING.md new file mode 100644 index 0000000..a1cb853 --- /dev/null +++ b/openwrt/BUILDING.md @@ -0,0 +1,50 @@ +# Cross-compiling PocketBox for OpenWrt (M1 preparation) + +**No firmware image or tested OpenWrt package is supplied yet.** These are +instructions for building the userspace executable with an existing, +device-compatible OpenWrt SDK. + +## Supported hardware only + +| Device | CPU family | Flash | RAM | +| --- | --- | --- | --- | +| TP-Link TL-WR703N | Atheros AR9331 / MIPS 24K | 4 MiB | 32 MiB | +| GL.iNet Mango GL-MT300N-V2 | MediaTek MT7628 / MIPS 24K | 16 MiB | 128 MiB | + +The devices require different OpenWrt targets and toolchains. **Do not use +a Mango image or package on WR703N or vice versa.** Confirm the hardware +revision, exact OpenWrt target/subtarget, SDK compatibility, flash space and +recovery method before building or installing anything. In particular, +current OpenWrt releases may not support a 4 MiB WR703N configuration. + +## Build with a verified SDK + +On a Linux build host, extract the SDK matching the chosen device's +installed OpenWrt release. Within the SDK, locate its MIPS musl cross-compiler +and add the toolchain bin directory to PATH. Then from the PocketBox repo: + +```sh +make clean +make CC=mips-openwrt-linux-musl-gcc +file pocketbox +``` + +The compiler prefix above is **illustrative**; inspect the actual SDK +toolchain and use its exact compiler name. Do not substitute the host +compiler. The resulting binary must be MIPS for the correct target ABI, +not x86_64 or ARM. This step produces only a userspace binary, not an +`.ipk` or flashable firmware image. + +## Qualification gates + +1. Confirm both targets' supported OpenWrt releases and available SDKs. +2. Cross-compile and inspect ELF architecture, ABI, dynamic loader and size. +3. Check free flash after base system and Wi-Fi/AP dependencies. +4. Verify service binding, init integration and file root on the device. +5. Measure RSS, CPU, and file listing latency with USB storage attached. +6. Exercise HTTP, terminal, bridge, and DHCP with real devices. +7. Test recovery procedure before distributing firmware. + +PocketBox firmware remains deliberately limited to local file services +and the AP/LAN bridge. IIAB, Kiwix, Kolibri and similar services belong on +a separate Ethernet-connected host. From 1d211c6190791845019a96e859ef68ec596c7f30 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:38:04 +0200 Subject: [PATCH 59/79] openwrt: scaffold opt-in PocketBox package and procd service --- openwrt/package/pocketbox/Makefile | 38 ++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 openwrt/package/pocketbox/Makefile diff --git a/openwrt/package/pocketbox/Makefile b/openwrt/package/pocketbox/Makefile new file mode 100644 index 0000000..65dbbbe --- /dev/null +++ b/openwrt/package/pocketbox/Makefile @@ -0,0 +1,38 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=pocketbox +PKG_VERSION:=0.1.0 +PKG_RELEASE:=1 + +include $(INCLUDE_DIR)/package.mk + +define Package/pocketbox + SECTION:=net + CATEGORY:=Network + TITLE:=PocketBox offline file portal and ASCII terminal + DEPENDS:=+libc +endef + +define Package/pocketbox/description + Small offline HTTP file portal and ASCII terminal for supported MIPS OpenWrt routers. +endef + +define Build/Prepare + mkdir -p $(PKG_BUILD_DIR)/src + $(CP) $(TOPDIR)/package/pocketbox/src/* $(PKG_BUILD_DIR)/src/ +endef + +TARGET_CFLAGS += -D_GNU_SOURCE -std=c11 -Os -Wall -Wextra + +define Build/Compile + $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) -o $(PKG_BUILD_DIR)/pocketbox $(PKG_BUILD_DIR)/src/main.c +endef + +define Package/pocketbox/install + $(INSTALL_DIR) $(1)/usr/bin $(1)/etc/init.d $(1)/etc/config + $(INSTALL_BIN) $(PKG_BUILD_DIR)/pocketbox $(1)/usr/bin/pocketbox + $(INSTALL_BIN) ./files/pocketbox.init $(1)/etc/init.d/pocketbox + $(INSTALL_CONF) ./files/pocketbox.config $(1)/etc/config/pocketbox +endef + +$(eval $(call BuildPackage,pocketbox)) From 5d1020dc29de91092f4c9790938122c9f30e3501 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:38:06 +0200 Subject: [PATCH 60/79] openwrt: scaffold opt-in PocketBox package and procd service --- openwrt/package/pocketbox/files/pocketbox.config | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 openwrt/package/pocketbox/files/pocketbox.config diff --git a/openwrt/package/pocketbox/files/pocketbox.config b/openwrt/package/pocketbox/files/pocketbox.config new file mode 100644 index 0000000..bf154cb --- /dev/null +++ b/openwrt/package/pocketbox/files/pocketbox.config @@ -0,0 +1,5 @@ +config pocketbox 'main' + option enabled '0' + option root '/mnt/pocketbox' + option http_port '8080' + option terminal_port '2323' From e8b87c63283bdd9eb627b524e988619588b952f9 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:38:08 +0200 Subject: [PATCH 61/79] openwrt: scaffold opt-in PocketBox package and procd service --- .../package/pocketbox/files/pocketbox.init | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 openwrt/package/pocketbox/files/pocketbox.init diff --git a/openwrt/package/pocketbox/files/pocketbox.init b/openwrt/package/pocketbox/files/pocketbox.init new file mode 100644 index 0000000..3ed7782 --- /dev/null +++ b/openwrt/package/pocketbox/files/pocketbox.init @@ -0,0 +1,20 @@ +#!/bin/sh /etc/rc.common + +START=90 +STOP=10 +USE_PROCD=1 + +start_service() { + local enabled root http_port terminal_port + config_load pocketbox + config_get_bool enabled main enabled 0 + [ "$enabled" -eq 1 ] || return 0 + config_get root main root /mnt/pocketbox + config_get http_port main http_port 8080 + config_get terminal_port main terminal_port 2323 + [ -d "$root" ] || { logger -t pocketbox "missing file root: $root"; return 1; } + procd_open_instance + procd_set_param command /usr/bin/pocketbox --root "$root" --http-port "$http_port" --telnet-port "$terminal_port" + procd_set_param respawn + procd_close_instance +} From 8b674ddb157fbefa8e42e5a23c2f5356ce1c5112 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:38:16 +0200 Subject: [PATCH 62/79] docs: document package staging and loopback-only deployment blocker --- openwrt/package/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 openwrt/package/README.md diff --git a/openwrt/package/README.md b/openwrt/package/README.md new file mode 100644 index 0000000..a1749b7 --- /dev/null +++ b/openwrt/package/README.md @@ -0,0 +1,24 @@ +# Experimental OpenWrt package scaffold + +This is a **developer-only package scaffold**, not a qualified router image. +PocketBox currently binds its listeners to **127.0.0.1**, so enabling this +service does **not** yet expose the portal to Wi-Fi/LAN clients. LAN binding +must be implemented and reviewed before device deployment. + +To stage the package in a compatible, already configured OpenWrt build tree: + +```sh +mkdir -p /path/to/openwrt/package/pocketbox/src +cp -R openwrt/package/pocketbox/Makefile openwrt/package/pocketbox/files /path/to/openwrt/package/pocketbox/ +cp src/*.c src/*.h /path/to/openwrt/package/pocketbox/src/ +``` + +From that OpenWrt build tree, select the package using menuconfig, then run +`make package/pocketbox/compile V=s`. Verify target architecture and package +size. Do not flash an image until the exact device/revision is qualified. + +The init service is disabled by default. It does not modify firewall, DHCP, +Wi-Fi, bridge, or other network configuration. The configured file root must +already exist. USB mounting is managed independently by the administrator. + +The package has **not** been cross-compiled or tested on physical hardware. From 77c51a86d059802ff00af20f6634c650b28e93ce Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:44:11 +0200 Subject: [PATCH 63/79] feat: explicit IPv4 listener binding; reject wildcard and multicast addresses --- src/main.c | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/main.c b/src/main.c index 13e6ad8..8cd248c 100644 --- a/src/main.c +++ b/src/main.c @@ -20,14 +20,14 @@ struct terminal { int fd; unsigned inputs; char cursor[128]; char output[4608]; static const char *terminal_menu = "\r\n*** POCKETBOX M1 ***\r\n[F] Files [N] Next page [Q] Quit\r\nChoice: "; static volatile sig_atomic_t running = 1; static void on_signal(int sig) { (void)sig; running = 0; } -static int listener(unsigned port) { +static int listener(unsigned port, struct in_addr bind_addr) { int fd = socket(AF_INET, SOCK_STREAM, 0), opt = 1; struct sockaddr_in a; if (fd < 0) return -1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof opt); memset(&a, 0, sizeof a); a.sin_family = AF_INET; a.sin_port = htons((unsigned short)port); - a.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + a.sin_addr = bind_addr; if (bind(fd, (struct sockaddr *)&a, sizeof a) || listen(fd, 8)) { close(fd); return -1; } return fd; } @@ -85,22 +85,31 @@ static void terminal_input(struct terminal *c, int root) { } } int main(int argc, char **argv) { - const char *dir = "."; unsigned http = 8080, telnet = 2323; + const char *dir = ".", *bind_ip = "127.0.0.1"; unsigned http = 8080, telnet = 2323; for (int i = 1; i < argc; ++i) { if (i + 1 >= argc) { fprintf(stderr, "Missing value for %s\n", argv[i]); return 2; } if (!strcmp(argv[i], "--root")) dir = argv[++i]; + else if (!strcmp(argv[i], "--bind")) bind_ip = argv[++i]; else if (!strcmp(argv[i], "--http-port")) http = (unsigned)atoi(argv[++i]); else if (!strcmp(argv[i], "--telnet-port")) telnet = (unsigned)atoi(argv[++i]); else { fprintf(stderr, "Unknown option: %s\n", argv[i]); return 2; } } if (!http || http > 65535 || !telnet || telnet > 65535 || http == telnet) return 2; + struct in_addr bind_addr; + if (inet_pton(AF_INET, bind_ip, &bind_addr) != 1 || + bind_addr.s_addr == htonl(INADDR_ANY) || + bind_addr.s_addr == htonl(INADDR_BROADCAST) || + IN_MULTICAST(ntohl(bind_addr.s_addr))) { + fprintf(stderr, "Invalid --bind: specify a concrete IPv4 interface address (not 0.0.0.0)\n"); + return 2; + } int root = open(dir, O_RDONLY | O_DIRECTORY | O_NOFOLLOW); if (root < 0) { perror("root"); return 1; } - int h = listener(http), t = listener(telnet); + int h = listener(http, bind_addr), t = listener(telnet, bind_addr); if (h < 0 || t < 0) { perror("listen"); if (h >= 0) close(h); if (t >= 0) close(t); close(root); return 1; } signal(SIGINT, on_signal); signal(SIGTERM, on_signal); signal(SIGPIPE, SIG_IGN); - fprintf(stderr, "PocketBox M0 listening on 127.0.0.1 HTTP:%u Telnet:%u\n", http, telnet); + fprintf(stderr, "PocketBox M1 listening on %s HTTP:%u Telnet:%u\n", bind_ip, http, telnet); /* Both HTTP and terminal sockets are bounded and polled. */ struct http_conn web[MAX_HTTP]; for (unsigned i = 0; i < MAX_HTTP; ++i) { web[i].fd = -1; web[i].file = -1; } From d35369f4f2d36f9016ed060f6134cc8de9d015f6 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:44:19 +0200 Subject: [PATCH 64/79] openwrt: configure explicit safe listener address --- openwrt/package/pocketbox/files/pocketbox.config | 1 + 1 file changed, 1 insertion(+) diff --git a/openwrt/package/pocketbox/files/pocketbox.config b/openwrt/package/pocketbox/files/pocketbox.config index bf154cb..b37576d 100644 --- a/openwrt/package/pocketbox/files/pocketbox.config +++ b/openwrt/package/pocketbox/files/pocketbox.config @@ -1,5 +1,6 @@ config pocketbox 'main' option enabled '0' + option bind '127.0.0.1' option root '/mnt/pocketbox' option http_port '8080' option terminal_port '2323' From b2da80032a974bc6060270fd254081b105d7dd25 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:44:22 +0200 Subject: [PATCH 65/79] openwrt: configure explicit safe listener address --- openwrt/package/pocketbox/files/pocketbox.init | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openwrt/package/pocketbox/files/pocketbox.init b/openwrt/package/pocketbox/files/pocketbox.init index 3ed7782..ba30a58 100644 --- a/openwrt/package/pocketbox/files/pocketbox.init +++ b/openwrt/package/pocketbox/files/pocketbox.init @@ -5,16 +5,17 @@ STOP=10 USE_PROCD=1 start_service() { - local enabled root http_port terminal_port + local enabled root http_port terminal_port bind config_load pocketbox config_get_bool enabled main enabled 0 [ "$enabled" -eq 1 ] || return 0 config_get root main root /mnt/pocketbox + config_get bind main bind 127.0.0.1 config_get http_port main http_port 8080 config_get terminal_port main terminal_port 2323 [ -d "$root" ] || { logger -t pocketbox "missing file root: $root"; return 1; } procd_open_instance - procd_set_param command /usr/bin/pocketbox --root "$root" --http-port "$http_port" --telnet-port "$terminal_port" + procd_set_param command /usr/bin/pocketbox --bind "$bind" --root "$root" --http-port "$http_port" --telnet-port "$terminal_port" procd_set_param respawn procd_close_instance } From 394b0a2b7ae479f35db1444ce2ab4c5e161091bd Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:44:24 +0200 Subject: [PATCH 66/79] openwrt: configure explicit safe listener address --- openwrt/package/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openwrt/package/README.md b/openwrt/package/README.md index a1749b7..fdf7ca2 100644 --- a/openwrt/package/README.md +++ b/openwrt/package/README.md @@ -1,9 +1,10 @@ # Experimental OpenWrt package scaffold This is a **developer-only package scaffold**, not a qualified router image. -PocketBox currently binds its listeners to **127.0.0.1**, so enabling this -service does **not** yet expose the portal to Wi-Fi/LAN clients. LAN binding -must be implemented and reviewed before device deployment. +PocketBox binds to **127.0.0.1 by default**. To expose the service to LAN +clients, configure an explicit IPv4 address assigned to the PocketBox LAN +interface. Wildcard binding (0.0.0.0) is rejected. Review firewall rules +and verify the actual LAN address before enabling the service. To stage the package in a compatible, already configured OpenWrt build tree: From 1d19060bfe3614c71d67a3ffe26491b26f361571 Mon Sep 17 00:00:00 2001 From: pos Date: Sat, 10 Oct 2026 23:44:29 +0200 Subject: [PATCH 67/79] test: reject unsafe wildcard and multicast bind addresses --- tests/smoke.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/smoke.py b/tests/smoke.py index e8bdc36..e6e849a 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -23,6 +23,12 @@ def available_port(): hp, tp = available_port(), available_port() while hp == tp: tp = available_port() + for bad_ip in ("0.0.0.0", "255.255.255.255", "224.0.0.1", "not-an-ip"): + invalid = subprocess.run( + ["./pocketbox", "--root", d, "--bind", bad_ip, + "--http-port", str(hp), "--telnet-port", str(tp)], + capture_output=True, timeout=2) + assert invalid.returncode == 2, (bad_ip, invalid.stderr) p = subprocess.Popen(["./pocketbox", "--root", d, "--http-port", str(hp), "--telnet-port", str(tp)]) try: for _ in range(50): From 0da4edfd3195308d5703d22c840f732407ec4033 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:42:47 +0200 Subject: [PATCH 68/79] test: explicit IPv4 bind serves both protocols and unavailable IP fails startup --- tests/smoke.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/smoke.py b/tests/smoke.py index e6e849a..75dcd74 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -29,6 +29,32 @@ def available_port(): "--http-port", str(hp), "--telnet-port", str(tp)], capture_output=True, timeout=2) assert invalid.returncode == 2, (bad_ip, invalid.stderr) + # Explicit loopback binding serves both protocols; unassigned addresses fail. + unavailable = subprocess.run( + ["./pocketbox", "--root", d, "--bind", "192.0.2.123", + "--http-port", str(hp), "--telnet-port", str(tp)], + capture_output=True, timeout=2) + assert unavailable.returncode != 0 + assert b"listen" in unavailable.stderr + bound = subprocess.Popen( + ["./pocketbox", "--root", d, "--bind", "127.0.0.1", + "--http-port", str(hp), "--telnet-port", str(tp)], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + try: + for _ in range(50): + try: + with socket.create_connection(("127.0.0.1", tp), timeout=0.2) as term: + term.settimeout(1) + assert b"POCKETBOX" in term.recv(2048) + assert b"PocketBox" in urlopen(f"http://127.0.0.1:{hp}/", timeout=1).read() + break + except (ConnectionRefusedError, TimeoutError): + time.sleep(0.05) + else: + raise AssertionError("explicit loopback listener did not start") + finally: + bound.terminate() + bound.wait(timeout=5) p = subprocess.Popen(["./pocketbox", "--root", d, "--http-port", str(hp), "--telnet-port", str(tp)]) try: for _ in range(50): From f406adb4af3ac0695c006418b82d4328fb5998fe Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:43:52 +0200 Subject: [PATCH 69/79] openwrt: validate configured ports and disallow wildcard bind before procd start --- openwrt/package/pocketbox/files/pocketbox.init | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/openwrt/package/pocketbox/files/pocketbox.init b/openwrt/package/pocketbox/files/pocketbox.init index ba30a58..901e8fe 100644 --- a/openwrt/package/pocketbox/files/pocketbox.init +++ b/openwrt/package/pocketbox/files/pocketbox.init @@ -4,6 +4,13 @@ START=90 STOP=10 USE_PROCD=1 +validate_port() { + case "$1" in + ''|*[!0-9]*) return 1 ;; + esac + [ "$1" -ge 1 ] && [ "$1" -le 65535 ] +} + start_service() { local enabled root http_port terminal_port bind config_load pocketbox @@ -14,6 +21,14 @@ start_service() { config_get http_port main http_port 8080 config_get terminal_port main terminal_port 2323 [ -d "$root" ] || { logger -t pocketbox "missing file root: $root"; return 1; } + validate_port "$http_port" && validate_port "$terminal_port" && + [ "$http_port" != "$terminal_port" ] || { + logger -t pocketbox "invalid or duplicate port configuration" + return 1 + } + case "$bind" in + ''|0.0.0.0|255.255.255.255) logger -t pocketbox "unsafe bind address"; return 1 ;; + esac procd_open_instance procd_set_param command /usr/bin/pocketbox --bind "$bind" --root "$root" --http-port "$http_port" --telnet-port "$terminal_port" procd_set_param respawn From be9b9450e47ef85ba34d175a2f35a05e2ea0f1d3 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:44:05 +0200 Subject: [PATCH 70/79] test: check OpenWrt init remains opt-in, procd-based and network-neutral --- tests/test_openwrt_init.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 tests/test_openwrt_init.py diff --git a/tests/test_openwrt_init.py b/tests/test_openwrt_init.py new file mode 100644 index 0000000..ef38e85 --- /dev/null +++ b/tests/test_openwrt_init.py @@ -0,0 +1,36 @@ +"""Static contract checks for the experimental OpenWrt procd script. + +These are host-side checks, not execution against an OpenWrt runtime. +""" +import pathlib +import unittest + +ROOT = pathlib.Path(__file__).resolve().parents[1] +INIT = ROOT / "openwrt/package/pocketbox/files/pocketbox.init" +CONFIG = ROOT / "openwrt/package/pocketbox/files/pocketbox.config" + + +class OpenWrtInitContract(unittest.TestCase): + def test_opt_in_and_loopback_defaults(self): + config = CONFIG.read_text() + self.assertIn("option enabled '0'", config) + self.assertIn("option bind '127.0.0.1'", config) + + def test_procd_and_configured_bind(self): + script = INIT.read_text() + self.assertIn("USE_PROCD=1", script) + self.assertIn("config_get_bool enabled main enabled 0", script) + self.assertIn('config_get bind main bind 127.0.0.1', script) + self.assertIn('command /usr/bin/pocketbox --bind "$bind"', script) + self.assertIn('validate_port "$http_port"', script) + self.assertIn('validate_port "$terminal_port"', script) + + def test_no_implicit_network_mutation(self): + script = INIT.read_text() + for forbidden in ("uci set", "uci commit", "ifconfig ", "ip addr add", + "iptables ", "nft ", "wifi reload", "network restart"): + self.assertNotIn(forbidden, script) + + +if __name__ == "__main__": + unittest.main() From a5ed771934abf31098231cbd779897abd9b3e6b2 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:44:52 +0200 Subject: [PATCH 71/79] build: stage OpenWrt package deterministically without overwriting existing files --- openwrt/stage_package.py | 59 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 openwrt/stage_package.py diff --git a/openwrt/stage_package.py b/openwrt/stage_package.py new file mode 100644 index 0000000..12b7d52 --- /dev/null +++ b/openwrt/stage_package.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Stage the PocketBox package into an existing OpenWrt source/build tree. + +Does not build, install, flash, or alter router network settings. +""" +import argparse +from pathlib import Path +import shutil +import sys + + +def stage(source: Path, target: Path) -> None: + source = source.resolve() + target = target.resolve() + package = source / "openwrt/package/pocketbox" + required = [ + source / "src/main.c", + source / "src/catalog.h", + source / "src/http_async.h", + package / "Makefile", + package / "files/pocketbox.init", + package / "files/pocketbox.config", + ] + for file in required: + if not file.is_file(): + raise ValueError(f"missing required source: {file}") + if not (target / "rules.mk").is_file() or not (target / "include/package.mk").is_file(): + raise ValueError("destination is not an OpenWrt source/build tree") + destination = target / "package/pocketbox" + if destination.exists(): + raise ValueError(f"destination already exists (refusing overwrite): {destination}") + destination.mkdir(parents=True) + try: + shutil.copy2(package / "Makefile", destination / "Makefile") + shutil.copytree(package / "files", destination / "files") + (destination / "src").mkdir() + for name in ("main.c", "catalog.h", "http_async.h"): + shutil.copy2(source / "src" / name, destination / "src" / name) + except Exception: + shutil.rmtree(destination) + raise + print(f"Staged PocketBox at {destination}") + print("Next: select package in menuconfig; make package/pocketbox/compile V=s") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--openwrt-tree", type=Path, required=True) + args = parser.parse_args() + try: + stage(Path(__file__).resolve().parents[1], args.openwrt_tree) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From a4580089b1932afd81e6218db9f8412aae6fdf17 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:44:58 +0200 Subject: [PATCH 72/79] test: verify deterministic OpenWrt package staging and no-overwrite safety --- tests/test_stage_package.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/test_stage_package.py diff --git a/tests/test_stage_package.py b/tests/test_stage_package.py new file mode 100644 index 0000000..677abba --- /dev/null +++ b/tests/test_stage_package.py @@ -0,0 +1,35 @@ +import pathlib +import tempfile +import unittest + +from openwrt.stage_package import stage + +REPO = pathlib.Path(__file__).resolve().parents[1] + + +class StagePackageTests(unittest.TestCase): + def test_stage_and_refuse_overwrite(self): + with tempfile.TemporaryDirectory() as directory: + tree = pathlib.Path(directory) + (tree / "include").mkdir() + (tree / "rules.mk").touch() + (tree / "include/package.mk").touch() + stage(REPO, tree) + package = tree / "package/pocketbox" + for relative in ("Makefile", "files/pocketbox.init", + "files/pocketbox.config", "src/main.c", + "src/catalog.h", "src/http_async.h"): + self.assertTrue((package / relative).is_file(), relative) + with self.assertRaisesRegex(ValueError, "refusing overwrite"): + stage(REPO, tree) + + def test_reject_non_openwrt_destination(self): + with tempfile.TemporaryDirectory() as directory: + tree = pathlib.Path(directory) + with self.assertRaisesRegex(ValueError, "not an OpenWrt"): + stage(REPO, tree) + self.assertFalse((tree / "package/pocketbox").exists()) + + +if __name__ == "__main__": + unittest.main() From bdede6a8eb9cd50c15bcae3a530bbcea346d4337 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:45:04 +0200 Subject: [PATCH 73/79] docs: use tested staging command for OpenWrt package preparation --- openwrt/package/README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/openwrt/package/README.md b/openwrt/package/README.md index fdf7ca2..02de88b 100644 --- a/openwrt/package/README.md +++ b/openwrt/package/README.md @@ -9,11 +9,13 @@ and verify the actual LAN address before enabling the service. To stage the package in a compatible, already configured OpenWrt build tree: ```sh -mkdir -p /path/to/openwrt/package/pocketbox/src -cp -R openwrt/package/pocketbox/Makefile openwrt/package/pocketbox/files /path/to/openwrt/package/pocketbox/ -cp src/*.c src/*.h /path/to/openwrt/package/pocketbox/src/ +python3 openwrt/stage_package.py --openwrt-tree /path/to/openwrt ``` +The script verifies the destination and copies the package and all required +source files. It refuses to overwrite an existing package directory. It +does not compile, install or flash anything. + From that OpenWrt build tree, select the package using menuconfig, then run `make package/pocketbox/compile V=s`. Verify target architecture and package size. Do not flash an image until the exact device/revision is qualified. From d06d2abefddc7c08c2b782bf28bfdbfdd7f35305 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:55:29 +0200 Subject: [PATCH 74/79] build: inspect ELF architecture and size for Mango and WR703N --- openwrt/check_binary.py | 58 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 openwrt/check_binary.py diff --git a/openwrt/check_binary.py b/openwrt/check_binary.py new file mode 100644 index 0000000..9581993 --- /dev/null +++ b/openwrt/check_binary.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Inspect a PocketBox ELF binary without running it. + +A passing check does not imply firmware/device compatibility. +""" +import argparse +from pathlib import Path +import struct +import sys + +ELF_MACHINE_MIPS = 8 + + +def inspect(path: Path) -> dict: + with path.open("rb") as stream: + header = stream.read(20) + if len(header) < 20 or header[:4] != b"\x7fELF": + raise ValueError("not an ELF binary") + elf_class, data = header[4], header[5] + if elf_class not in (1, 2) or data not in (1, 2): + raise ValueError("invalid ELF class or byte order") + order = "<" if data == 1 else ">" + machine = struct.unpack_from(order + "H", header, 18)[0] + return { + "bytes": path.stat().st_size, + "elf_bits": 32 if elf_class == 1 else 64, + "endian": "little" if data == 1 else "big", + "machine": machine, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("binary", type=Path) + parser.add_argument("--target", choices=("mango", "wr703n"), required=True) + args = parser.parse_args() + try: + result = inspect(args.binary) + except (OSError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + print(f"Target: {args.target}") + for key, value in result.items(): + print(f"{key}: {value}") + if result["machine"] != ELF_MACHINE_MIPS or result["elf_bits"] != 32: + print("error: expected a 32-bit MIPS ELF, not a host binary", file=sys.stderr) + return 1 + expected_endian = "little" if args.target == "mango" else "big" + if result["endian"] != expected_endian: + print(f"error: expected {expected_endian}-endian MIPS for {args.target}", + file=sys.stderr) + return 1 + print("ELF architecture matches expected family; ABI, libraries, flash and hardware still require validation.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 5d4e729a2e6eda48dbcc1a2dbe5894c3ee0a9ce5 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:55:35 +0200 Subject: [PATCH 75/79] test: cover ELF architecture inspector for both MIPS byte orders --- tests/test_check_binary.py | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/test_check_binary.py diff --git a/tests/test_check_binary.py b/tests/test_check_binary.py new file mode 100644 index 0000000..03c6206 --- /dev/null +++ b/tests/test_check_binary.py @@ -0,0 +1,42 @@ +import pathlib +import struct +import tempfile +import unittest + +from openwrt.check_binary import inspect + +class BinaryInspectionTests(unittest.TestCase): + def test_mips_little_endian_elf32(self): + with tempfile.TemporaryDirectory() as d: + p = pathlib.Path(d) / "pocketbox" + header = bytearray(20) + header[:4] = b"\x7fELF" + header[4] = 1 + header[5] = 1 + struct.pack_into("H", header, 18, 8) + p.write_bytes(header) + self.assertEqual(inspect(p)["endian"], "big") + + def test_reject_non_elf(self): + with tempfile.TemporaryDirectory() as d: + p = pathlib.Path(d) / "binary" + p.write_bytes(b"hello") + with self.assertRaisesRegex(ValueError, "not an ELF"): + inspect(p) + + +if __name__ == "__main__": + unittest.main() From d5a188022db7b1e51021b0431ed53e479cc8b689 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:55:42 +0200 Subject: [PATCH 76/79] docs: add target-specific ELF checks to OpenWrt build workflow --- openwrt/BUILDING.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/openwrt/BUILDING.md b/openwrt/BUILDING.md index a1cb853..43e0a20 100644 --- a/openwrt/BUILDING.md +++ b/openwrt/BUILDING.md @@ -26,7 +26,7 @@ and add the toolchain bin directory to PATH. Then from the PocketBox repo: ```sh make clean make CC=mips-openwrt-linux-musl-gcc -file pocketbox +file pocketbox\npython3 openwrt/check_binary.py pocketbox --target mango ``` The compiler prefix above is **illustrative**; inspect the actual SDK @@ -48,3 +48,17 @@ not x86_64 or ARM. This step produces only a userspace binary, not an PocketBox firmware remains deliberately limited to local file services and the AP/LAN bridge. IIAB, Kiwix, Kolibri and similar services belong on a separate Ethernet-connected host. + +## ELF architecture check + +After compiling with a verified target SDK, run: + +```sh +python3 openwrt/check_binary.py pocketbox --target mango +# Or, for a WR703N-specific binary: +python3 openwrt/check_binary.py pocketbox --target wr703n +``` + +This checks ELF32 MIPS, expected endianness and byte size. It does not +validate MIPS ISA extensions, ABI, loader, linked libraries or available +flash space. Do not treat a passing result as proof of device compatibility. From 40367491d2e5e964dfff24ecbd720680890001ec Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:56:27 +0200 Subject: [PATCH 77/79] ci: cross-compile generic MIPS little-endian Linux smoke artifact --- .github/workflows/mips-cross.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/mips-cross.yml diff --git a/.github/workflows/mips-cross.yml b/.github/workflows/mips-cross.yml new file mode 100644 index 0000000..12808f9 --- /dev/null +++ b/.github/workflows/mips-cross.yml @@ -0,0 +1,27 @@ +name: MIPS cross-compile smoke +on: + push: + pull_request: + +jobs: + mango-mipsel: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install generic MIPS little-endian cross-compiler + run: sudo apt-get update && sudo apt-get install -y gcc-mipsel-linux-gnu binutils-mipsel-linux-gnu + - name: Compile PocketBox for MIPS little-endian Linux + run: | + make clean + make CC=mipsel-linux-gnu-gcc + - name: Verify architecture and record size + run: | + python3 openwrt/check_binary.py pocketbox --target mango + mipsel-linux-gnu-readelf -h pocketbox + mipsel-linux-gnu-size pocketbox + - name: Preserve cross-compiled test artifact + uses: actions/upload-artifact@v4 + with: + name: pocketbox-generic-mipsel-not-openwrt + path: pocketbox + retention-days: 7 From da546eac14c3585244235064307ada85a730f938 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 16:56:34 +0200 Subject: [PATCH 78/79] docs: distinguish generic MIPS CI build from device-compatible OpenWrt SDK builds --- openwrt/BUILDING.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openwrt/BUILDING.md b/openwrt/BUILDING.md index 43e0a20..35c476a 100644 --- a/openwrt/BUILDING.md +++ b/openwrt/BUILDING.md @@ -62,3 +62,16 @@ python3 openwrt/check_binary.py pocketbox --target wr703n This checks ELF32 MIPS, expected endianness and byte size. It does not validate MIPS ISA extensions, ABI, loader, linked libraries or available flash space. Do not treat a passing result as proof of device compatibility. + +## Generic MIPS CI smoke build (not an OpenWrt package) + +The `.github/workflows/mips-cross.yml` job uses Ubuntu's +`gcc-mipsel-linux-gnu` to compile PocketBox for generic little-endian MIPS +Linux, runs the ELF architecture check and uploads a temporary test binary. +**That binary uses a different toolchain/runtime than the OpenWrt SDK and +must not be installed on Mango or treated as a supported device build.** + +For an actual OpenWrt package, use a version-matched OpenWrt build tree and +`openwrt/stage_package.py`; compile the package with OpenWrt's own compiler +and confirm the resulting package's ABI and dependencies. A passing generic +MIPS job is only a portability signal. From 457df5b73bab3d468dbad0b053c592f1a896c716 Mon Sep 17 00:00:00 2001 From: pos Date: Sun, 11 Oct 2026 21:35:12 +0200 Subject: [PATCH 79/79] docs: freeze PocketBox foundation pending MikroWrt integration contracts --- docs/FREEZE.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 docs/FREEZE.md diff --git a/docs/FREEZE.md b/docs/FREEZE.md new file mode 100644 index 0000000..aa4f2c1 --- /dev/null +++ b/docs/FREEZE.md @@ -0,0 +1,40 @@ +# PocketBox foundation freeze + +Status: **FROZEN — awaiting MikroWrt integration contract** +Date: 2026-10-11 +Branch: `m0-foundation` +Review: PR #1 (leave unmerged) + +## Scope + +Preserve the C11 HTTP file portal, ASCII terminal, catalog, bounded clients, +security checks, host tests and experimental packaging as a reference baseline. +The generic MIPS little-endian cross-build is a portability smoke test only; +it is not an installable MikroWrt/OpenWrt package. + +## Allowed during freeze + +- Fix verified correctness or security defects. +- Repair CI failures and maintain tests/documentation. +- Record integration requirements and measurements without redesigning runtime. + +## Deferred until MikroWrt contracts are established + +- Refactor runtime and service integration. +- Select package manager, package metadata and dependency policy. +- Bind to MikroWrt init/service supervision and configuration. +- Define AP/LAN/USB storage ownership and lifecycle. +- Build or flash device firmware, or claim Mango/WR703N qualification. + +## Unfreeze gates + +1. MikroWrt establishes supported device targets and reproducible toolchains. +2. Package format and installation/update policy are defined. +3. Init/service supervision and configuration conventions are defined. +4. Networking ownership is explicit: PocketBox does not mutate AP, bridge, + DHCP, firewall or upstream host services. +5. USB mount and storage paths are defined with safe failure behavior. +6. A migration plan preserves HTTP/terminal behavior and regression tests. + +Do not merge PR #1 as part of the freeze. Revisit this document before +starting integration or refactoring.