diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5154fd0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,15 @@ +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 + - name: AP/LAN profile tests + run: python3 -m unittest discover -s tests -p 'test_*.py' 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aced351 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +pocketbox +*.o +__pycache__/ 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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..20cb890 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +CC ?= cc +CFLAGS ?= -Os -std=c11 -Wall -Wextra -Werror -pedantic +CPPFLAGS ?= -D_GNU_SOURCE +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 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..0192673 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,21 @@ +# 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. + +## 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. diff --git a/docs/ETHERNET-SERVICES.md b/docs/ETHERNET-SERVICES.md new file mode 100644 index 0000000..b32c252 --- /dev/null +++ b/docs/ETHERNET-SERVICES.md @@ -0,0 +1,44 @@ +# Ethernet connectivity: PocketBox as Wi-Fi access point + +Status: architecture decision; hardware implementation pending. + +## Core requirement + +**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. + +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. + +## Preferred topology: simple LAN bridge + +```text + Wi-Fi clients ))) PocketBox (Wi-Fi AP) --- Ethernet --- external host + | + local PocketBox services +``` + +- **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. + +## Alternative topology: routed AP + +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. + +## M0/M1 boundaries + +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. + +## Acceptance tests + +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. + +Optional links to IIAB or BBS in the PocketBox portal may be added later, but are not required for any of the above. 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. 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. 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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..c4253f5 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,16 @@ +# PocketBox roadmap + +- **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. + +## Ethernet decision + +**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). + +M1 provides a **review-only UCI command generator**, not an installer or validated router configuration. Physical tests remain required. 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. diff --git a/openwrt/BUILDING.md b/openwrt/BUILDING.md new file mode 100644 index 0000000..35c476a --- /dev/null +++ b/openwrt/BUILDING.md @@ -0,0 +1,77 @@ +# 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\npython3 openwrt/check_binary.py pocketbox --target mango +``` + +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. + +## 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. + +## 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. 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 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()) 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() diff --git a/openwrt/package/README.md b/openwrt/package/README.md new file mode 100644 index 0000000..02de88b --- /dev/null +++ b/openwrt/package/README.md @@ -0,0 +1,27 @@ +# Experimental OpenWrt package scaffold + +This is a **developer-only package scaffold**, not a qualified router image. +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: + +```sh +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. + +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. 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)) diff --git a/openwrt/package/pocketbox/files/pocketbox.config b/openwrt/package/pocketbox/files/pocketbox.config new file mode 100644 index 0000000..b37576d --- /dev/null +++ b/openwrt/package/pocketbox/files/pocketbox.config @@ -0,0 +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' diff --git a/openwrt/package/pocketbox/files/pocketbox.init b/openwrt/package/pocketbox/files/pocketbox.init new file mode 100644 index 0000000..901e8fe --- /dev/null +++ b/openwrt/package/pocketbox/files/pocketbox.init @@ -0,0 +1,36 @@ +#!/bin/sh /etc/rc.common + +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 + 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; } + 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 + procd_close_instance +} 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()) diff --git a/src/catalog.h b/src/catalog.h new file mode 100644 index 0000000..546b2de --- /dev/null +++ b/src/catalog.h @@ -0,0 +1,75 @@ +/* Shared, bounded file catalog for HTTP and ASCII terminal. */ +#include +#define CATALOG_MAX 32 +#define CATALOG_NAME 128 +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, '/') || + 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_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); + } +} +static size_t catalog_html(const struct catalog *cat, char *out, size_t cap) { + size_t used = 0; + const char *prefix = "PocketBox

Files

"; + /* 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 + 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 || + (size_t)written + reserve >= cap - used) break; + used += (size_t)written; + shown++; + } + if (shown < cat->count || cat->has_more) { + if (!shown) return 0; + int written = snprintf(out + used, cap - used, + "
  • Next page
  • ", cat->names[shown - 1]); + if (written < 0 || (size_t)written >= cap - used) return 0; + used += (size_t)written; + } + n = strlen(suffix); + if (n >= cap - used) return 0; + memcpy(out + used, suffix, n); + return used + n; +} diff --git a/src/http_async.h b/src/http_async.h new file mode 100644 index 0000000..e599fe2 --- /dev/null +++ b/src/http_async.h @@ -0,0 +1,95 @@ +/* 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, '%')) { + http_error(c, 400, "Bad Request"); return; + } + if (!strcmp(path, "/") || !strncmp(path, "/?after=", 8)) { + struct catalog 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 - 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; } + 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 (!catalog_valid(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; +} diff --git a/src/main.c b/src/main.c new file mode 100644 index 0000000..8cd248c --- /dev/null +++ b/src/main.c @@ -0,0 +1,186 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define BUFSZ 2048 +#define MAX_TERMINALS 12 +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; } +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 = bind_addr; + if (bind(fd, (struct sockaddr *)&a, sizeof a) || listen(fd, 8)) { close(fd); return -1; } + return fd; +} +#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) { 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. */ +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; } + 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' || ch == 'n' || ch == 'N') { + if (ch == 'f' || ch == 'F') c->cursor[0] = 0; + struct catalog cat; + catalog_load_page(root, &cat, c->cursor); + const char *header = "\r\nPocketBox files:\r\n"; + terminal_text(c, header); + 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 + ? "[N] Next page [F] First page [Q] Quit\r\nChoice: " + : "[F] First page [Q] Quit\r\nChoice: "; + terminal_text(c, footer); + } + } +} +int main(int argc, char **argv) { + 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, 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 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; } + 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 + 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, (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}; + int ready = poll(fds, count, 1000); + if (ready < 0) { if (errno == EINTR) continue; perror("poll"); break; } + if (!ready) continue; + /* 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], root); if (clients[j].fd >= 0 && (fds[i].revents & POLLOUT)) terminal_flush(&clients[j]); } + 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) { + 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) { + 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 = fd}; + terminal_text(&clients[slot], terminal_menu); + } + } + } + } + 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; +} diff --git a/tests/smoke.py b/tests/smoke.py new file mode 100644 index 0000000..75dcd74 --- /dev/null +++ b/tests/smoke.py @@ -0,0 +1,204 @@ +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") + 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() + 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) + # 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): + 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" + 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 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" + # 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) + 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"]: + 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) + 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 + # 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") + 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() + p.wait(timeout=5) 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() 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() 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() 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()