From 54bc822878d09929cf0bdb6caa722969e5d8d8d8 Mon Sep 17 00:00:00 2001 From: Zepher Ashe Date: Fri, 17 Jul 2026 16:11:03 +0100 Subject: [PATCH 1/5] Add installable OpenWrt package edition Implement Phase N (Installable OpenWrt Edition) with: - Package infrastructure: Makefile, staging script, UCI configuration - Read-only rpcd API (firewall.visualiser) with six methods via ucode service - Frontend live mode with authenticated router access and bounded polling - Platform abstraction layer supporting offline and openwrt runtime modes - OpenWrt API client with automatic session handling and normalization - Dedicated listener setup command for management-address-specific HTTPS - SDK build validation and optional x86_64 QEMU smoke test in GitHub Actions - Comprehensive contract tests covering API, ACL, package layout, and shell scripts - Updated documentation and README with package installation instructions --- .github/workflows/openwrt.yml | 140 ++++++ .github/workflows/static.yml | 1 + .gitignore | 2 + ARCHITECTURE.md | 72 +-- README.md | 46 +- package.json | 2 +- packaging/openwrt/README.md | 56 +++ packaging/openwrt/package/Makefile | 65 +++ .../files/etc/config/firewall-visualiser | 5 + .../etc/uci-defaults/90-firewall-visualiser | 23 + .../files/usr/sbin/firewall-visualiser-setup | 109 +++++ .../share/rpcd/acl.d/firewall-visualiser.json | 18 + .../share/rpcd/ucode/firewall-visualiser.uc | 420 ++++++++++++++++++ .../openwrt/package/overlay/runtime-config.js | 19 + public/assets/css/styles.css | 28 ++ public/assets/js/app.js | 304 ++++++++++++- public/assets/js/openwrt-api.js | 366 +++++++++++++++ public/assets/js/platform.js | 268 +++++++++++ public/assets/js/runtime-config.js | 7 + public/index.html | 20 + scripts/ci/openwrt-qemu-smoke.sh | 173 ++++++++ scripts/prepare-openwrt-feed.sh | 77 ++++ tests/app.test.js | 39 ++ tests/helpers/load-app.js | 7 +- tests/openwrt/acl.test.js | 17 + tests/openwrt/fixtures/snapshot.json | 58 +++ tests/openwrt/package-layout.test.js | 77 ++++ tests/openwrt/platform.test.js | 131 ++++++ tests/openwrt/rpc-contract.test.js | 22 + tests/openwrt/setup-script.test.js | 56 +++ tests/openwrt/ucode-contract.test.js | 28 ++ 31 files changed, 2606 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/openwrt.yml create mode 100644 .gitignore create mode 100644 packaging/openwrt/README.md create mode 100644 packaging/openwrt/package/Makefile create mode 100644 packaging/openwrt/package/files/etc/config/firewall-visualiser create mode 100755 packaging/openwrt/package/files/etc/uci-defaults/90-firewall-visualiser create mode 100755 packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup create mode 100644 packaging/openwrt/package/files/usr/share/rpcd/acl.d/firewall-visualiser.json create mode 100644 packaging/openwrt/package/files/usr/share/rpcd/ucode/firewall-visualiser.uc create mode 100644 packaging/openwrt/package/overlay/runtime-config.js create mode 100644 public/assets/js/openwrt-api.js create mode 100644 public/assets/js/platform.js create mode 100644 public/assets/js/runtime-config.js create mode 100755 scripts/ci/openwrt-qemu-smoke.sh create mode 100755 scripts/prepare-openwrt-feed.sh create mode 100644 tests/openwrt/acl.test.js create mode 100644 tests/openwrt/fixtures/snapshot.json create mode 100644 tests/openwrt/package-layout.test.js create mode 100644 tests/openwrt/platform.test.js create mode 100644 tests/openwrt/rpc-contract.test.js create mode 100644 tests/openwrt/setup-script.test.js create mode 100644 tests/openwrt/ucode-contract.test.js diff --git a/.github/workflows/openwrt.yml b/.github/workflows/openwrt.yml new file mode 100644 index 0000000..c6f5f52 --- /dev/null +++ b/.github/workflows/openwrt.yml @@ -0,0 +1,140 @@ +name: Test OpenWrt package + +on: + pull_request: + paths: + - "public/**" + - "packaging/openwrt/**" + - "scripts/prepare-openwrt-feed.sh" + - "scripts/ci/openwrt-qemu-smoke.sh" + - "tests/**" + - "package.json" + - ".github/workflows/openwrt.yml" + push: + branches: ["main"] + paths: + - "public/**" + - "packaging/openwrt/**" + - "scripts/prepare-openwrt-feed.sh" + - "scripts/ci/openwrt-qemu-smoke.sh" + - "tests/**" + - "package.json" + - ".github/workflows/openwrt.yml" + schedule: + - cron: "23 4 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: openwrt-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install validation tools + run: | + sudo apt-get update + sudo apt-get install --yes jq shellcheck + + - name: Run unit and contract tests + run: npm test + + - name: Validate shell files + run: | + sh -n scripts/prepare-openwrt-feed.sh + sh -n scripts/ci/openwrt-qemu-smoke.sh + sh -n packaging/openwrt/package/files/etc/uci-defaults/90-firewall-visualiser + sh -n packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup + shellcheck -s sh scripts/prepare-openwrt-feed.sh + shellcheck -s sh scripts/ci/openwrt-qemu-smoke.sh + shellcheck -s sh packaging/openwrt/package/files/etc/uci-defaults/90-firewall-visualiser + shellcheck -s sh packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup + + - name: Validate ACL JSON + run: jq --exit-status . packaging/openwrt/package/files/usr/share/rpcd/acl.d/firewall-visualiser.json >/dev/null + + - name: Stage OpenWrt feed + run: ./scripts/prepare-openwrt-feed.sh + + sdk-build: + needs: validate + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + sdk: + - x86_64-25.12.4 + - x86_64-24.10.7 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Stage OpenWrt feed + run: ./scripts/prepare-openwrt-feed.sh + + - name: Create artifact directory + run: mkdir -p artifacts + + - name: Build with official OpenWrt SDK container + uses: openwrt/gh-action-sdk@797d0e3d0eb13b355c3447f60d0179d4b43089e2 + env: + ARCH: ${{ matrix.sdk }} + FEED_DIR: ${{ github.workspace }}/.build/openwrt-feed + FEEDNAME: firewall-visualiser + PACKAGES: openwrt-firewall-visualiser + ARTIFACTS_DIR: ${{ github.workspace }}/artifacts + BUILD_LOG: 1 + V: s + + - name: Upload packages and build logs + uses: actions/upload-artifact@v4 + with: + name: openwrt-firewall-visualiser-${{ matrix.sdk }} + if-no-files-found: error + path: artifacts/ + + qemu-smoke: + if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule' + needs: sdk-build + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Download x86_64 IPK artifact + uses: actions/download-artifact@v4 + with: + pattern: openwrt-firewall-visualiser-x86_64-24.10.7 + path: runtime-artifacts + merge-multiple: true + + - name: Install smoke-test tools + run: | + sudo apt-get update + sudo apt-get install --yes curl jq openssh-client qemu-system-x86 + + - name: Run OpenWrt QEMU smoke test + run: ./scripts/ci/openwrt-qemu-smoke.sh runtime-artifacts + + - name: Upload runtime diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: openwrt-firewall-visualiser-qemu-diagnostics + if-no-files-found: warn + path: runtime-artifacts/qemu-diagnostics/ + diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 53cf7e3..7e0456c 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -30,6 +30,7 @@ jobs: run: | sh -n scripts/openwrt_export_hosts.sh sh -n scripts/openwrt_export_subnet_mappings.sh + sh -n scripts/prepare-openwrt-feed.sh # Single deploy job since we're just deploying deploy: diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4710f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.build/ + diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e434eba..1dcf177 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -696,10 +696,19 @@ Implemented details: ## Phase N: Installable OpenWrt Edition -Status: planned. +Status: implemented; SDK and QEMU release verification is pending the first successful GitHub Actions run. Objective: add an OpenWrt-native, installable edition without replacing or forking the existing static site under `public/`. +Implemented details: + +- `public/` remains the canonical frontend and defaults to offline mode. +- `platform.js` and `openwrt-api.js` provide authenticated, read-only live mode without persisting credentials or session tokens. +- `packaging/openwrt/package/` contains the package definition, rpcd ucode service, ACL, UCI defaults, and listener setup command. +- `scripts/prepare-openwrt-feed.sh` creates a reproducible package feed under `.build/` and overlays installed-mode runtime configuration. +- `tests/openwrt/` covers the API contract, ACL, package layout, runtime modes, setup command, and fixed backend provider surface. +- `.github/workflows/openwrt.yml` validates and builds both APK and IPK targets, with a scheduled/manual x86_64 QEMU runtime smoke test. + The installable edition must remain an additive distribution target: - `public/` remains the canonical frontend and continues to work as a local file, on GitHub Pages, or on any static web server. @@ -750,7 +759,7 @@ Responsibilities: A separate Node.js, Python, PHP-FPM, or database service is not required. -### Planned Repository Layout +### Repository Layout ```text openwrt-firewall-visualiser/ @@ -781,8 +790,8 @@ openwrt-firewall-visualiser/ | | | | `-- firewall-visualiser.json | | | `-- ucode/ | | | `-- firewall-visualiser.uc -| | `-- overlay/ -| | `-- runtime-config.js # Installed-mode override +| |-- overlay/ +| | `-- runtime-config.js # Installed-mode override | `-- README.md # Package development notes |-- scripts/ | |-- prepare-openwrt-feed.sh # Creates a generated SDK feed @@ -795,10 +804,10 @@ openwrt-firewall-visualiser/ | |-- package-layout.test.js | |-- platform.test.js | |-- rpc-contract.test.js +| |-- setup-script.test.js +| |-- ucode-contract.test.js | `-- fixtures/ -| |-- snapshot.json -| |-- firewall.json -| `-- devices.json +| `-- snapshot.json `-- .github/workflows/ |-- static.yml `-- openwrt.yml # Package and optional runtime tests @@ -808,7 +817,7 @@ Generated package payloads must be written under `.build/` and excluded from Git ### Package Staging Model -`prepare-openwrt-feed.sh` should build a disposable local feed such as: +`prepare-openwrt-feed.sh` builds a disposable local feed such as: ```text .build/openwrt-feed/ @@ -822,7 +831,7 @@ Generated package payloads must be written under `.build/` and excluded from Git `-- assets/... ``` -The staging script should: +The staging script: 1. Remove the previous generated feed directory. 2. Copy `packaging/openwrt/package/Makefile` and the package-owned backend/configuration files. @@ -835,7 +844,7 @@ This avoids fragile package Makefile references outside the feed and guarantees ### Package Definition -The first package should be named: +The package is named: ```text openwrt-firewall-visualiser @@ -843,7 +852,7 @@ openwrt-firewall-visualiser It contains architecture-independent HTML, CSS, JavaScript, JSON, shell, and ucode files, so the package should use `PKGARCH:=all` unless compiled components are introduced later. -Expected dependencies: +Package dependencies: ```makefile DEPENDS:= \ @@ -1116,7 +1125,7 @@ Security-focused assertions should include: ### OpenWrt SDK Build Workflow -Add `.github/workflows/openwrt.yml` after the package directory and staging script exist. +`.github/workflows/openwrt.yml` implements the package validation and build gate. The mandatory CI gate should: @@ -1128,7 +1137,7 @@ The mandatory CI gate should: 6. Upload package artefacts and build logs. 7. Test at least one current APK-based release and one maintained IPK-based release while both are supported. -Proposed workflow: +Implemented workflow outline: ```yaml name: Test OpenWrt package @@ -1165,10 +1174,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@v4 with: node-version: 22 @@ -1204,13 +1213,12 @@ jobs: fail-fast: false matrix: sdk: - - x86_64-25.12.5 + - x86_64-25.12.4 - x86_64-24.10.7 - - mips_24kc-25.12.5 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Stage OpenWrt feed run: ./scripts/prepare-openwrt-feed.sh @@ -1219,7 +1227,7 @@ jobs: run: mkdir -p artifacts - name: Build with OpenWrt SDK - uses: openwrt/gh-action-sdk@v11 + uses: openwrt/gh-action-sdk@797d0e3d0eb13b355c3447f60d0179d4b43089e2 env: ARCH: ${{ matrix.sdk }} FEED_DIR: ${{ github.workspace }}/.build/openwrt-feed @@ -1230,7 +1238,7 @@ jobs: V: s - name: Upload packages and logs - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v4 with: name: openwrt-firewall-visualiser-${{ matrix.sdk }} if-no-files-found: error @@ -1244,16 +1252,16 @@ jobs: The release numbers in the matrix are intentionally pinned. They must be reviewed when OpenWrt support status changes. Third-party and official actions should be pinned to immutable commit SHAs in the implemented workflow where repository policy requires stronger supply-chain controls. -### Optional OpenWrt Runtime Smoke Test +### OpenWrt Runtime Smoke Test An SDK build proves that the package description and dependencies are valid, but it does not prove that rpcd registers the object or that uHTTPd can serve the installed application. -Add an x86_64 QEMU smoke test once the package builds reliably. It may initially run on `workflow_dispatch` and a weekly schedule before becoming a pull-request gate. +The x86_64 QEMU smoke test initially runs on `workflow_dispatch` and a weekly schedule. It can become a pull-request gate after it has proved stable. -`scripts/ci/openwrt-qemu-smoke.sh` should: +`scripts/ci/openwrt-qemu-smoke.sh`: -1. Download a pinned official OpenWrt x86_64 image and its checksum file. -2. Verify the image checksum before booting it. +1. Download a pinned official OpenWrt x86_64 image. +2. Verify it against the release checksum pinned beside the image URL before booting it. 3. Start QEMU with an isolated user-mode network and forwarded SSH/HTTPS ports. 4. Wait for SSH with a fixed timeout. 5. Install the built package and dependencies using the package manager provided by that release. @@ -1285,6 +1293,8 @@ The QEMU job should use the x86_64 package artefact from the SDK matrix and shou #### Phase N1: Package Skeleton +Status: implemented. + - Add package staging, Makefile, file layout, and deterministic manifest. - Install the unmodified static application under `/www/openwrt-firewall-visualiser/`. - Add SDK build CI for supported OpenWrt releases. @@ -1292,6 +1302,8 @@ The QEMU job should use the x86_64 package artefact from the SDK matrix and shou #### Phase N2: Read-Only rpcd API +Status: implemented. + - Add `firewall.visualiser` with `capabilities`, `firewall`, `devices`, `interfaces`, and `health`. - Add the read-only ACL. - Add contract fixtures and backend parser tests. @@ -1299,25 +1311,31 @@ The QEMU job should use the x86_64 package artefact from the SDK matrix and shou #### Phase N3: Live Frontend Adapter +Status: implemented. + - Add authenticated OpenWrt mode and manual login/session handling. - Add snapshot loading, bounded polling, error states, and offline fallback. - Update the graph incrementally rather than replacing the Cytoscape instance for every refresh. #### Phase N4: Dedicated Listener Setup +Status: implemented. + - Add the explicit setup command for management address and port. - Add idempotence and wildcard-address refusal tests. - Document the management-zone input rule without applying it automatically. #### Phase N5: Runtime CI +Status: implemented as a scheduled/manual gate; promotion to pull-request gating remains pending runtime stability. + - Add the pinned x86_64 QEMU smoke test. - Verify installation, authentication, RPC registration, static serving, package removal, and resource use. - Promote the smoke job from scheduled/manual to a required pull-request check after it is stable. ### Phase N Acceptance Criteria -The phase is complete when: +Implementation is complete. Release readiness additionally requires the SDK matrix and QEMU smoke job to pass in GitHub Actions: - The GitHub Pages/static distribution behaves as it did before the package work. - `public/` is the only maintained frontend source tree. @@ -1381,7 +1399,7 @@ Use this workflow after changes: 4. Add, edit, and remove a device mapping. 5. Test a specific source-to-destination path. 6. Try each graph layout and filter. -7. Upload a local firewall config and verify that no data leaves the browser except the Cytoscape CDN request. +7. Upload a local firewall config and verify that no data leaves the browser. ## Last Updated diff --git a/README.md b/README.md index 5d4ac5c..df432c9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # OpenWrt Firewall Relationship Visualiser -A single-page browser application for visualising `/etc/config/firewall` relationships. Paste or upload an OpenWrt firewall configuration, map devices to zones, and visually inspect zone-to-zone and device-to-device connectivity. +A single-page browser application for visualising `/etc/config/firewall` relationships. It works as a local static application and as an installable, read-only OpenWrt package with authenticated live data. --- ## Features -- **No Backend Required** — All parsing and analysis happens in your browser; no server, database, or build step needed +- **Offline Mode** — All parsing and analysis can run in your browser with no backend, database, or build step +- **Installable OpenWrt Mode** — An optional package serves live, read-only router state through authenticated rpcd methods - **Local-First Privacy** — Firewall configs and device mappings stay on your computer and are saved to `localStorage` - **Visual Relationship Mapping** — Interactive Cytoscape.js graph showing zone and device connectivity - **Zone Analysis** — View zone policies, forwardings, rules, and connectivity matrix @@ -85,8 +86,8 @@ The `scripts/openwrt_export_subnet_mappings.sh` script generates UCI outputs whi | Markup | HTML5 with semantic structure | | Styling | Custom CSS with dark theme and CSS variables | | Logic | Vanilla JavaScript (no frameworks or build tools) | -| Graph Rendering | Cytoscape.js v3.30.4 (loaded from CDN) | -| Deployment | Static files in `public/` directory | +| Graph Rendering | Locally bundled Cytoscape.js v3.30.4 | +| Deployment | Static `public/` files or generated OpenWrt package | --- @@ -100,12 +101,36 @@ npm test GitHub Actions runs the same test command before deploying GitHub Pages. +### OpenWrt Package + +Generate the disposable package feed from the current frontend: + +```bash +./scripts/prepare-openwrt-feed.sh +``` + +The result is written to `.build/openwrt-feed/openwrt-firewall-visualiser/` for use with an OpenWrt SDK. The package installs the application at `/www/openwrt-firewall-visualiser/` and registers the read-only `firewall.visualiser` rpcd object. + +Installation does not create a firewall rule or a dedicated listener. The existing uHTTPd listener serves the app at: + +```text +https://ROUTER/openwrt-firewall-visualiser/ +``` + +An optional management-address-specific HTTPS listener can be configured after installation: + +```bash +firewall-visualiser-setup 192.168.1.1 8443 +``` + +See [`packaging/openwrt/README.md`](packaging/openwrt/README.md) for package and listener details. + --- ## Limitations - **Simplified firewall model** — Does not simulate every fw3/fw4, nftables, iptables, NAT, conntrack, or bridge behavior -- **Manual device mapping** — Device context depends on user input (not auto-inferred) +- **Device attribution** — Live mode combines bounded DHCP and ARP data; unresolved zones still require subnet mappings or manual input - **Parser scope** — Does not process include files, generated fragments, or complex quoting - **Limited protocol/port matching** — Handles common patterns but not all OpenWrt match expressions - **Per-browser storage** — `localStorage` is browser/profile-specific; export sessions to share @@ -114,11 +139,12 @@ GitHub Actions runs the same test command before deploying GitHub Pages. ## Security & Privacy -✅ **No backend** — All data stays in your browser -✅ **No external requests** — Cytoscape is run locally is for graph rendering -✅ **Safe parsing** — User-controlled strings are escaped before HTML insertion -✅ **Static files** — Can be hosted on any static web server with no special permissions -✅ **Local persistence** — Uses browser `localStorage` only +- ✅ **Offline privacy** — Offline mode keeps imported firewall and device data in your browser +- ✅ **Authenticated live mode** — Installed mode uses same-origin HTTPS and an in-memory ubus session token +- ✅ **No external assets** — Cytoscape is bundled locally for graph rendering +- ✅ **Safe parsing** — User-controlled strings are escaped before HTML insertion +- ✅ **Static files** — Can be hosted on any static web server with no special permissions +- ✅ **Scoped persistence** — Offline state uses browser `localStorage`; live credentials and session tokens are not persisted --- diff --git a/package.json b/package.json index 3ad5128..8d42913 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,6 @@ "version": "0.1.0", "private": true, "scripts": { - "test": "node --check public/assets/js/app.js && node --test tests/*.test.js" + "test": "node --check public/assets/js/app.js && node --check public/assets/js/platform.js && node --check public/assets/js/openwrt-api.js && node --test tests/*.test.js tests/openwrt/*.test.js" } } diff --git a/packaging/openwrt/README.md b/packaging/openwrt/README.md new file mode 100644 index 0000000..d8ff71d --- /dev/null +++ b/packaging/openwrt/README.md @@ -0,0 +1,56 @@ +# OpenWrt Package + +This directory contains router-specific package files. The browser frontend is +not duplicated here: `scripts/prepare-openwrt-feed.sh` copies the canonical +`public/` tree into `.build/openwrt-feed/` and overlays installed-mode runtime +configuration there. + +## Stage The Feed + +```sh +./scripts/prepare-openwrt-feed.sh +``` + +The generated feed package is written to: + +```text +.build/openwrt-feed/openwrt-firewall-visualiser/ +``` + +The staging command rejects symlinks, executable frontend assets, oversized +files, and common private-key or credential files. It also writes a sorted +SHA-256 manifest beside the package directory. + +## Router Behaviour + +The package installs the app at `/www/openwrt-firewall-visualiser/` and exposes +the authenticated, read-only `firewall.visualiser` rpcd object. It does not add +a firewall rule or a dedicated listener automatically. + +The normal root rpcd login can use the object. A restricted rpcd login must be +granted the `firewall-visualiser` read ACL group explicitly in `/etc/config/rpcd`. + +The normal router uHTTPd listener serves the app at: + +```text +https://ROUTER/openwrt-firewall-visualiser/ +``` + +To create an optional dedicated HTTPS listener, select an explicit management +address and port: + +```sh +firewall-visualiser-setup 192.168.1.1 8443 +``` + +That listener serves the application at `https://192.168.1.1:8443/`. + +The command refuses wildcard addresses by default. Any firewall input rule for +the selected port must be created separately and restricted to a management or +VPN zone. + +To remove the dedicated listener: + +```sh +firewall-visualiser-setup --disable +``` diff --git a/packaging/openwrt/package/Makefile b/packaging/openwrt/package/Makefile new file mode 100644 index 0000000..596fcbf --- /dev/null +++ b/packaging/openwrt/package/Makefile @@ -0,0 +1,65 @@ +include $(TOPDIR)/rules.mk + +PKG_NAME:=openwrt-firewall-visualiser +PKG_VERSION:=0.1.0 +PKG_RELEASE:=1 +PKGARCH:=all + +include $(INCLUDE_DIR)/package.mk + +define Package/openwrt-firewall-visualiser + SECTION:=net + CATEGORY:=Network + TITLE:=Read-only OpenWrt firewall relationship visualiser + URL:=https://github.com/safesploitOrg/openwrt-firewall-visualiser + DEPENDS:=+uhttpd +uhttpd-mod-ubus +rpcd +rpcd-mod-ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-uci +endef + +define Package/openwrt-firewall-visualiser/description + Installs the static firewall visualiser and a narrow, authenticated rpcd API. +endef + +define Package/openwrt-firewall-visualiser/conffiles +/etc/config/firewall-visualiser +endef + +define Build/Compile +endef + +define Package/openwrt-firewall-visualiser/install + $(INSTALL_DIR) $(1)/www/openwrt-firewall-visualiser + $(CP) ./files/www/openwrt-firewall-visualiser/. $(1)/www/openwrt-firewall-visualiser/ + $(INSTALL_DIR) $(1)/etc/config + $(INSTALL_CONF) ./files/etc/config/firewall-visualiser $(1)/etc/config/firewall-visualiser + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_BIN) ./files/etc/uci-defaults/90-firewall-visualiser $(1)/etc/uci-defaults/90-firewall-visualiser + $(INSTALL_DIR) $(1)/usr/sbin + $(INSTALL_BIN) ./files/usr/sbin/firewall-visualiser-setup $(1)/usr/sbin/firewall-visualiser-setup + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./files/usr/share/rpcd/acl.d/firewall-visualiser.json $(1)/usr/share/rpcd/acl.d/firewall-visualiser.json + $(INSTALL_DIR) $(1)/usr/share/rpcd/ucode + $(INSTALL_DATA) ./files/usr/share/rpcd/ucode/firewall-visualiser.uc $(1)/usr/share/rpcd/ucode/firewall-visualiser.uc +endef + +define Package/openwrt-firewall-visualiser/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + [ ! -x /etc/uci-defaults/90-firewall-visualiser ] || { + /etc/uci-defaults/90-firewall-visualiser && rm -f /etc/uci-defaults/90-firewall-visualiser + } + /etc/init.d/rpcd reload 2>/dev/null || true + /etc/init.d/uhttpd reload 2>/dev/null || true +} +exit 0 +endef + +define Package/openwrt-firewall-visualiser/postrm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { + /etc/init.d/rpcd reload 2>/dev/null || true + /etc/init.d/uhttpd reload 2>/dev/null || true +} +exit 0 +endef + +$(eval $(call BuildPackage,openwrt-firewall-visualiser)) diff --git a/packaging/openwrt/package/files/etc/config/firewall-visualiser b/packaging/openwrt/package/files/etc/config/firewall-visualiser new file mode 100644 index 0000000..5e92c9b --- /dev/null +++ b/packaging/openwrt/package/files/etc/config/firewall-visualiser @@ -0,0 +1,5 @@ +config main 'main' + option enabled '1' + option max_devices '250' + option max_neighbours '500' + diff --git a/packaging/openwrt/package/files/etc/uci-defaults/90-firewall-visualiser b/packaging/openwrt/package/files/etc/uci-defaults/90-firewall-visualiser new file mode 100755 index 0000000..12fe41e --- /dev/null +++ b/packaging/openwrt/package/files/etc/uci-defaults/90-firewall-visualiser @@ -0,0 +1,23 @@ +#!/bin/sh + +changed=0 + +if uci -q get uhttpd.main >/dev/null 2>&1; then + if ! uci -q get uhttpd.main.ubus_prefix >/dev/null 2>&1; then + uci set uhttpd.main.ubus_prefix='/ubus' + changed=1 + fi + + if ! uci -q get uhttpd.main.no_ubusauth >/dev/null 2>&1; then + uci set uhttpd.main.no_ubusauth='0' + changed=1 + fi +fi + +if [ "$changed" -eq 1 ]; then + uci commit uhttpd + /etc/init.d/uhttpd reload 2>/dev/null || true +fi + +exit 0 + diff --git a/packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup b/packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup new file mode 100755 index 0000000..188370a --- /dev/null +++ b/packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup @@ -0,0 +1,109 @@ +#!/bin/sh + +set -eu + +UCI_BIN="${FIREWALL_VISUALISER_UCI_BIN:-uci}" +UHTTPD_INIT="${FIREWALL_VISUALISER_UHTTPD_INIT:-/etc/init.d/uhttpd}" +SECTION="firewall_visualiser" + +usage() { + cat <<'EOF' +Usage: + firewall-visualiser-setup ADDRESS PORT [--allow-wildcard] + firewall-visualiser-setup --disable + +Creates a dedicated HTTPS-only uHTTPd listener. No firewall rule is created. +ADDRESS must be an explicit management address unless --allow-wildcard is used. +EOF +} + +reload_uhttpd() { + if [ -x "$UHTTPD_INIT" ]; then + "$UHTTPD_INIT" reload + fi +} + +if [ "${1:-}" = "--disable" ]; then + [ "$#" -eq 1 ] || { + usage >&2 + exit 2 + } + "$UCI_BIN" -q delete "uhttpd.$SECTION" 2>/dev/null || true + "$UCI_BIN" commit uhttpd + reload_uhttpd + exit 0 +fi + +[ "$#" -ge 2 ] && [ "$#" -le 3 ] || { + usage >&2 + exit 2 +} + +address=$1 +port=$2 +allow_wildcard=0 + +if [ "$#" -eq 3 ]; then + [ "$3" = "--allow-wildcard" ] || { + usage >&2 + exit 2 + } + allow_wildcard=1 +fi + +case "$address" in + \*|\[::\]) ;; + ""|*[!0-9A-Za-z:._%-]*) + echo "Invalid listener address: $address" >&2 + exit 2 + ;; +esac + +case "$port" in + ""|*[!0-9]*) + echo "Port must be an integer from 1 to 65535." >&2 + exit 2 + ;; +esac + +if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "Port must be an integer from 1 to 65535." >&2 + exit 2 +fi + +case "$address" in + 0.0.0.0|::|\[::\]|\*) + if [ "$allow_wildcard" -ne 1 ]; then + echo "Wildcard listeners are refused. Select a management address explicitly." >&2 + exit 2 + fi + ;; +esac + +case "$address" in + \[*\] ) listen_address="$address:$port" ;; + *:* ) listen_address="[$address]:$port" ;; + * ) listen_address="$address:$port" ;; +esac + +"$UCI_BIN" -q delete "uhttpd.$SECTION" 2>/dev/null || true +"$UCI_BIN" set "uhttpd.$SECTION=uhttpd" +"$UCI_BIN" set "uhttpd.$SECTION.enabled=1" +"$UCI_BIN" set "uhttpd.$SECTION.home=/www/openwrt-firewall-visualiser" +"$UCI_BIN" set "uhttpd.$SECTION.index_page=index.html" +"$UCI_BIN" add_list "uhttpd.$SECTION.listen_https=$listen_address" +"$UCI_BIN" set "uhttpd.$SECTION.cert=/etc/uhttpd.crt" +"$UCI_BIN" set "uhttpd.$SECTION.key=/etc/uhttpd.key" +"$UCI_BIN" set "uhttpd.$SECTION.ubus_prefix=/ubus" +"$UCI_BIN" set "uhttpd.$SECTION.no_ubusauth=0" +"$UCI_BIN" set "uhttpd.$SECTION.no_dirlists=1" +"$UCI_BIN" set "uhttpd.$SECTION.no_symlinks=1" +"$UCI_BIN" set "uhttpd.$SECTION.max_connections=10" +"$UCI_BIN" set "uhttpd.$SECTION.max_requests=3" +"$UCI_BIN" set "uhttpd.$SECTION.script_timeout=15" +"$UCI_BIN" set "uhttpd.$SECTION.network_timeout=10" +"$UCI_BIN" commit uhttpd +reload_uhttpd + +echo "HTTPS listener configured at $listen_address" +echo "No firewall rule was added. Permit access only from a management or VPN zone if required." diff --git a/packaging/openwrt/package/files/usr/share/rpcd/acl.d/firewall-visualiser.json b/packaging/openwrt/package/files/usr/share/rpcd/acl.d/firewall-visualiser.json new file mode 100644 index 0000000..11a2d95 --- /dev/null +++ b/packaging/openwrt/package/files/usr/share/rpcd/acl.d/firewall-visualiser.json @@ -0,0 +1,18 @@ +{ + "firewall-visualiser": { + "description": "Read OpenWrt firewall visualisation state", + "read": { + "ubus": { + "firewall.visualiser": [ + "capabilities", + "snapshot", + "firewall", + "devices", + "interfaces", + "health" + ] + } + } + } +} + diff --git a/packaging/openwrt/package/files/usr/share/rpcd/ucode/firewall-visualiser.uc b/packaging/openwrt/package/files/usr/share/rpcd/ucode/firewall-visualiser.uc new file mode 100644 index 0000000..014a068 --- /dev/null +++ b/packaging/openwrt/package/files/usr/share/rpcd/ucode/firewall-visualiser.uc @@ -0,0 +1,420 @@ +'use strict'; + +import { readfile, stat } from 'fs'; +import { cursor } from 'uci'; +import { connect } from 'ubus'; + +const API_VERSION = 1; +const DEFAULT_MAX_DEVICES = 250; +const DEFAULT_MAX_NEIGHBOURS = 500; +const uci = cursor(); +const ubus = connect(); + +function bounded_config(name, fallback, maximum) { + let value = +uci.get('firewall-visualiser', 'main', name); + + if (value < 1) + value = fallback; + + return value > maximum ? maximum : value; +} + +function limits(truncated = false) { + return { + max_devices: bounded_config('max_devices', DEFAULT_MAX_DEVICES, 1000), + max_neighbours: bounded_config('max_neighbours', DEFAULT_MAX_NEIGHBOURS, 2000), + truncated: truncated + }; +} + +function strings(value) { + let result = []; + + if (type(value) == 'array') { + for (let item in value) { + item = trim(`${item ?? ''}`); + if (item) + push(result, item); + } + } + else if (value != null) { + for (let item in split(trim(`${value}`), /\s+/)) { + if (item) + push(result, item); + } + } + + return result; +} + +function revision() { + let info = stat('/etc/config/firewall'); + return sprintf('file:%s:%s', info?.mtime ?? 0, info?.size ?? 0); +} + +function firewall_model() { + let zones = {}; + let forwardings = []; + let rules = []; + let rule_index = 0; + let truncated = false; + let supported_rule_fields = { + name: true, + src: true, + dest: true, + src_ip: true, + dest_ip: true, + proto: true, + dest_port: true, + target: true, + family: true, + enabled: true + }; + + uci.foreach('firewall', 'zone', (section) => { + if (length(zones) >= 128) { + truncated = true; + return; + } + + let name = trim(`${section.name ?? section['.name'] ?? ''}`); + + if (!name) + return; + + zones[name] = { + name: name, + input: uc(`${section.input ?? 'REJECT'}`), + output: uc(`${section.output ?? 'REJECT'}`), + forward: uc(`${section.forward ?? 'REJECT'}`), + networks: strings(section.network) + }; + }); + + uci.foreach('firewall', 'forwarding', (section) => { + if (length(forwardings) >= 256) { + truncated = true; + return; + } + + let src = trim(`${section.src ?? ''}`); + let dest = trim(`${section.dest ?? ''}`); + + if (src && dest) + push(forwardings, { src: src, dest: dest }); + }); + + uci.foreach('firewall', 'rule', (section) => { + let unsupported = []; + + if (length(rules) >= 1000) { + truncated = true; + return; + } + + for (let key in section) { + if (length(unsupported) < 32 && substr(key, 0, 1) != '.' && !supported_rule_fields[key]) + push(unsupported, key); + } + + rule_index++; + push(rules, { + index: rule_index, + name: trim(`${section.name ?? section['.name'] ?? 'Unnamed rule'}`), + src: trim(`${section.src ?? ''}`), + dest: trim(`${section.dest ?? ''}`), + src_ip: trim(`${section.src_ip ?? ''}`), + dest_ip: trim(`${section.dest_ip ?? ''}`), + proto: trim(`${section.proto ?? ''}`), + dest_port: trim(`${section.dest_port ?? ''}`), + target: uc(`${section.target ?? ''}`), + unsupported_fields: unsupported + }); + }); + + return { + zones: zones, + forwardings: forwardings, + rules: rules, + revision: revision(), + truncated: truncated + }; +} + +function netmask_prefix(netmask) { + let values = { '255': 8, '254': 7, '252': 6, '248': 5, '240': 4, '224': 3, '192': 2, '128': 1, '0': 0 }; + let octets = split(`${netmask ?? ''}`, '.'); + let prefix = 0; + let zero_seen = false; + + if (length(octets) != 4) + return null; + + for (let octet in octets) { + if (values[octet] == null || (zero_seen && values[octet] != 0)) + return null; + + prefix += values[octet]; + if (values[octet] != 8) + zero_seen = true; + } + + return prefix; +} + +function subnet_mappings(model) { + let result = []; + let seen = {}; + + for (let zone_name, zone in model.zones) { + for (let network in zone.networks) { + if (length(result) >= 128) + return result; + + let address = strings(uci.get('network', network, 'ipaddr'))[0]; + let netmask = uci.get('network', network, 'netmask') ?? '255.255.255.0'; + + if (!address) + continue; + + let cidr; + + if (match(address, /^\d{1,3}(?:\.\d{1,3}){3}\/\d{1,2}$/)) { + let cidr_parts = split(address, '/'); + if (!is_ipv4(cidr_parts[0]) || +cidr_parts[1] > 32) + continue; + cidr = address; + } + else { + let prefix = netmask_prefix(netmask); + if (!is_ipv4(address) || prefix == null) + continue; + cidr = `${address}/${prefix}`; + } + let mapping = `${cidr} ${zone_name}`; + + if (!seen[mapping]) { + seen[mapping] = true; + push(result, mapping); + } + } + } + + return result; +} + +function is_ipv4(value) { + let octets = split(`${value ?? ''}`, '.'); + + if (length(octets) != 4) + return false; + + for (let octet in octets) { + if (!match(octet, /^\d{1,3}$/) || +octet > 255) + return false; + } + + return true; +} + +function device_data() { + let max_devices = bounded_config('max_devices', DEFAULT_MAX_DEVICES, 1000); + let max_neighbours = bounded_config('max_neighbours', DEFAULT_MAX_NEIGHBOURS, 2000); + let by_ip = {}; + let order = []; + let truncated = false; + let errors = []; + let leases = readfile('/tmp/dhcp.leases'); + + if (leases != null) { + for (let line in split(leases, '\n')) { + let fields = split(trim(line), /\s+/); + + if (length(fields) < 4 || !is_ipv4(fields[2])) + continue; + + let ip = fields[2]; + if (!by_ip[ip] && length(order) >= max_devices) { + truncated = true; + break; + } + + if (!by_ip[ip]) + push(order, ip); + + by_ip[ip] = { + name: fields[3] == '*' ? ip : substr(fields[3], 0, 128), + ip: ip, + zone: '', + mac: substr(fields[1], 0, 32), + source: 'dhcp', + sources: [ 'dhcp' ] + }; + } + } + else { + push(errors, 'DHCP lease data is unavailable.'); + } + + let neighbours = readfile('/proc/net/arp'); + let neighbour_count = 0; + + if (neighbours != null) { + for (let line in split(neighbours, '\n')) { + let fields = split(trim(line), /\s+/); + + if (length(fields) < 6 || !is_ipv4(fields[0])) + continue; + + if (neighbour_count++ >= max_neighbours) { + truncated = true; + break; + } + + let ip = fields[0]; + if (by_ip[ip]) { + if (!by_ip[ip].mac) + by_ip[ip].mac = substr(fields[3], 0, 32); + push(by_ip[ip].sources, 'arp'); + } + else { + if (length(order) >= max_devices) { + truncated = true; + continue; + } + + push(order, ip); + by_ip[ip] = { + name: ip, + ip: ip, + zone: '', + mac: substr(fields[3], 0, 32), + source: 'arp', + sources: [ 'arp' ], + device: substr(fields[5], 0, 64) + }; + } + } + } + else { + push(errors, 'ARP neighbour data is unavailable.'); + } + + let devices = []; + for (let ip in order) { + if (length(devices) >= max_devices) { + truncated = true; + break; + } + push(devices, by_ip[ip]); + } + + return { devices: devices, truncated: truncated, errors: errors }; +} + +function interface_data() { + let result = []; + let errors = []; + let truncated = false; + let reply; + + try { + reply = ubus.call('network.interface', 'dump', {}); + } + catch (exception) { + push(errors, 'network.interface dump failed.'); + } + + for (let item in reply?.interface ?? []) { + if (length(result) >= 128) { + truncated = true; + break; + } + + let addresses = []; + for (let address in item['ipv4-address'] ?? []) { + if (address.address && length(addresses) < 32) + push(addresses, `${address.address}/${address.mask ?? 32}`); + } + + push(result, { + name: substr(`${item.interface ?? ''}`, 0, 64), + device: substr(`${item.l3_device ?? item.device ?? ''}`, 0, 64), + up: item.up == true, + available: item.available != false, + pending: item.pending == true, + ipv4: addresses + }); + } + + return { interfaces: result, errors: errors, truncated: truncated }; +} + +function memory_available() { + for (let line in split(readfile('/proc/meminfo') ?? '', '\n')) { + let found = match(line, /^MemAvailable:\s+(\d+)/); + if (found) + return +found[1]; + } + return 0; +} + +function uptime() { + return +(split(trim(readfile('/proc/uptime') ?? '0'), /\s+/)[0] ?? 0); +} + +function capabilities() { + return { + api_version: API_VERSION, + read_only: true, + providers: { firewall_uci: true, dhcp_leases: true, arp: true, network_ubus: true }, + features: { live_snapshot: true, dedicated_listener: true, write_access: false }, + limits: limits(false) + }; +} + +function snapshot() { + let firewall = firewall_model(); + let device_result = device_data(); + let interface_result = interface_data(); + let provider_errors = []; + + for (let message in device_result.errors) + push(provider_errors, message); + for (let message in interface_result.errors) + push(provider_errors, message); + + return { + api_version: API_VERSION, + generated_at: time(), + revision: firewall.revision, + limits: limits(device_result.truncated || interface_result.truncated || firewall.truncated), + firewall: firewall, + devices: device_result.devices, + interfaces: interface_result.interfaces, + subnet_mappings: subnet_mappings(firewall), + provider_errors: provider_errors + }; +} + +return { + 'firewall.visualiser': { + capabilities: { call: function() { return capabilities(); } }, + snapshot: { call: function() { return snapshot(); } }, + firewall: { call: function() { + let firewall = firewall_model(); + return { api_version: API_VERSION, generated_at: time(), revision: firewall.revision, firewall: firewall }; + } }, + devices: { call: function() { + let result = device_data(); + return { api_version: API_VERSION, generated_at: time(), revision: revision(), limits: limits(result.truncated), devices: result.devices, provider_errors: result.errors }; + } }, + interfaces: { call: function() { + let result = interface_data(); + return { api_version: API_VERSION, generated_at: time(), revision: revision(), interfaces: result.interfaces, provider_errors: result.errors }; + } }, + health: { call: function() { + return { api_version: API_VERSION, generated_at: time(), uptime: uptime(), memory_available_kb: memory_available(), provider_errors: [] }; + } } + } +}; diff --git a/packaging/openwrt/package/overlay/runtime-config.js b/packaging/openwrt/package/overlay/runtime-config.js new file mode 100644 index 0000000..4e5885a --- /dev/null +++ b/packaging/openwrt/package/overlay/runtime-config.js @@ -0,0 +1,19 @@ +(function configureFirewallVisualiserRuntime(global) { + "use strict"; + + global.FIREWALL_VISUALISER_RUNTIME = { + mode: "openwrt", + rpcUrl: "/ubus", + rpcObject: "firewall.visualiser", + refresh: { + firewallMs: 30000, + devicesMs: 10000, + interfacesMs: 5000 + }, + limits: { + maxDevices: 250, + maxNeighbours: 500 + } + }; +})(globalThis); + diff --git a/public/assets/css/styles.css b/public/assets/css/styles.css index 92a1203..4666a62 100644 --- a/public/assets/css/styles.css +++ b/public/assets/css/styles.css @@ -167,6 +167,30 @@ input { min-width: 5.25rem; } +.live-router-section { + margin-top: 0; + margin-bottom: 1rem; + padding: 0 0 1rem; + border-top: 0; + border-bottom: 1px solid rgba(34, 197, 94, 0.55); +} + +.live-router-login { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr) auto; + gap: 0.5rem; +} + +.live-router-section.connected #openwrtLiveStatus { + color: var(--good); + border-color: rgba(34, 197, 94, 0.7); +} + +.live-router-section.error #openwrtLiveStatus { + color: var(--bad); + border-color: rgba(239, 68, 68, 0.7); +} + .help-box { margin: 0.75rem 0; padding: 0.75rem; @@ -564,6 +588,10 @@ pre { grid-template-columns: 1fr; } + .live-router-login { + grid-template-columns: 1fr; + } + #cy { height: 520px; } diff --git a/public/assets/js/app.js b/public/assets/js/app.js index 73715bf..3ad6917 100644 --- a/public/assets/js/app.js +++ b/public/assets/js/app.js @@ -102,6 +102,8 @@ let subnetImportHasRun = false; let sessionImportHasRun = false; let importSectionCollapsed = false; let storageAvailable = true; +let runtimePlatform = null; +let openWrtConnected = false; const autoParse = debounce(() => { parseAndRender(); @@ -116,6 +118,7 @@ function main() { renderSubnetMappings(); renderPathCriteria(); parseAndRender({ persist: false }); + initialiseRuntimePlatform(); } function toggleHelp() { @@ -150,6 +153,176 @@ function renderImportSectionCollapsed() { } } +async function initialiseRuntimePlatform() { + try { + const platformModule = window.OpenWrtVisualiserPlatform; + + if (!platformModule?.createPlatform) { + return; + } + + const runtimeConfig = platformModule.getRuntimeConfig(window); + runtimePlatform = platformModule.createPlatform(runtimeConfig, { document }); + const runtime = await runtimePlatform.initialise(); + + if (runtime.mode !== "openwrt") { + return; + } + + document.getElementById("openwrtLiveSection")?.classList.remove("hidden"); + setOpenWrtConnectionState("disconnected", "Authenticate with the router to load live read-only state."); + } catch (error) { + runtimePlatform = null; + console.warn("OpenWrt runtime initialisation failed; offline mode remains active.", error); + } +} + +async function connectOpenWrt() { + if (!runtimePlatform || runtimePlatform.mode !== "openwrt") { + return; + } + + const usernameInput = document.getElementById("openwrtUsername"); + const passwordInput = document.getElementById("openwrtPassword"); + const connectButton = document.getElementById("openwrtConnectButton"); + const credentials = { + username: usernameInput?.value || "", + password: passwordInput?.value || "" + }; + + if (passwordInput) { + passwordInput.value = ""; + } + + if (connectButton) { + connectButton.disabled = true; + } + + setOpenWrtConnectionState("connecting", "Authenticating with OpenWrt..."); + + try { + const result = await runtimePlatform.authenticate(credentials); + openWrtConnected = true; + applyOpenWrtSnapshot(result.snapshot, { source: "initial" }); + setOpenWrtConnectionState("connected", "Live read-only data loaded from OpenWrt."); + runtimePlatform.start({ + onSnapshot: (snapshot, metadata) => applyOpenWrtSnapshot(snapshot, metadata), + onStatus: () => setOpenWrtConnectionState("connected"), + onError: (error) => setOpenWrtConnectionState("error", formatOpenWrtError(error)) + }); + } catch (error) { + openWrtConnected = false; + runtimePlatform.stop(); + setOpenWrtConnectionState("error", `${formatOpenWrtError(error)} Offline data remains available.`); + } finally { + credentials.password = ""; + + if (connectButton) { + connectButton.disabled = false; + } + } +} + +async function refreshOpenWrt() { + if (!runtimePlatform || !openWrtConnected) { + return; + } + + setOpenWrtConnectionState("connecting", "Refreshing live router state..."); + + try { + applyOpenWrtSnapshot(await runtimePlatform.getSnapshot(), { source: "manual" }); + setOpenWrtConnectionState("connected", "Live router state refreshed."); + } catch (error) { + setOpenWrtConnectionState("error", formatOpenWrtError(error)); + } +} + +function disconnectOpenWrt() { + runtimePlatform?.stop(); + openWrtConnected = false; + loadState(); + renderDeviceInputs(); + renderSubnetMappings(); + renderPathCriteria(); + parseAndRender({ persist: false }); + setOpenWrtConnectionState("disconnected", "Live session cleared. Offline data restored."); +} + +function applyOpenWrtSnapshot(snapshot, metadata = {}) { + if (!snapshot?.firewall || !Array.isArray(snapshot.devices)) { + throw new Error("OpenWrt returned an incomplete snapshot."); + } + + if (Array.isArray(snapshot.subnetMappings)) { + subnetMappingsText = snapshot.subnetMappings.join("\n"); + subnetImportHasRun = snapshot.subnetMappings.length > 0; + } + + firewallModel = snapshot.firewall; + const mappings = parseSubnetMappings(subnetMappingsText); + devices = normaliseSavedDevices(snapshot.devices).map((device) => ({ + ...device, + zone: device.zone || inferZoneForIp(device.ip, mappings) + })); + graphPathHighlightRequested = false; + hostImportHasRun = devices.length > 0; + + const firewallInput = document.getElementById("firewallInput"); + + if (firewallInput) { + firewallInput.value = serialiseFirewallModel(firewallModel); + } + + renderDeviceInputs(); + renderSubnetMappings(); + renderCurrentModel({ persist: false, incrementalGraph: true }); + + const generatedAt = snapshot.generatedAt > 0 + ? new Date(snapshot.generatedAt * 1000).toLocaleString() + : "now"; + const truncated = snapshot.limits?.truncated ? " Device results were truncated." : ""; + const source = metadata.source || metadata.kind || "refresh"; + setOpenWrtResult(`Live ${source} loaded ${devices.length} devices at ${generatedAt}.${truncated}`); +} + +function setOpenWrtConnectionState(state, message = "") { + const section = document.getElementById("openwrtLiveSection"); + const status = document.getElementById("openwrtLiveStatus"); + const loginControls = document.getElementById("openwrtLoginControls"); + const connectedControls = document.getElementById("openwrtConnectedControls"); + + section?.classList.toggle("connected", state === "connected"); + section?.classList.toggle("error", state === "error"); + + if (status) { + status.textContent = state === "connecting" + ? "Connecting" + : state === "connected" + ? "Connected" + : state === "error" ? "Error" : "Disconnected"; + } + + loginControls?.classList.toggle("hidden", openWrtConnected); + connectedControls?.classList.toggle("hidden", !openWrtConnected); + + if (message) { + setOpenWrtResult(message); + } +} + +function setOpenWrtResult(message) { + const result = document.getElementById("openwrtLiveResult"); + + if (result) { + result.textContent = message; + } +} + +function formatOpenWrtError(error) { + return error?.message || "OpenWrt API request failed."; +} + function loadExample() { if (!confirm("Load the example config and reset devices? Current unsaved page state will be replaced.")) { return; @@ -343,12 +516,16 @@ function removeDevice(index) { } function parseAndRender(options = {}) { - const shouldPersist = options.persist !== false; const configText = document.getElementById("firewallInput").value; firewallModel = parseOpenWrtFirewall(configText); + renderCurrentModel(options); +} + +function renderCurrentModel(options = {}) { + const shouldPersist = options.persist !== false; renderSummary(); - renderGraph(); + renderGraph(null, { incremental: options.incrementalGraph === true }); renderZoneView(); renderMatrix(); renderDeviceSelectors(); @@ -433,7 +610,7 @@ function loadState() { } function saveState() { - if (!storageAvailable) { + if (!storageAvailable || openWrtConnected) { return; } @@ -1662,6 +1839,61 @@ function parseOpenWrtFirewall(text) { return model; } +function serialiseFirewallModel(model) { + const lines = ["# Read-only firewall snapshot loaded from OpenWrt."]; + + Object.values(model?.zones || {}).forEach((zone) => { + if (!zone?.name) { + return; + } + + lines.push( + "", + "config zone", + `\toption name '${uciDisplayValue(zone.name)}'`, + `\toption input '${uciDisplayValue(zone.input || "REJECT")}'`, + `\toption output '${uciDisplayValue(zone.output || "REJECT")}'`, + `\toption forward '${uciDisplayValue(zone.forward || "REJECT")}'` + ); + + (zone.networks || []).forEach((network) => { + lines.push(`\tlist network '${uciDisplayValue(network)}'`); + }); + }); + + (model?.forwardings || []).forEach((forwarding) => { + lines.push( + "", + "config forwarding", + `\toption src '${uciDisplayValue(forwarding.src)}'`, + `\toption dest '${uciDisplayValue(forwarding.dest)}'` + ); + }); + + (model?.rules || []).forEach((rule) => { + lines.push("", "config rule", `\toption name '${uciDisplayValue(rule.name || "Unnamed rule")}'`); + [ + ["src", rule.src], + ["dest", rule.dest], + ["src_ip", rule.srcIp], + ["dest_ip", rule.destIp], + ["proto", rule.proto], + ["dest_port", rule.destPort], + ["target", rule.target] + ].forEach(([key, value]) => { + if (value) { + lines.push(`\toption ${key} '${uciDisplayValue(value)}'`); + } + }); + }); + + return `${lines.join("\n")}\n`; +} + +function uciDisplayValue(value) { + return String(value || "").replace(/[\r\n']/g, " ").trim(); +} + function renderSummary() { const zoneCount = Object.keys(firewallModel.zones).length; const forwardingCount = firewallModel.forwardings.length; @@ -1987,7 +2219,7 @@ function classifyZone(zoneName) { return "other"; } -function renderGraph(layoutName = null) { +function renderGraph(layoutName = null, options = {}) { if (layoutName) { currentLayout = normaliseGraphLayout(layoutName); saveState(); @@ -2001,6 +2233,11 @@ function renderGraph(layoutName = null) { return; } + if (cy && options.incremental) { + synchroniseGraphElements(elements); + return; + } + if (cy) { cy.destroy(); } @@ -2117,6 +2354,44 @@ function renderGraph(layoutName = null) { }); } +function synchroniseGraphElements(elements) { + const nextElements = new Map(elements.map((element) => [element.data.id, element])); + const positions = new Map(); + const extent = cy.extent(); + let newNodeIndex = 0; + + cy.nodes().forEach((node) => positions.set(node.id(), node.position())); + + cy.batch(() => { + cy.elements().forEach((element) => { + const next = nextElements.get(element.id()); + + if (!next) { + element.remove(); + return; + } + + element.data(next.data); + nextElements.delete(element.id()); + }); + + nextElements.forEach((element) => { + const added = cy.add(element); + const position = positions.get(element.data.id); + + if (added.isNode()) { + added.position(position || { + x: (extent.x1 + extent.x2) / 2 + (newNodeIndex % 5) * 36, + y: (extent.y1 + extent.y2) / 2 + Math.floor(newNodeIndex / 5) * 36 + }); + newNodeIndex += 1; + } + }); + }); + + cy.elements().removeClass("highlighted faded"); +} + function handleGraphFilterChange() { renderGraph(); renderCurrentTestResult(false, { highlight: graphPathHighlightRequested }); @@ -2176,7 +2451,7 @@ function buildGraphElements() { elements.push({ data: { - id: `membership-${index}`, + id: `membership-${deviceNodeId(index)}`, source: deviceNodeId(index), target: zoneNodeId(device.zone), type: "membership", @@ -2204,7 +2479,7 @@ function buildZoneRelationshipEdges() { edges.push({ data: { - id: `zone-${src}-to-${dst}`, + id: `zone-edge-${safeId(src)}-to-${safeId(dst)}`, source: zoneNodeId(src), target: zoneNodeId(dst), type: "zone-relationship", @@ -2223,12 +2498,14 @@ function buildDeviceRelationshipEdges() { const edges = []; const relationships = buildDeviceRelationships(); - relationships.forEach((item, index) => { + relationships.forEach((item) => { + const source = deviceNodeId(item.srcIndex); + const target = deviceNodeId(item.dstIndex); edges.push({ data: { - id: `device-${index}`, - source: deviceNodeId(item.srcIndex), - target: deviceNodeId(item.dstIndex), + id: `device-edge-${source}-to-${target}`, + source, + target, type: "device-relationship", decision: item.decision.allowed ? "allowed" : "blocked", label: item.decision.allowed ? "ALLOW" : "DENY", @@ -2513,7 +2790,12 @@ function zoneNodeId(zoneName) { } function deviceNodeId(index) { - return `device-${index}`; + const device = devices[index]; + const identity = safeId(device?.ip || device?.mac || device?.name || index); + const priorMatches = devices.slice(0, index).filter((candidate) => { + return safeId(candidate?.ip || candidate?.mac || candidate?.name || "") === identity; + }).length; + return `device-${identity}${priorMatches ? `-${priorMatches}` : ""}`; } function safeId(value) { diff --git a/public/assets/js/openwrt-api.js b/public/assets/js/openwrt-api.js new file mode 100644 index 0000000..b525879 --- /dev/null +++ b/public/assets/js/openwrt-api.js @@ -0,0 +1,366 @@ +(function exposeOpenWrtApi(global, factory) { + "use strict"; + + const api = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = api; + } + + global.OpenWrtVisualiserApi = api; +})(globalThis, function createOpenWrtApi() { + "use strict"; + + const API_VERSION = 1; + const ANONYMOUS_SESSION = "00000000000000000000000000000000"; + const DEFAULT_TIMEOUT_MS = 10000; + const DEFAULT_LIMITS = { + maxDevices: 250, + maxNeighbours: 500 + }; + + class OpenWrtApiError extends Error { + constructor(message, code = "OPENWRT_API_ERROR", details = null) { + super(message); + this.name = "OpenWrtApiError"; + this.code = code; + this.details = details; + } + } + + class OpenWrtApiClient { + constructor(options = {}) { + this.rpcUrl = options.rpcUrl || "/ubus"; + this.rpcObject = options.rpcObject || "firewall.visualiser"; + this.timeoutMs = positiveInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS); + this.fetchImpl = options.fetchImpl || globalThis.fetch?.bind(globalThis); + this.sessionToken = ""; + this.requestId = 0; + this.controllers = new Set(); + + if (!this.fetchImpl) { + throw new OpenWrtApiError("Fetch is unavailable in this browser.", "FETCH_UNAVAILABLE"); + } + } + + async authenticate(credentials = {}) { + const username = String(credentials.username || "").trim(); + const password = String(credentials.password || ""); + + if (!username || !password) { + throw new OpenWrtApiError("Username and password are required.", "INVALID_CREDENTIALS"); + } + + const response = await this.request(ANONYMOUS_SESSION, "session", "login", { + username, + password + }); + const token = String(response.ubus_rpc_session || ""); + + if (!/^[a-f0-9]{32}$/i.test(token)) { + throw new OpenWrtApiError("OpenWrt did not return a valid session token.", "INVALID_SESSION"); + } + + this.sessionToken = token; + return { + expiresIn: positiveInteger(response.expires, 0), + username + }; + } + + logout() { + this.abortAll(); + this.sessionToken = ""; + } + + abortAll() { + this.controllers.forEach((controller) => controller.abort()); + this.controllers.clear(); + } + + async getCapabilities() { + return validateCapabilities(await this.call("capabilities")); + } + + async getSnapshot() { + return normaliseSnapshot(await this.call("snapshot")); + } + + async getFirewall() { + const response = await this.call("firewall"); + return { + apiVersion: requireApiVersion(response), + generatedAt: finiteNumber(response.generated_at, 0), + revision: String(response.revision || ""), + firewall: normaliseFirewall(response.firewall || response) + }; + } + + async getDevices() { + const response = await this.call("devices"); + const limits = normaliseLimits(response.limits); + return { + apiVersion: requireApiVersion(response), + generatedAt: finiteNumber(response.generated_at, 0), + revision: String(response.revision || ""), + limits, + devices: normaliseDevices(response.devices, limits.maxDevices) + }; + } + + async getInterfaces() { + const response = await this.call("interfaces"); + return { + apiVersion: requireApiVersion(response), + generatedAt: finiteNumber(response.generated_at, 0), + revision: String(response.revision || ""), + interfaces: normaliseInterfaces(response.interfaces) + }; + } + + async getHealth() { + const response = await this.call("health"); + requireApiVersion(response); + return { + apiVersion: API_VERSION, + generatedAt: finiteNumber(response.generated_at, 0), + uptime: finiteNumber(response.uptime, 0), + memoryAvailableKb: finiteNumber(response.memory_available_kb, 0), + providerErrors: normaliseStringArray(response.provider_errors, 20) + }; + } + + async call(method, params = {}) { + if (!this.sessionToken) { + throw new OpenWrtApiError("Authenticate before calling the router API.", "NOT_AUTHENTICATED"); + } + + return this.request(this.sessionToken, this.rpcObject, method, params); + } + + async request(session, object, method, params) { + const controller = new AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), this.timeoutMs); + this.controllers.add(controller); + + try { + const response = await this.fetchImpl(this.rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: ++this.requestId, + method: "call", + params: [session, object, method, params] + }), + signal: controller.signal, + credentials: "same-origin" + }); + + if (!response.ok) { + throw new OpenWrtApiError(`Router API returned HTTP ${response.status}.`, "HTTP_ERROR", response.status); + } + + const payload = await response.json(); + + if (payload.error) { + throw new OpenWrtApiError(payload.error.message || "Router API request failed.", "JSON_RPC_ERROR", payload.error); + } + + if (!Array.isArray(payload.result) || payload.result.length < 1) { + throw new OpenWrtApiError("Router API returned an invalid JSON-RPC envelope.", "INVALID_ENVELOPE"); + } + + const status = Number(payload.result[0]); + + if (status !== 0) { + throw new OpenWrtApiError(`ubus call failed with status ${status}.`, "UBUS_ERROR", status); + } + + return payload.result[1] && typeof payload.result[1] === "object" ? payload.result[1] : {}; + } catch (error) { + if (error?.name === "AbortError") { + throw new OpenWrtApiError("Router API request timed out.", "TIMEOUT"); + } + + throw error instanceof OpenWrtApiError + ? error + : new OpenWrtApiError(error?.message || "Router API request failed.", "NETWORK_ERROR"); + } finally { + globalThis.clearTimeout(timeout); + this.controllers.delete(controller); + } + } + } + + function validateCapabilities(response) { + const apiVersion = requireApiVersion(response); + const limits = normaliseLimits(response.limits); + + return { + apiVersion, + readOnly: response.read_only !== false, + providers: normaliseBooleanMap(response.providers), + features: normaliseBooleanMap(response.features), + limits + }; + } + + function normaliseSnapshot(response) { + const apiVersion = requireApiVersion(response); + const limits = normaliseLimits(response.limits); + + if (!response.firewall || typeof response.firewall !== "object") { + throw new OpenWrtApiError("Snapshot is missing firewall data.", "INVALID_SNAPSHOT"); + } + + if (!Array.isArray(response.devices) || !Array.isArray(response.interfaces)) { + throw new OpenWrtApiError("Snapshot is missing device or interface arrays.", "INVALID_SNAPSHOT"); + } + + return { + apiVersion, + generatedAt: finiteNumber(response.generated_at, 0), + revision: String(response.revision || ""), + limits, + firewall: normaliseFirewall(response.firewall), + devices: normaliseDevices(response.devices, limits.maxDevices), + interfaces: normaliseInterfaces(response.interfaces), + subnetMappings: normaliseStringArray(response.subnet_mappings, 128) + }; + } + + function normaliseFirewall(value) { + const source = value && typeof value === "object" ? value : {}; + const zones = {}; + const sourceZones = source.zones && typeof source.zones === "object" ? source.zones : {}; + + Object.entries(sourceZones).slice(0, 128).forEach(([key, zone]) => { + if (!zone || typeof zone !== "object") { + return; + } + + const name = cleanString(zone.name || key, 64); + + if (!name) { + return; + } + + zones[name] = { + name, + input: cleanString(zone.input || "REJECT", 16).toUpperCase(), + output: cleanString(zone.output || "REJECT", 16).toUpperCase(), + forward: cleanString(zone.forward || "REJECT", 16).toUpperCase(), + networks: normaliseStringArray(zone.networks, 32) + }; + }); + + return { + zones, + forwardings: Array.isArray(source.forwardings) + ? source.forwardings.slice(0, 256).map((item) => ({ + src: cleanString(item?.src, 64), + dest: cleanString(item?.dest, 64) + })).filter((item) => item.src && item.dest) + : [], + rules: Array.isArray(source.rules) + ? source.rules.slice(0, 1000).map((rule, index) => ({ + index: positiveInteger(rule?.index, index + 1), + name: cleanString(rule?.name || "Unnamed rule", 128), + src: cleanString(rule?.src, 64), + dest: cleanString(rule?.dest, 64), + srcIp: cleanString(rule?.srcIp ?? rule?.src_ip, 64), + destIp: cleanString(rule?.destIp ?? rule?.dest_ip, 64), + proto: cleanString(rule?.proto, 32), + destPort: cleanString(rule?.destPort ?? rule?.dest_port, 64), + target: cleanString(rule?.target, 16).toUpperCase(), + unsupportedFields: normaliseStringArray(rule?.unsupportedFields ?? rule?.unsupported_fields, 32) + })) + : [], + revision: cleanString(source.revision, 128) + }; + } + + function normaliseDevices(value, limit = DEFAULT_LIMITS.maxDevices) { + return Array.isArray(value) + ? value.slice(0, limit).map((device) => ({ + name: cleanString(device?.name || device?.hostname || device?.ip || "Imported Host", 128), + ip: cleanString(device?.ip, 64), + zone: cleanString(device?.zone, 64), + mac: cleanString(device?.mac, 32), + source: cleanString(device?.source || "openwrt", 32) + })).filter((device) => device.ip) + : []; + } + + function normaliseInterfaces(value) { + return Array.isArray(value) + ? value.slice(0, 128).map((item) => ({ + name: cleanString(item?.name || item?.interface, 64), + device: cleanString(item?.device || item?.l3_device, 64), + up: Boolean(item?.up), + available: item?.available !== false, + pending: Boolean(item?.pending), + ipv4: normaliseStringArray(item?.ipv4, 32) + })).filter((item) => item.name) + : []; + } + + function normaliseLimits(value) { + const source = value && typeof value === "object" ? value : {}; + + return { + maxDevices: Math.min(1000, positiveInteger(source.max_devices ?? source.maxDevices, DEFAULT_LIMITS.maxDevices)), + maxNeighbours: Math.min(2000, positiveInteger(source.max_neighbours ?? source.maxNeighbours, DEFAULT_LIMITS.maxNeighbours)), + truncated: Boolean(source.truncated) + }; + } + + function normaliseBooleanMap(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + + return Object.fromEntries(Object.entries(value).slice(0, 64).map(([key, enabled]) => [cleanString(key, 64), Boolean(enabled)])); + } + + function normaliseStringArray(value, limit) { + const values = Array.isArray(value) ? value : typeof value === "string" ? [value] : []; + return values.slice(0, limit).map((item) => cleanString(item, 256)).filter(Boolean); + } + + function requireApiVersion(response) { + const version = Number(response?.api_version ?? response?.apiVersion); + + if (version !== API_VERSION) { + throw new OpenWrtApiError(`Unsupported router API version: ${Number.isFinite(version) ? version : "missing"}.`, "UNSUPPORTED_API_VERSION"); + } + + return version; + } + + function positiveInteger(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number >= 0 ? number : fallback; + } + + function finiteNumber(value, fallback) { + const number = Number(value); + return Number.isFinite(number) ? number : fallback; + } + + function cleanString(value, maxLength) { + return String(value ?? "").trim().slice(0, maxLength); + } + + return { + ANONYMOUS_SESSION, + API_VERSION, + OpenWrtApiClient, + OpenWrtApiError, + normaliseSnapshot, + validateCapabilities + }; +}); diff --git a/public/assets/js/platform.js b/public/assets/js/platform.js new file mode 100644 index 0000000..d8e9eb4 --- /dev/null +++ b/public/assets/js/platform.js @@ -0,0 +1,268 @@ +(function exposeFirewallVisualiserPlatform(global, factory) { + "use strict"; + + const platformApi = factory(global.OpenWrtVisualiserApi); + + if (typeof module === "object" && module.exports) { + module.exports = platformApi; + } + + global.OpenWrtVisualiserPlatform = platformApi; +})(globalThis, function createPlatformModule(openWrtApi) { + "use strict"; + + const DEFAULT_REFRESH = { + firewallMs: 30000, + devicesMs: 10000, + interfacesMs: 5000 + }; + const MAX_BACKOFF_MS = 120000; + + class OfflinePlatform { + constructor() { + this.mode = "offline"; + } + + async initialise() { + return { mode: this.mode }; + } + + async authenticate() { + throw new Error("Authentication is unavailable in offline mode."); + } + + async getCapabilities() { + return { mode: this.mode, readOnly: true }; + } + + async getSnapshot() { + return null; + } + + async refreshDevices() { + return null; + } + + async refreshInterfaces() { + return null; + } + + start() {} + + stop() {} + } + + class OpenWrtPlatform { + constructor(config = {}, options = {}) { + if (!openWrtApi?.OpenWrtApiClient) { + throw new Error("OpenWrt API adapter is unavailable."); + } + + this.mode = "openwrt"; + this.config = config; + this.document = options.document || globalThis.document; + this.api = options.apiClient || new openWrtApi.OpenWrtApiClient({ + rpcUrl: config.rpcUrl, + rpcObject: config.rpcObject, + timeoutMs: config.timeoutMs, + fetchImpl: options.fetchImpl + }); + this.refresh = normaliseRefresh(config.refresh); + this.timers = new Map(); + this.inFlight = new Set(); + this.failures = new Map(); + this.callbacks = {}; + this.snapshot = null; + this.running = false; + this.visibilityHandler = () => this.handleVisibilityChange(); + } + + async initialise() { + return { + mode: this.mode, + rpcObject: this.config.rpcObject || "firewall.visualiser" + }; + } + + async authenticate(credentials) { + try { + const session = await this.api.authenticate(credentials); + const capabilities = await this.getCapabilities(); + const snapshot = await this.getSnapshot(); + return { session, capabilities, snapshot }; + } catch (error) { + this.api.logout(); + throw error; + } + } + + async getCapabilities() { + return this.api.getCapabilities(); + } + + async getSnapshot() { + this.snapshot = await this.api.getSnapshot(); + return this.snapshot; + } + + async refreshDevices() { + const result = await this.api.getDevices(); + + if (this.snapshot) { + this.snapshot.devices = result.devices; + this.snapshot.limits = result.limits; + this.snapshot.revision = result.revision || this.snapshot.revision; + } + + return result; + } + + async refreshInterfaces() { + const result = await this.api.getInterfaces(); + + if (this.snapshot) { + this.snapshot.interfaces = result.interfaces; + } + + return result; + } + + start(callbacks = {}) { + this.stopPolling(); + this.callbacks = callbacks; + this.failures.clear(); + this.running = true; + this.document?.addEventListener?.("visibilitychange", this.visibilityHandler); + this.schedule("snapshot", this.refresh.firewallMs); + this.schedule("devices", this.refresh.devicesMs); + this.schedule("interfaces", this.refresh.interfacesMs); + } + + stop() { + this.running = false; + this.stopPolling(); + this.document?.removeEventListener?.("visibilitychange", this.visibilityHandler); + this.api.logout(); + this.snapshot = null; + } + + stopPolling() { + this.timers.forEach((timer) => globalThis.clearTimeout(timer)); + this.timers.clear(); + } + + handleVisibilityChange() { + if (!this.running) { + return; + } + + if (this.document?.hidden) { + this.stopPolling(); + return; + } + + this.schedule("snapshot", 0); + this.schedule("devices", 0); + this.schedule("interfaces", 0); + } + + schedule(kind, delay = null) { + if (!this.running || this.document?.hidden) { + return; + } + + const existing = this.timers.get(kind); + + if (existing) { + globalThis.clearTimeout(existing); + } + + const baseDelay = this.baseDelay(kind); + const failures = this.failures.get(kind) || 0; + const backoff = Math.min(MAX_BACKOFF_MS, baseDelay * (2 ** failures)); + const wait = delay === null ? backoff : delay; + this.timers.set(kind, globalThis.setTimeout(() => this.runRefresh(kind), wait)); + } + + async runRefresh(kind) { + if (!this.running || this.inFlight.has(kind) || this.document?.hidden) { + this.schedule(kind); + return; + } + + this.inFlight.add(kind); + + try { + const previousRevision = this.snapshot?.revision || ""; + let result; + + if (kind === "snapshot") { + result = await this.getSnapshot(); + } else if (kind === "devices") { + result = await this.refreshDevices(); + } else { + result = await this.refreshInterfaces(); + } + + this.failures.set(kind, 0); + + if (kind !== "snapshot" || !previousRevision || result?.revision !== previousRevision) { + this.callbacks.onSnapshot?.(this.snapshot, { kind }); + } + + this.callbacks.onStatus?.({ connected: true, kind }); + } catch (error) { + this.failures.set(kind, Math.min(6, (this.failures.get(kind) || 0) + 1)); + this.callbacks.onError?.(error, { kind }); + } finally { + this.inFlight.delete(kind); + this.schedule(kind); + } + } + + baseDelay(kind) { + if (kind === "devices") { + return this.refresh.devicesMs; + } + + if (kind === "interfaces") { + return this.refresh.interfacesMs; + } + + return this.refresh.firewallMs; + } + } + + function createPlatform(runtimeConfig = {}, options = {}) { + return runtimeConfig.mode === "openwrt" + ? new OpenWrtPlatform(runtimeConfig, options) + : new OfflinePlatform(); + } + + function getRuntimeConfig(globalObject = globalThis) { + const config = globalObject.FIREWALL_VISUALISER_RUNTIME; + return config && typeof config === "object" ? config : { mode: "offline" }; + } + + function normaliseRefresh(value = {}) { + return { + firewallMs: boundedDelay(value.firewallMs, DEFAULT_REFRESH.firewallMs), + devicesMs: boundedDelay(value.devicesMs, DEFAULT_REFRESH.devicesMs), + interfacesMs: boundedDelay(value.interfacesMs, DEFAULT_REFRESH.interfacesMs) + }; + } + + function boundedDelay(value, fallback) { + const number = Number(value); + return Number.isInteger(number) && number >= 1000 && number <= 300000 ? number : fallback; + } + + return { + DEFAULT_REFRESH, + OfflinePlatform, + OpenWrtPlatform, + createPlatform, + getRuntimeConfig, + normaliseRefresh + }; +}); diff --git a/public/assets/js/runtime-config.js b/public/assets/js/runtime-config.js new file mode 100644 index 0000000..dae2a1e --- /dev/null +++ b/public/assets/js/runtime-config.js @@ -0,0 +1,7 @@ +(function configureFirewallVisualiserRuntime(global) { + "use strict"; + + global.FIREWALL_VISUALISER_RUNTIME = { + mode: "offline" + }; +})(globalThis); diff --git a/public/index.html b/public/index.html index 13ef353..e97c5b2 100644 --- a/public/index.html +++ b/public/index.html @@ -21,6 +21,23 @@

🔥 OpenWrt Firewall Relationship Visualiser

+ +

📥 Import

@@ -237,6 +254,9 @@

🧾 Firewall Comparison

© + + + diff --git a/scripts/ci/openwrt-qemu-smoke.sh b/scripts/ci/openwrt-qemu-smoke.sh new file mode 100755 index 0000000..0bcc095 --- /dev/null +++ b/scripts/ci/openwrt-qemu-smoke.sh @@ -0,0 +1,173 @@ +#!/bin/sh + +set -eu + +ARTIFACT_DIR=${1:-} +IMAGE_URL=${OPENWRT_IMAGE_URL:-https://downloads.openwrt.org/releases/24.10.7/targets/x86/64/openwrt-24.10.7-x86-64-generic-ext4-combined.img.gz} +IMAGE_SHA256=${OPENWRT_IMAGE_SHA256:-3caea69f186b2bce80938d265e5e2a3dfd0f8713aed101df35d60b88d7270d1f} +SSH_PORT=${OPENWRT_SSH_PORT:-2222} +HTTPS_PORT=${OPENWRT_HTTPS_PORT:-8443} +PASSWORD=${OPENWRT_TEST_PASSWORD:-firewall-visualiser-ci} + +case "$PASSWORD" in + ""|*[!0-9A-Za-z._-]*) + echo "OPENWRT_TEST_PASSWORD may contain only letters, numbers, dot, underscore, and hyphen." >&2 + exit 2 + ;; +esac + +[ -d "$ARTIFACT_DIR" ] || { + echo "Usage: $0 ARTIFACT_DIRECTORY" >&2 + exit 2 +} + +PACKAGE=$(find "$ARTIFACT_DIR" -type f -name 'openwrt-firewall-visualiser_*_all.ipk' -print -quit) +[ -n "$PACKAGE" ] || { + echo "OpenWrt IPK artifact was not found in $ARTIFACT_DIR" >&2 + exit 1 +} + +for command in curl gzip jq qemu-system-x86_64 scp sha256sum ssh ssh-keygen; do + command -v "$command" >/dev/null 2>&1 || { + echo "Required command is unavailable: $command" >&2 + exit 1 + } +done + +WORK_DIR=$(mktemp -d) +DIAGNOSTICS="$ARTIFACT_DIR/qemu-diagnostics" +QEMU_PID="" +mkdir -p "$DIAGNOSTICS" + +ssh_router() { + ssh -i "$WORK_DIR/id_ed25519" -p "$SSH_PORT" \ + -o BatchMode=yes -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + root@127.0.0.1 "$@" +} + +cleanup() { + status=$? + trap - EXIT INT TERM + if [ -n "$QEMU_PID" ] && kill -0 "$QEMU_PID" 2>/dev/null; then + ssh_router 'logread; echo; ps w; echo; free' > "$DIAGNOSTICS/router-state.log" 2>&1 || true + kill "$QEMU_PID" 2>/dev/null || true + wait "$QEMU_PID" 2>/dev/null || true + fi + cp "$WORK_DIR/qemu.log" "$DIAGNOSTICS/qemu.log" 2>/dev/null || true + rm -rf "$WORK_DIR" + exit "$status" +} +trap cleanup EXIT INT TERM + +curl --fail --location --silent --show-error "$IMAGE_URL" -o "$WORK_DIR/openwrt.img.gz" +printf '%s %s\n' "$IMAGE_SHA256" "$WORK_DIR/openwrt.img.gz" | sha256sum --check --status +gzip -dc "$WORK_DIR/openwrt.img.gz" > "$WORK_DIR/openwrt.img" +ssh-keygen -q -t ed25519 -N '' -f "$WORK_DIR/id_ed25519" + +qemu-system-x86_64 \ + -machine q35,accel=kvm:tcg \ + -m 256 \ + -nographic \ + -drive "file=$WORK_DIR/openwrt.img,format=raw,if=virtio" \ + -netdev "user,id=net0,net=192.168.1.0/24,dhcpstart=192.168.1.100,hostfwd=tcp:127.0.0.1:$SSH_PORT-192.168.1.1:22,hostfwd=tcp:127.0.0.1:$HTTPS_PORT-192.168.1.1:443" \ + -device virtio-net-pci,netdev=net0 \ + > "$WORK_DIR/qemu.log" 2>&1 & +QEMU_PID=$! + +ready=0 +attempt=0 +while [ "$attempt" -lt 90 ]; do + if ssh_router true >/dev/null 2>&1; then + ready=1 + break + fi + attempt=$((attempt + 1)) + sleep 2 +done +[ "$ready" -eq 1 ] || { + echo "OpenWrt did not expose SSH before the timeout." >&2 + exit 1 +} + +ssh_router 'umask 077; mkdir -p /etc/dropbear; cat > /etc/dropbear/authorized_keys' < "$WORK_DIR/id_ed25519.pub" +ssh_router "ip route replace default via 192.168.1.2; mkdir -p /tmp/resolv.conf.d; printf 'nameserver 192.168.1.3\\n' > /tmp/resolv.conf.d/resolv.conf.auto" + +firewall_before=$(ssh_router "uci show firewall | sha256sum | cut -d' ' -f1") +package_name=/tmp/$(basename "$PACKAGE") +scp -i "$WORK_DIR/id_ed25519" -P "$SSH_PORT" \ + -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + "$PACKAGE" "root@127.0.0.1:$package_name" + +ssh_router opkg update +ssh_router opkg install "$package_name" +printf '%s\n%s\n' "$PASSWORD" "$PASSWORD" | ssh_router passwd root +ssh_router '/etc/init.d/rpcd restart; /etc/init.d/uhttpd restart' + +object_ready=0 +attempt=0 +while [ "$attempt" -lt 30 ]; do + if ssh_router "ubus list firewall.visualiser | grep -qx firewall.visualiser" >/dev/null 2>&1; then + object_ready=1 + break + fi + attempt=$((attempt + 1)) + sleep 1 +done +[ "$object_ready" -eq 1 ] || { + echo "rpcd did not register firewall.visualiser." >&2 + exit 1 +} + +rpc_call() { + session=$1 + object=$2 + method=$3 + curl --fail --insecure --silent --show-error \ + -H 'Content-Type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"call\",\"params\":[\"$session\",\"$object\",\"$method\",{}]}" \ + "https://127.0.0.1:$HTTPS_PORT/ubus" +} + +anonymous=00000000000000000000000000000000 +unauthenticated=$(rpc_call "$anonymous" firewall.visualiser snapshot) +[ "$(printf '%s' "$unauthenticated" | jq -r '.result[0]')" != "0" ] || { + echo "Unauthenticated snapshot request was unexpectedly accepted." >&2 + exit 1 +} + +login=$(curl --fail --insecure --silent --show-error \ + -H 'Content-Type: application/json' \ + --data "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"call\",\"params\":[\"$anonymous\",\"session\",\"login\",{\"username\":\"root\",\"password\":\"$PASSWORD\"}]}" \ + "https://127.0.0.1:$HTTPS_PORT/ubus") +session=$(printf '%s' "$login" | jq -er '.result[1].ubus_rpc_session') + +for method in capabilities health snapshot; do + response=$(rpc_call "$session" firewall.visualiser "$method") + printf '%s\n' "$response" > "$DIAGNOSTICS/$method.json" + printf '%s' "$response" | jq --exit-status '.result[0] == 0 and .result[1].api_version == 1' >/dev/null +done +jq --exit-status ' + .result[1].limits.max_devices <= 1000 and + .result[1].limits.max_neighbours <= 2000 and + (.result[1].limits.truncated | type) == "boolean" +' "$DIAGNOSTICS/snapshot.json" >/dev/null + +curl --fail --insecure --silent --show-error \ + "https://127.0.0.1:$HTTPS_PORT/openwrt-firewall-visualiser/" \ + | grep -q 'OpenWrt Firewall Relationship Visualiser' +curl --fail --insecure --silent --show-error \ + "https://127.0.0.1:$HTTPS_PORT/openwrt-firewall-visualiser/assets/js/runtime-config.js" \ + | grep -q 'mode: "openwrt"' + +ssh_router '! uci -q get uhttpd.firewall_visualiser >/dev/null' +firewall_after=$(ssh_router "uci show firewall | sha256sum | cut -d' ' -f1") +[ "$firewall_before" = "$firewall_after" ] || { + echo "Package installation changed the firewall configuration." >&2 + exit 1 +} + +ssh_router "uci set firewall-visualiser.main.max_devices=251; uci commit firewall-visualiser; opkg remove openwrt-firewall-visualiser" +ssh_router 'test ! -e /www/openwrt-firewall-visualiser/index.html && test "$(uci -q get firewall-visualiser.main.max_devices)" = 251' + +echo "OpenWrt QEMU smoke test passed." diff --git a/scripts/prepare-openwrt-feed.sh b/scripts/prepare-openwrt-feed.sh new file mode 100755 index 0000000..1a022cb --- /dev/null +++ b/scripts/prepare-openwrt-feed.sh @@ -0,0 +1,77 @@ +#!/bin/sh + +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." || exit 1; pwd) +SOURCE_DIR="$ROOT_DIR/packaging/openwrt/package" +BUILD_ROOT="$ROOT_DIR/.build/openwrt-feed" +PACKAGE_DIR="$BUILD_ROOT/openwrt-firewall-visualiser" +MANIFEST="$BUILD_ROOT/openwrt-firewall-visualiser.manifest" +MAX_FILE_KIB=4096 + +fail() { + echo "OpenWrt staging failed: $*" >&2 + exit 1 +} + +hash_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{ print $1 }' + else + shasum -a 256 "$1" | awk '{ print $1 }' + fi +} + +[ -f "$SOURCE_DIR/Makefile" ] || fail "package Makefile is missing" +[ -f "$ROOT_DIR/public/index.html" ] || fail "canonical frontend is missing" + +mkdir -p "$BUILD_ROOT" +rm -rf "$PACKAGE_DIR" +rm -f "$MANIFEST" +mkdir -p "$PACKAGE_DIR/files/www/openwrt-firewall-visualiser" + +cp "$SOURCE_DIR/Makefile" "$PACKAGE_DIR/Makefile" +cp -R "$SOURCE_DIR/files/." "$PACKAGE_DIR/files/" +cp -R "$ROOT_DIR/public/." "$PACKAGE_DIR/files/www/openwrt-firewall-visualiser/" +cp "$SOURCE_DIR/overlay/runtime-config.js" \ + "$PACKAGE_DIR/files/www/openwrt-firewall-visualiser/assets/js/runtime-config.js" + +symlink=$(find "$PACKAGE_DIR" -type l -print -quit) +[ -z "$symlink" ] || fail "symlink is not permitted: $symlink" + +unexpected_executable=$(find "$PACKAGE_DIR/files/www" -type f -perm -111 -print -quit) +[ -z "$unexpected_executable" ] || fail "frontend file is unexpectedly executable: $unexpected_executable" + +package_files=$(find "$PACKAGE_DIR" -type f -print) +while IFS= read -r file; do + [ -n "$file" ] || continue + case "$(basename "$file")" in + .env|.env.*|id_rsa|id_ed25519|*.key|*.pem|*.p12|*.pfx|credentials|credentials.json) + fail "secret-like file is not permitted: $file" + ;; + esac + + size_kib=$((($(wc -c < "$file") + 1023) / 1024)) + [ "$size_kib" -le "$MAX_FILE_KIB" ] || fail "file exceeds ${MAX_FILE_KIB} KiB: $file" + + if grep -E 'BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY|AWS_SECRET_ACCESS_KEY=' "$file" >/dev/null 2>&1; then + fail "private key or credential content is not permitted: $file" + fi +done < "$MANIFEST" + +echo "Staged OpenWrt feed package: $PACKAGE_DIR" +echo "Manifest: $MANIFEST" diff --git a/tests/app.test.js b/tests/app.test.js index 632cf96..c71dc16 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -47,6 +47,14 @@ config rule assert.deepEqual(plain(model.rules[0].unsupportedFields), ["limit"]); }); +test("serialiseFirewallModel produces a parseable firewall snapshot", () => { + const { app } = loadApp(); + const model = app.parseOpenWrtFirewall(app.EXAMPLE_FIREWALL); + const roundTrip = app.parseOpenWrtFirewall(app.serialiseFirewallModel(model)); + + assert.deepEqual(plain(roundTrip), plain(model)); +}); + test("evaluateDevicePath gives specific rules precedence over same-zone policy", () => { const { app } = loadApp(); const firewallModel = app.parseOpenWrtFirewall(app.EXAMPLE_FIREWALL); @@ -213,6 +221,17 @@ test("buildStatePayload includes import and UI state", () => { assert.deepEqual(payload.devices, [{ name: "Laptop", ip: "172.16.10.20", zone: "lan" }]); }); +test("saveState does not replace offline persistence with live router state", () => { + const { app, localStorage } = loadApp(); + app.__setState({ openWrtConnected: true }); + app.saveState(); + assert.equal(localStorage.getItem(app.STORAGE_KEY), null); + + app.__setState({ openWrtConnected: false }); + app.saveState(); + assert.notEqual(localStorage.getItem(app.STORAGE_KEY), null); +}); + test("renderImportSectionCollapsed updates class and button state", () => { const importSection = createElement("importSection"); const importCollapseButton = createElement("importCollapseButton"); @@ -230,3 +249,23 @@ test("renderImportSectionCollapsed updates class and button state", () => { assert.equal(importCollapseButton.textContent, "Expand"); assert.equal(importCollapseButton.attributes["aria-expanded"], "false"); }); + +test("graph nodes and edges have stable, unique element IDs", () => { + const graphFilter = createElement("graphFilter"); + graphFilter.value = "all"; + const { app } = loadApp({ elements: { graphFilter } }); + app.__setState({ + devices: [ + { name: "Camera", ip: "172.16.20.50", zone: "iot" }, + { name: "Laptop", ip: "172.16.10.20", zone: "lan" } + ], + firewallModel: app.parseOpenWrtFirewall(app.EXAMPLE_FIREWALL) + }); + + const elements = app.buildGraphElements(); + const ids = elements.map((element) => element.data.id); + + assert.equal(new Set(ids).size, ids.length); + assert.ok(ids.includes("device-172-16-20-50")); + assert.ok(ids.some((id) => id.startsWith("device-edge-"))); +}); diff --git a/tests/helpers/load-app.js b/tests/helpers/load-app.js index b42f207..74b6a02 100644 --- a/tests/helpers/load-app.js +++ b/tests/helpers/load-app.js @@ -11,6 +11,7 @@ const EXPOSED_NAMES = [ "STORAGE_KEY", "STORAGE_TTL_MS", "buildStatePayload", + "buildGraphElements", "clearExpiredState", "evaluateDevicePath", "evaluateZonePath", @@ -29,7 +30,9 @@ const EXPOSED_NAMES = [ "readStoredState", "renderImportSectionCollapsed", "rulePortMatches", - "ruleProtocolMatches" + "ruleProtocolMatches", + "saveState", + "serialiseFirewallModel" ]; function createElement(id = "") { @@ -147,6 +150,7 @@ function __setState(nextState = {}) { if (hasOwn(nextState, "sessionImportHasRun")) sessionImportHasRun = nextState.sessionImportHasRun; if (hasOwn(nextState, "importSectionCollapsed")) importSectionCollapsed = nextState.importSectionCollapsed; if (hasOwn(nextState, "storageAvailable")) storageAvailable = nextState.storageAvailable; + if (hasOwn(nextState, "openWrtConnected")) openWrtConnected = nextState.openWrtConnected; } function __getState() { return { @@ -155,6 +159,7 @@ function __getState() { firewallModel, hostImportHasRun, importSectionCollapsed, + openWrtConnected, selectedPathCriteria, selectedTestPath, sessionImportHasRun, diff --git a/tests/openwrt/acl.test.js b/tests/openwrt/acl.test.js new file mode 100644 index 0000000..fc35275 --- /dev/null +++ b/tests/openwrt/acl.test.js @@ -0,0 +1,17 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); + +const aclPath = path.resolve(__dirname, "../../packaging/openwrt/package/files/usr/share/rpcd/acl.d/firewall-visualiser.json"); +const expectedMethods = ["capabilities", "snapshot", "firewall", "devices", "interfaces", "health"]; + +test("rpcd ACL grants only the visualiser read methods", () => { + const acl = JSON.parse(fs.readFileSync(aclPath, "utf8")); + const group = acl["firewall-visualiser"]; + + assert.deepEqual(group.read.ubus["firewall.visualiser"], expectedMethods); + assert.equal(group.write, undefined); + assert.deepEqual(Object.keys(group.read.ubus), ["firewall.visualiser"]); + assert.doesNotMatch(JSON.stringify(acl), /file\.|uci\.|system\.|\*/); +}); diff --git a/tests/openwrt/fixtures/snapshot.json b/tests/openwrt/fixtures/snapshot.json new file mode 100644 index 0000000..a6e0370 --- /dev/null +++ b/tests/openwrt/fixtures/snapshot.json @@ -0,0 +1,58 @@ +{ + "api_version": 1, + "generated_at": 1784304000, + "revision": "file:1784303990:1024", + "limits": { + "max_devices": 2, + "max_neighbours": 500, + "truncated": false + }, + "firewall": { + "zones": { + "lan": { + "name": "lan", + "input": "ACCEPT", + "output": "ACCEPT", + "forward": "ACCEPT", + "networks": ["lan"] + }, + "iot": { + "name": "iot", + "input": "REJECT", + "output": "ACCEPT", + "forward": "REJECT", + "networks": ["iot"] + } + }, + "forwardings": [ + {"src": "lan", "dest": "iot"} + ], + "rules": [ + { + "index": 1, + "name": "Allow camera", + "src": "lan", + "dest": "iot", + "dest_ip": "172.16.20.50", + "proto": "tcp", + "dest_port": "554", + "target": "ACCEPT", + "unsupported_fields": [] + } + ], + "revision": "file:1784303990:1024" + }, + "devices": [ + {"name": "Camera", "ip": "172.16.20.50", "zone": "", "mac": "aa:bb:cc:dd:ee:ff", "source": "dhcp"}, + {"name": "Laptop", "ip": "172.16.10.20", "zone": "lan", "source": "arp"}, + {"name": "Over limit", "ip": "172.16.10.21", "zone": "lan", "source": "arp"} + ], + "interfaces": [ + {"name": "lan", "device": "br-lan", "up": true, "available": true, "pending": false, "ipv4": ["172.16.10.1/24"]} + ], + "subnet_mappings": [ + "172.16.10.1/24 lan", + "172.16.20.1/24 iot" + ], + "unknown_future_field": "ignored" +} diff --git a/tests/openwrt/package-layout.test.js b/tests/openwrt/package-layout.test.js new file mode 100644 index 0000000..27096f5 --- /dev/null +++ b/tests/openwrt/package-layout.test.js @@ -0,0 +1,77 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const root = path.resolve(__dirname, "../.."); +const stageScript = path.join(root, "scripts/prepare-openwrt-feed.sh"); +const buildRoot = path.join(root, ".build/openwrt-feed"); +const packageRoot = path.join(buildRoot, "openwrt-firewall-visualiser"); +const manifestPath = path.join(buildRoot, "openwrt-firewall-visualiser.manifest"); + +function stage() { + childProcess.execFileSync("sh", [stageScript], { cwd: root, stdio: "pipe" }); +} + +function mode(file) { + return fs.statSync(file).mode & 0o777; +} + +function containsSymlink(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).some((entry) => { + const fullPath = path.join(directory, entry.name); + return entry.isSymbolicLink() || (entry.isDirectory() && containsSymlink(fullPath)); + }); +} + +test("staged package has reproducible content, runtime overlay, and safe permissions", () => { + stage(); + const firstManifest = fs.readFileSync(manifestPath, "utf8"); + stage(); + const secondManifest = fs.readFileSync(manifestPath, "utf8"); + + assert.equal(firstManifest, secondManifest); + + const required = [ + "Makefile", + "files/www/openwrt-firewall-visualiser/index.html", + "files/www/openwrt-firewall-visualiser/assets/js/app.js", + "files/www/openwrt-firewall-visualiser/assets/js/platform.js", + "files/www/openwrt-firewall-visualiser/assets/js/openwrt-api.js", + "files/etc/config/firewall-visualiser", + "files/etc/uci-defaults/90-firewall-visualiser", + "files/usr/sbin/firewall-visualiser-setup", + "files/usr/share/rpcd/acl.d/firewall-visualiser.json", + "files/usr/share/rpcd/ucode/firewall-visualiser.uc" + ]; + + for (const relativePath of required) { + assert.equal(fs.existsSync(path.join(packageRoot, relativePath)), true, relativePath); + assert.match(firstManifest, new RegExp(`${relativePath.replaceAll("/", "\\/")}$`, "m")); + } + + const canonicalRuntime = fs.readFileSync(path.join(root, "public/assets/js/runtime-config.js"), "utf8"); + const stagedRuntime = fs.readFileSync(path.join(packageRoot, "files/www/openwrt-firewall-visualiser/assets/js/runtime-config.js"), "utf8"); + assert.match(canonicalRuntime, /mode:\s*"offline"/); + assert.doesNotMatch(canonicalRuntime, /mode:\s*"openwrt"/); + assert.match(stagedRuntime, /mode:\s*"openwrt"/); + + assert.equal(mode(path.join(packageRoot, "files/usr/sbin/firewall-visualiser-setup")), 0o755); + assert.equal(mode(path.join(packageRoot, "files/etc/uci-defaults/90-firewall-visualiser")), 0o755); + assert.equal(mode(path.join(packageRoot, "files/www/openwrt-firewall-visualiser/index.html")), 0o644); + + assert.equal(containsSymlink(packageRoot), false); +}); + +test("staging refuses credential-like files", () => { + const forbidden = path.join(root, "packaging/openwrt/package/files/.env"); + fs.writeFileSync(forbidden, "TOKEN=must-not-be-staged\n"); + + try { + assert.throws(() => stage(), /OpenWrt staging failed: secret-like file is not permitted/); + } finally { + fs.unlinkSync(forbidden); + stage(); + } +}); diff --git a/tests/openwrt/platform.test.js b/tests/openwrt/platform.test.js new file mode 100644 index 0000000..b9a6920 --- /dev/null +++ b/tests/openwrt/platform.test.js @@ -0,0 +1,131 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); + +const apiModule = require("../../public/assets/js/openwrt-api.js"); +const platformModule = require("../../public/assets/js/platform.js"); + +const fixture = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures/snapshot.json"), "utf8")); +const SESSION = "0123456789abcdef0123456789abcdef"; + +function response(result) { + return { + ok: true, + status: 200, + async json() { + return { jsonrpc: "2.0", id: 1, result: [0, result] }; + } + }; +} + +test("runtime defaults to offline mode", async () => { + assert.deepEqual(platformModule.getRuntimeConfig({}), { mode: "offline" }); + const platform = platformModule.createPlatform({ mode: "offline" }); + assert.equal((await platform.initialise()).mode, "offline"); + assert.equal(await platform.getSnapshot(), null); +}); + +test("API login keeps the session in memory and normalises bounded snapshots", async () => { + const requests = []; + let storageWrites = 0; + const originalStorage = global.localStorage; + global.localStorage = { setItem() { storageWrites += 1; } }; + + try { + const client = new apiModule.OpenWrtApiClient({ + fetchImpl: async (url, options) => { + const body = JSON.parse(options.body); + requests.push({ url, body }); + return body.params[1] === "session" + ? response({ ubus_rpc_session: SESSION, expires: 300 }) + : response(fixture); + } + }); + + await client.authenticate({ username: "root", password: "not-persisted" }); + const snapshot = await client.getSnapshot(); + + assert.equal(client.sessionToken, SESSION); + assert.equal(storageWrites, 0); + assert.equal(requests[0].body.params[1], "session"); + assert.equal(requests[1].body.params[1], "firewall.visualiser"); + assert.equal(requests[1].body.params[2], "snapshot"); + assert.equal(snapshot.devices.length, 2); + assert.equal(snapshot.firewall.rules[0].destIp, "172.16.20.50"); + assert.equal(snapshot.unknown_future_field, undefined); + + client.logout(); + assert.equal(client.sessionToken, ""); + } finally { + global.localStorage = originalStorage; + } +}); + +test("API rejects missing fields and incompatible versions", () => { + assert.throws( + () => apiModule.normaliseSnapshot({ api_version: 2, firewall: {}, devices: [], interfaces: [] }), + (error) => error.code === "UNSUPPORTED_API_VERSION" + ); + assert.throws( + () => apiModule.normaliseSnapshot({ api_version: 1, devices: [], interfaces: [] }), + (error) => error.code === "INVALID_SNAPSHOT" + ); +}); + +test("API aborts timed-out requests", async () => { + const client = new apiModule.OpenWrtApiClient({ + timeoutMs: 5, + fetchImpl: (url, options) => new Promise((resolve, reject) => { + options.signal.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }) + }); + + await assert.rejects( + client.authenticate({ username: "root", password: "password" }), + (error) => error.code === "TIMEOUT" + ); +}); + +test("platform prevents overlapping refreshes", async () => { + let resolveSnapshot; + let calls = 0; + const apiClient = { + getSnapshot() { + calls += 1; + return new Promise((resolve) => { resolveSnapshot = resolve; }); + }, + logout() {} + }; + const platform = new platformModule.OpenWrtPlatform( + { mode: "openwrt" }, + { apiClient, document: { hidden: false } } + ); + platform.running = true; + platform.schedule = () => {}; + + const first = platform.runRefresh("snapshot"); + const second = platform.runRefresh("snapshot"); + assert.equal(calls, 1); + + resolveSnapshot({ revision: "one", devices: [], interfaces: [] }); + await Promise.all([first, second]); + assert.equal(calls, 1); +}); + +test("platform clears a partial login when capability loading fails", async () => { + let loggedOut = false; + const apiClient = { + async authenticate() { return { username: "root" }; }, + async getCapabilities() { throw new Error("rpc unavailable"); }, + logout() { loggedOut = true; } + }; + const platform = new platformModule.OpenWrtPlatform({ mode: "openwrt" }, { apiClient }); + + await assert.rejects(platform.authenticate({ username: "root", password: "password" }), /rpc unavailable/); + assert.equal(loggedOut, true); +}); diff --git a/tests/openwrt/rpc-contract.test.js b/tests/openwrt/rpc-contract.test.js new file mode 100644 index 0000000..b7ff8ca --- /dev/null +++ b/tests/openwrt/rpc-contract.test.js @@ -0,0 +1,22 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); + +const { normaliseSnapshot } = require("../../public/assets/js/openwrt-api.js"); + +const fixturePath = path.join(__dirname, "fixtures/snapshot.json"); + +test("RPC fixture satisfies API version 1 and frontend model contracts", () => { + const fixture = JSON.parse(fs.readFileSync(fixturePath, "utf8")); + const snapshot = normaliseSnapshot(fixture); + + assert.equal(snapshot.apiVersion, 1); + assert.match(snapshot.revision, /^file:/); + assert.equal(snapshot.limits.maxDevices, 2); + assert.equal(snapshot.limits.truncated, false); + assert.equal(snapshot.devices.length, 2); + assert.equal(snapshot.interfaces[0].name, "lan"); + assert.equal(snapshot.firewall.zones.iot.forward, "REJECT"); + assert.deepEqual(snapshot.subnetMappings, ["172.16.10.1/24 lan", "172.16.20.1/24 iot"]); +}); diff --git a/tests/openwrt/setup-script.test.js b/tests/openwrt/setup-script.test.js new file mode 100644 index 0000000..3481ed9 --- /dev/null +++ b/tests/openwrt/setup-script.test.js @@ -0,0 +1,56 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const childProcess = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const script = path.resolve(__dirname, "../../packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup"); + +function makeExecutable(file, source) { + fs.writeFileSync(file, source, { mode: 0o755 }); +} + +test("dedicated-listener setup is valid shell and contains no firewall mutation", () => { + childProcess.execFileSync("sh", ["-n", script]); + const source = fs.readFileSync(script, "utf8"); + + assert.doesNotMatch(source, /(?:uci|UCI_BIN)[^\n]*(?:firewall\.|commit firewall|add firewall)/); + assert.match(source, /no_ubusauth=0/); + assert.match(source, /no_dirlists=1/); + assert.match(source, /no_symlinks=1/); +}); + +test("dedicated-listener setup refuses wildcards and applies idempotent named-section commands", () => { + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "firewall-visualiser-setup-")); + const log = path.join(temporary, "uci.log"); + const uci = path.join(temporary, "uci"); + const init = path.join(temporary, "uhttpd"); + makeExecutable(uci, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${log}"\n`); + makeExecutable(init, "#!/bin/sh\nexit 0\n"); + const env = { + ...process.env, + FIREWALL_VISUALISER_UCI_BIN: uci, + FIREWALL_VISUALISER_UHTTPD_INIT: init + }; + + const refused = childProcess.spawnSync("sh", [script, "0.0.0.0", "8443"], { env, encoding: "utf8" }); + assert.equal(refused.status, 2); + assert.match(refused.stderr, /Wildcard listeners are refused/); + assert.equal(fs.existsSync(log), false); + + childProcess.execFileSync("sh", [script, "192.168.50.1", "8443"], { env, stdio: "pipe" }); + const first = fs.readFileSync(log, "utf8"); + fs.writeFileSync(log, ""); + childProcess.execFileSync("sh", [script, "192.168.50.1", "8443"], { env, stdio: "pipe" }); + const second = fs.readFileSync(log, "utf8"); + + assert.equal(first, second); + assert.match(first, /-q delete uhttpd\.firewall_visualiser/); + assert.match(first, /add_list uhttpd\.firewall_visualiser\.listen_https=192\.168\.50\.1:8443/); + assert.doesNotMatch(first, /(?:^|\s)firewall(?:\.|\s|$)|commit firewall/m); + + fs.writeFileSync(log, ""); + childProcess.execFileSync("sh", [script, "::", "9443", "--allow-wildcard"], { env, stdio: "pipe" }); + assert.match(fs.readFileSync(log, "utf8"), /listen_https=\[::\]:9443/); +}); diff --git a/tests/openwrt/ucode-contract.test.js b/tests/openwrt/ucode-contract.test.js new file mode 100644 index 0000000..6db4e98 --- /dev/null +++ b/tests/openwrt/ucode-contract.test.js @@ -0,0 +1,28 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const fs = require("node:fs"); +const path = require("node:path"); + +const sourcePath = path.resolve(__dirname, "../../packaging/openwrt/package/files/usr/share/rpcd/ucode/firewall-visualiser.uc"); +const source = fs.readFileSync(sourcePath, "utf8"); + +test("ucode service exposes the fixed read-only method set", () => { + for (const method of ["capabilities", "snapshot", "firewall", "devices", "interfaces", "health"]) { + assert.match(source, new RegExp(`\\b${method}: \\{ call:`)); + } + + assert.match(source, /'firewall\.visualiser'/); + assert.match(source, /max_devices/); + assert.match(source, /max_neighbours/); + assert.match(source, /length\(rules\) >= 1000/); + assert.match(source, /length\(result\) >= 128/); +}); + +test("ucode service uses fixed providers and has no command execution surface", () => { + assert.doesNotMatch(source, /\b(?:popen|system|exec)\s*\(/); + assert.doesNotMatch(source, /request\.args|args:\s*\{/); + assert.deepEqual( + Array.from(source.matchAll(/readfile\((['"])(.*?)\1\)/g), (match) => match[2]).sort(), + ["/proc/meminfo", "/proc/net/arp", "/proc/uptime", "/tmp/dhcp.leases"].sort() + ); +}); From 6fa27b8b98af2fad891f2b26803be105876d7213 Mon Sep 17 00:00:00 2001 From: Zepher Ashe Date: Fri, 17 Jul 2026 16:14:48 +0100 Subject: [PATCH 2/5] Update prepare-openwrt-feed.sh --- scripts/prepare-openwrt-feed.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/prepare-openwrt-feed.sh b/scripts/prepare-openwrt-feed.sh index 1a022cb..2052f0a 100755 --- a/scripts/prepare-openwrt-feed.sh +++ b/scripts/prepare-openwrt-feed.sh @@ -2,7 +2,7 @@ set -eu -ROOT_DIR=$(CDPATH= cd -- "$(dirname "$0")/.." || exit 1; pwd) +ROOT_DIR=$(CDPATH='' cd -- "$(dirname "$0")/.." || exit 1; pwd) SOURCE_DIR="$ROOT_DIR/packaging/openwrt/package" BUILD_ROOT="$ROOT_DIR/.build/openwrt-feed" PACKAGE_DIR="$BUILD_ROOT/openwrt-firewall-visualiser" From 7dfe5127d365bef9f6576fe79d319923e8bb5074 Mon Sep 17 00:00:00 2001 From: Zepher Ashe Date: Fri, 17 Jul 2026 16:19:11 +0100 Subject: [PATCH 3/5] Refactor SSH test command for clarity Split the complex single-line SSH command into separate steps to improve readability. The change separates the file existence check from the UCI value verification, making the test easier to understand and debug. --- scripts/ci/openwrt-qemu-smoke.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci/openwrt-qemu-smoke.sh b/scripts/ci/openwrt-qemu-smoke.sh index 0bcc095..eba75e6 100755 --- a/scripts/ci/openwrt-qemu-smoke.sh +++ b/scripts/ci/openwrt-qemu-smoke.sh @@ -168,6 +168,8 @@ firewall_after=$(ssh_router "uci show firewall | sha256sum | cut -d' ' -f1") } ssh_router "uci set firewall-visualiser.main.max_devices=251; uci commit firewall-visualiser; opkg remove openwrt-firewall-visualiser" -ssh_router 'test ! -e /www/openwrt-firewall-visualiser/index.html && test "$(uci -q get firewall-visualiser.main.max_devices)" = 251' +ssh_router test ! -e /www/openwrt-firewall-visualiser/index.html +saved_max_devices=$(ssh_router uci -q get firewall-visualiser.main.max_devices) +[ "$saved_max_devices" = 251 ] echo "OpenWrt QEMU smoke test passed." From e39a9043e07e0b2dfa41ec585fe4df6982904b6d Mon Sep 17 00:00:00 2001 From: Zepher Ashe Date: Fri, 17 Jul 2026 16:24:30 +0100 Subject: [PATCH 4/5] Update firewall-visualiser-setup --- .../openwrt/package/files/usr/sbin/firewall-visualiser-setup | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup b/packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup index 188370a..1269157 100755 --- a/packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup +++ b/packaging/openwrt/package/files/usr/sbin/firewall-visualiser-setup @@ -34,10 +34,10 @@ if [ "${1:-}" = "--disable" ]; then exit 0 fi -[ "$#" -ge 2 ] && [ "$#" -le 3 ] || { +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then usage >&2 exit 2 -} +fi address=$1 port=$2 From ed82a9559131381276e9c075a9d666090169bde1 Mon Sep 17 00:00:00 2001 From: Zepher Ashe Date: Sun, 26 Jul 2026 13:49:43 +0100 Subject: [PATCH 5/5] Use OpenWrt-compatible feed name OpenWrt feed names cannot contain hyphens. Changed FEEDNAME from 'firewall-visualiser' to 'firewall_visualiser' to comply with OpenWrt naming requirements. Added a test to enforce this constraint going forward. --- .github/workflows/openwrt.yml | 3 +-- ARCHITECTURE.md | 2 +- tests/openwrt/package-layout.test.js | 9 +++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/openwrt.yml b/.github/workflows/openwrt.yml index c6f5f52..7b6f34f 100644 --- a/.github/workflows/openwrt.yml +++ b/.github/workflows/openwrt.yml @@ -93,7 +93,7 @@ jobs: env: ARCH: ${{ matrix.sdk }} FEED_DIR: ${{ github.workspace }}/.build/openwrt-feed - FEEDNAME: firewall-visualiser + FEEDNAME: firewall_visualiser PACKAGES: openwrt-firewall-visualiser ARTIFACTS_DIR: ${{ github.workspace }}/artifacts BUILD_LOG: 1 @@ -137,4 +137,3 @@ jobs: name: openwrt-firewall-visualiser-qemu-diagnostics if-no-files-found: warn path: runtime-artifacts/qemu-diagnostics/ - diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1dcf177..7da9774 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1231,7 +1231,7 @@ jobs: env: ARCH: ${{ matrix.sdk }} FEED_DIR: ${{ github.workspace }}/.build/openwrt-feed - FEEDNAME: firewall-visualiser + FEEDNAME: firewall_visualiser PACKAGES: openwrt-firewall-visualiser ARTIFACTS_DIR: ${{ github.workspace }}/artifacts BUILD_LOG: 1 diff --git a/tests/openwrt/package-layout.test.js b/tests/openwrt/package-layout.test.js index 27096f5..fde6920 100644 --- a/tests/openwrt/package-layout.test.js +++ b/tests/openwrt/package-layout.test.js @@ -9,6 +9,7 @@ const stageScript = path.join(root, "scripts/prepare-openwrt-feed.sh"); const buildRoot = path.join(root, ".build/openwrt-feed"); const packageRoot = path.join(buildRoot, "openwrt-firewall-visualiser"); const manifestPath = path.join(buildRoot, "openwrt-firewall-visualiser.manifest"); +const workflowPath = path.join(root, ".github/workflows/openwrt.yml"); function stage() { childProcess.execFileSync("sh", [stageScript], { cwd: root, stdio: "pipe" }); @@ -75,3 +76,11 @@ test("staging refuses credential-like files", () => { stage(); } }); + +test("SDK workflow uses an OpenWrt-compatible feed name", () => { + const workflow = fs.readFileSync(workflowPath, "utf8"); + const match = workflow.match(/^\s*FEEDNAME:\s*(\S+)\s*$/m); + + assert.notEqual(match, null, "FEEDNAME is required by gh-action-sdk"); + assert.match(match[1], /^\w+$/, "OpenWrt feed names cannot contain hyphens"); +});