From b37e0d6bcef6993193fdb8878baf3995760c1cf1 Mon Sep 17 00:00:00 2001 From: Koda YeenBean Date: Sun, 19 Jul 2026 20:25:12 +0000 Subject: [PATCH] refactor #1 + adding documentation --- .gitea/workflows/documentation.yaml | 20 + .gitea/workflows/release.yaml | 2 + AGENTS.md | 17 +- Makefile | 29 +- Makefile.standalone | 14 +- README.md | 40 +- scripts/docs/generate-wiki.py | 581 ++++++++ scripts/docs/publish-wiki.sh | 55 + src/announce.c | 6 +- src/inform/inform.c | 164 +++ src/inform/inform_internal.h | 34 + src/inform/packet.c | 148 +++ src/{inform.c => inform/payload.c} | 667 +--------- src/inform/response.c | 361 +++++ src/lldp.c | 2 +- src/lldp.h | 2 +- src/main.c | 4 +- src/sysinfo.h | 4 +- src/wlan.c | 1906 --------------------------- src/wlan/common.c | 229 ++++ src/wlan/legacy.c | 271 ++++ src/wlan/provision.c | 586 ++++++++ src/wlan/radio.c | 318 +++++ src/wlan/telemetry.c | 166 +++ src/wlan/uci.c | 310 +++++ src/wlan/wlan_internal.h | 60 + wiki/Call-Graph.md | 411 ++++++ wiki/Developer-Guide.md | 166 +++ wiki/Home.md | 13 + wiki/Runtime-Flow.md | 76 ++ wiki/_Sidebar.md | 4 + 31 files changed, 4068 insertions(+), 2598 deletions(-) create mode 100644 .gitea/workflows/documentation.yaml create mode 100755 scripts/docs/generate-wiki.py create mode 100755 scripts/docs/publish-wiki.sh create mode 100644 src/inform/inform.c create mode 100644 src/inform/inform_internal.h create mode 100644 src/inform/packet.c rename src/{inform.c => inform/payload.c} (58%) create mode 100644 src/inform/response.c delete mode 100644 src/wlan.c create mode 100644 src/wlan/common.c create mode 100644 src/wlan/legacy.c create mode 100644 src/wlan/provision.c create mode 100644 src/wlan/radio.c create mode 100644 src/wlan/telemetry.c create mode 100644 src/wlan/uci.c create mode 100644 src/wlan/wlan_internal.h create mode 100644 wiki/Call-Graph.md create mode 100644 wiki/Developer-Guide.md create mode 100644 wiki/Home.md create mode 100644 wiki/Runtime-Flow.md create mode 100644 wiki/_Sidebar.md diff --git a/.gitea/workflows/documentation.yaml b/.gitea/workflows/documentation.yaml new file mode 100644 index 0000000..a346616 --- /dev/null +++ b/.gitea/workflows/documentation.yaml @@ -0,0 +1,20 @@ +name: Validate source documentation + +on: + pull_request: + workflow_dispatch: + +permissions: + code: read + +jobs: + generated-wiki: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 2 + - name: Check generated Gitea Wiki pages + run: ./scripts/docs/generate-wiki.py --check + - name: Check patch whitespace + run: git diff --check HEAD^ diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index b14f245..daa6e6a 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -22,6 +22,8 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Verify generated developer Wiki + run: ./scripts/docs/generate-wiki.py --check - name: Install metadata dependencies run: sudo apt-get update && sudo apt-get install --yes jq - name: Read release configuration diff --git a/AGENTS.md b/AGENTS.md index 74962ed..9e52e7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,16 +19,18 @@ threading or event framework; the main loop runs once per second. | Lifecycle and scheduling | `src/main.c` | Load config/state, choose model, run announce/inform/LLDP timers | | User configuration | `src/config.[ch]`, `files/openuf.conf` | Parse static daemon settings and defaults | | Persistent controller state | `src/state.[ch]` | Read/write `/etc/openuf/state.json` | -| Inform/adoption protocol | `src/inform.[ch]` | Build telemetry, encode TNBU packets, handle controller commands | +| Inform exchange | `src/inform/inform.c`, `src/inform.h` | Orchestrate payload, TNBU, HTTP, and response stages | +| Inform internals | `src/inform/{payload,packet,response}.c` | Build telemetry, encode TNBU packets, handle commands | | Transport and encryption | `src/http.[ch]`, `src/crypto.[ch]` | Raw HTTP/1.0 client and AES-CBC/AES-GCM helpers | -| WiFi provisioning | `src/wlan.[ch]` | Translate controller config to UCI and report VAP state | +| WiFi provisioning | `src/wlan/{provision,legacy,radio,uci}.c`, `src/wlan.h` | Translate controller config to UCI | +| WiFi telemetry/helpers | `src/wlan/{telemetry,common}.c` | Report VAP state and share translations | | Device telemetry | `src/sysinfo.[ch]`, `src/clients.[ch]` | Read proc/sysfs/`iw`/bridge/dnsmasq data | | Discovery/topology | `src/announce.[ch]`, `src/lldp.[ch]` | UniFi UDP announce and LLDP send/read | | Emulated hardware | `src/models.c`, `src/ufmodel.h` | Model registry, radios, ports, and band-to-radio mapping | | Packaging/service | `Makefile`, `files/openuf.init` | OpenWrt package recipe and procd service | Runtime flow: `main()` -> `inform_send()` -> build telemetry -> encrypt -> -`http_post()` -> decrypt response -> `handle_response()` -> optionally +`http_post()` -> decrypt response -> `inform_handle_response()` -> optionally `wlan_apply_config()`/`wlan_apply_system_cfg()` and `state_save()`. ## Important invariants @@ -70,6 +72,15 @@ make package/OpenUniFi/compile `Makefile.standalone` is for compiling on an OpenWrt device with development packages installed; it is not a host-side substitute for the cross-build. + +Architecture pages for the Gitea Wiki are generated from the recursive C source +tree. After source or architecture changes, run: + +```sh +./scripts/docs/generate-wiki.py +./scripts/docs/generate-wiki.py --check +``` + There is currently no automated test suite. For documentation-only changes, check paths and commands against the source and package Makefile. For C changes, the cross-build is the minimum verification; report any device/controller diff --git a/Makefile b/Makefile index 960d6fe..d275e87 100644 --- a/Makefile +++ b/Makefile @@ -26,27 +26,28 @@ endef define Build/Prepare mkdir -p $(PKG_BUILD_DIR) - $(CP) ./src/* $(PKG_BUILD_DIR)/ + $(CP) ./src/. $(PKG_BUILD_DIR)/ endef -TARGET_CFLAGS += -I$(STAGING_DIR)/usr/include -DENABLE_LOGGING=1 +OPENUF_SRCS := \ + main.c config.c state.c crypto.c http.c announce.c \ + inform/inform.c inform/payload.c inform/packet.c inform/response.c \ + wlan/common.c wlan/uci.c wlan/radio.c wlan/provision.c \ + wlan/legacy.c wlan/telemetry.c \ + sysinfo.c clients.c lldp.c models.c + +TARGET_CFLAGS += \ + -I$(STAGING_DIR)/usr/include \ + -I$(PKG_BUILD_DIR) \ + -I$(PKG_BUILD_DIR)/inform \ + -I$(PKG_BUILD_DIR)/wlan \ + -DENABLE_LOGGING=1 TARGET_LDFLAGS += -lmbedtls -lmbedcrypto -luci -ljson-c define Build/Compile $(TARGET_CC) $(TARGET_CFLAGS) $(TARGET_LDFLAGS) \ -o $(PKG_BUILD_DIR)/openuf \ - $(PKG_BUILD_DIR)/main.c \ - $(PKG_BUILD_DIR)/config.c \ - $(PKG_BUILD_DIR)/state.c \ - $(PKG_BUILD_DIR)/crypto.c \ - $(PKG_BUILD_DIR)/http.c \ - $(PKG_BUILD_DIR)/announce.c \ - $(PKG_BUILD_DIR)/inform.c \ - $(PKG_BUILD_DIR)/wlan.c \ - $(PKG_BUILD_DIR)/sysinfo.c \ - $(PKG_BUILD_DIR)/clients.c \ - $(PKG_BUILD_DIR)/lldp.c \ - $(PKG_BUILD_DIR)/models.c + $(addprefix $(PKG_BUILD_DIR)/,$(OPENUF_SRCS)) endef define Package/openuf/install diff --git a/Makefile.standalone b/Makefile.standalone index 55c4178..f562439 100644 --- a/Makefile.standalone +++ b/Makefile.standalone @@ -10,7 +10,7 @@ # make -f Makefile.standalone install CC = gcc -CFLAGS = -Wall -Wextra -O2 -I/usr/include -DENABLE_LOGGING=1 +CFLAGS = -Wall -Wextra -O2 -I/usr/include -Isrc -Isrc/inform -Isrc/wlan -DENABLE_LOGGING=1 LDFLAGS = -lmbedtls -lmbedcrypto -luci -ljson-c SRCS = src/main.c \ @@ -19,8 +19,16 @@ SRCS = src/main.c \ src/crypto.c \ src/http.c \ src/announce.c \ - src/inform.c \ - src/wlan.c \ + src/inform/inform.c \ + src/inform/payload.c \ + src/inform/packet.c \ + src/inform/response.c \ + src/wlan/common.c \ + src/wlan/uci.c \ + src/wlan/radio.c \ + src/wlan/provision.c \ + src/wlan/legacy.c \ + src/wlan/telemetry.c \ src/sysinfo.c \ src/clients.c \ src/lldp.c \ diff --git a/README.md b/README.md index 4c45b1a..da65c50 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,13 @@ Daemon that makes an OpenWrt router appear as a UniFi AP to UniFi Network contro | Feature | Description | Implementation | | --- | --- | --- | | **L2 Discovery** | UDP broadcast + multicast every 10s | `announce.c` → port 10001 | -| **Adoption** | AES-128-CBC handshake with the controller | `inform.c` → `handle_response()` | -| **Remote reboot** | Reboots OpenWrt when requested by the controller | `inform.c` → `handle_response()` | -| **Firmware spoofing** | Persists and reports the target version requested by an upgrade | `inform.c` → `handle_response()` | -| **WiFi Config** | Creates WiFi networks from the controller via UCI | `wlan.c` → `wlan_apply_config()` | -| **Band Steering** | 802.11k/v Neighbor Reports + BSS Transition | `wlan.c` → `apply_vap()` | -| **Fast Roaming** | 802.11r FT with mobility_domain derived from MAC | `wlan.c` → `apply_vap()` | -| **WPA3 / PMF** | SAE, SAE-mixed, 802.11w 0/1/2 | `wlan.c` → `sec_to_uci()` | +| **Adoption** | AES-128-CBC/GCM handshake with the controller | `inform/response.c` → `inform_handle_response()` | +| **Remote reboot** | Reboots OpenWrt when requested by the controller | `inform/response.c` → `inform_handle_response()` | +| **Firmware spoofing** | Persists and reports the target version requested by an upgrade | `inform/response.c` → `inform_handle_response()` | +| **WiFi Config** | Creates WiFi networks from the controller via UCI | `wlan/provision.c` → `wlan_apply_config()` | +| **Band Steering** | 802.11k/v Neighbor Reports + BSS Transition | `wlan/provision.c` and `wlan/uci.c` | +| **Fast Roaming** | 802.11r FT with a stable SSID mobility domain | `wlan/provision.c` → `apply_vap()` | +| **WPA3 / PMF** | SAE, SAE-mixed, 802.11w 0/1/2 | `wlan/common.c` and `wlan/provision.c` | | **WiFi Clients** | MAC, signal, bitrate, bytes per VAP | `clients.c` → `iw station dump` | | **Wired Clients** | MACs from bridge FDB | `clients.c` → `bridge fdb` | | **CPU / RAM** | Real-time usage | `sysinfo.c` → `/proc/stat` + `/proc/meminfo` | @@ -46,6 +46,28 @@ make -f Makefile.standalone Contributors and AI agents should read [`AGENTS.md`](AGENTS.md) for the concise architecture map, invariants, and validation checklist. +## Developer documentation and Gitea Wiki + +The publish-ready Gitea Wiki pages live in [`wiki/`](wiki/). They include a +developer guide, a generated Mermaid function-call graph, and runtime flow +charts. Refresh them after source or architecture changes: + +```shell +./scripts/docs/generate-wiki.py +``` + +Use the same tool as a linter to reject stale pages, oversized C modules, +missing documented entry points, or known non-English comment fragments: + +```shell +./scripts/docs/generate-wiki.py --check +``` + +Because Gitea stores Wiki pages in a separate Git repository, publishing is an +explicit authenticated step: `./scripts/docs/publish-wiki.sh`. The script +derives the `.wiki.git` URL from `origin` and accepts an explicit URL override. +No credentials are stored in this repository. + ## Automated releases Every push to `main` runs `.gitea/workflows/release.yaml`. It builds with pinned @@ -106,7 +128,7 @@ On the access point, run the following to install the package: apk del wpad-basic-mbedtls && apk add wpad-mbedtls && /etc/init.d/network restart # Remove old installation -apk -r del openuf +apk -r del openuf # Install new version apk add openuf-0.4.0-r3.apk --allow-untrusted @@ -146,7 +168,7 @@ version in `state.json` and reports it in subsequent inform packets. ## Glossary -### TNBU +### TNBU TNBU is the magic string/identifier at the start of the binary packet format used in this custom Inform protocol implementation. diff --git a/scripts/docs/generate-wiki.py b/scripts/docs/generate-wiki.py new file mode 100755 index 0000000..07bf88d --- /dev/null +++ b/scripts/docs/generate-wiki.py @@ -0,0 +1,581 @@ +#!/usr/bin/env python3 +"""Generate and lint the openUF Gitea Wiki architecture documentation.""" + +from __future__ import annotations + +import argparse +import dataclasses +import re +import sys +from pathlib import Path + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +SOURCE_ROOT = REPOSITORY_ROOT / "src" +DEFAULT_WIKI_ROOT = REPOSITORY_ROOT / "wiki" +MAX_IMPLEMENTATION_LINES = 900 + +CONTROL_WORDS = { + "for", "if", "return", "sizeof", "switch", "while", "do", + "case", "defined", "typeof", "alignof", +} + +NON_ENGLISH_COMMENT_WORDS = { + "algunos", "configuración", "configuramos", "controlador", "cuando", + "contraseña", "desde", "descripción", "envía", "lectura", "modelo", + "obligatorio", "opcional", "para", "puerto", "requiere", "siempre", + "sin", "tienen", "traduce", +} + +FUNCTION_PATTERN = re.compile( + r"(?m)^[ \t]*" + r"(?P(?:static\s+)?(?:inline\s+)?(?:const\s+)?" + r"(?:(?:struct|enum)\s+[A-Za-z_]\w*\s*\*?\s*|" + r"[A-Za-z_]\w*(?:\s+|\s*\*+\s*))+?)" + r"(?P[A-Za-z_]\w*)\s*" + r"\((?P[^;{}]*)\)\s*\{" +) + + +@dataclasses.dataclass(frozen=True) +class Function: + name: str + relative_file: str + line: int + body: str + is_static: bool + + @property + def node_id(self) -> str: + raw = f"{self.relative_file}_{self.name}" + return "function_" + re.sub(r"[^A-Za-z0-9_]", "_", raw) + + +MODULE_DESCRIPTIONS = { + "main.c": "Daemon lifecycle and one-second scheduler.", + "config.c": "Static daemon configuration parser and defaults.", + "state.c": "Persistent adoption and controller state.", + "models.c": "Emulated hardware model registry.", + "announce.c": "UniFi layer-2 UDP discovery.", + "lldp.c": "LLDP frame transmission and neighbor collection.", + "http.c": "Minimal HTTP/1.0 transport.", + "crypto.c": "AES-CBC, AES-GCM, and encoding helpers.", + "sysinfo.c": "Kernel and nl80211 device telemetry.", + "clients.c": "Wireless and bridge client telemetry.", + "inform/inform.c": "One inform request/response exchange.", + "inform/payload.c": "Inform JSON telemetry assembly.", + "inform/packet.c": "TNBU binary envelope codec.", + "inform/response.c": "Controller command and provisioning dispatch.", + "wlan/common.c": "Shared Wi-Fi translations and validators.", + "wlan/uci.c": "Reusable UCI, VLAN, steering, and cleanup operations.", + "wlan/radio.c": "Runtime radio mapping and radio settings.", + "wlan/provision.c": "Modern setstate Wi-Fi provisioning.", + "wlan/legacy.c": "Legacy system_cfg translation.", + "wlan/telemetry.c": "Managed VAP telemetry from UCI and nl80211.", +} + + +def mask_comments_and_literals(source: str) -> str: + """Replace comments and literals with spaces while preserving newlines.""" + output = list(source) + index = 0 + state = "code" + while index < len(source): + current = source[index] + following = source[index + 1] if index + 1 < len(source) else "" + if state == "code" and current == "/" and following == "*": + output[index] = output[index + 1] = " " + state = "block_comment" + index += 2 + continue + if state == "code" and current == "/" and following == "/": + output[index] = output[index + 1] = " " + state = "line_comment" + index += 2 + continue + if state == "code" and current in {'"', "'"}: + output[index] = " " + state = "string" if current == '"' else "character" + index += 1 + continue + if state == "block_comment": + if current == "*" and following == "/": + output[index] = output[index + 1] = " " + state = "code" + index += 2 + continue + if current != "\n": + output[index] = " " + elif state == "line_comment": + if current == "\n": + state = "code" + else: + output[index] = " " + elif state in {"string", "character"}: + if current == "\\" and following: + output[index] = " " + if following != "\n": + output[index + 1] = " " + index += 2 + continue + terminator = '"' if state == "string" else "'" + if current == terminator: + state = "code" + if current != "\n": + output[index] = " " + index += 1 + return "".join(output) + + +def matching_brace(source: str, opening_brace: int) -> int: + depth = 0 + for index in range(opening_brace, len(source)): + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return index + raise ValueError(f"unmatched opening brace at byte {opening_brace}") + + +def discover_functions() -> list[Function]: + functions: list[Function] = [] + for path in sorted(SOURCE_ROOT.rglob("*.c")): + source = path.read_text(encoding="utf-8") + masked = mask_comments_and_literals(source) + relative_file = path.relative_to(SOURCE_ROOT).as_posix() + for match in FUNCTION_PATTERN.finditer(masked): + name = match.group("name") + if name in CONTROL_WORDS: + continue + opening_brace = match.end() - 1 + try: + closing_brace = matching_brace(masked, opening_brace) + except ValueError as error: + raise ValueError( + f"cannot parse {relative_file}:{name}: {error}") from error + functions.append(Function( + name=name, + relative_file=relative_file, + line=source.count("\n", 0, match.start()) + 1, + body=masked[opening_brace + 1:closing_brace], + is_static="static" in match.group("prefix").split(), + )) + return functions + + +def resolve_calls(functions: list[Function]) -> dict[Function, list[Function]]: + by_name: dict[str, list[Function]] = {} + for function in functions: + by_name.setdefault(function.name, []).append(function) + + result: dict[Function, list[Function]] = {} + for caller in functions: + callees: set[Function] = set() + for name in re.findall(r"\b([A-Za-z_]\w*)\s*\(", caller.body): + candidates = by_name.get(name, []) + local_candidates = [ + candidate for candidate in candidates + if candidate.relative_file == caller.relative_file + ] + if local_candidates: + callees.update(local_candidates) + elif len(candidates) == 1: + callees.add(candidates[0]) + else: + callees.update( + candidate for candidate in candidates + if not candidate.is_static + ) + result[caller] = sorted( + callees, key=lambda item: (item.relative_file, item.line, item.name) + ) + return result + + +def module_rows() -> str: + rows = [] + for path in sorted(SOURCE_ROOT.rglob("*.c")): + relative = path.relative_to(SOURCE_ROOT).as_posix() + description = MODULE_DESCRIPTIONS.get(relative, "Implementation module.") + rows.append(f"| `src/{relative}` | {description} |") + return "\n".join(rows) + + +def developer_guide() -> str: + return f"""# Developer Guide + +This guide describes the current openUF implementation. The daemon is a +single-process, single-threaded OpenWrt service. Its main loop wakes once per +second and schedules discovery, LLDP, and controller inform work. + +## Safety first + +openUF runs as root. It can replace managed Wi-Fi configuration, create VLAN +devices, send raw Ethernet frames, reboot the device, and persist adoption +credentials. Do not run the daemon itself on a development workstation. +Compile it with the OpenWrt toolchain and perform runtime tests on a disposable +OpenWrt access point. + +Protocol debug level 2 can log decrypted credentials. Never enable it by +default, attach those logs to issues, or commit controller payload captures. + +## Source layout + +| Module | Responsibility | +| --- | --- | +{module_rows()} + +Public contracts remain in `src/*.h`. The `src/inform/` and `src/wlan/` +directories contain private implementation units; their `*_internal.h` files +are not stable interfaces for other subsystems. + +## Runtime architecture + +`main()` loads static configuration, persistent controller state, and the +emulated model. It discovers the LAN MAC/IP and an initial controller from the +default route when no explicit controller is configured. The main loop then +schedules: + +1. `announce_send()` for UniFi UDP discovery. +2. `lldp_send_frame()` for each model Ethernet port. +3. `inform_send()` for adoption, telemetry, commands, and provisioning. + +See [[Runtime Flow|Runtime-Flow]] for the decision flow and +[[Function Call Graph|Call-Graph]] for generated caller/callee relationships. + +## Inform and adoption invariants + +- The TNBU layout, big-endian fields, flag meanings, authenticated header, and + cipher selection are controller compatibility constraints. +- An unadopted device always encrypts with `DEFAULT_AUTH_KEY`, even if stale + state contains another key. +- The controller response is decoded before command dispatch. Adoption can be + completed through either legacy `set-adopt` or modern `setparam` data. +- `cfgversion` records configuration that was successfully applied locally. + Failed provisioning resets it to `"0"` so the controller retries. +- Increase `OPENUF_CONFIG_SCHEMA` only when an existing persisted + configuration must be reapplied after an upgrade. + +## Wi-Fi ownership and provisioning + +Only UCI `wifi-iface` sections prefixed with `openuf_` belong to this daemon. +Cleanup must preserve every unrelated section. Controller VAP ObjectIds are +stored in `openuf_vap_id` so client topology remains stable across reprovision. + +Modern `setstate` data is applied by `wlan_apply_config()`. Legacy +newline-separated `system_cfg` data is first translated to the same JSON shape +by `wlan_apply_system_cfg()`, keeping one provisioning path responsible for UCI +commits and radio startup. + +Model band and port assumptions belong in `models.c`. The runtime radio mapper +may resolve a model band to a different local PHY, but protocol and telemetry +code must not invent model-specific mappings. + +## Common development tasks + +### Add telemetry + +1. Add a bounded reader to `sysinfo.c`, `clients.c`, or another focused module. +2. Add the controller field in `inform/payload.c`. +3. Document units, fallback behavior, ownership, and any sensitive content. +4. Regenerate this Wiki and cross-build the package. + +### Add a controller command + +1. Extend dispatch in `inform/response.c`. +2. Validate controller values before changing state or invoking a command. +3. Save state only after a coherent transition. +4. Preserve adoption-key and `cfgversion` behavior. + +### Add a Wi-Fi setting + +1. Parse controller aliases in `wlan/provision.c` or `wlan/legacy.c`. +2. Put reusable UCI work in `wlan/uci.c` and translations in `wlan/common.c`. +3. Report the effective value from `wlan/telemetry.c` when the controller + expects it in `vap_table`. +4. Test on device; a successful cross-build cannot validate netifd/hostapd + behavior. + +### Add or change a model + +Update `ufmodel.h`, the complete model entry in `models.c`, and every affected +telemetry or WLAN consumer together. Do not scatter model checks across the +protocol implementation. + +## Build and validation + +From the OpenWrt root: + +```sh +make package/OpenUniFi/compile +``` + +Use `V=s` for detailed compiler diagnostics. If a clean package rebuild is +needed, clean only this package: + +```sh +make package/OpenUniFi/clean +make package/OpenUniFi/compile +``` + +`Makefile.standalone` is only for compiling directly on an OpenWrt device with +development packages installed. It is not a host-side test substitute. + +## Documentation workflow + +Generate pages after changing C code or architecture: + +```sh +./scripts/docs/generate-wiki.py +``` + +Run the linter-style drift and structure check in CI or before committing: + +```sh +./scripts/docs/generate-wiki.py --check +``` + +The checker parses C functions recursively, rebuilds internal call edges, +validates required runtime entry points, rejects known non-English comment +fragments, and limits each implementation unit to +{MAX_IMPLEMENTATION_LINES} lines. + +## Publishing to the Gitea Wiki + +Gitea stores a repository Wiki in a separate Git repository whose URL normally +ends in `.wiki.git`. The generated `wiki/` directory is ready for that remote. +Run `scripts/docs/publish-wiki.sh` from a trusted machine with suitable +credentials. It derives the Wiki remote from `origin`; an explicit URL may be +passed when needed. The publisher regenerates and checks the pages, +updates only the generated Markdown files, commits changed pages, and pushes +them to the Wiki repository. +""" + + +def home_page() -> str: + return """# openUF Developer Wiki + +This Wiki is generated from the current source tree and maintained in the main +repository so architecture documentation changes can be reviewed with code. + +- [[Developer Guide|Developer-Guide]] — architecture, invariants, extension + points, build validation, and Wiki publishing. +- [[Runtime Flow|Runtime-Flow]] — lifecycle, inform, response, and Wi-Fi + provisioning flow charts. +- [[Function Call Graph|Call-Graph]] — generated internal caller/callee graph + and searchable function table. + +Run `./scripts/docs/generate-wiki.py --check` to verify these pages are current. +""" + + +def call_graph_page(functions: list[Function], calls: dict[Function, list[Function]]) -> str: + lines = [ + "# Function Call Graph", "", + "Generated from `src/**/*.c` by `scripts/docs/generate-wiki.py`.", "", + "```mermaid", "flowchart LR", + ] + by_file: dict[str, list[Function]] = {} + for function in functions: + by_file.setdefault(function.relative_file, []).append(function) + for index, (relative_file, file_functions) in enumerate(sorted(by_file.items())): + lines.append(f' subgraph module_{index}["src/{relative_file}"]') + for function in file_functions: + lines.append(f' {function.node_id}["{function.name}()"]') + lines.append(" end") + for caller in functions: + for callee in calls[caller]: + lines.append(f" {caller.node_id} --> {callee.node_id}") + lines.extend(["```", "", "## Caller/callee index", "", + "| Source | Caller | Internal callees |", + "| --- | --- | --- |"]) + for caller in functions: + callees = ", ".join( + f"`{callee.name}()`" for callee in calls[caller] + ) or "—" + lines.append( + f"| `src/{caller.relative_file}:{caller.line}` | " + f"`{caller.name}()` | {callees} |" + ) + return "\n".join(lines) + "\n" + + +def runtime_flow_page() -> str: + return """# Runtime Flow + +The flow is generated alongside the call graph. Function names are validated +against the current C sources so renamed or removed stages fail documentation +checks. + +## Daemon scheduler + +```mermaid +flowchart TD + start([main]) --> config[config_load] + config --> state[state_load] + state --> model[ufmodel_find] + model --> identity[Read LAN MAC, IP, and default gateway] + identity --> persist[state_save] + persist --> announceInit[announce_init when enabled] + announceInit --> loop{One-second main loop} + loop -->|announce due| announce[announce_send] + announce --> loop + loop -->|LLDP due| ports{For every model port} + ports --> lldp[lldp_send_frame] + lldp --> loop + loop -->|inform due| refresh[Refresh controller URL and device IP] + refresh --> inform[inform_send] + inform --> loop + loop -->|nothing due| sleep[sleep 1 second] + sleep --> loop +``` + +## Inform exchange + +```mermaid +flowchart TD + send([inform_send]) --> url{inform URL available?} + url -->|no| error[Return an error] + url -->|yes| key{Device adopted?} + key -->|no| defaultKey[Use DEFAULT_AUTH_KEY] + key -->|yes| stateKey[Use persisted device key] + defaultKey --> payload[inform_build_payload] + stateKey --> payload + payload --> packet[inform_packet_build] + packet --> post[http_post] + post --> status{HTTP 200?} + status -->|no, first attempt| retry[Retry with alternate CBC or GCM cipher] + retry --> packet + status -->|no, final attempt| error + status -->|yes| decode[inform_packet_parse] + decode --> json[Parse controller JSON] + json --> handle[inform_handle_response] +``` + +## Controller response and provisioning + +```mermaid +flowchart TD + response([inform_handle_response]) --> type{_type} + type -->|noop| done[No state change] + type -->|upgrade| version[Persist requested firmware version] + type -->|reboot or reset| reboot[reboot_openwrt] + type -->|cmd adopt| adopt[Persist key, URL, and adopted state] + type -->|setparam| params[Parse management parameters] + params --> legacy{system_cfg present?} + legacy -->|yes| legacyApply[wlan_apply_system_cfg] + legacyApply --> normalize[Build radio_table and vap_table JSON] + normalize --> apply[wlan_apply_config] + type -->|setstate| apply + apply --> ids[wlan_ensure_vap_ids] + ids --> clear[wlan_clear managed openuf_ VAPs] + clear --> radios[wlan_apply_radio for each band] + radios --> vaps[Create controller VAP sections] + vaps --> commit[Commit UCI and configure usteer] + commit --> start[Start and verify radios sequentially] + start --> result{Apply succeeded?} + result -->|yes| saved[Save cfgversion and config schema] + result -->|no| retryConfig[Reset cfgversion to 0] +``` +""" + + +def sidebar() -> str: + return """- [[Home]] +- [[Developer Guide|Developer-Guide]] +- [[Runtime Flow|Runtime-Flow]] +- [[Function Call Graph|Call-Graph]] +""" + + +def validate_source(functions: list[Function]) -> list[str]: + errors: list[str] = [] + names = {function.name for function in functions} + required = { + "main", "config_load", "state_load", "state_save", "ufmodel_find", + "announce_init", "announce_send", "lldp_send_frame", "inform_send", + "inform_build_payload", "inform_packet_build", "http_post", + "inform_packet_parse", "inform_handle_response", "reboot_openwrt", + "wlan_apply_system_cfg", "wlan_apply_config", "wlan_ensure_vap_ids", + "wlan_clear", "wlan_apply_radio", + } + missing = sorted(required - names) + if missing: + errors.append("runtime documentation references missing functions: " + + ", ".join(missing)) + + for path in sorted(SOURCE_ROOT.rglob("*.c")): + source = path.read_text(encoding="utf-8") + line_count = source.count("\n") + 1 + if line_count > MAX_IMPLEMENTATION_LINES: + errors.append( + f"{path.relative_to(REPOSITORY_ROOT)} has {line_count} lines; " + f"split modules above {MAX_IMPLEMENTATION_LINES} lines" + ) + comments = "\n".join(re.findall(r"/\*.*?\*/|//[^\n]*", source, + flags=re.DOTALL)).lower() + found = sorted( + word for word in NON_ENGLISH_COMMENT_WORDS + if re.search(rf"\b{re.escape(word)}\b", comments) + ) + if found: + errors.append( + f"{path.relative_to(REPOSITORY_ROOT)} contains non-English " + f"comment words: {', '.join(found)}" + ) + return errors + + +def render_pages(functions: list[Function]) -> dict[str, str]: + calls = resolve_calls(functions) + return { + "Home.md": home_page(), + "Developer-Guide.md": developer_guide(), + "Call-Graph.md": call_graph_page(functions, calls), + "Runtime-Flow.md": runtime_flow_page(), + "_Sidebar.md": sidebar(), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", + help="fail if generated pages or source checks are stale") + parser.add_argument("--output", type=Path, default=DEFAULT_WIKI_ROOT, + help="Wiki output directory (default: repository wiki/)") + arguments = parser.parse_args() + + functions = discover_functions() + errors = validate_source(functions) + pages = render_pages(functions) + output = arguments.output.resolve() + + if arguments.check: + for filename, expected in pages.items(): + path = output / filename + if not path.exists(): + errors.append(f"missing generated page: {path}") + elif path.read_text(encoding="utf-8") != expected: + errors.append(f"generated page is stale: {path}") + else: + output.mkdir(parents=True, exist_ok=True) + for filename, content in pages.items(): + (output / filename).write_text(content, encoding="utf-8") + + if errors: + for error in errors: + print(f"documentation error: {error}", file=sys.stderr) + if arguments.check: + print("run ./scripts/docs/generate-wiki.py to refresh pages", + file=sys.stderr) + return 1 + + action = "verified" if arguments.check else "generated" + print(f"{action} {len(pages)} Wiki pages from {len(functions)} C functions") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/docs/publish-wiki.sh b/scripts/docs/publish-wiki.sh new file mode 100755 index 0000000..bd8fd3f --- /dev/null +++ b/scripts/docs/publish-wiki.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env sh +set -eu + +if [ "$#" -gt 1 ]; then + echo "usage: $0 [WIKI_REPOSITORY_URL]" >&2 + exit 2 +fi + +script_directory=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repository_root=$(CDPATH= cd -- "$script_directory/../.." && pwd) +wiki_repository_url=${1:-} +temporary_directory=$(mktemp -d "${TMPDIR:-/tmp}/openuf-wiki.XXXXXX") + +if [ -z "$wiki_repository_url" ]; then + origin_url=$(git -C "$repository_root" config --get remote.origin.url || true) + if [ -z "$origin_url" ]; then + echo "no origin remote found; pass the Gitea Wiki repository URL" >&2 + exit 2 + fi + case $origin_url in + *.git) wiki_repository_url=${origin_url%.git}.wiki.git ;; + *) wiki_repository_url=${origin_url}.wiki.git ;; + esac +fi + +cleanup() { + rm -rf -- "$temporary_directory" +} +trap cleanup EXIT HUP INT TERM + +"$script_directory/generate-wiki.py" +"$script_directory/generate-wiki.py" --check + +git clone -- "$wiki_repository_url" "$temporary_directory/wiki" + +for page in \ + Home.md \ + Developer-Guide.md \ + Call-Graph.md \ + Runtime-Flow.md \ + _Sidebar.md +do + cp -- "$repository_root/wiki/$page" "$temporary_directory/wiki/$page" +done + +cd "$temporary_directory/wiki" +git add -- Home.md Developer-Guide.md Call-Graph.md Runtime-Flow.md _Sidebar.md + +if git diff --cached --quiet; then + echo "Gitea Wiki is already current" + exit 0 +fi + +git commit -m "docs: update generated developer wiki" +git push origin HEAD diff --git a/src/announce.c b/src/announce.c index 5c6b5f2..8758acb 100644 --- a/src/announce.c +++ b/src/announce.c @@ -196,7 +196,7 @@ int announce_init(announce_ctx_t *ctx, ctx->counter = 0; ctx->uptime = 10; - /* ── Socket para broadcast 255.255.255.255 ─────────────────── */ + /* ── Socket for broadcast 255.255.255.255 ─────────────────── */ ctx->sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); if (ctx->sockfd < 0) { perror("[openuf] announce socket"); @@ -204,7 +204,7 @@ int announce_init(announce_ctx_t *ctx, } int on = 1; setsockopt(ctx->sockfd, SOL_SOCKET, SO_BROADCAST, &on, sizeof(on)); - /* Bind a puerto efímero — OpenWrt no permite setpeername() a broadcast */ + /* Bind an ephemeral port; OpenWrt does not allow broadcast setpeername(). */ struct sockaddr_in bind_addr = { .sin_family = AF_INET, .sin_addr.s_addr = INADDR_ANY, @@ -262,7 +262,7 @@ int announce_send(announce_ctx_t *ctx) inet_pton(AF_INET, "233.89.188.1", &dest_mcast.sin_addr); if (sendto(ctx->sockfd_mcast, ctx->pkt, ctx->pkt_len, 0, (struct sockaddr *)&dest_mcast, sizeof(dest_mcast)) < 0) { - /* No es error crítico — algunos kernels no tienen ruta multicast */ + /* This is nonfatal; some kernels have no multicast route. */ } } diff --git a/src/inform/inform.c b/src/inform/inform.c new file mode 100644 index 0000000..1662bcd --- /dev/null +++ b/src/inform/inform.c @@ -0,0 +1,164 @@ +/* + * Run one inform exchange: select the adoption key and cipher, encode the + * request, post it to the controller, and dispatch the decoded response. + */ + + +#include +#include +#include +#include +#include +#include + +#include "inform.h" +#include "crypto.h" +#include "http.h" +#include "wlan.h" +#include "state.h" +#include "config.h" +#include "sysinfo.h" +#include "clients.h" +#include "lldp.h" +#include "inform_internal.h" + +/* Public inform-cycle entry point. */ +int inform_send(openuf_state_t *st, + const uf_model_t *model, + long uptime, + char *err_out) +{ + if (!st->inform_url[0]) { + LOG("No inform_url set"); + strncpy(err_out, "no inform_url", 127); + return -1; + } + + const char *key_hex = (st->authkey[0]) ? st->authkey : DEFAULT_AUTH_KEY; + + /* CRITICAL: When not adopted, ALWAYS use DEFAULT_AUTH_KEY */ + if (!st->adopted && st->authkey[0] && strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0) { + LOG("WARNING: Device not adopted but has custom authkey! Using DEFAULT instead!"); + key_hex = DEFAULT_AUTH_KEY; + } + + LOG("Sending inform: adopted=%d, authkey=%.8s..., inform_url=%s", + st->adopted, key_hex, st->inform_url); + + /* MAC without colons */ + char mac_hex[32] = {0}; + { + const char *s = st->mac; int j = 0; + for (int i = 0; s[i] && j < 12; i++) + if (s[i] != ':') mac_hex[j++] = s[i]; + } + + char *payload = inform_build_payload(st, model, uptime); + if (!payload) { strncpy(err_out, "inform_build_payload OOM", 127); return -1; } + + LOG("Built payload, length: %zu", strlen(payload)); + + unsigned char *resp_body = NULL; + size_t resp_len = 0; + int status = -1; + int selected_gcm = st->use_aes_gcm; + + /* + * A controller remembers the negotiated cipher. If local state was + * created before use_aes_gcm was persisted, it rejects CBC with HTTP 400 + * and cannot send another setparam. Retry once with the other cipher. + */ + for (int attempt = 0; attempt < 2; attempt++) { + size_t pkt_len = 0; + unsigned char *pkt = inform_packet_build(mac_hex, key_hex, payload, + selected_gcm, &pkt_len); + if (!pkt) { + free(payload); + strncpy(err_out, "inform_packet_build failed", 127); + return -1; + } + + LOG("Built packet, length: %zu, cipher: %s", pkt_len, + selected_gcm ? "AES-GCM" : "AES-CBC"); + + status = http_post(st->inform_url, + "application/x-binary-data", + pkt, pkt_len, + &resp_body, &resp_len); + free(pkt); + + LOG("HTTP POST to %s, status: %d, response length: %zu", + st->inform_url, status, resp_len); + + if (status != 400 || !st->adopted || attempt != 0) + break; + + free(resp_body); + resp_body = NULL; + resp_len = 0; + selected_gcm = !selected_gcm; + LOG("Controller rejected %s; retrying once with %s", + selected_gcm ? "AES-CBC" : "AES-GCM", + selected_gcm ? "AES-GCM" : "AES-CBC"); + } + free(payload); + + if (status < 0) { + snprintf(err_out, 127, "HTTP connect failed"); + return -1; + } + if (status != 200) { + snprintf(err_out, 127, "HTTP %d", status); + free(resp_body); + return -1; + } + + if (st->use_aes_gcm != selected_gcm) { + st->use_aes_gcm = selected_gcm; + state_save(st); + LOG("Recovered cipher state; persisted aes_gcm=%d", + st->use_aes_gcm); + } + + if (!resp_body || resp_len == 0) { + LOG("No response body"); + free(resp_body); + return 0; + } + + char *resp_json = inform_packet_parse(resp_body, resp_len, key_hex); + free(resp_body); + if (!resp_json) { + LOG("Failed to parse response packet"); + snprintf(err_out, 127, "inform_packet_parse failed"); + return -1; + } + + LOG("Parsed response JSON, length=%zu", strlen(resp_json)); + + struct json_object *resp_obj = json_tokener_parse(resp_json); + if (!resp_obj) { + LOG("Failed to parse JSON"); + snprintf(err_out, 127, "JSON parse failed"); + free(resp_json); + return -1; + } + + inform_log_controller_response(resp_obj, resp_json); + free(resp_json); + struct json_object *response_type; + if (json_object_object_get_ex(resp_obj, "_type", &response_type)) + LOG("Parsed response type: %s", + json_object_get_string(response_type)); + + char action[64] = "noop"; + inform_handle_response(st, model, resp_obj, action); + json_object_put(resp_obj); + + LOG("Response action: %s", action); + + if (strcmp(action, "noop") != 0) + printf("[openuf] Action: %s\n", action); + + return 0; +} diff --git a/src/inform/inform_internal.h b/src/inform/inform_internal.h new file mode 100644 index 0000000..db5ec53 --- /dev/null +++ b/src/inform/inform_internal.h @@ -0,0 +1,34 @@ +#ifndef OPENUF_INFORM_INTERNAL_H +#define OPENUF_INFORM_INTERNAL_H + +#include +#include + +#include "state.h" +#include "ufmodel.h" + +/* Internal contracts shared by the focused inform implementation units. */ +char *inform_build_payload(const openuf_state_t *state, + const uf_model_t *model, + long uptime); + +unsigned char *inform_packet_build(const char *mac_hex, + const char *key_hex, + const char *payload, + int use_aes_gcm, + size_t *packet_length); + +char *inform_packet_parse(const unsigned char *data, + size_t data_length, + const char *key_hex); + +void inform_handle_response(openuf_state_t *state, + const uf_model_t *model, + struct json_object *response, + char *action_out); + +void inform_log_controller_response(struct json_object *response, + const char *raw_json); +int inform_debug_level(void); + +#endif /* OPENUF_INFORM_INTERNAL_H */ diff --git a/src/inform/packet.c b/src/inform/packet.c new file mode 100644 index 0000000..635c54e --- /dev/null +++ b/src/inform/packet.c @@ -0,0 +1,148 @@ +/* + * Encode and decode the binary TNBU envelope. Packet layout, byte order, + * flags, authenticated header bytes, and cipher selection are protocol ABI. + */ + + +#include +#include +#include +#include +#include +#include + +#include "inform.h" +#include "crypto.h" +#include "http.h" +#include "wlan.h" +#include "state.h" +#include "config.h" +#include "sysinfo.h" +#include "clients.h" +#include "lldp.h" +#include "inform_internal.h" + +/* ─── Big-endian helpers ────────────────────────────────────────── */ +static void put32be(unsigned char *p, uint32_t v) +{ + p[0]=(v>>24)&0xff; p[1]=(v>>16)&0xff; + p[2]=(v>> 8)&0xff; p[3]=v&0xff; +} +static void put16be(unsigned char *p, uint16_t v) +{ + p[0]=(v>>8)&0xff; p[1]=v&0xff; +} +static uint32_t get32be(const unsigned char *p) +{ + return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)| + ((uint32_t)p[2]<<8)|(uint32_t)p[3]; +} +static uint16_t get16be(const unsigned char *p) +{ + return ((uint16_t)p[0]<<8)|(uint16_t)p[1]; +} + +unsigned char *inform_packet_build(const char *mac_hex, + const char *key_hex, + const char *payload, + int use_aes_gcm, + size_t *out_len) +{ + unsigned char iv_hex[33] = {0}; + if (crypto_random_hex(iv_hex, 16) != 0) return NULL; + + unsigned char mac_bin[6]; + crypto_hex2bin(mac_hex, mac_bin, 6); + + size_t pl_len = strlen(payload); + size_t body_len = use_aes_gcm + ? pl_len + 16 + : pl_len + (16 - (pl_len % 16)); + size_t pkt_len = 40 + body_len; + unsigned char *pkt = malloc(pkt_len); + if (!pkt) return NULL; + + unsigned char *p = pkt; + memcpy(p, INFORM_MAGIC, 4); p += 4; + put32be(p, INFORM_PKT_VERSION); p += 4; + memcpy(p, mac_bin, 6); p += 6; + put16be(p, INFORM_FLAG_ENCRYPTED | + (use_aes_gcm ? INFORM_FLAG_GCM : 0)); p += 2; + + unsigned char iv_bin[16]; + crypto_hex2bin((char *)iv_hex, iv_bin, 16); + memcpy(p, iv_bin, 16); p += 16; + put32be(p, INFORM_DATA_VERSION); p += 4; + put32be(p, (uint32_t)body_len); p += 4; + + int enc_len; + if (use_aes_gcm) { + unsigned char tag[16]; + enc_len = crypto_gcm_encrypt(key_hex, (char *)iv_hex, + pkt, 40, + (const unsigned char *)payload, pl_len, + p, tag); + if (enc_len >= 0) + memcpy(p + enc_len, tag, sizeof(tag)); + } else { + enc_len = crypto_encrypt(key_hex, (char *)iv_hex, + (const unsigned char *)payload, pl_len, p); + } + if (enc_len < 0) { + free(pkt); + return NULL; + } + + *out_len = pkt_len; + return pkt; +} + +/* ═══════════════════════════════════════════════════════════════════ + Parse binary response from the controller + ═══════════════════════════════════════════════════════════════════ */ +char *inform_packet_parse(const unsigned char *data, size_t data_len, + const char *key_hex) +{ + if (data_len < 40) return NULL; + if (memcmp(data, INFORM_MAGIC, 4) != 0) return NULL; + + uint16_t flags = get16be(data + 14); + const unsigned char *iv_bin = data + 16; + uint32_t body_len = get32be(data + 36); + const unsigned char *body = data + 40; + + if (40 + body_len > data_len) return NULL; + + if ((flags & INFORM_FLAG_GCM) != 0) { + if (body_len < 16) return NULL; + size_t cipher_len = body_len - 16; + char iv_hex[33]; + crypto_bin2hex(iv_bin, 16, iv_hex); + unsigned char *plain = malloc(cipher_len + 1); + if (!plain) return NULL; + int pl = crypto_gcm_decrypt(key_hex, iv_hex, data, 40, + body, cipher_len, body + cipher_len, + plain); + if (pl < 0) { free(plain); return NULL; } + plain[pl] = '\0'; + return (char *)plain; + } + + if (flags & INFORM_FLAG_ENCRYPTED) { + char iv_hex[33]; + crypto_bin2hex(iv_bin, 16, iv_hex); + unsigned char *plain = malloc(body_len + 1); + if (!plain) return NULL; + int pl = crypto_decrypt(key_hex, iv_hex, body, body_len, plain); + if (pl < 0) { free(plain); return NULL; } + plain[pl] = '\0'; + return (char *)plain; + } + + char *copy = malloc(body_len + 1); + if (!copy) return NULL; + memcpy(copy, body, body_len); + copy[body_len] = '\0'; + return copy; +} + diff --git a/src/inform.c b/src/inform/payload.c similarity index 58% rename from src/inform.c rename to src/inform/payload.c index a4350af..5f67cf1 100644 --- a/src/inform.c +++ b/src/inform/payload.c @@ -1,52 +1,9 @@ /* - * openuf - inform.c - * - * UniFi Inform Protocol — full implementation. - * - * ── HOW IT WORKS ───────────────────────────────────────────────────── - * - * Every 10 seconds the AP makes an HTTP POST to http://:8080/inform - * with a binary TNBU packet containing JSON encrypted with AES-128-CBC. - * - * The controller responds with another TNBU packet. The AP decrypts, parses - * the JSON, and executes the action (_type). - * - * ── TNBU BINARY PACKET ─────────────────────────────────────────────── - * - * Offset Bytes Field - * ------ ----- ----- - * 0 4 Magic "TNBU" - * 4 4 Packet version (=0), uint32 BE - * 8 6 AP MAC address - * 14 2 Flags: bit0=encrypted, bit1=zlib - * 16 16 AES IV (when encrypted) - * 32 4 Data version (=1), uint32 BE - * 36 4 Payload length, uint32 BE - * 40 N JSON payload, encrypted with AES-128-CBC - * - * ── HOW PARAMETERS ARE READ ────────────────────────────────────────── - * - * CPU: sysinfo_cpu_percent() → /proc/stat (delta across 2 calls) - * RAM: sysinfo_mem() → /proc/meminfo - * Interfaces: sysinfo_iface() → /proc/net/dev + /sys/class/net/ - * Radios: sysinfo_radio() → iw dev info + survey - * UCI VAPs: wlan_get_vap_table() → libuci wireless.* - * WiFi clients: clients_build_sta_table() → iw dev station dump - * IP clients: clients_mac_to_ip() → /proc/net/arp - * Client names: clients_mac_to_hostname() → /tmp/dhcp.leases - * LLDP neighbors: lldp_read_neighbors() → lldpctl -f json - * - * ── ADOPTION CYCLE ─────────────────────────────────────────────────── - * - * 1. AP sends inform with key=DEFAULT, default=true, state=1 - * 2. Controller responds: {_type:"cmd", cmd:"set-adopt", - * key:"new32hexkey", uri:"http://..."} - * 3. AP saves the new key + URL to state.json, adopted=true - * 4. AP sends inform with the new key, state=4, default=false - * 5. Controller responds: {_type:"setstate", radio_table:[...], vap_table:[...]} - * 6. AP applies WiFi config via wlan_apply_config() → libuci → wifi reload + * Collect device, radio, interface, client, and topology telemetry into the + * JSON payload sent during an inform exchange. */ + #include #include #include @@ -63,98 +20,9 @@ #include "sysinfo.h" #include "clients.h" #include "lldp.h" +#include "inform_internal.h" -/* ─── Big-endian helpers ────────────────────────────────────────── */ -static void put32be(unsigned char *p, uint32_t v) -{ - p[0]=(v>>24)&0xff; p[1]=(v>>16)&0xff; - p[2]=(v>> 8)&0xff; p[3]=v&0xff; -} -static void put16be(unsigned char *p, uint16_t v) -{ - p[0]=(v>>8)&0xff; p[1]=v&0xff; -} -static uint32_t get32be(const unsigned char *p) -{ - return ((uint32_t)p[0]<<24)|((uint32_t)p[1]<<16)| - ((uint32_t)p[2]<<8)|(uint32_t)p[3]; -} -static uint16_t get16be(const unsigned char *p) -{ - return ((uint16_t)p[0]<<8)|(uint16_t)p[1]; -} - -static int valid_authkey(const char *key) -{ - if (!key || strlen(key) != 32) - return 0; - for (size_t i = 0; i < 32; i++) - if (!isxdigit((unsigned char)key[i])) - return 0; - return 1; -} - -static int protocol_debug_level; - -void inform_set_debug_level(int level) -{ - protocol_debug_level = level < 0 ? 0 : level > 2 ? 2 : level; -} - -static int debug_system_cfg_key(const char *key) -{ - if (!key || (strncmp(key, "aaa.", 4) && - strncmp(key, "wireless.", 9))) - return 0; - return strstr(key, ".ssid") || strstr(key, ".id") || - strstr(key, ".vap_ind") || strstr(key, ".parent"); -} - -static void debug_log_controller_response(struct json_object *response, - const char *raw_json) -{ - if (protocol_debug_level <= 0 || !response) - return; - - LOG("Protocol debug: decrypted controller response fields follow"); - json_object_object_foreach(response, key, value) { - LOG("Protocol field: %s type=%s", key, - json_type_to_name(json_object_get_type(value))); - } - - struct json_object *system_cfg_object; - if (json_object_object_get_ex(response, "system_cfg", - &system_cfg_object)) { - const char *system_cfg = json_object_get_string(system_cfg_object); - char *copy = system_cfg ? strdup(system_cfg) : NULL; - if (copy) { - char *save = NULL; - for (char *line = strtok_r(copy, "\n", &save); - line; line = strtok_r(NULL, "\n", &save)) { - line[strcspn(line, "\r")] = '\0'; - char *equals = strchr(line, '='); - if (!equals) - continue; - *equals = '\0'; - if (debug_system_cfg_key(line)) - LOG("Protocol system_cfg: %s=%s", line, equals + 1); - } - free(copy); - } - } - - if (protocol_debug_level >= 2) { - LOG("WARNING: full decrypted response may contain credentials"); - LOG("Protocol response JSON: %s", raw_json ? raw_json : ""); - } -} - -/* ═══════════════════════════════════════════════════════════════════ - sys_stats — CPU and memory of the system - ═══════════════════════════════════════════════════════════════════ - The controller shows CPU and RAM in the device view. - We read /proc/stat and /proc/meminfo directly. -*/ +/* Build system CPU, memory, and load statistics. */ static struct json_object *build_sys_stats(int *cpu_percent, double *mem_percent) { @@ -216,8 +84,8 @@ static struct json_object *build_system_stats(int cpu_percent, /* ═══════════════════════════════════════════════════════════════════ if_table — network interface statistics ═══════════════════════════════════════════════════════════════════ - All Ethernet ports on the model are reported. - /proc/net/dev is read for counters, and /sys/class/net// + All Ethernet ports on the model are reported. + /proc/net/dev is read for counters, and /sys/class/net// for speed, duplex, and link status. */ static struct json_object *build_if_table(const uf_model_t *m, @@ -410,7 +278,7 @@ static void build_radio_table(struct json_object *root, json_object_object_add(root, alias, json_object_get(o)); json_object_array_add(arr, o); - if (protocol_debug_level > 0) + if (inform_debug_level() > 0) LOG("Protocol radio capability: name=%s band=%s nss=%d tx_chains=%d rx_chains=%d", r->name, r->radio, nss, tx_antennas, rx_antennas); } @@ -795,9 +663,9 @@ static struct json_object *collect_sta_table(struct json_object *vap_table) } /* ═══════════════════════════════════════════════════════════════════ - build_payload — Complete assembly of the inform JSON + inform_build_payload — Complete assembly of the inform JSON ═══════════════════════════════════════════════════════════════════ */ -static char *build_payload(const openuf_state_t *st, +char *inform_build_payload(const openuf_state_t *st, const uf_model_t *m, long uptime) { @@ -917,527 +785,18 @@ static char *build_payload(const openuf_state_t *st, json_object_object_add(root, "num_sta", json_object_new_int(station_count)); const char *s = json_object_to_json_string(root); - + /* Log shows what authkey is actually in the payload */ LOG("Payload state=%d, default=%s, adopted=%d, cfgversion=%s, config_applied=%d, x_authkey=%.8s...", - st->adopted ? 4 : 1, + st->adopted ? 4 : 1, !st->adopted ? "true" : "false", st->adopted, st->cfgversion, st->config_applied, st->authkey[0] ? st->authkey : "DEFAULT"); - + char *copy = strdup(s); json_object_put(root); return copy; } -/* ═══════════════════════════════════════════════════════════════════ - TNBU binary packet - ═══════════════════════════════════════════════════════════════════ */ -static unsigned char *build_packet(const char *mac_hex, - const char *key_hex, - const char *payload, - int use_aes_gcm, - size_t *out_len) -{ - unsigned char iv_hex[33] = {0}; - if (crypto_random_hex(iv_hex, 16) != 0) return NULL; - - unsigned char mac_bin[6]; - crypto_hex2bin(mac_hex, mac_bin, 6); - - size_t pl_len = strlen(payload); - size_t body_len = use_aes_gcm - ? pl_len + 16 - : pl_len + (16 - (pl_len % 16)); - size_t pkt_len = 40 + body_len; - unsigned char *pkt = malloc(pkt_len); - if (!pkt) return NULL; - - unsigned char *p = pkt; - memcpy(p, INFORM_MAGIC, 4); p += 4; - put32be(p, INFORM_PKT_VERSION); p += 4; - memcpy(p, mac_bin, 6); p += 6; - put16be(p, INFORM_FLAG_ENCRYPTED | - (use_aes_gcm ? INFORM_FLAG_GCM : 0)); p += 2; - - unsigned char iv_bin[16]; - crypto_hex2bin((char *)iv_hex, iv_bin, 16); - memcpy(p, iv_bin, 16); p += 16; - put32be(p, INFORM_DATA_VERSION); p += 4; - put32be(p, (uint32_t)body_len); p += 4; - - int enc_len; - if (use_aes_gcm) { - unsigned char tag[16]; - enc_len = crypto_gcm_encrypt(key_hex, (char *)iv_hex, - pkt, 40, - (const unsigned char *)payload, pl_len, - p, tag); - if (enc_len >= 0) - memcpy(p + enc_len, tag, sizeof(tag)); - } else { - enc_len = crypto_encrypt(key_hex, (char *)iv_hex, - (const unsigned char *)payload, pl_len, p); - } - if (enc_len < 0) { - free(pkt); - return NULL; - } - - *out_len = pkt_len; - return pkt; -} - -/* ═══════════════════════════════════════════════════════════════════ - Parse binary response from the controller - ═══════════════════════════════════════════════════════════════════ */ -static char *parse_packet(const unsigned char *data, size_t data_len, - const char *key_hex) -{ - if (data_len < 40) return NULL; - if (memcmp(data, INFORM_MAGIC, 4) != 0) return NULL; - - uint16_t flags = get16be(data + 14); - const unsigned char *iv_bin = data + 16; - uint32_t body_len = get32be(data + 36); - const unsigned char *body = data + 40; - - if (40 + body_len > data_len) return NULL; - - if ((flags & INFORM_FLAG_GCM) != 0) { - if (body_len < 16) return NULL; - size_t cipher_len = body_len - 16; - char iv_hex[33]; - crypto_bin2hex(iv_bin, 16, iv_hex); - unsigned char *plain = malloc(cipher_len + 1); - if (!plain) return NULL; - int pl = crypto_gcm_decrypt(key_hex, iv_hex, data, 40, - body, cipher_len, body + cipher_len, - plain); - if (pl < 0) { free(plain); return NULL; } - plain[pl] = '\0'; - return (char *)plain; - } - - if (flags & INFORM_FLAG_ENCRYPTED) { - char iv_hex[33]; - crypto_bin2hex(iv_bin, 16, iv_hex); - unsigned char *plain = malloc(body_len + 1); - if (!plain) return NULL; - int pl = crypto_decrypt(key_hex, iv_hex, body, body_len, plain); - if (pl < 0) { free(plain); return NULL; } - plain[pl] = '\0'; - return (char *)plain; - } - - char *copy = malloc(body_len + 1); - if (!copy) return NULL; - memcpy(copy, body, body_len); - copy[body_len] = '\0'; - return copy; -} - -static void reboot_openwrt(void) -{ - LOG("Controller requested an OpenWrt reboot"); - int status = system("/sbin/reboot"); - if (status != 0) - LOG("OpenWrt reboot command failed with status=%d", status); -} - -/* ═══════════════════════════════════════════════════════════════════ - Process JSON command from the controller - ═══════════════════════════════════════════════════════════════════ - - _type == "noop" → do nothing - _type == "reboot" → reboot OpenWrt - _type == "cmd" → set-adopt / legacy reboot / reset / locate - _type == "setstate" → apply radio_table + vap_table via UCI - _type == "setparam" → change a single parameter -*/ -static void handle_response(openuf_state_t *st, - const uf_model_t *model, - struct json_object *resp, - char *action_out) -{ - struct json_object *v; - const char *type = "noop"; - if (json_object_object_get_ex(resp, "_type", &v) && - json_object_is_type(v, json_type_string)) - type = json_object_get_string(v); - - LOG("Handling response type: %s", type); - - /* An OpenWrt host cannot install UniFi firmware. Acknowledge the - * controller's request by reporting its target version from now on. */ - if (!strcmp(type, "upgrade")) { - if (json_object_object_get_ex(resp, "version", &v) && - json_object_is_type(v, json_type_string)) { - const char *version = json_object_get_string(v); - size_t len = strlen(version); - if (len > 0 && len < sizeof(st->firmware_version)) { - snprintf(st->firmware_version, - sizeof(st->firmware_version), "%s", version); - state_save(st); - LOG("Firmware upgrade spoofed; now reporting version=%s", - st->firmware_version); - strcpy(action_out, "upgrade-spoofed"); - return; - } - } - LOG("Ignoring upgrade response without a valid version"); - strcpy(action_out, "upgrade-invalid"); - return; - } - - /* ── noop ────────────────────────────────────────────────────── */ - if (!strcmp(type, "noop")) { - strcpy(action_out, "noop"); - return; - } - - /* Modern controllers send reboot as a top-level response type rather - * than wrapping it in {"_type":"cmd","cmd":"reboot"}. */ - if (!strcmp(type, "reboot")) { - strcpy(action_out, "reboot"); - reboot_openwrt(); - return; - } - - /* ── setparam ────────────────────────────────────────────────── */ - if (!strcmp(type, "setparam")) { - int received_adoption_key = 0; - int applied_system_cfg = 0; - - /* First parse mgmt_cfg used by modern controllers. */ - if (json_object_object_get_ex(resp, "mgmt_cfg", &v)) { - const char *mgmt_cfg = json_object_get_string(v); - LOG("Parsing mgmt_cfg: %s", mgmt_cfg); - - /* Parse newline-separated key=value pairs. */ - char cfg_copy[2048]; - strncpy(cfg_copy, mgmt_cfg, sizeof(cfg_copy)-1); - cfg_copy[sizeof(cfg_copy)-1] = '\0'; - - char *line = strtok(cfg_copy, "\n"); - while (line) { - char *eq = strchr(line, '='); - if (eq) { - *eq = '\0'; - const char *key = line; - const char *val = eq + 1; - - if (!strcmp(key, "authkey")) - LOG("mgmt_cfg param: authkey = %.8s...", val); - else - LOG("mgmt_cfg param: %s = %s", key, val); - - if (!strcmp(key, "authkey")) { - if (valid_authkey(val) && - strcmp(st->authkey, val) != 0) { - int replacing_key = st->authkey[0] && - strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0; - strncpy(st->authkey, val, - sizeof(st->authkey)-1); - st->authkey[sizeof(st->authkey)-1] = '\0'; - received_adoption_key = 1; - LOG("%s device key from setparam", - replacing_key ? "Replaced" : "Accepted"); - } else if (!valid_authkey(val)) { - LOG("Ignoring invalid authkey from setparam"); - } - } else if (!strcmp(key, "cfgversion")) { - /* - * This is the version the controller wants, not proof - * that its setstate has been applied locally. - */ - LOG("Controller requested cfgversion=%s; currently applied=%s", - val, st->cfgversion); - } else if (!strcmp(key, "use_aes_gcm")) { - st->use_aes_gcm = !strcmp(val, "true") || - !strcmp(val, "1"); - LOG("AES-GCM %s for subsequent inform packets", - st->use_aes_gcm ? "enabled" : "disabled"); - } else if (!strcmp(key, "mgmt_url")) { - /* Could save mgmt_url for future use */ - } - /* Other management parameters are currently informational. */ - } - line = strtok(NULL, "\n"); - } - } - - struct json_object *system_cfg_obj; - if (json_object_object_get_ex(resp, "system_cfg", - &system_cfg_obj)) { - const char *system_cfg = json_object_get_string(system_cfg_obj); - LOG("Applying legacy system_cfg, length=%zu", - strlen(system_cfg)); - if (wlan_apply_system_cfg(system_cfg, model) == 0) { - applied_system_cfg = 1; - st->config_applied = true; - st->config_schema = OPENUF_CONFIG_SCHEMA; - if (json_object_object_get_ex(resp, "cfgversion", &v)) - snprintf(st->cfgversion, sizeof(st->cfgversion), "%s", - json_object_get_string(v)); - LOG("Legacy system_cfg applied successfully, cfgversion=%s", - st->cfgversion); - } else { - st->config_applied = false; - strncpy(st->cfgversion, "0", - sizeof(st->cfgversion) - 1); - LOG("Legacy system_cfg failed; requesting provisioning retry"); - } - } - - /* Fall back to the direct key/value format used by older controllers. */ - if (json_object_object_get_ex(resp, "key", &v)) { - const char *key = json_object_get_string(v); - struct json_object *val_o; - if (json_object_object_get_ex(resp, "value", &val_o)) { - const char *val = json_object_get_string(val_o); - LOG("setparam key=%s val=%s", key, val); - if (!strcmp(key, "inform_url")) - strncpy(st->inform_url, val, sizeof(st->inform_url)-1); - else if (!strcmp(key, "authkey") && valid_authkey(val) && - strcmp(st->authkey, val) != 0) { - int replacing_key = st->authkey[0] && - strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0; - strncpy(st->authkey, val, sizeof(st->authkey)-1); - st->authkey[sizeof(st->authkey)-1] = '\0'; - received_adoption_key = 1; - LOG("%s device key from direct setparam", - replacing_key ? "Replaced" : "Accepted"); - } - } - } - - /* - * Modern controllers complete adoption by returning the per-device - * key in setparam. Mark the device adopted before its next inform so - * both the payload and packet encryption switch to that key. - */ - if (received_adoption_key) { - st->adopted = true; - LOG("Adoption completed through setparam; next inform will use the controller key"); - } - - state_save(st); - LOG("State saved after setparam"); - strcpy(action_out, applied_system_cfg ? "provisioned" : - received_adoption_key ? "adopted" : "setparam"); - return; - } - - /* ── cmd ─────────────────────────────────────────────────────── */ - if (!strcmp(type, "cmd")) { - const char *cmd = ""; - if (json_object_object_get_ex(resp, "cmd", &v)) - cmd = json_object_get_string(v); - - if (!strcmp(cmd, "set-adopt") || !strcmp(cmd, "adopt")) { - if (json_object_object_get_ex(resp, "uri", &v)) - strncpy(st->inform_url, json_object_get_string(v), - sizeof(st->inform_url)-1); - if (json_object_object_get_ex(resp, "key", &v)) - strncpy(st->authkey, json_object_get_string(v), - sizeof(st->authkey)-1); - st->adopted = true; - state_save(st); - strcpy(action_out, "adopted"); - LOG("Adopted successfully. Key: %.8s...", st->authkey); - - } else if (!strcmp(cmd, "reboot")) { - strcpy(action_out, "reboot"); - reboot_openwrt(); - - } else if (!strcmp(cmd, "reset")) { - strcpy(action_out, "reset"); - system("rm -f " OPENUF_STATE_FILE); - reboot_openwrt(); - - } else if (!strcmp(cmd, "locate")) { - /* Blink LED — on OpenWrt: echo 1 > /sys/class/leds/.../trigger */ - strcpy(action_out, "locate"); - } else { - snprintf(action_out, 64, "cmd:%s", cmd); - } - return; - } - - /* ── setstate — WiFi configuration from the controller ──────────── */ - if (!strcmp(type, "setstate")) { - if (json_object_object_get_ex(resp, "cfgversion", &v)) - snprintf(st->cfgversion, sizeof(st->cfgversion), - "%s", json_object_get_string(v)); - - struct json_object *rt = NULL, *vt = NULL; - json_object_object_get_ex(resp, "radio_table", &rt); - json_object_object_get_ex(resp, "vap_table", &vt); - int apply_ok = 0; - if (rt || vt) { - printf("[openuf] Applying controller WiFi configuration...\n"); - apply_ok = wlan_apply_config(resp, model) == 0; - } else { - LOG("setstate contained neither radio_table nor vap_table"); - } - - st->config_applied = apply_ok; - if (apply_ok) - st->config_schema = OPENUF_CONFIG_SCHEMA; - if (!apply_ok) { - strncpy(st->cfgversion, "0", sizeof(st->cfgversion) - 1); - LOG("WiFi configuration failed; cfgversion reset so the controller retries"); - } - state_save(st); - strcpy(action_out, apply_ok ? "setstate" : "setstate-failed"); - return; - } - - snprintf(action_out, 64, "unknown:%s", type); -} - -/* ═══════════════════════════════════════════════════════════════════ - inform_send — main public function - ═══════════════════════════════════════════════════════════════════ */ -int inform_send(openuf_state_t *st, - const uf_model_t *model, - long uptime, - char *err_out) -{ - if (!st->inform_url[0]) { - LOG("No inform_url set"); - strncpy(err_out, "no inform_url", 127); - return -1; - } - - const char *key_hex = (st->authkey[0]) ? st->authkey : DEFAULT_AUTH_KEY; - - /* CRITICAL: When not adopted, ALWAYS use DEFAULT_AUTH_KEY */ - if (!st->adopted && st->authkey[0] && strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0) { - LOG("WARNING: Device not adopted but has custom authkey! Using DEFAULT instead!"); - key_hex = DEFAULT_AUTH_KEY; - } - - LOG("Sending inform: adopted=%d, authkey=%.8s..., inform_url=%s", - st->adopted, key_hex, st->inform_url); - - /* MAC without colons */ - char mac_hex[32] = {0}; - { - const char *s = st->mac; int j = 0; - for (int i = 0; s[i] && j < 12; i++) - if (s[i] != ':') mac_hex[j++] = s[i]; - } - - char *payload = build_payload(st, model, uptime); - if (!payload) { strncpy(err_out, "build_payload OOM", 127); return -1; } - - LOG("Built payload, length: %zu", strlen(payload)); - - unsigned char *resp_body = NULL; - size_t resp_len = 0; - int status = -1; - int selected_gcm = st->use_aes_gcm; - - /* - * A controller remembers the negotiated cipher. If local state was - * created before use_aes_gcm was persisted, it rejects CBC with HTTP 400 - * and cannot send another setparam. Retry once with the other cipher. - */ - for (int attempt = 0; attempt < 2; attempt++) { - size_t pkt_len = 0; - unsigned char *pkt = build_packet(mac_hex, key_hex, payload, - selected_gcm, &pkt_len); - if (!pkt) { - free(payload); - strncpy(err_out, "build_packet failed", 127); - return -1; - } - - LOG("Built packet, length: %zu, cipher: %s", pkt_len, - selected_gcm ? "AES-GCM" : "AES-CBC"); - - status = http_post(st->inform_url, - "application/x-binary-data", - pkt, pkt_len, - &resp_body, &resp_len); - free(pkt); - - LOG("HTTP POST to %s, status: %d, response length: %zu", - st->inform_url, status, resp_len); - - if (status != 400 || !st->adopted || attempt != 0) - break; - - free(resp_body); - resp_body = NULL; - resp_len = 0; - selected_gcm = !selected_gcm; - LOG("Controller rejected %s; retrying once with %s", - selected_gcm ? "AES-CBC" : "AES-GCM", - selected_gcm ? "AES-GCM" : "AES-CBC"); - } - free(payload); - - if (status < 0) { - snprintf(err_out, 127, "HTTP connect failed"); - return -1; - } - if (status != 200) { - snprintf(err_out, 127, "HTTP %d", status); - free(resp_body); - return -1; - } - - if (st->use_aes_gcm != selected_gcm) { - st->use_aes_gcm = selected_gcm; - state_save(st); - LOG("Recovered cipher state; persisted aes_gcm=%d", - st->use_aes_gcm); - } - - if (!resp_body || resp_len == 0) { - LOG("No response body"); - free(resp_body); - return 0; - } - - char *resp_json = parse_packet(resp_body, resp_len, key_hex); - free(resp_body); - if (!resp_json) { - LOG("Failed to parse response packet"); - snprintf(err_out, 127, "parse_packet failed"); - return -1; - } - - LOG("Parsed response JSON, length=%zu", strlen(resp_json)); - - struct json_object *resp_obj = json_tokener_parse(resp_json); - if (!resp_obj) { - LOG("Failed to parse JSON"); - snprintf(err_out, 127, "JSON parse failed"); - free(resp_json); - return -1; - } - - debug_log_controller_response(resp_obj, resp_json); - free(resp_json); - struct json_object *response_type; - if (json_object_object_get_ex(resp_obj, "_type", &response_type)) - LOG("Parsed response type: %s", - json_object_get_string(response_type)); - - char action[64] = "noop"; - handle_response(st, model, resp_obj, action); - json_object_put(resp_obj); - - LOG("Response action: %s", action); - - if (strcmp(action, "noop") != 0) - printf("[openuf] Action: %s\n", action); - - return 0; -} diff --git a/src/inform/response.c b/src/inform/response.c new file mode 100644 index 0000000..3d23d21 --- /dev/null +++ b/src/inform/response.c @@ -0,0 +1,361 @@ +/* + * Handle controller commands, adoption state changes, provisioning results, + * firmware-version spoofing, and protocol-safe response diagnostics. + */ + + +#include +#include +#include +#include +#include +#include + +#include "inform.h" +#include "crypto.h" +#include "http.h" +#include "wlan.h" +#include "state.h" +#include "config.h" +#include "sysinfo.h" +#include "clients.h" +#include "lldp.h" +#include "inform_internal.h" + + +static int inform_valid_auth_key(const char *key) +{ + if (!key || strlen(key) != 32) + return 0; + for (size_t i = 0; i < 32; i++) + if (!isxdigit((unsigned char)key[i])) + return 0; + return 1; +} + +static int protocol_debug_level; + +void inform_set_debug_level(int level) +{ + protocol_debug_level = level < 0 ? 0 : level > 2 ? 2 : level; +} + +int inform_debug_level(void) +{ + return protocol_debug_level; +} + +static int inform_debug_system_cfg_key(const char *key) +{ + if (!key || (strncmp(key, "aaa.", 4) && + strncmp(key, "wireless.", 9))) + return 0; + return strstr(key, ".ssid") || strstr(key, ".id") || + strstr(key, ".vap_ind") || strstr(key, ".parent"); +} + +void inform_log_controller_response(struct json_object *response, + const char *raw_json) +{ + if (protocol_debug_level <= 0 || !response) + return; + + LOG("Protocol debug: decrypted controller response fields follow"); + json_object_object_foreach(response, key, value) { + LOG("Protocol field: %s type=%s", key, + json_type_to_name(json_object_get_type(value))); + } + + struct json_object *system_cfg_object; + if (json_object_object_get_ex(response, "system_cfg", + &system_cfg_object)) { + const char *system_cfg = json_object_get_string(system_cfg_object); + char *copy = system_cfg ? strdup(system_cfg) : NULL; + if (copy) { + char *save = NULL; + for (char *line = strtok_r(copy, "\n", &save); + line; line = strtok_r(NULL, "\n", &save)) { + line[strcspn(line, "\r")] = '\0'; + char *equals = strchr(line, '='); + if (!equals) + continue; + *equals = '\0'; + if (inform_debug_system_cfg_key(line)) + LOG("Protocol system_cfg: %s=%s", line, equals + 1); + } + free(copy); + } + } + + if (protocol_debug_level >= 2) { + LOG("WARNING: full decrypted response may contain credentials"); + LOG("Protocol response JSON: %s", raw_json ? raw_json : ""); + } +} + +/* ═══════════════════════════════════════════════════════════════════ + sys_stats — CPU and memory of the system + ═══════════════════════════════════════════════════════════════════ + The controller shows CPU and RAM in the device view. + We read /proc/stat and /proc/meminfo directly. +*/ +static void reboot_openwrt(void) +{ + LOG("Controller requested an OpenWrt reboot"); + int status = system("/sbin/reboot"); + if (status != 0) + LOG("OpenWrt reboot command failed with status=%d", status); +} + +/* ═══════════════════════════════════════════════════════════════════ + Process JSON command from the controller + ═══════════════════════════════════════════════════════════════════ + + _type == "noop" → do nothing + _type == "reboot" → reboot OpenWrt + _type == "cmd" → set-adopt / legacy reboot / reset / locate + _type == "setstate" → apply radio_table + vap_table via UCI + _type == "setparam" → change a single parameter +*/ +void inform_handle_response(openuf_state_t *st, + const uf_model_t *model, + struct json_object *resp, + char *action_out) +{ + struct json_object *v; + const char *type = "noop"; + if (json_object_object_get_ex(resp, "_type", &v) && + json_object_is_type(v, json_type_string)) + type = json_object_get_string(v); + + LOG("Handling response type: %s", type); + + /* An OpenWrt host cannot install UniFi firmware. Acknowledge the + * controller's request by reporting its target version from now on. */ + if (!strcmp(type, "upgrade")) { + if (json_object_object_get_ex(resp, "version", &v) && + json_object_is_type(v, json_type_string)) { + const char *version = json_object_get_string(v); + size_t len = strlen(version); + if (len > 0 && len < sizeof(st->firmware_version)) { + snprintf(st->firmware_version, + sizeof(st->firmware_version), "%s", version); + state_save(st); + LOG("Firmware upgrade spoofed; now reporting version=%s", + st->firmware_version); + strcpy(action_out, "upgrade-spoofed"); + return; + } + } + LOG("Ignoring upgrade response without a valid version"); + strcpy(action_out, "upgrade-invalid"); + return; + } + + /* ── noop ────────────────────────────────────────────────────── */ + if (!strcmp(type, "noop")) { + strcpy(action_out, "noop"); + return; + } + + /* Modern controllers send reboot as a top-level response type rather + * than wrapping it in {"_type":"cmd","cmd":"reboot"}. */ + if (!strcmp(type, "reboot")) { + strcpy(action_out, "reboot"); + reboot_openwrt(); + return; + } + + /* ── setparam ────────────────────────────────────────────────── */ + if (!strcmp(type, "setparam")) { + int received_adoption_key = 0; + int applied_system_cfg = 0; + + /* First parse mgmt_cfg used by modern controllers. */ + if (json_object_object_get_ex(resp, "mgmt_cfg", &v)) { + const char *mgmt_cfg = json_object_get_string(v); + LOG("Parsing mgmt_cfg: %s", mgmt_cfg); + + /* Parse newline-separated key=value pairs. */ + char cfg_copy[2048]; + strncpy(cfg_copy, mgmt_cfg, sizeof(cfg_copy)-1); + cfg_copy[sizeof(cfg_copy)-1] = '\0'; + + char *line = strtok(cfg_copy, "\n"); + while (line) { + char *eq = strchr(line, '='); + if (eq) { + *eq = '\0'; + const char *key = line; + const char *val = eq + 1; + + if (!strcmp(key, "authkey")) + LOG("mgmt_cfg param: authkey = %.8s...", val); + else + LOG("mgmt_cfg param: %s = %s", key, val); + + if (!strcmp(key, "authkey")) { + if (inform_valid_auth_key(val) && + strcmp(st->authkey, val) != 0) { + int replacing_key = st->authkey[0] && + strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0; + strncpy(st->authkey, val, + sizeof(st->authkey)-1); + st->authkey[sizeof(st->authkey)-1] = '\0'; + received_adoption_key = 1; + LOG("%s device key from setparam", + replacing_key ? "Replaced" : "Accepted"); + } else if (!inform_valid_auth_key(val)) { + LOG("Ignoring invalid authkey from setparam"); + } + } else if (!strcmp(key, "cfgversion")) { + /* + * This is the version the controller wants, not proof + * that its setstate has been applied locally. + */ + LOG("Controller requested cfgversion=%s; currently applied=%s", + val, st->cfgversion); + } else if (!strcmp(key, "use_aes_gcm")) { + st->use_aes_gcm = !strcmp(val, "true") || + !strcmp(val, "1"); + LOG("AES-GCM %s for subsequent inform packets", + st->use_aes_gcm ? "enabled" : "disabled"); + } else if (!strcmp(key, "mgmt_url")) { + /* Could save mgmt_url for future use */ + } + /* Other management parameters are currently informational. */ + } + line = strtok(NULL, "\n"); + } + } + + struct json_object *system_cfg_obj; + if (json_object_object_get_ex(resp, "system_cfg", + &system_cfg_obj)) { + const char *system_cfg = json_object_get_string(system_cfg_obj); + LOG("Applying legacy system_cfg, length=%zu", + strlen(system_cfg)); + if (wlan_apply_system_cfg(system_cfg, model) == 0) { + applied_system_cfg = 1; + st->config_applied = true; + st->config_schema = OPENUF_CONFIG_SCHEMA; + if (json_object_object_get_ex(resp, "cfgversion", &v)) + snprintf(st->cfgversion, sizeof(st->cfgversion), "%s", + json_object_get_string(v)); + LOG("Legacy system_cfg applied successfully, cfgversion=%s", + st->cfgversion); + } else { + st->config_applied = false; + strncpy(st->cfgversion, "0", + sizeof(st->cfgversion) - 1); + LOG("Legacy system_cfg failed; requesting provisioning retry"); + } + } + + /* Fall back to the direct key/value format used by older controllers. */ + if (json_object_object_get_ex(resp, "key", &v)) { + const char *key = json_object_get_string(v); + struct json_object *val_o; + if (json_object_object_get_ex(resp, "value", &val_o)) { + const char *val = json_object_get_string(val_o); + LOG("setparam key=%s val=%s", key, val); + if (!strcmp(key, "inform_url")) + strncpy(st->inform_url, val, sizeof(st->inform_url)-1); + else if (!strcmp(key, "authkey") && inform_valid_auth_key(val) && + strcmp(st->authkey, val) != 0) { + int replacing_key = st->authkey[0] && + strcmp(st->authkey, DEFAULT_AUTH_KEY) != 0; + strncpy(st->authkey, val, sizeof(st->authkey)-1); + st->authkey[sizeof(st->authkey)-1] = '\0'; + received_adoption_key = 1; + LOG("%s device key from direct setparam", + replacing_key ? "Replaced" : "Accepted"); + } + } + } + + /* + * Modern controllers complete adoption by returning the per-device + * key in setparam. Mark the device adopted before its next inform so + * both the payload and packet encryption switch to that key. + */ + if (received_adoption_key) { + st->adopted = true; + LOG("Adoption completed through setparam; next inform will use the controller key"); + } + + state_save(st); + LOG("State saved after setparam"); + strcpy(action_out, applied_system_cfg ? "provisioned" : + received_adoption_key ? "adopted" : "setparam"); + return; + } + + /* ── cmd ─────────────────────────────────────────────────────── */ + if (!strcmp(type, "cmd")) { + const char *cmd = ""; + if (json_object_object_get_ex(resp, "cmd", &v)) + cmd = json_object_get_string(v); + + if (!strcmp(cmd, "set-adopt") || !strcmp(cmd, "adopt")) { + if (json_object_object_get_ex(resp, "uri", &v)) + strncpy(st->inform_url, json_object_get_string(v), + sizeof(st->inform_url)-1); + if (json_object_object_get_ex(resp, "key", &v)) + strncpy(st->authkey, json_object_get_string(v), + sizeof(st->authkey)-1); + st->adopted = true; + state_save(st); + strcpy(action_out, "adopted"); + LOG("Adopted successfully. Key: %.8s...", st->authkey); + + } else if (!strcmp(cmd, "reboot")) { + strcpy(action_out, "reboot"); + reboot_openwrt(); + + } else if (!strcmp(cmd, "reset")) { + strcpy(action_out, "reset"); + system("rm -f " OPENUF_STATE_FILE); + reboot_openwrt(); + + } else if (!strcmp(cmd, "locate")) { + /* Blink LED — on OpenWrt: echo 1 > /sys/class/leds/.../trigger */ + strcpy(action_out, "locate"); + } else { + snprintf(action_out, 64, "cmd:%s", cmd); + } + return; + } + + /* ── setstate — WiFi configuration from the controller ──────────── */ + if (!strcmp(type, "setstate")) { + if (json_object_object_get_ex(resp, "cfgversion", &v)) + snprintf(st->cfgversion, sizeof(st->cfgversion), + "%s", json_object_get_string(v)); + + struct json_object *rt = NULL, *vt = NULL; + json_object_object_get_ex(resp, "radio_table", &rt); + json_object_object_get_ex(resp, "vap_table", &vt); + int apply_ok = 0; + if (rt || vt) { + printf("[openuf] Applying controller WiFi configuration...\n"); + apply_ok = wlan_apply_config(resp, model) == 0; + } else { + LOG("setstate contained neither radio_table nor vap_table"); + } + + st->config_applied = apply_ok; + if (apply_ok) + st->config_schema = OPENUF_CONFIG_SCHEMA; + if (!apply_ok) { + strncpy(st->cfgversion, "0", sizeof(st->cfgversion) - 1); + LOG("WiFi configuration failed; cfgversion reset so the controller retries"); + } + state_save(st); + strcpy(action_out, apply_ok ? "setstate" : "setstate-failed"); + return; + } + + snprintf(action_out, 64, "unknown:%s", type); +} + diff --git a/src/lldp.c b/src/lldp.c index 38ed6d3..aa30d00 100644 --- a/src/lldp.c +++ b/src/lldp.c @@ -107,7 +107,7 @@ int lldp_send_frame(const char *ifname, const char *model_desc, int ttl) { - /* Socket raw — requiere root */ + /* Raw packet sockets require root privileges or CAP_NET_RAW. */ int fd = socket(AF_PACKET, SOCK_RAW, htons(LLDP_ETHERTYPE)); if (fd < 0) return -1; /* EPERM without root → silent */ diff --git a/src/lldp.h b/src/lldp.h index 0fea786..61996f0 100644 --- a/src/lldp.h +++ b/src/lldp.h @@ -24,7 +24,7 @@ * TLV type=2 Port ID subtype=5(ifname), value="eth0" * TLV type=3 TTL value=uint16_BE * TLV type=5 System Name value=hostname - * TLV type=6 System Desc value="modelo versión" + * TLV type=6 System Description value="model version" * TLV type=7 Capabilities cap=0x0040(WLAN-AP), en=0x0040 * TLV type=0 End of LLDPDU len=0 * diff --git a/src/main.c b/src/main.c index 32e2fbb..d8ae39e 100644 --- a/src/main.c +++ b/src/main.c @@ -3,7 +3,7 @@ * * Main daemon. Loop with three tasks: * 1. Announce – UDP broadcast+multicast each 10s (discovery L2) - * 2. Inform – HTTP POST cifrado each 10s (adoption + telemetrics) + * 2. Inform – encrypted HTTP POST every 10s (adoption + telemetry) * 3. LLDP – Raw frame L2 each 30s (visual topology in UniFi) */ @@ -151,7 +151,7 @@ int main(int argc, char *argv[]) strncpy(state.hostname, model->display_name, sizeof(state.hostname)-1); /* Log initial state */ - LOG("Initial device state: adopted=%d, authkey=%.8s...", state.adopted, + LOG("Initial device state: adopted=%d, authkey=%.8s...", state.adopted, state.authkey[0] ? state.authkey : "DEFAULT"); /* diff --git a/src/sysinfo.h b/src/sysinfo.h index 2172a3f..d86d2f0 100644 --- a/src/sysinfo.h +++ b/src/sysinfo.h @@ -17,7 +17,7 @@ #include -/* ── Memoria ─────────────────────────────────────────────────────── */ +/* ── Memory ─────────────────────────────────────────────────────── */ typedef struct { long total_kb; long free_kb; @@ -33,7 +33,7 @@ int sysinfo_mem(mem_stats_t *out); * With a 10s interval this gives a good average of usage. */ int sysinfo_cpu_percent(void); -/* ── Interfaz de red ─────────────────────────────────────────────── */ +/* ── Network interface ─────────────────────────────────────────────── */ typedef struct { char name[32]; char mac[32]; diff --git a/src/wlan.c b/src/wlan.c deleted file mode 100644 index 15e05d1..0000000 --- a/src/wlan.c +++ /dev/null @@ -1,1906 +0,0 @@ -/* - * openuf - wlan.c - * - * Traduce la configuración WiFi del controlador UniFi en settings - * UCI de OpenWrt usando libuci directamente (sin shell). - * - * ── CÓMO SE APLICA LA CONFIGURACIÓN ──────────────────────────────── - * - * El controlador envía "setstate" con: - * radio_table[] → configuración de las radios (canal, potencia, HT) - * vap_table[] → configuración de las redes WiFi (SSID, clave, roaming...) - * - * Este módulo: - * 1. Borra todas las wifi-iface UCI con prefijo "openuf_" - * 2. Aplica radio_table → wireless..channel/txpower/htmode - * 3. Crea nuevas wifi-iface por cada VAP con su configuración - * 4. Ejecuta "wifi reload" para aplicar sin reiniciar - * - * ── MAPEO DE SEGURIDAD ────────────────────────────────────────────── - * - * UniFi OpenWrt UCI Descripción - * ───────────────────────────────────────────── - * open none Sin contraseña - * wpapsk psk WPA Personal - * wpa2psk psk2 WPA2 Personal - * wpapskwpa2psk psk-mixed WPA/WPA2 mixto - * wpa3 sae WPA3 Personal - * wpa3transition sae-mixed WPA2+WPA3 transición - * wpa2enterprise wpa2 WPA2 Enterprise (RADIUS) - * wpa3enterprise wpa3 WPA3 Enterprise - * - * ── BAND STEERING (802.11k/v) ────────────────────────────────────── - * - * Cuando UniFi activa band_steering, configuramos en UCI: - * ieee80211k = 1 → Neighbor Reports (AP informa a cliente de otros APs) - * ieee80211v = 1 → BSS Transition Management (AP puede pedir que el - * cliente se mueva a otro AP/radio) - * rrm_neighbor_report = 1 - * bss_transition = 1 - * - * El hostapd de OpenWrt usa estos flags para implementar 802.11k/v. - * Band steering real requiere lógica adicional (daemon externo o - * script que monitoriza RSSI y envía BTM Request). - * - * ── FAST ROAMING (802.11r) ───────────────────────────────────────── - * - * Cuando UniFi activa fast_roaming_enabled: - * ieee80211r = 1 → FT (Fast BSS Transition) - * ft_over_ds = 1 → FT sobre Distribution System (más compatible) - * mobility_domain = XXXX → Mismo dominio en todos los APs del site - * ft_psk_generate_local = 1 → PSK sin servidor FT externo - * - * El mobility_domain se deriva de los primeros 2 bytes del MAC del AP. - * Todos los APs del mismo site deben usar el mismo mobility_domain. - * - * ── PMF (Protected Management Frames / 802.11w) ───────────────────── - * - * pmf_mode → ieee80211w: - * "disabled" → 0 (sin PMF) - * "optional" → 1 (PMF opcional, compatible con clientes sin PMF) - * "required" → 2 (PMF obligatorio, solo clientes con PMF) - * - * WPA3 siempre requiere PMF=2. - * - * ── LECTURA DE VAPs DESDE UCI ─────────────────────────────────────── - * - * wlan_get_vap_table() itera todas las wifi-iface de /etc/config/wireless - * que tengan prefijo "openuf_" y construye el JSON vap_table para - * incluirlo en el payload inform. - * - * Para cada VAP leemos: ssid, device, bssid, encryption, key, disabled - * y los traducimos al formato que espera el controlador. - */ - -#include -#include -#include -#include -#include -#include - -#include "wlan.h" -#include "ufmodel.h" -#include "crypto.h" - -#define MAX_RESOLVED_RADIOS 8 - -typedef struct { - char device[32]; - unsigned int bands; -} radio_capability_t; - -static const uf_model_t *resolved_model; -static char resolved_devices[MAX_RESOLVED_RADIOS][32]; - -static unsigned int band_bit(const char *band) -{ - if (!band) return 0; - if (!strcmp(band, "ng") || !strcmp(band, "2g")) return 1u; - if (!strcmp(band, "na") || !strcmp(band, "5g")) return 2u; - if (!strcmp(band, "6g") || !strcmp(band, "6GHz")) return 4u; - return 0; -} - -static int bit_count(unsigned int value) -{ - int count = 0; - while (value) { - count += value & 1u; - value >>= 1; - } - return count; -} - -/* OpenWrt's generated radioN and phyN indices correspond for mac80211 - * devices. Read actual frequencies instead of assuming PHY band order. */ -static unsigned int detect_radio_bands(const char *device) -{ - int phy_index = -1; - char command[96]; - char line[256]; - unsigned int bands = 0; - - if (!device || sscanf(device, "radio%d", &phy_index) != 1 || phy_index < 0) - return 0; - - snprintf(command, sizeof(command), "iw phy phy%d info 2>/dev/null", phy_index); - FILE *pipe = popen(command, "r"); - if (!pipe) return 0; - - while (fgets(line, sizeof(line), pipe)) { - char *mhz = strstr(line, " MHz ["); - if (!mhz || strstr(line, "(disabled)")) - continue; - - char *start = mhz; - while (start > line && - ((start[-1] >= '0' && start[-1] <= '9') || start[-1] == '.')) - start--; - double frequency = strtod(start, NULL); - if (frequency >= 2300.0 && frequency < 3000.0) - bands |= 1u; - else if (frequency >= 4900.0 && frequency < 5925.0) - bands |= 2u; - else if (frequency >= 5925.0 && frequency < 7200.0) - bands |= 4u; - } - pclose(pipe); - return bands; -} - -static void resolve_radio_map(const uf_model_t *model) -{ - if (!model || resolved_model == model) - return; - - memset(resolved_devices, 0, sizeof(resolved_devices)); - resolved_model = model; - - int count = model->radio_map_len; - if (count > MAX_RESOLVED_RADIOS) - count = MAX_RESOLVED_RADIOS; - - radio_capability_t caps[MAX_RESOLVED_RADIOS] = {0}; - int used[MAX_RESOLVED_RADIOS] = {0}; - for (int i = 0; i < count; i++) { - snprintf(caps[i].device, sizeof(caps[i].device), "%s", - model->radio_map[i].device); - caps[i].bands = detect_radio_bands(caps[i].device); - } - - for (int i = 0; i < count; i++) { - unsigned int wanted = band_bit(model->radio_map[i].band); - int best = -1; - int best_band_count = 99; - - for (int j = 0; j < count; j++) { - if (used[j] || !(caps[j].bands & wanted)) - continue; - int supported = bit_count(caps[j].bands); - if (supported < best_band_count) { - best = j; - best_band_count = supported; - } - } - - if (best >= 0) { - used[best] = 1; - snprintf(resolved_devices[i], sizeof(resolved_devices[i]), "%s", - caps[best].device); - } else { - snprintf(resolved_devices[i], sizeof(resolved_devices[i]), "%s", - model->radio_map[i].device); - } - - printf("[openuf] Radio mapping: %s -> %s%s\n", - model->radio_map[i].band, resolved_devices[i], - best >= 0 ? " (detected)" : " (model fallback)"); - } -} - -const char *wlan_device_for_band(const uf_model_t *model, const char *band) -{ - if (!model || !band) return NULL; - resolve_radio_map(model); - for (int i = 0; i < model->radio_map_len; i++) - if (!strcmp(model->radio_map[i].band, band)) - return i < MAX_RESOLVED_RADIOS && resolved_devices[i][0] - ? resolved_devices[i] : model->radio_map[i].device; - return NULL; -} - -const char *wlan_band_for_device(const uf_model_t *model, const char *device) -{ - if (!model || !device) return NULL; - resolve_radio_map(model); - for (int i = 0; i < model->radio_map_len; i++) { - const char *mapped = i < MAX_RESOLVED_RADIOS && resolved_devices[i][0] - ? resolved_devices[i] - : model->radio_map[i].device; - if (!strcmp(mapped, device)) - return model->radio_map[i].band; - } - return NULL; -} - -/* ─── Mapeo de seguridad UniFi → OpenWrt UCI ────────────────────── */ -static const char *sec_to_uci(const char *uf) -{ - if (!uf || !strcmp(uf,"open")) return "none"; - if (!strcmp(uf,"wpapsk")) return "psk"; - if (!strcmp(uf,"wpa2psk")) return "psk2"; - if (!strcmp(uf,"wpapskwpa2psk")) return "psk-mixed"; - if (!strcmp(uf,"wpa3")) return "sae"; - if (!strcmp(uf,"wpa3transition")) return "sae-mixed"; - if (!strcmp(uf,"wpa2enterprise")) return "wpa2"; - if (!strcmp(uf,"wpa3enterprise")) return "wpa3"; - return "psk2"; /* default */ -} - -/* Mapeo inverso: UCI → UniFi (para wlan_get_vap_table) */ -static const char *sec_to_unifi(const char *uci) -{ - if (!uci || !strcmp(uci,"none")) return "open"; - if (!strcmp(uci,"psk")) return "wpapsk"; - if (!strcmp(uci,"psk2")) return "wpa2psk"; - if (!strcmp(uci,"psk-mixed")) return "wpapskwpa2psk"; - if (!strcmp(uci,"sae")) return "wpa3"; - if (!strcmp(uci,"sae-mixed")) return "wpa3transition"; - if (!strcmp(uci,"wpa2")) return "wpa2enterprise"; - if (!strcmp(uci,"wpa3")) return "wpa3enterprise"; - return "wpa2psk"; -} - -/* Return true only for the 24-character hexadecimal IDs used by UniFi. */ -static int valid_object_id(const char *id) -{ - if (!id || strlen(id) != 24) - return 0; - for (size_t i = 0; i < 24; i++) - if (!((id[i] >= '0' && id[i] <= '9') || - (id[i] >= 'a' && id[i] <= 'f') || - (id[i] >= 'A' && id[i] <= 'F'))) - return 0; - return 1; -} -/* - * UniFi uses a 24-character hexadecimal MongoDB ObjectId to associate VAP - * telemetry and stations with a WLAN. Older legacy system_cfg payloads do - * not include that ID, so allocate a local one and keep it stable in UCI. - * All radio instances of the same WLAN must report the same ID. - */ -static int ensure_local_vap_ids(struct json_object *vaps) -{ - if (!vaps || !json_object_is_type(vaps, json_type_array)) - return 0; - - struct uci_context *ctx = uci_alloc_context(); - struct uci_package *pkg = NULL; - if (ctx) - uci_load(ctx, "wireless", &pkg); - - int count = json_object_array_length(vaps); - for (int i = 0; i < count; i++) { - struct json_object *vap = json_object_array_get_idx(vaps, i); - struct json_object *value; - if (!vap) - continue; - - const char *id = NULL; - const char *id_keys[] = { "_id", "id", "wlanconf_id" }; - for (size_t n = 0; n < sizeof(id_keys) / sizeof(id_keys[0]); n++) { - if (json_object_object_get_ex(vap, id_keys[n], &value)) { - const char *candidate = json_object_get_string(value); - if (valid_object_id(candidate)) { - id = candidate; - break; - } - } - } - if (id) - continue; - - const char *ssid = ""; - if (json_object_object_get_ex(vap, "essid", &value)) - ssid = json_object_get_string(value); - - /* Reuse an ID already assigned to this WLAN in the same payload. */ - for (int j = 0; j < i && !id; j++) { - struct json_object *previous = json_object_array_get_idx(vaps, j); - struct json_object *previous_ssid; - struct json_object *previous_id; - if (previous && - json_object_object_get_ex(previous, "essid", &previous_ssid) && - !strcmp(json_object_get_string(previous_ssid), ssid) && - json_object_object_get_ex(previous, "id", &previous_id) && - valid_object_id(json_object_get_string(previous_id))) - id = json_object_get_string(previous_id); - } - - /* Reuse the ID committed by an earlier provisioning cycle. */ - if (!id && pkg) { - struct uci_element *element; - uci_foreach_element(&pkg->sections, element) { - struct uci_section *section = uci_to_section(element); - const char *stored_ssid; - const char *stored_id; - if (strcmp(section->type, "wifi-iface") || - strncmp(section->e.name, "openuf_", 7)) - continue; - stored_ssid = uci_lookup_option_string(ctx, section, "ssid"); - stored_id = uci_lookup_option_string(ctx, section, - "openuf_vap_id"); - if (stored_ssid && !strcmp(stored_ssid, ssid) && - valid_object_id(stored_id)) { - id = stored_id; - break; - } - } - } - - char generated[25]; - if (!id) { - if (crypto_random_hex((unsigned char *)generated, 12) != 0) { - printf("[openuf] Failed to generate a VAP ID for '%s'\n", ssid); - if (pkg) uci_unload(ctx, pkg); - if (ctx) uci_free_context(ctx); - return -1; - } - id = generated; - printf("[openuf] Generated persistent VAP ID %s for '%s'\n", - id, ssid); - } - - json_object_object_add(vap, "id", json_object_new_string(id)); - json_object_object_add(vap, "wlanconf_id", json_object_new_string(id)); - } - - if (pkg) uci_unload(ctx, pkg); - if (ctx) uci_free_context(ctx); - return 0; -} - -/* Build one stable 802.11r mobility domain shared by every AP for an SSID. */ -static void mobility_domain_for_ssid(const char *ssid, char out[5]) -{ - unsigned int hash = 2166136261u; - const unsigned char *p = (const unsigned char *)(ssid ? ssid : ""); - while (*p) { - hash ^= *p++; - hash *= 16777619u; - } - snprintf(out, 5, "%04x", (hash ^ (hash >> 16)) & 0xffffu); -} - -/* Return true when an OpenWrt radio is backed by the ath9k kernel driver. */ -static int radio_uses_ath9k(const char *device_name) -{ - int phy_index; - if (!device_name || sscanf(device_name, "radio%d", &phy_index) != 1) - return 0; - - char path[128]; - char target[256]; - snprintf(path, sizeof(path), - "/sys/class/ieee80211/phy%d/device/driver", phy_index); - ssize_t length = readlink(path, target, sizeof(target) - 1); - if (length < 0) - return 0; - target[length] = '\0'; - return strstr(target, "ath9k") != NULL; -} - -/* Read a UniFi boolean while accepting names used by controller versions. */ -static int json_boolean_any(struct json_object *object, - const char *const *keys, size_t key_count) -{ - struct json_object *value; - for (size_t i = 0; i < key_count; i++) { - if (!json_object_object_get_ex(object, keys[i], &value)) - continue; - if (json_object_is_type(value, json_type_string)) { - const char *text = json_object_get_string(value); - if (!text || !text[0] || !strcasecmp(text, "disabled") || - !strcasecmp(text, "false") || !strcasecmp(text, "off") || - !strcasecmp(text, "none") || !strcmp(text, "0")) - return 0; - /* Also accepts controller modes such as "prefer_5g". */ - return 1; - } - return json_object_get_boolean(value) ? 1 : 0; - } - return 0; -} - -/* Interpret Boolean text and UniFi feature modes such as "prefer_5g". */ -static int feature_text_enabled(const char *text) -{ - return text && text[0] && strcasecmp(text, "disabled") && - strcasecmp(text, "false") && strcasecmp(text, "off") && - strcasecmp(text, "none") && strcmp(text, "0"); -} - -/* Safe UCI identifier fragment (maximum 15 characters). */ -static void safe_section_name(const char *ssid, char *out, size_t sz) -{ - size_t j = 0; - for (size_t i = 0; ssid[i] && j < sz-1 && j < 15; i++) { - char c = ssid[i]; - if ((c>='a'&&c<='z')||(c>='A'&&c<='Z')|| - (c>='0'&&c<='9')||c=='_') - out[j++] = c; - else - out[j++] = '_'; - } - out[j] = '\0'; -} - -/* ─── libuci: set un valor en wireless ─────────────────────────── */ -static int uci_set_val(struct uci_context *ctx, - const char *path, const char *val) -{ - struct uci_ptr ptr; - char *p = malloc(strlen(path) + strlen(val) + 2); - if (!p) return -1; - sprintf(p, "%s=%s", path, val); - int ret = uci_lookup_ptr(ctx, &ptr, p, true); - if (ret == UCI_OK) - ret = uci_set(ctx, &ptr); - /* ptr.value may point inside p, so free it only after uci_set(). */ - free(p); - return ret == UCI_OK ? 0 : -1; -} -/* Set and verify an option whose absence would make a VAP unusable. */ -static int uci_set_required(struct uci_context *ctx, - struct uci_package *pkg, - const char *section_name, - const char *option_name, - const char *value) -{ - char path[256]; - snprintf(path, sizeof(path), "%s.%s.%s", - pkg->e.name, section_name, option_name); - if (uci_set_val(ctx, path, value) != 0) - return -1; - - struct uci_section *section = - uci_lookup_section(ctx, pkg, section_name); - const char *stored = section ? - uci_lookup_option_string(ctx, section, option_name) : NULL; - return stored && !strcmp(stored, value) ? 0 : -1; -} - -/* Add one value to a UCI list option. */ -static int uci_add_list_val(struct uci_context *ctx, - const char *path, const char *val) -{ - struct uci_ptr ptr; - char *assignment = malloc(strlen(path) + strlen(val) + 2); - if (!assignment) return -1; - sprintf(assignment, "%s=%s", path, val); - int ret = uci_lookup_ptr(ctx, &ptr, assignment, true); - if (ret == UCI_OK) - ret = uci_add_list(ctx, &ptr); - /* ptr.value may point inside assignment. */ - free(assignment); - return ret == UCI_OK ? 0 : -1; -} - - -/* Wrapper que formatea path y value en printf style */ -#define UCI_SET(ctx, pkg, sec, opt, val) do { \ - char _path[256]; \ - snprintf(_path, sizeof(_path), "%s.%s.%s", pkg, sec, opt); \ - uci_set_val(ctx, _path, val); \ -} while(0) - -#define UCI_SET_INT(ctx, pkg, sec, opt, ival) do { \ - char _v[32]; snprintf(_v, sizeof(_v), "%d", ival); \ - UCI_SET(ctx, pkg, sec, opt, _v); \ -} while(0) - -/* ─── Encontrar/crear sección UCI ──────────────────────────────── */ -static int uci_ensure_section(struct uci_context *ctx, - struct uci_package *pkg, - const char *sec_name, - const char *sec_type) -{ - struct uci_element *e; - uci_foreach_element(&pkg->sections, e) { - struct uci_section *s = uci_to_section(e); - if (!strcmp(s->e.name, sec_name) && !strcmp(s->type, sec_type)) - return 0; /* ya existe */ - } - /* Create a named section: wireless.=. */ - char *p = malloc(strlen(pkg->e.name) + strlen(sec_name) + - strlen(sec_type) + 3); - if (!p) return -1; - sprintf(p, "%s.%s=%s", pkg->e.name, sec_name, sec_type); - struct uci_ptr ptr; - int ret = uci_lookup_ptr(ctx, &ptr, p, true); - if (ret == UCI_OK) - ret = uci_set(ctx, &ptr); - free(p); - return ret == UCI_OK ? 0 : -1; -} - -/* - * Resolve the physical port below network.lan's bridge. VLAN tagging must - * happen on that port (for example eth0.11), not above the management bridge. - */ -static void find_vlan_uplink(struct uci_context *ctx, - struct uci_package *pkg, - char *out, size_t out_size) -{ - const char *lan_device = "br-lan"; - struct uci_element *element; - - uci_foreach_element(&pkg->sections, element) { - struct uci_section *section = uci_to_section(element); - if (!strcmp(section->type, "interface") && - !strcmp(section->e.name, "lan")) { - const char *device = uci_lookup_option_string(ctx, section, - "device"); - if (device && device[0]) lan_device = device; - break; - } - } - - uci_foreach_element(&pkg->sections, element) { - struct uci_section *section = uci_to_section(element); - const char *name; - struct uci_option *ports; - if (strcmp(section->type, "device")) continue; - name = uci_lookup_option_string(ctx, section, "name"); - if (!name || strcmp(name, lan_device)) continue; - ports = uci_lookup_option(ctx, section, "ports"); - if (!ports) break; - if (ports->type == UCI_TYPE_STRING) { - snprintf(out, out_size, "%s", ports->v.string); - return; - } - if (ports->type == UCI_TYPE_LIST && !uci_list_empty(&ports->v.list)) { - struct uci_element *port = - list_to_element(ports->v.list.next); - snprintf(out, out_size, "%s", port->name); - return; - } - break; - } - - snprintf(out, out_size, "eth0"); -} - -static int ensure_vlan_network(int vid) -{ - struct uci_context *ctx = uci_alloc_context(); - if (!ctx) return -1; - - struct uci_package *pkg = NULL; - if (uci_load(ctx, "network", &pkg) != UCI_OK) { - uci_free_context(ctx); - return -1; - } - - char vlan_section[48], bridge_section[48], interface_section[32]; - char vlan_uplink[32], vlan_device[32], bridge_device[32], vid_string[16]; - find_vlan_uplink(ctx, pkg, vlan_uplink, sizeof(vlan_uplink)); - snprintf(vlan_section, sizeof(vlan_section), - "openuf_vlan%d", vid); - snprintf(bridge_section, sizeof(bridge_section), "openuf_br%d", vid); - snprintf(interface_section, sizeof(interface_section), - "vlan%d", vid); - snprintf(vlan_device, sizeof(vlan_device), "%s.%d", vlan_uplink, vid); - snprintf(bridge_device, sizeof(bridge_device), "br-openuf-%d", vid); - snprintf(vid_string, sizeof(vid_string), "%d", vid); - - int ok = uci_ensure_section(ctx, pkg, vlan_section, "device") == 0 && - uci_ensure_section(ctx, pkg, bridge_section, "device") == 0 && - uci_ensure_section(ctx, pkg, interface_section, "interface") == 0; - if (ok) { - UCI_SET(ctx, "network", vlan_section, "type", "8021q"); - UCI_SET(ctx, "network", vlan_section, "ifname", vlan_uplink); - UCI_SET(ctx, "network", vlan_section, "vid", vid_string); - UCI_SET(ctx, "network", vlan_section, "name", vlan_device); - - /* A VAP needs a bridge containing the tagged wired device. */ - UCI_SET(ctx, "network", bridge_section, "type", "bridge"); - UCI_SET(ctx, "network", bridge_section, "name", bridge_device); - char ports_path[128]; - snprintf(ports_path, sizeof(ports_path), "network.%s.ports", - bridge_section); - /* Replace the list so repeated provisioning never duplicates ports. */ - struct uci_ptr ports_ptr; - char ports_lookup[128]; - snprintf(ports_lookup, sizeof(ports_lookup), "%s", ports_path); - if (uci_lookup_ptr(ctx, &ports_ptr, ports_lookup, true) == UCI_OK && - ports_ptr.o) - uci_delete(ctx, &ports_ptr); - ok = uci_add_list_val(ctx, ports_path, vlan_device) == 0; - - UCI_SET(ctx, "network", interface_section, "proto", "none"); - UCI_SET(ctx, "network", interface_section, "device", bridge_device); - ok = ok && uci_commit(ctx, &pkg, false) == UCI_OK; - } - - uci_unload(ctx, pkg); - uci_free_context(ctx); - if (ok) - printf("[openuf] Configured VLAN %d on uplink %s as network '%s'\n", - vid, vlan_uplink, interface_section); - return ok ? 0 : -1; -} - -/* - * Configure OpenWrt's steering policy engine. The hostapd 802.11k/v flags - * only expose measurements and transition commands; they do not decide when - * a station should move. usteer supplies that missing policy loop. - */ -static int configure_band_steering(int enabled) -{ - struct uci_context *ctx = uci_alloc_context(); - if (!ctx) - return -1; - - struct uci_package *pkg = NULL; - if (uci_load(ctx, "usteer", &pkg) != UCI_OK) { - printf("[openuf] Cannot load /etc/config/usteer\n"); - uci_free_context(ctx); - return -1; - } - - struct uci_section *settings = NULL; - struct uci_element *element; - uci_foreach_element(&pkg->sections, element) { - struct uci_section *section = uci_to_section(element); - if (!strcmp(section->type, "usteer")) { - settings = section; - break; - } - } - - if (!settings) { - if (uci_ensure_section(ctx, pkg, "openuf", "usteer") != 0) { - uci_unload(ctx, pkg); - uci_free_context(ctx); - return -1; - } - settings = uci_lookup_section(ctx, pkg, "openuf"); - } - - if (!settings) { - uci_unload(ctx, pkg); - uci_free_context(ctx); - return -1; - } - - /* - * A zero interval disables higher-band steering. A zero station-count - * threshold is important for small networks: usteer's default of five - * otherwise prevents a lone client from being considered. The signal - * floor avoids pushing a client onto 5 GHz when that link is too weak. - */ - UCI_SET(ctx, "usteer", settings->e.name, "band_steering_interval", - enabled ? "30000" : "0"); - UCI_SET(ctx, "usteer", settings->e.name, "band_steering_threshold", "0"); - UCI_SET(ctx, "usteer", settings->e.name, "band_steering_min_snr", "-65"); - - int ok = uci_commit(ctx, &pkg, false) == UCI_OK; - uci_unload(ctx, pkg); - uci_free_context(ctx); - printf("[openuf] Band steering policy %s (usteer)\n", - enabled ? "enabled" : "disabled"); - return ok ? 0 : -1; -} - -/* ═══════════════════════════════════════════════════════════════════ - wlan_clear — remove all VAPs before applying controller ownership - ═══════════════════════════════════════════════════════════════════ */ -void wlan_clear(void) -{ - struct uci_context *ctx = uci_alloc_context(); - if (!ctx) return; - - struct uci_package *pkg = NULL; - if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { - uci_free_context(ctx); - return; - } - - /* Collect sections to remove (do not modify during iteration) */ - char *to_del[64]; - int ndel = 0; - struct uci_element *e; - uci_foreach_element(&pkg->sections, e) { - struct uci_section *s = uci_to_section(e); - if (!strcmp(s->type, "wifi-iface") && ndel < 64) { - to_del[ndel++] = strdup(s->e.name); - } - } - - for (int i = 0; i < ndel; i++) { - struct uci_ptr ptr; - char path[128]; - snprintf(path, sizeof(path), "wireless.%s", to_del[i]); - if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) - uci_delete(ctx, &ptr); - free(to_del[i]); - } - - if (ndel > 0) { - uci_commit(ctx, &pkg, false); - printf("[openuf] wlan_clear: removed %d existing VAPs\n", ndel); - } - - uci_unload(ctx, pkg); - uci_free_context(ctx); -} - -enum wifi_standard { - WIFI_STANDARD_UNKNOWN = 0, - WIFI_STANDARD_4 = 4, - WIFI_STANDARD_5 = 5, - WIFI_STANDARD_6 = 6, - WIFI_STANDARD_7 = 7, -}; - -/* Return the newest 802.11 generation advertised by the local PHY. */ -static enum wifi_standard radio_max_standard(const char *device_name) -{ - int phy_index = -1; - char command[96]; - char line[256]; - enum wifi_standard standard = WIFI_STANDARD_UNKNOWN; - - if (!device_name || sscanf(device_name, "radio%d", &phy_index) != 1 || - phy_index < 0) - return WIFI_STANDARD_UNKNOWN; - - snprintf(command, sizeof(command), "iw phy phy%d info 2>/dev/null", - phy_index); - FILE *pipe = popen(command, "r"); - if (!pipe) - return WIFI_STANDARD_UNKNOWN; - - while (fgets(line, sizeof(line), pipe)) { - if (strstr(line, "EHT Iftypes")) - standard = WIFI_STANDARD_7; - else if (standard < WIFI_STANDARD_6 && strstr(line, "HE Iftypes")) - standard = WIFI_STANDARD_6; - else if (standard < WIFI_STANDARD_5 && - strstr(line, "VHT Capabilities")) - standard = WIFI_STANDARD_5; - else if (standard < WIFI_STANDARD_4 && strstr(line, "Capabilities:")) - standard = WIFI_STANDARD_4; - } - pclose(pipe); - return standard; -} - -/* Keep the controller-selected width but use the newest PHY generation. */ -static void select_htmode(const char *device_name, const char *radio_band, - const char *requested, int force_wifi4, - char *result, size_t result_size) -{ - enum wifi_standard standard = radio_max_standard(device_name); - int width = 20; - const char *number = requested; - - while (number && *number && (*number < '0' || *number > '9')) - number++; - if (number && *number) - width = atoi(number); - - if (force_wifi4) { - /* 802.11n cannot use 80 MHz or wider channels. */ - standard = WIFI_STANDARD_4; - if (width > 40) - width = 40; - } else if (standard == WIFI_STANDARD_5 && radio_band && - (!strcmp(radio_band, "ng") || !strcmp(radio_band, "2g"))) { - /* OpenWrt does not use VHT modes on the 2.4 GHz band. */ - standard = WIFI_STANDARD_4; - } - - const char *prefix = standard == WIFI_STANDARD_7 ? "EHT" : - standard == WIFI_STANDARD_6 ? "HE" : - standard == WIFI_STANDARD_5 ? "VHT" : - standard == WIFI_STANDARD_4 ? "HT" : NULL; - if (!prefix) { - snprintf(result, result_size, "%s", requested ? requested : "HT20"); - return; - } - - snprintf(result, result_size, "%s%d", prefix, width); -} - -/* ═══════════════════════════════════════════════════════════════════ - wlan_apply_radio — apply radio config (channel, HT, power) - ═══════════════════════════════════════════════════════════════════ - - Reading parameters from the controller's JSON: - channel → wireless..channel - ht → wireless..htmode ("HT20" / "HT40" / "HT80" / "HE80") - tx_power → wireless..txpower - min_rssi → not mapped to UCI (requires an external daemon) -*/ -void wlan_apply_radio(struct json_object *radio_json, - const char *device_name, - int force_wifi4) -{ - struct uci_context *ctx = uci_alloc_context(); - if (!ctx) return; - - struct uci_package *pkg = NULL; - if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { - uci_free_context(ctx); return; - } - - struct json_object *v; - char path[256]; - - /* Map UniFi band names to OpenWrt mac80211 band names. */ - const char *radio_band = NULL; - if (json_object_object_get_ex(radio_json, "radio", &v)) { - radio_band = json_object_get_string(v); - const char *band = !strcmp(radio_band, "ng") ? "2g" : - !strcmp(radio_band, "na") ? "5g" : - !strcmp(radio_band, "6g") ? "6g" : NULL; - if (band) { - snprintf(path, sizeof(path), "wireless.%s.band=%s", - device_name, band); - struct uci_ptr ptr; - if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) - uci_set(ctx, &ptr); - } - } - - if (json_object_object_get_ex(radio_json, "ht", &v)) { - const char *requested = json_object_get_string(v); - char htmode[16]; - select_htmode(device_name, radio_band, requested, force_wifi4, - htmode, sizeof(htmode)); - snprintf(path, sizeof(path), "wireless.%s.htmode=%s", - device_name, htmode); - struct uci_ptr ptr; - if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) - uci_set(ctx, &ptr); - printf("[openuf] Radio %s standard: %s%s\n", device_name, htmode, - force_wifi4 ? " (Force WiFi 4)" : " (newest supported)"); - } - - /* Channel: 0 = auto in UniFi */ - if (json_object_object_get_ex(radio_json, "channel", &v)) { - int ch = json_object_get_int(v); - if (ch == 0) { - snprintf(path, sizeof(path), "wireless.%s.channel=auto", device_name); - } else { - snprintf(path, sizeof(path), "wireless.%s.channel=%d", device_name, ch); - } - struct uci_ptr ptr; - if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) - uci_set(ctx, &ptr); - } - - /* tx_power */ - if (json_object_object_get_ex(radio_json, "tx_power", &v)) { - snprintf(path, sizeof(path), "wireless.%s.txpower=%d", - device_name, json_object_get_int(v)); - struct uci_ptr ptr; - if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) - uci_set(ctx, &ptr); - } - - /* Enable the radio */ - snprintf(path, sizeof(path), "wireless.%s.disabled=0", device_name); - struct uci_ptr ptr; - if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) - uci_set(ctx, &ptr); - - uci_commit(ctx, &pkg, false); - uci_unload(ctx, pkg); - uci_free_context(ctx); -} - -/* ═══════════════════════════════════════════════════════════════════ - Create a VAP (wifi-iface UCI) from a controller VAP JSON - ═══════════════════════════════════════════════════════════════════ - - Controller parameters we read and how we map them: - - essid → wireless.openuf_X.ssid - x_passphrase → wireless.openuf_X.key - security → wireless.openuf_X.encryption (via sec_to_uci) - hide_ssid → wireless.openuf_X.hidden - guest_policy → wireless.openuf_X.isolate (client isolation) - fast_roaming_enabled → ieee80211r, ft_over_ds, mobility_domain, ft_psk_generate_local - band_steering → ieee80211k, ieee80211v, rrm_neighbor_report, bss_transition - pmf_mode → ieee80211w (0/1/2) - wpa3_support → add "sae-mixed" if WPA2+WPA3 - uapsd → uapsd (U-APSD power saving) - vlan_id → wireless.openuf_X.vlan_id (if ≠ 0) -*/ -static int apply_vap(struct uci_context *ctx, - struct uci_package *pkg, - struct json_object *vap_json, - const char *device_name, - const char *radio_band, - const char *mac_str, - int vap_idx) -{ - struct json_object *v; - (void)mac_str; - const char *essid = ""; - const char *security = "wpa2psk"; - const char *pass = ""; - const char *auth_server = NULL; - const char *auth_secret = NULL; - const char *acct_server = NULL; - const char *acct_secret = NULL; - int auth_port = 1812; - int acct_port = 1813; - - if (json_object_object_get_ex(vap_json, "essid", &v)) essid = json_object_get_string(v); - if (json_object_object_get_ex(vap_json, "security", &v)) security = json_object_get_string(v); - if (json_object_object_get_ex(vap_json, "x_passphrase",&v)) pass = json_object_get_string(v); - - int enterprise = !strcmp(security, "wpa2enterprise") || - !strcmp(security, "wpa3enterprise"); - if (json_object_object_get_ex(vap_json, "auth_server", &v)) - auth_server = json_object_get_string(v); - if (json_object_object_get_ex(vap_json, "auth_secret", &v)) - auth_secret = json_object_get_string(v); - if (json_object_object_get_ex(vap_json, "auth_port", &v)) - auth_port = json_object_get_int(v); - if (json_object_object_get_ex(vap_json, "acct_server", &v)) - acct_server = json_object_get_string(v); - if (json_object_object_get_ex(vap_json, "acct_secret", &v)) - acct_secret = json_object_get_string(v); - if (json_object_object_get_ex(vap_json, "acct_port", &v)) - acct_port = json_object_get_int(v); - - /* Resolve the final network before creating the VAP; never fail open. */ - int vid = 0; - char target_network[32] = "lan"; - if (json_object_object_get_ex(vap_json, "vlan_id", &v)) - vid = json_object_get_int(v); - if (vid > 0) { - if (ensure_vlan_network(vid) != 0) { - printf("[openuf] Failed to configure VLAN network %d\n", vid); - return -1; - } - snprintf(target_network, sizeof(target_network), "vlan%d", vid); - } - - /* Section name: openuf__ */ - char safe[16] = {0}; - safe_section_name(essid, safe, sizeof(safe)); - char sec_name[48]; - snprintf(sec_name, sizeof(sec_name), "openuf_%d_%s", vap_idx, safe); - - if (uci_ensure_section(ctx, pkg, sec_name, "wifi-iface") != 0) { - printf("[openuf] Failed to create VAP section '%s'\n", sec_name); - return -1; - } - - UCI_SET(ctx, "wireless", sec_name, "device", device_name); - UCI_SET(ctx, "wireless", sec_name, "mode", "ap"); - UCI_SET(ctx, "wireless", sec_name, "ssid", essid); - UCI_SET(ctx, "wireless", sec_name, "network", target_network); - UCI_SET(ctx, "wireless", sec_name, "encryption", sec_to_uci(security)); - - /* - * Preserve the controller's WLAN configuration ID. Inform telemetry must - * refer to this ObjectId; a label such as "user" is not a valid VAP ID. - * Controller versions use different keys, so accept the known variants. - */ - const char *vap_id = NULL; - const char *id_keys[] = { "_id", "id", "wlanconf_id" }; - for (size_t i = 0; i < sizeof(id_keys) / sizeof(id_keys[0]); i++) { - if (json_object_object_get_ex(vap_json, id_keys[i], &v)) { - const char *candidate = json_object_get_string(v); - if (valid_object_id(candidate)) { - vap_id = candidate; - break; - } - } - } - if (vap_id) - UCI_SET(ctx, "wireless", sec_name, "openuf_vap_id", vap_id); - - /* Password */ - if (!enterprise && pass && pass[0] && strcmp(security,"open") != 0) - UCI_SET(ctx, "wireless", sec_name, "key", pass); - - if (enterprise) { - if (!auth_server || !auth_server[0] || - !auth_secret || !auth_secret[0]) { - printf("[openuf] Refusing Enterprise VAP '%s': missing RADIUS " - "authentication server or secret\n", essid); - return -1; - } - - char path[256]; - char port[16]; - snprintf(path, sizeof(path), "wireless.%s.auth_server", sec_name); - snprintf(port, sizeof(port), "%d", auth_port > 0 ? auth_port : 1812); - if (uci_add_list_val(ctx, path, auth_server) != 0 || - uci_set_required(ctx, pkg, sec_name, "auth_port", port) != 0 || - uci_set_required(ctx, pkg, sec_name, "auth_secret", - auth_secret) != 0) { - printf("[openuf] Failed to store RADIUS authentication for " - "VAP '%s'\n", essid); - return -1; - } - - if (acct_server && acct_server[0]) { - if (!acct_secret || !acct_secret[0]) { - printf("[openuf] Refusing Enterprise VAP '%s': accounting " - "server has no secret\n", essid); - return -1; - } - snprintf(path, sizeof(path), "wireless.%s.acct_server", sec_name); - snprintf(port, sizeof(port), "%d", - acct_port > 0 ? acct_port : 1813); - if (uci_add_list_val(ctx, path, acct_server) != 0 || - uci_set_required(ctx, pkg, sec_name, "acct_port", port) != 0 || - uci_set_required(ctx, pkg, sec_name, "acct_secret", - acct_secret) != 0) { - printf("[openuf] Failed to store RADIUS accounting for " - "VAP '%s'\n", essid); - return -1; - } - } - } - - /* hidden SSID */ - int hidden = 0; - if (json_object_object_get_ex(vap_json, "hide_ssid", &v)) - hidden = json_object_get_boolean(v) ? 1 : 0; - UCI_SET_INT(ctx, "wireless", sec_name, "hidden", hidden); - - /* Client isolation (guest network) */ - int isolate = 0; - if (json_object_object_get_ex(vap_json, "guest_policy", &v)) - isolate = json_object_get_boolean(v) ? 1 : 0; - UCI_SET_INT(ctx, "wireless", sec_name, "isolate", isolate); - - /* U-APSD (power saving for mobile clients) */ - int uapsd = 1; - if (json_object_object_get_ex(vap_json, "uapsd", &v)) - uapsd = json_object_get_boolean(v) ? 1 : 0; - UCI_SET_INT(ctx, "wireless", sec_name, "uapsd", uapsd); - - /* ── PMF (Protected Management Frames / 802.11w) ────────────── - * "disabled" → 0, "optional" → 1, "required" → 2 - * WPA3-only requires PMF. WPA2/WPA3 transition mode must leave PMF - * optional so WPA2-only clients can still associate. */ - int pmf = 0; - if (json_object_object_get_ex(vap_json, "pmf_mode", &v)) { - const char *pm = json_object_get_string(v); - if (!strcmp(pm, "optional")) pmf = 1; - if (!strcmp(pm, "required")) pmf = 2; - } - if (!strcmp(security,"wpa3") || !strcmp(security,"wpa3enterprise")) - pmf = 2; - else if (!strcmp(security,"wpa3transition") && pmf < 1) - pmf = 1; - UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211w", pmf); - - /* ── Fast Roaming (802.11r FT) ──────────────────────────────── - * Allows clients to move between APs without re-authentication - * complete. The FT handshake only takes ~50ms vs ~200-300ms for a normal one. */ - const char *ft_keys[] = { - "fast_roaming_enabled", "fast_roaming", "ft_enabled", "ieee80211r" - }; - int ft = json_boolean_any(vap_json, ft_keys, - sizeof(ft_keys) / sizeof(ft_keys[0])); - - /* - * This legacy 2.4 GHz ath9k PHY rejects every FT beacon tested, including - * WPA2 with PMF disabled. Preserve the controller request for telemetry, - * but disable 802.11r on this one unsupported PHY so the BSS can start. - */ - if (ft && radio_band && !strcmp(radio_band, "ng") && - radio_uses_ath9k(device_name)) { - UCI_SET_INT(ctx, "wireless", sec_name, "openuf_ft_requested", 1); - ft = 0; - printf("[openuf] Disabled FT on unsupported 2.4 GHz ath9k radio %s\n", - device_name); - } - - if (ft) { - char mdomain[5]; - mobility_domain_for_ssid(essid, mdomain); - UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211r", 1); - UCI_SET_INT(ctx, "wireless", sec_name, "ft_over_ds", 0); - UCI_SET_INT(ctx, "wireless", sec_name, "ft_psk_generate_local", 1); - /* Local key generation avoids external R0KH/R1KH dependencies. */ - UCI_SET(ctx, "wireless", sec_name, "mobility_domain", mdomain); - } else { - UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211r", 0); - } - - /* Enable the hostapd capabilities used by steering and 802.11v hints. */ - const char *band_steer_keys[] = { - "band_steering", "band_steering_enabled", "band_steering_mode", - "steering_enabled" - }; - const char *handoff_keys[] = { - "bss_transition", "bss_transition_enabled", - "bss_transition_management", "handoff_suggestions", - "handoff_suggestions_enabled", "ieee80211v" - }; - int band_steer = json_boolean_any( - vap_json, band_steer_keys, - sizeof(band_steer_keys) / sizeof(band_steer_keys[0])); - int handoff = json_boolean_any( - vap_json, handoff_keys, - sizeof(handoff_keys) / sizeof(handoff_keys[0])); - int rrm = band_steer || handoff; - int bss_transition_requested = band_steer || handoff; - - UCI_SET_INT(ctx, "wireless", sec_name, "openuf_band_steering", - band_steer); - UCI_SET_INT(ctx, "wireless", sec_name, "openuf_handoff_suggestions", - handoff); - UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211k", rrm); - UCI_SET_INT(ctx, "wireless", sec_name, "rrm_neighbor_report", rrm); - UCI_SET_INT(ctx, "wireless", sec_name, "rrm_beacon_report", rrm); - /* Enable 802.11v at runtime after hostapd starts. Putting this option - * in UCI makes builds without CONFIG_WNM_AP reject the entire BSS. */ - if (bss_transition_requested) - UCI_SET_INT(ctx, "wireless", sec_name, - "openuf_bss_transition_requested", 1); - - /* Record the controller VLAN for telemetry and diagnostics. */ - if (vid > 0) - UCI_SET_INT(ctx, "wireless", sec_name, "vlan_id", vid); - - /* Reassert and validate every option required to start a secure AP. */ - if (uci_set_required(ctx, pkg, sec_name, "device", device_name) != 0) { - printf("[openuf] Failed to bind VAP '%s' to %s\n", - essid, device_name); - return -1; - } - if (uci_set_required(ctx, pkg, sec_name, "mode", "ap") != 0 || - uci_set_required(ctx, pkg, sec_name, "ssid", essid) != 0 || - uci_set_required(ctx, pkg, sec_name, "encryption", - sec_to_uci(security)) != 0) { - printf("[openuf] Refusing incomplete VAP '%s': core AP options " - "could not be stored\n", essid); - return -1; - } - if (!enterprise && pass && pass[0] && strcmp(security, "open") != 0 && - uci_set_required(ctx, pkg, sec_name, "key", pass) != 0) { - printf("[openuf] Refusing unsecured VAP '%s': key could not be stored\n", - essid); - return -1; - } - if (uci_set_required(ctx, pkg, sec_name, "network", target_network) != 0) { - printf("[openuf] Refusing unsafe VAP '%s': cannot bind to %s\n", - essid, target_network); - return -1; - } - - printf("[openuf] VAP '%s' -> %s device=%s network=%s enc=%s " - "ft=%d bs=%d handoff=%d pmf=%d\n", - essid, sec_name, device_name, target_network, sec_to_uci(security), - ft, band_steer, handoff, pmf); - return 0; -} - -/* True when a UniFi WLAN on this band explicitly requests WiFi 4 mode. */ -static int force_wifi4_for_band(struct json_object *vaps, const char *band) -{ - if (!vaps || !json_object_is_type(vaps, json_type_array)) - return 0; - - const char *force_keys[] = { - "iot", "force_wifi4", "force_wifi4_mode" - }; - int count = json_object_array_length(vaps); - for (int i = 0; i < count; i++) { - struct json_object *vap = json_object_array_get_idx(vaps, i); - struct json_object *value; - if (!vap || !json_boolean_any(vap, force_keys, - sizeof(force_keys) / - sizeof(force_keys[0]))) - continue; - - const char *vap_band = NULL; - if (json_object_object_get_ex(vap, "radio", &value)) - vap_band = json_object_get_string(value); - if (!vap_band || !vap_band[0] || !strcmp(vap_band, "both") || - !strcmp(vap_band, "all") || !strcmp(vap_band, band) || - (!strcmp(vap_band, "2g") && !strcmp(band, "ng")) || - (!strcmp(vap_band, "5g") && !strcmp(band, "na")) || - (!strcmp(vap_band, "6GHz") && !strcmp(band, "6g"))) - return 1; - } - return 0; -} - -/* ═══════════════════════════════════════════════════════════════════ - wlan_apply_config — apply the controller's full configuration - ═══════════════════════════════════════════════════════════════════ - - Called from inform.c → handle_response() when _type=="setstate". - config_json is the controller's complete JSON. - - Process: - 1. Remove old VAPs (openuf_ prefix) - 2. Apply radio_table (channel, power, htmode) per radio - 3. Create one VAP for each entry in vap_table - 4. Commit UCI - 5. Run "wifi reload" to apply without rebooting the AP -*/ -int wlan_apply_config(struct json_object *config_json, - const uf_model_t *model) -{ - struct json_object *rt_arr = NULL, *vt_arr = NULL, *v; - json_object_object_get_ex(config_json, "radio_table", &rt_arr); - json_object_object_get_ex(config_json, "vap_table", &vt_arr); - /* Fill IDs omitted by legacy provisioning before old UCI VAPs are removed. */ - if (ensure_local_vap_ids(vt_arr) != 0) - return -1; - - /* Get the AP's MAC for mobility_domain */ - char mac_str[32] = "00:00:00:00:00:00"; - { - char path[128]; - snprintf(path, sizeof(path), "/sys/class/net/eth0/address"); - FILE *f = fopen(path, "r"); - if (f) { fgets(mac_str, sizeof(mac_str), f); fclose(f); } - mac_str[strcspn(mac_str, "\r\n")] = '\0'; - } - - /* Stop hostapd so a deleted BSS cannot survive a netifd reload race. */ - printf("[openuf] Stopping Wi-Fi before controller provisioning...\n"); - system("wifi down >/dev/null 2>&1"); - - /* Remove every existing VAP so UniFi becomes the sole Wi-Fi owner. */ - wlan_clear(); - - /* 2. Apply radio_table */ - if (rt_arr && json_object_is_type(rt_arr, json_type_array)) { - int nr = json_object_array_length(rt_arr); - for (int i = 0; i < nr; i++) { - struct json_object *r = json_object_array_get_idx(rt_arr, i); - if (!r) continue; - /* Find the UCI device corresponding to this band */ - const char *radio_band = ""; - if (json_object_object_get_ex(r, "radio", &v)) - radio_band = json_object_get_string(v); - const char *device_name = wlan_device_for_band(model, radio_band); - if (!device_name) { - printf("[openuf] Ignoring settings for unknown radio '%s'\n", - radio_band); - continue; - } - wlan_apply_radio(r, device_name, - force_wifi4_for_band(vt_arr, radio_band)); - } - } - - /* - * Load the package after the per-radio commits, otherwise this context - * contains a stale copy that can overwrite those changes on commit. - */ - struct uci_context *ctx = uci_alloc_context(); - if (!ctx) { - printf("[openuf] Failed to allocate UCI context\n"); - return -1; - } - struct uci_package *pkg = NULL; - if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { - char *uci_error = NULL; - uci_get_errorstr(ctx, &uci_error, "wireless"); - printf("[openuf] Failed to load UCI wireless configuration: %s\n", - uci_error ? uci_error : "unknown UCI error"); - free(uci_error); - uci_free_context(ctx); - return -1; - } - - /* - * Once UniFi provisioning owns Wi-Fi, disable OpenWrt's generated - * default VAPs. Leaving them enabled keeps broadcasting "OpenWrt" - * alongside the controller-managed SSIDs. - */ - int disabled_defaults = 0; - struct uci_element *default_element; - uci_foreach_element(&pkg->sections, default_element) { - struct uci_section *section = uci_to_section(default_element); - if (!strcmp(section->type, "wifi-iface") && - !strncmp(section->e.name, "default_radio", 13)) { - UCI_SET(ctx, "wireless", section->e.name, "disabled", "1"); - disabled_defaults++; - } - } - if (disabled_defaults) - printf("[openuf] Disabled %d default OpenWrt VAPs\n", - disabled_defaults); - - /* 3. Create VAPs and determine whether any WLAN requests steering. */ - int steering_policy_enabled = 0; - if (vt_arr && json_object_is_type(vt_arr, json_type_array)) { - int nv = json_object_array_length(vt_arr); - for (int i = 0; i < nv; i++) { - struct json_object *vap = json_object_array_get_idx(vt_arr, i); - if (!vap) continue; - - const char *band_steer_keys[] = { - "band_steering", "band_steering_enabled", - "band_steering_mode", "steering_enabled" - }; - const char *handoff_keys[] = { - "bss_transition", "bss_transition_enabled", - "bss_transition_management", "handoff_suggestions", - "handoff_suggestions_enabled", "ieee80211v" - }; - if (json_boolean_any(vap, band_steer_keys, - sizeof(band_steer_keys) / - sizeof(band_steer_keys[0])) || - json_boolean_any(vap, handoff_keys, - sizeof(handoff_keys) / - sizeof(handoff_keys[0]))) - steering_policy_enabled = 1; - - /* A VAP without an explicit band is a model-wide WLAN. */ - const char *radio_band = NULL; - if (json_object_object_get_ex(vap, "radio", &v)) - radio_band = json_object_get_string(v); - if (radio_band && !strcmp(radio_band, "2g")) radio_band = "ng"; - if (radio_band && !strcmp(radio_band, "5g")) radio_band = "na"; - if (radio_band && !strcmp(radio_band, "6GHz")) radio_band = "6g"; - int all_radios = !radio_band || !radio_band[0] || - !strcmp(radio_band, "both") || - !strcmp(radio_band, "all"); - int applied = 0; - for (int j = 0; j < model->radio_map_len; j++) { - if (!all_radios && - strcmp(model->radio_map[j].band, radio_band)) - continue; - int section_idx = i * model->radio_map_len + j; - const char *device = wlan_device_for_band( - model, model->radio_map[j].band); - if (!device || apply_vap(ctx, pkg, vap, device, - model->radio_map[j].band, - mac_str, section_idx) != 0) { - uci_unload(ctx, pkg); - uci_free_context(ctx); - return -1; - } - applied++; - } - if (!applied) { - printf("[openuf] Ignoring VAP with unknown radio '%s'\n", - radio_band ? radio_band : ""); - uci_unload(ctx, pkg); - uci_free_context(ctx); - return -1; - } - } - } - - /* 4. Commit UCI */ - if (uci_commit(ctx, &pkg, false) != UCI_OK) { - char *uci_error = NULL; - uci_get_errorstr(ctx, &uci_error, "wireless"); - printf("[openuf] Failed to commit UCI wireless configuration: %s\n", - uci_error ? uci_error : "unknown UCI error"); - free(uci_error); - uci_unload(ctx, pkg); - uci_free_context(ctx); - return -1; - } - uci_unload(ctx, pkg); - uci_free_context(ctx); - - if (configure_band_steering(steering_policy_enabled) != 0) - printf("[openuf] Failed to configure the band steering policy\n"); - - /* - * Reload netifd for generated VLAN devices, then bring the radios up one - * at a time. Some dual-ath9k devices intermittently fail their first beacon - * setup after ACS. Start and verify each PHY independently, retrying a failed radio so - * provisioning cannot leave one band visible but unusable. - */ - printf("[openuf] Starting controller-managed Wi-Fi sequentially...\n"); - system("ubus call network reload >/dev/null 2>&1"); - for (int i = 0; i < model->radio_map_len; i++) { - char command[256]; - const char *device = wlan_device_for_band( - model, model->radio_map[i].band); - - /* Model radio names are internal constants, but validate defensively. */ - if (!device || - strspn(device, - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") != - strlen(device)) { - printf("[openuf] Refusing invalid radio name\n"); - continue; - } - - int radio_up = 0; - for (int attempt = 1; attempt <= 2 && !radio_up; attempt++) { - printf("[openuf] Starting %s (%s), attempt %d...\n", - device, model->radio_map[i].band, attempt); - snprintf(command, sizeof(command), - "wifi up %s >/dev/null 2>&1", device); - system(command); - - /* ACS normally takes 6-8 seconds on this ath9k hardware. */ - sleep(10); - int phy_index = -1; - if (sscanf(device, "radio%d", &phy_index) != 1) - phy_index = -1; - snprintf(command, sizeof(command), - "iw dev phy%d-ap0 info 2>/dev/null | " - "grep -q '^[[:space:]]*ssid ' && echo true", - phy_index); - FILE *status = popen(command, "r"); - if (status) { - char value[16] = {0}; - if (fgets(value, sizeof(value), status) && - !strncmp(value, "true", 4)) - radio_up = 1; - pclose(status); - } - - if (!radio_up) - printf("[openuf] %s did not reach the up state; retrying\n", - device); - } - - if (!radio_up) - printf("[openuf] %s failed after 2 start attempts\n", device); - } - - /* Enable management features only after hostapd has registered each BSS. - * Unsupported WNM methods fail harmlessly without preventing AP startup. */ - if (steering_policy_enabled) { - for (int i = 0; i < model->radio_map_len; i++) { - const char *device = wlan_device_for_band( - model, model->radio_map[i].band); - int phy_index = -1; - if (!device || sscanf(device, "radio%d", &phy_index) != 1) - continue; - - char command[256]; - snprintf(command, sizeof(command), - "ubus -S call hostapd.phy%d-ap0 bss_mgmt_enable " - "%c{ \"neighbor_report\": true, " - "\"beacon_report\": true, " - "\"bss_transition\": true }%c >/dev/null 2>&1", - phy_index, 39, 39); - if (system(command) != 0) - printf("[openuf] hostapd on phy%d lacks runtime 802.11v " - "support; continuing without BSS Transition\n", - phy_index); - } - } - - /* Restart after hostapd has registered both BSSes on ubus. */ - system("/etc/init.d/usteer restart >/dev/null 2>&1"); - return 0; -} - -static int system_cfg_get(const char *cfg, const char *key, - char *out, size_t out_size) -{ - size_t key_len = strlen(key); - const char *line = cfg; - - while (line && *line) { - const char *end = strchr(line, '\n'); - size_t line_len = end ? (size_t)(end - line) : strlen(line); - if (line_len > key_len && !strncmp(line, key, key_len) && - line[key_len] == '=') { - size_t value_len = line_len - key_len - 1; - while (value_len && line[key_len + value_len] == '\r') - value_len--; - if (value_len >= out_size) value_len = out_size - 1; - memcpy(out, line + key_len + 1, value_len); - out[value_len] = '\0'; - return 1; - } - line = end ? end + 1 : NULL; - } - return 0; -} - -int wlan_apply_system_cfg(const char *system_cfg, - const uf_model_t *model) -{ - if (!system_cfg || !system_cfg[0]) - return -1; - - struct json_object *root = json_object_new_object(); - struct json_object *radios = json_object_new_array(); - struct json_object *vaps = json_object_new_array(); - char key[64], value[256]; - - for (int i = 1; i <= 4; i++) { - snprintf(key, sizeof(key), "radio.%d.ieee_mode", i); - if (!system_cfg_get(system_cfg, key, value, sizeof(value))) - continue; - - struct json_object *radio = json_object_new_object(); - const char *band = strstr(value, "11na") ? "na" : "ng"; - json_object_object_add(radio, "radio", - json_object_new_string(band)); - - const char *ht = strstr(value, "ht80") ? "HT80" : - strstr(value, "ht40") ? "HT40" : "HT20"; - json_object_object_add(radio, "ht", json_object_new_string(ht)); - - snprintf(key, sizeof(key), "radio.%d.channel", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(radio, "channel", - json_object_new_int(!strcmp(value, "auto") ? 0 : atoi(value))); - - snprintf(key, sizeof(key), "radio.%d.txpower", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value)) && - strcmp(value, "auto")) - json_object_object_add(radio, "tx_power", - json_object_new_int(atoi(value))); - json_object_array_add(radios, radio); - } - - for (int i = 1; i <= 32; i++) { - snprintf(key, sizeof(key), "aaa.%d.ssid", i); - if (!system_cfg_get(system_cfg, key, value, sizeof(value))) - continue; - - struct json_object *vap = json_object_new_object(); - json_object_object_add(vap, "essid", - json_object_new_string(value)); - - /* Preserve the WLAN ObjectId used to attach clients in topology. */ - const char *id_suffixes[] = { "id", "_id", "wlanconf_id" }; - for (size_t id_index = 0; - id_index < sizeof(id_suffixes) / sizeof(id_suffixes[0]); - id_index++) { - snprintf(key, sizeof(key), "aaa.%d.%s", i, - id_suffixes[id_index]); - if (system_cfg_get(system_cfg, key, value, sizeof(value)) && - valid_object_id(value)) { - json_object_object_add(vap, "id", json_object_new_string(value)); - json_object_object_add(vap, "wlanconf_id", - json_object_new_string(value)); - break; - } - } - - snprintf(key, sizeof(key), "aaa.%d.status", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value)) && - strcmp(value, "enabled")) { - json_object_put(vap); - continue; - } - - snprintf(key, sizeof(key), "wireless.%d.parent", i); - const char *band = "ng"; - if (system_cfg_get(system_cfg, key, value, sizeof(value)) && - !strcmp(value, "wifi1")) - band = "na"; - json_object_object_add(vap, "radio", json_object_new_string(band)); - - /* UniFi's legacy Force WiFi 4 mode is carried as wireless.N.iot. */ - snprintf(key, sizeof(key), "wireless.%d.iot", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "iot", json_object_new_boolean( - feature_text_enabled(value))); - - char passphrase[256] = {0}; - snprintf(key, sizeof(key), "aaa.%d.wpa.psk", i); - int has_passphrase = system_cfg_get(system_cfg, key, passphrase, - sizeof(passphrase)); - if (!has_passphrase) { - snprintf(key, sizeof(key), "aaa.%d.sae.psk.1.psk", i); - has_passphrase = system_cfg_get(system_cfg, key, passphrase, - sizeof(passphrase)); - } - - char key_mgmt[256] = {0}; - snprintf(key, sizeof(key), "aaa.%d.wpa.key.1.mgmt", i); - int has_key_mgmt = system_cfg_get(system_cfg, key, key_mgmt, - sizeof(key_mgmt)); - - int wpa3_support = 0; - snprintf(key, sizeof(key), "aaa.%d.wpa3.support", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - wpa3_support = feature_text_enabled(value); - - int wpa3_transition = 0; - snprintf(key, sizeof(key), "aaa.%d.wpa3.transition", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - wpa3_transition = feature_text_enabled(value); - - const char *security = "open"; - if (has_key_mgmt && strstr(key_mgmt, "WPA-EAP")) { - security = wpa3_support ? "wpa3enterprise" : "wpa2enterprise"; - } else if (has_key_mgmt && strstr(key_mgmt, "SAE")) { - /* A transition BSS permits both WPA2-PSK and SAE. */ - security = wpa3_transition || strstr(key_mgmt, "WPA-PSK") - ? "wpa3transition" : "wpa3"; - } else if (has_passphrase) { - security = "wpa2psk"; - } - json_object_object_add(vap, "security", - json_object_new_string(security)); - - int enterprise = !strcmp(security, "wpa2enterprise") || - !strcmp(security, "wpa3enterprise"); - if (has_passphrase && !enterprise) { - json_object_object_add(vap, "x_passphrase", - json_object_new_string(passphrase)); - } - - if (enterprise) { - snprintf(key, sizeof(key), "aaa.%d.radius.auth.1.ip", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "auth_server", - json_object_new_string(value)); - - snprintf(key, sizeof(key), "aaa.%d.radius.auth.1.port", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "auth_port", - json_object_new_int(atoi(value))); - - snprintf(key, sizeof(key), "aaa.%d.radius.auth.1.secret", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "auth_secret", - json_object_new_string(value)); - - snprintf(key, sizeof(key), "aaa.%d.radius.acct.1.ip", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "acct_server", - json_object_new_string(value)); - - snprintf(key, sizeof(key), "aaa.%d.radius.acct.1.port", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "acct_port", - json_object_new_int(atoi(value))); - - snprintf(key, sizeof(key), "aaa.%d.radius.acct.1.secret", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "acct_secret", - json_object_new_string(value)); - } - - snprintf(key, sizeof(key), "aaa.%d.hide_ssid", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "hide_ssid", - json_object_new_boolean(!strcmp(value, "true"))); - - snprintf(key, sizeof(key), "aaa.%d.ft.status", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) - json_object_object_add(vap, "fast_roaming_enabled", - json_object_new_boolean(!strcmp(value, "enabled"))); - - const char *band_steer_suffixes[] = { - "band_steering", "band_steering_enabled", "band_steering_mode", - "steering" - }; - for (size_t n = 0; - n < sizeof(band_steer_suffixes) / - sizeof(band_steer_suffixes[0]); n++) { - snprintf(key, sizeof(key), "aaa.%d.%s", i, - band_steer_suffixes[n]); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) { - json_object_object_add(vap, "band_steering", - json_object_new_boolean(feature_text_enabled(value))); - break; - } - } - - const char *handoff_suffixes[] = { - "bss_transition", "bss_transition_enabled", - "handoff_suggestions", "handoff_suggestions_enabled" - }; - for (size_t n = 0; - n < sizeof(handoff_suffixes) / sizeof(handoff_suffixes[0]); n++) { - snprintf(key, sizeof(key), "aaa.%d.%s", i, - handoff_suffixes[n]); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) { - json_object_object_add(vap, "bss_transition", - json_object_new_boolean(feature_text_enabled(value))); - break; - } - } - - snprintf(key, sizeof(key), "aaa.%d.pmf.mode", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) { - const char *pmf = !strcmp(value, "2") ? "required" : - !strcmp(value, "1") ? "optional" : "disabled"; - json_object_object_add(vap, "pmf_mode", - json_object_new_string(pmf)); - } - - snprintf(key, sizeof(key), "aaa.%d.br.devname", i); - if (system_cfg_get(system_cfg, key, value, sizeof(value))) { - const char *dot = strrchr(value, '.'); - if (dot && atoi(dot + 1) > 0) - json_object_object_add(vap, "vlan_id", - json_object_new_int(atoi(dot + 1))); - } - json_object_array_add(vaps, vap); - } - - json_object_object_add(root, "radio_table", radios); - json_object_object_add(root, "vap_table", vaps); - printf("[openuf] Parsed legacy system_cfg: %zu radios, %zu VAPs\n", - json_object_array_length(radios), json_object_array_length(vaps)); - int result = wlan_apply_config(root, model); - json_object_put(root); - return result; -} - -/* ═══════════════════════════════════════════════════════════════════ - wlan_get_vap_table — read active VAPs from UCI - ═══════════════════════════════════════════════════════════════════ - - Iterates over all wifi-iface entries with the "openuf_" prefix in - /etc/config/wireless and builds the vap_table JSON to include in - the inform payload. - - Fields we read from UCI → fields in the JSON: - ssid → essid - device → (used to look up radio and BSSID) - encryption → security (via sec_to_unifi) - hidden → hide_ssid - ieee80211r → fast_roaming_enabled - ieee80211k → band_steering - ieee80211w → pmf_mode ("disabled"/"optional"/"required") - disabled → up (inverse) - - We also try to read the actual BSSID of the wlan interface - from /sys/class/net//address. -*/ -/* Resolve a configured VAP to the live interface reported by nl80211. */ -static int find_runtime_vap(int phy_index, const char *ssid, - char *out, size_t out_size) -{ - FILE *pipe = popen("iw dev 2>/dev/null", "r"); - if (!pipe) return -1; - - int phy = -1; - char candidate[32] = ""; - char line[256]; - while (fgets(line, sizeof(line), pipe)) { - int parsed_phy; - char value[128]; - if (sscanf(line, "phy#%d", &parsed_phy) == 1) { - phy = parsed_phy; - candidate[0] = '\0'; - continue; - } - if (sscanf(line, " Interface %31s", value) == 1) { - snprintf(candidate, sizeof(candidate), "%s", value); - continue; - } - if (phy == phy_index && candidate[0] && - sscanf(line, " ssid %127[^\n]", value) == 1 && - !strcmp(value, ssid)) { - snprintf(out, out_size, "%s", candidate); - pclose(pipe); - return 0; - } - } - pclose(pipe); - return -1; -} - -struct json_object *wlan_get_vap_table(const uf_model_t *model) -{ - struct json_object *arr = json_object_new_array(); - - struct uci_context *ctx = uci_alloc_context(); - if (!ctx) return arr; - - struct uci_package *pkg = NULL; - if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { - uci_free_context(ctx); - return arr; - } - - struct uci_element *e; - uci_foreach_element(&pkg->sections, e) { - struct uci_section *sec = uci_to_section(e); - if (strcmp(sec->type, "wifi-iface") != 0) continue; - /* Only report VAPs managed by openuf */ - if (strncmp(sec->e.name, "openuf_", 7) != 0) continue; - -#define UCI_GET(opt) uci_lookup_option_string(ctx, sec, opt) - - const char *ssid = UCI_GET("ssid"); - const char *device = UCI_GET("device"); - const char *enc = UCI_GET("encryption"); - const char *dis = UCI_GET("disabled"); - const char *r11 = UCI_GET("ieee80211r"); - const char *ft_req = UCI_GET("openuf_ft_requested"); - const char *k11 = UCI_GET("ieee80211k"); - const char *btm = UCI_GET("bss_transition"); - const char *bs_req = UCI_GET("openuf_band_steering"); - const char *ho_req = UCI_GET("openuf_handoff_suggestions"); - const char *w11 = UCI_GET("ieee80211w"); - const char *hidden = UCI_GET("hidden"); - const char *vap_id = UCI_GET("openuf_vap_id"); - const char *vlan = UCI_GET("vlan_id"); - - if (!ssid) ssid = ""; - if (!device) device = "radio0"; - - /* Band of this radio */ - const char *radio_band = wlan_band_for_device(model, device); - if (!radio_band) radio_band = "ng"; - - /* Resolve the actual netifd interface (for example phy1-ap0). */ - char wlan_iface[32]; - int ridx = 0; - sscanf(device, "radio%d", &ridx); - if (find_runtime_vap(ridx, ssid, wlan_iface, sizeof(wlan_iface)) != 0) - snprintf(wlan_iface, sizeof(wlan_iface), "phy%d-ap0", ridx); - - /* Read the actual BSSID from sysfs */ - char bssid[32] = "00:00:00:00:00:00"; - { - char path[128]; - snprintf(path, sizeof(path), "/sys/class/net/%s/address", wlan_iface); - FILE *f = fopen(path, "r"); - if (f) { - fgets(bssid, sizeof(bssid), f); fclose(f); - bssid[strcspn(bssid, "\r\n")] = '\0'; - } - } - - /* PMF: ieee80211w → "disabled"/"optional"/"required" */ - const char *pmf = "disabled"; - if (w11) { - if (!strcmp(w11,"1")) pmf = "optional"; - if (!strcmp(w11,"2")) pmf = "required"; - } - - bool ft_on = (r11 && !strcmp(r11,"1")) || - (ft_req && !strcmp(ft_req,"1")); - bool bs_on = bs_req ? !strcmp(bs_req, "1") : - (k11 && !strcmp(k11, "1")); - bool handoff_on = ho_req ? !strcmp(ho_req, "1") : - (btm && !strcmp(btm, "1")); - bool hid = (hidden && !strcmp(hidden,"1")); - bool up = !(dis && !strcmp(dis,"1")); - - struct json_object *o = json_object_new_object(); - json_object_object_add(o, "essid", json_object_new_string(ssid)); - json_object_object_add(o, "bssid", json_object_new_string(bssid)); - json_object_object_add(o, "name", json_object_new_string(wlan_iface)); - json_object_object_add(o, "ifname", json_object_new_string(wlan_iface)); - json_object_object_add(o, "radio", json_object_new_string(radio_band)); - json_object_object_add(o, "security", json_object_new_string(sec_to_unifi(enc))); - json_object_object_add(o, "up", json_object_new_boolean(up)); - json_object_object_add(o, "hide_ssid", json_object_new_boolean(hid)); - json_object_object_add(o, "fast_roaming_enabled",json_object_new_boolean(ft_on)); - json_object_object_add(o, "band_steering", json_object_new_boolean(bs_on)); - json_object_object_add(o, "bss_transition", json_object_new_boolean(handoff_on)); - json_object_object_add(o, "handoff_suggestions", json_object_new_boolean(handoff_on)); - json_object_object_add(o, "pmf_mode", json_object_new_string(pmf)); - json_object_object_add(o, "num_sta", json_object_new_int(0)); - if (vlan && atoi(vlan) > 0) - json_object_object_add(o, "vlan_id", json_object_new_int(atoi(vlan))); - if (valid_object_id(vap_id)) - json_object_object_add(o, "id", json_object_new_string(vap_id)); - if (valid_object_id(vap_id)) - json_object_object_add(o, "wlanconf_id", - json_object_new_string(vap_id)); - json_object_array_add(arr, o); -#undef UCI_GET - } - - uci_unload(ctx, pkg); - uci_free_context(ctx); - return arr; -} diff --git a/src/wlan/common.c b/src/wlan/common.c new file mode 100644 index 0000000..0bf0beb --- /dev/null +++ b/src/wlan/common.c @@ -0,0 +1,229 @@ +/* + * Shared translations and validators used by Wi-Fi provisioning and + * telemetry. This unit does not commit controller configuration itself. + */ + + +#include +#include +#include +#include +#include +#include + +#include "wlan.h" +#include "ufmodel.h" +#include "crypto.h" +#include "wlan_internal.h" + + +const char *wlan_security_to_uci(const char *uf) +{ + if (!uf || !strcmp(uf,"open")) return "none"; + if (!strcmp(uf,"wpapsk")) return "psk"; + if (!strcmp(uf,"wpa2psk")) return "psk2"; + if (!strcmp(uf,"wpapskwpa2psk")) return "psk-mixed"; + if (!strcmp(uf,"wpa3")) return "sae"; + if (!strcmp(uf,"wpa3transition")) return "sae-mixed"; + if (!strcmp(uf,"wpa2enterprise")) return "wpa2"; + if (!strcmp(uf,"wpa3enterprise")) return "wpa3"; + return "psk2"; /* default */ +} + +/* OpenWrt UCI encryption names to UniFi security names. */ +const char *wlan_security_to_unifi(const char *uci) +{ + if (!uci || !strcmp(uci,"none")) return "open"; + if (!strcmp(uci,"psk")) return "wpapsk"; + if (!strcmp(uci,"psk2")) return "wpa2psk"; + if (!strcmp(uci,"psk-mixed")) return "wpapskwpa2psk"; + if (!strcmp(uci,"sae")) return "wpa3"; + if (!strcmp(uci,"sae-mixed")) return "wpa3transition"; + if (!strcmp(uci,"wpa2")) return "wpa2enterprise"; + if (!strcmp(uci,"wpa3")) return "wpa3enterprise"; + return "wpa2psk"; +} + +/* Return true only for the 24-character hexadecimal IDs used by UniFi. */ +int wlan_valid_object_id(const char *id) +{ + if (!id || strlen(id) != 24) + return 0; + for (size_t i = 0; i < 24; i++) + if (!((id[i] >= '0' && id[i] <= '9') || + (id[i] >= 'a' && id[i] <= 'f') || + (id[i] >= 'A' && id[i] <= 'F'))) + return 0; + return 1; +} +/* + * UniFi uses a 24-character hexadecimal MongoDB ObjectId to associate VAP + * telemetry and stations with a WLAN. Older legacy system_cfg payloads do + * not include that ID, so allocate a local one and keep it stable in UCI. + * All radio instances of the same WLAN must report the same ID. + */ +int wlan_ensure_vap_ids(struct json_object *vaps) +{ + if (!vaps || !json_object_is_type(vaps, json_type_array)) + return 0; + + struct uci_context *ctx = uci_alloc_context(); + struct uci_package *pkg = NULL; + if (ctx) + uci_load(ctx, "wireless", &pkg); + + int count = json_object_array_length(vaps); + for (int i = 0; i < count; i++) { + struct json_object *vap = json_object_array_get_idx(vaps, i); + struct json_object *value; + if (!vap) + continue; + + const char *id = NULL; + const char *id_keys[] = { "_id", "id", "wlanconf_id" }; + for (size_t n = 0; n < sizeof(id_keys) / sizeof(id_keys[0]); n++) { + if (json_object_object_get_ex(vap, id_keys[n], &value)) { + const char *candidate = json_object_get_string(value); + if (wlan_valid_object_id(candidate)) { + id = candidate; + break; + } + } + } + if (id) + continue; + + const char *ssid = ""; + if (json_object_object_get_ex(vap, "essid", &value)) + ssid = json_object_get_string(value); + + /* Reuse an ID already assigned to this WLAN in the same payload. */ + for (int j = 0; j < i && !id; j++) { + struct json_object *previous = json_object_array_get_idx(vaps, j); + struct json_object *previous_ssid; + struct json_object *previous_id; + if (previous && + json_object_object_get_ex(previous, "essid", &previous_ssid) && + !strcmp(json_object_get_string(previous_ssid), ssid) && + json_object_object_get_ex(previous, "id", &previous_id) && + wlan_valid_object_id(json_object_get_string(previous_id))) + id = json_object_get_string(previous_id); + } + + /* Reuse the ID committed by an earlier provisioning cycle. */ + if (!id && pkg) { + struct uci_element *element; + uci_foreach_element(&pkg->sections, element) { + struct uci_section *section = uci_to_section(element); + const char *stored_ssid; + const char *stored_id; + if (strcmp(section->type, "wifi-iface") || + strncmp(section->e.name, "openuf_", 7)) + continue; + stored_ssid = uci_lookup_option_string(ctx, section, "ssid"); + stored_id = uci_lookup_option_string(ctx, section, + "openuf_vap_id"); + if (stored_ssid && !strcmp(stored_ssid, ssid) && + wlan_valid_object_id(stored_id)) { + id = stored_id; + break; + } + } + } + + char generated[25]; + if (!id) { + if (crypto_random_hex((unsigned char *)generated, 12) != 0) { + printf("[openuf] Failed to generate a VAP ID for '%s'\n", ssid); + if (pkg) uci_unload(ctx, pkg); + if (ctx) uci_free_context(ctx); + return -1; + } + id = generated; + printf("[openuf] Generated persistent VAP ID %s for '%s'\n", + id, ssid); + } + + json_object_object_add(vap, "id", json_object_new_string(id)); + json_object_object_add(vap, "wlanconf_id", json_object_new_string(id)); + } + + if (pkg) uci_unload(ctx, pkg); + if (ctx) uci_free_context(ctx); + return 0; +} + +/* Build one stable 802.11r mobility domain shared by every AP for an SSID. */ +void wlan_mobility_domain_for_ssid(const char *ssid, char out[5]) +{ + unsigned int hash = 2166136261u; + const unsigned char *p = (const unsigned char *)(ssid ? ssid : ""); + while (*p) { + hash ^= *p++; + hash *= 16777619u; + } + snprintf(out, 5, "%04x", (hash ^ (hash >> 16)) & 0xffffu); +} + +/* Return true when an OpenWrt radio is backed by the ath9k kernel driver. */ +int wlan_radio_uses_ath9k(const char *device_name) +{ + int phy_index; + if (!device_name || sscanf(device_name, "radio%d", &phy_index) != 1) + return 0; + + char path[128]; + char target[256]; + snprintf(path, sizeof(path), + "/sys/class/ieee80211/phy%d/device/driver", phy_index); + ssize_t length = readlink(path, target, sizeof(target) - 1); + if (length < 0) + return 0; + target[length] = '\0'; + return strstr(target, "ath9k") != NULL; +} + +/* Read a UniFi boolean while accepting names used by controller versions. */ +int wlan_json_boolean_any(struct json_object *object, + const char *const *keys, size_t key_count) +{ + struct json_object *value; + for (size_t i = 0; i < key_count; i++) { + if (!json_object_object_get_ex(object, keys[i], &value)) + continue; + if (json_object_is_type(value, json_type_string)) { + const char *text = json_object_get_string(value); + if (!text || !text[0] || !strcasecmp(text, "disabled") || + !strcasecmp(text, "false") || !strcasecmp(text, "off") || + !strcasecmp(text, "none") || !strcmp(text, "0")) + return 0; + /* Also accepts controller modes such as "prefer_5g". */ + return 1; + } + return json_object_get_boolean(value) ? 1 : 0; + } + return 0; +} + +/* Interpret Boolean text and UniFi feature modes such as "prefer_5g". */ +int wlan_feature_enabled(const char *text) +{ + return text && text[0] && strcasecmp(text, "disabled") && + strcasecmp(text, "false") && strcasecmp(text, "off") && + strcasecmp(text, "none") && strcmp(text, "0"); +} + +/* Safe UCI identifier fragment (maximum 15 characters). */ +void wlan_safe_section_name(const char *ssid, char *out, size_t sz) +{ + size_t j = 0; + for (size_t i = 0; ssid[i] && j < sz-1 && j < 15; i++) { + char c = ssid[i]; + if ((c>='a'&&c<='z')||(c>='A'&&c<='Z')|| + (c>='0'&&c<='9')||c=='_') + out[j++] = c; + else + out[j++] = '_'; + } + out[j] = '\0'; +} diff --git a/src/wlan/legacy.c b/src/wlan/legacy.c new file mode 100644 index 0000000..87a20a6 --- /dev/null +++ b/src/wlan/legacy.c @@ -0,0 +1,271 @@ +/* + * Parse the legacy newline-separated system_cfg representation and translate + * it into the same JSON configuration consumed by modern provisioning. + */ + + +#include +#include +#include +#include +#include +#include + +#include "wlan.h" +#include "ufmodel.h" +#include "crypto.h" +#include "wlan_internal.h" + + +static int system_cfg_get(const char *cfg, const char *key, + char *out, size_t out_size) +{ + size_t key_len = strlen(key); + const char *line = cfg; + + while (line && *line) { + const char *end = strchr(line, '\n'); + size_t line_len = end ? (size_t)(end - line) : strlen(line); + if (line_len > key_len && !strncmp(line, key, key_len) && + line[key_len] == '=') { + size_t value_len = line_len - key_len - 1; + while (value_len && line[key_len + value_len] == '\r') + value_len--; + if (value_len >= out_size) value_len = out_size - 1; + memcpy(out, line + key_len + 1, value_len); + out[value_len] = '\0'; + return 1; + } + line = end ? end + 1 : NULL; + } + return 0; +} + +int wlan_apply_system_cfg(const char *system_cfg, + const uf_model_t *model) +{ + if (!system_cfg || !system_cfg[0]) + return -1; + + struct json_object *root = json_object_new_object(); + struct json_object *radios = json_object_new_array(); + struct json_object *vaps = json_object_new_array(); + char key[64], value[256]; + + for (int i = 1; i <= 4; i++) { + snprintf(key, sizeof(key), "radio.%d.ieee_mode", i); + if (!system_cfg_get(system_cfg, key, value, sizeof(value))) + continue; + + struct json_object *radio = json_object_new_object(); + const char *band = strstr(value, "11na") ? "na" : "ng"; + json_object_object_add(radio, "radio", + json_object_new_string(band)); + + const char *ht = strstr(value, "ht80") ? "HT80" : + strstr(value, "ht40") ? "HT40" : "HT20"; + json_object_object_add(radio, "ht", json_object_new_string(ht)); + + snprintf(key, sizeof(key), "radio.%d.channel", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(radio, "channel", + json_object_new_int(!strcmp(value, "auto") ? 0 : atoi(value))); + + snprintf(key, sizeof(key), "radio.%d.txpower", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value)) && + strcmp(value, "auto")) + json_object_object_add(radio, "tx_power", + json_object_new_int(atoi(value))); + json_object_array_add(radios, radio); + } + + for (int i = 1; i <= 32; i++) { + snprintf(key, sizeof(key), "aaa.%d.ssid", i); + if (!system_cfg_get(system_cfg, key, value, sizeof(value))) + continue; + + struct json_object *vap = json_object_new_object(); + json_object_object_add(vap, "essid", + json_object_new_string(value)); + + /* Preserve the WLAN ObjectId used to attach clients in topology. */ + const char *id_suffixes[] = { "id", "_id", "wlanconf_id" }; + for (size_t id_index = 0; + id_index < sizeof(id_suffixes) / sizeof(id_suffixes[0]); + id_index++) { + snprintf(key, sizeof(key), "aaa.%d.%s", i, + id_suffixes[id_index]); + if (system_cfg_get(system_cfg, key, value, sizeof(value)) && + wlan_valid_object_id(value)) { + json_object_object_add(vap, "id", json_object_new_string(value)); + json_object_object_add(vap, "wlanconf_id", + json_object_new_string(value)); + break; + } + } + + snprintf(key, sizeof(key), "aaa.%d.status", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value)) && + strcmp(value, "enabled")) { + json_object_put(vap); + continue; + } + + snprintf(key, sizeof(key), "wireless.%d.parent", i); + const char *band = "ng"; + if (system_cfg_get(system_cfg, key, value, sizeof(value)) && + !strcmp(value, "wifi1")) + band = "na"; + json_object_object_add(vap, "radio", json_object_new_string(band)); + + /* UniFi's legacy Force WiFi 4 mode is carried as wireless.N.iot. */ + snprintf(key, sizeof(key), "wireless.%d.iot", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "iot", json_object_new_boolean( + wlan_feature_enabled(value))); + + char passphrase[256] = {0}; + snprintf(key, sizeof(key), "aaa.%d.wpa.psk", i); + int has_passphrase = system_cfg_get(system_cfg, key, passphrase, + sizeof(passphrase)); + if (!has_passphrase) { + snprintf(key, sizeof(key), "aaa.%d.sae.psk.1.psk", i); + has_passphrase = system_cfg_get(system_cfg, key, passphrase, + sizeof(passphrase)); + } + + char key_mgmt[256] = {0}; + snprintf(key, sizeof(key), "aaa.%d.wpa.key.1.mgmt", i); + int has_key_mgmt = system_cfg_get(system_cfg, key, key_mgmt, + sizeof(key_mgmt)); + + int wpa3_support = 0; + snprintf(key, sizeof(key), "aaa.%d.wpa3.support", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + wpa3_support = wlan_feature_enabled(value); + + int wpa3_transition = 0; + snprintf(key, sizeof(key), "aaa.%d.wpa3.transition", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + wpa3_transition = wlan_feature_enabled(value); + + const char *security = "open"; + if (has_key_mgmt && strstr(key_mgmt, "WPA-EAP")) { + security = wpa3_support ? "wpa3enterprise" : "wpa2enterprise"; + } else if (has_key_mgmt && strstr(key_mgmt, "SAE")) { + /* A transition BSS permits both WPA2-PSK and SAE. */ + security = wpa3_transition || strstr(key_mgmt, "WPA-PSK") + ? "wpa3transition" : "wpa3"; + } else if (has_passphrase) { + security = "wpa2psk"; + } + json_object_object_add(vap, "security", + json_object_new_string(security)); + + int enterprise = !strcmp(security, "wpa2enterprise") || + !strcmp(security, "wpa3enterprise"); + if (has_passphrase && !enterprise) { + json_object_object_add(vap, "x_passphrase", + json_object_new_string(passphrase)); + } + + if (enterprise) { + snprintf(key, sizeof(key), "aaa.%d.radius.auth.1.ip", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "auth_server", + json_object_new_string(value)); + + snprintf(key, sizeof(key), "aaa.%d.radius.auth.1.port", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "auth_port", + json_object_new_int(atoi(value))); + + snprintf(key, sizeof(key), "aaa.%d.radius.auth.1.secret", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "auth_secret", + json_object_new_string(value)); + + snprintf(key, sizeof(key), "aaa.%d.radius.acct.1.ip", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "acct_server", + json_object_new_string(value)); + + snprintf(key, sizeof(key), "aaa.%d.radius.acct.1.port", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "acct_port", + json_object_new_int(atoi(value))); + + snprintf(key, sizeof(key), "aaa.%d.radius.acct.1.secret", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "acct_secret", + json_object_new_string(value)); + } + + snprintf(key, sizeof(key), "aaa.%d.hide_ssid", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "hide_ssid", + json_object_new_boolean(!strcmp(value, "true"))); + + snprintf(key, sizeof(key), "aaa.%d.ft.status", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) + json_object_object_add(vap, "fast_roaming_enabled", + json_object_new_boolean(!strcmp(value, "enabled"))); + + const char *band_steer_suffixes[] = { + "band_steering", "band_steering_enabled", "band_steering_mode", + "steering" + }; + for (size_t n = 0; + n < sizeof(band_steer_suffixes) / + sizeof(band_steer_suffixes[0]); n++) { + snprintf(key, sizeof(key), "aaa.%d.%s", i, + band_steer_suffixes[n]); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) { + json_object_object_add(vap, "band_steering", + json_object_new_boolean(wlan_feature_enabled(value))); + break; + } + } + + const char *handoff_suffixes[] = { + "bss_transition", "bss_transition_enabled", + "handoff_suggestions", "handoff_suggestions_enabled" + }; + for (size_t n = 0; + n < sizeof(handoff_suffixes) / sizeof(handoff_suffixes[0]); n++) { + snprintf(key, sizeof(key), "aaa.%d.%s", i, + handoff_suffixes[n]); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) { + json_object_object_add(vap, "bss_transition", + json_object_new_boolean(wlan_feature_enabled(value))); + break; + } + } + + snprintf(key, sizeof(key), "aaa.%d.pmf.mode", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) { + const char *pmf = !strcmp(value, "2") ? "required" : + !strcmp(value, "1") ? "optional" : "disabled"; + json_object_object_add(vap, "pmf_mode", + json_object_new_string(pmf)); + } + + snprintf(key, sizeof(key), "aaa.%d.br.devname", i); + if (system_cfg_get(system_cfg, key, value, sizeof(value))) { + const char *dot = strrchr(value, '.'); + if (dot && atoi(dot + 1) > 0) + json_object_object_add(vap, "vlan_id", + json_object_new_int(atoi(dot + 1))); + } + json_object_array_add(vaps, vap); + } + + json_object_object_add(root, "radio_table", radios); + json_object_object_add(root, "vap_table", vaps); + printf("[openuf] Parsed legacy system_cfg: %zu radios, %zu VAPs\n", + json_object_array_length(radios), json_object_array_length(vaps)); + int result = wlan_apply_config(root, model); + json_object_put(root); + return result; +} + diff --git a/src/wlan/provision.c b/src/wlan/provision.c new file mode 100644 index 0000000..4a0d7c0 --- /dev/null +++ b/src/wlan/provision.c @@ -0,0 +1,586 @@ +/* + * Apply controller radio and VAP configuration, preserving the openuf_ + * ownership boundary and committing the resulting UCI configuration. + */ + + +#include +#include +#include +#include +#include +#include + +#include "wlan.h" +#include "ufmodel.h" +#include "crypto.h" +#include "wlan_internal.h" + + +static int apply_vap(struct uci_context *ctx, + struct uci_package *pkg, + struct json_object *vap_json, + const char *device_name, + const char *radio_band, + const char *mac_str, + int vap_idx) +{ + struct json_object *v; + (void)mac_str; + const char *essid = ""; + const char *security = "wpa2psk"; + const char *pass = ""; + const char *auth_server = NULL; + const char *auth_secret = NULL; + const char *acct_server = NULL; + const char *acct_secret = NULL; + int auth_port = 1812; + int acct_port = 1813; + + if (json_object_object_get_ex(vap_json, "essid", &v)) essid = json_object_get_string(v); + if (json_object_object_get_ex(vap_json, "security", &v)) security = json_object_get_string(v); + if (json_object_object_get_ex(vap_json, "x_passphrase",&v)) pass = json_object_get_string(v); + + int enterprise = !strcmp(security, "wpa2enterprise") || + !strcmp(security, "wpa3enterprise"); + if (json_object_object_get_ex(vap_json, "auth_server", &v)) + auth_server = json_object_get_string(v); + if (json_object_object_get_ex(vap_json, "auth_secret", &v)) + auth_secret = json_object_get_string(v); + if (json_object_object_get_ex(vap_json, "auth_port", &v)) + auth_port = json_object_get_int(v); + if (json_object_object_get_ex(vap_json, "acct_server", &v)) + acct_server = json_object_get_string(v); + if (json_object_object_get_ex(vap_json, "acct_secret", &v)) + acct_secret = json_object_get_string(v); + if (json_object_object_get_ex(vap_json, "acct_port", &v)) + acct_port = json_object_get_int(v); + + /* Resolve the final network before creating the VAP; never fail open. */ + int vid = 0; + char target_network[32] = "lan"; + if (json_object_object_get_ex(vap_json, "vlan_id", &v)) + vid = json_object_get_int(v); + if (vid > 0) { + if (wlan_ensure_vlan_network(vid) != 0) { + printf("[openuf] Failed to configure VLAN network %d\n", vid); + return -1; + } + snprintf(target_network, sizeof(target_network), "vlan%d", vid); + } + + /* Section name: openuf__ */ + char safe[16] = {0}; + wlan_safe_section_name(essid, safe, sizeof(safe)); + char sec_name[48]; + snprintf(sec_name, sizeof(sec_name), "openuf_%d_%s", vap_idx, safe); + + if (wlan_uci_ensure_section(ctx, pkg, sec_name, "wifi-iface") != 0) { + printf("[openuf] Failed to create VAP section '%s'\n", sec_name); + return -1; + } + + WLAN_UCI_SET(ctx, "wireless", sec_name, "device", device_name); + WLAN_UCI_SET(ctx, "wireless", sec_name, "mode", "ap"); + WLAN_UCI_SET(ctx, "wireless", sec_name, "ssid", essid); + WLAN_UCI_SET(ctx, "wireless", sec_name, "network", target_network); + WLAN_UCI_SET(ctx, "wireless", sec_name, "encryption", wlan_security_to_uci(security)); + + /* + * Preserve the controller's WLAN configuration ID. Inform telemetry must + * refer to this ObjectId; a label such as "user" is not a valid VAP ID. + * Controller versions use different keys, so accept the known variants. + */ + const char *vap_id = NULL; + const char *id_keys[] = { "_id", "id", "wlanconf_id" }; + for (size_t i = 0; i < sizeof(id_keys) / sizeof(id_keys[0]); i++) { + if (json_object_object_get_ex(vap_json, id_keys[i], &v)) { + const char *candidate = json_object_get_string(v); + if (wlan_valid_object_id(candidate)) { + vap_id = candidate; + break; + } + } + } + if (vap_id) + WLAN_UCI_SET(ctx, "wireless", sec_name, "openuf_vap_id", vap_id); + + /* Password */ + if (!enterprise && pass && pass[0] && strcmp(security,"open") != 0) + WLAN_UCI_SET(ctx, "wireless", sec_name, "key", pass); + + if (enterprise) { + if (!auth_server || !auth_server[0] || + !auth_secret || !auth_secret[0]) { + printf("[openuf] Refusing Enterprise VAP '%s': missing RADIUS " + "authentication server or secret\n", essid); + return -1; + } + + char path[256]; + char port[16]; + snprintf(path, sizeof(path), "wireless.%s.auth_server", sec_name); + snprintf(port, sizeof(port), "%d", auth_port > 0 ? auth_port : 1812); + if (wlan_uci_add_list(ctx, path, auth_server) != 0 || + wlan_uci_set_required(ctx, pkg, sec_name, "auth_port", port) != 0 || + wlan_uci_set_required(ctx, pkg, sec_name, "auth_secret", + auth_secret) != 0) { + printf("[openuf] Failed to store RADIUS authentication for " + "VAP '%s'\n", essid); + return -1; + } + + if (acct_server && acct_server[0]) { + if (!acct_secret || !acct_secret[0]) { + printf("[openuf] Refusing Enterprise VAP '%s': accounting " + "server has no secret\n", essid); + return -1; + } + snprintf(path, sizeof(path), "wireless.%s.acct_server", sec_name); + snprintf(port, sizeof(port), "%d", + acct_port > 0 ? acct_port : 1813); + if (wlan_uci_add_list(ctx, path, acct_server) != 0 || + wlan_uci_set_required(ctx, pkg, sec_name, "acct_port", port) != 0 || + wlan_uci_set_required(ctx, pkg, sec_name, "acct_secret", + acct_secret) != 0) { + printf("[openuf] Failed to store RADIUS accounting for " + "VAP '%s'\n", essid); + return -1; + } + } + } + + /* hidden SSID */ + int hidden = 0; + if (json_object_object_get_ex(vap_json, "hide_ssid", &v)) + hidden = json_object_get_boolean(v) ? 1 : 0; + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "hidden", hidden); + + /* Client isolation (guest network) */ + int isolate = 0; + if (json_object_object_get_ex(vap_json, "guest_policy", &v)) + isolate = json_object_get_boolean(v) ? 1 : 0; + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "isolate", isolate); + + /* U-APSD (power saving for mobile clients) */ + int uapsd = 1; + if (json_object_object_get_ex(vap_json, "uapsd", &v)) + uapsd = json_object_get_boolean(v) ? 1 : 0; + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "uapsd", uapsd); + + /* ── PMF (Protected Management Frames / 802.11w) ────────────── + * "disabled" → 0, "optional" → 1, "required" → 2 + * WPA3-only requires PMF. WPA2/WPA3 transition mode must leave PMF + * optional so WPA2-only clients can still associate. */ + int pmf = 0; + if (json_object_object_get_ex(vap_json, "pmf_mode", &v)) { + const char *pm = json_object_get_string(v); + if (!strcmp(pm, "optional")) pmf = 1; + if (!strcmp(pm, "required")) pmf = 2; + } + if (!strcmp(security,"wpa3") || !strcmp(security,"wpa3enterprise")) + pmf = 2; + else if (!strcmp(security,"wpa3transition") && pmf < 1) + pmf = 1; + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211w", pmf); + + /* ── Fast Roaming (802.11r FT) ──────────────────────────────── + * Allows clients to move between APs without re-authentication + * complete. The FT handshake only takes ~50ms vs ~200-300ms for a normal one. */ + const char *ft_keys[] = { + "fast_roaming_enabled", "fast_roaming", "ft_enabled", "ieee80211r" + }; + int ft = wlan_json_boolean_any(vap_json, ft_keys, + sizeof(ft_keys) / sizeof(ft_keys[0])); + + /* + * This legacy 2.4 GHz ath9k PHY rejects every FT beacon tested, including + * WPA2 with PMF disabled. Preserve the controller request for telemetry, + * but disable 802.11r on this one unsupported PHY so the BSS can start. + */ + if (ft && radio_band && !strcmp(radio_band, "ng") && + wlan_radio_uses_ath9k(device_name)) { + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "openuf_ft_requested", 1); + ft = 0; + printf("[openuf] Disabled FT on unsupported 2.4 GHz ath9k radio %s\n", + device_name); + } + + if (ft) { + char mdomain[5]; + wlan_mobility_domain_for_ssid(essid, mdomain); + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211r", 1); + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "ft_over_ds", 0); + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "ft_psk_generate_local", 1); + /* Local key generation avoids external R0KH/R1KH dependencies. */ + WLAN_UCI_SET(ctx, "wireless", sec_name, "mobility_domain", mdomain); + } else { + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211r", 0); + } + + /* Enable the hostapd capabilities used by steering and 802.11v hints. */ + const char *band_steer_keys[] = { + "band_steering", "band_steering_enabled", "band_steering_mode", + "steering_enabled" + }; + const char *handoff_keys[] = { + "bss_transition", "bss_transition_enabled", + "bss_transition_management", "handoff_suggestions", + "handoff_suggestions_enabled", "ieee80211v" + }; + int band_steer = wlan_json_boolean_any( + vap_json, band_steer_keys, + sizeof(band_steer_keys) / sizeof(band_steer_keys[0])); + int handoff = wlan_json_boolean_any( + vap_json, handoff_keys, + sizeof(handoff_keys) / sizeof(handoff_keys[0])); + int rrm = band_steer || handoff; + int bss_transition_requested = band_steer || handoff; + + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "openuf_band_steering", + band_steer); + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "openuf_handoff_suggestions", + handoff); + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "ieee80211k", rrm); + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "rrm_neighbor_report", rrm); + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "rrm_beacon_report", rrm); + /* Enable 802.11v at runtime after hostapd starts. Putting this option + * in UCI makes builds without CONFIG_WNM_AP reject the entire BSS. */ + if (bss_transition_requested) + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, + "openuf_bss_transition_requested", 1); + + /* Record the controller VLAN for telemetry and diagnostics. */ + if (vid > 0) + WLAN_UCI_SET_INT(ctx, "wireless", sec_name, "vlan_id", vid); + + /* Reassert and validate every option required to start a secure AP. */ + if (wlan_uci_set_required(ctx, pkg, sec_name, "device", device_name) != 0) { + printf("[openuf] Failed to bind VAP '%s' to %s\n", + essid, device_name); + return -1; + } + if (wlan_uci_set_required(ctx, pkg, sec_name, "mode", "ap") != 0 || + wlan_uci_set_required(ctx, pkg, sec_name, "ssid", essid) != 0 || + wlan_uci_set_required(ctx, pkg, sec_name, "encryption", + wlan_security_to_uci(security)) != 0) { + printf("[openuf] Refusing incomplete VAP '%s': core AP options " + "could not be stored\n", essid); + return -1; + } + if (!enterprise && pass && pass[0] && strcmp(security, "open") != 0 && + wlan_uci_set_required(ctx, pkg, sec_name, "key", pass) != 0) { + printf("[openuf] Refusing unsecured VAP '%s': key could not be stored\n", + essid); + return -1; + } + if (wlan_uci_set_required(ctx, pkg, sec_name, "network", target_network) != 0) { + printf("[openuf] Refusing unsafe VAP '%s': cannot bind to %s\n", + essid, target_network); + return -1; + } + + printf("[openuf] VAP '%s' -> %s device=%s network=%s enc=%s " + "ft=%d bs=%d handoff=%d pmf=%d\n", + essid, sec_name, device_name, target_network, wlan_security_to_uci(security), + ft, band_steer, handoff, pmf); + return 0; +} + +/* True when a UniFi WLAN on this band explicitly requests WiFi 4 mode. */ +static int force_wifi4_for_band(struct json_object *vaps, const char *band) +{ + if (!vaps || !json_object_is_type(vaps, json_type_array)) + return 0; + + const char *force_keys[] = { + "iot", "force_wifi4", "force_wifi4_mode" + }; + int count = json_object_array_length(vaps); + for (int i = 0; i < count; i++) { + struct json_object *vap = json_object_array_get_idx(vaps, i); + struct json_object *value; + if (!vap || !wlan_json_boolean_any(vap, force_keys, + sizeof(force_keys) / + sizeof(force_keys[0]))) + continue; + + const char *vap_band = NULL; + if (json_object_object_get_ex(vap, "radio", &value)) + vap_band = json_object_get_string(value); + if (!vap_band || !vap_band[0] || !strcmp(vap_band, "both") || + !strcmp(vap_band, "all") || !strcmp(vap_band, band) || + (!strcmp(vap_band, "2g") && !strcmp(band, "ng")) || + (!strcmp(vap_band, "5g") && !strcmp(band, "na")) || + (!strcmp(vap_band, "6GHz") && !strcmp(band, "6g"))) + return 1; + } + return 0; +} + +/* ═══════════════════════════════════════════════════════════════════ + wlan_apply_config — apply the controller's full configuration + ═══════════════════════════════════════════════════════════════════ + + Called from inform.c → handle_response() when _type=="setstate". + config_json is the controller's complete JSON. + + Process: + 1. Remove old VAPs (openuf_ prefix) + 2. Apply radio_table (channel, power, htmode) per radio + 3. Create one VAP for each entry in vap_table + 4. Commit UCI + 5. Run "wifi reload" to apply without rebooting the AP +*/ +int wlan_apply_config(struct json_object *config_json, + const uf_model_t *model) +{ + struct json_object *rt_arr = NULL, *vt_arr = NULL, *v; + json_object_object_get_ex(config_json, "radio_table", &rt_arr); + json_object_object_get_ex(config_json, "vap_table", &vt_arr); + /* Fill IDs omitted by legacy provisioning before old UCI VAPs are removed. */ + if (wlan_ensure_vap_ids(vt_arr) != 0) + return -1; + + /* Get the AP's MAC for mobility_domain */ + char mac_str[32] = "00:00:00:00:00:00"; + { + char path[128]; + snprintf(path, sizeof(path), "/sys/class/net/eth0/address"); + FILE *f = fopen(path, "r"); + if (f) { fgets(mac_str, sizeof(mac_str), f); fclose(f); } + mac_str[strcspn(mac_str, "\r\n")] = '\0'; + } + + /* Stop hostapd so a deleted BSS cannot survive a netifd reload race. */ + printf("[openuf] Stopping Wi-Fi before controller provisioning...\n"); + system("wifi down >/dev/null 2>&1"); + + /* Replace prior openuf_ VAPs while preserving unrelated UCI sections. */ + wlan_clear(); + + /* 2. Apply radio_table */ + if (rt_arr && json_object_is_type(rt_arr, json_type_array)) { + int nr = json_object_array_length(rt_arr); + for (int i = 0; i < nr; i++) { + struct json_object *r = json_object_array_get_idx(rt_arr, i); + if (!r) continue; + /* Find the UCI device corresponding to this band */ + const char *radio_band = ""; + if (json_object_object_get_ex(r, "radio", &v)) + radio_band = json_object_get_string(v); + const char *device_name = wlan_device_for_band(model, radio_band); + if (!device_name) { + printf("[openuf] Ignoring settings for unknown radio '%s'\n", + radio_band); + continue; + } + wlan_apply_radio(r, device_name, + force_wifi4_for_band(vt_arr, radio_band)); + } + } + + /* + * Load the package after the per-radio commits, otherwise this context + * contains a stale copy that can overwrite those changes on commit. + */ + struct uci_context *ctx = uci_alloc_context(); + if (!ctx) { + printf("[openuf] Failed to allocate UCI context\n"); + return -1; + } + struct uci_package *pkg = NULL; + if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { + char *uci_error = NULL; + uci_get_errorstr(ctx, &uci_error, "wireless"); + printf("[openuf] Failed to load UCI wireless configuration: %s\n", + uci_error ? uci_error : "unknown UCI error"); + free(uci_error); + uci_free_context(ctx); + return -1; + } + + /* + * Once UniFi provisioning owns Wi-Fi, disable OpenWrt's generated + * default VAPs. Leaving them enabled keeps broadcasting "OpenWrt" + * alongside the controller-managed SSIDs. + */ + int disabled_defaults = 0; + struct uci_element *default_element; + uci_foreach_element(&pkg->sections, default_element) { + struct uci_section *section = uci_to_section(default_element); + if (!strcmp(section->type, "wifi-iface") && + !strncmp(section->e.name, "default_radio", 13)) { + WLAN_UCI_SET(ctx, "wireless", section->e.name, "disabled", "1"); + disabled_defaults++; + } + } + if (disabled_defaults) + printf("[openuf] Disabled %d default OpenWrt VAPs\n", + disabled_defaults); + + /* 3. Create VAPs and determine whether any WLAN requests steering. */ + int steering_policy_enabled = 0; + if (vt_arr && json_object_is_type(vt_arr, json_type_array)) { + int nv = json_object_array_length(vt_arr); + for (int i = 0; i < nv; i++) { + struct json_object *vap = json_object_array_get_idx(vt_arr, i); + if (!vap) continue; + + const char *band_steer_keys[] = { + "band_steering", "band_steering_enabled", + "band_steering_mode", "steering_enabled" + }; + const char *handoff_keys[] = { + "bss_transition", "bss_transition_enabled", + "bss_transition_management", "handoff_suggestions", + "handoff_suggestions_enabled", "ieee80211v" + }; + if (wlan_json_boolean_any(vap, band_steer_keys, + sizeof(band_steer_keys) / + sizeof(band_steer_keys[0])) || + wlan_json_boolean_any(vap, handoff_keys, + sizeof(handoff_keys) / + sizeof(handoff_keys[0]))) + steering_policy_enabled = 1; + + /* A VAP without an explicit band is a model-wide WLAN. */ + const char *radio_band = NULL; + if (json_object_object_get_ex(vap, "radio", &v)) + radio_band = json_object_get_string(v); + if (radio_band && !strcmp(radio_band, "2g")) radio_band = "ng"; + if (radio_band && !strcmp(radio_band, "5g")) radio_band = "na"; + if (radio_band && !strcmp(radio_band, "6GHz")) radio_band = "6g"; + int all_radios = !radio_band || !radio_band[0] || + !strcmp(radio_band, "both") || + !strcmp(radio_band, "all"); + int applied = 0; + for (int j = 0; j < model->radio_map_len; j++) { + if (!all_radios && + strcmp(model->radio_map[j].band, radio_band)) + continue; + int section_idx = i * model->radio_map_len + j; + const char *device = wlan_device_for_band( + model, model->radio_map[j].band); + if (!device || apply_vap(ctx, pkg, vap, device, + model->radio_map[j].band, + mac_str, section_idx) != 0) { + uci_unload(ctx, pkg); + uci_free_context(ctx); + return -1; + } + applied++; + } + if (!applied) { + printf("[openuf] Ignoring VAP with unknown radio '%s'\n", + radio_band ? radio_band : ""); + uci_unload(ctx, pkg); + uci_free_context(ctx); + return -1; + } + } + } + + /* 4. Commit UCI */ + if (uci_commit(ctx, &pkg, false) != UCI_OK) { + char *uci_error = NULL; + uci_get_errorstr(ctx, &uci_error, "wireless"); + printf("[openuf] Failed to commit UCI wireless configuration: %s\n", + uci_error ? uci_error : "unknown UCI error"); + free(uci_error); + uci_unload(ctx, pkg); + uci_free_context(ctx); + return -1; + } + uci_unload(ctx, pkg); + uci_free_context(ctx); + + if (wlan_configure_band_steering(steering_policy_enabled) != 0) + printf("[openuf] Failed to configure the band steering policy\n"); + + /* + * Reload netifd for generated VLAN devices, then bring the radios up one + * at a time. Some dual-ath9k devices intermittently fail their first beacon + * setup after ACS. Start and verify each PHY independently, retrying a failed radio so + * provisioning cannot leave one band visible but unusable. + */ + printf("[openuf] Starting controller-managed Wi-Fi sequentially...\n"); + system("ubus call network reload >/dev/null 2>&1"); + for (int i = 0; i < model->radio_map_len; i++) { + char command[256]; + const char *device = wlan_device_for_band( + model, model->radio_map[i].band); + + /* Model radio names are internal constants, but validate defensively. */ + if (!device || + strspn(device, + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-") != + strlen(device)) { + printf("[openuf] Refusing invalid radio name\n"); + continue; + } + + int radio_up = 0; + for (int attempt = 1; attempt <= 2 && !radio_up; attempt++) { + printf("[openuf] Starting %s (%s), attempt %d...\n", + device, model->radio_map[i].band, attempt); + snprintf(command, sizeof(command), + "wifi up %s >/dev/null 2>&1", device); + system(command); + + /* ACS normally takes 6-8 seconds on this ath9k hardware. */ + sleep(10); + int phy_index = -1; + if (sscanf(device, "radio%d", &phy_index) != 1) + phy_index = -1; + snprintf(command, sizeof(command), + "iw dev phy%d-ap0 info 2>/dev/null | " + "grep -q '^[[:space:]]*ssid ' && echo true", + phy_index); + FILE *status = popen(command, "r"); + if (status) { + char value[16] = {0}; + if (fgets(value, sizeof(value), status) && + !strncmp(value, "true", 4)) + radio_up = 1; + pclose(status); + } + + if (!radio_up) + printf("[openuf] %s did not reach the up state; retrying\n", + device); + } + + if (!radio_up) + printf("[openuf] %s failed after 2 start attempts\n", device); + } + + /* Enable management features only after hostapd has registered each BSS. + * Unsupported WNM methods fail harmlessly without preventing AP startup. */ + if (steering_policy_enabled) { + for (int i = 0; i < model->radio_map_len; i++) { + const char *device = wlan_device_for_band( + model, model->radio_map[i].band); + int phy_index = -1; + if (!device || sscanf(device, "radio%d", &phy_index) != 1) + continue; + + char command[256]; + snprintf(command, sizeof(command), + "ubus -S call hostapd.phy%d-ap0 bss_mgmt_enable " + "%c{ \"neighbor_report\": true, " + "\"beacon_report\": true, " + "\"bss_transition\": true }%c >/dev/null 2>&1", + phy_index, 39, 39); + if (system(command) != 0) + printf("[openuf] hostapd on phy%d lacks runtime 802.11v " + "support; continuing without BSS Transition\n", + phy_index); + } + } + + /* Restart after hostapd has registered both BSSes on ubus. */ + system("/etc/init.d/usteer restart >/dev/null 2>&1"); + return 0; +} + diff --git a/src/wlan/radio.c b/src/wlan/radio.c new file mode 100644 index 0000000..fae7eb5 --- /dev/null +++ b/src/wlan/radio.c @@ -0,0 +1,318 @@ +/* + * Resolve model bands to local PHYs and apply channel, width, generation, and + * transmit-power settings to OpenWrt radio devices. + */ + + +#include +#include +#include +#include +#include +#include + +#include "wlan.h" +#include "ufmodel.h" +#include "crypto.h" +#include "wlan_internal.h" + +#define MAX_RESOLVED_RADIOS 8 + +typedef struct { + char device[32]; + unsigned int bands; +} radio_capability_t; + +static const uf_model_t *resolved_model; +static char resolved_devices[MAX_RESOLVED_RADIOS][32]; + +static unsigned int band_bit(const char *band) +{ + if (!band) return 0; + if (!strcmp(band, "ng") || !strcmp(band, "2g")) return 1u; + if (!strcmp(band, "na") || !strcmp(band, "5g")) return 2u; + if (!strcmp(band, "6g") || !strcmp(band, "6GHz")) return 4u; + return 0; +} + +static int bit_count(unsigned int value) +{ + int count = 0; + while (value) { + count += value & 1u; + value >>= 1; + } + return count; +} + +/* OpenWrt's generated radioN and phyN indices correspond for mac80211 + * devices. Read actual frequencies instead of assuming PHY band order. */ +static unsigned int detect_radio_bands(const char *device) +{ + int phy_index = -1; + char command[96]; + char line[256]; + unsigned int bands = 0; + + if (!device || sscanf(device, "radio%d", &phy_index) != 1 || phy_index < 0) + return 0; + + snprintf(command, sizeof(command), "iw phy phy%d info 2>/dev/null", phy_index); + FILE *pipe = popen(command, "r"); + if (!pipe) return 0; + + while (fgets(line, sizeof(line), pipe)) { + char *mhz = strstr(line, " MHz ["); + if (!mhz || strstr(line, "(disabled)")) + continue; + + char *start = mhz; + while (start > line && + ((start[-1] >= '0' && start[-1] <= '9') || start[-1] == '.')) + start--; + double frequency = strtod(start, NULL); + if (frequency >= 2300.0 && frequency < 3000.0) + bands |= 1u; + else if (frequency >= 4900.0 && frequency < 5925.0) + bands |= 2u; + else if (frequency >= 5925.0 && frequency < 7200.0) + bands |= 4u; + } + pclose(pipe); + return bands; +} + +static void resolve_radio_map(const uf_model_t *model) +{ + if (!model || resolved_model == model) + return; + + memset(resolved_devices, 0, sizeof(resolved_devices)); + resolved_model = model; + + int count = model->radio_map_len; + if (count > MAX_RESOLVED_RADIOS) + count = MAX_RESOLVED_RADIOS; + + radio_capability_t caps[MAX_RESOLVED_RADIOS] = {0}; + int used[MAX_RESOLVED_RADIOS] = {0}; + for (int i = 0; i < count; i++) { + snprintf(caps[i].device, sizeof(caps[i].device), "%s", + model->radio_map[i].device); + caps[i].bands = detect_radio_bands(caps[i].device); + } + + for (int i = 0; i < count; i++) { + unsigned int wanted = band_bit(model->radio_map[i].band); + int best = -1; + int best_band_count = 99; + + for (int j = 0; j < count; j++) { + if (used[j] || !(caps[j].bands & wanted)) + continue; + int supported = bit_count(caps[j].bands); + if (supported < best_band_count) { + best = j; + best_band_count = supported; + } + } + + if (best >= 0) { + used[best] = 1; + snprintf(resolved_devices[i], sizeof(resolved_devices[i]), "%s", + caps[best].device); + } else { + snprintf(resolved_devices[i], sizeof(resolved_devices[i]), "%s", + model->radio_map[i].device); + } + + printf("[openuf] Radio mapping: %s -> %s%s\n", + model->radio_map[i].band, resolved_devices[i], + best >= 0 ? " (detected)" : " (model fallback)"); + } +} + +const char *wlan_device_for_band(const uf_model_t *model, const char *band) +{ + if (!model || !band) return NULL; + resolve_radio_map(model); + for (int i = 0; i < model->radio_map_len; i++) + if (!strcmp(model->radio_map[i].band, band)) + return i < MAX_RESOLVED_RADIOS && resolved_devices[i][0] + ? resolved_devices[i] : model->radio_map[i].device; + return NULL; +} + +const char *wlan_band_for_device(const uf_model_t *model, const char *device) +{ + if (!model || !device) return NULL; + resolve_radio_map(model); + for (int i = 0; i < model->radio_map_len; i++) { + const char *mapped = i < MAX_RESOLVED_RADIOS && resolved_devices[i][0] + ? resolved_devices[i] + : model->radio_map[i].device; + if (!strcmp(mapped, device)) + return model->radio_map[i].band; + } + return NULL; +} + +/* UniFi security names to OpenWrt UCI encryption names. */ +/* Return the newest 802.11 generation advertised by the local PHY. */ +static enum wifi_standard radio_max_standard(const char *device_name) +{ + int phy_index = -1; + char command[96]; + char line[256]; + enum wifi_standard standard = WIFI_STANDARD_UNKNOWN; + + if (!device_name || sscanf(device_name, "radio%d", &phy_index) != 1 || + phy_index < 0) + return WIFI_STANDARD_UNKNOWN; + + snprintf(command, sizeof(command), "iw phy phy%d info 2>/dev/null", + phy_index); + FILE *pipe = popen(command, "r"); + if (!pipe) + return WIFI_STANDARD_UNKNOWN; + + while (fgets(line, sizeof(line), pipe)) { + if (strstr(line, "EHT Iftypes")) + standard = WIFI_STANDARD_7; + else if (standard < WIFI_STANDARD_6 && strstr(line, "HE Iftypes")) + standard = WIFI_STANDARD_6; + else if (standard < WIFI_STANDARD_5 && + strstr(line, "VHT Capabilities")) + standard = WIFI_STANDARD_5; + else if (standard < WIFI_STANDARD_4 && strstr(line, "Capabilities:")) + standard = WIFI_STANDARD_4; + } + pclose(pipe); + return standard; +} + +/* Keep the controller-selected width but use the newest PHY generation. */ +static void select_htmode(const char *device_name, const char *radio_band, + const char *requested, int force_wifi4, + char *result, size_t result_size) +{ + enum wifi_standard standard = radio_max_standard(device_name); + int width = 20; + const char *number = requested; + + while (number && *number && (*number < '0' || *number > '9')) + number++; + if (number && *number) + width = atoi(number); + + if (force_wifi4) { + /* 802.11n cannot use 80 MHz or wider channels. */ + standard = WIFI_STANDARD_4; + if (width > 40) + width = 40; + } else if (standard == WIFI_STANDARD_5 && radio_band && + (!strcmp(radio_band, "ng") || !strcmp(radio_band, "2g"))) { + /* OpenWrt does not use VHT modes on the 2.4 GHz band. */ + standard = WIFI_STANDARD_4; + } + + const char *prefix = standard == WIFI_STANDARD_7 ? "EHT" : + standard == WIFI_STANDARD_6 ? "HE" : + standard == WIFI_STANDARD_5 ? "VHT" : + standard == WIFI_STANDARD_4 ? "HT" : NULL; + if (!prefix) { + snprintf(result, result_size, "%s", requested ? requested : "HT20"); + return; + } + + snprintf(result, result_size, "%s%d", prefix, width); +} + +/* ═══════════════════════════════════════════════════════════════════ + wlan_apply_radio — apply radio config (channel, HT, power) + ═══════════════════════════════════════════════════════════════════ + + Reading parameters from the controller's JSON: + channel → wireless..channel + ht → wireless..htmode ("HT20" / "HT40" / "HT80" / "HE80") + tx_power → wireless..txpower + min_rssi → not mapped to UCI (requires an external daemon) +*/ +void wlan_apply_radio(struct json_object *radio_json, + const char *device_name, + int force_wifi4) +{ + struct uci_context *ctx = uci_alloc_context(); + if (!ctx) return; + + struct uci_package *pkg = NULL; + if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { + uci_free_context(ctx); return; + } + + struct json_object *v; + char path[256]; + + /* Map UniFi band names to OpenWrt mac80211 band names. */ + const char *radio_band = NULL; + if (json_object_object_get_ex(radio_json, "radio", &v)) { + radio_band = json_object_get_string(v); + const char *band = !strcmp(radio_band, "ng") ? "2g" : + !strcmp(radio_band, "na") ? "5g" : + !strcmp(radio_band, "6g") ? "6g" : NULL; + if (band) { + snprintf(path, sizeof(path), "wireless.%s.band=%s", + device_name, band); + struct uci_ptr ptr; + if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) + uci_set(ctx, &ptr); + } + } + + if (json_object_object_get_ex(radio_json, "ht", &v)) { + const char *requested = json_object_get_string(v); + char htmode[16]; + select_htmode(device_name, radio_band, requested, force_wifi4, + htmode, sizeof(htmode)); + snprintf(path, sizeof(path), "wireless.%s.htmode=%s", + device_name, htmode); + struct uci_ptr ptr; + if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) + uci_set(ctx, &ptr); + printf("[openuf] Radio %s standard: %s%s\n", device_name, htmode, + force_wifi4 ? " (Force WiFi 4)" : " (newest supported)"); + } + + /* Channel: 0 = auto in UniFi */ + if (json_object_object_get_ex(radio_json, "channel", &v)) { + int ch = json_object_get_int(v); + if (ch == 0) { + snprintf(path, sizeof(path), "wireless.%s.channel=auto", device_name); + } else { + snprintf(path, sizeof(path), "wireless.%s.channel=%d", device_name, ch); + } + struct uci_ptr ptr; + if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) + uci_set(ctx, &ptr); + } + + /* tx_power */ + if (json_object_object_get_ex(radio_json, "tx_power", &v)) { + snprintf(path, sizeof(path), "wireless.%s.txpower=%d", + device_name, json_object_get_int(v)); + struct uci_ptr ptr; + if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) + uci_set(ctx, &ptr); + } + + /* Enable the radio */ + snprintf(path, sizeof(path), "wireless.%s.disabled=0", device_name); + struct uci_ptr ptr; + if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) + uci_set(ctx, &ptr); + + uci_commit(ctx, &pkg, false); + uci_unload(ctx, pkg); + uci_free_context(ctx); +} + diff --git a/src/wlan/telemetry.c b/src/wlan/telemetry.c new file mode 100644 index 0000000..7320896 --- /dev/null +++ b/src/wlan/telemetry.c @@ -0,0 +1,166 @@ +/* + * Read openuf-managed UCI VAP sections and expose the controller-compatible + * vap_table representation, including runtime interface and BSSID data. + */ + + +#include +#include +#include +#include +#include +#include + +#include "wlan.h" +#include "ufmodel.h" +#include "crypto.h" +#include "wlan_internal.h" + +/* + * Only wifi-iface sections with the openuf_ prefix are reported. Runtime + * nl80211 interface names and sysfs BSSIDs are joined with their effective + * UCI security, roaming, steering, PMF, visibility, and VLAN settings. +*/ +/* Resolve a configured VAP to the live interface reported by nl80211. */ +static int find_runtime_vap(int phy_index, const char *ssid, + char *out, size_t out_size) +{ + FILE *pipe = popen("iw dev 2>/dev/null", "r"); + if (!pipe) return -1; + + int phy = -1; + char candidate[32] = ""; + char line[256]; + while (fgets(line, sizeof(line), pipe)) { + int parsed_phy; + char value[128]; + if (sscanf(line, "phy#%d", &parsed_phy) == 1) { + phy = parsed_phy; + candidate[0] = '\0'; + continue; + } + if (sscanf(line, " Interface %31s", value) == 1) { + snprintf(candidate, sizeof(candidate), "%s", value); + continue; + } + if (phy == phy_index && candidate[0] && + sscanf(line, " ssid %127[^\n]", value) == 1 && + !strcmp(value, ssid)) { + snprintf(out, out_size, "%s", candidate); + pclose(pipe); + return 0; + } + } + pclose(pipe); + return -1; +} + +struct json_object *wlan_get_vap_table(const uf_model_t *model) +{ + struct json_object *arr = json_object_new_array(); + + struct uci_context *ctx = uci_alloc_context(); + if (!ctx) return arr; + + struct uci_package *pkg = NULL; + if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { + uci_free_context(ctx); + return arr; + } + + struct uci_element *e; + uci_foreach_element(&pkg->sections, e) { + struct uci_section *sec = uci_to_section(e); + if (strcmp(sec->type, "wifi-iface") != 0) continue; + /* Only report VAPs managed by openuf */ + if (strncmp(sec->e.name, "openuf_", 7) != 0) continue; + +#define UCI_GET(opt) uci_lookup_option_string(ctx, sec, opt) + + const char *ssid = UCI_GET("ssid"); + const char *device = UCI_GET("device"); + const char *enc = UCI_GET("encryption"); + const char *dis = UCI_GET("disabled"); + const char *r11 = UCI_GET("ieee80211r"); + const char *ft_req = UCI_GET("openuf_ft_requested"); + const char *k11 = UCI_GET("ieee80211k"); + const char *btm = UCI_GET("bss_transition"); + const char *bs_req = UCI_GET("openuf_band_steering"); + const char *ho_req = UCI_GET("openuf_handoff_suggestions"); + const char *w11 = UCI_GET("ieee80211w"); + const char *hidden = UCI_GET("hidden"); + const char *vap_id = UCI_GET("openuf_vap_id"); + const char *vlan = UCI_GET("vlan_id"); + + if (!ssid) ssid = ""; + if (!device) device = "radio0"; + + /* Band of this radio */ + const char *radio_band = wlan_band_for_device(model, device); + if (!radio_band) radio_band = "ng"; + + /* Resolve the actual netifd interface (for example phy1-ap0). */ + char wlan_iface[32]; + int ridx = 0; + sscanf(device, "radio%d", &ridx); + if (find_runtime_vap(ridx, ssid, wlan_iface, sizeof(wlan_iface)) != 0) + snprintf(wlan_iface, sizeof(wlan_iface), "phy%d-ap0", ridx); + + /* Read the actual BSSID from sysfs */ + char bssid[32] = "00:00:00:00:00:00"; + { + char path[128]; + snprintf(path, sizeof(path), "/sys/class/net/%s/address", wlan_iface); + FILE *f = fopen(path, "r"); + if (f) { + fgets(bssid, sizeof(bssid), f); fclose(f); + bssid[strcspn(bssid, "\r\n")] = '\0'; + } + } + + /* PMF: ieee80211w → "disabled"/"optional"/"required" */ + const char *pmf = "disabled"; + if (w11) { + if (!strcmp(w11,"1")) pmf = "optional"; + if (!strcmp(w11,"2")) pmf = "required"; + } + + bool ft_on = (r11 && !strcmp(r11,"1")) || + (ft_req && !strcmp(ft_req,"1")); + bool bs_on = bs_req ? !strcmp(bs_req, "1") : + (k11 && !strcmp(k11, "1")); + bool handoff_on = ho_req ? !strcmp(ho_req, "1") : + (btm && !strcmp(btm, "1")); + bool hid = (hidden && !strcmp(hidden,"1")); + bool up = !(dis && !strcmp(dis,"1")); + + struct json_object *o = json_object_new_object(); + json_object_object_add(o, "essid", json_object_new_string(ssid)); + json_object_object_add(o, "bssid", json_object_new_string(bssid)); + json_object_object_add(o, "name", json_object_new_string(wlan_iface)); + json_object_object_add(o, "ifname", json_object_new_string(wlan_iface)); + json_object_object_add(o, "radio", json_object_new_string(radio_band)); + json_object_object_add(o, "security", json_object_new_string(wlan_security_to_unifi(enc))); + json_object_object_add(o, "up", json_object_new_boolean(up)); + json_object_object_add(o, "hide_ssid", json_object_new_boolean(hid)); + json_object_object_add(o, "fast_roaming_enabled",json_object_new_boolean(ft_on)); + json_object_object_add(o, "band_steering", json_object_new_boolean(bs_on)); + json_object_object_add(o, "bss_transition", json_object_new_boolean(handoff_on)); + json_object_object_add(o, "handoff_suggestions", json_object_new_boolean(handoff_on)); + json_object_object_add(o, "pmf_mode", json_object_new_string(pmf)); + json_object_object_add(o, "num_sta", json_object_new_int(0)); + if (vlan && atoi(vlan) > 0) + json_object_object_add(o, "vlan_id", json_object_new_int(atoi(vlan))); + if (wlan_valid_object_id(vap_id)) + json_object_object_add(o, "id", json_object_new_string(vap_id)); + if (wlan_valid_object_id(vap_id)) + json_object_object_add(o, "wlanconf_id", + json_object_new_string(vap_id)); + json_object_array_add(arr, o); +#undef UCI_GET + } + + uci_unload(ctx, pkg); + uci_free_context(ctx); + return arr; +} diff --git a/src/wlan/uci.c b/src/wlan/uci.c new file mode 100644 index 0000000..c77f5d6 --- /dev/null +++ b/src/wlan/uci.c @@ -0,0 +1,310 @@ +/* + * Small UCI operations shared by provisioning: named sections, VLAN network + * construction, steering policy, and managed-VAP cleanup. + */ + + +#include +#include +#include +#include +#include +#include + +#include "wlan.h" +#include "ufmodel.h" +#include "crypto.h" +#include "wlan_internal.h" + + +/* Set one UCI option from a fully qualified assignment path. */ +int wlan_uci_set(struct uci_context *ctx, + const char *path, const char *val) +{ + struct uci_ptr ptr; + char *p = malloc(strlen(path) + strlen(val) + 2); + if (!p) return -1; + sprintf(p, "%s=%s", path, val); + int ret = uci_lookup_ptr(ctx, &ptr, p, true); + if (ret == UCI_OK) + ret = uci_set(ctx, &ptr); + /* ptr.value may point inside p, so free it only after uci_set(). */ + free(p); + return ret == UCI_OK ? 0 : -1; +} +/* Set and verify an option whose absence would make a VAP unusable. */ +int wlan_uci_set_required(struct uci_context *ctx, + struct uci_package *pkg, + const char *section_name, + const char *option_name, + const char *value) +{ + char path[256]; + snprintf(path, sizeof(path), "%s.%s.%s", + pkg->e.name, section_name, option_name); + if (wlan_uci_set(ctx, path, value) != 0) + return -1; + + struct uci_section *section = + uci_lookup_section(ctx, pkg, section_name); + const char *stored = section ? + uci_lookup_option_string(ctx, section, option_name) : NULL; + return stored && !strcmp(stored, value) ? 0 : -1; +} + +/* Add one value to a UCI list option. */ +int wlan_uci_add_list(struct uci_context *ctx, + const char *path, const char *val) +{ + struct uci_ptr ptr; + char *assignment = malloc(strlen(path) + strlen(val) + 2); + if (!assignment) return -1; + sprintf(assignment, "%s=%s", path, val); + int ret = uci_lookup_ptr(ctx, &ptr, assignment, true); + if (ret == UCI_OK) + ret = uci_add_list(ctx, &ptr); + /* ptr.value may point inside assignment. */ + free(assignment); + return ret == UCI_OK ? 0 : -1; +} + +/* Find or create a named UCI section. */ +int wlan_uci_ensure_section(struct uci_context *ctx, + struct uci_package *pkg, + const char *sec_name, + const char *sec_type) +{ + struct uci_element *e; + uci_foreach_element(&pkg->sections, e) { + struct uci_section *s = uci_to_section(e); + if (!strcmp(s->e.name, sec_name) && !strcmp(s->type, sec_type)) + return 0; /* The section already exists. */ + } + /* Create a named section: wireless.=. */ + char *p = malloc(strlen(pkg->e.name) + strlen(sec_name) + + strlen(sec_type) + 3); + if (!p) return -1; + sprintf(p, "%s.%s=%s", pkg->e.name, sec_name, sec_type); + struct uci_ptr ptr; + int ret = uci_lookup_ptr(ctx, &ptr, p, true); + if (ret == UCI_OK) + ret = uci_set(ctx, &ptr); + free(p); + return ret == UCI_OK ? 0 : -1; +} + +/* + * Resolve the physical port below network.lan's bridge. VLAN tagging must + * happen on that port (for example eth0.11), not above the management bridge. + */ +static void find_vlan_uplink(struct uci_context *ctx, + struct uci_package *pkg, + char *out, size_t out_size) +{ + const char *lan_device = "br-lan"; + struct uci_element *element; + + uci_foreach_element(&pkg->sections, element) { + struct uci_section *section = uci_to_section(element); + if (!strcmp(section->type, "interface") && + !strcmp(section->e.name, "lan")) { + const char *device = uci_lookup_option_string(ctx, section, + "device"); + if (device && device[0]) lan_device = device; + break; + } + } + + uci_foreach_element(&pkg->sections, element) { + struct uci_section *section = uci_to_section(element); + const char *name; + struct uci_option *ports; + if (strcmp(section->type, "device")) continue; + name = uci_lookup_option_string(ctx, section, "name"); + if (!name || strcmp(name, lan_device)) continue; + ports = uci_lookup_option(ctx, section, "ports"); + if (!ports) break; + if (ports->type == UCI_TYPE_STRING) { + snprintf(out, out_size, "%s", ports->v.string); + return; + } + if (ports->type == UCI_TYPE_LIST && !uci_list_empty(&ports->v.list)) { + struct uci_element *port = + list_to_element(ports->v.list.next); + snprintf(out, out_size, "%s", port->name); + return; + } + break; + } + + snprintf(out, out_size, "eth0"); +} + +int wlan_ensure_vlan_network(int vid) +{ + struct uci_context *ctx = uci_alloc_context(); + if (!ctx) return -1; + + struct uci_package *pkg = NULL; + if (uci_load(ctx, "network", &pkg) != UCI_OK) { + uci_free_context(ctx); + return -1; + } + + char vlan_section[48], bridge_section[48], interface_section[32]; + char vlan_uplink[32], vlan_device[32], bridge_device[32], vid_string[16]; + find_vlan_uplink(ctx, pkg, vlan_uplink, sizeof(vlan_uplink)); + snprintf(vlan_section, sizeof(vlan_section), + "openuf_vlan%d", vid); + snprintf(bridge_section, sizeof(bridge_section), "openuf_br%d", vid); + snprintf(interface_section, sizeof(interface_section), + "vlan%d", vid); + snprintf(vlan_device, sizeof(vlan_device), "%s.%d", vlan_uplink, vid); + snprintf(bridge_device, sizeof(bridge_device), "br-openuf-%d", vid); + snprintf(vid_string, sizeof(vid_string), "%d", vid); + + int ok = wlan_uci_ensure_section(ctx, pkg, vlan_section, "device") == 0 && + wlan_uci_ensure_section(ctx, pkg, bridge_section, "device") == 0 && + wlan_uci_ensure_section(ctx, pkg, interface_section, "interface") == 0; + if (ok) { + WLAN_UCI_SET(ctx, "network", vlan_section, "type", "8021q"); + WLAN_UCI_SET(ctx, "network", vlan_section, "ifname", vlan_uplink); + WLAN_UCI_SET(ctx, "network", vlan_section, "vid", vid_string); + WLAN_UCI_SET(ctx, "network", vlan_section, "name", vlan_device); + + /* A VAP needs a bridge containing the tagged wired device. */ + WLAN_UCI_SET(ctx, "network", bridge_section, "type", "bridge"); + WLAN_UCI_SET(ctx, "network", bridge_section, "name", bridge_device); + char ports_path[128]; + snprintf(ports_path, sizeof(ports_path), "network.%s.ports", + bridge_section); + /* Replace the list so repeated provisioning never duplicates ports. */ + struct uci_ptr ports_ptr; + char ports_lookup[128]; + snprintf(ports_lookup, sizeof(ports_lookup), "%s", ports_path); + if (uci_lookup_ptr(ctx, &ports_ptr, ports_lookup, true) == UCI_OK && + ports_ptr.o) + uci_delete(ctx, &ports_ptr); + ok = wlan_uci_add_list(ctx, ports_path, vlan_device) == 0; + + WLAN_UCI_SET(ctx, "network", interface_section, "proto", "none"); + WLAN_UCI_SET(ctx, "network", interface_section, "device", bridge_device); + ok = ok && uci_commit(ctx, &pkg, false) == UCI_OK; + } + + uci_unload(ctx, pkg); + uci_free_context(ctx); + if (ok) + printf("[openuf] Configured VLAN %d on uplink %s as network '%s'\n", + vid, vlan_uplink, interface_section); + return ok ? 0 : -1; +} + +/* + * Configure OpenWrt's steering policy engine. The hostapd 802.11k/v flags + * only expose measurements and transition commands; they do not decide when + * a station should move. usteer supplies that missing policy loop. + */ +int wlan_configure_band_steering(int enabled) +{ + struct uci_context *ctx = uci_alloc_context(); + if (!ctx) + return -1; + + struct uci_package *pkg = NULL; + if (uci_load(ctx, "usteer", &pkg) != UCI_OK) { + printf("[openuf] Cannot load /etc/config/usteer\n"); + uci_free_context(ctx); + return -1; + } + + struct uci_section *settings = NULL; + struct uci_element *element; + uci_foreach_element(&pkg->sections, element) { + struct uci_section *section = uci_to_section(element); + if (!strcmp(section->type, "usteer")) { + settings = section; + break; + } + } + + if (!settings) { + if (wlan_uci_ensure_section(ctx, pkg, "openuf", "usteer") != 0) { + uci_unload(ctx, pkg); + uci_free_context(ctx); + return -1; + } + settings = uci_lookup_section(ctx, pkg, "openuf"); + } + + if (!settings) { + uci_unload(ctx, pkg); + uci_free_context(ctx); + return -1; + } + + /* + * A zero interval disables higher-band steering. A zero station-count + * threshold is important for small networks: usteer's default of five + * otherwise prevents a lone client from being considered. The signal + * floor avoids pushing a client onto 5 GHz when that link is too weak. + */ + WLAN_UCI_SET(ctx, "usteer", settings->e.name, "band_steering_interval", + enabled ? "30000" : "0"); + WLAN_UCI_SET(ctx, "usteer", settings->e.name, "band_steering_threshold", "0"); + WLAN_UCI_SET(ctx, "usteer", settings->e.name, "band_steering_min_snr", "-65"); + + int ok = uci_commit(ctx, &pkg, false) == UCI_OK; + uci_unload(ctx, pkg); + uci_free_context(ctx); + printf("[openuf] Band steering policy %s (usteer)\n", + enabled ? "enabled" : "disabled"); + return ok ? 0 : -1; +} + +/* ═══════════════════════════════════════════════════════════════════ + wlan_clear — remove only VAPs owned by openUF + ═══════════════════════════════════════════════════════════════════ */ +void wlan_clear(void) +{ + struct uci_context *ctx = uci_alloc_context(); + if (!ctx) return; + + struct uci_package *pkg = NULL; + if (uci_load(ctx, "wireless", &pkg) != UCI_OK) { + uci_free_context(ctx); + return; + } + + /* Collect sections to remove (do not modify during iteration) */ + char *sections_to_delete[64]; + int delete_count = 0; + struct uci_element *element; + uci_foreach_element(&pkg->sections, element) { + struct uci_section *section = uci_to_section(element); + if (!strcmp(section->type, "wifi-iface") && + !strncmp(section->e.name, "openuf_", 7) && delete_count < 64) { + sections_to_delete[delete_count++] = strdup(section->e.name); + } + } + + for (int i = 0; i < delete_count; i++) { + struct uci_ptr ptr; + char path[128]; + snprintf(path, sizeof(path), "wireless.%s", sections_to_delete[i]); + if (uci_lookup_ptr(ctx, &ptr, path, true) == UCI_OK) + uci_delete(ctx, &ptr); + free(sections_to_delete[i]); + } + + if (delete_count > 0) { + uci_commit(ctx, &pkg, false); + printf("[openuf] wlan_clear: removed %d managed VAPs\n", + delete_count); + } + + uci_unload(ctx, pkg); + uci_free_context(ctx); +} + + diff --git a/src/wlan/wlan_internal.h b/src/wlan/wlan_internal.h new file mode 100644 index 0000000..fdb34b4 --- /dev/null +++ b/src/wlan/wlan_internal.h @@ -0,0 +1,60 @@ +#ifndef OPENUF_WLAN_INTERNAL_H +#define OPENUF_WLAN_INTERNAL_H + +#include +#include +#include +#include + +enum wifi_standard { + WIFI_STANDARD_UNKNOWN = 0, + WIFI_STANDARD_4 = 4, + WIFI_STANDARD_5 = 5, + WIFI_STANDARD_6 = 6, + WIFI_STANDARD_7 = 7, +}; + +const char *wlan_security_to_uci(const char *unifi_security); +const char *wlan_security_to_unifi(const char *uci_security); +int wlan_valid_object_id(const char *object_id); +int wlan_ensure_vap_ids(struct json_object *vaps); +void wlan_mobility_domain_for_ssid(const char *ssid, char output[5]); +int wlan_radio_uses_ath9k(const char *device_name); +int wlan_json_boolean_any(struct json_object *object, + const char *const *keys, + size_t key_count); +int wlan_feature_enabled(const char *text); +void wlan_safe_section_name(const char *ssid, char *output, size_t output_size); + +int wlan_uci_set(struct uci_context *context, + const char *path, + const char *value); +int wlan_uci_set_required(struct uci_context *context, + struct uci_package *package, + const char *section_name, + const char *option_name, + const char *value); +int wlan_uci_add_list(struct uci_context *context, + const char *path, + const char *value); +int wlan_uci_ensure_section(struct uci_context *context, + struct uci_package *package, + const char *section_name, + const char *section_type); +int wlan_ensure_vlan_network(int vlan_id); +int wlan_configure_band_steering(int enabled); + +#define WLAN_UCI_SET(context, package, section, option, value) do { \ + char openuf_uci_path[256]; \ + snprintf(openuf_uci_path, sizeof(openuf_uci_path), "%s.%s.%s", \ + package, section, option); \ + wlan_uci_set(context, openuf_uci_path, value); \ +} while (0) + +#define WLAN_UCI_SET_INT(context, package, section, option, integer_value) do { \ + char openuf_uci_value[32]; \ + snprintf(openuf_uci_value, sizeof(openuf_uci_value), "%d", integer_value); \ + WLAN_UCI_SET(context, package, section, option, openuf_uci_value); \ +} while (0) + +#endif /* OPENUF_WLAN_INTERNAL_H */ diff --git a/wiki/Call-Graph.md b/wiki/Call-Graph.md new file mode 100644 index 0000000..cec69c7 --- /dev/null +++ b/wiki/Call-Graph.md @@ -0,0 +1,411 @@ +# Function Call Graph + +Generated from `src/**/*.c` by `scripts/docs/generate-wiki.py`. + +```mermaid +flowchart LR + subgraph module_0["src/announce.c"] + function_announce_c_tlv_append["tlv_append()"] + function_announce_c_tlv_str["tlv_str()"] + function_announce_c_put32be["put32be()"] + function_announce_c_parse_mac["parse_mac()"] + function_announce_c_parse_ip["parse_ip()"] + function_announce_c_announce_init["announce_init()"] + function_announce_c_announce_send["announce_send()"] + function_announce_c_announce_close["announce_close()"] + end + subgraph module_1["src/clients.c"] + function_clients_c_mac_lower["mac_lower()"] + function_clients_c_clients_mac_to_ip["clients_mac_to_ip()"] + function_clients_c_clients_mac_to_hostname["clients_mac_to_hostname()"] + function_clients_c_parse_rate_kbps["parse_rate_kbps()"] + function_clients_c_clients_read_wifi["clients_read_wifi()"] + function_clients_c_clients_build_sta_table["clients_build_sta_table()"] + end + subgraph module_2["src/config.c"] + function_config_c_config_load["config_load()"] + end + subgraph module_3["src/crypto.c"] + function_crypto_c_crypto_hex2bin["crypto_hex2bin()"] + function_crypto_c_crypto_bin2hex["crypto_bin2hex()"] + function_crypto_c_crypto_random_hex["crypto_random_hex()"] + function_crypto_c_crypto_encrypt["crypto_encrypt()"] + function_crypto_c_crypto_decrypt["crypto_decrypt()"] + function_crypto_c_crypto_gcm_encrypt["crypto_gcm_encrypt()"] + function_crypto_c_crypto_gcm_decrypt["crypto_gcm_decrypt()"] + end + subgraph module_4["src/http.c"] + function_http_c_parse_url["parse_url()"] + function_http_c_http_post["http_post()"] + end + subgraph module_5["src/inform/inform.c"] + function_inform_inform_c_inform_send["inform_send()"] + end + subgraph module_6["src/inform/packet.c"] + function_inform_packet_c_put32be["put32be()"] + function_inform_packet_c_put16be["put16be()"] + function_inform_packet_c_get32be["get32be()"] + function_inform_packet_c_get16be["get16be()"] + function_inform_packet_c_inform_packet_build["inform_packet_build()"] + function_inform_packet_c_inform_packet_parse["inform_packet_parse()"] + end + subgraph module_7["src/inform/payload.c"] + function_inform_payload_c_build_sys_stats["build_sys_stats()"] + function_inform_payload_c_build_system_stats["build_system_stats()"] + function_inform_payload_c_build_if_table["build_if_table()"] + function_inform_payload_c_radio_runtime_iface["radio_runtime_iface()"] + function_inform_payload_c_build_scan_table["build_scan_table()"] + function_inform_payload_c_build_athstats["build_athstats()"] + function_inform_payload_c_build_radio_table["build_radio_table()"] + function_inform_payload_c_build_radio_table_stats["build_radio_table_stats()"] + function_inform_payload_c_build_port_table["build_port_table()"] + function_inform_payload_c_build_eth_table["build_eth_table()"] + function_inform_payload_c_build_vap_table["build_vap_table()"] + function_inform_payload_c_collect_sta_table["collect_sta_table()"] + function_inform_payload_c_inform_build_payload["inform_build_payload()"] + end + subgraph module_8["src/inform/response.c"] + function_inform_response_c_inform_valid_auth_key["inform_valid_auth_key()"] + function_inform_response_c_inform_set_debug_level["inform_set_debug_level()"] + function_inform_response_c_inform_debug_level["inform_debug_level()"] + function_inform_response_c_inform_debug_system_cfg_key["inform_debug_system_cfg_key()"] + function_inform_response_c_inform_log_controller_response["inform_log_controller_response()"] + function_inform_response_c_reboot_openwrt["reboot_openwrt()"] + function_inform_response_c_inform_handle_response["inform_handle_response()"] + end + subgraph module_9["src/lldp.c"] + function_lldp_c_tlv_write["tlv_write()"] + function_lldp_c_tlv_str["tlv_str()"] + function_lldp_c_parse_mac["parse_mac()"] + function_lldp_c_lldp_send_frame["lldp_send_frame()"] + function_lldp_c_lldp_available["lldp_available()"] + function_lldp_c_lldp_read_neighbors["lldp_read_neighbors()"] + end + subgraph module_10["src/main.c"] + function_main_c_get_mac["get_mac()"] + function_main_c_get_ip["get_ip()"] + function_main_c_get_default_gateway["get_default_gateway()"] + function_main_c_main["main()"] + end + subgraph module_11["src/models.c"] + function_models_c_ufmodel_find["ufmodel_find()"] + end + subgraph module_12["src/state.c"] + function_state_c_state_defaults["state_defaults()"] + function_state_c_state_load["state_load()"] + function_state_c_state_save["state_save()"] + end + subgraph module_13["src/sysinfo.c"] + function_sysinfo_c_sysinfo_mem["sysinfo_mem()"] + function_sysinfo_c_read_cpu["read_cpu()"] + function_sysinfo_c_sysinfo_cpu_percent["sysinfo_cpu_percent()"] + function_sysinfo_c_read_sysfs_str["read_sysfs_str()"] + function_sysinfo_c_read_sysfs_int["read_sysfs_int()"] + function_sysinfo_c_read_ip_ioctl["read_ip_ioctl()"] + function_sysinfo_c_sysinfo_iface["sysinfo_iface()"] + function_sysinfo_c_utilization_percent["utilization_percent()"] + function_sysinfo_c_count_antenna_chains["count_antenna_chains()"] + function_sysinfo_c_sysinfo_radio["sysinfo_radio()"] + function_sysinfo_c_frequency_to_channel["frequency_to_channel()"] + function_sysinfo_c_sysinfo_wifi_scan_cache["sysinfo_wifi_scan_cache()"] + end + subgraph module_14["src/wlan/common.c"] + function_wlan_common_c_wlan_security_to_uci["wlan_security_to_uci()"] + function_wlan_common_c_wlan_security_to_unifi["wlan_security_to_unifi()"] + function_wlan_common_c_wlan_valid_object_id["wlan_valid_object_id()"] + function_wlan_common_c_wlan_ensure_vap_ids["wlan_ensure_vap_ids()"] + function_wlan_common_c_wlan_mobility_domain_for_ssid["wlan_mobility_domain_for_ssid()"] + function_wlan_common_c_wlan_radio_uses_ath9k["wlan_radio_uses_ath9k()"] + function_wlan_common_c_wlan_json_boolean_any["wlan_json_boolean_any()"] + function_wlan_common_c_wlan_feature_enabled["wlan_feature_enabled()"] + function_wlan_common_c_wlan_safe_section_name["wlan_safe_section_name()"] + end + subgraph module_15["src/wlan/legacy.c"] + function_wlan_legacy_c_system_cfg_get["system_cfg_get()"] + function_wlan_legacy_c_wlan_apply_system_cfg["wlan_apply_system_cfg()"] + end + subgraph module_16["src/wlan/provision.c"] + function_wlan_provision_c_apply_vap["apply_vap()"] + function_wlan_provision_c_force_wifi4_for_band["force_wifi4_for_band()"] + function_wlan_provision_c_wlan_apply_config["wlan_apply_config()"] + end + subgraph module_17["src/wlan/radio.c"] + function_wlan_radio_c_band_bit["band_bit()"] + function_wlan_radio_c_bit_count["bit_count()"] + function_wlan_radio_c_detect_radio_bands["detect_radio_bands()"] + function_wlan_radio_c_resolve_radio_map["resolve_radio_map()"] + function_wlan_radio_c_wlan_device_for_band["wlan_device_for_band()"] + function_wlan_radio_c_wlan_band_for_device["wlan_band_for_device()"] + function_wlan_radio_c_radio_max_standard["radio_max_standard()"] + function_wlan_radio_c_select_htmode["select_htmode()"] + function_wlan_radio_c_wlan_apply_radio["wlan_apply_radio()"] + end + subgraph module_18["src/wlan/telemetry.c"] + function_wlan_telemetry_c_find_runtime_vap["find_runtime_vap()"] + function_wlan_telemetry_c_wlan_get_vap_table["wlan_get_vap_table()"] + end + subgraph module_19["src/wlan/uci.c"] + function_wlan_uci_c_wlan_uci_set["wlan_uci_set()"] + function_wlan_uci_c_wlan_uci_set_required["wlan_uci_set_required()"] + function_wlan_uci_c_wlan_uci_add_list["wlan_uci_add_list()"] + function_wlan_uci_c_wlan_uci_ensure_section["wlan_uci_ensure_section()"] + function_wlan_uci_c_find_vlan_uplink["find_vlan_uplink()"] + function_wlan_uci_c_wlan_ensure_vlan_network["wlan_ensure_vlan_network()"] + function_wlan_uci_c_wlan_configure_band_steering["wlan_configure_band_steering()"] + function_wlan_uci_c_wlan_clear["wlan_clear()"] + end + function_announce_c_tlv_str --> function_announce_c_tlv_append + function_announce_c_announce_init --> function_announce_c_tlv_append + function_announce_c_announce_init --> function_announce_c_tlv_str + function_announce_c_announce_init --> function_announce_c_parse_mac + function_announce_c_announce_init --> function_announce_c_parse_ip + function_announce_c_announce_send --> function_announce_c_put32be + function_clients_c_clients_mac_to_ip --> function_clients_c_mac_lower + function_clients_c_clients_mac_to_hostname --> function_clients_c_mac_lower + function_clients_c_clients_read_wifi --> function_clients_c_clients_mac_to_ip + function_clients_c_clients_read_wifi --> function_clients_c_clients_mac_to_hostname + function_clients_c_clients_read_wifi --> function_clients_c_parse_rate_kbps + function_clients_c_clients_build_sta_table --> function_clients_c_clients_read_wifi + function_crypto_c_crypto_random_hex --> function_crypto_c_crypto_bin2hex + function_crypto_c_crypto_encrypt --> function_crypto_c_crypto_hex2bin + function_crypto_c_crypto_decrypt --> function_crypto_c_crypto_hex2bin + function_crypto_c_crypto_gcm_encrypt --> function_crypto_c_crypto_hex2bin + function_crypto_c_crypto_gcm_decrypt --> function_crypto_c_crypto_hex2bin + function_http_c_http_post --> function_http_c_parse_url + function_inform_inform_c_inform_send --> function_http_c_http_post + function_inform_inform_c_inform_send --> function_inform_packet_c_inform_packet_build + function_inform_inform_c_inform_send --> function_inform_packet_c_inform_packet_parse + function_inform_inform_c_inform_send --> function_inform_payload_c_inform_build_payload + function_inform_inform_c_inform_send --> function_inform_response_c_inform_log_controller_response + function_inform_inform_c_inform_send --> function_inform_response_c_inform_handle_response + function_inform_inform_c_inform_send --> function_state_c_state_save + function_inform_packet_c_inform_packet_build --> function_crypto_c_crypto_hex2bin + function_inform_packet_c_inform_packet_build --> function_crypto_c_crypto_random_hex + function_inform_packet_c_inform_packet_build --> function_crypto_c_crypto_encrypt + function_inform_packet_c_inform_packet_build --> function_crypto_c_crypto_gcm_encrypt + function_inform_packet_c_inform_packet_build --> function_inform_packet_c_put32be + function_inform_packet_c_inform_packet_build --> function_inform_packet_c_put16be + function_inform_packet_c_inform_packet_parse --> function_crypto_c_crypto_bin2hex + function_inform_packet_c_inform_packet_parse --> function_crypto_c_crypto_decrypt + function_inform_packet_c_inform_packet_parse --> function_crypto_c_crypto_gcm_decrypt + function_inform_packet_c_inform_packet_parse --> function_inform_packet_c_get32be + function_inform_packet_c_inform_packet_parse --> function_inform_packet_c_get16be + function_inform_payload_c_build_sys_stats --> function_sysinfo_c_sysinfo_mem + function_inform_payload_c_build_sys_stats --> function_sysinfo_c_sysinfo_cpu_percent + function_inform_payload_c_build_if_table --> function_sysinfo_c_sysinfo_iface + function_inform_payload_c_radio_runtime_iface --> function_wlan_radio_c_wlan_device_for_band + function_inform_payload_c_radio_runtime_iface --> function_wlan_telemetry_c_wlan_get_vap_table + function_inform_payload_c_build_scan_table --> function_sysinfo_c_sysinfo_wifi_scan_cache + function_inform_payload_c_build_radio_table --> function_inform_payload_c_radio_runtime_iface + function_inform_payload_c_build_radio_table --> function_inform_payload_c_build_scan_table + function_inform_payload_c_build_radio_table --> function_inform_payload_c_build_athstats + function_inform_payload_c_build_radio_table --> function_inform_response_c_inform_debug_level + function_inform_payload_c_build_radio_table_stats --> function_inform_payload_c_radio_runtime_iface + function_inform_payload_c_build_radio_table_stats --> function_sysinfo_c_sysinfo_radio + function_inform_payload_c_build_port_table --> function_sysinfo_c_sysinfo_iface + function_inform_payload_c_build_vap_table --> function_clients_c_clients_build_sta_table + function_inform_payload_c_build_vap_table --> function_sysinfo_c_sysinfo_iface + function_inform_payload_c_build_vap_table --> function_sysinfo_c_sysinfo_radio + function_inform_payload_c_build_vap_table --> function_wlan_radio_c_wlan_device_for_band + function_inform_payload_c_build_vap_table --> function_wlan_telemetry_c_wlan_get_vap_table + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_sys_stats + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_system_stats + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_if_table + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_radio_table + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_radio_table_stats + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_port_table + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_eth_table + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_build_vap_table + function_inform_payload_c_inform_build_payload --> function_inform_payload_c_collect_sta_table + function_inform_payload_c_inform_build_payload --> function_lldp_c_lldp_read_neighbors + function_inform_response_c_inform_log_controller_response --> function_inform_response_c_inform_debug_system_cfg_key + function_inform_response_c_inform_handle_response --> function_inform_response_c_inform_valid_auth_key + function_inform_response_c_inform_handle_response --> function_inform_response_c_reboot_openwrt + function_inform_response_c_inform_handle_response --> function_state_c_state_save + function_inform_response_c_inform_handle_response --> function_wlan_legacy_c_wlan_apply_system_cfg + function_inform_response_c_inform_handle_response --> function_wlan_provision_c_wlan_apply_config + function_lldp_c_tlv_str --> function_lldp_c_tlv_write + function_lldp_c_lldp_send_frame --> function_lldp_c_tlv_write + function_lldp_c_lldp_send_frame --> function_lldp_c_tlv_str + function_lldp_c_lldp_send_frame --> function_lldp_c_parse_mac + function_lldp_c_lldp_read_neighbors --> function_lldp_c_lldp_available + function_main_c_main --> function_announce_c_announce_init + function_main_c_main --> function_announce_c_announce_send + function_main_c_main --> function_announce_c_announce_close + function_main_c_main --> function_config_c_config_load + function_main_c_main --> function_inform_inform_c_inform_send + function_main_c_main --> function_inform_response_c_inform_set_debug_level + function_main_c_main --> function_lldp_c_lldp_send_frame + function_main_c_main --> function_lldp_c_lldp_available + function_main_c_main --> function_main_c_get_mac + function_main_c_main --> function_main_c_get_ip + function_main_c_main --> function_main_c_get_default_gateway + function_main_c_main --> function_models_c_ufmodel_find + function_main_c_main --> function_state_c_state_load + function_main_c_main --> function_state_c_state_save + function_state_c_state_load --> function_state_c_state_defaults + function_sysinfo_c_sysinfo_cpu_percent --> function_sysinfo_c_read_cpu + function_sysinfo_c_read_sysfs_int --> function_sysinfo_c_read_sysfs_str + function_sysinfo_c_sysinfo_iface --> function_sysinfo_c_read_sysfs_str + function_sysinfo_c_sysinfo_iface --> function_sysinfo_c_read_sysfs_int + function_sysinfo_c_sysinfo_iface --> function_sysinfo_c_read_ip_ioctl + function_sysinfo_c_sysinfo_radio --> function_sysinfo_c_utilization_percent + function_sysinfo_c_sysinfo_radio --> function_sysinfo_c_count_antenna_chains + function_sysinfo_c_sysinfo_wifi_scan_cache --> function_sysinfo_c_frequency_to_channel + function_wlan_common_c_wlan_ensure_vap_ids --> function_crypto_c_crypto_random_hex + function_wlan_common_c_wlan_ensure_vap_ids --> function_wlan_common_c_wlan_valid_object_id + function_wlan_legacy_c_wlan_apply_system_cfg --> function_wlan_common_c_wlan_valid_object_id + function_wlan_legacy_c_wlan_apply_system_cfg --> function_wlan_common_c_wlan_feature_enabled + function_wlan_legacy_c_wlan_apply_system_cfg --> function_wlan_legacy_c_system_cfg_get + function_wlan_legacy_c_wlan_apply_system_cfg --> function_wlan_provision_c_wlan_apply_config + function_wlan_provision_c_apply_vap --> function_wlan_common_c_wlan_security_to_uci + function_wlan_provision_c_apply_vap --> function_wlan_common_c_wlan_valid_object_id + function_wlan_provision_c_apply_vap --> function_wlan_common_c_wlan_mobility_domain_for_ssid + function_wlan_provision_c_apply_vap --> function_wlan_common_c_wlan_radio_uses_ath9k + function_wlan_provision_c_apply_vap --> function_wlan_common_c_wlan_json_boolean_any + function_wlan_provision_c_apply_vap --> function_wlan_common_c_wlan_safe_section_name + function_wlan_provision_c_apply_vap --> function_wlan_uci_c_wlan_uci_set_required + function_wlan_provision_c_apply_vap --> function_wlan_uci_c_wlan_uci_add_list + function_wlan_provision_c_apply_vap --> function_wlan_uci_c_wlan_uci_ensure_section + function_wlan_provision_c_apply_vap --> function_wlan_uci_c_wlan_ensure_vlan_network + function_wlan_provision_c_force_wifi4_for_band --> function_wlan_common_c_wlan_json_boolean_any + function_wlan_provision_c_wlan_apply_config --> function_wlan_common_c_wlan_ensure_vap_ids + function_wlan_provision_c_wlan_apply_config --> function_wlan_common_c_wlan_json_boolean_any + function_wlan_provision_c_wlan_apply_config --> function_wlan_provision_c_apply_vap + function_wlan_provision_c_wlan_apply_config --> function_wlan_provision_c_force_wifi4_for_band + function_wlan_provision_c_wlan_apply_config --> function_wlan_radio_c_wlan_device_for_band + function_wlan_provision_c_wlan_apply_config --> function_wlan_radio_c_wlan_apply_radio + function_wlan_provision_c_wlan_apply_config --> function_wlan_uci_c_wlan_configure_band_steering + function_wlan_provision_c_wlan_apply_config --> function_wlan_uci_c_wlan_clear + function_wlan_radio_c_resolve_radio_map --> function_wlan_radio_c_band_bit + function_wlan_radio_c_resolve_radio_map --> function_wlan_radio_c_bit_count + function_wlan_radio_c_resolve_radio_map --> function_wlan_radio_c_detect_radio_bands + function_wlan_radio_c_wlan_device_for_band --> function_wlan_radio_c_resolve_radio_map + function_wlan_radio_c_wlan_band_for_device --> function_wlan_radio_c_resolve_radio_map + function_wlan_radio_c_select_htmode --> function_wlan_radio_c_radio_max_standard + function_wlan_radio_c_wlan_apply_radio --> function_wlan_radio_c_select_htmode + function_wlan_telemetry_c_wlan_get_vap_table --> function_wlan_common_c_wlan_security_to_unifi + function_wlan_telemetry_c_wlan_get_vap_table --> function_wlan_common_c_wlan_valid_object_id + function_wlan_telemetry_c_wlan_get_vap_table --> function_wlan_radio_c_wlan_band_for_device + function_wlan_telemetry_c_wlan_get_vap_table --> function_wlan_telemetry_c_find_runtime_vap + function_wlan_uci_c_wlan_uci_set_required --> function_wlan_uci_c_wlan_uci_set + function_wlan_uci_c_wlan_ensure_vlan_network --> function_wlan_uci_c_wlan_uci_add_list + function_wlan_uci_c_wlan_ensure_vlan_network --> function_wlan_uci_c_wlan_uci_ensure_section + function_wlan_uci_c_wlan_ensure_vlan_network --> function_wlan_uci_c_find_vlan_uplink + function_wlan_uci_c_wlan_configure_band_steering --> function_wlan_uci_c_wlan_uci_ensure_section +``` + +## Caller/callee index + +| Source | Caller | Internal callees | +| --- | --- | --- | +| `src/announce.c:67` | `tlv_append()` | — | +| `src/announce.c:78` | `tlv_str()` | `tlv_append()` | +| `src/announce.c:85` | `put32be()` | — | +| `src/announce.c:94` | `parse_mac()` | — | +| `src/announce.c:102` | `parse_ip()` | — | +| `src/announce.c:110` | `announce_init()` | `tlv_append()`, `tlv_str()`, `parse_mac()`, `parse_ip()` | +| `src/announce.c:233` | `announce_send()` | `put32be()` | +| `src/announce.c:273` | `announce_close()` | — | +| `src/clients.c:45` | `mac_lower()` | — | +| `src/clients.c:55` | `clients_mac_to_ip()` | `mac_lower()` | +| `src/clients.c:86` | `clients_mac_to_hostname()` | `mac_lower()` | +| `src/clients.c:120` | `parse_rate_kbps()` | — | +| `src/clients.c:130` | `clients_read_wifi()` | `clients_mac_to_ip()`, `clients_mac_to_hostname()`, `parse_rate_kbps()` | +| `src/clients.c:247` | `clients_build_sta_table()` | `clients_read_wifi()` | +| `src/config.c:7` | `config_load()` | — | +| `src/crypto.c:13` | `crypto_hex2bin()` | — | +| `src/crypto.c:22` | `crypto_bin2hex()` | — | +| `src/crypto.c:30` | `crypto_random_hex()` | `crypto_bin2hex()` | +| `src/crypto.c:58` | `crypto_encrypt()` | `crypto_hex2bin()` | +| `src/crypto.c:93` | `crypto_decrypt()` | `crypto_hex2bin()` | +| `src/crypto.c:123` | `crypto_gcm_encrypt()` | `crypto_hex2bin()` | +| `src/crypto.c:144` | `crypto_gcm_decrypt()` | `crypto_hex2bin()` | +| `src/http.c:24` | `parse_url()` | — | +| `src/http.c:65` | `http_post()` | `parse_url()` | +| `src/inform/inform.c:26` | `inform_send()` | `http_post()`, `inform_packet_build()`, `inform_packet_parse()`, `inform_build_payload()`, `inform_log_controller_response()`, `inform_handle_response()`, `state_save()` | +| `src/inform/packet.c:26` | `put32be()` | — | +| `src/inform/packet.c:31` | `put16be()` | — | +| `src/inform/packet.c:35` | `get32be()` | — | +| `src/inform/packet.c:40` | `get16be()` | — | +| `src/inform/packet.c:45` | `inform_packet_build()` | `crypto_hex2bin()`, `crypto_random_hex()`, `crypto_encrypt()`, `crypto_gcm_encrypt()`, `put32be()`, `put16be()` | +| `src/inform/packet.c:103` | `inform_packet_parse()` | `crypto_bin2hex()`, `crypto_decrypt()`, `crypto_gcm_decrypt()`, `get32be()`, `get16be()` | +| `src/inform/payload.c:26` | `build_sys_stats()` | `sysinfo_mem()`, `sysinfo_cpu_percent()` | +| `src/inform/payload.c:69` | `build_system_stats()` | — | +| `src/inform/payload.c:91` | `build_if_table()` | `sysinfo_iface()` | +| `src/inform/payload.c:139` | `radio_runtime_iface()` | `wlan_device_for_band()`, `wlan_get_vap_table()` | +| `src/inform/payload.c:162` | `build_scan_table()` | `sysinfo_wifi_scan_cache()` | +| `src/inform/payload.c:191` | `build_athstats()` | — | +| `src/inform/payload.c:220` | `build_radio_table()` | `radio_runtime_iface()`, `build_scan_table()`, `build_athstats()`, `inform_debug_level()` | +| `src/inform/payload.c:296` | `build_radio_table_stats()` | `radio_runtime_iface()`, `sysinfo_radio()` | +| `src/inform/payload.c:373` | `build_port_table()` | `sysinfo_iface()` | +| `src/inform/payload.c:410` | `build_eth_table()` | — | +| `src/inform/payload.c:439` | `build_vap_table()` | `clients_build_sta_table()`, `sysinfo_iface()`, `sysinfo_radio()`, `wlan_device_for_band()`, `wlan_get_vap_table()` | +| `src/inform/payload.c:647` | `collect_sta_table()` | — | +| `src/inform/payload.c:668` | `inform_build_payload()` | `build_sys_stats()`, `build_system_stats()`, `build_if_table()`, `build_radio_table()`, `build_radio_table_stats()`, `build_port_table()`, `build_eth_table()`, `build_vap_table()`, `collect_sta_table()`, `lldp_read_neighbors()` | +| `src/inform/response.c:26` | `inform_valid_auth_key()` | — | +| `src/inform/response.c:38` | `inform_set_debug_level()` | — | +| `src/inform/response.c:43` | `inform_debug_level()` | — | +| `src/inform/response.c:48` | `inform_debug_system_cfg_key()` | — | +| `src/inform/response.c:57` | `inform_log_controller_response()` | `inform_debug_system_cfg_key()` | +| `src/inform/response.c:102` | `reboot_openwrt()` | — | +| `src/inform/response.c:120` | `inform_handle_response()` | `inform_valid_auth_key()`, `reboot_openwrt()`, `state_save()`, `wlan_apply_system_cfg()`, `wlan_apply_config()` | +| `src/lldp.c:75` | `tlv_write()` | — | +| `src/lldp.c:86` | `tlv_str()` | `tlv_write()` | +| `src/lldp.c:94` | `parse_mac()` | — | +| `src/lldp.c:104` | `lldp_send_frame()` | `tlv_write()`, `tlv_str()`, `parse_mac()` | +| `src/lldp.c:193` | `lldp_available()` | — | +| `src/lldp.c:210` | `lldp_read_neighbors()` | `lldp_available()` | +| `src/main.c:36` | `get_mac()` | — | +| `src/main.c:51` | `get_ip()` | — | +| `src/main.c:70` | `get_default_gateway()` | — | +| `src/main.c:116` | `main()` | `announce_init()`, `announce_send()`, `announce_close()`, `config_load()`, `inform_send()`, `inform_set_debug_level()`, `lldp_send_frame()`, `lldp_available()`, `get_mac()`, `get_ip()`, `get_default_gateway()`, `ufmodel_find()`, `state_load()`, `state_save()` | +| `src/models.c:179` | `ufmodel_find()` | — | +| `src/state.c:16` | `state_defaults()` | — | +| `src/state.c:27` | `state_load()` | `state_defaults()` | +| `src/state.c:102` | `state_save()` | — | +| `src/sysinfo.c:53` | `sysinfo_mem()` | — | +| `src/sysinfo.c:81` | `read_cpu()` | — | +| `src/sysinfo.c:92` | `sysinfo_cpu_percent()` | `read_cpu()` | +| `src/sysinfo.c:115` | `read_sysfs_str()` | — | +| `src/sysinfo.c:130` | `read_sysfs_int()` | `read_sysfs_str()` | +| `src/sysinfo.c:137` | `read_ip_ioctl()` | — | +| `src/sysinfo.c:151` | `sysinfo_iface()` | `read_sysfs_str()`, `read_sysfs_int()`, `read_ip_ioctl()` | +| `src/sysinfo.c:238` | `utilization_percent()` | — | +| `src/sysinfo.c:247` | `count_antenna_chains()` | — | +| `src/sysinfo.c:277` | `sysinfo_radio()` | `utilization_percent()`, `count_antenna_chains()` | +| `src/sysinfo.c:486` | `frequency_to_channel()` | — | +| `src/sysinfo.c:498` | `sysinfo_wifi_scan_cache()` | `frequency_to_channel()` | +| `src/wlan/common.c:20` | `wlan_security_to_uci()` | — | +| `src/wlan/common.c:34` | `wlan_security_to_unifi()` | — | +| `src/wlan/common.c:48` | `wlan_valid_object_id()` | — | +| `src/wlan/common.c:65` | `wlan_ensure_vap_ids()` | `crypto_random_hex()`, `wlan_valid_object_id()` | +| `src/wlan/common.c:157` | `wlan_mobility_domain_for_ssid()` | — | +| `src/wlan/common.c:169` | `wlan_radio_uses_ath9k()` | — | +| `src/wlan/common.c:187` | `wlan_json_boolean_any()` | — | +| `src/wlan/common.c:209` | `wlan_feature_enabled()` | — | +| `src/wlan/common.c:217` | `wlan_safe_section_name()` | — | +| `src/wlan/legacy.c:20` | `system_cfg_get()` | — | +| `src/wlan/legacy.c:44` | `wlan_apply_system_cfg()` | `wlan_valid_object_id()`, `wlan_feature_enabled()`, `system_cfg_get()`, `wlan_apply_config()` | +| `src/wlan/provision.c:20` | `apply_vap()` | `wlan_security_to_uci()`, `wlan_valid_object_id()`, `wlan_mobility_domain_for_ssid()`, `wlan_radio_uses_ath9k()`, `wlan_json_boolean_any()`, `wlan_safe_section_name()`, `wlan_uci_set_required()`, `wlan_uci_add_list()`, `wlan_uci_ensure_section()`, `wlan_ensure_vlan_network()` | +| `src/wlan/provision.c:291` | `force_wifi4_for_band()` | `wlan_json_boolean_any()` | +| `src/wlan/provision.c:335` | `wlan_apply_config()` | `wlan_ensure_vap_ids()`, `wlan_json_boolean_any()`, `apply_vap()`, `force_wifi4_for_band()`, `wlan_device_for_band()`, `wlan_apply_radio()`, `wlan_configure_band_steering()`, `wlan_clear()` | +| `src/wlan/radio.c:29` | `band_bit()` | — | +| `src/wlan/radio.c:38` | `bit_count()` | — | +| `src/wlan/radio.c:50` | `detect_radio_bands()` | — | +| `src/wlan/radio.c:85` | `resolve_radio_map()` | `band_bit()`, `bit_count()`, `detect_radio_bands()` | +| `src/wlan/radio.c:135` | `wlan_device_for_band()` | `resolve_radio_map()` | +| `src/wlan/radio.c:146` | `wlan_band_for_device()` | `resolve_radio_map()` | +| `src/wlan/radio.c:162` | `radio_max_standard()` | — | +| `src/wlan/radio.c:195` | `select_htmode()` | `radio_max_standard()` | +| `src/wlan/radio.c:241` | `wlan_apply_radio()` | `select_htmode()` | +| `src/wlan/telemetry.c:25` | `find_runtime_vap()` | — | +| `src/wlan/telemetry.c:58` | `wlan_get_vap_table()` | `wlan_security_to_unifi()`, `wlan_valid_object_id()`, `wlan_band_for_device()`, `find_runtime_vap()` | +| `src/wlan/uci.c:21` | `wlan_uci_set()` | — | +| `src/wlan/uci.c:36` | `wlan_uci_set_required()` | `wlan_uci_set()` | +| `src/wlan/uci.c:56` | `wlan_uci_add_list()` | — | +| `src/wlan/uci.c:72` | `wlan_uci_ensure_section()` | — | +| `src/wlan/uci.c:100` | `find_vlan_uplink()` | — | +| `src/wlan/uci.c:143` | `wlan_ensure_vlan_network()` | `wlan_uci_add_list()`, `wlan_uci_ensure_section()`, `find_vlan_uplink()` | +| `src/wlan/uci.c:208` | `wlan_configure_band_steering()` | `wlan_uci_ensure_section()` | +| `src/wlan/uci.c:268` | `wlan_clear()` | — | diff --git a/wiki/Developer-Guide.md b/wiki/Developer-Guide.md new file mode 100644 index 0000000..311ef2d --- /dev/null +++ b/wiki/Developer-Guide.md @@ -0,0 +1,166 @@ +# Developer Guide + +This guide describes the current openUF implementation. The daemon is a +single-process, single-threaded OpenWrt service. Its main loop wakes once per +second and schedules discovery, LLDP, and controller inform work. + +## Safety first + +openUF runs as root. It can replace managed Wi-Fi configuration, create VLAN +devices, send raw Ethernet frames, reboot the device, and persist adoption +credentials. Do not run the daemon itself on a development workstation. +Compile it with the OpenWrt toolchain and perform runtime tests on a disposable +OpenWrt access point. + +Protocol debug level 2 can log decrypted credentials. Never enable it by +default, attach those logs to issues, or commit controller payload captures. + +## Source layout + +| Module | Responsibility | +| --- | --- | +| `src/announce.c` | UniFi layer-2 UDP discovery. | +| `src/clients.c` | Wireless and bridge client telemetry. | +| `src/config.c` | Static daemon configuration parser and defaults. | +| `src/crypto.c` | AES-CBC, AES-GCM, and encoding helpers. | +| `src/http.c` | Minimal HTTP/1.0 transport. | +| `src/inform/inform.c` | One inform request/response exchange. | +| `src/inform/packet.c` | TNBU binary envelope codec. | +| `src/inform/payload.c` | Inform JSON telemetry assembly. | +| `src/inform/response.c` | Controller command and provisioning dispatch. | +| `src/lldp.c` | LLDP frame transmission and neighbor collection. | +| `src/main.c` | Daemon lifecycle and one-second scheduler. | +| `src/models.c` | Emulated hardware model registry. | +| `src/state.c` | Persistent adoption and controller state. | +| `src/sysinfo.c` | Kernel and nl80211 device telemetry. | +| `src/wlan/common.c` | Shared Wi-Fi translations and validators. | +| `src/wlan/legacy.c` | Legacy system_cfg translation. | +| `src/wlan/provision.c` | Modern setstate Wi-Fi provisioning. | +| `src/wlan/radio.c` | Runtime radio mapping and radio settings. | +| `src/wlan/telemetry.c` | Managed VAP telemetry from UCI and nl80211. | +| `src/wlan/uci.c` | Reusable UCI, VLAN, steering, and cleanup operations. | + +Public contracts remain in `src/*.h`. The `src/inform/` and `src/wlan/` +directories contain private implementation units; their `*_internal.h` files +are not stable interfaces for other subsystems. + +## Runtime architecture + +`main()` loads static configuration, persistent controller state, and the +emulated model. It discovers the LAN MAC/IP and an initial controller from the +default route when no explicit controller is configured. The main loop then +schedules: + +1. `announce_send()` for UniFi UDP discovery. +2. `lldp_send_frame()` for each model Ethernet port. +3. `inform_send()` for adoption, telemetry, commands, and provisioning. + +See [[Runtime Flow|Runtime-Flow]] for the decision flow and +[[Function Call Graph|Call-Graph]] for generated caller/callee relationships. + +## Inform and adoption invariants + +- The TNBU layout, big-endian fields, flag meanings, authenticated header, and + cipher selection are controller compatibility constraints. +- An unadopted device always encrypts with `DEFAULT_AUTH_KEY`, even if stale + state contains another key. +- The controller response is decoded before command dispatch. Adoption can be + completed through either legacy `set-adopt` or modern `setparam` data. +- `cfgversion` records configuration that was successfully applied locally. + Failed provisioning resets it to `"0"` so the controller retries. +- Increase `OPENUF_CONFIG_SCHEMA` only when an existing persisted + configuration must be reapplied after an upgrade. + +## Wi-Fi ownership and provisioning + +Only UCI `wifi-iface` sections prefixed with `openuf_` belong to this daemon. +Cleanup must preserve every unrelated section. Controller VAP ObjectIds are +stored in `openuf_vap_id` so client topology remains stable across reprovision. + +Modern `setstate` data is applied by `wlan_apply_config()`. Legacy +newline-separated `system_cfg` data is first translated to the same JSON shape +by `wlan_apply_system_cfg()`, keeping one provisioning path responsible for UCI +commits and radio startup. + +Model band and port assumptions belong in `models.c`. The runtime radio mapper +may resolve a model band to a different local PHY, but protocol and telemetry +code must not invent model-specific mappings. + +## Common development tasks + +### Add telemetry + +1. Add a bounded reader to `sysinfo.c`, `clients.c`, or another focused module. +2. Add the controller field in `inform/payload.c`. +3. Document units, fallback behavior, ownership, and any sensitive content. +4. Regenerate this Wiki and cross-build the package. + +### Add a controller command + +1. Extend dispatch in `inform/response.c`. +2. Validate controller values before changing state or invoking a command. +3. Save state only after a coherent transition. +4. Preserve adoption-key and `cfgversion` behavior. + +### Add a Wi-Fi setting + +1. Parse controller aliases in `wlan/provision.c` or `wlan/legacy.c`. +2. Put reusable UCI work in `wlan/uci.c` and translations in `wlan/common.c`. +3. Report the effective value from `wlan/telemetry.c` when the controller + expects it in `vap_table`. +4. Test on device; a successful cross-build cannot validate netifd/hostapd + behavior. + +### Add or change a model + +Update `ufmodel.h`, the complete model entry in `models.c`, and every affected +telemetry or WLAN consumer together. Do not scatter model checks across the +protocol implementation. + +## Build and validation + +From the OpenWrt root: + +```sh +make package/OpenUniFi/compile +``` + +Use `V=s` for detailed compiler diagnostics. If a clean package rebuild is +needed, clean only this package: + +```sh +make package/OpenUniFi/clean +make package/OpenUniFi/compile +``` + +`Makefile.standalone` is only for compiling directly on an OpenWrt device with +development packages installed. It is not a host-side test substitute. + +## Documentation workflow + +Generate pages after changing C code or architecture: + +```sh +./scripts/docs/generate-wiki.py +``` + +Run the linter-style drift and structure check in CI or before committing: + +```sh +./scripts/docs/generate-wiki.py --check +``` + +The checker parses C functions recursively, rebuilds internal call edges, +validates required runtime entry points, rejects known non-English comment +fragments, and limits each implementation unit to +900 lines. + +## Publishing to the Gitea Wiki + +Gitea stores a repository Wiki in a separate Git repository whose URL normally +ends in `.wiki.git`. The generated `wiki/` directory is ready for that remote. +Run `scripts/docs/publish-wiki.sh` from a trusted machine with suitable +credentials. It derives the Wiki remote from `origin`; an explicit URL may be +passed when needed. The publisher regenerates and checks the pages, +updates only the generated Markdown files, commits changed pages, and pushes +them to the Wiki repository. diff --git a/wiki/Home.md b/wiki/Home.md new file mode 100644 index 0000000..1e77b19 --- /dev/null +++ b/wiki/Home.md @@ -0,0 +1,13 @@ +# openUF Developer Wiki + +This Wiki is generated from the current source tree and maintained in the main +repository so architecture documentation changes can be reviewed with code. + +- [[Developer Guide|Developer-Guide]] — architecture, invariants, extension + points, build validation, and Wiki publishing. +- [[Runtime Flow|Runtime-Flow]] — lifecycle, inform, response, and Wi-Fi + provisioning flow charts. +- [[Function Call Graph|Call-Graph]] — generated internal caller/callee graph + and searchable function table. + +Run `./scripts/docs/generate-wiki.py --check` to verify these pages are current. diff --git a/wiki/Runtime-Flow.md b/wiki/Runtime-Flow.md new file mode 100644 index 0000000..0f82a57 --- /dev/null +++ b/wiki/Runtime-Flow.md @@ -0,0 +1,76 @@ +# Runtime Flow + +The flow is generated alongside the call graph. Function names are validated +against the current C sources so renamed or removed stages fail documentation +checks. + +## Daemon scheduler + +```mermaid +flowchart TD + start([main]) --> config[config_load] + config --> state[state_load] + state --> model[ufmodel_find] + model --> identity[Read LAN MAC, IP, and default gateway] + identity --> persist[state_save] + persist --> announceInit[announce_init when enabled] + announceInit --> loop{One-second main loop} + loop -->|announce due| announce[announce_send] + announce --> loop + loop -->|LLDP due| ports{For every model port} + ports --> lldp[lldp_send_frame] + lldp --> loop + loop -->|inform due| refresh[Refresh controller URL and device IP] + refresh --> inform[inform_send] + inform --> loop + loop -->|nothing due| sleep[sleep 1 second] + sleep --> loop +``` + +## Inform exchange + +```mermaid +flowchart TD + send([inform_send]) --> url{inform URL available?} + url -->|no| error[Return an error] + url -->|yes| key{Device adopted?} + key -->|no| defaultKey[Use DEFAULT_AUTH_KEY] + key -->|yes| stateKey[Use persisted device key] + defaultKey --> payload[inform_build_payload] + stateKey --> payload + payload --> packet[inform_packet_build] + packet --> post[http_post] + post --> status{HTTP 200?} + status -->|no, first attempt| retry[Retry with alternate CBC or GCM cipher] + retry --> packet + status -->|no, final attempt| error + status -->|yes| decode[inform_packet_parse] + decode --> json[Parse controller JSON] + json --> handle[inform_handle_response] +``` + +## Controller response and provisioning + +```mermaid +flowchart TD + response([inform_handle_response]) --> type{_type} + type -->|noop| done[No state change] + type -->|upgrade| version[Persist requested firmware version] + type -->|reboot or reset| reboot[reboot_openwrt] + type -->|cmd adopt| adopt[Persist key, URL, and adopted state] + type -->|setparam| params[Parse management parameters] + params --> legacy{system_cfg present?} + legacy -->|yes| legacyApply[wlan_apply_system_cfg] + legacyApply --> normalize[Build radio_table and vap_table JSON] + normalize --> apply[wlan_apply_config] + type -->|setstate| apply + apply --> ids[wlan_ensure_vap_ids] + ids --> clear[wlan_clear managed openuf_ VAPs] + clear --> radios[wlan_apply_radio for each band] + radios --> vaps[Create controller VAP sections] + vaps --> commit[Commit UCI and configure usteer] + commit --> start[Start and verify radios sequentially] + start --> result{Apply succeeded?} + result -->|yes| saved[Save cfgversion and config schema] + result -->|no| retryConfig[Reset cfgversion to 0] +``` diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md new file mode 100644 index 0000000..8a9ac4c --- /dev/null +++ b/wiki/_Sidebar.md @@ -0,0 +1,4 @@ +- [[Home]] +- [[Developer Guide|Developer-Guide]] +- [[Runtime Flow|Runtime-Flow]] +- [[Function Call Graph|Call-Graph]] -- 2.54.0