| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
refactor: migrate StartWRT into the monorepo (projects/start-wrt) (#3385) * complete lan sections, improve wan sections based on lan innovations * outbound vpns * devices in decent shape * proxy fe requests for dev testing * chore: update to Taiga 5 candidate * auth module * update ipv4 for slash 16 * exec api * Revert "proxy fe requests for dev testing" This reverts commit 99014b11bc465c21cd5cf620690cfc0d94a3e9c3. * set SESSION_EXPIRY_DAYS to 1 * Only read STARTWRT_SESSION_PATH once * chore: refactor existing pages * feat: add forwarding route * use temp file for saving sessions * replace fs with tokio * make exec_command async * Set file permissions on create * setting tab progress * reset_pass and make the rest of auth.rs async * fix sibling call * atomically write to shadow file instead * set temp cookie file permissions to 600 * impl Deref for CliContext * finish up port forwarding * refactor password verification * Use sha512 for hashing algo * published ports instead of forwards and firewall * wrap up published ports with protections and mocks * feat: add ethernet route * wrap up ethernet plus other cleanup * refactor: cleanup published ports * feat: add ModalHelp component * refactor: cleaning things up * feat: add inbound route * finish inbound and add wifi * add wifi blackout and labels for inbound vpns * wifi, security profiles, and other refactors * draft proposal for init tool and reflash flow * Update init/reflash proposal with architectural changes - Separate WiFi and admin passwords (WiFi from factory sticker, admin user-chosen on first access) - eMMC stores WiFi PMK only (no admin credentials) - Mount path /mnt/persistent → /persistent - Smart serial dispatcher (microSD → login, no PMK → init, normal → login) - Captive portal for reflash flow (StartWRT-Setup AP with default password) - Admin password set in captive portal wizard during reflash - DNS hijacking on normal boot when admin password is unset - Package manager corrected to opkg (noted as open question) - Design notes: PMK rationale, identity PSK compatibility, WiFi key never in UI * refactor new UI * Revise init/reflash proposal with design decisions - Clarify captive portal vs DNS hijacking terminology - Remove temp AP (StartWRT-Setup) in favor of real StartWRT SSID for all flows - Add WiFi PMK precedence (baked-in first, then eMMC) for custom image support - Document BPI-F3 storage architecture (SPI NOR + eMMC partitions) - Add U-Boot manufacturing flash flow for initial firmware provisioning - Use sysupgrade conffiles for Update path overlay preservation - Require minimum 12-character admin password with confirmation - Resolve package management: StartWRT packages in firmware, user packages wiped - Fix boot detection diagram to show sequential checks not exclusive branches * Streamline manufacturing to single microSD boot session Boot from microSD directly (U-Boot tries SD first on BPI-F3) instead of raw-copying firmware at the bootloader level. Flash + WiFi init now happen in one session via serial, using the same image as end-user reflash. * chore: comments * asides for all dialogs * revert backend chnage * whitelist, blacklist, and outbound * re-arrange, readme, claudemd, and api contract * feat: blackout timeline UI * feat: edit, add and remove * feat: implement help * fill in todos and fix small bug * Build/openwrt image pipeline (#24) * Add Makefile-driven OpenWrt image build pipeline Introduce a build system that produces a complete SD card image with OpenWrt + StartWRT components (Rust binaries, web UI, UCI configs). Adds openwrt as a git submodule pinned to the bianbu branch, and reduces image size from 4GB to 512MB. * Makefile: - Fix Rust binary path (ctrl/target/ → target/) to match workspace layout - Copy final image to out/ directory - Add npm install before web build - Remove web UI from staging deps (skipped until Taiga UI fixed) - make clean now fully removes openwrt build artifacts (build_dir/, staging_dir/, tmp/, bin/) instead of relying on openwrt's make clean which left stale stamps build/feeds.conf: - Update LuCI from openwrt-23.05 to openwrt-24.10 build/openwrt-setup.sh: - Add kernel tarball pre-seed check with clear error message - Document that archive.spacemit.com's CDN is broken build/build-rust.sh, build/stage-files.sh: - Fix Rust target dir paths (ctrl/target/ → target/) - Skip web UI staging build/openwrt.diffconfig: - Remove dead LOCALMIRROR setting for archive.spacemit.com * make incremental builds more robust * mute feed errors for build in packages * add FE to build pipeline * Fix boot hang and harden build resource management Stage web UI to /www instead of /var/www — OpenWrt's /var is a symlink to /tmp, and creating a real /var directory overwrote it, breaking ubus/procd and hanging the system at boot. Also: - Add cgroup memory fence and JOBS budget to prevent OOM during builds - Disable CONFIG_KERNEL_WERROR to fix kernel build failures - Remove stale kernel tarball pre-seed check from openwrt-setup.sh * Update openwrt submodule: revert 2.2.9 kernel, fix libxml2 Point submodule to fix/replace-dead-spacemit-sources which reverts the 2.2.9 kernel switch (generic patches conflict with the vendor kernel) and fixes libxml2 host build failure on incremental builds. * Fix build resource management to prevent OOM crashes Replace fragile systemd-run cgroup wrapping with simpler, portable approach: derive JOBS from MemAvailable (not MemTotal), set oom_score_adj=500 so the build is the preferred OOM-kill target instead of the user session, and keep nice/ionice + load-average back-pressure. Also remove explicit kmod-sound-core from diffconfig since it is pulled in automatically via the ffmpeg → alsa-lib dependency chain. * Harden build resource limits to prevent session crashes - Increase per-job memory budget from 2 GB to 3 GB to cover peak linker phases (kernel, samba4, ffmpeg can each peak at 3-4 GB) - Cap JOBS at nproc/2 instead of full nproc, reserving cores for the host desktop/session - Restore cgroup memory fence via systemd-run MemoryMax so the build is killed before the host session starves (falls back gracefully with a warning when systemd-run is unavailable) * update submodule to the latest * factory init, flash, activate wifi, and captive portal * auth fixes, working captive portal, and more * remove unused stty and replace respawn with respawnlate * Fix captive portal: skip login on captive portal and redirect to completion page * Add setup wizard with auth, flash orchestration, and setup tests Implement the setup wizard flow end-to-end: setup mode detection, PMK resolution from SD/eMMC, conffiles backup/restore across flash, admin password hashing, and streaming flash progress via SSE. Add auth middleware, bake-password tool, and the Angular setup wizard frontend. Fix SetupEvent serialization to use camelCase field names (rename_all_fields) matching the frontend contract, and add 26 unit tests covering serialization, conffiles, backup/restore, password writing, and flash error handling. * Harden post-flash durability of persistent partition writes fsync the parent directory after PMK file rename in emmc.rs to ensure the directory entry survives a crash within the journal commit window. Propagate the persistent partition device path through FlashResult so setup.rs can remount /persistent if the hotplug handler races and unmounts it after partx -u. Unmount /persistent after writing the PMK to guarantee all data is flushed before signaling completion to the client. * Switch rootfs from ext4 to squashfs+overlay and add factory-reset API Replace the ext4 rootfs with a squashfs rootfs + ext4 rootfs_data overlay, matching standard OpenWrt architecture. This enables factory reset via (which wipes the overlay) and reduces flash copy size by reading squashfs bytes_used from the superblock instead of copying the full partition. - flash: read squashfs superblock to determine copy end offset, expand rootfs_data instead of rootfs, format overlay with mkfs.ext4, and mark it FS_STATE_READY so mount_root preserves it on first boot - setup: mount three-layer overlayfs (squashfs + ext4 upper + merged) for config backup/restore and post-flash customization - startwrt-bake-password: write PMK after squashfs data in the rootfs partition (4096-aligned) instead of to the persistent partition - system: add RPC endpoint (firstboot -y + reboot) - web: wire Factory Reset button with confirmation dialog - auth: skip session auth for loopback connections so startwrt-cli works over SSH without a token - firstboot_config/wireless: change radio1 from 6g to 5g - .gitignore: add __pycache__/ * Add atomic SSH deploy targets (update, update-rust, update-web) Rapid development workflow: tar + SSH pipeline deploys Rust binaries and/or web UI to the target device with atomic rename and automatic rollback on failure. Transfer progress via pv, GNU dd, or BSD dd. * update rust build location to ignore * Fix build config after rebase: squashfs rootfs, uhttpd port conflict, path fixes - Enable squashfs in diffconfig and reduce rootfs partition to 256 MB - Move uhttpd to port 8080/8443 so startwrt-ctrld can bind port 80 (change was in config_experiments/ but never applied to firstboot_config/) - Disable kmod-rtl8852bs per spacemit MT7915 setup docs - Fix build script paths for backend/ reorganization (Makefile, build-rust.sh, stage-files.sh) - Update call_remote signature for rpc_toolkit OrdMap API change - Track backend/Cargo.lock for reproducible builds * Rename persistent partition to key_backup Match the submodule's partition table rename across all backend code, build scripts, and firstboot UCI config. * Fix overlayfs umount failure during setup flash Sync before unmounting and fall back to lazy unmount for the underlying squashfs/ext4 layers. After the overlayfs is unmounted, the kernel may still hold cached dentry/inode references to the lower mounts, causing normal umount to fail with EBUSY. * update PROPOSAL-init-reflash for partition name change from persistent -> key_backup * Merge CLI + daemon into single startwrt binary Replace separate startwrt-cli and startwrt-ctrld binaries with a single startwrt binary using a MultiExecutable dispatcher (startbox pattern). The dispatcher routes by argv[0] (symlink name) then argv[1], with CLI as the default. Symlinks preserve backward compatibility for procd init script and serial dispatcher. Also embeds the web UI into the binary via include_dir (replacing tower-http ServeDir), eliminating the separate /www/startwrt staging. * Add GitHub Actions CI to build OpenWrt image on PRs BuildJet 32vCPU runner by default for fast builds (~30 min vs 2-3 hrs on standard runners). Falls back to ubuntu-latest via manual dispatch. Includes reusable setup-build composite action (disk cleanup, Node.js, OpenWrt build deps, sccache config) and caching for openwrt/dl/. Requires OPENWRT_DEPLOY_KEY repo secret for openwrt submodule access. * latest partitions changes from openwrt submodule * fix image build race condition and clean stale .config - openwrt submodule: restrict debX device to squashfs-only builds (FILESYSTEMS := squashfs). The packaging scripts write to hardcoded filenames in a shared temp dir, causing collisions when ext4 and squashfs variants build in parallel. - Makefile: delete openwrt/.config on rm -rf out rm -rf backend/target rm -rf web/dist web/.angular rm -rf openwrt/files rm -rf openwrt/build_dir openwrt/staging_dir openwrt/tmp openwrt/bin rm -f openwrt/.config so it is always regenerated from the diffconfig source of truth. * Fix service reloads, session races, and WiFi password labels Invert effectful() so ServerContext returns true (enabling service Previously the server never ran wifi reload, network reload, etc. Replace file-based session storage with an in-memory RwLock to eliminate write races from concurrent requests that caused random logouts. Disk persistence now only happens on login/logout. Add label field to WifiStation UCI type and persist user-assigned password labels through get/set round-trips. Frontend WiFi password dialog now fetches real profiles from the API instead of using hardcoded values. * Implement profile delete, admin bootstrap, and VLAN lifecycle fixes Complete the profile CRUD surface: removes a profile's UCI entries across all five configs (startwrt, network, firewall, dhcp, wireless) with conflict-retry and LAN-owner protection. A new registers the existing LAN infrastructure as the Admin profile on first boot and daemon startup (idempotent). VLAN lifecycle is tightened: creates a VLAN 1 entry for all bridge ports when the first bridge-vlan appears, preventing traffic loss. wifi-vlan sections are now owned by profile create/delete rather than wifi.set, avoiding accidental removal. wifi.set gains a fallback that creates missing wifi-vlans for legacy profiles and uses smart reload (full restart only when VLAN topology changes). Ethernet port assignment now defaults unassigned ports to the admin profile when VLAN filtering is active and skips the WAN port. The uciedit macro skips leading comment/empty lines so appended sections are readable without a dump+parse round-trip, and non-existent config files no longer produce bogus conflict timestamps. * Fix devices table: filter WAN neighbors, map profiles, and handle * hostname Filter parseArpOutput() to br-lan* interfaces only, excluding upstream router neighbors discovered via IPv6 NDP on the WAN port. Map each device's VLAN tag to its profile name via profilesList() instead of hardcoding 'Default'. Treat dnsmasq's '*' placeholder hostname as empty so unnamed devices fall through to the generated device-XXXXXX name. * Implement DNS override for security profiles Add per-profile dns_override field that creates firewall DNAT redirect rules to intercept all port 53 traffic and forward it to specified DNS servers. This enforces DNS even for devices that hardcode their own resolvers. Also fixes: Makefile PV fallback for make update, and crate path for bootstrap_admin_profile in daemon.rs. * Fix profiles.get returning Whitelist instead of All for LAN access When a profile had forwarding rules to every other existing profile, profiles.get still returned LanAccess::OtherProfiles (Whitelist) unless access_to_new_profiles was also true. Remove that extra guard so the check only looks at whether the profile forwards to all other profiles. * Implement system preferences and remote access firewall rules Add smart endpoints for system.info, set-preferences, newer-versions, and apply-remote-access. Remote access mode (default/never/always) dynamically manages WAN firewall rules based on IP type — private IPv4 or ULA IPv6 gets access rules, public IPs do not. A hotplug script re-evaluates on WAN changes. Frontend: add remoteAccess to SystemInfoRes, fix mock language value, and refactor theme selector to store API values directly with stringify for display. * serve start-wrt at router.lan * Implement Launch LuCi button * Implement system logs: RPC endpoint, authenticated WebSocket streaming, and live log viewer - Add logs.rs with logread parser, system.logs RPC endpoint, and /api/logs WebSocket - Gate WebSocket upgrade with session cookie validation (returns 401 if unauthenticated) - Extract extract_session_token() from SessionAuth middleware for reuse - Build logs page with initial RPC load, live WebSocket streaming, auto-scroll, reconnect, and download - Document both endpoints in API_CONTRACT.md * Fix eMMC overlayfs umount failure blocking "Keep settings" reflash completion During "Keep settings" reflash, the second umount_emmc_overlayfs() call could fail with EINVAL because the squashfs was already detached by the kernel after the first mount/unmount cycle left stale superblock state. This fatal error prevented the WiFi PMK from being written to key_backup and SetupEvent::Complete from reaching the frontend, even though the flash itself had succeeded. Three fixes: - Drop kernel dentry/inode caches after unmounting overlayfs merged mount so the squashfs lowerdir can be cleanly unmounted - Treat "not mounted" as success: if both umount and umount -l fail, check /proc/mounts before reporting an error - Make the post-configuration umount non-fatal since no subsequent operations depend on those mounts and reboot cleans them up * Make LockedConfig::dump() atomic via write-to-tmp + rename * Implement devices smart endpoints with real-time speed monitoring Replace frontend UCI manipulation with six backend RPC endpoints (devices.list, devices.update, devices.block, devices.unblock, devices.forget, devices.data-usage). Speed is computed from conntrack byte counters, data usage from nlbwmon, and WiFi detection from hostapd. ARP online check includes DELAY/PROBE states to prevent transient speed drops during neighbor revalidation. * Replace raw getUci call in profiles page with computed from existing data Derive LAN subnet base from the owns_lan profile's gateway_ip instead of making a separate getUci call to read the network UCI config. * guard on theme being undefined * use ubuntu-latest for CI * remove python3-distutils as it is included in python3 * remove config.json from gitignore * add dtc * Fix profile creation error when dns_override is omitted The frontend sends dns_override as optional, omitting it when empty. Without #[serde(default)], serde requires the field to be present, causing a "missing field `dns_override`" deserialization error. * Switch to buildjet runner * Revert to GH runner and remove unused package behind a brokend CDN * feat: implement transition for dynamic form fields * Add IPv6 smart endpoints (LAN/WAN) and enable IPv6 infrastructure Migrate LAN IPv6 config from direct UCI manipulation in the frontend to purpose-built lan.ipv6-get/set and wan.ipv6-get/set RPC endpoints. Profiles now propagate global IPv6 state (RA/DHCPv6/ip6assign) when created or updated. Backend: - Add lan.rs and wan.rs with ipv6-get/set handlers - Extend NetworkInterface and Dhcp UCI types with IPv6 fields - Add IPv6 multicast ping in devices.list for NDP neighbor discovery - Bind daemon to [::] for dual-stack access - Fix wan6 device to @wan alias in firstboot config - Bootstrap default preferences in admin profile setup Frontend: - Replace LanIpv6UciService with smart endpoint calls - Add lanIpv6Get/Set to API contract, live, and mock services - Fix missing await on firstValueFrom in http.service Build: - Enable odhcp6c, odhcpd-ipv6only, kmod-nf-reject6 - Add odhcpd and RA/DHCPv6 defaults to firstboot dhcp config * Suppress expected network errors during service restarts Operations like toggling IPv6 restart network services, briefly dropping connectivity. The in-flight RPC request and FormService polling both fail with status-0 network errors, producing spurious error toasts. Add NetworkRestartService with a time-bounded suppression window. When ActionService.run() is called with restart: true, network errors are treated as success and polling errors are silently skipped until the window expires. Non-network errors (RPC validation, 4xx/5xx) still propagate normally. Also fix a pre-existing bug: move catchError inside switchMap in FormService so a polling error no longer permanently kills the observable chain. * Add LAN IPv4 smart endpoints and migrate frontend from UCI Replace raw UCI get/set with purpose-built lan.ipv4-get and lan.ipv4-set RPC endpoints. When the network block (first two octets) changes, all profile interface IPs and routing rules are automatically updated, and services are restarted in the correct order (network → wifi → dnsmasq). The frontend now uses the smart endpoints, fixes the gateway IP format to X.Y.Z.1 (3rd octet editable, 4th always .1), and handles admin IP changes by redirecting the browser to the new address. Default LAN IP changed from 192.168.1.1 to 192.168.0.1 to match the frontend and API contract. * set validator min routerOctet to zero * Fix WiFi clients losing internet after network block change Replace `network restart` with per-interface ifdown/ifup to avoid disrupting WAN. Run the restart sequence in a background thread so the HTTP response returns before network disruption. Add a WiFi bounce at the end so clients disassociate and get fresh DHCP leases on the new subnet instead of holding stale ones. * Fix devices showing Online+Ethernet after WiFi disconnect Linux keeps neighbor table entries in STALE state indefinitely on small networks (GC only runs when table exceeds gc_thresh1=128 entries). When a WiFi client disconnects, hostapd drops it immediately but the STALE ARP entry persists, causing the device to appear Online with an Ethernet connection (the fallback when not in hostapd). Fix: ping STALE non-WiFi entries concurrently to determine reachability via exit code. Devices that don't respond are marked Offline. WiFi clients skip probing entirely since hostapd is authoritative. Also improve Devices page load time: - Parallelize IPv6 multicast pings (spawn all, wait all — 1s flat instead of N*1s sequential) - Run UCI config parsing concurrently with initial data gathering - Structure handler into phased pipeline where probing overlaps with nlbw/conntrack/lease reads, adding zero net latency * Add WAN smart endpoints and migrate frontend from UCI Backend: add RPC endpoints for WAN IPv4, IPv6, DNS, DDNS, and MAC settings (get/set). Add DdnsService and NetworkDevice types to uciedit. Frontend: replace direct UCI reads/writes with new API calls and delete all WAN uci/ service and mock files. * Add 'Copied' toast to summary component * Fix CLI auth bypass failing for IPv4 connections to IPv6-bound server The server binds to [::]:80 (IPv6), so IPv4 CLI connections from 127.0.0.1 appear as ::ffff:127.0.0.1. Rust's is_loopback() returns false for IPv4-mapped IPv6 addresses, causing all CLI commands to require authentication. Canonicalize the address before checking. * Fix devices resurrecting as Online+Ethernet via lingering IPv6 NDP The previous fix (6e3b493) probed only STALE IPv4 ARP entries, but `ip neigh show` includes IPv6 NDP entries too. After a WiFi device disconnected and its IPv4 entry expired, a lingering STALE IPv6 NDP entry would bypass probing entirely and resurrect the device as Online+Ethernet. Two fixes: probe REACHABLE IPv4 entries for non-WiFi MACs (catches the window before ARP ages to STALE), and treat MACs with only IPv6 neighbor entries as unreachable (prevents NDP ghosts). * Add published ports smart endpoints and migrate frontend from UCI Backend: - New published_ports module with list/set RPC endpoints - List enriches ports with device status (name, IPs, online state) - Set validates inputs, writes firewall redirect/rule sections with retry loop for UCI conflicts, and fire-and-forget firewall restart - Add FirewallRedirect, FirewallRule, DhcpHost typed sections to uciedit - Add PublishedPortNotFound error variant Frontend: - Replace direct UCI reads/writes with publishedPortsList/publishedPortsSet - Delete PublishedPortsUciService and uci/ directory entirely - Update wan/ipv6, lan/ipv6, and device detail to query ApiService directly - Remove manual firewall section parsing and exec-based restarts - Update dialog to work with API types directly * Fix IPv6 port-protection bugs from rebase Fix WAN IPv6 'disabled' mode handler never matching due to 'ddisabled' typo (introduced in cca6b7b). Make LAN IPv6 SLAAC lock hint conditional so it only appears when published ports actually use IPv6. * Add duplicate profile name validation on create and rename * Fix form error alerts displaying [object Object] instead of message text * Add kmod-br-netfilter to install sysctl disabling bridge filtering CONFIG_BRIDGE_NETFILTER is compiled as a kernel built-in, so bridge netfilter is always active. But the sysctl that sets bridge-nf-call-iptables=0 is only installed by the kmod-br-netfilter package. Without the package selected, the kernel default of 1 applies, causing bridged TCP between devices on the same profile to be rejected by fw3 zone rules that don't match bridged frames. * Add inbound VPN server smart endpoints and wire up frontend Backend: new vpn_server module with full CRUD for WireGuard server interfaces and peer management, including key generation (x25519-dalek), client config rendering, and UCI/service orchestration. New wg module for WireGuard key pair utilities. Frontend: replace stubbed profiles and endpoints with live data from smart endpoints. Show profile display names instead of interface names, hide Add when all profiles already have a VPN (the upsert API makes adding a duplicate redundant—just edit the existing one), add peer IP validation (range + uniqueness), and auto-prompt first client creation after adding a new server. * Add FE validator for Profile fullname uniqueness * Add VPN-connected peers to devices list with nullable mac handling Backend (devices.rs): - VPN peers discovered via wg show + UCI peer configs, shown as online with speed/data - Device.mac changed to Option<String> (VPN peers are L3, no MAC) - published_ports.rs: skip MAC-less devices when indexing Frontend: - DeviceFromApi.mac / DeviceTableItem.mac now string | null - All three device tables (online/offline/blocked): @if (item.mac) guards on links and action buttons, {{ item.mac || '-' }} display, track item.mac ?? item.ipv4 - published-ports/service.ts: optional chaining on d.mac?.toUpperCase() (crash fix) - published-ports/dialog.ts: filter out MAC-less devices from port forwarding device picker - devices/service.ts: fallback name 'VPN Device' for MAC-less peers - VPN shield icon added to online table and summary page * Make network restart handlers synchronous and add frontend loading indicators Backend: remove std::thread::spawn from reload_system(), reload_system_and_wifi(), and restart_network_services() so handlers block until the network restart completes. Frontend: remove pauseFor() polling delays (now unnecessary), add restart: true with loading/success messages to all endpoints that trigger a network restart (profiles create/update/delete, inbound VPN set/delete/addPeer/deletePeer), and increase NETWORK_RESTART_TIMEOUT_MS to 30s. * update profiles validator to allow 0 for the third octet * Add outbound VPN client smart endpoints and migrate frontend from UCI Backend: - Add vpn_client module with list/create/update/delete/set-enabled RPC endpoints that parse WireGuard .conf files, manage UCI config, and handle interface lifecycle (ifup/ifdown) - Add per-profile DNS forwarding via dedicated dnsmasq instances so VPN DNS servers resolve .lan locally instead of bypassing dnsmasq with direct DNAT (fixes broken .lan resolution when using VPN DNS) - Add local subnet route to policy routing tables so LAN traffic between devices on the same profile stays local instead of going through the VPN tunnel - Change dnsmasq reload to restart (required for new dnsmasq instances) - Add ProfileDnsmasq typed section and WIREGUARD InterfaceProto variant - Make WgInterface, UciVpnServer, and several profile helpers pub(crate) Frontend: - Replace OutboundUciService (direct UCI read/write) with smart endpoint calls through ApiService (vpnClientList/Create/Update/Delete/SetEnabled) - Delete outbound/uci/service.ts and outbound/uci/mocks.ts - Add duplicate label validation to add/edit VPN dialogs - Wire up used_by profile list display from backend data - Add interfaceNameLength and duplicateName validators * Add VPN chain validation, cycle detection, and endpoint routing Prevent deleting or disabling a VPN that other VPNs chain through. Cascade label renames to dependents. Validate targets exist and won't create routing cycles. Automatically manage static routes (vcr_*) so chained WireGuard endpoints traverse the correct tunnel. Frontend filters target dropdown to cycle-safe options and disables delete when dependents exist. * Add per-profile DNS hijacking and SmartDNS-backed DNS resolution The existing WAN DNS smart endpoint stored servers as plain strings on the network interface and relied on dnsmasq's native forwarding — which couldn't support per-profile DNS or DoH. This replaces that approach with a SmartDNS proxy layer and firewall DNAT hijacking that meets the full requirements. Backend: - Add dns.rs module with SmartDNS config generation, per-profile server groups (port 5300 + vlan_tag), and structured DnsServer type ({address, ssl}) - Add UciSystemDns typed section in /etc/config/startwrt (replaces storing DNS on the network interface) - Rewrite wan.dns-get/dns-set to use startwrt config instead of network interface DNS lists - Add DNS hijacking (firewall DNAT on port 53) and per-profile dnsmasq instances that forward to SmartDNS or VPN DNS - dns-set rewrites dnsmasq/firewall for all profiles so system DNS changes propagate immediately - Profile create/set/delete regenerate SmartDNS config - Add SmartDNS restart to reload_system() and reload_system_and_wifi() Frontend: - Change dns_override type from string[] to DnsServer[] across API types, profiles dialog, and WAN DNS forms - Remove @853 string parsing in favor of structured DnsServer objects - Rename "TLS" label to "Secure (DoH)" to reflect actual protocol - Add DNS field validators to profiles dialog - Fix updateDnsValidators to validate all three server fields Build: - Add smartdns package to openwrt.diffconfig - Add custom SmartDNS init script using our generated config * Disable IPv6 per-profile when outbound VPN lacks IPv6 support Most VPN providers (NordVPN, ExpressVPN, Surfshark, etc.) don't carry IPv6 through their tunnels. When a profile routes through such a VPN, IPv6 traffic would bypass the tunnel and leak via WAN. Add outbound_supports_ipv6() which checks the VPN's WireGuard addresses for IPv6 entries. Profile create, update, and the global IPv6 toggle now gate ip6assign and DHCPv6/RA on this check, so profiles using IPv4-only VPNs automatically get IPv6 disabled on their VLAN interface. * Add outbound VPN enable/disable toggle with profile reset and confirmation dialog * Add SSH keys smart endpoints and migrate frontend from UCI file access Backend ssh_keys module handles list/add/delete via openssh-keys crate with fingerprint-based key identification, duplicate detection, and proper file permissions. Frontend now uses RPC endpoints instead of directly reading/writing authorized_keys. * Add HTTPS with self-signed CA, LuCI reverse proxy, and CORS support Generate a local Root CA and server leaf certificate (ECDSA P-256) at startup, serving HTTPS on port 443 alongside HTTP on port 80. The server cert is auto-renewed when expiring or when the LAN IP changes. A CA wizard on the login page guides users on non-HTTPS connections to download and trust the Root CA. Move uhttpd to localhost:8080 (HTTP only, no TLS) and proxy LuCI requests (/cgi-bin/*, /luci-static/*, /ubus/*) through the axum server, handling redirect chains and cookie forwarding. Update remote access firewall rules to expose port 443 instead of 8080/8443. Add Root CA download to the general settings page and change the advanced settings LuCI link to use the reverse proxy path. * Add cancel/reset support to general settings form * Store WiFi password as plaintext, add per-band SSID broadcasting, and use PSK hot-reload for password-only changes Password storage: replace PBKDF2-SHA1 PMK derivation with plaintext passphrase storage throughout the stack. A PMK is derived from both the passphrase and SSID, so it becomes invalid when the SSID changes — storing plaintext allows SSID changes (including the new -5G suffix) without re-deriving. This also matches OpenWrt's default behavior of storing plaintext passwords in /etc/config/wireless (root-only access), and simplifies the codebase by removing pbkdf2, sha1, and hex dependencies. Updates init, setup, emmc, flash, daemon, startwrt-bake-password (SWRTPMK→SWRTPWD format), and PROPOSAL-init-reflash.md. Fixes documented charset to include lowercase 'i' exclusion (67 chars, ~72.3 bits entropy). Broadcast separately: add broadcastSeparately field to WiFi config so dual-band routers can advertise "{SSID}-5G" on the 5GHz radio while keeping the base SSID on 2.4GHz. Backend detects differing per-radio SSIDs on read and applies the -5G suffix on write. Frontend adds a toggle (visible when band is "Both"), SSID-change confirmation dialog, and a reconnect dialog that polls until the backend is reachable. WiFi restart optimization: replace the boolean vlans_created return with a WifiRestart enum (Full vs PskOnly). Track whether device or interface config actually changed (SSID, channel, enabled, hidden, encryption, key, dynamic_vlan) and only issue a full `wifi` restart when needed; password-only changes use `wifi reload` to avoid disconnecting clients. Adds PartialEq/Eq to WifiChannel for the comparison logic. * Fix SmartDNS stealing port 53 from dnsmasq when no DNS groups are configured Remove the SmartDNS config file instead of writing an empty one so the init script's guard prevents SmartDNS from starting. Without a bind directive, SmartDNS defaults to 0.0.0.0:53 which conflicts with dnsmasq. * Add system.restart smart endpoint and migrate frontend from exec Backend: add system.restart RPC handler that spawns a delayed reboot (same pattern as factory-reset). Frontend: replace generic exec('reboot') call with the smart endpoint, suppress poll errors for 90s during reboot, and poll systemInfo until the device goes down then comes back up so the spinner stays visible for the full reboot cycle. * Add ethernet smart endpoints and migrate frontend from UCI file access Backend: - Redesign ethernet.get/set API contract: return structured Ethernet object with wan_ipv6, wan_port, and ports map instead of flat port list - Extract find_lan_bridge() helper, eliminating duplicated bridge lookup logic across ethernet.rs and profiles.rs - Filter WiFi/phy interfaces from ethernet port listing - Preserve non-ethernet bridge ports (wlan, phy) during ethernet.set - Skip unnecessary bridge device writes when ports haven't changed - Use reload_system_and_wifi() instead of raw Command for service restarts - Change firewall reload to restart for reliable rule application - Add comprehensive test suite (~1000 lines) covering get, set, round-trip, WAN management, WiFi port preservation, and bridge lookup Frontend: - Replace UCI-based EthernetUciService with smart endpoint calls (ethernetGet/ethernetSet) - Delete ethernet/uci/ directory (mocks.ts, service.ts) - Use real ProfileId objects instead of stub profile strings - Add empty-state placeholder for ports table - Network restart is now handled server-side; remove client-side restart uciedit: - Default Token::from_string to single-quoted output for UCI consistency - Only use double-quoting when value contains single quotes - Update all test expectations for new quoting behavior * Replace rcgen with openssl and add intermediate CA to PKI chain Switch certificate generation from rcgen to the openssl crate (vendored) to reduce dependency count and gain finer control over X509 extensions. Introduce an intermediate CA between the root CA and server leaf cert, following standard PKI hierarchy (root signs intermediate, intermediate signs leaf). * Prefer GUA over ULA when selecting a device's IPv6 address * Bounce changed ethernet ports and defer reload to background thread When a port's VLAN assignment changes, connected clients stay on their old DHCP lease and subnet. Bring changed ports down for 2 s (IEEE 802.3 break_link_timer) then back up so link partners re-run DHCP. Move network/firewall reload to a background thread so the RPC response reaches the client before the L2 path switch causes a hang. Drop the wifi/dnsmasq/smartdns restarts — only bridge VLAN config changed, and restarting wifi would lose bridge VLAN 1 entries on recreated interfaces. * Detect wan_ipv6 by interface name instead of device match * Add config backup and restore endpoints with settings UI * Centralize network reconnect handling in ActionService Move reconnection polling and UI out of individual pages into ActionService. Actions with `restart: true` now automatically race against a timeout, poll for network drop, and show a generic ReconnectingDialog. Special cases (LAN IP change, profile gateway change, WiFi SSID change) bypass the generic flow with their own redirect/reconnect logic. - Add ReconnectingDialog component for post-restart reconnection - Simplify NetworkRestartService to boolean suppress/recovered - Remove per-service refreshAndWait() calls from save methods - Handle factory reset and backup restore via restart+reconnect flow * Fix VPN peer reachability with proxy ARP and /32 policy routes When a profile uses an outbound VPN, policy routing's /24 subnet route catches VPN peer IPs, sending locally-generated responses (DNS, HTTP) to the LAN bridge instead of back through the WireGuard tunnel. Add /32 host routes per peer to override via longest-prefix match. LAN devices also fail to reach VPN peers because they ARP directly for IPs behind the WireGuard tunnel. Enable proxy ARP on the profile's bridge VLAN interface so the router answers on behalf of VPN peers. A hotplug script ensures the sysctl persists across reboots since netifd does not honor the UCI proxy_arp option. * Add cross-subnet routes to VPN policy tables for local reachability When a profile uses an outbound VPN, its policy routing table has a default route through the tunnel. Responses from the router's own IP to devices on sibling VLANs matched the source-based ip rule and exited through the VPN instead of routing locally — making the admin gateway IP unreachable from guest profile devices. sync_cross_subnet_routes() adds sibling subnet routes (e.g. 192.168.8.0/24 dev br-lan.101) to each VPN profile's table so local cross-VLAN traffic takes precedence over the VPN default route. * Update all profile-dependent configs when LAN subnet block changes Previously only network interfaces and routing rules were updated. Now also updates policy routes, dnsmasq listen addresses, and DNS-Override firewall redirects to match the new subnet block. * Add activity logging system with RPC endpoints and frontend integration Track user-visible actions (login, profile/device/VPN/backup/SSH key changes, factory reset, etc.) in a JSON log file with list, delete, and clear RPC endpoints. Every mutating handler now records success or failure with a human-readable summary. The frontend activity page consumes the new endpoints. * Add support diagnostics bundle download endpoint and UI Introduces GET /api/diagnostics that collects system logs (logread) and activity history into a tar.gz archive served as a browser download. Wires up the existing "Download Support Diagnostics" button in the advanced settings page to fetch and save the bundle. * Add RPC continuations system, migrate backup/diagnostics/activity Introduce a one-shot continuation mechanism (modeled on start-os) for binary I/O over REST, replacing ad-hoc HTTP endpoints with proper RPC methods that return a GUID for subsequent file transfer. - Add continuations module with TimedResource (per-continuation tokio timeout), Guid newtype, RestHandler returning Result, session-bound kill signals (OpenAuthedContinuations), and cleanup-on-add - Migrate backup create/restore from /api/backup and /api/restore to backup.create and backup.restore RPC + /rest/rpc/{guid} - Migrate diagnostics from /api/diagnostics to diagnostics.create RPC, simplified from tar.gz archive to plain syslog text - Migrate activity log from JSON file to SQLite (rusqlite) - Add CLI handlers for backup download/upload and diagnostics download - Add ServerContext fields for continuations and open_authed_continuations - Update frontend to use RPC + continuation GUIDs for file transfers - Swap tar/flate2/once_cell deps for rusqlite/dirs - Bump default log_size 128→512 and add log_file in firstboot config * Fix conffiles backup to expand directory entries from keep.d OpenWrt's base-files keep.d includes `/etc/config/` (the whole directory), but backup_conffiles() only backed up exact file paths — directory entries were silently skipped. This caused /etc/config/startwrt (and any other config not explicitly listed by a package) to be lost on reflash with "keep settings". * default CliArgs host to http://router.lan/rpc/v1 * Use in-memory SQLite for activity DB in tests The activity LazyLock panics trying to open /etc/startwrt/activity.db which doesn't exist in test environments, poisoning 45 tests. * Silence noisy child process output and tighten default log level Service reload commands (firewall, dnsmasq, network, wifi, etc.) write informational output to stderr, which procd logs as daemon.err and floods the syslog. Add a run_quiet() helper that redirects child stdout/stderr to /dev/null and migrate all ~30 call sites to use it. Also raise the default tracing filter from info to warn (keeping activity=info), add a startwrt-activity prefix with OK/FAIL status to activity log lines for easier logread filtering, and disable Samba NetBIOS (unused). * Mask VPN client config and QR code by default for security * Show LAN Access as 'All' when only one profile exists 'Same profile' is meaningless with a single profile since there are no other profiles to exclude. * Fix disconnected WiFi clients showing as Online Ethernet A REACHABLE IPv6 NDP entry was suppressing the IPv4 ping probe for recently disconnected WiFi clients, so they were never detected as unreachable. Only consider IPv4 REACHABLE entries when deciding whether to skip STALE probes, since IPv6 entries cannot be probed. * Move session storage to /etc/startwrt/ to persist across reboots /var/run/ is a tmpfs cleared on every reboot, causing all sessions to be invalidated. Store sessions in /etc/startwrt/ instead so users stay logged in across router restarts. * Use bridge FDB to detect stale WiFi clients and bind pings to interface Cross-reference the bridge forwarding database with hostapd to catch WiFi clients whose ARP/driver state lingers after disconnection. Bind ping probes to the correct interface to prevent WAN leakage on overlapping subnets. Also probe DELAY/PROBE ARP states alongside STALE. * Remove Samba, GnuTLS, and Chinese locale packages from build Drop samba4, its dependencies (GnuTLS, libgmp, libnettle, libtasn1), wsdd2, audio libs (alsa-lib, fdk-aac, lame-lib), and zh-Hans locale packages. These are SpacemiT K1 target defaults not needed by StartWRT. * feat: bundle used icons * chore: fix spacing * Update web/package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update captive portal IP to match default * pin feeds to 24.10 * Gate serial console setup on baked WiFi password presence Add `startwrt-cli has-baked-password` to check whether the boot image's rootfs contains a password written by startwrt-bake-password. The serial login script now uses this to either show the WiFi setup wizard prompt or fall through to the `manufacture` flow. * Add startwrt-cli verify command for factory QC Checks firmware integrity (squashfs superblock valid on eMMC) and WiFi SSID broadcast (hostapd running, SSID is "StartWRT"). Embeds git hash at build time for firmware version display. * Migrate to OpenWrt 25.12.1 with Armbian 6.18 vendor kernel Switch from SpacemiT vendor kernel 6.6 on OpenWrt 24.10 to Armbian's forward-ported vendor kernel 6.18.19 on OpenWrt 25.12.1. The Armbian kernel (github.com/jmontleon/linux-bianbu, branch linux-6.18.y) provides full BPI-F3 hardware support: SD card, dual GbE, USB 3.0, PCIe, PMIC. Build system: - Submodule based on upstream OpenWrt v25.12.1 (not SpacemiT fork) - Kernel via CONFIG_KERNEL_GIT_CLONE_URI (git-clone mechanism) - Feeds updated to openwrt-25.12 branches, written to feeds.conf (gitignored) instead of overwriting tracked feeds.conf.default - Removed spacemit_openwrt_feeds dependency - Simplified openwrt-setup.sh and stage-files.sh - Updated image name and path for bananapi-f3 target - Diffconfig: removed binutils override, updated device name, disabled kmod-crypto-sha512 (forced built-in by DRBG_HMAC) Kernel compat patches carried in submodule: - CFG80211_HEADERS for mac80211 backport wireless support - libcurve25519-generic compat module (vendor naming) - sha512_generic module rename (vendor naming) - SOCK_ASYNC compat for mac80211 on kernel 6.18 - NETFILTER_XTABLES_LEGACY for iptables support on 6.18 * Fix APK package feeds by aligning spacemit CPU_TYPE with upstream * Update openwrt submodule: build F2FS into kernel Build F2FS as built-in (=y) instead of a module so it is available at boot for mounting the root filesystem. * Update openwrt submodule: build 8021Q VLAN support into kernel Without this, netifd cannot create bridge VLAN sub-interfaces (br-lan.1) during early boot, breaking security profile isolation. * Update openwrt submodule: enable conntrack procfs for speed tracking * updated openwrt submodule to 25.12.2 * Add per-profile WAN schedules and system timezone support Profiles can now have scheduled WAN-block windows (e.g. block internet for Kids profile on school nights). Backend stores schedule data in UCI, generates crontab entries to toggle firewall REJECT rules, and evaluates current state at boot and after firewall reloads. System timezone is configurable via settings and auto-detected from the browser during initial setup. The backend writes the POSIX TZ string to /etc/TZ so cron and all time functions use local time — critical for schedule accuracy. * Autofocus Flash button in setup wizard so Enter confirms * Remove device blocking feature * Include LAN IPv6 address in server certificate SAN The server TLS certificate previously only included the LAN IPv4 address. This adds the LAN IPv6 (ULA) address as a Subject Alternative Name so browsers don't show certificate warnings when accessing the router over IPv6. The cert is now regenerated whenever the IPv6 configuration changes, and on startup if the SAN doesn't match. * Add local auth cookie for on-device CLI authentication The daemon generates a random token at startup and writes it to /run/startwrt/rpc.authcookie. CLI commands running on the router read this file and present it as a cookie, bypassing session auth. This replaces the previous loopback-only bypass with a cookie-based mechanism that works over any local transport. * Unify password charset and add server-side WiFi password generation Move the ambiguity-safe character set to a shared constant in lib.rs (PASSWORD_CHARS) so init.rs and startwrt-bake-password stay in sync. Add a PASSWORD_CHARS_ALNUM subset and generate_password() using rejection sampling. Expose wifi.generate-password RPC endpoint so the frontend generates passwords server-side with proper randomness instead of using Math.random in the browser. Charset changes: re-adds lowercase i/o, drops - and _. Adds ?. * Switch conntrack from procfs to netlink and improve nlbwmon config Replace /proc/net/nf_conntrack reads with `conntrack -L` (netlink API), allowing NF_CONNTRACK_PROCFS to be disabled in the kernel. Add the conntrack-tools package to the build. Fix nlbwmon configuration: move the database to persistent storage (/etc/nlbwmon/data), reduce the commit interval from 24h to 1h to limit data loss on unexpected reboots, and add all RFC 1918 subnets so it correctly accounts for LAN traffic across all security profile VLANs. * Flush ARP and DHCP lease when forgetting a device Previously, forgetting a device only reloaded dnsmasq, so the device would linger in the device list until its lease expired. Now we delete ARP neighbor entries and remove the lease line (stopping dnsmasq first to avoid it overwriting the file), so the device disappears immediately. * Add split/full tunnel routing option for inbound VPN peers Allow VPN clients to choose between routing all traffic (LAN + WAN) through the tunnel or only LAN traffic. Stored as a UCI flag per peer and reflected in generated WireGuard client configs. Also fixes client address mask from /24 to /32 and adds the missing DNS line to the frontend's config display for user-supplied-key peers. * Warn before deleting an outbound VPN that is in use by profiles * Warn before deleting inbound VPN when IP/subnet changes break peers Changing a profile's subnet or the router's LAN IP invalidates WireGuard peer allowed-IPs, silently breaking VPN clients. Add a guard that blocks the change unless force is set, with full VPN server teardown on force. The frontend catches the error, shows a confirmation dialog, and retries with force when the user accepts. * update tests to match changes in implementation code * Remove WPS * Fix "Manage clients" link to use correct route for inbound VPN The "Manage clients" dropdown option was navigating to /inbound/<port>, which didn't match any route. Changed it to use routerLink="client" with a port query param, matching the existing navigation pattern. Also removed the now-redundant link wrapper from the server label column. * Restructure documentation into ARCHITECTURE/CONTRIBUTING/README per component CLAUDE.md files were doing triple duty as architecture docs, contributing guides, and AI assistant references. Split them into purpose-specific files so each serves one audience: ARCHITECTURE.md for system design, CONTRIBUTING.md for developer onboarding, README.md for orientation, and CLAUDE.md as a slim quick-reference for AI tooling. Moves init-reflash proposal into docs/. * Update help text: add backup/schedule pages, fix DNS terminology, improve copy Add help content for backup settings, timezone, security certificate, WiFi enable toggle, and inbound VPN routing options. Rename DNS over TLS to DoH throughout. Tighten outbound VPN and profiles copy. Fix aside help lookup for dynamic profile schedule routes. * chore: bump Taiga to 5.0 * Fix DNAT reply routing for VPN-routed profiles with port forwards When a profile uses VPN policy routing, DNAT reply packets (from port forwards) were being captured by the source-based ip rules and sent through the VPN tunnel instead of back to the original client. Add a per-profile mangle MARK rule (conntrack --ctstate DNAT) and a shared ip rule (dnat_return) that routes fwmark 0x80 traffic via the main table. Assign explicit priorities (100 for DNAT return, 200 for VPN source routing) so the mark rule is always evaluated first. Also extend NetworkRule with optional src/mark/priority fields and FirewallRule with set_mark/extra fields to support mangle MARK targets. * Add static IPv6 LAN prefix delegation and harden IPv6 port forwarding - Add lan_prefix field to WAN IPv6 static mode for configuring the LAN delegation prefix (odhcpd ip6prefix), plumbed through API contract, backend, and frontend form - Skip IPv6 firewall rules for devices with only ULA addresses instead of blocking the entire port forward save; show warning in the dialog - Remove ip6assign from profile interfaces — only the admin LAN gets the delegated prefix until multi-prefix delegation is implemented - Enable kmod-ip6tables in diffconfig for IPv6 firewall rule support * Fix IPv6 LAN prefix delegation and clean up published-ports tests - lan.rs: skip LAN interface when stripping ip6assign so it retains its prefix delegation (the loop was incorrectly clearing it along with profile interfaces) - published_ports.rs: switch two tests to ipv4-only to match their actual assertions (v6 rule coverage exists elsewhere) - wan.rs: add lan_prefix field to all test request structs after the field was introduced in e7abf24 * Fix mock API, validation guards, and miscellaneous UI bugs (#33) * Guard against subnet changes when DHCP static hosts exist Add backend validation (DhcpStaticHostsInSubnet error) that rejects LAN IP or profile subnet changes when devices have static IP reservations in the affected range. On the frontend, proactively disable the Save button with a hint when static IPs are detected, and surface non-VPN backend errors as alert notifications instead of silently swallowing them. Update mock API to auto-reserve static IPs on port-forward enable. * Reserve static DHCP lease when re-enabling a published port Creating or editing a published port already reserves a static IP via the dialog flow, but toggling a disabled port back on in the table bypassed that — the device could lose its dynamic lease and break the forward. Call reserveDeviceIps from toggleEnabled on the frontend, and add a backend fallback that auto-creates missing DHCP reservations during published_ports.set for any enabled IPv4 port. * Refresh system info after saving general preferences The UI wasn't reflecting updated timezone/hostname after saving. Add SystemService.refresh() and call it after the preferences save. * Add IPv6 static reservation support to device detail and published ports * Validate unique subnet when creating or editing a profile * Filter radio band lookup to only enabled radios Prevents a disabled radio from shadowing the active one when populating the wifi settings form. * Overhaul mock API for correctness and interactivity Consolidate scattered mock device data into unified MockDeviceDef definitions with dynamic IP computation from profile gateways. Add cascade effects for profile rename/delete, VPN client delete/disable, and LAN IP changes. Log activity entries for all mutating operations. Fix WiFi reconnect dialog to complete dialog instead of reloading in mock mode, refresh WiFi state after reconnect, and check device IPv6 reservations instead of published ports for SLAAC lock. * Adopt start-os conventions and eliminate blocking I/O in async contexts (#34) * Adopt start-os conventions and eliminate blocking I/O in async contexts Aligns start-wrt's backend with start-os patterns by importing shared utilities from the startos crate and restructuring I/O to never block the async runtime. Foundation: - Rename startwrt-ctrl crate to startwrt-core (lib name startwrt) - Add start-os as a path dependency for direct reuse of its utilities - New error.rs modeled after startos::Error: #[repr(i32)] ErrorKind with 22 generic variants matching startos codes + 23 domain-specific variants at 1000+; Error { source, kind, info }; ResultExt/OptionExt - New prelude.rs with eyre!, instrument, Error, ErrorKind, etc. - From<crate::ErrorKind> for startos::ErrorKind and From<startos::Error> for crate::Error for seamless interop Imports from startos (code removed from start-wrt): - startos::util::Invoke replaces local Invoke impl (~280 lines) - startos::util::serde::{HandlerExtSerde, DisplaySerializable} replaces local versions (~130 lines) - startos::util::serde::StdinDeserializable used under the hood for multi-format (JSON/YAML/TOML/CBOR) stdin deserialization - startos::util::io::AtomicFile / write_file_atomic replace manual temp+rename patterns in ssl.rs, auth.rs, emmc.rs, setup.rs, backup.rs - startos::util::new_guid() replaces custom 128-bit hex Guid Error migration: - All ~30 handler modules converted from thiserror-based ErrorKind with fields to Error::new(eyre!("msg with {field}"), ErrorKind::Variant) - 300+ ErrorKind::Unknown usages replaced with specific kinds - RPC errors now serialize with numeric code + structured details (ErrorData) No blocking I/O on async threads: - Zero spawn_blocking wrappers around std::process::Command - Zero std::fs::* calls in async functions (all replaced with tokio::fs) - uciedit made async: parse_all, dump_all, Config::parse, Config::dump, LockedConfig::* all take tokio::fs::File; flock runs on blocking pool - uciedit adds read_all/ConfigBytes::parse + Configs::freeze/write_all splits so callers can avoid holding !Send Arena across awaits (used by init::configure_wifi so flash can spawn) - run_setup_flash runs on a dedicated thread with its own current_thread runtime (its future is !Send via transitive Arena) - All handlers that hold Arena across awaits registered via from_fn_async_local - files.rs handlers (get/set/dir_get) async; flock via spawn_blocking - CLI runtime switched from current_thread to multi_thread(1 worker) Tracing: - #[instrument(skip_all)] on every RPC handler function Tests: - 343 tests pass (326 ctrl + 17 uciedit) - Tests converted to #[tokio::test] where they call async handlers * Vendor start-os as a submodule Move the start-os path dependency from a sibling checkout (../../../start-os/core) to a submodule pinned at ../../start-os/core so fresh clones of start-wrt build without requiring a parallel start-os checkout. Track master on the submodule. start-os's build.rs reads build/env/GIT_HASH.txt, which isn't present in a fresh submodule checkout; build-rust.sh now generates it via start-os/build/env/check-git-hash.sh before invoking cargo. * Fixes/refactor regressions (#35) * Install rustls ring crypto provider explicitly in ctrld The start-os dep transitively enables rustls's `aws-lc-rs` feature via lettre, leaving rustls compiled with both `ring` and `aws-lc-rs`. Its auto-select then panics at runtime, so install the ring provider before anything touches rustls. * Fix service-reload hangs by redirecting child stdio to /dev/null The start-os Invoke trait pipes stdout/stderr and awaits wait_with_output(), which hangs indefinitely when init.d scripts spawn long-lived grandchildren (udhcpc, hotplug handlers) that inherit the pipe fds. Replace every init-script / ifup / wifi invocation in ctrl with a new run_quiet_async helper that nulls stdio and waits only on the direct child. * Pin RISC-V C builds to K1 ISA and fold in pending fixup work Build pipeline: - aws-lc-sys's cc_builder (selected over cmake because pregenerated bindings exist for riscv64gc-musl) only injects `-Wp,-U_FORTIFY_SOURCE` into its jitter-entropy sub-build when CFLAGS_<target> is present. `zig cc` rejects `-Wp,` passthrough, which broke the build. - Bake -mcpu into zigcc-k1.sh / zigcxx-k1.sh wrappers instead of exporting CFLAGS/CXXFLAGS, drop the dead AWS_LC_SYS_CMAKE_TOOLCHAIN_FILE env var (cmake never runs, so the toolchain file was never consulted), and verify the output binary contains no RVA23-only instructions via verify-isa.sh. - .DELETE_ON_ERROR: in Makefile avoids leaving truncated targets behind when a rule fails mid-write. Runtime: - setup.rs: replace tx.blocking_send with tx.try_send inside the flash progress callback so the async executor isn't blocked while the channel is full. Other: - Bump start-os submodule to 0.4.0-beta.6; propagate to backend/Cargo.lock and a peer-dep marker in web/package-lock.json. - startwrt-bake-password: allow a blank prompt to generate a random password matching the sticker rules. --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> --------- Co-authored-by: Dominion5254 <musashidisciple@proton.me> * fix: vpn addition dialog layout (#38) * Add release workflow, gitHash build stamping, and About section (#37) Install binutils-riscv64-linux-gnu in the CI build environment — previously missing, which was failing image builds on hosted runners. Stand up a GitHub Release job that publishes image artifacts for tag pushes and manual `deploy=release` dispatches, bumping the project to 0.1.0-beta.1. Gitignore web/config.json and generate it from config-sample.json at build time: `build/env/check-git-hash.sh` writes GIT_HASH.txt whenever git state changes, `web/update-config.sh` stamps it into config.json and forces useMocks=false for production, and `web/build-config.js` runs before `npm start`/`ng build` so dev builds also carry a hash. Surface the hash in Settings > General as a new About section (version + short gitHash) via a new GIT_HASH injection token. * build: chown backend/target on exit, clean root-owned files via docker (#39) Replace the post-build ownership fix in build-rust.sh with an EXIT trap so interrupted or failed Docker builds also leave backend/target owned by the host user. As a backstop for already-broken trees, make clean detects root-owned files and delegates removal to the cargo-zigbuild container. * refactor(devices): replace local cache with refreshAndWait DevicesService no longer maintains a parallel `devices` array; update and forget now refetch from the API after mutating, keeping the form state as the single source of truth. Drop unused getDevice/ getDevicesByStatus helpers. Editing a device now shows a spinner and success toast covering both the update call and the subsequent refresh. Mock API tracks device defs in a mutable instance field so forget actually removes them, and adds an offline mock device. * feat(wan/ipv6): show status badge in summary Display IPv6 mode as a badge (Disabled / Enabled + mode label) at the top of the WAN IPv6 summary so the current state is visible without inspecting individual fields. * feat: align UI with Start9 design guidelines * chore: address comment * refactor(ctrl): delegate cert generation to start-os ssl primitives Drop start-wrt's hand-rolled X509Builder code and reuse start-os's make_root_cert / make_int_cert / make_leaf_cert via the new CertBranding hook (start-os PR #3200), wired with a "StartWRT" CertBranding so the issued CN/OU strings match this product. Net diff in ssl.rs: -239 lines. Renewal threshold and serial-number generation now live in start-os too; start-wrt only retains the filesystem layout, LAN address discovery, and TLS config wiring. Also bumps the start-os submodule to master so the CertBranding API is available on the pinned commit. * refactor(profiles): inline WAN schedule editor into profile dialog (#44) Replace the standalone /profiles/:interface/schedule route with an embedded ProfileScheduleEditor component inside the profile add/edit dialog. Schedule windows are now loaded alongside the profile, edited in-place, and persisted as part of the same save flow. - Rename schedule/index.ts -> schedule/editor.ts and convert it from a routed page into a model()-based component - Delete schedule/service.ts; profileScheduleGet/Set are called directly from the profiles route and service - For admin-IP changes, write the schedule before the IP change so it survives the redirect to the new gateway - Drop the "WAN Schedule" row action and the dynamic-route help-path normalization in Aside; fold schedule help into the dialog help entry * Misc fixes: data-usage daily fan-out, session-expiry redirect, ethernet spinner hang (#43) * ethernet: align post-set reloads with backend conventions Drop the tokio::spawn wrapper around network/firewall reload — every other module (lan, profiles, dns, wan, wifi, system) awaits init.d calls inline after UCI writes. Spawning only papered over disconnects for users reassigning their own port; the unaffected case now gets a real success/failure response. Also switch `firewall restart` to `firewall reload` per backend/CLAUDE.md, preserving conntrack since only L2 changed. * devices: switch data-usage to daily nlbwmon fan-out, drop intra-day period The previous data_usage implementation called `nlbw -c json -g mac,interval` with a single start-date, which doesn't return per-day points the chart needs and silently produced empty results when archives were missing. Replace it with a per-day fan-out: - Fetch `nlbw -c list` once to learn which YYYY-MM-DD archives are retained, then run `nlbw -c json -g mac -t <date>` for each day in the requested window (8-way `buffer_unordered`). Days not in the archive set are zero-filled, so the series is always dense and oldest-first. - Drop the `Day` period — nlbwmon archives are daily, so intra-day points were never real. Periods are now week (7d), month (30d), and 3months (90d). - Add `ymd_to_days` / `parse_ymd_to_days` / `lookup_mac_bytes` helpers with unit tests covering round-trip dates, malformed input, and missing MACs. - Persist `/etc/nlbwmon/data/` across sysupgrade so history survives updates. Frontend: - Remove the `'day'` period from the type, dropdown, and mocks. - Render an empty-state message when every point is zero (real "no traffic" signal now that the backend zero-fills). - Render an error notification with a Retry button when the RPC fails. - Handle the single-point case as a flat segment instead of bailing. - Rotate weekday labels so the rightmost label is today. - Mock data_usage at the RPC level instead of faking the nlbw shell call, removing the period-from-date-range guessing. * auth: redirect on session expiry, mirroring start-os setUnverified The RPC error-34 handler only cleared the authenticated signal, so an expired session left the user on the protected route until they clicked something. Move the (clear signal + navigate) pair into AuthService.setUnverified() — matching start-os's auth.service — and call it from both the header logout and the RPC 401 path so session expiry actually redirects. * ethernet: fix spinner hang on profile change The pre-existing spinner hang lives in the FE poll loop, not the BE. pollUntilSettled awaits systemInfo() with no per-request timeout, so a poll wedged on a half-broken connection blocks the for-loop and the loading subscription never unsubscribes. Wrap each poll in a 5s Promise.race with a code: 0 throw so a wedged request transitions into the reconnecting dialog, which already polls concurrently and closes once the daemon recovers. 15ed46e tried to fix this on the BE by awaiting the reload sequence inline. That regressed badly for ethernet specifically — set_config rewrites every bridge-vlan on br-lan, briefly dropping wlan0's VLAN 1 membership, so the inline response rode the disrupted path. Revert to the spawned reload and `firewall restart`. Update the comment to flag why this endpoint diverges from the inline pattern used elsewhere. * chore: refactor schedules and other minor fixes * chore: fix * docs: distill CLAUDE.mds to AI-only; add Documentation section to CONTRIBUTING.mds Move framework intros, commands, and Key Files tables out of CLAUDE.mds (they duplicated content already in ARCHITECTURE/CONTRIBUTING). CLAUDE.md keeps only repo-specific gotchas: openwrt/ submodule warning, hours-long make image, no test framework wired in web/, etc. Add bulleted Documentation section near the top of CONTRIBUTING.md with the explicit doc-sync mandate. Workflow: skip OpenWrt image build on doc-only changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: build only on tags and dispatch; private-repo minutes were burning quota Master pushes and PRs were triggering ~5h OpenWrt builds (~29k min in May alone), exhausting the org's free Actions pool. Triggers preserved as comments inline to restore once the repo is published. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(wifi): source password from EEPROM, drop /key_backup partition (#47) * feat: source WiFi password from EEPROM tag 0x2F, drop /key_backup partition The on-board I²C EEPROM (24c02 at bus 2 / 0x50) is now the canonical store for the per-device WiFi PMK, programmed by the hardware vendor during manufacture as an ONIE TLV record (tag 0x2F, 12 ASCII bytes). UCI remains the live source of truth: restore_wifi_if_needed only consults EEPROM when /etc/config/wireless has no key on the AP interface — first boot or after a factory reset wipes the overlay. No on-device init flow is required for a vendor-programmed board. This removes the eMMC /key_backup partition and the SD-card baked-password mechanism (SWRTPWD magic), along with the startwrt-cli init / manufacture / has-baked-password subcommands and the serial console dispatcher logic that drove them. The setup wizard's flash path no longer writes /etc/config/wireless into the new overlay — the post-reboot eMMC daemon populates it from EEPROM instead. For boards the vendor never programmed (DIY installs, corrupt blobs), the AP simply doesn't come up and the operator runs \`startwrt-cli set-wifi-password [--manual]\` over ethernet/serial to provision one into UCI. Adds i2c-tools / coreutils-timeout / python3 to the OpenWrt config for EEPROM diagnostics. Removes /etc/config/wireless from CONFFILES_EXCLUDE so user VLAN/profile config is preserved across Update flashes. * feat(wifi): validate PSK length, auto-enable radios on first admin password - Reject PSK < 8 or > 63 chars at the API boundary so hostapd doesn't silently refuse to start the AP. - On first admin-password add, flip factory-disabled + hidden radios to enabled + broadcasting. Without this, a fresh device with an unprovisioned EEPROM tag 0x2F leaves wireless sections disabled, so setting a password would write the key but broadcast no SSID. Only fires when no AP iface yet has a key; explicit toggles are respected on subsequent edits. - Default firstboot SSID: OpenWrt -> StartWRT. * refactor(ctrl): replace axum-server with start-os WebServer + TlsListener (#46) axum-server's accept loop had no defense against connection accumulation (half-open TCP sockets, silently-dead HTTP/2 streams, transient accept errors), pushing the daemon toward fd/slot exhaustion with no recovery path. start-os already solves this in `core::net::web_server::WebServer` and `core::net::tls::TlsListener`; reuse those primitives instead of hand-rolling a hyper-util loop. WebServer provides: - Tuned TCP keepalive on every accepted socket (60s idle + 6×10s probes ≈ 2 min half-open detection, via the shared `default_keepalive` helper landed in start-os #3213) - HTTP/2 PING keepalives (25s interval, 300s timeout) - Accept retry with backoff on transient errors (EMFILE/ENFILE) - GracefulShutdown connection tracking - RFC 8441 extended CONNECT (enable_connect_protocol) for h2 WebSocket upgrades TlsListener adds 5s ClientHello + 15s full-handshake timeouts and runs each handshake in a per-connection task so a stalled client cannot block accept. Cert hot-reload is now an `Arc<ArcSwap<TlsMaterials>>` consulted on each handshake; `regenerate_server_cert` reloads the on-disk PEMs and swaps in a fresh value so LAN IP / IPv6 changes take effect without a daemon restart, while existing connections keep their original cert until they close. `/api/logs` is registered with `any` (not `get`) so HTTP/2 CONNECT requests reach the WebSocket extractor — required because WebServer serves h2 by default. Auth middleware reads `TcpMetadata` from request extensions instead of axum's `ConnectInfo<SocketAddr>` — same data, different plumbing. Frontend logs view: open the live WebSocket synchronously in the constructor (was deferred until after the snapshot RPC, which silently dropped the socket on quick teardown) and stop discarding the first N live entries — the snapshot and `logread -f` stream don't actually overlap. Bumps the start-os submodule to master. Replaces #40. * rebase submodule onto 25.12.3 (#49) * bump deps * feat(profiles): restructure security-profile dialog; validation, blackout/DNS/outbound polish - Group the profile dialog into General / LAN / DNS / WAN-Internet sections; constrain the Name field and align the Subnet octet group and the Outbound/DNS controls. - Replace the "use custom DNS" toggle with an "Inherit from system" / "Custom" radio; replace Outbound Routing's WAN-or-VPN select with a "Direct" / "VPN" radio plus a conditional VPN-client picker (the VPN option is disabled when no clients exist). - Rename the WAN schedule to "Blackout times" everywhere (dialog, help, and the shared Add/Edit Blackout Window dialog); disable the blackout section when WAN access is "None" without dropping its windows. - Stop disabling Save: surface inline errors instead. Require a profile selection for LAN whitelist, at least one IP/CIDR for WAN whitelist/blacklist, and keep the subnet-locked check when devices have static reservations. - Show a 15-minute quick-pick dropdown on the blackout window time inputs (also used by the Wi-Fi blackout schedule). - Fix the schedule grid's uneven inter-day column gap. - Register the @tui icons that were referenced but unregistered (hard-drive-upload, activity, external-link, inbox, repeat); the Backup -> Restore button now uses @tui.hard-drive-upload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: change profile modal design and few other things * Add signed OTA firmware updates (#53) * Port signed OTA update flow from feat/ota-update Manually port the OTA update feature from the (working) feat/ota-update branch onto a fresh branch off master, adapted to master's evolved shape. Diff base for the port is f5af239..feat/ota-update — that's the precise OTA-only delta. Cherry-picking against master would have dragged in the start-os WebServer/TlsListener refactor and other master-evolution noise unrelated to OTA. Backend: - New modules: progress, update, registry/{mod,asset,device_info,os,signer}, sign/{mod,ed25519,commitment}. Copied verbatim — they compile against master after a small Error::other shim in error.rs that wraps Error::new(eyre!(...), ErrorKind::Unknown). - continuations.rs: new WebSocketFuture + WebSocketHandler types, RpcContinuation enum-with-variants conversion, Guid::from_str. - lib.rs: register the four new modules; add signing_key field to ServerContext. - system.rs: real async newer_versions handler (replacing master's stub), semver comparison helpers, new 'update' subcommand. Both update and newer-versions registered with .with_call_remote::<CliContext>() so they're reachable via startwrt-cli, not just the daemon UI path. - daemon.rs: /ws/rpc/{guid} WebSocket route. Registered with any() not get() so HTTP/2 CONNECT (RFC 8441 extended CONNECT) reaches the upgrade extractor — same reason /api/logs uses any(). - error.rs: Error::other(msg) convenience constructor. - Cargo.toml: ed25519-dalek, blake3, url, der, pkcs8, futures-util, form_urlencoded, rand_core_06; reqwest gets +json +stream features. Frontend: - system.service.ts: full rewrite with updating/updateProgress/rebooting signals, WebSocket subscription, post-reboot polling. - api.service.ts + live/mock-api.service.ts: systemUpdate() abstract and impls; FullProgress/NamedProgress/Progress wire types. - settings/general/index.ts: update banner with three-state template (progress / rebooting / button-with-confirm). API_CONTRACT.md: system.update + system.newer-versions + /ws/rpc/{guid}. openwrt submodule: bump to start9/25.12.3-ota with two commits on top of 25.12.3: - spacemit: build sysupgrade image for BPI-F3 (reapplied from 8235008779 which lived on the abandoned 25.12.2 branch) - spacemit: declare SUPPORTED_DEVICES so sysupgrade metadata_check accepts OTAs ('spacemit,k1-x' + 'bananapi,bpi-f3' alongside 'bananapi-f3' to match what board_detect reports) Makefile: image target now produces both pack/sdcard.img AND sysupgrade.img.gz ($(OPENWRT_IMAGES) with grouped target rule). * Remove unused device signing key infrastructure; bump to beta.2 The port from feat/ota-update brought along a per-device Ed25519 key plumbed through ServerContext.signing_key, persisted at /etc/startwrt/device_key.pem (+ /etc/sysupgrade.conf entry to survive flashes), and attached as X-StartOS-Auth-Sig on registry RPC requests. This is dead end-to-end on this codebase: API_CONTRACT.md doesn't document any auth-sig header, the registry doesn't verify it, and firmware integrity is enforced by an independent path (Blake3 commitment + asset-side signers via RegistryAsset::validate). Remove the cruft to shed ~150 LOC of glue, the on-disk key file, the sysupgrade.conf entry, transitive trait methods, and 3 of 5 sign-module tests. What stays untouched (the real security primitives, all with active callers): Blake3Commitment, Digestable, AnyVerifyingKey, AnySignature, AnyDigest, AnyScheme, SIG_CONTEXT, AcceptSigners, RegistryAsset::validate + all_signers, and the verify()/verify_commitment() methods on SignatureScheme. Specifically removed: - update.rs: DEVICE_KEY_PATH, load_signing_key, generate_signing_key, ensure_sysupgrade_conf_entry; signing_key parameter from fetch_newer_versions. - lib.rs: signing_key field on ServerContext (struct + Default). - daemon.rs: load/generate bootstrap block at startup. - system.rs: ctx.signing_key.as_ref() arg in newer_versions handler. - registry/mod.rs: AUTH_SIG_HEADER, SignatureHeader struct + sign/ to_header_value impls, signing_key parameter on call_registry_rpc, the X-StartOS-Auth-Sig header injection block. - sign/mod.rs: AnySigningKey enum + all impls (FromStr, Display, Serialize, Deserialize, scheme, verifying_key); SignatureScheme::SigningKey associated type + sign() + sign_commitment() trait methods; AnyScheme::sign() impl. Three tests deleted (test_sign_and_verify_commitment, test_signing_key_pem_roundtrip, test_signature_pem_roundtrip). Two surviving tests rewritten to derive AnyVerifyingKey via raw ed25519_dalek without the wrapper. - sign/ed25519.rs: Ed25519::sign impl + SigningKey associated type. - sign/commitment.rs: RequestCommitment struct + impls (from_body, to_query_string, from_query, Digestable for RequestCommitment). Also bumps Cargo.toml version to 0.1.0-beta.2 to mark the cleaned post-port state. * Harden K1 OTA path: semver compare, no vector codegen Three independent fixes on the OTA update path: - Version comparison now uses the `semver` crate instead of a `(major, minor, patch)` tuple. The tuple compare stripped the pre-release suffix, collapsing every `0.1.0-beta.N` to `0.1.0` — so an OTA between two betas never registered as "newer". semver honours pre-release precedence (`beta.3 < beta.4 < 0.1.0`). - Drop `+v` (RISC-V Vector) from RUSTFLAGS and the zigcc/zigcxx `-mcpu` strings. K1 implements V 1.0 but traps on misaligned vector-element accesses the Bianbu 6.6 kernel doesn't emulate, so auto-vectorised code (blake3, memcpy, TLS) SIGBUSes with no Rust panic. verify-isa.sh now also fails the build on any `vset*vl*`. - Bump openwrt submodule for "rework K1 sysupgrade to write partitions in place". * Finish OTA update flow: progress fixes, boot confirmation, UI dialog Backend: - progress.rs: fix PhaseProgressTrackerHandle::complete() to flush remaining phase weight. Pre-assigning `contributed` made update_overall's change check skip the contribution, so phases that only start+complete (verify/apply) never counted toward overall. Add tests covering flush-on-complete and partial progress. - update.rs: call download_phase.start() before set_units/set_total — those are no-ops on a NotStarted phase, and start() reset them to None. Add a pending-update marker (/etc/startwrt/pending-update): written before sysupgrade, cleared if sysupgrade returns (failure), and confirmed on the next boot. Log update apply/success/failure to the Activity log. - daemon.rs: check the pending-update marker on normal-mode startup so a completed update is recorded once the new firmware is up. - system.rs: sort newer_versions() by semver precedence. Map iteration was lexicographic, mis-ranking multi-digit pre-releases (beta.10 before beta.9) while the frontend treats the last element as newest. - stage-files.sh: add the pending-update marker to keep.d so it survives the sysupgrade overlay wipe. Frontend: - Add UpdateProgressDialog: a blocking dialog that owns the update lifecycle (kicks off startUpdate, shows a spinner through update and reboot, self-closes on reconnect or start failure). Replaces the inline progress block in the general settings route. - system.service.ts: distinguish a clean update failure (no reboot, surface a toast) from a success reboot; suppress NetworkRestart poll errors for the update window; track whether the device actually went offline to tell a real reboot from a pre-reboot failure, and send the user to login after a confirmed reboot. * build: use npm ci for web build; revert package-lock.json churn The web build recipe ran `npm install`, which is free to rewrite package-lock.json — reconciling it against the registry and re-normalizing the lockfile format across npm versions. Commit 9fb067c carried 89 lines of exactly that churn (peer/optional flag normalization, re-added transitive optional deps) with no matching package.json change, so none of it was an intentional dependency update. - Makefile: switch the $(WEB_DIST) recipe from `npm install` to `npm ci`. `npm ci` installs strictly from the lockfile, never writes to it, and fails loudly if package.json and the lock drift instead of silently rewriting it. Mirrors start-os, which uses `npm ci` for its web build. - web/package-lock.json: revert to the pre-9fb067c state. The file is now byte-identical to master, so feat/ota-port carries zero net lockfile change. * bump start-wrt version in package.json and package-lock.json * build(web): restore optional/peer transitives in package-lock.json 47cbed4 reverted the lockfile to master's pre-9fb067c state, calling the 89 lines 9fb067c added unintentional churn. They weren't: master's lockfile was generated by an older npm that didn't materialize optional/peer-resolved transitives, while npm 10+ does. 9fb067c's `npm install` had correctly captured `@emnapi/core`, `@emnapi/runtime`, and `@types/semver` as peers of already-locked `@emnapi/wasi-threads` and `ng-morph`. `npm ci` (kept from 47cbed4) fails closed on the older-shape lock under any modern npm. Restore the 9fb067c shape, carrying the 0.1.0-beta.2 bump from 966f042 forward. * web(update-dialog): apply PR review cleanups - Replace .update-dialog wrapper with :host styling - Use global .g-secondary utility class for hint color - Swap <p> tags for <div> and drop the margin reset - Remove redundant `closed` latch; completeWith() synchronously destroys the dialog and tears down the effect Addresses review comments #4-#6 and #8 on PR #53. i18n/DialogService threads (#1-#3, #7) deferred to a follow-up; start-wrt has no i18n dictionaries or @start9labs/shared dependency yet. * web(update-dialog): use <small> for secondary hint Drop the <div> wrappers around dialog text — the host is already display: flex, so children lay out directly. Replace the .hint div with a native <small class="g-secondary">, which natively shrinks the font size and makes the custom .hint rule dead CSS. * Route IPv6 through outbound VPNs + migrate fw3→fw4/nftables (#54) * feat(vpn/ipv6): route IPv6 through outbound VPNs [UNTESTED] Profiles whose outbound is a v6-capable WireGuard VPN now route IPv6 the same way they route IPv4, instead of leaking it around the tunnel. Backend (ctrl/profiles.rs): - rewrite_routing emits a v6 leg gated on is_ipv6_enabled() AND the outbound VPN actually carrying v6 (outbound_supports_ipv6). Three new sections per profile: prt6_<iface> (::/0 in the per-VLAN table), prl6_<iface> (lookup main, suppress_prefixlength=0 — escape so cross-VLAN/link-local /64s stay local), and prr6_<iface> (per-VLAN VPN default). Can't reuse IPv4's src=<prefix> matcher since LAN /64s are dynamic under DHCPv6-PD, so we match on logical in-iface instead. - rewrite_dhcp forces ra_default=1 when v6 routes through a VPN (not for plain wan), so odhcpd advertises this router as the IPv6 default even when wan6 has no PD/default route. - reload_system{,_and_wifi} now restart odhcpd so RA/DHCPv6 changes take effect (it caches config in memory). Backend (ctrl/vpn_client.rs): - OutboundVpn gains supports_ipv6, derived from the WG interface having any IPv6 Address. - get_peer_endpoint_host strips surrounding [...] of bracketed IPv6 literals so chain endpoints parse as IpAddr. - rewrite_vpn_chain_routes emits a route6 /128 for IPv6 endpoints (was IPv4-only /32). Backend (uciedit/openwrt.rs): - New NetworkRoute6 / NetworkRule6 typed sections and Dhcp.ra_default. Build: enable ip6tables-mod-nat + kmod-ipt-nat6/nf-nat6. IPv6 SNAT is done out-of-band by /etc/firewall.startwrt-masq6 since fw3 has no masq6 UCI option. API: OutboundVpn.supports_ipv6 added to API_CONTRACT.md, api.service.ts, and mock-api.service.ts. Adds 9 unit tests covering the v6 routing gates, cleanup on outbound switch, bracket stripping, and route6 emission. End-to-end behavior on hardware is unverified. * fix(network): generate per-device ULA prefix instead of hardcoded /48 The firstboot network config shipped a hardcoded ULA prefix (fda7:5549:a8c::/48), so every device used the same ULA. Chaining start-wrt routers then collides: the WAN side learns the same /48 and shadows the LAN route, black-holing reverse-NAT'd replies such as IPv6 VPN return traffic. Set `option ula_prefix 'auto'` so the 12_network-generate-ula uci-default generates a unique random /48 per device at first boot (RFC 4193). * feat(firewall): migrate fw3/iptables → fw4/nftables; dedicated VPN egress zone Switch the image's firewall from fw3 (iptables) to fw4 (nftables) and rework VPN outbound routing to fit fw4's native feature set, replacing two iptables-era workarounds. Build: - Swap firewall→firewall4; drop ip{,6}tables + xtables packages and kmod-ipt-* / kmod-nf-nat6 in favor of kmod-nft-* (core/fib/nat/offload), libnftnl, nftables-json. - Bump openwrt submodule to ce8b3a0 (kmod-crypto-crc32c rename for 6.18), required for the nftables ruleset to build. VPN egress zone (ctrl/profiles.rs): - Replace "stuff the wg interface into the wan zone + out-of-band /etc/firewall.startwrt-masq6 ip6tables script" with a dedicated `vpn_<wg>` zone carrying masq=1 AND masq6=1. fw4 has a native masq6 UCI option, so NAT66 on VPN egress no longer needs an include script, and wan6's GUA path / inbound port-forwards stay untouched. - ensure_vpn_outbound_zone creates/maintains the zone; resolve_outbound_zone maps an outbound to its zone name ("wan" or "vpn_<wg>"). - rewrite_firewall now targets per-profile wan-access forwardings/rules at the resolved outbound zone instead of always "wan". - cleanup_orphaned_wan_vpns → cleanup_orphaned_vpn_zones: tears down orphaned `vpn_<X>` zones plus any forwardings/rules referencing them, and still strips stray pre-migration wg entries from the wan zone. DNAT-return marking: - fw4 has no UCI equivalent for `-m conntrack --ctstate DNAT`, so the per-profile mangle MARK rule moves to a static nftables chain shipped at /etc/nftables.d/10-startwrt-dnat-mark.nft (auto-included into inet fw4). The daemon now only ensures the matching `ip rule` (dnat_return → main). - Drop the now-unused FirewallRule.extra UCI field. - stage-files.sh copies backend/nftables/*.nft into /etc/nftables.d. Schedules: - Window-start/REJECT rules now target the profile's egress zone ("wan" or "vpn_<wg>") instead of hardcoded "wan", and a profile outbound change rewrites + restarts the schedule crontab so the next blackout boundary doesn't REJECT toward a stale zone. Tests updated for the dedicated-zone layout and the removal of the per-profile dnat_mark rule. Comments referencing fw3/iptables refreshed to fw4/nftables. End-to-end behavior on hardware is unverified. * feat(vpn): fail-closed kill switch for VPN-routed profiles Give the per-VLAN `dev <wg>` default route a low metric (1) and add an `unreachable` fallback default on loopback at a high metric (2048), for both v4 (prtb_) and v6 (prt6b_). While the tunnel is up the dev route wins; the moment the WG interface drops, the fallback catches traffic with ENETUNREACH instead of letting the ip rule fall through to the main table and leak out WAN. Install the v6 policy-routing rules (prl6_/prr6_) for every VPN-routed profile, independent of global IPv6 state or whether the VPN carries v6, so v6 always fails closed. The `dev <wg>` v6 default (prt6_) is still only added when the outbound actually carries v6. Add `metric` and `type` (kind) fields to NetworkRoute and `type` to NetworkRoute6 in uciedit to support metric-ordered and `unreachable` routes. * fix(vpn): rebuild WAN forwarding when a VPN is deleted or disabled After resetting an affected profile's outbound to "wan", re-apply its full config via the new profiles::reapply_profile_config (firewall + dhcp + dns + routing) instead of only rewrite_routing + rewrite_dns_forwarding. The old path left the profile's forwarding pointing at the torn-down vpn_<wg> zone with no `<zone> → wan` rule, so fw4 dropped all of its WAN traffic. Also run cleanup_orphaned_vpn_zones on the disable path, matching the delete path. * feat(vpn/ipv6): route inbound port-forward replies via wan6, not the VPN A device whose profile routes ::/0 through an outbound VPN must still be reachable on its native wan6-PD GUA via an IPv6 published port. The per-profile v6 policy rule (prr6_<iface>) captures the device's reply traffic too, so replies to externally-initiated connections would egress the VPN instead of wan6 — asymmetric routing, connection fails. IPv6 port-forwards are pure filter ACCEPTs (routable GUA, no DNAT), so unlike IPv4 there's no `ct status dnat` to key on. Instead, a new static nftables chain (11-startwrt-inbound6-mark.nft) connection-marks IPv6 flows initiated from WAN — defined by exclusion of the LAN bridge and WG tunnel interfaces, so it's immune to which physical port is WAN — and restores that mark onto every packet of the flow. The daemon ensures the matching ip6 rule (network.dnat_return6, fwmark 0x80 -> main, priority 100) for every VPN-routed profile, sitting ahead of prl6_ (150) and prr6_ (200) so marked replies leave via wan6. - profiles.rs: add ensure_dnat_return6_rule(), called from rewrite_routing alongside the v4 sibling; two tests covering VPN- vs wan-routed profiles - nftables/11-startwrt-inbound6-mark.nft: new prerouting/mangle chain * feat(vpn/ipv6): assign per-VLAN ULA /64 to v6-capable profiles Previously only the admin LAN got an ip6assign; profile interfaces had it stripped unconditionally, so non-admin VLANs never received IPv6. Now ipv6_set and profile create/edit sync each non-admin VLAN's ip6assign to its IPv6 eligibility: a profile whose outbound VPN carries v6 gets a /64 (a ULA carved from the device prefix, NAT66'd out the vpn_<X> zone's masq6), while a profile on a v4-only VPN gets none so v6 can't leak outside the tunnel. Supporting fixes: - set_config no longer clobbers the admin LAN's ip6assign. That prefix is owned by lan::ipv6_set (the LAN IPv6 page) and uses the user-configured value; editing the admin profile was resetting /60 -> /64. - Profile create/edit now does `network restart` rather than `reload` (reload_system_full / reload_system_and_wifi_full). netifd only recomputes IPv6 prefix distribution on a full restart, so a reload left a newly v6-eligible profile without its delegated /64 and odhcpd with nothing to advertise. Adds regression tests for the ip6assign sync, the admin-LAN prefix preservation, and documents a known limitation (TODO ipv6/nat66): a wan-routed non-admin profile on a /64-only ISP still has no v6 internet path, since the single GUA /64 goes to the admin LAN and the wan zone has no masq6. * fix(firewall): scope default-mode remote-access rules to private/ULA sources Default-mode remote access emitted ACCEPT rules for 80/443/22 restricted only by address family, with the private-vs-global decision made once at apply time from the current WAN address. The rules themselves carried no source restriction, so if a global address later appeared on the WAN (prefix change, DHCP lease swap, IPv6 GUA) without config being re-applied, those rules would expose the ports to the whole internet. Scope each default-mode rule by `src_ip` instead, so a globally-routable client can never match regardless of what address lands on the WAN: - IPv4: one rule per RFC1918 range (10/8, 172.16/12, 192.168/16). fw4's `option src_ip` holds a single value, so each range needs its own rule; a name suffix keeps the generated section names unique. - IPv6: the ULA supernet fc00::/7. `always` stays unscoped — it's the explicit opt-in to full exposure; `never` still opens nothing. Rework REMOTE_ACCESS_PORTS from (name, port) pairs to bare ports, since section names are now derived per rule (port + optional family suffix). Tests updated for the new rule counts (default IPv4-only 3->9, both families 12, etc.) and to assert the src_ip scoping per family. * feat(devices): persistent name cache; resolve display name server-side (#55) * feat(devices): persistent name cache; resolve display name server-side dnsmasq's lease file is RAM-backed and drops a client's hostname on lease expiry, dnsmasq restart, or renewals that omit DHCP option 12. Such devices reverted to a `device-<mac>` placeholder in the UI until they happened to re-advertise their name. Add a persistent, MAC-keyed cache (device_names.rs) that remembers the last DHCP-advertised hostname per device. It sits below the live UCI and DHCP-lease sources in the resolution chain, so a fresh live name always wins and the placeholder only ever shows for a never-seen device. - Cache holds DHCP-learned names only; authoritative UCI static names live in /etc/config/dhcp and are never cached or pruned. - Stored as JSON written atomically (temp + rename) so an external tar/read during `sysupgrade --create-backup` sees one complete doc; in-memory map is authoritative, disk rewritten only on change. - 60d retention and a 1000-entry cap (oldest-first by last_seen); last_seen bumps on unchanged entries are rate-limited to 1h to gate disk writes, so a quiet, stable network re-serializes at most hourly. - Listed in keep.d so it survives sysupgrade; dropped on `forget`. devices.list now resolves the full name chain (UCI -> DHCP -> cache -> placeholder) server-side and returns a non-optional `name`. The frontend stops generating names client-side; `DeviceFromApi.name` and `Device.name` become required. Updates API_CONTRACT.md and the mock service to mirror the server-side chain. * fix(devices): touch last_seen at most daily, not hourly The device-name cache bumped an unchanged entry's last_seen (and so rewrote the JSON to disk) at most once an hour. Against a 60-day retention window that cadence is far more disk churn than the LRU needs: a quiet, stable network was re-serializing the whole map every hour for no behavioral gain. Raise TOUCH_INTERVAL_SECS from 1h to 24h. New and renamed devices still flush immediately (they bypass the interval gate), so only the periodic keep-alive touch slows down; 24h of last_seen staleness against a 60-day prune cutoff cannot cause premature eviction. * fix(profiles): allocate interface ids that avoid reserved UCI names (#56) * fix(profiles): allocate interface ids that avoid reserved UCI names Interface id allocation only de-duplicated against live kernel netdevs via `ip link show <id>`. But a profile's interface id never becomes a literal netdev — the kernel device is `br-lan.<vlan>` — so that check never caught UCI-level collisions. Two profile names that sanitize to the same 5-char prefix, or a renamed-then-recreated name whose id is still reserved, would collide and hard-fail with InterfaceNameConflict. Gather the ids already in use from UCI (network interface sections plus startwrt profiles) before allocation, and have allocate_interface_name avoid them, falling back to a random id. Interface ids are opaque and never surface in the UI (profiles display fullname), so the random fallback carries no UX cost, and ids stay stable across renames. Also harden hint sanitizing: concatenate all alphanumerics instead of taking only the first segment, and drop leading digits (UCI ids map to shell variable names, which can't start with a digit). Mirror the same collision-safe allocation in the mock API. * fix(profiles): always randomize interface ids instead of seeding from name Interface id allocation previously seeded from the profile name and only fell back to random when the sanitized hint was empty or already taken. Since the id is opaque and never surfaces in the UI (profiles display fullname), there is no UX benefit to a name-derived id, and the seeding logic was the source of the collision the prior commit had to work around. Make allocate_interface_name always generate a random id, dropping the hint parameter and the sanitize_interface_hint helper. Keep the 'taken' set: a random 5-char id can still collide with a reserved UCI id (lan, wan, wg_*, another profile), which would hard-fail in create_config, so the allocator still retries against taken ids and live kernel netdevs. Mirror the same always-random allocation in the mock API. * Refactor/start os conventions (#57) * fix(cli): send JSON not CBOR for CLI→daemon RPC calls Regression from pulling start-os in as a submodule: it depends on rpc-toolkit with default features (`default = ["cbor"]`), and Cargo feature-unification then enabled `cbor` for the whole build. That silently switched `call_remote_http` to encode request bodies as CBOR, but the rpc-toolkit HTTP server only parses JSON — so the body fails to parse before any handler runs. The breakage wasn't noticed at the time. Replace `call_remote_http` with a hand-rolled POST that always speaks JSON, mirroring start-os's `signature::call_remote`. Auth still rides on the client cookie store (loopback / local auth cookie), so no signature header is needed. Also surface non-2xx HTTP responses as a clear Network error rather than feeding the (likely non-JSON) body to the JSON-RPC parser, since the server returns RPC-level errors as 200 + a JSON-RPC error body. * fix(ssl): give each Root CA a unique Subject DN to avoid browser collisions Browsers key trusted CAs by Subject DN. Every fresh-flashed build minted a Root CA with an identical DN but a different key, so Firefox/NSS verified the new chain against the old trusted CA's key and rejected it as SEC_ERROR_BAD_SIGNATURE ("Bad Signature"). Append a short random hex token (random_ca_suffix) to the Root CA CN at generation time so each minted CA is a distinct trust anchor — an old trusted CA now coexists harmlessly with a new one. Mirrors start-os, which embeds its per-device random hostname in the Root CA CN. * refactor(ssl): bake Root CA suffix into branding at construction Pass the per-CA random suffix into `startwrt_branding(root_ca_suffix)` instead of generating a base branding and then mutating `root_ca_cn` afterward in `generate_root_ca`. This mirrors start-os's `CertBranding::start_os(hostname)`, which embeds its per-device value in the CN at construction time. Intermediate/leaf generators pass `""` since they never read `root_ca_cn`. Tighten the test assertions: verify the Root CA CN carries the base label, and add `test_generate_root_ca_unique_subject_dn` to guard that two freshly-minted Root CAs get distinct Subject DNs (the collision that surfaces as "Bad Signature" on a reflashed device). No behavioral change to issued certs. * feat(schedule): support overnight windows + reject overlaps/equal times (#58) * feat(schedule): support overnight windows + reject overlaps/equal times Schedule windows (WiFi blackout and profile WAN) can now cross midnight: when end_time < start_time (e.g. 22:00-06:00) the window runs from start on its selected day(s) through end the following day. The closing cron edge (wifi up / firewall unblock) is shifted forward one weekday so it fires on the correct day, and the boot/reload reconciler (evaluate_and_apply_schedules) is made wrap-aware via a new window_contains helper gated on the previous day's mask for the after-midnight tail. Validation: equal start/end is rejected (ambiguous 0h/24h), and windows that overlap on the wrap-aware weekly timeline are rejected on *-set with InvalidValue. Overlap/shift/cron-day logic is factored into shared helpers in wifi.rs (windows_overlap, days_to_cron, shift_days_forward) reused by profiles.rs, with unit + async tests covering wrap, overlap, and equal-time cases. Frontend: the schedule timeline renders a wrapping window as a head block on its own day and a tail block on the next; blocks are edit-only (no drag/resize). The add/edit dialog allows end < start, rejects equal times, and runs a mirrored windowsOverlap check (web/src/app/utils/schedule.ts) to warn before submit. Time inputs now use 12-hour HH:MM AA display. Also adds a TODO noting WiFi blackout has no boot-time reconciler (unlike profile schedules), so an active overnight blackout is not reasserted across a reboot until the next cron edge. Updates API_CONTRACT.md and the mock API to document/exercise the new wrap and overlap semantics. * refactor(schedule): show per-block start/end times, single-click edit Each schedule block now displays its own range's start time at the top and end time at the bottom, replacing the ellipsis icons + full-window time that read identically on both halves of a wrapping window. Head ends at midnight, tail begins at midnight. Switch the edit gesture from double-click to single click and drop the now-removed `windowTime`/`getTime` helpers in favor of a `block()` factory that precomputes the formatted strings. Also: - Format a 24:00 (midnight) end as "12:00am" instead of "11:59pm". - Drop the "Are you sure?" confirmation dialog from window removal. * chore: fix schedule visuals a bit * chore: help text * chore: fix cards appearance * fix(schedule): render overnight window as one continuous segment An overnight window (e.g. 10pm Mon-6am Tue) splits into a head block on its own day and a tail block on the next. Both halves were labeling the shared midnight boundary, so the window read as two separate segments. Drop the head's midnight end label and the tail's midnight start label, leaving only the real start on the head and real end on the tail. Position the labels by their .start/.end class instead of :first-child/:last-child so a lone label still lands at the correct edge and keeps its backdrop band (a single remaining span otherwise matched both and stretched full height). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(schedule): allow equal start/end as a full 24h window Previously a window with end == start was rejected as ambiguous. Now it denotes a full 24-hour window (e.g. 09:00-09:00 next day), reusing the existing wrap-past-midnight machinery: end <= start spans into the next day, shifting the unblock/wifi-up edge forward by one weekday. Backend (profiles.rs, wifi.rs): - window_contains / windows_overlap / crontab regen treat end <= start as wrapping; drop the equal start/end rejection in schedule_set and blackout_set - add test_window_contains_full_day Frontend (schedule.ts, window.ts, schedule.ts util, blackout.html): - mirror end <= start wrap logic in windowsOverlap and block rendering (a midnight-start 24h window has no tail) - end-time picker offers a trailing 12:00 AM to close at end-of-day - drop the equalTimes validation error; update help text * fix(schedule): show end time for windows ending at midnight A window ending exactly at midnight (end == 0) closes on its own day and has no tail in the next column, yet it was rendered as a "head" block — whose end label is hidden so true overnight windows read as one segment. The result: such a window showed its start but never its 12:00am end (e.g. a 9:00pm-midnight block, or a full 24h midnight-to-midnight one). Distinguish "genuinely spills past midnight" (end > 0, splits into head + next-day tail) from "ends at midnight" (end == 0, stays a single "whole" block that keeps both labels). Only the former drops its boundary labels. The label-hiding was introduced in d80e86d; this surfaced more visibly once equal start/end 24h windows became allowed. * feat(schedule): deconflict cron edges and persist blackout windows in UCI Schedules built from adjacent or consecutive windows could race at a shared cron tick (one window's "up" edge firing at the same minute as the next window's "down" edge), briefly unblocking the resource. Project windows into deconflicted down/up edge maps keyed by minute-of-day and annihilate coincident down+up weekday bits, so back-to-back windows stay continuously blocked with no same-tick race. A fully-tiled week produces zero surviving edges, leaving cron nothing to execute, so reject full-week-no-gap coverage up front in both blackout_set and schedule_set (FE warns before submit via coversFullWeek). Move WiFi blackout windows into UCI (config wifi_blackout 'blackout') as the source of truth; the crontab becomes a disposable projection regenerated from UCI by regenerate_blackout_crontab. This drops the brittle round-trip that parsed windows back out of cron lines, and lets deconfliction merge/drop edges without losing the underlying windows. Factor the window serialize/parse/projection logic into shared wifi.rs helpers (serialize_windows, parse_windows, windows_to_minutes, deconflict_edges, covers_full_week) used by both the WiFi blackout and profile WAN-schedule paths; malformed entries and unparseable times are now dropped with a warning instead of silently. FE extracts toWeekSegments shared by windowsOverlap and the new coversFullWeek. Known gap (TODO retained): blackout is still edge-triggered with no boot-time reconciler, so a reboot mid-blackout won't reassert radio state until the next cron edge. * feat(schedule): reassert WiFi blackout on boot if inside active window WiFi blackout was edge-triggered by cron only. A reboot mid-window lost the edge: netifd raises the radios early in boot per the on-disk `disabled` flag (runtime `wifi down` doesn't persist), so WiFi came back up despite being inside an active blackout, staying up until the next cron edge. Add `reconcile_blackout_at_boot`, modelled on `profiles::evaluate_and_apply_schedules`. It recomputes the current in/out-of-window state (wrap-aware, reusing `window_contains`) and reasserts `wifi down` when inside a window. The out-of-window case is a deliberate no-op so we never re-enable a radio the user disabled. It runs after `restore_wifi_if_needed` so our `wifi down` is the final word over restore's `wifi reload`. The wrap-aware decision is split into a pure `windows_contain_now` helper with unit tests (non-wrap, overnight wrap, multiple/malformed, empty). Makes `window_contains` and `chrono_now` pub(crate) for reuse. This closes the steady-state gap, not the boot-window gap (netifd START=20 vs daemon START=99); that's documented as a follow-up TODO. * feat(schedule): regenerate cron projections from UCI on boot /etc/crontabs/root is a disposable projection of the UCI schedule stores and is wiped by sysupgrade (the stores persist), so schedule edges would stop firing after an upgrade. On boot, after the mid-window reconcilers run, rebuild both projections — WAN schedule and WiFi blackout — from UCI and restart cron once. This is a no-op when there are no windows and self-heals crontab drift on any boot. Both regenerators strip only their own tagged lines, so running them in sequence over the shared file is safe. --------- Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(lan): let users pick the /16 (second octet) within each RFC 1918 block (#59) * feat(lan): let users pick the second octet within each private block The LAN IPv4 form previously hardcoded the second octet per first octet (192->168, 10->0, 172->16), collapsing each RFC 1918 range to a single /16 and hiding the other 255 (10/8) and 15 (172.16/12) selectable blocks. Make the second octet a form control with per-block bounds: 192.168.0.0/16 -> locked to 168 (one /16) 172.16.0.0/12 -> 16..31 10.0.0.0/8 -> 0..255 The field is read-only for 192 and editable elsewhere; switching the first octet re-applies the block's min/max and snaps any out-of-range value back in. The wire contract is unchanged (the full address string already carried the octet). saveBlocked now also treats a second-octet change as a subnet change for the static-IP guard. * fix(lan): enforce RFC 1918 block boundaries server-side ipv4_set parsed any IPv4 and applied it with a /24 netmask, with no RFC-block validation — the per-/16 restriction was cosmetic (frontend only). The daemon RPC (and vestigial generic uci.set) would accept 8.8.8.8, out-of-range 172.x, any 192.x second octet, etc. Add validate_lan_block (10/8, 172.16/12, 192.168/16 with the same second-octet bounds the UI exposes) and call it before any config write. The admin (owns_lan) profile is a second path that sets the LAN /16, and non-admin profiles were unconstrained — an out-of-block VLAN subnet would escape the chosen range and break sync_cross_subnet_routes (which assumes siblings share the first two octets). Add validate_profile_block to set_config/create_config: admin must be a valid RFC 1918 selection; others must share the admin LAN's /16. All failures are ErrorKind::InvalidRequest. Documents the rules in API_CONTRACT.md (lan.ipv4-set, profiles.*). * test(lan): cover non-192.168 RFC 1918 block validation Add validate_profile_block_accepts_alternate_rfc1918_block, asserting a LAN in 10.42.0.0/16 accepts siblings within the block and a valid admin selection while still rejecting a sibling that escapes the block. Locks in the server-side boundary enforcement from 30fc874 for non-192.168 private blocks. Derive Debug on OldProfileState so test assertions can format it. * fix(lan): flag out-of-range second octet instead of silently snapping The second-octet field previously clamped any out-of-range value back into the active block's range, silently rewriting what the user typed. Replace the min/max validators with a dedicated block validator that flags the value instead, so saving is blocked and the allowed RFC 1918 range is surfaced to the user. - utils.ts: add secondOctetBlockValidator + isSecondOctetInRange; keep clampSecondOctet only for load-time normalization in parseIpToForm. - form/ip.ts: render the 192 block as a disabled display field, all other blocks as an editable number input with a signal-driven error hint that tracks both octets; swap the validator (not the bounds) on block change, only force-setting the value when there's a single legal choice (192 -> 168). - index.ts: extend saveBlocked to reject an out-of-range second octet and report the allowed min-max. * refactor(lan): apply PR #59 review feedback on the IPv4 block form Behavior-preserving response to the PR #59 reviews. The second-octet picker keeps the same per-block bounds, validation, and wire output — only the implementation is brought in line with the idioms the reviewers asked for, and the backend RFC 1918 check is simplified. - lan.rs: replace the hand-rolled match in validate_lan_block with Ipv4Addr::is_private(), which encodes exactly the same three blocks (10/8, 172.16/12, 192.168/16). Identical semantics; the existing validate_lan_block_* tests still pass. - form/ip.ts: remove the imperative effect (setValidators + force setValue) and the $-suffixed signal names. Derive the octet signals with tuiControlValue, bind the per-block validator declaratively via [tuiValidator], and resolve the locked 192 octet for display rather than mutating the control. Keep a display-only secondOctetError computed so the allowed-range hint still appears immediately on a block switch. - utils.ts: add resolveSecondOctet (collapses the single-value 192 block to its fixed octet) and route buildNetworkBlock/buildRouterIp through it; reduce the static secondOctet validator to just `required` (the block range is now applied by [tuiValidator]). - index.ts: resolve the second octet in saveBlocked before the range check and the subnet-change comparison, so the locked block's carried-over value is never falsely flagged. * Implement i18n - Translate the web UI into 5 languages (en/es/de/fr/pl) (#60) * feat(i18n): translate web UI into 5 languages (en/es/de/fr/pl) Port start-os's translation engine to localize the entire web frontend. Engine (web/src/app/i18n/): - i18n.service.ts: language switcher extending Taiga's TuiLanguageSwitcherService; maps POSIX locales (en_US, es_ES, …) to Taiga language names and lazy-loads the active dictionary. - i18n.providers.ts: I18N signal + I18N_LOADER injection tokens; wires Taiga's own widget strings and our dictionaries behind dynamic imports. - i18n.pipe.ts (`| i18n`): translates English keys via the active dict, falling back to the English key itself. - localize.pipe.ts (`| localize`) + locale-string.ts: render rich LocaleString values (plain string or per-locale map), mirroring start-os's T.LocaleString. - validation-errors.ts: provideTranslatedValidationErrors() routes <tui-error> messages through the pipe, with tpl() for interpolated templates; re-translates live on language change. Dictionaries: en.ts (source of truth, id->key) plus es/de/fr/pl, each lazy-loaded. Help content: replace the per-topic .html files with per-language TS modules (help/content/{en,es,de,fr,pl}.ts); update help.ts and modal-help.ts to resolve content by route and active language. Tooling: - scripts/check-i18n.mjs: validates that every `| i18n` / i18n.transform key exists in en.ts, every id is present in all dictionaries, and every help route is translated; run from the pre-commit hook. - package.json / angular.json wiring; utils/languages.ts defines the 5 supported languages with endonyms. Wiring: app.config.ts registers I18N_PROVIDERS; header adds a language switcher. Migrate all routes, components, and services to the i18n / localize pipes. * fix(i18n): apply saved theme/language globally and revert unsaved previews Treat saved system settings as the source of truth for both theme and language. The app-level effect now applies theme alongside language whenever system info loads or changes (boot and after Save), so the two preferences stay in sync with persisted state. On the General settings page, theme and language are previewed live on selection. If the user navigates away without saving, the DestroyRef hook now reverts both previews to the saved settings; a saved (pristine) form makes this a no-op. Mark the form pristine after a successful save so leaving the page afterwards doesn't trigger a spurious revert. * refactor(i18n): resolve help content to plain strings, drop LocalizePipe Help content is now resolved to the active language directly inside HelpService.content (returning Record<string, string>), instead of emitting LocaleString maps that are resolved later in the template via the `localize` pipe. This removes the indirection layer added for rich-content i18n: - delete LocaleString type and LocalizePipe - drop i18nService.localize() and the unused `loading` signal - header search filters on already-resolved strings - aside/modal-help templates drop the `| localize` step * rebase openwrt fork on 25.12.4 (#63) * fix(fonts): bundle Proxima Nova so the brand typeface actually loads (#62) * fix(fonts): bundle Proxima Nova so the brand typeface actually loads styles.scss overrode Taiga's --tui-typography-family-{text,display} to 'Proxima Nova', but the font was never shipped — no @font-face, no font files — so the UI silently fell back to system-ui and the brand typeface never loaded. Ported from start-os, which overrides the same vars but also ships the font. - Add the 7 Proxima Nova weights (100-900) under web/assets/fonts/Proxima_Nova/, served at /assets/fonts/. - Add matching @font-face declarations in styles.scss (mirrors start-os shared.scss). - Set font-family: 'Proxima Nova', system-ui on tui-root in main.ts (mirrors start-os app.component). The Taiga family vars only style text that uses Taiga typography tokens; this base rule makes all inherited text render in the brand font too. Visually subtle on Linux (system-ui is metrically close to Proxima Nova) but clearly different on macOS/Windows/mobile where system-ui differs. * fix(fonts): drop redundant font-family on tui-root Taiga components resolve their font from the --tui-typography-family-text and --tui-typography-family-display vars, which styles.scss already sets to 'Proxima Nova'. A raw font-family on the tui-root element is overridden by Taiga's typography tokens anyway (headings use the display var), so it added nothing the CSS vars don't already cover. Rely on the vars as the single source of truth so the brand font applies to both body text and headings. Addresses PR review feedback. * fix(fonts): ship only Proxima Nova 400 and 700 All Start9 designs use only normal and bold weights. Limiting the bundled faces to 400/700 keeps the UI on-spec — intermediate weights snap to the nearest shipped face instead of loading a distinct glyph. Drops the unused Thin/Light/Semibold/Extrabold/Black woffs. Addresses PR review feedback. * Fixes/ethernet devices vpn (#64) * fix(ethernet): use `firewall reload` after eth0 reassignment to avoid lockout Reassigning eth0 to a different profile ran `firewall restart` fire-and-forget after the network reload. The full fw4 table flush, under the default `input REJECT` policy, opened a window that (racing netifd's bridge work) intermittently locked out all management access until a manual reboot. netifd's `network reload` already applies the bridge VLAN/PVID change live via RTM_SETLINK/RTM_DELLINK netlink, and a port-VLAN move changes no zone<->interface binding, so an incremental `firewall reload` is sufficient. * fix(devices): list bridge-FDB-learned clients with no DHCP lease A device with an L2 link to the bridge but no DHCP lease and no IP-neighbor entry -- e.g. a static-IP or IPv6-only host reached through an external switch -- was omitted from devices.list entirely. Fold FDB-learned MACs into the membership set and mark them Online/Ethernet so physically-connected devices are visible. The bridge FDB ages (~300s default), so a just-unplugged device may linger briefly, which is preferable to a connected device never showing. * fix(dhcp): read all per-profile dnsmasq lease files, not just the base Profiles using custom or VPN DNS run their own dnsmasq instance writing /tmp/dhcp.leases.dns_<iface>; the base /tmp/dhcp.leases only holds the main instance's clients. The device list, the published-ports IPv4 fallback, and the lease flush/cleanup paths all read only the base file, so those clients were missing their DHCP hostname and lease IPv4. Add shared helpers (dhcp_lease_files / read_all_dhcp_leases) and route every reader through them. * feat(devices): recover device names via reverse mDNS Some devices never advertise a hostname via DHCP option 12, so they never land in the lease file and show up only as `device-<mac>`. They do still answer mDNS, so reverse-resolve their IPv4 over Bonjour to recover a display name. For any present, reachable device that no live source (UCI host, DHCP lease) or the name cache can name, query `avahi-resolve -a <ip>` against the local avahi daemon. Lookups are bounded — 1.5s timeout with kill_on_drop per query, fan-out capped at 8 concurrent — so a large LAN or a non-responder can't stall the device list. Hits are persisted to the existing name cache, so each device is queried at most once and a steady-state network produces no targets (no-op). The name cache now holds both DHCP- and mDNS-learned names, so rename `Observation::dhcp_hostname` to `hostname` to match. mDNS sits below DHCP and above the cache in the resolution chain. Enable the `avahi-utils` package (provides `avahi-resolve`); avahi-dbus-daemon was already enabled. * fix(devices): prefer static reservation IP over live ARP/lease When a device has a static IP reservation (UCI `host.ip`), surface that address as its `ipv4` rather than the live ARP neighbour or DHCP lease. The reserved address is the one the device is pinned to, so this stops the edit form from snapping back to the stale DHCP address after a reservation is saved — the client keeps its old lease (and thus its old ARP/lease entry) until it renews. Falls back to ARP, then the DHCP lease, when no reservation is set. * fix(devices): report the live IP/profile when a device roams VLANs A device that moves to another security profile picks up a lease on the new VLAN bridge but leaves a stale neighbor entry on its old one. Both entries share the MAC, and devices.list keyed everything by MAC then took the first ARP entry it found — so it reported the abandoned IP and the wrong security profile until the kernel aged the stale entry out. Use the active probe that already runs as ground truth: ping_unreachable_macs now also returns the IPs that actually replied (live_ipv4s) instead of collapsing to a per-MAC boolean. A new choose_ipv4_entry ranks a MAC's IPv4 neighbor entries — REACHABLE > probe-confirmed > DELAY/PROBE > STALE — so a confirmed-live STALE entry beats a stale-but-DELAY one (ranking on neighbor state alone would pick the wrong address). The displayed IPv4 and the VLAN-derived profile now both come from that single chosen entry, so they can't disagree, and the mDNS reverse-resolve target uses the same chooser so an unnamed roamed device is queried at its current address. Adds unit tests for the chooser (probe-confirmed-over-fresher-state, REACHABLE preference, IPv6/empty handling, deterministic tie-break). Status semantics and static-reservation precedence are unchanged. * fix(devices): cap mDNS reverse-resolve at one attempt per device per run The mDNS name-recovery pass was gated only on the name cache, so a device that suppresses DHCP option 12 *and* never answers Bonjour was re-queried on every poll — it never lands in the cache, so nothing stopped the retry. On a network with such devices, each list() call paid the avahi-resolve cost repeatedly. Track attempted MACs in a new MDNS_ATTEMPTED set: a device that answers is persisted to the name cache (gated out by the cache check); one that stays silent is recorded in MDNS_ATTEMPTED (gated out by the set). Either way a MAC is reverse-resolved at most once per daemon run. The set is cleared only on daemon restart, so a device that later starts answering Bonjour is picked up after the next restart. The lock is held only across the synchronous selection loop — no .await inside — and released before resolve_mdns_names(). * fix(vpn): route cross-profile peer replies through the tunnel, not the LAN bridge Two related gaps in inbound-VPN reachability across profiles: 1. Cross-profile peer routing. sync_peer_policy_routes only adds a peer's /32 to its own profile's policy table, while sync_cross_subnet_routes adds a sibling's whole /24 via the LAN bridge. In a vpn-routed sibling's table a peer IP then matches the /24-via-bridge route, so replies are sent onto the LAN bridge instead of into the WireGuard tunnel and the connection breaks. Add sync_vpn_peer_cross_routes, which installs a more-specific /32 via the peer's wg_<P> interface (named vxr_*) into every OTHER vpn-routed profile's table, overriding the /24. Recomputed idempotently and wired into every apply site that touches profiles or VPN servers (profiles create/set/delete, vpn_client delete/set_enabled, vpn_server set/delete/peer_add/peer_delete/remove). These routes only correct the L3 path; reachability stays governed by the firewall. 2. LAN-only client AllowedIPs. A "LAN only" peer previously got only the profile's own /24 in AllowedIPs, so it couldn't reach the other profiles that profile's lan_access permits. Add lan_only_allowed_ips, which builds the split-tunnel list from the profile's outbound lan_access (SameProfile / OtherProfiles / All). Computed before dump_all consumes cfgs in peer_add. Known gap documented inline (TODO reverse-parity): a profile permitted to initiate INTO a LAN-only peer's profile still can't reach the peer, since WireGuard drops inbound packets sourced outside the peer's AllowedIPs. * feat(vpn): give inbound VPN server peers first-class IPv6 When a profile serves IPv6 to its clients, inbound WireGuard server peers now get a stable v6 address alongside their v4 /32, instead of being v4-only. Peers can't sit in the profile's own /64 (DHCPv6-PD assigns it at runtime, non-deterministically) while WG client configs are issued once and must be static. So carve a dedicated, stable /64 from the device ULA /48 (network.globals.ula_prefix) using a high subnet-id band (0xf000 | vlan_tag) that stays clear of odhcpd's low sequential assignments: - wg_server_v6_groups() derives the /64, gated on the profile actually serving v6 (is_ipv6_enabled + outbound_supports_ipv6) and a concrete ULA prefix existing (None pre-first-boot when ula_prefix is still 'auto'). - set_wireguard_interface / add_single_peer give the interface its <wg64>::1 and each peer <wg64>::<v4-octet>, as a /128 in allowed_ips so route_allowed_ips installs it in the main table (how the router and sibling profiles reach the peer over v6 - no proxy_ndp needed). - peer_add writes the v6 /128 into the client config's Address and, for LAN-only peers, adds the device ULA /48 to AllowedIPs (v6 lan_access is enforced at the firewall; we can't scope to runtime-assigned sibling /64s). - sync_peer_policy_routes installs vsl6_/vsr6_ ip rules mirroring prl6_/prr6_ but keyed on 'in: wg_<X>', so peer v6 escapes locally for cross-VLAN/own-LAN and otherwise follows the per-VLAN tunnel - never leaking out wan6 on a vpn-routed profile (a v4-only tunnel drops it on the kill-switch). - ensure_server_v6_address keeps the interface consistent on peer_add if v6 was toggled on after the server was created. Also fix get_vpn_peer_configs / get_peers_for_interface to parse the v4 host from allowed_ips and ignore the trailing v6 /128, so the device list reports the IPv4 instead of letting the v6 entry clobber it. Adds NetworkGlobals to uciedit and exports VPN_ROUTING_PRIORITY / VPN_ROUTING_V6_LOCAL_PRIORITY from profiles for the new rules. * fix(ethernet): keep Admin VLAN 1 alive when its last port is reassigned Reassigning the last Admin (VID 1) ethernet port dropped the VLAN-1 bridge section, taking down br-lan.1 and the default WiFi SSID ("No route to host"). The AP netdevs are attached to br-lan at runtime by hostapd, so they never appear in `ethernet.ports` and can't keep VID 1 alive on their own. - Only drop an empty VID 1 section when VLAN filtering is off (flat bridge); when filtering is on, keep it so it carries br-lan.1 and the default SSID. - Re-run `wifi` after the network reload so the hostapd-attached AP netdevs re-acquire their PVID once the VLANs exist again. - Add a regression test for the single-ethernet-port hardware layout. * Align the device timezone with crond so scheduled jobs fire at the expected local time (#61) * fix(timezone): resolve POSIX TZ on-device from LuCI zoneinfo, drop bundled table The frontend shipped a hand-maintained IANA→POSIX table and sent both the IANA name and POSIX string to the backend. That table could drift from the device's actual tzdata and only covered ~80 curated zones. Move the source of truth onto the device: Backend: - `system.set-timezone` now takes only the IANA name and resolves the POSIX string via `ubus call luci getTimezones` (resolve_posix_tz); unknown zones error instead of silently writing garbage. Store zonename verbatim (with underscores) to match modern LuCI's writer and the zoneinfo keys. - Add `system.get-timezones` to back the settings dropdown with exactly the set the device can resolve (UTC first, then sorted table keys). - Restart crond after a timezone change so wall-clock schedules (WiFi blackout, WAN) re-base on the new /etc/TZ. - Carry the wizard's browser timezone into the fresh eMMC config on FreshStart (write_timezone), since the live set-timezone only reaches the throwaway microSD overlay. Record the outcome straight into the eMMC activity DB via the new activity::log_to helper. - Log timezone-updated activity entries (success / UTC-fallback). Frontend: - Delete the 600-line TIMEZONES table and getPosixTz/resolveTimezone; the dropdown now loads from getTimezones() and labels are formatted live via Intl (getTimezoneLabel). Default to UTC when the device zone is unset rather than masking it with the browser zone. - setTimezone params drop posixTz; setup wizard forwards the browser zone in the flash request. Updates API_CONTRACT.md, api.service.ts, and both live/mock services. * feat(timezone): make settings dropdown searchable, widen for long labels The on-device zone list (~400 entries) is too long to scan, and long labels like "(GMT-03:00) America/Argentina/Buenos Aires" were truncated in the narrow select. - Swap the timezone tuiSelect for tuiComboBox so the user can type to filter; the dropdown is fed through the tuiFilterByInput pipe. - Move the field to its own row (flex-basis: 100%, max 30rem) since the 50rem form section can't fit a box wide enough for the long labels alongside Theme + Language. tuiComboBox isn't matched by the global :has([tuiSelect]) sizing rule, so the width is set locally. * chore: cleanup --------- Co-authored-by: waterplea <alexander@inkin.ru> * Web UI polish: VPN path, viewport fixes, IP-reservation warning (#67) * feat(outbound): show client device at the head of the VPN connection path The Outbound VPN summary's connection-path graphic started at the VPN provider (e.g. Proton -> Internet), which obscured where traffic originates. Prepend a fixed "Client" node with a device icon so the chain reads Client -> VPN -> Internet (and Client -> Mullvad -> Proton -> Internet for multi-hop). The node is presentational only — rendered in the template, not added to buildConnectionPath() — so the VPN-chain/cycle logic and the loading fallback are untouched. Add the translatable "Client" string (id 508) to all five locale dictionaries. * fix(mock): show profile name, not UCI interface, in VPN "Used by" The Outbound VPN summary's "Used by" field rendered raw UCI interface names (lan, guest) when running against the mock API. The live backend (get_used_by_profiles in vpn_client.rs) already returns each profile's fullname, so the mock diverged from real-device behavior — and interface names are not user-meaningful, especially now that VPN interfaces are randomly generated. Map used-by profiles to p.fullname instead of p.interface so the mock matches live and the documented contract ("Which security profiles route through this VPN"). * fix(web): keep Settings tables and wide content within the viewport Several views ran off the right edge of the screen: - Settings > SSH Keys and Logs had no page-width cap (unlike sibling tabs such as Activity), so their tables overflowed horizontally. Cap the page with :host { max-width: 50rem } to match the established idiom. - The app shell sized the scroll area as calc(100vw - 3rem), which hardcodes the collapsed-sidebar width. With the sidebar expanded the scroll area was wider than the visible main column, so content on the right - the wide Published Ports table, its row actions, and the header Add button - was clipped and unreachable. Use min-width: 100% so the scroll area tracks the real main-column width in both sidebar states. This also lets wide tables scroll horizontally via the slim app-level scrollbar (matching the devices list). * feat(web): warn when a static IP reservation pins a new address When a user saves a device reservation that changes the IPv4 to a different address, show a dialog explaining the change only takes effect on the device's next DHCP request — the router can't push it to a connected client, so the fastest path is reconnect/reboot, and otherwise it applies within ~12h. Only warn when the address actually changes; merely making the current lease static (reserving the existing address) is silent. Add i18n strings (509/510) across en/de/es/fr/pl for the dialog body and dismiss button. * fix(web): restore full-contrast text for labeled avatars in VPN summary Taiga renders [tuiSubtitle] content in a muted color, which dimmed the device label shown alongside tui-avatar-labeled in the VPN connection path. Override the color to --tui-text-primary so the labeled avatar reads at full contrast. * fix(web): pin mobile content to viewport so nav expand slides, not squishes On mobile, expanding the side nav reflowed and squished the page content instead of sliding it off-screen. Pin tui-scrollbar to a min-width of calc(100vw - 3rem) and cap the page header to the same width under tui-root._mobile, mirroring start-os start-tunnel's outlet (desktop min: 100%, mobile: 100vw - 3rem). The desktop header max-width is relaxed back to 100% now that the mobile cap is scoped separately. * Unify reconnect UX into a global ConnectionService (#68) * Unify reconnect UX into a single global ConnectionService Replace the per-flow reconnect modals (RECONNECTING_DIALOG, ReconnectDialog) and the NetworkRestartService.suppress() plumbing with one root ConnectionService that owns all "router is unreachable" UX. Any network-level error (HttpError code 0) anywhere now funnels through reportUnreachable(): the first caller shows ONE sticky "Reconnecting" toast and starts ONE cancelling poll loop, and concurrent callers (the ~15 background form pollers) collapse into it. On recovery it dismisses the toast, confirms with a single "Connection restored" toast, and optionally runs a recovery intent (e.g. reload on a same-host IP change). Callers declare intent up front via expectDisruption() so whichever path observes the drop shows the right copy; the recovery intent is honored only from the call that actually observed the drop, so a stale context can't trigger an unexpected reload. Add per-request timeout plumbing through rpc/http: the RxJS timeout operator unsubscribes and aborts the in-flight XHR, so a wedged HTTP/2 connection (left dead after conntrack is flushed mid-restart) is torn down and the next probe opens a fresh socket, instead of multiplexing onto the dead one and stalling for the browser's full TCP timeout. Timeouts surface as network errors (code 0) so they flow through the same reconnect path. Update ActionService and all restart call sites (wifi, lan/ipv4, profiles, backup, advanced) to the new API; drop the old polling dialog and NetworkRestartService. Add "Reconnecting"/"Connection restored" strings to the en/es/de/fr/pl dictionaries. * Make reconnect recovery reliable across IP, reboot, and SSID changes Builds on the global ConnectionService to fix three recovery gaps where the indicator could get stuck or confirm too early: - IP / subnet change: add ConnectionService.reconnectAt(), which probes the destination cross-origin (no-cors fetch of /static/root-ca.crt) and auto-navigates there once it answers, with a 60s fallback force-redirect for untrusted-HTTPS / wedged connections. LAN IPv4 and profile admin-IP saves now suppress the indicator before the drop and hand off to it, replacing the old per-route "IP changed" dialogs. Scheme/port preserved; bare-IP targets the new address, hostnames re-resolve after DHCP renew. - Restart actions: defer the success toast ("WiFi settings saved") until the router answers again via successMessage, instead of toasting success while still unreachable. systemRestart polls with a 5s per-probe timeout so the drop surfaces promptly instead of stalling on the 60s race. - SSID change: replace the transient toast with a persistent ReconnectDialog that instructs the user to rejoin the new network and reloads on recovery; saveForSsidChange suppresses the global indicator and rethrows real backend failures instead of spinning. Supporting changes: new NetworkService (online/offline observable) drives a fresh probe on link/tab return so recovery isn't stuck on a wedged socket; preload all lazy route chunks at boot so an import() can't fail against the dead connection mid-restart. * Harden published-port forwarding against silent breakage (#70) * fix(published-ports): gate IPv6 forwards on a real global address (GUA) IPv6 port forwarding only works when the target is reachable from the WAN, which requires a Global Unicast Address (2000::/3). ULA (fc00::/7) and link-local (fe80::/10) are unreachable, so a forward to them is a silent no-op. Previously such forwards could be saved and would quietly never work. Backend: - Reject an enabled IPv6 port at save time (ErrorKind::MissingDeviceAddress) on any confident signal it can't work: the router has no delegated global prefix, the device resolved to a ULA, or the device is online with only a link-local address. A genuinely-offline device (no IPv6 seen) on a GUA-capable router is still deferred to the rule-creation GUA guard so a briefly-offline device doesn't fail an otherwise-valid save. - Consolidate the definition of "global" on system::has_global_ipv6 (parsed 2000::/3 range check). is_gua and devices::pick_ipv6 now delegate to it instead of string-prefix heuristics that misclassified deprecated scopes (e.g. site-local fec0::/10) as global. - Track ipv6_link_local_only on DeviceNetInfo to distinguish "offline" from "online but link-local only". - wan::ipv6_get now reports a GUA-preferred assigned_ipv6 (falls back to the first address on a ULA-only WAN) so every consumer sees the reachable scope. - Add unit tests for is_gua, pick_ipv6, and has_global_ipv6 boundaries. Frontend (published-ports dialog): - Replace disabled radio items + auto-correct with an inline <tui-error> and a reactive Save-disable driven by form validity, surfacing the specific reason (no IPv4, IPv6 not enabled, no GUA, or local-only address). - Mirror the backend GUA check in a shared isGua() helper; the route now offers IPv6 only when the WAN has a GUA prefix, not merely IPv6 enabled. - Add i18n strings (514/515) across en/es/de/fr/pl. Updates API_CONTRACT.md to document the new validation and GUA-preferred assigned_ipv6 behavior. * fix(vpn-routing): tie dnat_return ip-rule lifecycle to VPN profiles The global dnat_return / dnat_return6 ip rules (fwmark 0x80 -> main table, keeping inbound port-forward replies off the VPN tunnel) were created on a name-only existence check and never removed. Two consequences on already-provisioned devices: - A stale definition (old priority/mark from a prior release, or a manual edit) survived forever, silently breaking the priority ordering with the source-based VPN policy rules. - The rules lingered after the last VPN-routed profile was switched back to WAN / deleted / disabled, with no consumer. Rewrite both ensure_* helpers as remove-then-append so the rule is always re-emitted from the current constants, and add a cleanup step that drops both rules when no VPN-routed profile remains. The rules now exist iff at least one VPN-routed profile does, mirroring the vpn_<wg> zone lifecycle. Removal is safe: the static nft marking chains keep running but the fwmark->main rule is a no-op without source-based VPN rules. Add tests for stale-rule rewrite and teardown-on-last-VPN-removed. * feat(published-ports): warn when a published port is exposed despite VPN routing A published port is always reached over the public WAN IP, even when the owning device's security profile routes its outbound traffic through a VPN. Users can reasonably assume "behind a VPN" means "not on my real IP", so surface that gap inline in both directions: - Published-port dialog: when creating a new port for a device whose profile has a VPN outbound, show a warning naming the profile and VPN. Only on create (not edit), so we warn on the first save. - Profile dialog: when switching a profile's outbound to VPN, warn if any device in that profile already has published ports, listing up to 3. Wiring: - published-ports/index resolves profile -> VPN-label map (profilesList + vpnClientList + profileGet) and the default LAN-owning profile, passed into the dialog; failures are non-fatal (warning simply not shown). - profiles/index collects published-port labels for the profile's devices (devicesList + publishedPortsList in parallel, each guarded) and passes them to the dialog. - Export `fill()` from i18n/validation-errors so both dialogs can interpolate the translated strings. - Add strings 516 and 523 across en/de/es/fr/pl dictionaries. * feat(published-ports): confirm before breaking or exposing published ports A published port forwards to a device at its current subnet address. Two config changes can silently break or expose such a port, with no warning at the point of action: - Reassigning the device's security profile (moving an Ethernet port to a new VLAN, or reassigning/deleting a WiFi password) moves the device to a different subnet, leaving the port's DNAT rule pointing at an address the device no longer holds. - A port is always reached over the public WAN IP even when its device's profile routes outbound through a VPN — "behind a VPN" does not mean "off my real IP". Gate both behind an explicit confirmation dialog fired at the moment the change is made. Break-on-reassign (ethernet.set / wifi.set): - Add a `confirm_published_port_deletion` flag and a result carrying `pending_published_port_deletions`. Without the flag, set() is a dry run: it detects which ports would break and returns them, applying nothing. With the flag, it deletes those ports' firewall rules and now-stale DHCP reservations atomically with the bridge-VLAN / WiFi update, then reloads network + firewall (+ dnsmasq when reservations changed). - Ethernet attributes affected devices via the bridge FDB (port-precise); WiFi attributes them by reservation subnet of a "vacated" profile (one that lost its last password) intersected with currently-WiFi-connected devices. Ethernet devices on a vacated WiFi profile keep their IP, so they are correctly excluded. - Shared helpers in published_ports: AffectedPublishedPort, affected_ports_for_macs, affected_wifi_ports_for_vacated_profiles, remove_ports_for_macs; export get_bridge_fdb. CLI edit paths confirm implicitly (no dialog). Add unit tests. Expose-despite-VPN: replace the inline form warnings added in f9a1114 (which sat passively in the profile and published-port dialogs) with a blocking confirmPublishedPortExposed dialog fired uniformly wherever exposure is newly created — creating/editing a port, re-enabling a disabled one, or switching a profile's outbound to a VPN. The warning now fires once, at the moment of exposure, instead of perpetually in a form. Drop the dead updatePassword path and the unused publishedPortLabels/vpnProfiles dialog plumbing it replaced. Frontend: two new shared confirm helpers (published-port-deletion, vpn-exposed-port); wifi/ethernet services gain previewWifi/previewPorts dry-run methods; toggleEnabled re-reads the port list after the await to survive the 5s auto-refresh. Swap i18n strings 516/523 for 524-529 across en/de/es/fr/pl. Update API_CONTRACT.md for the new request/result shapes on ethernet.set and wifi.set. * feat(published-ports): keep IPv6 forwards working when your ISP changes prefix When an ISP rotates its delegated IPv6 prefix, every IPv6 published-port forward was left pointing at an address the router no longer owns and silently stopped working. Now: - A wan6 hotplug hook repairs all IPv6 forwards to the new prefix automatically when the prefix changes. - Enabled IPv6 ports pin a stable device address so forwards survive rotations (and don't break if the device is briefly offline). - A stranded forward is shown as "IPv6 address out of date" (Partial or Error) instead of a false-green Active status. Also fixes profile-vacate cleanup to catch IPv6-only ports and to preserve user-named DHCP reservations (clearing only the stale IPv4). * some taiga idioms * Outbound VPN: configurable MTU + WireGuard config validation (#71) * feat(vpn): configurable MTU for outbound WireGuard tunnels Add an optional MTU setting to outbound VPN clients so tunnels on obfuscated or :443 endpoints (whose path can't carry the 1420 kernel default) can be lowered to a working value. Unset inherits the kernel default. Backend: - Parse an uncommented `MTU` from the uploaded .conf into `option mtu`; a commented `#MTU` is ignored. - Validate MTU to 1280-1500 (1280 = IPv6 min link MTU / universal safe floor); accept it on create and update. - `update` writes/clears the `mtu` option (UCI is the single source of truth) and bounces the WG interface only when the value changed. - Expose `mtu` on `OutboundVpn` (list) and add the `WgInterface.mtu` UCI field. - Enable `option mtu_fix 1` (TCP MSS clamp) on the dedicated vpn_<X> egress zone as a backstop against a too-high MTU black-holing large packets. Frontend: - Add an optional MTU number field (1280-1500) to the VPN edit form with a hint to lower it to 1280 when the VPN connects but times out. - Thread `mtu` through the API types, update payload, and mock API. - Register the new UI strings across all locale dictionaries. API_CONTRACT, Rust handlers, api.service.ts and mock-api.service.ts updated together. * feat(vpn): validate WireGuard config, reject OpenVPN/malformed uploads Harden outbound VPN config parsing so non-WireGuard files fail fast with a clear message instead of the misleading "missing PrivateKey" error. - Backend: require [Interface]/[Peer] headers, an interface Address, and a peer Endpoint; detect OpenVPN configs for a specific message. Add tests. - Web: async validator mirrors the check (backend stays authoritative); fix file-input flicker by listening on statusChanges and treating PENDING as not-yet-valid. - Document the rules in API_CONTRACT.md. * Release toolkit + CI S3 publishing + beta.3 bump (#72) * Add local release toolkit + CI S3 publishing Introduce scripts/manage-release.sh, a human-run release gate modeled on start-os's manage-release.sh, and wire CI to publish build artifacts to S3. CI (build-image.yaml): - Build artifact and GitHub Release now carry both images: the sdcard .img (fresh install) and the sysupgrade .img.gz (OTA). - New step uploads images to s3://startwrt-images (DigitalOcean Spaces, nyc3) -- the only publishing done in CI. manage-release.sh: - download/pull/verify/upload/register/index/sign/cosign/notes and a full-release pipeline (download -> register -> index -> sign -> notes). - Registering, indexing, and signing stay a deliberate local gate run with the developer key, sitting between "built + uploaded" and "live in the registry". - sysupgrade .img.gz is indexed into the registry "squashfs" slot via a hardlink so start-cli resolves the asset kind; the indexed URL still points at the honestly-named .img.gz. Add scripts/startwrt-release.key.asc (public half of the release signing key) for out-of-band fingerprint confirmation. * Bump version to 0.1.0-beta.3 * Update scripts/manage-release.sh Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * update asc file to start9 key --------- Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * Fix/board name (#73) * update board name to spacemit,k1-x so the update is visable to devices checking for updates * update default registry to use https * chore: update to Angular 22 (#69) * chore: update to Angular 22 * fix(web): resolve ng-qrcode peer against Angular 22 via npm overrides ng-qrcode@21 (latest) declares @angular/{core,common} ">=21 <22", which blocks a flag-free `npm ci`/`npm install` on Angular 22 — the exact step `make image` runs (web/Makefile line 113). Override its Angular peers to the root versions so install resolves cleanly with a single Angular 22 and no nested duplicate. Remove once ng-qrcode ships an Angular 22 peer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: small fixes --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(start-wrt): wire migrated product onto shared monorepo code Following the subtree import, dissolve start-wrt's standalone backend Cargo workspace into the root monorepo workspace. The backend (startwrt-core/ctrl, uciedit, uciedit_macros) now links the shared start-core crate (aliased as `startos`, zero source churn) plus the vendored rpc-toolkit and imbl-value, replacing the embedded start-os submodule (removed). openwrt becomes the repo's only git submodule. - build.mk (startwrt / startwrt-image / startwrt-openwrt-setup / startwrt-update / test-startwrt / clean+format), included by the root Makefile; build scripts made monorepo-root-relative (binary now lands in the workspace-root target/) - run-tests.sh + test-startwrt mirror start-core's containerized run-tests.sh, package-scoped so a bare cargo test no longer drags in startos-backup-fs/fuser - .github/workflows/start-wrt.yaml (riscv64 binary on PR, OpenWrt image on dispatch) - web kept standalone (its own package.json) this stage; folding it into the root Angular workspace + @start9labs/shared is the follow-up (Stage B) - docs: product/backend/web AGENTS.md (+ one-line CLAUDE.md), CONTRIBUTING, CHANGELOG; registered in root AGENTS.md + ARCHITECTURE.md Validated: cargo check + 451 unit tests green; make startwrt and make startwrt-image build; Tier 0-3 on-device validation passes on K1. * fix(start-wrt): submit outbound VPN dialog + re-embed UI on web-only rebuilds Two unrelated frontend/backend fixes plus doc touch-ups. - Outbound VPN dialog: adding a VPN silently did nothing. save() called tuiMarkControlAsTouchedAndValidate, which re-ran the WireGuard .conf async validator; the in-flight run is cancelled when the file input remounts during the PENDING phase, so the form stayed PENDING and the create request never fired. Now submit completes directly when the form is already valid, and falls back to markAllAsTouched() (no re-validation) to surface errors when it isn't. - ctrl/build.rs: emit cargo:rerun-if-changed for web/dist so web-only changes re-embed into the startwrt binary. The UI is baked in via include_dir!, which doesn't register embedded files as cargo deps, so a changed bundle was ignored unless a .rs file also changed — shipping a stale UI. - Docs: rename the deploy env var REMOTE -> STARTWRT_REMOTE across AGENTS.md, ARCHITECTURE.md, CONTRIBUTING.md, and add CHANGELOG entries for both fixes. * refactor(start-wrt): fold web into root Angular workspace (Stage B1) Move the StartWRT frontend from a standalone Angular app into the root Angular workspace, matching how ui/setup-wizard/start-tunnel/brochure are wired. Mechanical, no behavior change. - angular.json: add `start-wrt` project (application builder, outputPath dist/startwrt so the existing include_dir! embed path is unchanged, .html/.svg text loaders, taiga less styles, port 8300). - Collapse web/tsconfig.json + tsconfig.app.json into one tsconfig extending the root; redeclare paths (*, @taiga-ui/icons/*, @start9labs/shared). Keep noUncheckedIndexedAccess off (re-assert noImplicitReturns/isolatedModules) to preserve the app's original strictness during the fold-in. - package.json: add build:wrt / start:wrt / check:wrt / check:i18n:wrt; add the two check:*:wrt to the `check` aggregate; add the web dir to the format/format:check globs. No dependency changes (marked/ng-qrcode/taiga addons already satisfied by the root). - Delete standalone web scaffolding (package.json, package-lock.json, angular.json, tsconfig.app.json, .husky). build-config.js resolves paths via __dirname so it runs from the repo root. - build.mk: web dist now built via `npm run build:wrt` with the workspace build:deps prerequisites (WEB_SHARED_SRC + .angular/.updated); fold web prettier into format-web. - CI: add shared-libs/ts-modules/**, angular.json, package.json, package-lock.json, tsconfig.json to start-wrt.yaml paths. - Add root .prettierignore for build outputs (dist/.angular/out-tsc). - Docs/changelog: flip the "web is standalone" notes to "in the root workspace" across root + start-wrt docs. Verified: npm run build:wrt, check:wrt, check:i18n:wrt, format:check, and a host `cargo build -p startwrt-core --bin startwrt` (embeds the workspace-built UI) all pass. * refactor(start-wrt): adopt @start9labs/shared utilities (Stage B2) Replace the three hand-mirrored utilities that have clean shared equivalents: - pauseFor → @start9labs/shared (util/misc.util); delete local utils/pauseFor.ts - RELATIVE_URL token → @start9labs/shared (tokens/relative-url); drop the local token from http.service.ts and app.config.ts - MarkdownPipe → @start9labs/shared (pipes/markdown.pipe); delete local pipes/markdown.pipe.ts (marked stays only in the shared pipe) Kept local by design (investigated during B2): - HttpService/RpcService/ConnectionService — start-wrt uses an *aborting* per-request timeout (rxjs `timeout`) that surfaces a code-0 network error and tears down wedged HTTP/2 connections for the reconnect flow; the shared HttpService's race-based timeout does not abort, so swapping would regress the reconnect UX. - Error surfacing — ActionService/FormService route network drops into the global reconnect indicator with per-action copy; there is no ErrorService mirror to replace. - WorkspaceConfig (flat config.json), the WebSocket progress types (start-wrt ABI, not start-core's), and the i18n-routed validation-errors provider — no clean shared equivalent. Mark @start9labs/shared `sideEffects: false` so importing a few symbols from its barrel tree-shakes: without it, start-wrt's first-ever shared import dragged in ~875 kB of unused shared code (server-name-words, ansi-to-html, the monaco logs-window, …), pushing the embedded UI bundle to 1.85 MB (over its 1.5 MB budget). The lib is verified side-effect-free; the flag also shrinks the other apps' bundles. Verified: npm run check (whole workspace), build:wrt (974.93 kB, under budget), build:tunnel, cargo build -p startwrt-core --bin startwrt, and format:check all pass. * fix(start-wrt): keep UI Build field's git hash fresh and mark dirty builds The Settings → General "Build" field showed a stale git hash after the monorepo migration (frozen at the import commit). Two causes: 1. build.mk lost the wiring that refreshed build/env/GIT_HASH.txt every build and made it a prerequisite of web/config.json, so the stamp never re-ran when HEAD moved. Restore it: run check-git-hash.sh at parse time and list GIT_HASH.txt as a prereq of config.json. 2. The UI shortened the hash with slice(0, 12), dropping the trailing "-modified" dirty marker. shortGitHash now preserves any trailing marker, matching the "-dirty" indicator `startwrt verify` already prints. * fix(start-wrt): restore release CI dropped in the monorepo migration The standalone build-image.yaml published releases (S3 upload + GitHub Release) on a v* tag push, but the migration into the monorepo dropped that job — start-wrt.yaml could build the image yet never publish it. Restore publishing as a `deploy` job, following the canonical start-os pattern (startos-iso.yaml): gated on a manual workflow_dispatch with a `deploy: release` input rather than a tag push, since no product in the monorepo releases by tag. It uploads the built images to s3://startwrt-images and cuts a GitHub Release, reading the version from backend/ctrl/Cargo.toml (the web/package.json the standalone workflow read was removed when the UI folded into the root Angular workspace). Registry indexing/signing stays a deliberate local gate in scripts/manage-release.sh, re-pointed here at the new workflow and the `startwrt-openwrt-image` artifact name. Also restore the OpenWrt download-cache keying the migration had narrowed: the image job's cache key again includes build/feeds.conf (so changing the feed set busts the cache) and carries a restore-keys fallback for partial restores. Updates projects/start-wrt/CHANGELOG.md. * fix(start-wrt): guard against colliding profile subnets Changing the Router IP could strand the network on a subnet already owned by another profile. The LAN IPv4 page exposed a "Router IP" (3rd-octet) field that duplicated the Admin Security Profile's subnet field but, unlike it, had no collision guard — so pointing the router at an in-use /24 put two interfaces on the same subnet, producing overlapping routes that silently broke all access to the router (unrecoverable even by a keep-settings reflash). Remove the duplicate field: the LAN page now only selects the /16 network block, and the Admin profile is the single source of truth for the 3rd octet (routerOctet stays in the model, populated from the loaded IP, so a network-block change preserves the subnet and the summary can still show the router IP). Add a backend guard so a direct RPC/CLI call can't bypass the UI: profiles.create/profiles.edit now reject a gateway whose /24 collides with an existing profile (including the admin LAN), via a new SubnetCollision error kind. Edits that keep their own subnet are skipped, so no-op edits — and recovery from an already-broken config — still pass. * docs(start-wrt): add StartWRT user manual to the docs site Fold the StartWRT documentation book (previously in the standalone start-docs repo, branch feat/start-wrt) into the monorepo at projects/start-wrt/docs/, matching the layout of the other product books (start-os, start-tunnel, start-sdk). The 24-page mdBook covers install, setup, security profiles, WiFi/VPN/WAN/LAN, backups, and reference. Wire the book into the shared docs site: - versions.conf: register start-wrt=0.1.0.x (drives build, deploy, nginx) - build.sh: map the start-wrt book to its product dir - serve.sh + landing/index.html: add the StartWRT URL/card - generate-llms-txt.ts: add the StartWRT label/description - docs-deploy.yml: trigger deploy on projects/start-wrt/docs/** - .gitignore: ignore the in-place docs/book/ build output - book.toml: adapt to monorepo conventions (build-dir, git/edit URLs, shared theme; drop docs-agent assets absent from this theme) Correct doc claims that no longer match the shipped UI/backend, found by auditing every page against the Rust backend and Angular UI: remove the nonexistent LAN "Blacklist" access mode; move the LAN "Router IP" to the Admin profile subnet; fix the SLAAC-disable trigger; drop the phantom SSH-key "name" step; correct the DDNS status fields and provider label; soften "restarts the router" to a network reload; note the random Root CA name suffix; scope the "no separate database" claim; and fix the Wi-Fi password-preservation rationale. Also repair the theme symlink for the existing product books (start-os/start-tunnel/start-sdk): the monorepo migration pointed them at the nonexistent projects/docs/theme, breaking the docs build. Retarget all four to ../../start-docs/theme. Update start-docs/AGENTS.md and start-wrt/ARCHITECTURE.md to reflect the new book and docs/'s dual role. * fix(start-wrt): enforce 12-character password minimum on password change The Settings → Password form and its auth.set-password backend endpoint (also reached via `startwrt auth set-password`) accepted passwords shorter than 12 characters, even though first-time setup required it and the docs documented the minimum. A weak password could be set from the Settings tab or the CLI, contradicting set-initial-password and settings.md. Backend: extract a shared validate_password_length() helper (MIN_PASSWORD_LEN = 12) and call it from both reset_password_impl and set_initial_password_impl so the rule can't drift between the two endpoints; add boundary tests. Frontend: add Validators.minLength(12) to the new-password control and the existing 'minlength' i18n error to the settings password form, matching the setup screens (the error string is already in all five dictionaries). * fix(start-wrt): close the lan.ipv4-set gap in the subnet-collision guard The colliding-subnet fix (715f34364) guarded profiles.create/profiles.edit but left lan.ipv4-set unguarded: when only the 3rd octet changed, nothing stopped a direct RPC/CLI call (`startwrt lan ipv4-set`) from moving the LAN onto another profile's /24 — the exact stranded-router bug the commit fixed. The UI path was closed (the Router IP field is gone), but the backend vector the guard was added for remained open. Call guard_subnet_collision from ipv4_set before any config is mutated. On a network-block change the profiles keep their 3rd octet, so the new 3rd octet is tested inside the *current* block (subnet_guard_ip), which is equivalent to the post-move state — a block change that would land the LAN on a profile's relative /24 is rejected too, while no-op re-sets and same-octet block moves still pass. Integration + unit tests cover all four cases; API_CONTRACT.md documents the SubnetCollision error. Also update the /lan/ipv4 in-app help (all five languages), which still described the removed editable "Router IP" field: the router address is now explained as the gateway of the Admin profile's subnet, matching lan.md. * ci: stop cloning the openwrt submodule in jobs that don't need it Adding projects/start-wrt/openwrt gave the repo its first git submodule, and every pre-existing workflow checks out with `submodules: recursive` — a no-op until now, but a full clone of the multi-GB OpenWrt fork ever since. Worst hit is test.yaml, which runs both its jobs on every push/PR. Only start-wrt's image job needs the submodule (start-wrt.yaml already scopes it correctly); switch everything else to `submodules: false`. build.yml/release.yml are the reusable workflows executed in external service repos, so their `recursive` refers to those repos' submodules and stays. * fix(start-wrt): track shared-crate and full-web-dir build inputs in build.mk $(STARTWRT_BIN) only depended on the backend tree, but startwrt-core path-depends on start-core (aliased startos), rpc-toolkit, and imbl-value — so after editing shared crate code, `make startwrt`/`make startwrt-update` saw the binary as up to date and could deploy a stale build. Add those crates (via CORE_SRC, matching tunnelbox) plus Cargo.toml/Cargo.lock as prerequisites, which also re-syncs build.mk with the shared-libs paths the CI workflow already lists (root AGENTS.md "Coupled changes"). Also widen STARTWRT_WEB_SRC from web/src to the whole tracked web/ dir so web/assets and web/tsconfig.json retrigger the UI build, matching how the other apps' source globs work. * fix(start-wrt): namespace releases per product and point tooling/docs at the monorepo Releases now live on Start9Labs/start-technologies, which hosts every product's releases on independent cadences — so bare v* tags would collide across products (StartOS history already owns v0.x). Tag StartWRT releases startwrt/v<version> and name them "StartWRT v<version>" in the deploy job. manage-release.sh still pointed its gh calls at the dead standalone Start9Labs/start-wrt repo (despite the release-CI restore claiming it was re-pointed) — fix REPO and the release-tag references to match the new scheme. The user manual's download link, source-code pointer, and both issue-tracker links likewise moved from the standalone repo to the monorepo. * docs: add start-wrt to the root and shared-libs docs the migration missed The migration updated the root AGENTS.md/ARCHITECTURE.md product lists but missed the rest of the hierarchy: - README.md: StartWRT row in the product table + "rest of the monorepo" entry - AGENTS.md: "all five bins" -> six - CONTRIBUTING.md: startwrt build target, test-startwrt, format-startwrt - Makefile help: startwrt/startwrt-image and test-startwrt lines - start-core crate docs (AGENTS/README/ARCHITECTURE): startwrt as the sixth consumer, noting it is a full backend importing the crate aliased as `startos` rather than a MultiExecutable wrapper - shared-libs/AGENTS.md + ts-modules AGENTS/ARCHITECTURE: start-wrt in the Angular workspace app lists ("four apps" -> five; UI ships embedded in the startwrt binary) * fix(start-wrt): name release assets per the startos convention The release assets were the raw OpenWrt output names (openwrt-spacemit-k1-sbc-bananapi-f3-squashfs-{sdcard.img,sysupgrade.img.gz}) — no product, version, or hash, indistinguishable on a shared monorepo releases page. Rename them on the copy into results/ to follow start-os's basename convention (build/env/basename.sh: <project>-<version>-<githash7>_ <platform>), plus a role suffix since both artifacts share the .img/.img.gz extensions: startwrt-<version>-<githash7>_spacemit-k1-sdcard.img startwrt-<version>-<githash7>_spacemit-k1-sysupgrade.img.gz The basename is composed in build.mk from start-wrt's own stamps (basename.sh reads StartOS's PLATFORM/ENVIRONMENT/GIT_HASH build state and a manifest path that doesn't exist here); the version sed matches the workflow's Determine version step. Keeping the -sdcard.img / -sysupgrade.img.gz endings preserves every downstream match: manage-release.sh's suffix globs and cmd_notes regexes, the .img.gz -> .squashfs hardlink for registry kind inference, and the workflow's extension-based S3/gh-release globs. Only the image job's upload-artifact path glob keyed on the openwrt- prefix and is updated. installing.md now names the real download filename shape (keeping startwrt.img as an explicit placeholder in the checksum commands). * fix(start-wrt): unbreak the two failing CI jobs - build.rs now creates the web dist dir before compilation, so cargo test/check builds (CI's make test, fresh clones) no longer panic in include_dir! when the Angular app was never built. Real builds are unaffected: make startwrt populates the dir first via the $(STARTWRT_WEB_DIST) prerequisite. - The compile job installs binutils-riscv64-linux-gnu so build/verify-isa.sh finds a riscv64 objdump on the runner instead of failing the build after a successful cross-compile. * fix(start-core): tolerate empty scope dirs when bundling node_modules into dist npm 10 leaves empty scope directories (@emnapi, @napi-rs, @tybys) behind when it skips optional wasm-fallback packages, and cpy fails on them ("Cannot copy ...: the file doesn't exist"), breaking make dist and everything downstream (make test, make startwrt). Copy node_modules with cp -a instead; cpy still handles the lib globs. --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | 2 个月前 | |
fix(start-wrt): write the eMMC partition table instead of trusting the card's (#3930) The flash raw-copied sector 0 through the end of the squashfs from the microSD card to the eMMC and then expected the copied primary GPT header to parse. That holds only while the card still carries the image's own header, which describes a ~580 MB disk. Any partition tool that "fixes" the card (parted/gparted's Fix prompt, sgdisk -e, a write from fdisk) rewrites the header's last usable LBA to the card's real size; copied onto a 16 GB eMMC from a larger card, that header is rejected by libfdisk, the kernel and U-Boot alike, and all three fall back to whatever backup GPT the eMMC already held at its last sector. On a factory board that table has a rootfs and no rootfs_data, so the flash died with `rootfs_data partition not found on eMMC` and the board no longer booted from eMMC (`GPT: last_usable_lba incorrect: 76F4FDE > 1d1f000`). The hardware supplier hit this flashing v1.1.0 on 2026-09-11. After the copy, dump the card's table with sfdisk, drop the two lines that describe the card (`device`, `last-lba`) and the overlay's size, and write the result to the eMMC with `sfdisk --force`. sfdisk derives the last usable sector from the eMMC, writes both the primary and the backup copy, and extends rootfs_data to the free space, which the existing expansion step then pins to the last usable sector as before. Dropping the size covers a card whose rootfs_data was also grown to fill it, which sfdisk would otherwise refuse on the smaller eMMC. Bench-verified on a BPI-F3 with a 32 GB card: the relocated-header case reproduced the supplier's failure on 1.1.0 and flashed clean on this build, as did a card with rootfs_data grown to 29.4 GB; the eMMC booted after each. A pristine card is unaffected. Claude-Session: https://claude.ai/code/session_01C779xbxXwoX1AGUKchrcMz | 4 天前 | |
feat(start-wrt): SNI hostname routes end to end — UPnP vendor actions, StartWRT dataplane, Remote Access coexistence (#3783) * feat(start-tunnel): UPnP vendor action for SNI hostname mappings Add X_START9_AddHostnameMapping / X_START9_DeleteHostnameMapping to the shared UPnP IGD server, giving UPnP parity with the PCP HOSTNAME option: a client that reaches a Start9 gateway over UPnP but not PCP no longer silently loses SNI demux. The StartOS port-map client falls back to the vendor action when no gateway grants the hostname over PCP, detecting support via the SCPD action list the discovery already fetched (no PatchDb field, no TS bindings). Refresh re-asserts the route without a remote delete — registration reclaims idempotently for the same target, so re-adding in place avoids a per-tick outage window. Unlike standard UPnP mappings, vendor-action routes are always lease-bearing (clamped to the server max): a permanent SNI binding is reserved for operator-created routes, and an unreaped device route would answer HostnameTaken (fault 800) to its legitimate owner forever. The delete action carries NewInternalPort because an SNI route's ownership is its full (peer, internal port) target, mirroring the PCP lifetime-0 MAP, and is gated on is_known_client like the PCP delete. Non-TCP requests are refused (the demux is TCP-only), and hostnames are validated client-side before being interpolated into the envelope. Both handlers guard on the backend having an SNI dataplane, faulting 801 HostnameNotSupported otherwise — the UPnP twin of the PCP path's RESULT_UNSUPP_HOSTNAME refusal. Served by StartTunnel today; a StartWRT gateway (which has no dataplane yet) advertises-but-refuses until its demux lands, then serves the action with no further edit. * feat(start-wrt): SNI hostname-route dataplane StartWRT's port-control gateway now serves TLS-SNI hostname routes end to end — PCP HOSTNAME and the X_START9_AddHostnameMapping UPnP vendor action both work against the router instead of faulting 801, so several devices (or several services on one StartOS server) share an external port such as 443, demuxed by ClientHello hostname. Dataplane: the shared SniDemux runs as-is; what StartWRT needed was the plumbing around it. The reply-path divert's nft half ships declaratively as an fw4 include (12-startwrt-sni-divert.nft, `mark or` to preserve the 0x80 DNAT-return bit) — fw4 re-renders includes on every reload, so no reload window can drop it. The iproute2 half is parameterized via a new shared DivertConfig (route table 5344 to clear the VLAN-tag table namespace, masked fwmark to match the or-set mark, manage_nft off); defaults reproduce StartOS/StartTunnel behavior bit-for-bit. The `socket transparent` expression needs kmod-nft-socket (+kmod-nf-socket), added to the image diffconfig in this same commit — without the module fw4 refuses the entire ruleset, so the include and the kmods must ship together. Admission: each demuxed port gets a WAN-input ACCEPT rule (apf_sni_<port>, tagged _apf_label 'SNI' via a new FirewallRule field), written inline under the write lock so a concurrent plain-forward scan can never miss it; the demux's on_change teardown drops it, the sweep heals strays and gaps, and daemon start purges leftovers (routes are demux-memory only — finite-lease, device-renewed — so rules must not outlive them). The rule also makes the port read as router-reserved, keeping plain auto forwards off a demuxed port for free. Conversely add_sni_forward refuses ports already DNAT-forwarded or answered by the router itself (Remote Access, VPN): the demux's specific (wan_ip, port) bind would beat their wildcard binds and capture traffic it has no route for. WAN re-key: listeners bind the WAN address itself, so a new address strands them. A new wan hotplug hook fires published-ports.wan-changed (hidden RPC, daemon-forwarded like reconcile) to re-key immediately via the new shared SniDemux::rekey_ipv4 — which never fires the teardown callback, since the port set is unchanged — with the sweep as a once-a-minute backstop. Visibility: published-ports.auto-list now appends one row per live route (label "SNI", new hostname field, device resolved from the target address) via the new shared SniDemux::snapshot; the Automatic table gains a Hostname column. API_CONTRACT, the user docs' Automatic Port Forwarding page, and the unreleased 1.1.0 changelog entry (which claimed StartWRT has no SNI demux) updated to match; build.mk's staging deps now cover backend/hotplug and backend/nftables (pre-existing gap). * fix(start-wrt): SNI demux binds beside the UI and coexists with Remote Access Bench testing found the StartWRT dataplane dead on 443 and worse than dead: the web UI wildcard-binds [::]:443, so the demux's specific (wan_ip, 443) bind failed EADDRINUSE forever in its spawned retry loop — while the grant had already succeeded and opened the apf_sni_443 WAN admit rule. WAN 443 traffic fell through to the UI's wildcard socket, serving the router admin interface to WAN clients with Remote Access set to Never. On a public-WAN router (where "behind NAT" mode writes no rules and so nothing conflict-refuses the route) that would have been the open internet. Three changes close it: - The demux listener and the daemon's UI 80/443 listeners all bind with SO_REUSEPORT (the DNS :53 pattern). TCP delivery prefers the most specific bound address, so the demux takes WAN-IP-destined connections and the UI wildcard keeps the LAN. - Grants are bind-gated: SniDemux::register/register_fallback bind inline and refuse with PCP NO_RESOURCES (UPnP fault 501) on failure, rolling back the registration — a grant can never outrun its socket and leave the admitted port served by whatever shares it. A re-key bind failure now drops that port's routes and fires the teardown callback rather than stranding the admit rule. - Remote Access coexists with hostname routes on 443 instead of reserving it (it is the default mode behind NAT, and demanding it be turned off to share 443 was untenable): its rules no longer count as SNI conflicts on 443; the daemon instead registers its own UI as the demuxed port's fallback, so no-SNI/unknown-SNI connections (browsing the router by IP sends no SNI) still reach the UI. The fallback leg is a plain connect (a source-preserving dial to ourselves would be martian-dropped) and enforces the same source scoping the displaced firewall rules encoded — any source in "always", RFC1918-only in "default" behind NAT, none in "never" — re-synced on route add, on a Remote Access change, and by the sweep, which also clears it when the last 443 route expires. SSH (server-speaks-first) and the port-80 redirect (plain HTTP) can't ride an SNI peek, so those Remote Access ports — and manual forwards and the VPN port — keep refusing routes. The IGD hostname stub tests move to an unprivileged external port: registration now really binds, and 443 needs root the runner lacks. * feat(start-wrt): router-port confirm dialog names the port's actual holder The publish-confirmation dialog said "Used by This Router" even when the colliding WAN-input rule was an SNI-demux admit rule — a port really held by a device's hostname routes. router_reserved_overlaps now classifies each overlapping rule, RouterPortCollision splits the specs into router_ports and sni_ports (the latter enriched from the live demux with the routed hostnames and owning devices, named the same way auto-list rows are), and the dialog composes its copy from the actual holders: router services, hostname routes, or both on a shared port. The override semantics are unchanged. * fix(start-wrt): revoking a device's permission drops its SNI hostname routes `close_device_forwards` removed a device's `apf_*` UCI redirects but never touched the SNI demux, so its hostname routes kept delivering WAN traffic — admit rule and all — until their lease lapsed, up to an hour after the toggle went off or the device was forgotten. StartTunnel's `clear_for_peer` already covers routes; this brings StartWRT in line. Routes are keyed by target address, not device, so rather than carry a grant-time address→MAC map that can drift, the owner is re-derived the way a grant derives it (neighbor table, else DHCP leases and static hosts) and any route whose device is no longer `_allow_pcp` is unregistered. That audit runs on every revocation and once a minute from the sweep, which also catches an address recycled to a different, unauthorized device. An address that maps to no device is left to its lease rather than reaped on a guess. * fix(start-wrt): hairpinned SNI clients get a working connection, not a hang A LAN client dialing the router's WAN address for a routed hostname hung. The demux opens the internal leg from the client's own source address, so the target — sitting on the same bridge as the client — answered it directly over that segment. The reply never came back through the router, and the client discarded it as coming from an address it never dialed. Drop source preservation for exactly that case and dial the target as ourselves instead, the trade the DNAT path already makes with the hairpin masquerade in `build/lib/scripts/forward-port` — and, on this router, the one fw4's redirect reflection already makes for every manual published port, so the two now behave alike. The demux learns a host's segments through `LocalPrefix`, the userspace equivalent of that script's `target_prefix`: StartWRT answers it from the `br-*` addresses it already parses for SSDP, so the test is per-bridge. A client on another profile's VLAN, or on the WAN, still routes its replies through the router and keeps its address. The tunnel supplies no resolver — every path through its WireGuard hub returns to it, so nothing there needs the trade. The check runs before the dial, never as a fallback after a failed one: the backend gates LAN-only addresses on the source being private, so a blanket plain-connect would present a WAN client as LAN-local. * docs(start-wrt): a hostname route is reachable from every Security Profile The trust note tells the reader to isolate an untrusted device on its own profile so it cannot act for the devices they trust — true of opening forwards, and easy to over-read as reach isolation. A profile's LAN Access setting is a forwarding control, and the router serves a hostname route from its own socket, so no profile boundary stands between a client and a routed hostname. Say so, alongside the fact that makes it uninteresting: the service is published to the Internet either way. * docs(rfcs): UPnP vendor-action status claims only Start9-internal acceptance Aiden's review flagged the bare "Status: accepted" as reading like a standards-status claim. The doc is not IETF-targeted — its own open question 4 notes a UPnP vendor action has no standards venue — so the status line now scopes the acceptance to Start9 and says where external documentation lives (the SCPD). Claude-Session: https://claude.ai/code/session_012peNJE6QiAEMDJEjWYDwjE * fix: address SNI hostname route review findings * fix: make SNI route grants transactional * fix(start-wrt): inline divert setup error mapping * fix(start-os): restore mapped external IP handling * chore: regenerate start-core TypeScript bindings * style(start-os): format port-map tests --------- Co-authored-by: Helix <267227783+helix-nine@users.noreply.github.com> | 13 天前 | |
docs: backport live-docs db4a83ebc to master Backports the following from live-docs: - db4a83ebc docs: landing-page card order; correct the StartWRT Wi-Fi module spec (#3938) | 11 小时前 | |
feat(start-wrt): provision the eMMC boot partitions (boot0/boot1) in flash and sysupgrade (#3607) * feat(start-wrt): provision the eMMC boot partitions in flash and sysupgrade The K1 BootROM boots eMMC from the hardware boot partitions (an 80-byte bootinfo header at boot0 offset 0 pointing at the FSBL/u-boot SPL, with a mirror in boot1) — regions only vendor factory tooling (Titan/fastboot) ever wrote. The wizard's raw copy covers just the user area, so a DIY BananaPi BPI-F3 whose factory left boot0 empty or carrying an incompatible bootloader vintage completed setup but couldn't boot once the microSD was removed. Boards that worked did so on unversioned factory FSBLs that every sysupgrade re-pairs with new OpenSBI/u-boot — an untested combination. Converge boot0/boot1 to the release's own bootinfo + FSBL (built from the pinned spacemit-com/uboot-2022.10 source; the FSBL is read from the image's fsbl partition — the binary that booted the running system) in both write paths: - flash wizard/CLI: backend/ctrl/src/boot0.rs, called from run_flash_core after the user-area copy (progress stays within the existing flash step) - sysupgrade: /lib/upgrade/spacemit_boot0.sh (target-level base-files, auto-sourced), called from both subtargets' platform_do_upgrade; logs but never aborts the upgrade Idempotent (byte-compared, skipped when already current), read-back verified (O_DIRECT / iflag=direct where supported), commit-safe write order (boot1 mirror, boot0 FSBL, single-sector bootinfo header last). EXT_CSD boot-enable bits are deliberately untouched — the vendor factory flasher never sets them either. bootinfo_emmc.bin now ships in the bootfs FAT partition (previously only packed into the Titan archive) as the runtime source for both paths, and uboot-spacemit gains a real PKG_MIRROR_HASH in place of skip. * fix(start-wrt): make sysupgrade boot0 provisioning actually run in stage2 Two bugs found by the corrupted-boot1 OTA smoke test (boot1 came back unrepaired, hash-identified as untouched): - The bootinfo magic was validated against the wrong decimal (2953778416 = 0xb00f14f0, a mistranslation of 0xb00714f0 = 2953254128), so the helper rejected every valid bootinfo_emmc.bin and bailed before writing anything. The Rust path was unaffected (it compares in hex, golden-tested). - Region comparison used cmp, which is not in the fixed binary list stage2's switch_to_ramfs copies into the upgrade ramfs — the gate and the read-back verify would both fail there. Compare via md5sum instead, which is on the upstream list. * feat(start-wrt): ship MT7915 wifi firmware so MT7915-based DIY cards initialize The image carries the kmod-mt7915e driver (it covers MT7915 and MT7916) but only the MT7916 firmware files, so an MT7915-based card (e.g. AsiaRF AW7915-NP1) probes, fails the mt7915_rom_patch.bin load with -2, and never registers a PHY. Add kmod-mt7915-firmware; the two firmware packages coexist and the driver requests the set matching the detected chip. Band-selectable MT7915 cards still run one band at a time: radio0 (2.4 GHz, bare PCIe path) resolves to the single PHY, radio1 (+1 path suffix) only exists on DBDC cards. Document the DIY wifi-card support matrix. * docs(start-wrt): move eMMC boot-provisioning changelog entry under 1.0.2 The entry was written under 1.0.0 while that version was still prospective, but 1.0.0 and 1.0.1 have since been released from master without this branch — the feature actually ships in 1.0.2. * chore(start-wrt): bump the prospective version to 1.1.0 New-hardware support (MT7915 Wi-Fi firmware) is a minor per semver, not a patch, so the unreleased 1.0.2 heading — never tagged, never shipped — becomes 1.1.0 in place, with the manifest bumped to match. * feat(start-wrt): converge eMMC boot firmware on every boot sysupgrade stage2 runs the OLD system's upgrade scripts, so the release that introduces boot0 provisioning (or a future FSBL change) cannot converge a box during its own installation — only the upgrade after it could. Add a preinit hook (registered after 79_move_config on the same preinit_mount_root stage) that runs the existing spacemit_provision_boot0 helper on every boot: provisioning takes effect on the first boot after installing this release, and damaged or interrupted boot-firmware writes self-heal thereafter. A converged board performs three region reads and writes nothing. Output lands in /tmp/spacemit_boot0.log — syslog is not up during preinit. The stage2 hook stays: only upgrade-time provisioning can move the whole boot chain within a single power cycle when a future release changes the FSBL. | 26 天前 | |
chore(license): fixes from license hygiene audit (#3627) * chore(license): replace proprietary fonts with an open family Proxima Nova (Copyright (c) Mark Simonson, all rights reserved) and MBF Minimal Custom (Copyright (c) MoonBandit, all rights reserved) were tracked in-tree and copied into the ui, setup-wizard, start-tunnel, start-wrt and marketplace bundles by angular.json, under a repository that grants recipients MIT rights to distribute and sublicense. Replace them with Hanken Grotesk (SIL OFL 1.1). One variable face covers the whole 100-900 range the seven static Proxima weights were serving, so the two woff2 subsets are 54 KB against the 460 KB they replace. The MBF face was declared in start-tunnel but no rule ever applied the family, so it is dropped rather than replaced. * chore(license): carve the GPL-2.0 OpenWrt material out of start-wrt's MIT grant projects/start-wrt/LICENSE claimed MIT over the whole project, but sixteen files under openwrt-overlay/ carry an explicit SPDX-License-Identifier: GPL-2.0-only header naming SpaceMiT Ltd. or OpenWrt.org, and openwrt-patches/ are derivative works of GPL-2.0 OpenWrt sources. GPL-2.0 cannot be relicensed downstream, so the MIT grant was offering rights Start9 does not hold. Add COPYING (GPL-2.0) plus a README to each directory recording provenance and the GPL-2.0 §3 source obligation, and scope the MIT grant in the LICENSE and README to exclude them. CONTRIBUTING described openwrt-overlay/ as "the Start9 additions"; it is mostly SpaceMiT's BSP, so say so. * chore(license): attribute vendored third-party code imbl-value's src/de.rs is 84% line-identical to serde_json's src/value/de.rs (longest identical run 107 lines), ser.rs 80% and index.rs 74%, and macros.rs is serde_json's json! muncher with its comments intact — yet LICENSE named only Start9 and AGENTS.md asserted the impls were not serde_json's. All the upstreams here are MIT or MIT-compatible, so this is notice compliance, not relicensing. - imbl-value: credit Erick Tryzelaar and David Tolnay in LICENSE, record the derivation in README, and correct the AGENTS.md bullet. - patch-db/json-patch: a fork of idubrov/json-patch with serde_json swapped for imbl-value + json-ptr, shipping no license text despite declaring dual terms. Add LICENSE-MIT and the verbatim LICENSE-APACHE, a README recording the fork, and normalize the SPDX expression to "MIT OR Apache-2.0". - jsonpath: fill in the MIT template placeholders left as "[2019] [Changseok Han]" and credit the upstream in the README. - init_resize.sh: name RPi-Distro/raspi-config, which it derives from. json-patch and jsonpath_lib are also set publish = false: both sit at crates.io names owned by their upstreams, so publishing a diverged fork from here would be wrong even if it were possible. * chore(license): declare MIT on every manifest, ship it with published crates Five Cargo manifests (backup-fs, patch-db-util, the three start-wrt crates) and eleven package.json files declared no license at all, and the workspace root has no [workspace.package] table to inherit one from, so tooling reported them as unlicensed. Declare MIT on each. exver and rpc-toolkit are published to crates.io but carried no license text in their tarballs; give them a LICENSE. Normalize the copyright holder across the existing files, which variously read "Start9", "Start9 Labs" and "Start9 Labs, Inc.", and rename yasi's LICENSE.md so cargo packages it by convention. The upload-each action's committed ncc bundle inlined 97 dependencies with their notices stripped; build it with --license so the per-dependency notices are generated and committed alongside. The bundle itself is byte-identical. * chore(license): add NOTICE.md and state the licensing policy Nothing in the repo said what "everything is MIT" excludes, so the claim could not be checked. NOTICE.md is now the complete list of files under other terms — if something is not named there, it is MIT — and the root LICENSE, README and the StartOS architecture doc point at it. Also: - CONTRIBUTING gains an inbound-licensing statement and a rule that vendored code keeps its notice and lands in NOTICE.md in the same PR. - .deb packages are built with a usr/share/doc/<pkg>/copyright file, which Debian policy requires and which the hand-written control block omitted. - deny.toml moves to the workspace root and drops copyleft/unlicensed/ allow-osi-fsf-free, removed from cargo-deny's schema in 0.14. Note nothing in CI runs it yet, so it remains documentation rather than a gate. - The Intel BIOS capsule mirrored for the discontinued 2023 Server One is attributed to Intel on the page that serves it. It is Intel's, not ours, and the architecture doc no longer implies the shipped image is MIT end to end. * chore(license): sync lockfiles with the added license fields npm records the root package's license in package-lock.json, so declaring it in package.json leaves the lockfile out of sync — which is exactly what CI's `npm ci` drift gate rejects. Five lockfiles, one line each, no dependency churn. * chore(license): taplo-format deny.toml The relocated file landed at the repo root without going through taplo, whose reorder_keys sorts each [[licenses.clarify]] table's keys. Caught by CI's format-check, which I hadn't run locally because it needs the fmt container. * chore(license): make deny.toml describe the tree it actually governs Nothing had ever run this policy, so it had drifted from reality in both directions. Ran cargo-deny against the real dependency graph: - LGPL-3.0, OpenSSL and Unicode-DFS-2016 were allowed but appear nowhere. LGPL is the one that mattered: allowing it invited a copyleft dependency that would have been incompatible with shipping start-core as MIT. - The webpki and ring clarifications are dead — webpki is gone (rustls-webpki now) and ring declares "Apache-2.0 AND ISC" itself. - Four permissive licenses genuinely in the tree were missing, so 25 crates were rejected for no good reason: Unicode-3.0 (the ICU stack), CDLA-Permissive-2.0 (webpki-roots), BSL-1.0 (xxhash-rust, lazy-bytes-cast) and 0BSD (quoted_printable). MPL-2.0 stays allowed and is now explained: its copyleft is per-file, so linking imbl and friends into an MIT binary is fine as long as we don't modify them. This leaves two genuine GPL-3.0-or-later rejections, which need code changes rather than a policy change, so the CI gate lands with that fix. * chore(license): revert the overreach in this branch Review of my own decisions found several that were wrong or went further than the evidence supported. Reverting them. - Restore "LGPL-3.0" to deny.toml. My commit message claimed allowing it was "incompatible with shipping start-core as MIT". That is wrong: LGPL-3.0 §4 expressly permits combining the library with a work conveyed under other terms, subject to notice and relink conditions. Worse, the entry was not drift — 5b22d0a3b (Aiden McClelland, 2021-06-17) added it in the commit that created the file, as a deliberate carve-out. An unmatched allowance is only a warning, so removing it bought nothing and would have hard-rejected a future LGPL dependency that is in fact perfectly usable. - Restore the root LICENSE to byte-canonical MIT. Editing the license body to add a carve-out risks breaking automated license detection, and the carve-out already lives in README.md and NOTICE.md. Same for start-wrt's LICENSE. - Restore extract-ikconfig. Deleting a working debugging tool from the image was a functional change made for a licensing reason that a NOTICE entry solves. - Restore jsonpath's LICENSE verbatim. Filling in a third party's copyright placeholders is not ours to do; the clarification belongs in the README. - CONTRIBUTING lumped LGPL in with GPL/AGPL as forbidden. Corrected: GPL/AGPL can't be linked into our binaries, LGPL and MPL-2.0 can, with obligations. - NOTICE claimed to be "the complete list" and that anything unlisted is MIT. Softened — I can't guarantee completeness. - Moved brand marks out of "Not MIT" into their own Trademarks section. Trademark is not copyright and is not granted or withheld by a copyright license, so listing logos as a licensing exception was a category error. - The MPL note said obligations attach only if we modify the sources. They attach on distribution (§3.2), modified or not. - The overlay README asserted every unheadered file was GPL "including the Start9-authored ones, which are derivative works". A new file in an OpenWrt tree is not automatically derivative; that gave away Start9's own copyright by assertion. Now stated as a deliberate contribution choice. Also dropped "the one exception" (NOTICE lists several) and softened GPL-2.0-only to GPL-licensed, since the tree mixes -only and -or-later. - Restore the start-wrt font-weight design comment, reworded for the variable face rather than deleted. * refactor(core): replace socks5-impl with fast-socks5 socks5-impl is GPL-3.0-or-later, and it was a direct dependency of start-core, so every product binary linking start-core would have to be conveyed under GPL-3.0. No permissive version exists upstream — the current 0.9.6 is still GPL-3.0-or-later — so this is a swap rather than a bump. fast-socks5 (MIT) covers the same ground: read_command() hands back the target before connecting, which is what lets us keep intercepting .onion (tunnel via the tor service's SOCKS proxy) and .local (resolve over mDNS), and get_socket() unwraps the client tunnel to a plain TcpStream so the keepalive still applies. BIND and UDP ASSOCIATE are still answered CommandNotSupported. The proxy had no test coverage, so this adds three: a round trip through the proxy to an echo server, an unreachable target refused rather than hung, and BIND/UDP rejected. * refactor(start-wrt): replace tracing-rfc-5424 with syslog-tracing, gate licenses in CI tracing-rfc-5424 is GPL-3.0-or-later — the last crate in the tree whose terms we can't meet while conveying our binaries under MIT. syslog-tracing (MIT) writes through libc's syslog(), which lands in the same /dev/log that the old UnixSocket transport targeted, so logread still sees the entries. Its MakeWriter maps tracing levels to syslog severities via make_writer_for. init_logging took a name it then ignored; syslog-tracing can use it as the openlog identity, so startwrt-cli and startwrt-ctrld are now distinguishable in logread instead of sharing one tag. The startwrt-activity marker activity.rs greps for is in the message body, so it is unaffected. Timestamps and tag are dropped from the formatter because syslogd adds its own. With that, `cargo deny check licenses` passes, so it becomes a CI job. LGPL-3.0 stays allowed and simply reports as an unmatched allowance. | 1 个月前 | |
fix(start-wrt): write the eMMC partition table instead of trusting the card's (#3930) The flash raw-copied sector 0 through the end of the squashfs from the microSD card to the eMMC and then expected the copied primary GPT header to parse. That holds only while the card still carries the image's own header, which describes a ~580 MB disk. Any partition tool that "fixes" the card (parted/gparted's Fix prompt, sgdisk -e, a write from fdisk) rewrites the header's last usable LBA to the card's real size; copied onto a 16 GB eMMC from a larger card, that header is rejected by libfdisk, the kernel and U-Boot alike, and all three fall back to whatever backup GPT the eMMC already held at its last sector. On a factory board that table has a rootfs and no rootfs_data, so the flash died with `rootfs_data partition not found on eMMC` and the board no longer booted from eMMC (`GPT: last_usable_lba incorrect: 76F4FDE > 1d1f000`). The hardware supplier hit this flashing v1.1.0 on 2026-09-11. After the copy, dump the card's table with sfdisk, drop the two lines that describe the card (`device`, `last-lba`) and the overlay's size, and write the result to the eMMC with `sfdisk --force`. sfdisk derives the last usable sector from the eMMC, writes both the primary and the backup copy, and extends rootfs_data to the free space, which the existing expansion step then pins to the last usable sector as before. Dropping the size covers a card whose rootfs_data was also grown to fill it, which sfdisk would otherwise refuse on the smaller eMMC. Bench-verified on a BPI-F3 with a 32 GB card: the relocated-header case reproduced the supplier's failure on 1.1.0 and flashed clean on this build, as did a card with rootfs_data grown to 29.4 GB; the eMMC booted after each. A pristine card is unaffected. Claude-Session: https://claude.ai/code/session_01C779xbxXwoX1AGUKchrcMz | 4 天前 | |
fix(start-wrt): keep a rule's router-port override across an edit (#3889) fix(start-wrt): keep a rule's WAN-port override across an edit The confirmation for publishing a port already in use on the WAN is persisted per rule, and the list round-trips it. The edit dialog built its result from the form fields alone, so every edit — a label change included — sent the rule with the flag cleared, the backend reported the collision again, and the user was asked again. Carry the flag over, but only while the rule still publishes what was confirmed: the override is a blanket per-rule bypass, so repointing a confirmed rule at a different external range or changing its protocol must re-validate rather than inherit a confirmation the user never gave for those ports. Claude-Session: https://claude.ai/code/session_01QzRghGUbYU542mkwFQUcv9 | 11 天前 | |
refactor: migrate StartWRT into the monorepo (projects/start-wrt) (#3385) * complete lan sections, improve wan sections based on lan innovations * outbound vpns * devices in decent shape * proxy fe requests for dev testing * chore: update to Taiga 5 candidate * auth module * update ipv4 for slash 16 * exec api * Revert "proxy fe requests for dev testing" This reverts commit 99014b11bc465c21cd5cf620690cfc0d94a3e9c3. * set SESSION_EXPIRY_DAYS to 1 * Only read STARTWRT_SESSION_PATH once * chore: refactor existing pages * feat: add forwarding route * use temp file for saving sessions * replace fs with tokio * make exec_command async * Set file permissions on create * setting tab progress * reset_pass and make the rest of auth.rs async * fix sibling call * atomically write to shadow file instead * set temp cookie file permissions to 600 * impl Deref for CliContext * finish up port forwarding * refactor password verification * Use sha512 for hashing algo * published ports instead of forwards and firewall * wrap up published ports with protections and mocks * feat: add ethernet route * wrap up ethernet plus other cleanup * refactor: cleanup published ports * feat: add ModalHelp component * refactor: cleaning things up * feat: add inbound route * finish inbound and add wifi * add wifi blackout and labels for inbound vpns * wifi, security profiles, and other refactors * draft proposal for init tool and reflash flow * Update init/reflash proposal with architectural changes - Separate WiFi and admin passwords (WiFi from factory sticker, admin user-chosen on first access) - eMMC stores WiFi PMK only (no admin credentials) - Mount path /mnt/persistent → /persistent - Smart serial dispatcher (microSD → login, no PMK → init, normal → login) - Captive portal for reflash flow (StartWRT-Setup AP with default password) - Admin password set in captive portal wizard during reflash - DNS hijacking on normal boot when admin password is unset - Package manager corrected to opkg (noted as open question) - Design notes: PMK rationale, identity PSK compatibility, WiFi key never in UI * refactor new UI * Revise init/reflash proposal with design decisions - Clarify captive portal vs DNS hijacking terminology - Remove temp AP (StartWRT-Setup) in favor of real StartWRT SSID for all flows - Add WiFi PMK precedence (baked-in first, then eMMC) for custom image support - Document BPI-F3 storage architecture (SPI NOR + eMMC partitions) - Add U-Boot manufacturing flash flow for initial firmware provisioning - Use sysupgrade conffiles for Update path overlay preservation - Require minimum 12-character admin password with confirmation - Resolve package management: StartWRT packages in firmware, user packages wiped - Fix boot detection diagram to show sequential checks not exclusive branches * Streamline manufacturing to single microSD boot session Boot from microSD directly (U-Boot tries SD first on BPI-F3) instead of raw-copying firmware at the bootloader level. Flash + WiFi init now happen in one session via serial, using the same image as end-user reflash. * chore: comments * asides for all dialogs * revert backend chnage * whitelist, blacklist, and outbound * re-arrange, readme, claudemd, and api contract * feat: blackout timeline UI * feat: edit, add and remove * feat: implement help * fill in todos and fix small bug * Build/openwrt image pipeline (#24) * Add Makefile-driven OpenWrt image build pipeline Introduce a build system that produces a complete SD card image with OpenWrt + StartWRT components (Rust binaries, web UI, UCI configs). Adds openwrt as a git submodule pinned to the bianbu branch, and reduces image size from 4GB to 512MB. * Makefile: - Fix Rust binary path (ctrl/target/ → target/) to match workspace layout - Copy final image to out/ directory - Add npm install before web build - Remove web UI from staging deps (skipped until Taiga UI fixed) - make clean now fully removes openwrt build artifacts (build_dir/, staging_dir/, tmp/, bin/) instead of relying on openwrt's make clean which left stale stamps build/feeds.conf: - Update LuCI from openwrt-23.05 to openwrt-24.10 build/openwrt-setup.sh: - Add kernel tarball pre-seed check with clear error message - Document that archive.spacemit.com's CDN is broken build/build-rust.sh, build/stage-files.sh: - Fix Rust target dir paths (ctrl/target/ → target/) - Skip web UI staging build/openwrt.diffconfig: - Remove dead LOCALMIRROR setting for archive.spacemit.com * make incremental builds more robust * mute feed errors for build in packages * add FE to build pipeline * Fix boot hang and harden build resource management Stage web UI to /www instead of /var/www — OpenWrt's /var is a symlink to /tmp, and creating a real /var directory overwrote it, breaking ubus/procd and hanging the system at boot. Also: - Add cgroup memory fence and JOBS budget to prevent OOM during builds - Disable CONFIG_KERNEL_WERROR to fix kernel build failures - Remove stale kernel tarball pre-seed check from openwrt-setup.sh * Update openwrt submodule: revert 2.2.9 kernel, fix libxml2 Point submodule to fix/replace-dead-spacemit-sources which reverts the 2.2.9 kernel switch (generic patches conflict with the vendor kernel) and fixes libxml2 host build failure on incremental builds. * Fix build resource management to prevent OOM crashes Replace fragile systemd-run cgroup wrapping with simpler, portable approach: derive JOBS from MemAvailable (not MemTotal), set oom_score_adj=500 so the build is the preferred OOM-kill target instead of the user session, and keep nice/ionice + load-average back-pressure. Also remove explicit kmod-sound-core from diffconfig since it is pulled in automatically via the ffmpeg → alsa-lib dependency chain. * Harden build resource limits to prevent session crashes - Increase per-job memory budget from 2 GB to 3 GB to cover peak linker phases (kernel, samba4, ffmpeg can each peak at 3-4 GB) - Cap JOBS at nproc/2 instead of full nproc, reserving cores for the host desktop/session - Restore cgroup memory fence via systemd-run MemoryMax so the build is killed before the host session starves (falls back gracefully with a warning when systemd-run is unavailable) * update submodule to the latest * factory init, flash, activate wifi, and captive portal * auth fixes, working captive portal, and more * remove unused stty and replace respawn with respawnlate * Fix captive portal: skip login on captive portal and redirect to completion page * Add setup wizard with auth, flash orchestration, and setup tests Implement the setup wizard flow end-to-end: setup mode detection, PMK resolution from SD/eMMC, conffiles backup/restore across flash, admin password hashing, and streaming flash progress via SSE. Add auth middleware, bake-password tool, and the Angular setup wizard frontend. Fix SetupEvent serialization to use camelCase field names (rename_all_fields) matching the frontend contract, and add 26 unit tests covering serialization, conffiles, backup/restore, password writing, and flash error handling. * Harden post-flash durability of persistent partition writes fsync the parent directory after PMK file rename in emmc.rs to ensure the directory entry survives a crash within the journal commit window. Propagate the persistent partition device path through FlashResult so setup.rs can remount /persistent if the hotplug handler races and unmounts it after partx -u. Unmount /persistent after writing the PMK to guarantee all data is flushed before signaling completion to the client. * Switch rootfs from ext4 to squashfs+overlay and add factory-reset API Replace the ext4 rootfs with a squashfs rootfs + ext4 rootfs_data overlay, matching standard OpenWrt architecture. This enables factory reset via (which wipes the overlay) and reduces flash copy size by reading squashfs bytes_used from the superblock instead of copying the full partition. - flash: read squashfs superblock to determine copy end offset, expand rootfs_data instead of rootfs, format overlay with mkfs.ext4, and mark it FS_STATE_READY so mount_root preserves it on first boot - setup: mount three-layer overlayfs (squashfs + ext4 upper + merged) for config backup/restore and post-flash customization - startwrt-bake-password: write PMK after squashfs data in the rootfs partition (4096-aligned) instead of to the persistent partition - system: add RPC endpoint (firstboot -y + reboot) - web: wire Factory Reset button with confirmation dialog - auth: skip session auth for loopback connections so startwrt-cli works over SSH without a token - firstboot_config/wireless: change radio1 from 6g to 5g - .gitignore: add __pycache__/ * Add atomic SSH deploy targets (update, update-rust, update-web) Rapid development workflow: tar + SSH pipeline deploys Rust binaries and/or web UI to the target device with atomic rename and automatic rollback on failure. Transfer progress via pv, GNU dd, or BSD dd. * update rust build location to ignore * Fix build config after rebase: squashfs rootfs, uhttpd port conflict, path fixes - Enable squashfs in diffconfig and reduce rootfs partition to 256 MB - Move uhttpd to port 8080/8443 so startwrt-ctrld can bind port 80 (change was in config_experiments/ but never applied to firstboot_config/) - Disable kmod-rtl8852bs per spacemit MT7915 setup docs - Fix build script paths for backend/ reorganization (Makefile, build-rust.sh, stage-files.sh) - Update call_remote signature for rpc_toolkit OrdMap API change - Track backend/Cargo.lock for reproducible builds * Rename persistent partition to key_backup Match the submodule's partition table rename across all backend code, build scripts, and firstboot UCI config. * Fix overlayfs umount failure during setup flash Sync before unmounting and fall back to lazy unmount for the underlying squashfs/ext4 layers. After the overlayfs is unmounted, the kernel may still hold cached dentry/inode references to the lower mounts, causing normal umount to fail with EBUSY. * update PROPOSAL-init-reflash for partition name change from persistent -> key_backup * Merge CLI + daemon into single startwrt binary Replace separate startwrt-cli and startwrt-ctrld binaries with a single startwrt binary using a MultiExecutable dispatcher (startbox pattern). The dispatcher routes by argv[0] (symlink name) then argv[1], with CLI as the default. Symlinks preserve backward compatibility for procd init script and serial dispatcher. Also embeds the web UI into the binary via include_dir (replacing tower-http ServeDir), eliminating the separate /www/startwrt staging. * Add GitHub Actions CI to build OpenWrt image on PRs BuildJet 32vCPU runner by default for fast builds (~30 min vs 2-3 hrs on standard runners). Falls back to ubuntu-latest via manual dispatch. Includes reusable setup-build composite action (disk cleanup, Node.js, OpenWrt build deps, sccache config) and caching for openwrt/dl/. Requires OPENWRT_DEPLOY_KEY repo secret for openwrt submodule access. * latest partitions changes from openwrt submodule * fix image build race condition and clean stale .config - openwrt submodule: restrict debX device to squashfs-only builds (FILESYSTEMS := squashfs). The packaging scripts write to hardcoded filenames in a shared temp dir, causing collisions when ext4 and squashfs variants build in parallel. - Makefile: delete openwrt/.config on rm -rf out rm -rf backend/target rm -rf web/dist web/.angular rm -rf openwrt/files rm -rf openwrt/build_dir openwrt/staging_dir openwrt/tmp openwrt/bin rm -f openwrt/.config so it is always regenerated from the diffconfig source of truth. * Fix service reloads, session races, and WiFi password labels Invert effectful() so ServerContext returns true (enabling service Previously the server never ran wifi reload, network reload, etc. Replace file-based session storage with an in-memory RwLock to eliminate write races from concurrent requests that caused random logouts. Disk persistence now only happens on login/logout. Add label field to WifiStation UCI type and persist user-assigned password labels through get/set round-trips. Frontend WiFi password dialog now fetches real profiles from the API instead of using hardcoded values. * Implement profile delete, admin bootstrap, and VLAN lifecycle fixes Complete the profile CRUD surface: removes a profile's UCI entries across all five configs (startwrt, network, firewall, dhcp, wireless) with conflict-retry and LAN-owner protection. A new registers the existing LAN infrastructure as the Admin profile on first boot and daemon startup (idempotent). VLAN lifecycle is tightened: creates a VLAN 1 entry for all bridge ports when the first bridge-vlan appears, preventing traffic loss. wifi-vlan sections are now owned by profile create/delete rather than wifi.set, avoiding accidental removal. wifi.set gains a fallback that creates missing wifi-vlans for legacy profiles and uses smart reload (full restart only when VLAN topology changes). Ethernet port assignment now defaults unassigned ports to the admin profile when VLAN filtering is active and skips the WAN port. The uciedit macro skips leading comment/empty lines so appended sections are readable without a dump+parse round-trip, and non-existent config files no longer produce bogus conflict timestamps. * Fix devices table: filter WAN neighbors, map profiles, and handle * hostname Filter parseArpOutput() to br-lan* interfaces only, excluding upstream router neighbors discovered via IPv6 NDP on the WAN port. Map each device's VLAN tag to its profile name via profilesList() instead of hardcoding 'Default'. Treat dnsmasq's '*' placeholder hostname as empty so unnamed devices fall through to the generated device-XXXXXX name. * Implement DNS override for security profiles Add per-profile dns_override field that creates firewall DNAT redirect rules to intercept all port 53 traffic and forward it to specified DNS servers. This enforces DNS even for devices that hardcode their own resolvers. Also fixes: Makefile PV fallback for make update, and crate path for bootstrap_admin_profile in daemon.rs. * Fix profiles.get returning Whitelist instead of All for LAN access When a profile had forwarding rules to every other existing profile, profiles.get still returned LanAccess::OtherProfiles (Whitelist) unless access_to_new_profiles was also true. Remove that extra guard so the check only looks at whether the profile forwards to all other profiles. * Implement system preferences and remote access firewall rules Add smart endpoints for system.info, set-preferences, newer-versions, and apply-remote-access. Remote access mode (default/never/always) dynamically manages WAN firewall rules based on IP type — private IPv4 or ULA IPv6 gets access rules, public IPs do not. A hotplug script re-evaluates on WAN changes. Frontend: add remoteAccess to SystemInfoRes, fix mock language value, and refactor theme selector to store API values directly with stringify for display. * serve start-wrt at router.lan * Implement Launch LuCi button * Implement system logs: RPC endpoint, authenticated WebSocket streaming, and live log viewer - Add logs.rs with logread parser, system.logs RPC endpoint, and /api/logs WebSocket - Gate WebSocket upgrade with session cookie validation (returns 401 if unauthenticated) - Extract extract_session_token() from SessionAuth middleware for reuse - Build logs page with initial RPC load, live WebSocket streaming, auto-scroll, reconnect, and download - Document both endpoints in API_CONTRACT.md * Fix eMMC overlayfs umount failure blocking "Keep settings" reflash completion During "Keep settings" reflash, the second umount_emmc_overlayfs() call could fail with EINVAL because the squashfs was already detached by the kernel after the first mount/unmount cycle left stale superblock state. This fatal error prevented the WiFi PMK from being written to key_backup and SetupEvent::Complete from reaching the frontend, even though the flash itself had succeeded. Three fixes: - Drop kernel dentry/inode caches after unmounting overlayfs merged mount so the squashfs lowerdir can be cleanly unmounted - Treat "not mounted" as success: if both umount and umount -l fail, check /proc/mounts before reporting an error - Make the post-configuration umount non-fatal since no subsequent operations depend on those mounts and reboot cleans them up * Make LockedConfig::dump() atomic via write-to-tmp + rename * Implement devices smart endpoints with real-time speed monitoring Replace frontend UCI manipulation with six backend RPC endpoints (devices.list, devices.update, devices.block, devices.unblock, devices.forget, devices.data-usage). Speed is computed from conntrack byte counters, data usage from nlbwmon, and WiFi detection from hostapd. ARP online check includes DELAY/PROBE states to prevent transient speed drops during neighbor revalidation. * Replace raw getUci call in profiles page with computed from existing data Derive LAN subnet base from the owns_lan profile's gateway_ip instead of making a separate getUci call to read the network UCI config. * guard on theme being undefined * use ubuntu-latest for CI * remove python3-distutils as it is included in python3 * remove config.json from gitignore * add dtc * Fix profile creation error when dns_override is omitted The frontend sends dns_override as optional, omitting it when empty. Without #[serde(default)], serde requires the field to be present, causing a "missing field `dns_override`" deserialization error. * Switch to buildjet runner * Revert to GH runner and remove unused package behind a brokend CDN * feat: implement transition for dynamic form fields * Add IPv6 smart endpoints (LAN/WAN) and enable IPv6 infrastructure Migrate LAN IPv6 config from direct UCI manipulation in the frontend to purpose-built lan.ipv6-get/set and wan.ipv6-get/set RPC endpoints. Profiles now propagate global IPv6 state (RA/DHCPv6/ip6assign) when created or updated. Backend: - Add lan.rs and wan.rs with ipv6-get/set handlers - Extend NetworkInterface and Dhcp UCI types with IPv6 fields - Add IPv6 multicast ping in devices.list for NDP neighbor discovery - Bind daemon to [::] for dual-stack access - Fix wan6 device to @wan alias in firstboot config - Bootstrap default preferences in admin profile setup Frontend: - Replace LanIpv6UciService with smart endpoint calls - Add lanIpv6Get/Set to API contract, live, and mock services - Fix missing await on firstValueFrom in http.service Build: - Enable odhcp6c, odhcpd-ipv6only, kmod-nf-reject6 - Add odhcpd and RA/DHCPv6 defaults to firstboot dhcp config * Suppress expected network errors during service restarts Operations like toggling IPv6 restart network services, briefly dropping connectivity. The in-flight RPC request and FormService polling both fail with status-0 network errors, producing spurious error toasts. Add NetworkRestartService with a time-bounded suppression window. When ActionService.run() is called with restart: true, network errors are treated as success and polling errors are silently skipped until the window expires. Non-network errors (RPC validation, 4xx/5xx) still propagate normally. Also fix a pre-existing bug: move catchError inside switchMap in FormService so a polling error no longer permanently kills the observable chain. * Add LAN IPv4 smart endpoints and migrate frontend from UCI Replace raw UCI get/set with purpose-built lan.ipv4-get and lan.ipv4-set RPC endpoints. When the network block (first two octets) changes, all profile interface IPs and routing rules are automatically updated, and services are restarted in the correct order (network → wifi → dnsmasq). The frontend now uses the smart endpoints, fixes the gateway IP format to X.Y.Z.1 (3rd octet editable, 4th always .1), and handles admin IP changes by redirecting the browser to the new address. Default LAN IP changed from 192.168.1.1 to 192.168.0.1 to match the frontend and API contract. * set validator min routerOctet to zero * Fix WiFi clients losing internet after network block change Replace `network restart` with per-interface ifdown/ifup to avoid disrupting WAN. Run the restart sequence in a background thread so the HTTP response returns before network disruption. Add a WiFi bounce at the end so clients disassociate and get fresh DHCP leases on the new subnet instead of holding stale ones. * Fix devices showing Online+Ethernet after WiFi disconnect Linux keeps neighbor table entries in STALE state indefinitely on small networks (GC only runs when table exceeds gc_thresh1=128 entries). When a WiFi client disconnects, hostapd drops it immediately but the STALE ARP entry persists, causing the device to appear Online with an Ethernet connection (the fallback when not in hostapd). Fix: ping STALE non-WiFi entries concurrently to determine reachability via exit code. Devices that don't respond are marked Offline. WiFi clients skip probing entirely since hostapd is authoritative. Also improve Devices page load time: - Parallelize IPv6 multicast pings (spawn all, wait all — 1s flat instead of N*1s sequential) - Run UCI config parsing concurrently with initial data gathering - Structure handler into phased pipeline where probing overlaps with nlbw/conntrack/lease reads, adding zero net latency * Add WAN smart endpoints and migrate frontend from UCI Backend: add RPC endpoints for WAN IPv4, IPv6, DNS, DDNS, and MAC settings (get/set). Add DdnsService and NetworkDevice types to uciedit. Frontend: replace direct UCI reads/writes with new API calls and delete all WAN uci/ service and mock files. * Add 'Copied' toast to summary component * Fix CLI auth bypass failing for IPv4 connections to IPv6-bound server The server binds to [::]:80 (IPv6), so IPv4 CLI connections from 127.0.0.1 appear as ::ffff:127.0.0.1. Rust's is_loopback() returns false for IPv4-mapped IPv6 addresses, causing all CLI commands to require authentication. Canonicalize the address before checking. * Fix devices resurrecting as Online+Ethernet via lingering IPv6 NDP The previous fix (6e3b493) probed only STALE IPv4 ARP entries, but `ip neigh show` includes IPv6 NDP entries too. After a WiFi device disconnected and its IPv4 entry expired, a lingering STALE IPv6 NDP entry would bypass probing entirely and resurrect the device as Online+Ethernet. Two fixes: probe REACHABLE IPv4 entries for non-WiFi MACs (catches the window before ARP ages to STALE), and treat MACs with only IPv6 neighbor entries as unreachable (prevents NDP ghosts). * Add published ports smart endpoints and migrate frontend from UCI Backend: - New published_ports module with list/set RPC endpoints - List enriches ports with device status (name, IPs, online state) - Set validates inputs, writes firewall redirect/rule sections with retry loop for UCI conflicts, and fire-and-forget firewall restart - Add FirewallRedirect, FirewallRule, DhcpHost typed sections to uciedit - Add PublishedPortNotFound error variant Frontend: - Replace direct UCI reads/writes with publishedPortsList/publishedPortsSet - Delete PublishedPortsUciService and uci/ directory entirely - Update wan/ipv6, lan/ipv6, and device detail to query ApiService directly - Remove manual firewall section parsing and exec-based restarts - Update dialog to work with API types directly * Fix IPv6 port-protection bugs from rebase Fix WAN IPv6 'disabled' mode handler never matching due to 'ddisabled' typo (introduced in cca6b7b). Make LAN IPv6 SLAAC lock hint conditional so it only appears when published ports actually use IPv6. * Add duplicate profile name validation on create and rename * Fix form error alerts displaying [object Object] instead of message text * Add kmod-br-netfilter to install sysctl disabling bridge filtering CONFIG_BRIDGE_NETFILTER is compiled as a kernel built-in, so bridge netfilter is always active. But the sysctl that sets bridge-nf-call-iptables=0 is only installed by the kmod-br-netfilter package. Without the package selected, the kernel default of 1 applies, causing bridged TCP between devices on the same profile to be rejected by fw3 zone rules that don't match bridged frames. * Add inbound VPN server smart endpoints and wire up frontend Backend: new vpn_server module with full CRUD for WireGuard server interfaces and peer management, including key generation (x25519-dalek), client config rendering, and UCI/service orchestration. New wg module for WireGuard key pair utilities. Frontend: replace stubbed profiles and endpoints with live data from smart endpoints. Show profile display names instead of interface names, hide Add when all profiles already have a VPN (the upsert API makes adding a duplicate redundant—just edit the existing one), add peer IP validation (range + uniqueness), and auto-prompt first client creation after adding a new server. * Add FE validator for Profile fullname uniqueness * Add VPN-connected peers to devices list with nullable mac handling Backend (devices.rs): - VPN peers discovered via wg show + UCI peer configs, shown as online with speed/data - Device.mac changed to Option<String> (VPN peers are L3, no MAC) - published_ports.rs: skip MAC-less devices when indexing Frontend: - DeviceFromApi.mac / DeviceTableItem.mac now string | null - All three device tables (online/offline/blocked): @if (item.mac) guards on links and action buttons, {{ item.mac || '-' }} display, track item.mac ?? item.ipv4 - published-ports/service.ts: optional chaining on d.mac?.toUpperCase() (crash fix) - published-ports/dialog.ts: filter out MAC-less devices from port forwarding device picker - devices/service.ts: fallback name 'VPN Device' for MAC-less peers - VPN shield icon added to online table and summary page * Make network restart handlers synchronous and add frontend loading indicators Backend: remove std::thread::spawn from reload_system(), reload_system_and_wifi(), and restart_network_services() so handlers block until the network restart completes. Frontend: remove pauseFor() polling delays (now unnecessary), add restart: true with loading/success messages to all endpoints that trigger a network restart (profiles create/update/delete, inbound VPN set/delete/addPeer/deletePeer), and increase NETWORK_RESTART_TIMEOUT_MS to 30s. * update profiles validator to allow 0 for the third octet * Add outbound VPN client smart endpoints and migrate frontend from UCI Backend: - Add vpn_client module with list/create/update/delete/set-enabled RPC endpoints that parse WireGuard .conf files, manage UCI config, and handle interface lifecycle (ifup/ifdown) - Add per-profile DNS forwarding via dedicated dnsmasq instances so VPN DNS servers resolve .lan locally instead of bypassing dnsmasq with direct DNAT (fixes broken .lan resolution when using VPN DNS) - Add local subnet route to policy routing tables so LAN traffic between devices on the same profile stays local instead of going through the VPN tunnel - Change dnsmasq reload to restart (required for new dnsmasq instances) - Add ProfileDnsmasq typed section and WIREGUARD InterfaceProto variant - Make WgInterface, UciVpnServer, and several profile helpers pub(crate) Frontend: - Replace OutboundUciService (direct UCI read/write) with smart endpoint calls through ApiService (vpnClientList/Create/Update/Delete/SetEnabled) - Delete outbound/uci/service.ts and outbound/uci/mocks.ts - Add duplicate label validation to add/edit VPN dialogs - Wire up used_by profile list display from backend data - Add interfaceNameLength and duplicateName validators * Add VPN chain validation, cycle detection, and endpoint routing Prevent deleting or disabling a VPN that other VPNs chain through. Cascade label renames to dependents. Validate targets exist and won't create routing cycles. Automatically manage static routes (vcr_*) so chained WireGuard endpoints traverse the correct tunnel. Frontend filters target dropdown to cycle-safe options and disables delete when dependents exist. * Add per-profile DNS hijacking and SmartDNS-backed DNS resolution The existing WAN DNS smart endpoint stored servers as plain strings on the network interface and relied on dnsmasq's native forwarding — which couldn't support per-profile DNS or DoH. This replaces that approach with a SmartDNS proxy layer and firewall DNAT hijacking that meets the full requirements. Backend: - Add dns.rs module with SmartDNS config generation, per-profile server groups (port 5300 + vlan_tag), and structured DnsServer type ({address, ssl}) - Add UciSystemDns typed section in /etc/config/startwrt (replaces storing DNS on the network interface) - Rewrite wan.dns-get/dns-set to use startwrt config instead of network interface DNS lists - Add DNS hijacking (firewall DNAT on port 53) and per-profile dnsmasq instances that forward to SmartDNS or VPN DNS - dns-set rewrites dnsmasq/firewall for all profiles so system DNS changes propagate immediately - Profile create/set/delete regenerate SmartDNS config - Add SmartDNS restart to reload_system() and reload_system_and_wifi() Frontend: - Change dns_override type from string[] to DnsServer[] across API types, profiles dialog, and WAN DNS forms - Remove @853 string parsing in favor of structured DnsServer objects - Rename "TLS" label to "Secure (DoH)" to reflect actual protocol - Add DNS field validators to profiles dialog - Fix updateDnsValidators to validate all three server fields Build: - Add smartdns package to openwrt.diffconfig - Add custom SmartDNS init script using our generated config * Disable IPv6 per-profile when outbound VPN lacks IPv6 support Most VPN providers (NordVPN, ExpressVPN, Surfshark, etc.) don't carry IPv6 through their tunnels. When a profile routes through such a VPN, IPv6 traffic would bypass the tunnel and leak via WAN. Add outbound_supports_ipv6() which checks the VPN's WireGuard addresses for IPv6 entries. Profile create, update, and the global IPv6 toggle now gate ip6assign and DHCPv6/RA on this check, so profiles using IPv4-only VPNs automatically get IPv6 disabled on their VLAN interface. * Add outbound VPN enable/disable toggle with profile reset and confirmation dialog * Add SSH keys smart endpoints and migrate frontend from UCI file access Backend ssh_keys module handles list/add/delete via openssh-keys crate with fingerprint-based key identification, duplicate detection, and proper file permissions. Frontend now uses RPC endpoints instead of directly reading/writing authorized_keys. * Add HTTPS with self-signed CA, LuCI reverse proxy, and CORS support Generate a local Root CA and server leaf certificate (ECDSA P-256) at startup, serving HTTPS on port 443 alongside HTTP on port 80. The server cert is auto-renewed when expiring or when the LAN IP changes. A CA wizard on the login page guides users on non-HTTPS connections to download and trust the Root CA. Move uhttpd to localhost:8080 (HTTP only, no TLS) and proxy LuCI requests (/cgi-bin/*, /luci-static/*, /ubus/*) through the axum server, handling redirect chains and cookie forwarding. Update remote access firewall rules to expose port 443 instead of 8080/8443. Add Root CA download to the general settings page and change the advanced settings LuCI link to use the reverse proxy path. * Add cancel/reset support to general settings form * Store WiFi password as plaintext, add per-band SSID broadcasting, and use PSK hot-reload for password-only changes Password storage: replace PBKDF2-SHA1 PMK derivation with plaintext passphrase storage throughout the stack. A PMK is derived from both the passphrase and SSID, so it becomes invalid when the SSID changes — storing plaintext allows SSID changes (including the new -5G suffix) without re-deriving. This also matches OpenWrt's default behavior of storing plaintext passwords in /etc/config/wireless (root-only access), and simplifies the codebase by removing pbkdf2, sha1, and hex dependencies. Updates init, setup, emmc, flash, daemon, startwrt-bake-password (SWRTPMK→SWRTPWD format), and PROPOSAL-init-reflash.md. Fixes documented charset to include lowercase 'i' exclusion (67 chars, ~72.3 bits entropy). Broadcast separately: add broadcastSeparately field to WiFi config so dual-band routers can advertise "{SSID}-5G" on the 5GHz radio while keeping the base SSID on 2.4GHz. Backend detects differing per-radio SSIDs on read and applies the -5G suffix on write. Frontend adds a toggle (visible when band is "Both"), SSID-change confirmation dialog, and a reconnect dialog that polls until the backend is reachable. WiFi restart optimization: replace the boolean vlans_created return with a WifiRestart enum (Full vs PskOnly). Track whether device or interface config actually changed (SSID, channel, enabled, hidden, encryption, key, dynamic_vlan) and only issue a full `wifi` restart when needed; password-only changes use `wifi reload` to avoid disconnecting clients. Adds PartialEq/Eq to WifiChannel for the comparison logic. * Fix SmartDNS stealing port 53 from dnsmasq when no DNS groups are configured Remove the SmartDNS config file instead of writing an empty one so the init script's guard prevents SmartDNS from starting. Without a bind directive, SmartDNS defaults to 0.0.0.0:53 which conflicts with dnsmasq. * Add system.restart smart endpoint and migrate frontend from exec Backend: add system.restart RPC handler that spawns a delayed reboot (same pattern as factory-reset). Frontend: replace generic exec('reboot') call with the smart endpoint, suppress poll errors for 90s during reboot, and poll systemInfo until the device goes down then comes back up so the spinner stays visible for the full reboot cycle. * Add ethernet smart endpoints and migrate frontend from UCI file access Backend: - Redesign ethernet.get/set API contract: return structured Ethernet object with wan_ipv6, wan_port, and ports map instead of flat port list - Extract find_lan_bridge() helper, eliminating duplicated bridge lookup logic across ethernet.rs and profiles.rs - Filter WiFi/phy interfaces from ethernet port listing - Preserve non-ethernet bridge ports (wlan, phy) during ethernet.set - Skip unnecessary bridge device writes when ports haven't changed - Use reload_system_and_wifi() instead of raw Command for service restarts - Change firewall reload to restart for reliable rule application - Add comprehensive test suite (~1000 lines) covering get, set, round-trip, WAN management, WiFi port preservation, and bridge lookup Frontend: - Replace UCI-based EthernetUciService with smart endpoint calls (ethernetGet/ethernetSet) - Delete ethernet/uci/ directory (mocks.ts, service.ts) - Use real ProfileId objects instead of stub profile strings - Add empty-state placeholder for ports table - Network restart is now handled server-side; remove client-side restart uciedit: - Default Token::from_string to single-quoted output for UCI consistency - Only use double-quoting when value contains single quotes - Update all test expectations for new quoting behavior * Replace rcgen with openssl and add intermediate CA to PKI chain Switch certificate generation from rcgen to the openssl crate (vendored) to reduce dependency count and gain finer control over X509 extensions. Introduce an intermediate CA between the root CA and server leaf cert, following standard PKI hierarchy (root signs intermediate, intermediate signs leaf). * Prefer GUA over ULA when selecting a device's IPv6 address * Bounce changed ethernet ports and defer reload to background thread When a port's VLAN assignment changes, connected clients stay on their old DHCP lease and subnet. Bring changed ports down for 2 s (IEEE 802.3 break_link_timer) then back up so link partners re-run DHCP. Move network/firewall reload to a background thread so the RPC response reaches the client before the L2 path switch causes a hang. Drop the wifi/dnsmasq/smartdns restarts — only bridge VLAN config changed, and restarting wifi would lose bridge VLAN 1 entries on recreated interfaces. * Detect wan_ipv6 by interface name instead of device match * Add config backup and restore endpoints with settings UI * Centralize network reconnect handling in ActionService Move reconnection polling and UI out of individual pages into ActionService. Actions with `restart: true` now automatically race against a timeout, poll for network drop, and show a generic ReconnectingDialog. Special cases (LAN IP change, profile gateway change, WiFi SSID change) bypass the generic flow with their own redirect/reconnect logic. - Add ReconnectingDialog component for post-restart reconnection - Simplify NetworkRestartService to boolean suppress/recovered - Remove per-service refreshAndWait() calls from save methods - Handle factory reset and backup restore via restart+reconnect flow * Fix VPN peer reachability with proxy ARP and /32 policy routes When a profile uses an outbound VPN, policy routing's /24 subnet route catches VPN peer IPs, sending locally-generated responses (DNS, HTTP) to the LAN bridge instead of back through the WireGuard tunnel. Add /32 host routes per peer to override via longest-prefix match. LAN devices also fail to reach VPN peers because they ARP directly for IPs behind the WireGuard tunnel. Enable proxy ARP on the profile's bridge VLAN interface so the router answers on behalf of VPN peers. A hotplug script ensures the sysctl persists across reboots since netifd does not honor the UCI proxy_arp option. * Add cross-subnet routes to VPN policy tables for local reachability When a profile uses an outbound VPN, its policy routing table has a default route through the tunnel. Responses from the router's own IP to devices on sibling VLANs matched the source-based ip rule and exited through the VPN instead of routing locally — making the admin gateway IP unreachable from guest profile devices. sync_cross_subnet_routes() adds sibling subnet routes (e.g. 192.168.8.0/24 dev br-lan.101) to each VPN profile's table so local cross-VLAN traffic takes precedence over the VPN default route. * Update all profile-dependent configs when LAN subnet block changes Previously only network interfaces and routing rules were updated. Now also updates policy routes, dnsmasq listen addresses, and DNS-Override firewall redirects to match the new subnet block. * Add activity logging system with RPC endpoints and frontend integration Track user-visible actions (login, profile/device/VPN/backup/SSH key changes, factory reset, etc.) in a JSON log file with list, delete, and clear RPC endpoints. Every mutating handler now records success or failure with a human-readable summary. The frontend activity page consumes the new endpoints. * Add support diagnostics bundle download endpoint and UI Introduces GET /api/diagnostics that collects system logs (logread) and activity history into a tar.gz archive served as a browser download. Wires up the existing "Download Support Diagnostics" button in the advanced settings page to fetch and save the bundle. * Add RPC continuations system, migrate backup/diagnostics/activity Introduce a one-shot continuation mechanism (modeled on start-os) for binary I/O over REST, replacing ad-hoc HTTP endpoints with proper RPC methods that return a GUID for subsequent file transfer. - Add continuations module with TimedResource (per-continuation tokio timeout), Guid newtype, RestHandler returning Result, session-bound kill signals (OpenAuthedContinuations), and cleanup-on-add - Migrate backup create/restore from /api/backup and /api/restore to backup.create and backup.restore RPC + /rest/rpc/{guid} - Migrate diagnostics from /api/diagnostics to diagnostics.create RPC, simplified from tar.gz archive to plain syslog text - Migrate activity log from JSON file to SQLite (rusqlite) - Add CLI handlers for backup download/upload and diagnostics download - Add ServerContext fields for continuations and open_authed_continuations - Update frontend to use RPC + continuation GUIDs for file transfers - Swap tar/flate2/once_cell deps for rusqlite/dirs - Bump default log_size 128→512 and add log_file in firstboot config * Fix conffiles backup to expand directory entries from keep.d OpenWrt's base-files keep.d includes `/etc/config/` (the whole directory), but backup_conffiles() only backed up exact file paths — directory entries were silently skipped. This caused /etc/config/startwrt (and any other config not explicitly listed by a package) to be lost on reflash with "keep settings". * default CliArgs host to http://router.lan/rpc/v1 * Use in-memory SQLite for activity DB in tests The activity LazyLock panics trying to open /etc/startwrt/activity.db which doesn't exist in test environments, poisoning 45 tests. * Silence noisy child process output and tighten default log level Service reload commands (firewall, dnsmasq, network, wifi, etc.) write informational output to stderr, which procd logs as daemon.err and floods the syslog. Add a run_quiet() helper that redirects child stdout/stderr to /dev/null and migrate all ~30 call sites to use it. Also raise the default tracing filter from info to warn (keeping activity=info), add a startwrt-activity prefix with OK/FAIL status to activity log lines for easier logread filtering, and disable Samba NetBIOS (unused). * Mask VPN client config and QR code by default for security * Show LAN Access as 'All' when only one profile exists 'Same profile' is meaningless with a single profile since there are no other profiles to exclude. * Fix disconnected WiFi clients showing as Online Ethernet A REACHABLE IPv6 NDP entry was suppressing the IPv4 ping probe for recently disconnected WiFi clients, so they were never detected as unreachable. Only consider IPv4 REACHABLE entries when deciding whether to skip STALE probes, since IPv6 entries cannot be probed. * Move session storage to /etc/startwrt/ to persist across reboots /var/run/ is a tmpfs cleared on every reboot, causing all sessions to be invalidated. Store sessions in /etc/startwrt/ instead so users stay logged in across router restarts. * Use bridge FDB to detect stale WiFi clients and bind pings to interface Cross-reference the bridge forwarding database with hostapd to catch WiFi clients whose ARP/driver state lingers after disconnection. Bind ping probes to the correct interface to prevent WAN leakage on overlapping subnets. Also probe DELAY/PROBE ARP states alongside STALE. * Remove Samba, GnuTLS, and Chinese locale packages from build Drop samba4, its dependencies (GnuTLS, libgmp, libnettle, libtasn1), wsdd2, audio libs (alsa-lib, fdk-aac, lame-lib), and zh-Hans locale packages. These are SpacemiT K1 target defaults not needed by StartWRT. * feat: bundle used icons * chore: fix spacing * Update web/package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update captive portal IP to match default * pin feeds to 24.10 * Gate serial console setup on baked WiFi password presence Add `startwrt-cli has-baked-password` to check whether the boot image's rootfs contains a password written by startwrt-bake-password. The serial login script now uses this to either show the WiFi setup wizard prompt or fall through to the `manufacture` flow. * Add startwrt-cli verify command for factory QC Checks firmware integrity (squashfs superblock valid on eMMC) and WiFi SSID broadcast (hostapd running, SSID is "StartWRT"). Embeds git hash at build time for firmware version display. * Migrate to OpenWrt 25.12.1 with Armbian 6.18 vendor kernel Switch from SpacemiT vendor kernel 6.6 on OpenWrt 24.10 to Armbian's forward-ported vendor kernel 6.18.19 on OpenWrt 25.12.1. The Armbian kernel (github.com/jmontleon/linux-bianbu, branch linux-6.18.y) provides full BPI-F3 hardware support: SD card, dual GbE, USB 3.0, PCIe, PMIC. Build system: - Submodule based on upstream OpenWrt v25.12.1 (not SpacemiT fork) - Kernel via CONFIG_KERNEL_GIT_CLONE_URI (git-clone mechanism) - Feeds updated to openwrt-25.12 branches, written to feeds.conf (gitignored) instead of overwriting tracked feeds.conf.default - Removed spacemit_openwrt_feeds dependency - Simplified openwrt-setup.sh and stage-files.sh - Updated image name and path for bananapi-f3 target - Diffconfig: removed binutils override, updated device name, disabled kmod-crypto-sha512 (forced built-in by DRBG_HMAC) Kernel compat patches carried in submodule: - CFG80211_HEADERS for mac80211 backport wireless support - libcurve25519-generic compat module (vendor naming) - sha512_generic module rename (vendor naming) - SOCK_ASYNC compat for mac80211 on kernel 6.18 - NETFILTER_XTABLES_LEGACY for iptables support on 6.18 * Fix APK package feeds by aligning spacemit CPU_TYPE with upstream * Update openwrt submodule: build F2FS into kernel Build F2FS as built-in (=y) instead of a module so it is available at boot for mounting the root filesystem. * Update openwrt submodule: build 8021Q VLAN support into kernel Without this, netifd cannot create bridge VLAN sub-interfaces (br-lan.1) during early boot, breaking security profile isolation. * Update openwrt submodule: enable conntrack procfs for speed tracking * updated openwrt submodule to 25.12.2 * Add per-profile WAN schedules and system timezone support Profiles can now have scheduled WAN-block windows (e.g. block internet for Kids profile on school nights). Backend stores schedule data in UCI, generates crontab entries to toggle firewall REJECT rules, and evaluates current state at boot and after firewall reloads. System timezone is configurable via settings and auto-detected from the browser during initial setup. The backend writes the POSIX TZ string to /etc/TZ so cron and all time functions use local time — critical for schedule accuracy. * Autofocus Flash button in setup wizard so Enter confirms * Remove device blocking feature * Include LAN IPv6 address in server certificate SAN The server TLS certificate previously only included the LAN IPv4 address. This adds the LAN IPv6 (ULA) address as a Subject Alternative Name so browsers don't show certificate warnings when accessing the router over IPv6. The cert is now regenerated whenever the IPv6 configuration changes, and on startup if the SAN doesn't match. * Add local auth cookie for on-device CLI authentication The daemon generates a random token at startup and writes it to /run/startwrt/rpc.authcookie. CLI commands running on the router read this file and present it as a cookie, bypassing session auth. This replaces the previous loopback-only bypass with a cookie-based mechanism that works over any local transport. * Unify password charset and add server-side WiFi password generation Move the ambiguity-safe character set to a shared constant in lib.rs (PASSWORD_CHARS) so init.rs and startwrt-bake-password stay in sync. Add a PASSWORD_CHARS_ALNUM subset and generate_password() using rejection sampling. Expose wifi.generate-password RPC endpoint so the frontend generates passwords server-side with proper randomness instead of using Math.random in the browser. Charset changes: re-adds lowercase i/o, drops - and _. Adds ?. * Switch conntrack from procfs to netlink and improve nlbwmon config Replace /proc/net/nf_conntrack reads with `conntrack -L` (netlink API), allowing NF_CONNTRACK_PROCFS to be disabled in the kernel. Add the conntrack-tools package to the build. Fix nlbwmon configuration: move the database to persistent storage (/etc/nlbwmon/data), reduce the commit interval from 24h to 1h to limit data loss on unexpected reboots, and add all RFC 1918 subnets so it correctly accounts for LAN traffic across all security profile VLANs. * Flush ARP and DHCP lease when forgetting a device Previously, forgetting a device only reloaded dnsmasq, so the device would linger in the device list until its lease expired. Now we delete ARP neighbor entries and remove the lease line (stopping dnsmasq first to avoid it overwriting the file), so the device disappears immediately. * Add split/full tunnel routing option for inbound VPN peers Allow VPN clients to choose between routing all traffic (LAN + WAN) through the tunnel or only LAN traffic. Stored as a UCI flag per peer and reflected in generated WireGuard client configs. Also fixes client address mask from /24 to /32 and adds the missing DNS line to the frontend's config display for user-supplied-key peers. * Warn before deleting an outbound VPN that is in use by profiles * Warn before deleting inbound VPN when IP/subnet changes break peers Changing a profile's subnet or the router's LAN IP invalidates WireGuard peer allowed-IPs, silently breaking VPN clients. Add a guard that blocks the change unless force is set, with full VPN server teardown on force. The frontend catches the error, shows a confirmation dialog, and retries with force when the user accepts. * update tests to match changes in implementation code * Remove WPS * Fix "Manage clients" link to use correct route for inbound VPN The "Manage clients" dropdown option was navigating to /inbound/<port>, which didn't match any route. Changed it to use routerLink="client" with a port query param, matching the existing navigation pattern. Also removed the now-redundant link wrapper from the server label column. * Restructure documentation into ARCHITECTURE/CONTRIBUTING/README per component CLAUDE.md files were doing triple duty as architecture docs, contributing guides, and AI assistant references. Split them into purpose-specific files so each serves one audience: ARCHITECTURE.md for system design, CONTRIBUTING.md for developer onboarding, README.md for orientation, and CLAUDE.md as a slim quick-reference for AI tooling. Moves init-reflash proposal into docs/. * Update help text: add backup/schedule pages, fix DNS terminology, improve copy Add help content for backup settings, timezone, security certificate, WiFi enable toggle, and inbound VPN routing options. Rename DNS over TLS to DoH throughout. Tighten outbound VPN and profiles copy. Fix aside help lookup for dynamic profile schedule routes. * chore: bump Taiga to 5.0 * Fix DNAT reply routing for VPN-routed profiles with port forwards When a profile uses VPN policy routing, DNAT reply packets (from port forwards) were being captured by the source-based ip rules and sent through the VPN tunnel instead of back to the original client. Add a per-profile mangle MARK rule (conntrack --ctstate DNAT) and a shared ip rule (dnat_return) that routes fwmark 0x80 traffic via the main table. Assign explicit priorities (100 for DNAT return, 200 for VPN source routing) so the mark rule is always evaluated first. Also extend NetworkRule with optional src/mark/priority fields and FirewallRule with set_mark/extra fields to support mangle MARK targets. * Add static IPv6 LAN prefix delegation and harden IPv6 port forwarding - Add lan_prefix field to WAN IPv6 static mode for configuring the LAN delegation prefix (odhcpd ip6prefix), plumbed through API contract, backend, and frontend form - Skip IPv6 firewall rules for devices with only ULA addresses instead of blocking the entire port forward save; show warning in the dialog - Remove ip6assign from profile interfaces — only the admin LAN gets the delegated prefix until multi-prefix delegation is implemented - Enable kmod-ip6tables in diffconfig for IPv6 firewall rule support * Fix IPv6 LAN prefix delegation and clean up published-ports tests - lan.rs: skip LAN interface when stripping ip6assign so it retains its prefix delegation (the loop was incorrectly clearing it along with profile interfaces) - published_ports.rs: switch two tests to ipv4-only to match their actual assertions (v6 rule coverage exists elsewhere) - wan.rs: add lan_prefix field to all test request structs after the field was introduced in e7abf24 * Fix mock API, validation guards, and miscellaneous UI bugs (#33) * Guard against subnet changes when DHCP static hosts exist Add backend validation (DhcpStaticHostsInSubnet error) that rejects LAN IP or profile subnet changes when devices have static IP reservations in the affected range. On the frontend, proactively disable the Save button with a hint when static IPs are detected, and surface non-VPN backend errors as alert notifications instead of silently swallowing them. Update mock API to auto-reserve static IPs on port-forward enable. * Reserve static DHCP lease when re-enabling a published port Creating or editing a published port already reserves a static IP via the dialog flow, but toggling a disabled port back on in the table bypassed that — the device could lose its dynamic lease and break the forward. Call reserveDeviceIps from toggleEnabled on the frontend, and add a backend fallback that auto-creates missing DHCP reservations during published_ports.set for any enabled IPv4 port. * Refresh system info after saving general preferences The UI wasn't reflecting updated timezone/hostname after saving. Add SystemService.refresh() and call it after the preferences save. * Add IPv6 static reservation support to device detail and published ports * Validate unique subnet when creating or editing a profile * Filter radio band lookup to only enabled radios Prevents a disabled radio from shadowing the active one when populating the wifi settings form. * Overhaul mock API for correctness and interactivity Consolidate scattered mock device data into unified MockDeviceDef definitions with dynamic IP computation from profile gateways. Add cascade effects for profile rename/delete, VPN client delete/disable, and LAN IP changes. Log activity entries for all mutating operations. Fix WiFi reconnect dialog to complete dialog instead of reloading in mock mode, refresh WiFi state after reconnect, and check device IPv6 reservations instead of published ports for SLAAC lock. * Adopt start-os conventions and eliminate blocking I/O in async contexts (#34) * Adopt start-os conventions and eliminate blocking I/O in async contexts Aligns start-wrt's backend with start-os patterns by importing shared utilities from the startos crate and restructuring I/O to never block the async runtime. Foundation: - Rename startwrt-ctrl crate to startwrt-core (lib name startwrt) - Add start-os as a path dependency for direct reuse of its utilities - New error.rs modeled after startos::Error: #[repr(i32)] ErrorKind with 22 generic variants matching startos codes + 23 domain-specific variants at 1000+; Error { source, kind, info }; ResultExt/OptionExt - New prelude.rs with eyre!, instrument, Error, ErrorKind, etc. - From<crate::ErrorKind> for startos::ErrorKind and From<startos::Error> for crate::Error for seamless interop Imports from startos (code removed from start-wrt): - startos::util::Invoke replaces local Invoke impl (~280 lines) - startos::util::serde::{HandlerExtSerde, DisplaySerializable} replaces local versions (~130 lines) - startos::util::serde::StdinDeserializable used under the hood for multi-format (JSON/YAML/TOML/CBOR) stdin deserialization - startos::util::io::AtomicFile / write_file_atomic replace manual temp+rename patterns in ssl.rs, auth.rs, emmc.rs, setup.rs, backup.rs - startos::util::new_guid() replaces custom 128-bit hex Guid Error migration: - All ~30 handler modules converted from thiserror-based ErrorKind with fields to Error::new(eyre!("msg with {field}"), ErrorKind::Variant) - 300+ ErrorKind::Unknown usages replaced with specific kinds - RPC errors now serialize with numeric code + structured details (ErrorData) No blocking I/O on async threads: - Zero spawn_blocking wrappers around std::process::Command - Zero std::fs::* calls in async functions (all replaced with tokio::fs) - uciedit made async: parse_all, dump_all, Config::parse, Config::dump, LockedConfig::* all take tokio::fs::File; flock runs on blocking pool - uciedit adds read_all/ConfigBytes::parse + Configs::freeze/write_all splits so callers can avoid holding !Send Arena across awaits (used by init::configure_wifi so flash can spawn) - run_setup_flash runs on a dedicated thread with its own current_thread runtime (its future is !Send via transitive Arena) - All handlers that hold Arena across awaits registered via from_fn_async_local - files.rs handlers (get/set/dir_get) async; flock via spawn_blocking - CLI runtime switched from current_thread to multi_thread(1 worker) Tracing: - #[instrument(skip_all)] on every RPC handler function Tests: - 343 tests pass (326 ctrl + 17 uciedit) - Tests converted to #[tokio::test] where they call async handlers * Vendor start-os as a submodule Move the start-os path dependency from a sibling checkout (../../../start-os/core) to a submodule pinned at ../../start-os/core so fresh clones of start-wrt build without requiring a parallel start-os checkout. Track master on the submodule. start-os's build.rs reads build/env/GIT_HASH.txt, which isn't present in a fresh submodule checkout; build-rust.sh now generates it via start-os/build/env/check-git-hash.sh before invoking cargo. * Fixes/refactor regressions (#35) * Install rustls ring crypto provider explicitly in ctrld The start-os dep transitively enables rustls's `aws-lc-rs` feature via lettre, leaving rustls compiled with both `ring` and `aws-lc-rs`. Its auto-select then panics at runtime, so install the ring provider before anything touches rustls. * Fix service-reload hangs by redirecting child stdio to /dev/null The start-os Invoke trait pipes stdout/stderr and awaits wait_with_output(), which hangs indefinitely when init.d scripts spawn long-lived grandchildren (udhcpc, hotplug handlers) that inherit the pipe fds. Replace every init-script / ifup / wifi invocation in ctrl with a new run_quiet_async helper that nulls stdio and waits only on the direct child. * Pin RISC-V C builds to K1 ISA and fold in pending fixup work Build pipeline: - aws-lc-sys's cc_builder (selected over cmake because pregenerated bindings exist for riscv64gc-musl) only injects `-Wp,-U_FORTIFY_SOURCE` into its jitter-entropy sub-build when CFLAGS_<target> is present. `zig cc` rejects `-Wp,` passthrough, which broke the build. - Bake -mcpu into zigcc-k1.sh / zigcxx-k1.sh wrappers instead of exporting CFLAGS/CXXFLAGS, drop the dead AWS_LC_SYS_CMAKE_TOOLCHAIN_FILE env var (cmake never runs, so the toolchain file was never consulted), and verify the output binary contains no RVA23-only instructions via verify-isa.sh. - .DELETE_ON_ERROR: in Makefile avoids leaving truncated targets behind when a rule fails mid-write. Runtime: - setup.rs: replace tx.blocking_send with tx.try_send inside the flash progress callback so the async executor isn't blocked while the channel is full. Other: - Bump start-os submodule to 0.4.0-beta.6; propagate to backend/Cargo.lock and a peer-dep marker in web/package-lock.json. - startwrt-bake-password: allow a blank prompt to generate a random password matching the sticker rules. --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> --------- Co-authored-by: Dominion5254 <musashidisciple@proton.me> * fix: vpn addition dialog layout (#38) * Add release workflow, gitHash build stamping, and About section (#37) Install binutils-riscv64-linux-gnu in the CI build environment — previously missing, which was failing image builds on hosted runners. Stand up a GitHub Release job that publishes image artifacts for tag pushes and manual `deploy=release` dispatches, bumping the project to 0.1.0-beta.1. Gitignore web/config.json and generate it from config-sample.json at build time: `build/env/check-git-hash.sh` writes GIT_HASH.txt whenever git state changes, `web/update-config.sh` stamps it into config.json and forces useMocks=false for production, and `web/build-config.js` runs before `npm start`/`ng build` so dev builds also carry a hash. Surface the hash in Settings > General as a new About section (version + short gitHash) via a new GIT_HASH injection token. * build: chown backend/target on exit, clean root-owned files via docker (#39) Replace the post-build ownership fix in build-rust.sh with an EXIT trap so interrupted or failed Docker builds also leave backend/target owned by the host user. As a backstop for already-broken trees, make clean detects root-owned files and delegates removal to the cargo-zigbuild container. * refactor(devices): replace local cache with refreshAndWait DevicesService no longer maintains a parallel `devices` array; update and forget now refetch from the API after mutating, keeping the form state as the single source of truth. Drop unused getDevice/ getDevicesByStatus helpers. Editing a device now shows a spinner and success toast covering both the update call and the subsequent refresh. Mock API tracks device defs in a mutable instance field so forget actually removes them, and adds an offline mock device. * feat(wan/ipv6): show status badge in summary Display IPv6 mode as a badge (Disabled / Enabled + mode label) at the top of the WAN IPv6 summary so the current state is visible without inspecting individual fields. * feat: align UI with Start9 design guidelines * chore: address comment * refactor(ctrl): delegate cert generation to start-os ssl primitives Drop start-wrt's hand-rolled X509Builder code and reuse start-os's make_root_cert / make_int_cert / make_leaf_cert via the new CertBranding hook (start-os PR #3200), wired with a "StartWRT" CertBranding so the issued CN/OU strings match this product. Net diff in ssl.rs: -239 lines. Renewal threshold and serial-number generation now live in start-os too; start-wrt only retains the filesystem layout, LAN address discovery, and TLS config wiring. Also bumps the start-os submodule to master so the CertBranding API is available on the pinned commit. * refactor(profiles): inline WAN schedule editor into profile dialog (#44) Replace the standalone /profiles/:interface/schedule route with an embedded ProfileScheduleEditor component inside the profile add/edit dialog. Schedule windows are now loaded alongside the profile, edited in-place, and persisted as part of the same save flow. - Rename schedule/index.ts -> schedule/editor.ts and convert it from a routed page into a model()-based component - Delete schedule/service.ts; profileScheduleGet/Set are called directly from the profiles route and service - For admin-IP changes, write the schedule before the IP change so it survives the redirect to the new gateway - Drop the "WAN Schedule" row action and the dynamic-route help-path normalization in Aside; fold schedule help into the dialog help entry * Misc fixes: data-usage daily fan-out, session-expiry redirect, ethernet spinner hang (#43) * ethernet: align post-set reloads with backend conventions Drop the tokio::spawn wrapper around network/firewall reload — every other module (lan, profiles, dns, wan, wifi, system) awaits init.d calls inline after UCI writes. Spawning only papered over disconnects for users reassigning their own port; the unaffected case now gets a real success/failure response. Also switch `firewall restart` to `firewall reload` per backend/CLAUDE.md, preserving conntrack since only L2 changed. * devices: switch data-usage to daily nlbwmon fan-out, drop intra-day period The previous data_usage implementation called `nlbw -c json -g mac,interval` with a single start-date, which doesn't return per-day points the chart needs and silently produced empty results when archives were missing. Replace it with a per-day fan-out: - Fetch `nlbw -c list` once to learn which YYYY-MM-DD archives are retained, then run `nlbw -c json -g mac -t <date>` for each day in the requested window (8-way `buffer_unordered`). Days not in the archive set are zero-filled, so the series is always dense and oldest-first. - Drop the `Day` period — nlbwmon archives are daily, so intra-day points were never real. Periods are now week (7d), month (30d), and 3months (90d). - Add `ymd_to_days` / `parse_ymd_to_days` / `lookup_mac_bytes` helpers with unit tests covering round-trip dates, malformed input, and missing MACs. - Persist `/etc/nlbwmon/data/` across sysupgrade so history survives updates. Frontend: - Remove the `'day'` period from the type, dropdown, and mocks. - Render an empty-state message when every point is zero (real "no traffic" signal now that the backend zero-fills). - Render an error notification with a Retry button when the RPC fails. - Handle the single-point case as a flat segment instead of bailing. - Rotate weekday labels so the rightmost label is today. - Mock data_usage at the RPC level instead of faking the nlbw shell call, removing the period-from-date-range guessing. * auth: redirect on session expiry, mirroring start-os setUnverified The RPC error-34 handler only cleared the authenticated signal, so an expired session left the user on the protected route until they clicked something. Move the (clear signal + navigate) pair into AuthService.setUnverified() — matching start-os's auth.service — and call it from both the header logout and the RPC 401 path so session expiry actually redirects. * ethernet: fix spinner hang on profile change The pre-existing spinner hang lives in the FE poll loop, not the BE. pollUntilSettled awaits systemInfo() with no per-request timeout, so a poll wedged on a half-broken connection blocks the for-loop and the loading subscription never unsubscribes. Wrap each poll in a 5s Promise.race with a code: 0 throw so a wedged request transitions into the reconnecting dialog, which already polls concurrently and closes once the daemon recovers. 15ed46e tried to fix this on the BE by awaiting the reload sequence inline. That regressed badly for ethernet specifically — set_config rewrites every bridge-vlan on br-lan, briefly dropping wlan0's VLAN 1 membership, so the inline response rode the disrupted path. Revert to the spawned reload and `firewall restart`. Update the comment to flag why this endpoint diverges from the inline pattern used elsewhere. * chore: refactor schedules and other minor fixes * chore: fix * docs: distill CLAUDE.mds to AI-only; add Documentation section to CONTRIBUTING.mds Move framework intros, commands, and Key Files tables out of CLAUDE.mds (they duplicated content already in ARCHITECTURE/CONTRIBUTING). CLAUDE.md keeps only repo-specific gotchas: openwrt/ submodule warning, hours-long make image, no test framework wired in web/, etc. Add bulleted Documentation section near the top of CONTRIBUTING.md with the explicit doc-sync mandate. Workflow: skip OpenWrt image build on doc-only changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: build only on tags and dispatch; private-repo minutes were burning quota Master pushes and PRs were triggering ~5h OpenWrt builds (~29k min in May alone), exhausting the org's free Actions pool. Triggers preserved as comments inline to restore once the repo is published. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(wifi): source password from EEPROM, drop /key_backup partition (#47) * feat: source WiFi password from EEPROM tag 0x2F, drop /key_backup partition The on-board I²C EEPROM (24c02 at bus 2 / 0x50) is now the canonical store for the per-device WiFi PMK, programmed by the hardware vendor during manufacture as an ONIE TLV record (tag 0x2F, 12 ASCII bytes). UCI remains the live source of truth: restore_wifi_if_needed only consults EEPROM when /etc/config/wireless has no key on the AP interface — first boot or after a factory reset wipes the overlay. No on-device init flow is required for a vendor-programmed board. This removes the eMMC /key_backup partition and the SD-card baked-password mechanism (SWRTPWD magic), along with the startwrt-cli init / manufacture / has-baked-password subcommands and the serial console dispatcher logic that drove them. The setup wizard's flash path no longer writes /etc/config/wireless into the new overlay — the post-reboot eMMC daemon populates it from EEPROM instead. For boards the vendor never programmed (DIY installs, corrupt blobs), the AP simply doesn't come up and the operator runs \`startwrt-cli set-wifi-password [--manual]\` over ethernet/serial to provision one into UCI. Adds i2c-tools / coreutils-timeout / python3 to the OpenWrt config for EEPROM diagnostics. Removes /etc/config/wireless from CONFFILES_EXCLUDE so user VLAN/profile config is preserved across Update flashes. * feat(wifi): validate PSK length, auto-enable radios on first admin password - Reject PSK < 8 or > 63 chars at the API boundary so hostapd doesn't silently refuse to start the AP. - On first admin-password add, flip factory-disabled + hidden radios to enabled + broadcasting. Without this, a fresh device with an unprovisioned EEPROM tag 0x2F leaves wireless sections disabled, so setting a password would write the key but broadcast no SSID. Only fires when no AP iface yet has a key; explicit toggles are respected on subsequent edits. - Default firstboot SSID: OpenWrt -> StartWRT. * refactor(ctrl): replace axum-server with start-os WebServer + TlsListener (#46) axum-server's accept loop had no defense against connection accumulation (half-open TCP sockets, silently-dead HTTP/2 streams, transient accept errors), pushing the daemon toward fd/slot exhaustion with no recovery path. start-os already solves this in `core::net::web_server::WebServer` and `core::net::tls::TlsListener`; reuse those primitives instead of hand-rolling a hyper-util loop. WebServer provides: - Tuned TCP keepalive on every accepted socket (60s idle + 6×10s probes ≈ 2 min half-open detection, via the shared `default_keepalive` helper landed in start-os #3213) - HTTP/2 PING keepalives (25s interval, 300s timeout) - Accept retry with backoff on transient errors (EMFILE/ENFILE) - GracefulShutdown connection tracking - RFC 8441 extended CONNECT (enable_connect_protocol) for h2 WebSocket upgrades TlsListener adds 5s ClientHello + 15s full-handshake timeouts and runs each handshake in a per-connection task so a stalled client cannot block accept. Cert hot-reload is now an `Arc<ArcSwap<TlsMaterials>>` consulted on each handshake; `regenerate_server_cert` reloads the on-disk PEMs and swaps in a fresh value so LAN IP / IPv6 changes take effect without a daemon restart, while existing connections keep their original cert until they close. `/api/logs` is registered with `any` (not `get`) so HTTP/2 CONNECT requests reach the WebSocket extractor — required because WebServer serves h2 by default. Auth middleware reads `TcpMetadata` from request extensions instead of axum's `ConnectInfo<SocketAddr>` — same data, different plumbing. Frontend logs view: open the live WebSocket synchronously in the constructor (was deferred until after the snapshot RPC, which silently dropped the socket on quick teardown) and stop discarding the first N live entries — the snapshot and `logread -f` stream don't actually overlap. Bumps the start-os submodule to master. Replaces #40. * rebase submodule onto 25.12.3 (#49) * bump deps * feat(profiles): restructure security-profile dialog; validation, blackout/DNS/outbound polish - Group the profile dialog into General / LAN / DNS / WAN-Internet sections; constrain the Name field and align the Subnet octet group and the Outbound/DNS controls. - Replace the "use custom DNS" toggle with an "Inherit from system" / "Custom" radio; replace Outbound Routing's WAN-or-VPN select with a "Direct" / "VPN" radio plus a conditional VPN-client picker (the VPN option is disabled when no clients exist). - Rename the WAN schedule to "Blackout times" everywhere (dialog, help, and the shared Add/Edit Blackout Window dialog); disable the blackout section when WAN access is "None" without dropping its windows. - Stop disabling Save: surface inline errors instead. Require a profile selection for LAN whitelist, at least one IP/CIDR for WAN whitelist/blacklist, and keep the subnet-locked check when devices have static reservations. - Show a 15-minute quick-pick dropdown on the blackout window time inputs (also used by the Wi-Fi blackout schedule). - Fix the schedule grid's uneven inter-day column gap. - Register the @tui icons that were referenced but unregistered (hard-drive-upload, activity, external-link, inbox, repeat); the Backup -> Restore button now uses @tui.hard-drive-upload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: change profile modal design and few other things * Add signed OTA firmware updates (#53) * Port signed OTA update flow from feat/ota-update Manually port the OTA update feature from the (working) feat/ota-update branch onto a fresh branch off master, adapted to master's evolved shape. Diff base for the port is f5af239..feat/ota-update — that's the precise OTA-only delta. Cherry-picking against master would have dragged in the start-os WebServer/TlsListener refactor and other master-evolution noise unrelated to OTA. Backend: - New modules: progress, update, registry/{mod,asset,device_info,os,signer}, sign/{mod,ed25519,commitment}. Copied verbatim — they compile against master after a small Error::other shim in error.rs that wraps Error::new(eyre!(...), ErrorKind::Unknown). - continuations.rs: new WebSocketFuture + WebSocketHandler types, RpcContinuation enum-with-variants conversion, Guid::from_str. - lib.rs: register the four new modules; add signing_key field to ServerContext. - system.rs: real async newer_versions handler (replacing master's stub), semver comparison helpers, new 'update' subcommand. Both update and newer-versions registered with .with_call_remote::<CliContext>() so they're reachable via startwrt-cli, not just the daemon UI path. - daemon.rs: /ws/rpc/{guid} WebSocket route. Registered with any() not get() so HTTP/2 CONNECT (RFC 8441 extended CONNECT) reaches the upgrade extractor — same reason /api/logs uses any(). - error.rs: Error::other(msg) convenience constructor. - Cargo.toml: ed25519-dalek, blake3, url, der, pkcs8, futures-util, form_urlencoded, rand_core_06; reqwest gets +json +stream features. Frontend: - system.service.ts: full rewrite with updating/updateProgress/rebooting signals, WebSocket subscription, post-reboot polling. - api.service.ts + live/mock-api.service.ts: systemUpdate() abstract and impls; FullProgress/NamedProgress/Progress wire types. - settings/general/index.ts: update banner with three-state template (progress / rebooting / button-with-confirm). API_CONTRACT.md: system.update + system.newer-versions + /ws/rpc/{guid}. openwrt submodule: bump to start9/25.12.3-ota with two commits on top of 25.12.3: - spacemit: build sysupgrade image for BPI-F3 (reapplied from 8235008779 which lived on the abandoned 25.12.2 branch) - spacemit: declare SUPPORTED_DEVICES so sysupgrade metadata_check accepts OTAs ('spacemit,k1-x' + 'bananapi,bpi-f3' alongside 'bananapi-f3' to match what board_detect reports) Makefile: image target now produces both pack/sdcard.img AND sysupgrade.img.gz ($(OPENWRT_IMAGES) with grouped target rule). * Remove unused device signing key infrastructure; bump to beta.2 The port from feat/ota-update brought along a per-device Ed25519 key plumbed through ServerContext.signing_key, persisted at /etc/startwrt/device_key.pem (+ /etc/sysupgrade.conf entry to survive flashes), and attached as X-StartOS-Auth-Sig on registry RPC requests. This is dead end-to-end on this codebase: API_CONTRACT.md doesn't document any auth-sig header, the registry doesn't verify it, and firmware integrity is enforced by an independent path (Blake3 commitment + asset-side signers via RegistryAsset::validate). Remove the cruft to shed ~150 LOC of glue, the on-disk key file, the sysupgrade.conf entry, transitive trait methods, and 3 of 5 sign-module tests. What stays untouched (the real security primitives, all with active callers): Blake3Commitment, Digestable, AnyVerifyingKey, AnySignature, AnyDigest, AnyScheme, SIG_CONTEXT, AcceptSigners, RegistryAsset::validate + all_signers, and the verify()/verify_commitment() methods on SignatureScheme. Specifically removed: - update.rs: DEVICE_KEY_PATH, load_signing_key, generate_signing_key, ensure_sysupgrade_conf_entry; signing_key parameter from fetch_newer_versions. - lib.rs: signing_key field on ServerContext (struct + Default). - daemon.rs: load/generate bootstrap block at startup. - system.rs: ctx.signing_key.as_ref() arg in newer_versions handler. - registry/mod.rs: AUTH_SIG_HEADER, SignatureHeader struct + sign/ to_header_value impls, signing_key parameter on call_registry_rpc, the X-StartOS-Auth-Sig header injection block. - sign/mod.rs: AnySigningKey enum + all impls (FromStr, Display, Serialize, Deserialize, scheme, verifying_key); SignatureScheme::SigningKey associated type + sign() + sign_commitment() trait methods; AnyScheme::sign() impl. Three tests deleted (test_sign_and_verify_commitment, test_signing_key_pem_roundtrip, test_signature_pem_roundtrip). Two surviving tests rewritten to derive AnyVerifyingKey via raw ed25519_dalek without the wrapper. - sign/ed25519.rs: Ed25519::sign impl + SigningKey associated type. - sign/commitment.rs: RequestCommitment struct + impls (from_body, to_query_string, from_query, Digestable for RequestCommitment). Also bumps Cargo.toml version to 0.1.0-beta.2 to mark the cleaned post-port state. * Harden K1 OTA path: semver compare, no vector codegen Three independent fixes on the OTA update path: - Version comparison now uses the `semver` crate instead of a `(major, minor, patch)` tuple. The tuple compare stripped the pre-release suffix, collapsing every `0.1.0-beta.N` to `0.1.0` — so an OTA between two betas never registered as "newer". semver honours pre-release precedence (`beta.3 < beta.4 < 0.1.0`). - Drop `+v` (RISC-V Vector) from RUSTFLAGS and the zigcc/zigcxx `-mcpu` strings. K1 implements V 1.0 but traps on misaligned vector-element accesses the Bianbu 6.6 kernel doesn't emulate, so auto-vectorised code (blake3, memcpy, TLS) SIGBUSes with no Rust panic. verify-isa.sh now also fails the build on any `vset*vl*`. - Bump openwrt submodule for "rework K1 sysupgrade to write partitions in place". * Finish OTA update flow: progress fixes, boot confirmation, UI dialog Backend: - progress.rs: fix PhaseProgressTrackerHandle::complete() to flush remaining phase weight. Pre-assigning `contributed` made update_overall's change check skip the contribution, so phases that only start+complete (verify/apply) never counted toward overall. Add tests covering flush-on-complete and partial progress. - update.rs: call download_phase.start() before set_units/set_total — those are no-ops on a NotStarted phase, and start() reset them to None. Add a pending-update marker (/etc/startwrt/pending-update): written before sysupgrade, cleared if sysupgrade returns (failure), and confirmed on the next boot. Log update apply/success/failure to the Activity log. - daemon.rs: check the pending-update marker on normal-mode startup so a completed update is recorded once the new firmware is up. - system.rs: sort newer_versions() by semver precedence. Map iteration was lexicographic, mis-ranking multi-digit pre-releases (beta.10 before beta.9) while the frontend treats the last element as newest. - stage-files.sh: add the pending-update marker to keep.d so it survives the sysupgrade overlay wipe. Frontend: - Add UpdateProgressDialog: a blocking dialog that owns the update lifecycle (kicks off startUpdate, shows a spinner through update and reboot, self-closes on reconnect or start failure). Replaces the inline progress block in the general settings route. - system.service.ts: distinguish a clean update failure (no reboot, surface a toast) from a success reboot; suppress NetworkRestart poll errors for the update window; track whether the device actually went offline to tell a real reboot from a pre-reboot failure, and send the user to login after a confirmed reboot. * build: use npm ci for web build; revert package-lock.json churn The web build recipe ran `npm install`, which is free to rewrite package-lock.json — reconciling it against the registry and re-normalizing the lockfile format across npm versions. Commit 9fb067c carried 89 lines of exactly that churn (peer/optional flag normalization, re-added transitive optional deps) with no matching package.json change, so none of it was an intentional dependency update. - Makefile: switch the $(WEB_DIST) recipe from `npm install` to `npm ci`. `npm ci` installs strictly from the lockfile, never writes to it, and fails loudly if package.json and the lock drift instead of silently rewriting it. Mirrors start-os, which uses `npm ci` for its web build. - web/package-lock.json: revert to the pre-9fb067c state. The file is now byte-identical to master, so feat/ota-port carries zero net lockfile change. * bump start-wrt version in package.json and package-lock.json * build(web): restore optional/peer transitives in package-lock.json 47cbed4 reverted the lockfile to master's pre-9fb067c state, calling the 89 lines 9fb067c added unintentional churn. They weren't: master's lockfile was generated by an older npm that didn't materialize optional/peer-resolved transitives, while npm 10+ does. 9fb067c's `npm install` had correctly captured `@emnapi/core`, `@emnapi/runtime`, and `@types/semver` as peers of already-locked `@emnapi/wasi-threads` and `ng-morph`. `npm ci` (kept from 47cbed4) fails closed on the older-shape lock under any modern npm. Restore the 9fb067c shape, carrying the 0.1.0-beta.2 bump from 966f042 forward. * web(update-dialog): apply PR review cleanups - Replace .update-dialog wrapper with :host styling - Use global .g-secondary utility class for hint color - Swap <p> tags for <div> and drop the margin reset - Remove redundant `closed` latch; completeWith() synchronously destroys the dialog and tears down the effect Addresses review comments #4-#6 and #8 on PR #53. i18n/DialogService threads (#1-#3, #7) deferred to a follow-up; start-wrt has no i18n dictionaries or @start9labs/shared dependency yet. * web(update-dialog): use <small> for secondary hint Drop the <div> wrappers around dialog text — the host is already display: flex, so children lay out directly. Replace the .hint div with a native <small class="g-secondary">, which natively shrinks the font size and makes the custom .hint rule dead CSS. * Route IPv6 through outbound VPNs + migrate fw3→fw4/nftables (#54) * feat(vpn/ipv6): route IPv6 through outbound VPNs [UNTESTED] Profiles whose outbound is a v6-capable WireGuard VPN now route IPv6 the same way they route IPv4, instead of leaking it around the tunnel. Backend (ctrl/profiles.rs): - rewrite_routing emits a v6 leg gated on is_ipv6_enabled() AND the outbound VPN actually carrying v6 (outbound_supports_ipv6). Three new sections per profile: prt6_<iface> (::/0 in the per-VLAN table), prl6_<iface> (lookup main, suppress_prefixlength=0 — escape so cross-VLAN/link-local /64s stay local), and prr6_<iface> (per-VLAN VPN default). Can't reuse IPv4's src=<prefix> matcher since LAN /64s are dynamic under DHCPv6-PD, so we match on logical in-iface instead. - rewrite_dhcp forces ra_default=1 when v6 routes through a VPN (not for plain wan), so odhcpd advertises this router as the IPv6 default even when wan6 has no PD/default route. - reload_system{,_and_wifi} now restart odhcpd so RA/DHCPv6 changes take effect (it caches config in memory). Backend (ctrl/vpn_client.rs): - OutboundVpn gains supports_ipv6, derived from the WG interface having any IPv6 Address. - get_peer_endpoint_host strips surrounding [...] of bracketed IPv6 literals so chain endpoints parse as IpAddr. - rewrite_vpn_chain_routes emits a route6 /128 for IPv6 endpoints (was IPv4-only /32). Backend (uciedit/openwrt.rs): - New NetworkRoute6 / NetworkRule6 typed sections and Dhcp.ra_default. Build: enable ip6tables-mod-nat + kmod-ipt-nat6/nf-nat6. IPv6 SNAT is done out-of-band by /etc/firewall.startwrt-masq6 since fw3 has no masq6 UCI option. API: OutboundVpn.supports_ipv6 added to API_CONTRACT.md, api.service.ts, and mock-api.service.ts. Adds 9 unit tests covering the v6 routing gates, cleanup on outbound switch, bracket stripping, and route6 emission. End-to-end behavior on hardware is unverified. * fix(network): generate per-device ULA prefix instead of hardcoded /48 The firstboot network config shipped a hardcoded ULA prefix (fda7:5549:a8c::/48), so every device used the same ULA. Chaining start-wrt routers then collides: the WAN side learns the same /48 and shadows the LAN route, black-holing reverse-NAT'd replies such as IPv6 VPN return traffic. Set `option ula_prefix 'auto'` so the 12_network-generate-ula uci-default generates a unique random /48 per device at first boot (RFC 4193). * feat(firewall): migrate fw3/iptables → fw4/nftables; dedicated VPN egress zone Switch the image's firewall from fw3 (iptables) to fw4 (nftables) and rework VPN outbound routing to fit fw4's native feature set, replacing two iptables-era workarounds. Build: - Swap firewall→firewall4; drop ip{,6}tables + xtables packages and kmod-ipt-* / kmod-nf-nat6 in favor of kmod-nft-* (core/fib/nat/offload), libnftnl, nftables-json. - Bump openwrt submodule to ce8b3a0 (kmod-crypto-crc32c rename for 6.18), required for the nftables ruleset to build. VPN egress zone (ctrl/profiles.rs): - Replace "stuff the wg interface into the wan zone + out-of-band /etc/firewall.startwrt-masq6 ip6tables script" with a dedicated `vpn_<wg>` zone carrying masq=1 AND masq6=1. fw4 has a native masq6 UCI option, so NAT66 on VPN egress no longer needs an include script, and wan6's GUA path / inbound port-forwards stay untouched. - ensure_vpn_outbound_zone creates/maintains the zone; resolve_outbound_zone maps an outbound to its zone name ("wan" or "vpn_<wg>"). - rewrite_firewall now targets per-profile wan-access forwardings/rules at the resolved outbound zone instead of always "wan". - cleanup_orphaned_wan_vpns → cleanup_orphaned_vpn_zones: tears down orphaned `vpn_<X>` zones plus any forwardings/rules referencing them, and still strips stray pre-migration wg entries from the wan zone. DNAT-return marking: - fw4 has no UCI equivalent for `-m conntrack --ctstate DNAT`, so the per-profile mangle MARK rule moves to a static nftables chain shipped at /etc/nftables.d/10-startwrt-dnat-mark.nft (auto-included into inet fw4). The daemon now only ensures the matching `ip rule` (dnat_return → main). - Drop the now-unused FirewallRule.extra UCI field. - stage-files.sh copies backend/nftables/*.nft into /etc/nftables.d. Schedules: - Window-start/REJECT rules now target the profile's egress zone ("wan" or "vpn_<wg>") instead of hardcoded "wan", and a profile outbound change rewrites + restarts the schedule crontab so the next blackout boundary doesn't REJECT toward a stale zone. Tests updated for the dedicated-zone layout and the removal of the per-profile dnat_mark rule. Comments referencing fw3/iptables refreshed to fw4/nftables. End-to-end behavior on hardware is unverified. * feat(vpn): fail-closed kill switch for VPN-routed profiles Give the per-VLAN `dev <wg>` default route a low metric (1) and add an `unreachable` fallback default on loopback at a high metric (2048), for both v4 (prtb_) and v6 (prt6b_). While the tunnel is up the dev route wins; the moment the WG interface drops, the fallback catches traffic with ENETUNREACH instead of letting the ip rule fall through to the main table and leak out WAN. Install the v6 policy-routing rules (prl6_/prr6_) for every VPN-routed profile, independent of global IPv6 state or whether the VPN carries v6, so v6 always fails closed. The `dev <wg>` v6 default (prt6_) is still only added when the outbound actually carries v6. Add `metric` and `type` (kind) fields to NetworkRoute and `type` to NetworkRoute6 in uciedit to support metric-ordered and `unreachable` routes. * fix(vpn): rebuild WAN forwarding when a VPN is deleted or disabled After resetting an affected profile's outbound to "wan", re-apply its full config via the new profiles::reapply_profile_config (firewall + dhcp + dns + routing) instead of only rewrite_routing + rewrite_dns_forwarding. The old path left the profile's forwarding pointing at the torn-down vpn_<wg> zone with no `<zone> → wan` rule, so fw4 dropped all of its WAN traffic. Also run cleanup_orphaned_vpn_zones on the disable path, matching the delete path. * feat(vpn/ipv6): route inbound port-forward replies via wan6, not the VPN A device whose profile routes ::/0 through an outbound VPN must still be reachable on its native wan6-PD GUA via an IPv6 published port. The per-profile v6 policy rule (prr6_<iface>) captures the device's reply traffic too, so replies to externally-initiated connections would egress the VPN instead of wan6 — asymmetric routing, connection fails. IPv6 port-forwards are pure filter ACCEPTs (routable GUA, no DNAT), so unlike IPv4 there's no `ct status dnat` to key on. Instead, a new static nftables chain (11-startwrt-inbound6-mark.nft) connection-marks IPv6 flows initiated from WAN — defined by exclusion of the LAN bridge and WG tunnel interfaces, so it's immune to which physical port is WAN — and restores that mark onto every packet of the flow. The daemon ensures the matching ip6 rule (network.dnat_return6, fwmark 0x80 -> main, priority 100) for every VPN-routed profile, sitting ahead of prl6_ (150) and prr6_ (200) so marked replies leave via wan6. - profiles.rs: add ensure_dnat_return6_rule(), called from rewrite_routing alongside the v4 sibling; two tests covering VPN- vs wan-routed profiles - nftables/11-startwrt-inbound6-mark.nft: new prerouting/mangle chain * feat(vpn/ipv6): assign per-VLAN ULA /64 to v6-capable profiles Previously only the admin LAN got an ip6assign; profile interfaces had it stripped unconditionally, so non-admin VLANs never received IPv6. Now ipv6_set and profile create/edit sync each non-admin VLAN's ip6assign to its IPv6 eligibility: a profile whose outbound VPN carries v6 gets a /64 (a ULA carved from the device prefix, NAT66'd out the vpn_<X> zone's masq6), while a profile on a v4-only VPN gets none so v6 can't leak outside the tunnel. Supporting fixes: - set_config no longer clobbers the admin LAN's ip6assign. That prefix is owned by lan::ipv6_set (the LAN IPv6 page) and uses the user-configured value; editing the admin profile was resetting /60 -> /64. - Profile create/edit now does `network restart` rather than `reload` (reload_system_full / reload_system_and_wifi_full). netifd only recomputes IPv6 prefix distribution on a full restart, so a reload left a newly v6-eligible profile without its delegated /64 and odhcpd with nothing to advertise. Adds regression tests for the ip6assign sync, the admin-LAN prefix preservation, and documents a known limitation (TODO ipv6/nat66): a wan-routed non-admin profile on a /64-only ISP still has no v6 internet path, since the single GUA /64 goes to the admin LAN and the wan zone has no masq6. * fix(firewall): scope default-mode remote-access rules to private/ULA sources Default-mode remote access emitted ACCEPT rules for 80/443/22 restricted only by address family, with the private-vs-global decision made once at apply time from the current WAN address. The rules themselves carried no source restriction, so if a global address later appeared on the WAN (prefix change, DHCP lease swap, IPv6 GUA) without config being re-applied, those rules would expose the ports to the whole internet. Scope each default-mode rule by `src_ip` instead, so a globally-routable client can never match regardless of what address lands on the WAN: - IPv4: one rule per RFC1918 range (10/8, 172.16/12, 192.168/16). fw4's `option src_ip` holds a single value, so each range needs its own rule; a name suffix keeps the generated section names unique. - IPv6: the ULA supernet fc00::/7. `always` stays unscoped — it's the explicit opt-in to full exposure; `never` still opens nothing. Rework REMOTE_ACCESS_PORTS from (name, port) pairs to bare ports, since section names are now derived per rule (port + optional family suffix). Tests updated for the new rule counts (default IPv4-only 3->9, both families 12, etc.) and to assert the src_ip scoping per family. * feat(devices): persistent name cache; resolve display name server-side (#55) * feat(devices): persistent name cache; resolve display name server-side dnsmasq's lease file is RAM-backed and drops a client's hostname on lease expiry, dnsmasq restart, or renewals that omit DHCP option 12. Such devices reverted to a `device-<mac>` placeholder in the UI until they happened to re-advertise their name. Add a persistent, MAC-keyed cache (device_names.rs) that remembers the last DHCP-advertised hostname per device. It sits below the live UCI and DHCP-lease sources in the resolution chain, so a fresh live name always wins and the placeholder only ever shows for a never-seen device. - Cache holds DHCP-learned names only; authoritative UCI static names live in /etc/config/dhcp and are never cached or pruned. - Stored as JSON written atomically (temp + rename) so an external tar/read during `sysupgrade --create-backup` sees one complete doc; in-memory map is authoritative, disk rewritten only on change. - 60d retention and a 1000-entry cap (oldest-first by last_seen); last_seen bumps on unchanged entries are rate-limited to 1h to gate disk writes, so a quiet, stable network re-serializes at most hourly. - Listed in keep.d so it survives sysupgrade; dropped on `forget`. devices.list now resolves the full name chain (UCI -> DHCP -> cache -> placeholder) server-side and returns a non-optional `name`. The frontend stops generating names client-side; `DeviceFromApi.name` and `Device.name` become required. Updates API_CONTRACT.md and the mock service to mirror the server-side chain. * fix(devices): touch last_seen at most daily, not hourly The device-name cache bumped an unchanged entry's last_seen (and so rewrote the JSON to disk) at most once an hour. Against a 60-day retention window that cadence is far more disk churn than the LRU needs: a quiet, stable network was re-serializing the whole map every hour for no behavioral gain. Raise TOUCH_INTERVAL_SECS from 1h to 24h. New and renamed devices still flush immediately (they bypass the interval gate), so only the periodic keep-alive touch slows down; 24h of last_seen staleness against a 60-day prune cutoff cannot cause premature eviction. * fix(profiles): allocate interface ids that avoid reserved UCI names (#56) * fix(profiles): allocate interface ids that avoid reserved UCI names Interface id allocation only de-duplicated against live kernel netdevs via `ip link show <id>`. But a profile's interface id never becomes a literal netdev — the kernel device is `br-lan.<vlan>` — so that check never caught UCI-level collisions. Two profile names that sanitize to the same 5-char prefix, or a renamed-then-recreated name whose id is still reserved, would collide and hard-fail with InterfaceNameConflict. Gather the ids already in use from UCI (network interface sections plus startwrt profiles) before allocation, and have allocate_interface_name avoid them, falling back to a random id. Interface ids are opaque and never surface in the UI (profiles display fullname), so the random fallback carries no UX cost, and ids stay stable across renames. Also harden hint sanitizing: concatenate all alphanumerics instead of taking only the first segment, and drop leading digits (UCI ids map to shell variable names, which can't start with a digit). Mirror the same collision-safe allocation in the mock API. * fix(profiles): always randomize interface ids instead of seeding from name Interface id allocation previously seeded from the profile name and only fell back to random when the sanitized hint was empty or already taken. Since the id is opaque and never surfaces in the UI (profiles display fullname), there is no UX benefit to a name-derived id, and the seeding logic was the source of the collision the prior commit had to work around. Make allocate_interface_name always generate a random id, dropping the hint parameter and the sanitize_interface_hint helper. Keep the 'taken' set: a random 5-char id can still collide with a reserved UCI id (lan, wan, wg_*, another profile), which would hard-fail in create_config, so the allocator still retries against taken ids and live kernel netdevs. Mirror the same always-random allocation in the mock API. * Refactor/start os conventions (#57) * fix(cli): send JSON not CBOR for CLI→daemon RPC calls Regression from pulling start-os in as a submodule: it depends on rpc-toolkit with default features (`default = ["cbor"]`), and Cargo feature-unification then enabled `cbor` for the whole build. That silently switched `call_remote_http` to encode request bodies as CBOR, but the rpc-toolkit HTTP server only parses JSON — so the body fails to parse before any handler runs. The breakage wasn't noticed at the time. Replace `call_remote_http` with a hand-rolled POST that always speaks JSON, mirroring start-os's `signature::call_remote`. Auth still rides on the client cookie store (loopback / local auth cookie), so no signature header is needed. Also surface non-2xx HTTP responses as a clear Network error rather than feeding the (likely non-JSON) body to the JSON-RPC parser, since the server returns RPC-level errors as 200 + a JSON-RPC error body. * fix(ssl): give each Root CA a unique Subject DN to avoid browser collisions Browsers key trusted CAs by Subject DN. Every fresh-flashed build minted a Root CA with an identical DN but a different key, so Firefox/NSS verified the new chain against the old trusted CA's key and rejected it as SEC_ERROR_BAD_SIGNATURE ("Bad Signature"). Append a short random hex token (random_ca_suffix) to the Root CA CN at generation time so each minted CA is a distinct trust anchor — an old trusted CA now coexists harmlessly with a new one. Mirrors start-os, which embeds its per-device random hostname in the Root CA CN. * refactor(ssl): bake Root CA suffix into branding at construction Pass the per-CA random suffix into `startwrt_branding(root_ca_suffix)` instead of generating a base branding and then mutating `root_ca_cn` afterward in `generate_root_ca`. This mirrors start-os's `CertBranding::start_os(hostname)`, which embeds its per-device value in the CN at construction time. Intermediate/leaf generators pass `""` since they never read `root_ca_cn`. Tighten the test assertions: verify the Root CA CN carries the base label, and add `test_generate_root_ca_unique_subject_dn` to guard that two freshly-minted Root CAs get distinct Subject DNs (the collision that surfaces as "Bad Signature" on a reflashed device). No behavioral change to issued certs. * feat(schedule): support overnight windows + reject overlaps/equal times (#58) * feat(schedule): support overnight windows + reject overlaps/equal times Schedule windows (WiFi blackout and profile WAN) can now cross midnight: when end_time < start_time (e.g. 22:00-06:00) the window runs from start on its selected day(s) through end the following day. The closing cron edge (wifi up / firewall unblock) is shifted forward one weekday so it fires on the correct day, and the boot/reload reconciler (evaluate_and_apply_schedules) is made wrap-aware via a new window_contains helper gated on the previous day's mask for the after-midnight tail. Validation: equal start/end is rejected (ambiguous 0h/24h), and windows that overlap on the wrap-aware weekly timeline are rejected on *-set with InvalidValue. Overlap/shift/cron-day logic is factored into shared helpers in wifi.rs (windows_overlap, days_to_cron, shift_days_forward) reused by profiles.rs, with unit + async tests covering wrap, overlap, and equal-time cases. Frontend: the schedule timeline renders a wrapping window as a head block on its own day and a tail block on the next; blocks are edit-only (no drag/resize). The add/edit dialog allows end < start, rejects equal times, and runs a mirrored windowsOverlap check (web/src/app/utils/schedule.ts) to warn before submit. Time inputs now use 12-hour HH:MM AA display. Also adds a TODO noting WiFi blackout has no boot-time reconciler (unlike profile schedules), so an active overnight blackout is not reasserted across a reboot until the next cron edge. Updates API_CONTRACT.md and the mock API to document/exercise the new wrap and overlap semantics. * refactor(schedule): show per-block start/end times, single-click edit Each schedule block now displays its own range's start time at the top and end time at the bottom, replacing the ellipsis icons + full-window time that read identically on both halves of a wrapping window. Head ends at midnight, tail begins at midnight. Switch the edit gesture from double-click to single click and drop the now-removed `windowTime`/`getTime` helpers in favor of a `block()` factory that precomputes the formatted strings. Also: - Format a 24:00 (midnight) end as "12:00am" instead of "11:59pm". - Drop the "Are you sure?" confirmation dialog from window removal. * chore: fix schedule visuals a bit * chore: help text * chore: fix cards appearance * fix(schedule): render overnight window as one continuous segment An overnight window (e.g. 10pm Mon-6am Tue) splits into a head block on its own day and a tail block on the next. Both halves were labeling the shared midnight boundary, so the window read as two separate segments. Drop the head's midnight end label and the tail's midnight start label, leaving only the real start on the head and real end on the tail. Position the labels by their .start/.end class instead of :first-child/:last-child so a lone label still lands at the correct edge and keeps its backdrop band (a single remaining span otherwise matched both and stretched full height). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(schedule): allow equal start/end as a full 24h window Previously a window with end == start was rejected as ambiguous. Now it denotes a full 24-hour window (e.g. 09:00-09:00 next day), reusing the existing wrap-past-midnight machinery: end <= start spans into the next day, shifting the unblock/wifi-up edge forward by one weekday. Backend (profiles.rs, wifi.rs): - window_contains / windows_overlap / crontab regen treat end <= start as wrapping; drop the equal start/end rejection in schedule_set and blackout_set - add test_window_contains_full_day Frontend (schedule.ts, window.ts, schedule.ts util, blackout.html): - mirror end <= start wrap logic in windowsOverlap and block rendering (a midnight-start 24h window has no tail) - end-time picker offers a trailing 12:00 AM to close at end-of-day - drop the equalTimes validation error; update help text * fix(schedule): show end time for windows ending at midnight A window ending exactly at midnight (end == 0) closes on its own day and has no tail in the next column, yet it was rendered as a "head" block — whose end label is hidden so true overnight windows read as one segment. The result: such a window showed its start but never its 12:00am end (e.g. a 9:00pm-midnight block, or a full 24h midnight-to-midnight one). Distinguish "genuinely spills past midnight" (end > 0, splits into head + next-day tail) from "ends at midnight" (end == 0, stays a single "whole" block that keeps both labels). Only the former drops its boundary labels. The label-hiding was introduced in d80e86d; this surfaced more visibly once equal start/end 24h windows became allowed. * feat(schedule): deconflict cron edges and persist blackout windows in UCI Schedules built from adjacent or consecutive windows could race at a shared cron tick (one window's "up" edge firing at the same minute as the next window's "down" edge), briefly unblocking the resource. Project windows into deconflicted down/up edge maps keyed by minute-of-day and annihilate coincident down+up weekday bits, so back-to-back windows stay continuously blocked with no same-tick race. A fully-tiled week produces zero surviving edges, leaving cron nothing to execute, so reject full-week-no-gap coverage up front in both blackout_set and schedule_set (FE warns before submit via coversFullWeek). Move WiFi blackout windows into UCI (config wifi_blackout 'blackout') as the source of truth; the crontab becomes a disposable projection regenerated from UCI by regenerate_blackout_crontab. This drops the brittle round-trip that parsed windows back out of cron lines, and lets deconfliction merge/drop edges without losing the underlying windows. Factor the window serialize/parse/projection logic into shared wifi.rs helpers (serialize_windows, parse_windows, windows_to_minutes, deconflict_edges, covers_full_week) used by both the WiFi blackout and profile WAN-schedule paths; malformed entries and unparseable times are now dropped with a warning instead of silently. FE extracts toWeekSegments shared by windowsOverlap and the new coversFullWeek. Known gap (TODO retained): blackout is still edge-triggered with no boot-time reconciler, so a reboot mid-blackout won't reassert radio state until the next cron edge. * feat(schedule): reassert WiFi blackout on boot if inside active window WiFi blackout was edge-triggered by cron only. A reboot mid-window lost the edge: netifd raises the radios early in boot per the on-disk `disabled` flag (runtime `wifi down` doesn't persist), so WiFi came back up despite being inside an active blackout, staying up until the next cron edge. Add `reconcile_blackout_at_boot`, modelled on `profiles::evaluate_and_apply_schedules`. It recomputes the current in/out-of-window state (wrap-aware, reusing `window_contains`) and reasserts `wifi down` when inside a window. The out-of-window case is a deliberate no-op so we never re-enable a radio the user disabled. It runs after `restore_wifi_if_needed` so our `wifi down` is the final word over restore's `wifi reload`. The wrap-aware decision is split into a pure `windows_contain_now` helper with unit tests (non-wrap, overnight wrap, multiple/malformed, empty). Makes `window_contains` and `chrono_now` pub(crate) for reuse. This closes the steady-state gap, not the boot-window gap (netifd START=20 vs daemon START=99); that's documented as a follow-up TODO. * feat(schedule): regenerate cron projections from UCI on boot /etc/crontabs/root is a disposable projection of the UCI schedule stores and is wiped by sysupgrade (the stores persist), so schedule edges would stop firing after an upgrade. On boot, after the mid-window reconcilers run, rebuild both projections — WAN schedule and WiFi blackout — from UCI and restart cron once. This is a no-op when there are no windows and self-heals crontab drift on any boot. Both regenerators strip only their own tagged lines, so running them in sequence over the shared file is safe. --------- Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(lan): let users pick the /16 (second octet) within each RFC 1918 block (#59) * feat(lan): let users pick the second octet within each private block The LAN IPv4 form previously hardcoded the second octet per first octet (192->168, 10->0, 172->16), collapsing each RFC 1918 range to a single /16 and hiding the other 255 (10/8) and 15 (172.16/12) selectable blocks. Make the second octet a form control with per-block bounds: 192.168.0.0/16 -> locked to 168 (one /16) 172.16.0.0/12 -> 16..31 10.0.0.0/8 -> 0..255 The field is read-only for 192 and editable elsewhere; switching the first octet re-applies the block's min/max and snaps any out-of-range value back in. The wire contract is unchanged (the full address string already carried the octet). saveBlocked now also treats a second-octet change as a subnet change for the static-IP guard. * fix(lan): enforce RFC 1918 block boundaries server-side ipv4_set parsed any IPv4 and applied it with a /24 netmask, with no RFC-block validation — the per-/16 restriction was cosmetic (frontend only). The daemon RPC (and vestigial generic uci.set) would accept 8.8.8.8, out-of-range 172.x, any 192.x second octet, etc. Add validate_lan_block (10/8, 172.16/12, 192.168/16 with the same second-octet bounds the UI exposes) and call it before any config write. The admin (owns_lan) profile is a second path that sets the LAN /16, and non-admin profiles were unconstrained — an out-of-block VLAN subnet would escape the chosen range and break sync_cross_subnet_routes (which assumes siblings share the first two octets). Add validate_profile_block to set_config/create_config: admin must be a valid RFC 1918 selection; others must share the admin LAN's /16. All failures are ErrorKind::InvalidRequest. Documents the rules in API_CONTRACT.md (lan.ipv4-set, profiles.*). * test(lan): cover non-192.168 RFC 1918 block validation Add validate_profile_block_accepts_alternate_rfc1918_block, asserting a LAN in 10.42.0.0/16 accepts siblings within the block and a valid admin selection while still rejecting a sibling that escapes the block. Locks in the server-side boundary enforcement from 30fc874 for non-192.168 private blocks. Derive Debug on OldProfileState so test assertions can format it. * fix(lan): flag out-of-range second octet instead of silently snapping The second-octet field previously clamped any out-of-range value back into the active block's range, silently rewriting what the user typed. Replace the min/max validators with a dedicated block validator that flags the value instead, so saving is blocked and the allowed RFC 1918 range is surfaced to the user. - utils.ts: add secondOctetBlockValidator + isSecondOctetInRange; keep clampSecondOctet only for load-time normalization in parseIpToForm. - form/ip.ts: render the 192 block as a disabled display field, all other blocks as an editable number input with a signal-driven error hint that tracks both octets; swap the validator (not the bounds) on block change, only force-setting the value when there's a single legal choice (192 -> 168). - index.ts: extend saveBlocked to reject an out-of-range second octet and report the allowed min-max. * refactor(lan): apply PR #59 review feedback on the IPv4 block form Behavior-preserving response to the PR #59 reviews. The second-octet picker keeps the same per-block bounds, validation, and wire output — only the implementation is brought in line with the idioms the reviewers asked for, and the backend RFC 1918 check is simplified. - lan.rs: replace the hand-rolled match in validate_lan_block with Ipv4Addr::is_private(), which encodes exactly the same three blocks (10/8, 172.16/12, 192.168/16). Identical semantics; the existing validate_lan_block_* tests still pass. - form/ip.ts: remove the imperative effect (setValidators + force setValue) and the $-suffixed signal names. Derive the octet signals with tuiControlValue, bind the per-block validator declaratively via [tuiValidator], and resolve the locked 192 octet for display rather than mutating the control. Keep a display-only secondOctetError computed so the allowed-range hint still appears immediately on a block switch. - utils.ts: add resolveSecondOctet (collapses the single-value 192 block to its fixed octet) and route buildNetworkBlock/buildRouterIp through it; reduce the static secondOctet validator to just `required` (the block range is now applied by [tuiValidator]). - index.ts: resolve the second octet in saveBlocked before the range check and the subnet-change comparison, so the locked block's carried-over value is never falsely flagged. * Implement i18n - Translate the web UI into 5 languages (en/es/de/fr/pl) (#60) * feat(i18n): translate web UI into 5 languages (en/es/de/fr/pl) Port start-os's translation engine to localize the entire web frontend. Engine (web/src/app/i18n/): - i18n.service.ts: language switcher extending Taiga's TuiLanguageSwitcherService; maps POSIX locales (en_US, es_ES, …) to Taiga language names and lazy-loads the active dictionary. - i18n.providers.ts: I18N signal + I18N_LOADER injection tokens; wires Taiga's own widget strings and our dictionaries behind dynamic imports. - i18n.pipe.ts (`| i18n`): translates English keys via the active dict, falling back to the English key itself. - localize.pipe.ts (`| localize`) + locale-string.ts: render rich LocaleString values (plain string or per-locale map), mirroring start-os's T.LocaleString. - validation-errors.ts: provideTranslatedValidationErrors() routes <tui-error> messages through the pipe, with tpl() for interpolated templates; re-translates live on language change. Dictionaries: en.ts (source of truth, id->key) plus es/de/fr/pl, each lazy-loaded. Help content: replace the per-topic .html files with per-language TS modules (help/content/{en,es,de,fr,pl}.ts); update help.ts and modal-help.ts to resolve content by route and active language. Tooling: - scripts/check-i18n.mjs: validates that every `| i18n` / i18n.transform key exists in en.ts, every id is present in all dictionaries, and every help route is translated; run from the pre-commit hook. - package.json / angular.json wiring; utils/languages.ts defines the 5 supported languages with endonyms. Wiring: app.config.ts registers I18N_PROVIDERS; header adds a language switcher. Migrate all routes, components, and services to the i18n / localize pipes. * fix(i18n): apply saved theme/language globally and revert unsaved previews Treat saved system settings as the source of truth for both theme and language. The app-level effect now applies theme alongside language whenever system info loads or changes (boot and after Save), so the two preferences stay in sync with persisted state. On the General settings page, theme and language are previewed live on selection. If the user navigates away without saving, the DestroyRef hook now reverts both previews to the saved settings; a saved (pristine) form makes this a no-op. Mark the form pristine after a successful save so leaving the page afterwards doesn't trigger a spurious revert. * refactor(i18n): resolve help content to plain strings, drop LocalizePipe Help content is now resolved to the active language directly inside HelpService.content (returning Record<string, string>), instead of emitting LocaleString maps that are resolved later in the template via the `localize` pipe. This removes the indirection layer added for rich-content i18n: - delete LocaleString type and LocalizePipe - drop i18nService.localize() and the unused `loading` signal - header search filters on already-resolved strings - aside/modal-help templates drop the `| localize` step * rebase openwrt fork on 25.12.4 (#63) * fix(fonts): bundle Proxima Nova so the brand typeface actually loads (#62) * fix(fonts): bundle Proxima Nova so the brand typeface actually loads styles.scss overrode Taiga's --tui-typography-family-{text,display} to 'Proxima Nova', but the font was never shipped — no @font-face, no font files — so the UI silently fell back to system-ui and the brand typeface never loaded. Ported from start-os, which overrides the same vars but also ships the font. - Add the 7 Proxima Nova weights (100-900) under web/assets/fonts/Proxima_Nova/, served at /assets/fonts/. - Add matching @font-face declarations in styles.scss (mirrors start-os shared.scss). - Set font-family: 'Proxima Nova', system-ui on tui-root in main.ts (mirrors start-os app.component). The Taiga family vars only style text that uses Taiga typography tokens; this base rule makes all inherited text render in the brand font too. Visually subtle on Linux (system-ui is metrically close to Proxima Nova) but clearly different on macOS/Windows/mobile where system-ui differs. * fix(fonts): drop redundant font-family on tui-root Taiga components resolve their font from the --tui-typography-family-text and --tui-typography-family-display vars, which styles.scss already sets to 'Proxima Nova'. A raw font-family on the tui-root element is overridden by Taiga's typography tokens anyway (headings use the display var), so it added nothing the CSS vars don't already cover. Rely on the vars as the single source of truth so the brand font applies to both body text and headings. Addresses PR review feedback. * fix(fonts): ship only Proxima Nova 400 and 700 All Start9 designs use only normal and bold weights. Limiting the bundled faces to 400/700 keeps the UI on-spec — intermediate weights snap to the nearest shipped face instead of loading a distinct glyph. Drops the unused Thin/Light/Semibold/Extrabold/Black woffs. Addresses PR review feedback. * Fixes/ethernet devices vpn (#64) * fix(ethernet): use `firewall reload` after eth0 reassignment to avoid lockout Reassigning eth0 to a different profile ran `firewall restart` fire-and-forget after the network reload. The full fw4 table flush, under the default `input REJECT` policy, opened a window that (racing netifd's bridge work) intermittently locked out all management access until a manual reboot. netifd's `network reload` already applies the bridge VLAN/PVID change live via RTM_SETLINK/RTM_DELLINK netlink, and a port-VLAN move changes no zone<->interface binding, so an incremental `firewall reload` is sufficient. * fix(devices): list bridge-FDB-learned clients with no DHCP lease A device with an L2 link to the bridge but no DHCP lease and no IP-neighbor entry -- e.g. a static-IP or IPv6-only host reached through an external switch -- was omitted from devices.list entirely. Fold FDB-learned MACs into the membership set and mark them Online/Ethernet so physically-connected devices are visible. The bridge FDB ages (~300s default), so a just-unplugged device may linger briefly, which is preferable to a connected device never showing. * fix(dhcp): read all per-profile dnsmasq lease files, not just the base Profiles using custom or VPN DNS run their own dnsmasq instance writing /tmp/dhcp.leases.dns_<iface>; the base /tmp/dhcp.leases only holds the main instance's clients. The device list, the published-ports IPv4 fallback, and the lease flush/cleanup paths all read only the base file, so those clients were missing their DHCP hostname and lease IPv4. Add shared helpers (dhcp_lease_files / read_all_dhcp_leases) and route every reader through them. * feat(devices): recover device names via reverse mDNS Some devices never advertise a hostname via DHCP option 12, so they never land in the lease file and show up only as `device-<mac>`. They do still answer mDNS, so reverse-resolve their IPv4 over Bonjour to recover a display name. For any present, reachable device that no live source (UCI host, DHCP lease) or the name cache can name, query `avahi-resolve -a <ip>` against the local avahi daemon. Lookups are bounded — 1.5s timeout with kill_on_drop per query, fan-out capped at 8 concurrent — so a large LAN or a non-responder can't stall the device list. Hits are persisted to the existing name cache, so each device is queried at most once and a steady-state network produces no targets (no-op). The name cache now holds both DHCP- and mDNS-learned names, so rename `Observation::dhcp_hostname` to `hostname` to match. mDNS sits below DHCP and above the cache in the resolution chain. Enable the `avahi-utils` package (provides `avahi-resolve`); avahi-dbus-daemon was already enabled. * fix(devices): prefer static reservation IP over live ARP/lease When a device has a static IP reservation (UCI `host.ip`), surface that address as its `ipv4` rather than the live ARP neighbour or DHCP lease. The reserved address is the one the device is pinned to, so this stops the edit form from snapping back to the stale DHCP address after a reservation is saved — the client keeps its old lease (and thus its old ARP/lease entry) until it renews. Falls back to ARP, then the DHCP lease, when no reservation is set. * fix(devices): report the live IP/profile when a device roams VLANs A device that moves to another security profile picks up a lease on the new VLAN bridge but leaves a stale neighbor entry on its old one. Both entries share the MAC, and devices.list keyed everything by MAC then took the first ARP entry it found — so it reported the abandoned IP and the wrong security profile until the kernel aged the stale entry out. Use the active probe that already runs as ground truth: ping_unreachable_macs now also returns the IPs that actually replied (live_ipv4s) instead of collapsing to a per-MAC boolean. A new choose_ipv4_entry ranks a MAC's IPv4 neighbor entries — REACHABLE > probe-confirmed > DELAY/PROBE > STALE — so a confirmed-live STALE entry beats a stale-but-DELAY one (ranking on neighbor state alone would pick the wrong address). The displayed IPv4 and the VLAN-derived profile now both come from that single chosen entry, so they can't disagree, and the mDNS reverse-resolve target uses the same chooser so an unnamed roamed device is queried at its current address. Adds unit tests for the chooser (probe-confirmed-over-fresher-state, REACHABLE preference, IPv6/empty handling, deterministic tie-break). Status semantics and static-reservation precedence are unchanged. * fix(devices): cap mDNS reverse-resolve at one attempt per device per run The mDNS name-recovery pass was gated only on the name cache, so a device that suppresses DHCP option 12 *and* never answers Bonjour was re-queried on every poll — it never lands in the cache, so nothing stopped the retry. On a network with such devices, each list() call paid the avahi-resolve cost repeatedly. Track attempted MACs in a new MDNS_ATTEMPTED set: a device that answers is persisted to the name cache (gated out by the cache check); one that stays silent is recorded in MDNS_ATTEMPTED (gated out by the set). Either way a MAC is reverse-resolved at most once per daemon run. The set is cleared only on daemon restart, so a device that later starts answering Bonjour is picked up after the next restart. The lock is held only across the synchronous selection loop — no .await inside — and released before resolve_mdns_names(). * fix(vpn): route cross-profile peer replies through the tunnel, not the LAN bridge Two related gaps in inbound-VPN reachability across profiles: 1. Cross-profile peer routing. sync_peer_policy_routes only adds a peer's /32 to its own profile's policy table, while sync_cross_subnet_routes adds a sibling's whole /24 via the LAN bridge. In a vpn-routed sibling's table a peer IP then matches the /24-via-bridge route, so replies are sent onto the LAN bridge instead of into the WireGuard tunnel and the connection breaks. Add sync_vpn_peer_cross_routes, which installs a more-specific /32 via the peer's wg_<P> interface (named vxr_*) into every OTHER vpn-routed profile's table, overriding the /24. Recomputed idempotently and wired into every apply site that touches profiles or VPN servers (profiles create/set/delete, vpn_client delete/set_enabled, vpn_server set/delete/peer_add/peer_delete/remove). These routes only correct the L3 path; reachability stays governed by the firewall. 2. LAN-only client AllowedIPs. A "LAN only" peer previously got only the profile's own /24 in AllowedIPs, so it couldn't reach the other profiles that profile's lan_access permits. Add lan_only_allowed_ips, which builds the split-tunnel list from the profile's outbound lan_access (SameProfile / OtherProfiles / All). Computed before dump_all consumes cfgs in peer_add. Known gap documented inline (TODO reverse-parity): a profile permitted to initiate INTO a LAN-only peer's profile still can't reach the peer, since WireGuard drops inbound packets sourced outside the peer's AllowedIPs. * feat(vpn): give inbound VPN server peers first-class IPv6 When a profile serves IPv6 to its clients, inbound WireGuard server peers now get a stable v6 address alongside their v4 /32, instead of being v4-only. Peers can't sit in the profile's own /64 (DHCPv6-PD assigns it at runtime, non-deterministically) while WG client configs are issued once and must be static. So carve a dedicated, stable /64 from the device ULA /48 (network.globals.ula_prefix) using a high subnet-id band (0xf000 | vlan_tag) that stays clear of odhcpd's low sequential assignments: - wg_server_v6_groups() derives the /64, gated on the profile actually serving v6 (is_ipv6_enabled + outbound_supports_ipv6) and a concrete ULA prefix existing (None pre-first-boot when ula_prefix is still 'auto'). - set_wireguard_interface / add_single_peer give the interface its <wg64>::1 and each peer <wg64>::<v4-octet>, as a /128 in allowed_ips so route_allowed_ips installs it in the main table (how the router and sibling profiles reach the peer over v6 - no proxy_ndp needed). - peer_add writes the v6 /128 into the client config's Address and, for LAN-only peers, adds the device ULA /48 to AllowedIPs (v6 lan_access is enforced at the firewall; we can't scope to runtime-assigned sibling /64s). - sync_peer_policy_routes installs vsl6_/vsr6_ ip rules mirroring prl6_/prr6_ but keyed on 'in: wg_<X>', so peer v6 escapes locally for cross-VLAN/own-LAN and otherwise follows the per-VLAN tunnel - never leaking out wan6 on a vpn-routed profile (a v4-only tunnel drops it on the kill-switch). - ensure_server_v6_address keeps the interface consistent on peer_add if v6 was toggled on after the server was created. Also fix get_vpn_peer_configs / get_peers_for_interface to parse the v4 host from allowed_ips and ignore the trailing v6 /128, so the device list reports the IPv4 instead of letting the v6 entry clobber it. Adds NetworkGlobals to uciedit and exports VPN_ROUTING_PRIORITY / VPN_ROUTING_V6_LOCAL_PRIORITY from profiles for the new rules. * fix(ethernet): keep Admin VLAN 1 alive when its last port is reassigned Reassigning the last Admin (VID 1) ethernet port dropped the VLAN-1 bridge section, taking down br-lan.1 and the default WiFi SSID ("No route to host"). The AP netdevs are attached to br-lan at runtime by hostapd, so they never appear in `ethernet.ports` and can't keep VID 1 alive on their own. - Only drop an empty VID 1 section when VLAN filtering is off (flat bridge); when filtering is on, keep it so it carries br-lan.1 and the default SSID. - Re-run `wifi` after the network reload so the hostapd-attached AP netdevs re-acquire their PVID once the VLANs exist again. - Add a regression test for the single-ethernet-port hardware layout. * Align the device timezone with crond so scheduled jobs fire at the expected local time (#61) * fix(timezone): resolve POSIX TZ on-device from LuCI zoneinfo, drop bundled table The frontend shipped a hand-maintained IANA→POSIX table and sent both the IANA name and POSIX string to the backend. That table could drift from the device's actual tzdata and only covered ~80 curated zones. Move the source of truth onto the device: Backend: - `system.set-timezone` now takes only the IANA name and resolves the POSIX string via `ubus call luci getTimezones` (resolve_posix_tz); unknown zones error instead of silently writing garbage. Store zonename verbatim (with underscores) to match modern LuCI's writer and the zoneinfo keys. - Add `system.get-timezones` to back the settings dropdown with exactly the set the device can resolve (UTC first, then sorted table keys). - Restart crond after a timezone change so wall-clock schedules (WiFi blackout, WAN) re-base on the new /etc/TZ. - Carry the wizard's browser timezone into the fresh eMMC config on FreshStart (write_timezone), since the live set-timezone only reaches the throwaway microSD overlay. Record the outcome straight into the eMMC activity DB via the new activity::log_to helper. - Log timezone-updated activity entries (success / UTC-fallback). Frontend: - Delete the 600-line TIMEZONES table and getPosixTz/resolveTimezone; the dropdown now loads from getTimezones() and labels are formatted live via Intl (getTimezoneLabel). Default to UTC when the device zone is unset rather than masking it with the browser zone. - setTimezone params drop posixTz; setup wizard forwards the browser zone in the flash request. Updates API_CONTRACT.md, api.service.ts, and both live/mock services. * feat(timezone): make settings dropdown searchable, widen for long labels The on-device zone list (~400 entries) is too long to scan, and long labels like "(GMT-03:00) America/Argentina/Buenos Aires" were truncated in the narrow select. - Swap the timezone tuiSelect for tuiComboBox so the user can type to filter; the dropdown is fed through the tuiFilterByInput pipe. - Move the field to its own row (flex-basis: 100%, max 30rem) since the 50rem form section can't fit a box wide enough for the long labels alongside Theme + Language. tuiComboBox isn't matched by the global :has([tuiSelect]) sizing rule, so the width is set locally. * chore: cleanup --------- Co-authored-by: waterplea <alexander@inkin.ru> * Web UI polish: VPN path, viewport fixes, IP-reservation warning (#67) * feat(outbound): show client device at the head of the VPN connection path The Outbound VPN summary's connection-path graphic started at the VPN provider (e.g. Proton -> Internet), which obscured where traffic originates. Prepend a fixed "Client" node with a device icon so the chain reads Client -> VPN -> Internet (and Client -> Mullvad -> Proton -> Internet for multi-hop). The node is presentational only — rendered in the template, not added to buildConnectionPath() — so the VPN-chain/cycle logic and the loading fallback are untouched. Add the translatable "Client" string (id 508) to all five locale dictionaries. * fix(mock): show profile name, not UCI interface, in VPN "Used by" The Outbound VPN summary's "Used by" field rendered raw UCI interface names (lan, guest) when running against the mock API. The live backend (get_used_by_profiles in vpn_client.rs) already returns each profile's fullname, so the mock diverged from real-device behavior — and interface names are not user-meaningful, especially now that VPN interfaces are randomly generated. Map used-by profiles to p.fullname instead of p.interface so the mock matches live and the documented contract ("Which security profiles route through this VPN"). * fix(web): keep Settings tables and wide content within the viewport Several views ran off the right edge of the screen: - Settings > SSH Keys and Logs had no page-width cap (unlike sibling tabs such as Activity), so their tables overflowed horizontally. Cap the page with :host { max-width: 50rem } to match the established idiom. - The app shell sized the scroll area as calc(100vw - 3rem), which hardcodes the collapsed-sidebar width. With the sidebar expanded the scroll area was wider than the visible main column, so content on the right - the wide Published Ports table, its row actions, and the header Add button - was clipped and unreachable. Use min-width: 100% so the scroll area tracks the real main-column width in both sidebar states. This also lets wide tables scroll horizontally via the slim app-level scrollbar (matching the devices list). * feat(web): warn when a static IP reservation pins a new address When a user saves a device reservation that changes the IPv4 to a different address, show a dialog explaining the change only takes effect on the device's next DHCP request — the router can't push it to a connected client, so the fastest path is reconnect/reboot, and otherwise it applies within ~12h. Only warn when the address actually changes; merely making the current lease static (reserving the existing address) is silent. Add i18n strings (509/510) across en/de/es/fr/pl for the dialog body and dismiss button. * fix(web): restore full-contrast text for labeled avatars in VPN summary Taiga renders [tuiSubtitle] content in a muted color, which dimmed the device label shown alongside tui-avatar-labeled in the VPN connection path. Override the color to --tui-text-primary so the labeled avatar reads at full contrast. * fix(web): pin mobile content to viewport so nav expand slides, not squishes On mobile, expanding the side nav reflowed and squished the page content instead of sliding it off-screen. Pin tui-scrollbar to a min-width of calc(100vw - 3rem) and cap the page header to the same width under tui-root._mobile, mirroring start-os start-tunnel's outlet (desktop min: 100%, mobile: 100vw - 3rem). The desktop header max-width is relaxed back to 100% now that the mobile cap is scoped separately. * Unify reconnect UX into a global ConnectionService (#68) * Unify reconnect UX into a single global ConnectionService Replace the per-flow reconnect modals (RECONNECTING_DIALOG, ReconnectDialog) and the NetworkRestartService.suppress() plumbing with one root ConnectionService that owns all "router is unreachable" UX. Any network-level error (HttpError code 0) anywhere now funnels through reportUnreachable(): the first caller shows ONE sticky "Reconnecting" toast and starts ONE cancelling poll loop, and concurrent callers (the ~15 background form pollers) collapse into it. On recovery it dismisses the toast, confirms with a single "Connection restored" toast, and optionally runs a recovery intent (e.g. reload on a same-host IP change). Callers declare intent up front via expectDisruption() so whichever path observes the drop shows the right copy; the recovery intent is honored only from the call that actually observed the drop, so a stale context can't trigger an unexpected reload. Add per-request timeout plumbing through rpc/http: the RxJS timeout operator unsubscribes and aborts the in-flight XHR, so a wedged HTTP/2 connection (left dead after conntrack is flushed mid-restart) is torn down and the next probe opens a fresh socket, instead of multiplexing onto the dead one and stalling for the browser's full TCP timeout. Timeouts surface as network errors (code 0) so they flow through the same reconnect path. Update ActionService and all restart call sites (wifi, lan/ipv4, profiles, backup, advanced) to the new API; drop the old polling dialog and NetworkRestartService. Add "Reconnecting"/"Connection restored" strings to the en/es/de/fr/pl dictionaries. * Make reconnect recovery reliable across IP, reboot, and SSID changes Builds on the global ConnectionService to fix three recovery gaps where the indicator could get stuck or confirm too early: - IP / subnet change: add ConnectionService.reconnectAt(), which probes the destination cross-origin (no-cors fetch of /static/root-ca.crt) and auto-navigates there once it answers, with a 60s fallback force-redirect for untrusted-HTTPS / wedged connections. LAN IPv4 and profile admin-IP saves now suppress the indicator before the drop and hand off to it, replacing the old per-route "IP changed" dialogs. Scheme/port preserved; bare-IP targets the new address, hostnames re-resolve after DHCP renew. - Restart actions: defer the success toast ("WiFi settings saved") until the router answers again via successMessage, instead of toasting success while still unreachable. systemRestart polls with a 5s per-probe timeout so the drop surfaces promptly instead of stalling on the 60s race. - SSID change: replace the transient toast with a persistent ReconnectDialog that instructs the user to rejoin the new network and reloads on recovery; saveForSsidChange suppresses the global indicator and rethrows real backend failures instead of spinning. Supporting changes: new NetworkService (online/offline observable) drives a fresh probe on link/tab return so recovery isn't stuck on a wedged socket; preload all lazy route chunks at boot so an import() can't fail against the dead connection mid-restart. * Harden published-port forwarding against silent breakage (#70) * fix(published-ports): gate IPv6 forwards on a real global address (GUA) IPv6 port forwarding only works when the target is reachable from the WAN, which requires a Global Unicast Address (2000::/3). ULA (fc00::/7) and link-local (fe80::/10) are unreachable, so a forward to them is a silent no-op. Previously such forwards could be saved and would quietly never work. Backend: - Reject an enabled IPv6 port at save time (ErrorKind::MissingDeviceAddress) on any confident signal it can't work: the router has no delegated global prefix, the device resolved to a ULA, or the device is online with only a link-local address. A genuinely-offline device (no IPv6 seen) on a GUA-capable router is still deferred to the rule-creation GUA guard so a briefly-offline device doesn't fail an otherwise-valid save. - Consolidate the definition of "global" on system::has_global_ipv6 (parsed 2000::/3 range check). is_gua and devices::pick_ipv6 now delegate to it instead of string-prefix heuristics that misclassified deprecated scopes (e.g. site-local fec0::/10) as global. - Track ipv6_link_local_only on DeviceNetInfo to distinguish "offline" from "online but link-local only". - wan::ipv6_get now reports a GUA-preferred assigned_ipv6 (falls back to the first address on a ULA-only WAN) so every consumer sees the reachable scope. - Add unit tests for is_gua, pick_ipv6, and has_global_ipv6 boundaries. Frontend (published-ports dialog): - Replace disabled radio items + auto-correct with an inline <tui-error> and a reactive Save-disable driven by form validity, surfacing the specific reason (no IPv4, IPv6 not enabled, no GUA, or local-only address). - Mirror the backend GUA check in a shared isGua() helper; the route now offers IPv6 only when the WAN has a GUA prefix, not merely IPv6 enabled. - Add i18n strings (514/515) across en/es/de/fr/pl. Updates API_CONTRACT.md to document the new validation and GUA-preferred assigned_ipv6 behavior. * fix(vpn-routing): tie dnat_return ip-rule lifecycle to VPN profiles The global dnat_return / dnat_return6 ip rules (fwmark 0x80 -> main table, keeping inbound port-forward replies off the VPN tunnel) were created on a name-only existence check and never removed. Two consequences on already-provisioned devices: - A stale definition (old priority/mark from a prior release, or a manual edit) survived forever, silently breaking the priority ordering with the source-based VPN policy rules. - The rules lingered after the last VPN-routed profile was switched back to WAN / deleted / disabled, with no consumer. Rewrite both ensure_* helpers as remove-then-append so the rule is always re-emitted from the current constants, and add a cleanup step that drops both rules when no VPN-routed profile remains. The rules now exist iff at least one VPN-routed profile does, mirroring the vpn_<wg> zone lifecycle. Removal is safe: the static nft marking chains keep running but the fwmark->main rule is a no-op without source-based VPN rules. Add tests for stale-rule rewrite and teardown-on-last-VPN-removed. * feat(published-ports): warn when a published port is exposed despite VPN routing A published port is always reached over the public WAN IP, even when the owning device's security profile routes its outbound traffic through a VPN. Users can reasonably assume "behind a VPN" means "not on my real IP", so surface that gap inline in both directions: - Published-port dialog: when creating a new port for a device whose profile has a VPN outbound, show a warning naming the profile and VPN. Only on create (not edit), so we warn on the first save. - Profile dialog: when switching a profile's outbound to VPN, warn if any device in that profile already has published ports, listing up to 3. Wiring: - published-ports/index resolves profile -> VPN-label map (profilesList + vpnClientList + profileGet) and the default LAN-owning profile, passed into the dialog; failures are non-fatal (warning simply not shown). - profiles/index collects published-port labels for the profile's devices (devicesList + publishedPortsList in parallel, each guarded) and passes them to the dialog. - Export `fill()` from i18n/validation-errors so both dialogs can interpolate the translated strings. - Add strings 516 and 523 across en/de/es/fr/pl dictionaries. * feat(published-ports): confirm before breaking or exposing published ports A published port forwards to a device at its current subnet address. Two config changes can silently break or expose such a port, with no warning at the point of action: - Reassigning the device's security profile (moving an Ethernet port to a new VLAN, or reassigning/deleting a WiFi password) moves the device to a different subnet, leaving the port's DNAT rule pointing at an address the device no longer holds. - A port is always reached over the public WAN IP even when its device's profile routes outbound through a VPN — "behind a VPN" does not mean "off my real IP". Gate both behind an explicit confirmation dialog fired at the moment the change is made. Break-on-reassign (ethernet.set / wifi.set): - Add a `confirm_published_port_deletion` flag and a result carrying `pending_published_port_deletions`. Without the flag, set() is a dry run: it detects which ports would break and returns them, applying nothing. With the flag, it deletes those ports' firewall rules and now-stale DHCP reservations atomically with the bridge-VLAN / WiFi update, then reloads network + firewall (+ dnsmasq when reservations changed). - Ethernet attributes affected devices via the bridge FDB (port-precise); WiFi attributes them by reservation subnet of a "vacated" profile (one that lost its last password) intersected with currently-WiFi-connected devices. Ethernet devices on a vacated WiFi profile keep their IP, so they are correctly excluded. - Shared helpers in published_ports: AffectedPublishedPort, affected_ports_for_macs, affected_wifi_ports_for_vacated_profiles, remove_ports_for_macs; export get_bridge_fdb. CLI edit paths confirm implicitly (no dialog). Add unit tests. Expose-despite-VPN: replace the inline form warnings added in f9a1114 (which sat passively in the profile and published-port dialogs) with a blocking confirmPublishedPortExposed dialog fired uniformly wherever exposure is newly created — creating/editing a port, re-enabling a disabled one, or switching a profile's outbound to a VPN. The warning now fires once, at the moment of exposure, instead of perpetually in a form. Drop the dead updatePassword path and the unused publishedPortLabels/vpnProfiles dialog plumbing it replaced. Frontend: two new shared confirm helpers (published-port-deletion, vpn-exposed-port); wifi/ethernet services gain previewWifi/previewPorts dry-run methods; toggleEnabled re-reads the port list after the await to survive the 5s auto-refresh. Swap i18n strings 516/523 for 524-529 across en/de/es/fr/pl. Update API_CONTRACT.md for the new request/result shapes on ethernet.set and wifi.set. * feat(published-ports): keep IPv6 forwards working when your ISP changes prefix When an ISP rotates its delegated IPv6 prefix, every IPv6 published-port forward was left pointing at an address the router no longer owns and silently stopped working. Now: - A wan6 hotplug hook repairs all IPv6 forwards to the new prefix automatically when the prefix changes. - Enabled IPv6 ports pin a stable device address so forwards survive rotations (and don't break if the device is briefly offline). - A stranded forward is shown as "IPv6 address out of date" (Partial or Error) instead of a false-green Active status. Also fixes profile-vacate cleanup to catch IPv6-only ports and to preserve user-named DHCP reservations (clearing only the stale IPv4). * some taiga idioms * Outbound VPN: configurable MTU + WireGuard config validation (#71) * feat(vpn): configurable MTU for outbound WireGuard tunnels Add an optional MTU setting to outbound VPN clients so tunnels on obfuscated or :443 endpoints (whose path can't carry the 1420 kernel default) can be lowered to a working value. Unset inherits the kernel default. Backend: - Parse an uncommented `MTU` from the uploaded .conf into `option mtu`; a commented `#MTU` is ignored. - Validate MTU to 1280-1500 (1280 = IPv6 min link MTU / universal safe floor); accept it on create and update. - `update` writes/clears the `mtu` option (UCI is the single source of truth) and bounces the WG interface only when the value changed. - Expose `mtu` on `OutboundVpn` (list) and add the `WgInterface.mtu` UCI field. - Enable `option mtu_fix 1` (TCP MSS clamp) on the dedicated vpn_<X> egress zone as a backstop against a too-high MTU black-holing large packets. Frontend: - Add an optional MTU number field (1280-1500) to the VPN edit form with a hint to lower it to 1280 when the VPN connects but times out. - Thread `mtu` through the API types, update payload, and mock API. - Register the new UI strings across all locale dictionaries. API_CONTRACT, Rust handlers, api.service.ts and mock-api.service.ts updated together. * feat(vpn): validate WireGuard config, reject OpenVPN/malformed uploads Harden outbound VPN config parsing so non-WireGuard files fail fast with a clear message instead of the misleading "missing PrivateKey" error. - Backend: require [Interface]/[Peer] headers, an interface Address, and a peer Endpoint; detect OpenVPN configs for a specific message. Add tests. - Web: async validator mirrors the check (backend stays authoritative); fix file-input flicker by listening on statusChanges and treating PENDING as not-yet-valid. - Document the rules in API_CONTRACT.md. * Release toolkit + CI S3 publishing + beta.3 bump (#72) * Add local release toolkit + CI S3 publishing Introduce scripts/manage-release.sh, a human-run release gate modeled on start-os's manage-release.sh, and wire CI to publish build artifacts to S3. CI (build-image.yaml): - Build artifact and GitHub Release now carry both images: the sdcard .img (fresh install) and the sysupgrade .img.gz (OTA). - New step uploads images to s3://startwrt-images (DigitalOcean Spaces, nyc3) -- the only publishing done in CI. manage-release.sh: - download/pull/verify/upload/register/index/sign/cosign/notes and a full-release pipeline (download -> register -> index -> sign -> notes). - Registering, indexing, and signing stay a deliberate local gate run with the developer key, sitting between "built + uploaded" and "live in the registry". - sysupgrade .img.gz is indexed into the registry "squashfs" slot via a hardlink so start-cli resolves the asset kind; the indexed URL still points at the honestly-named .img.gz. Add scripts/startwrt-release.key.asc (public half of the release signing key) for out-of-band fingerprint confirmation. * Bump version to 0.1.0-beta.3 * Update scripts/manage-release.sh Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * update asc file to start9 key --------- Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * Fix/board name (#73) * update board name to spacemit,k1-x so the update is visable to devices checking for updates * update default registry to use https * chore: update to Angular 22 (#69) * chore: update to Angular 22 * fix(web): resolve ng-qrcode peer against Angular 22 via npm overrides ng-qrcode@21 (latest) declares @angular/{core,common} ">=21 <22", which blocks a flag-free `npm ci`/`npm install` on Angular 22 — the exact step `make image` runs (web/Makefile line 113). Override its Angular peers to the root versions so install resolves cleanly with a single Angular 22 and no nested duplicate. Remove once ng-qrcode ships an Angular 22 peer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: small fixes --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(start-wrt): wire migrated product onto shared monorepo code Following the subtree import, dissolve start-wrt's standalone backend Cargo workspace into the root monorepo workspace. The backend (startwrt-core/ctrl, uciedit, uciedit_macros) now links the shared start-core crate (aliased as `startos`, zero source churn) plus the vendored rpc-toolkit and imbl-value, replacing the embedded start-os submodule (removed). openwrt becomes the repo's only git submodule. - build.mk (startwrt / startwrt-image / startwrt-openwrt-setup / startwrt-update / test-startwrt / clean+format), included by the root Makefile; build scripts made monorepo-root-relative (binary now lands in the workspace-root target/) - run-tests.sh + test-startwrt mirror start-core's containerized run-tests.sh, package-scoped so a bare cargo test no longer drags in startos-backup-fs/fuser - .github/workflows/start-wrt.yaml (riscv64 binary on PR, OpenWrt image on dispatch) - web kept standalone (its own package.json) this stage; folding it into the root Angular workspace + @start9labs/shared is the follow-up (Stage B) - docs: product/backend/web AGENTS.md (+ one-line CLAUDE.md), CONTRIBUTING, CHANGELOG; registered in root AGENTS.md + ARCHITECTURE.md Validated: cargo check + 451 unit tests green; make startwrt and make startwrt-image build; Tier 0-3 on-device validation passes on K1. * fix(start-wrt): submit outbound VPN dialog + re-embed UI on web-only rebuilds Two unrelated frontend/backend fixes plus doc touch-ups. - Outbound VPN dialog: adding a VPN silently did nothing. save() called tuiMarkControlAsTouchedAndValidate, which re-ran the WireGuard .conf async validator; the in-flight run is cancelled when the file input remounts during the PENDING phase, so the form stayed PENDING and the create request never fired. Now submit completes directly when the form is already valid, and falls back to markAllAsTouched() (no re-validation) to surface errors when it isn't. - ctrl/build.rs: emit cargo:rerun-if-changed for web/dist so web-only changes re-embed into the startwrt binary. The UI is baked in via include_dir!, which doesn't register embedded files as cargo deps, so a changed bundle was ignored unless a .rs file also changed — shipping a stale UI. - Docs: rename the deploy env var REMOTE -> STARTWRT_REMOTE across AGENTS.md, ARCHITECTURE.md, CONTRIBUTING.md, and add CHANGELOG entries for both fixes. * refactor(start-wrt): fold web into root Angular workspace (Stage B1) Move the StartWRT frontend from a standalone Angular app into the root Angular workspace, matching how ui/setup-wizard/start-tunnel/brochure are wired. Mechanical, no behavior change. - angular.json: add `start-wrt` project (application builder, outputPath dist/startwrt so the existing include_dir! embed path is unchanged, .html/.svg text loaders, taiga less styles, port 8300). - Collapse web/tsconfig.json + tsconfig.app.json into one tsconfig extending the root; redeclare paths (*, @taiga-ui/icons/*, @start9labs/shared). Keep noUncheckedIndexedAccess off (re-assert noImplicitReturns/isolatedModules) to preserve the app's original strictness during the fold-in. - package.json: add build:wrt / start:wrt / check:wrt / check:i18n:wrt; add the two check:*:wrt to the `check` aggregate; add the web dir to the format/format:check globs. No dependency changes (marked/ng-qrcode/taiga addons already satisfied by the root). - Delete standalone web scaffolding (package.json, package-lock.json, angular.json, tsconfig.app.json, .husky). build-config.js resolves paths via __dirname so it runs from the repo root. - build.mk: web dist now built via `npm run build:wrt` with the workspace build:deps prerequisites (WEB_SHARED_SRC + .angular/.updated); fold web prettier into format-web. - CI: add shared-libs/ts-modules/**, angular.json, package.json, package-lock.json, tsconfig.json to start-wrt.yaml paths. - Add root .prettierignore for build outputs (dist/.angular/out-tsc). - Docs/changelog: flip the "web is standalone" notes to "in the root workspace" across root + start-wrt docs. Verified: npm run build:wrt, check:wrt, check:i18n:wrt, format:check, and a host `cargo build -p startwrt-core --bin startwrt` (embeds the workspace-built UI) all pass. * refactor(start-wrt): adopt @start9labs/shared utilities (Stage B2) Replace the three hand-mirrored utilities that have clean shared equivalents: - pauseFor → @start9labs/shared (util/misc.util); delete local utils/pauseFor.ts - RELATIVE_URL token → @start9labs/shared (tokens/relative-url); drop the local token from http.service.ts and app.config.ts - MarkdownPipe → @start9labs/shared (pipes/markdown.pipe); delete local pipes/markdown.pipe.ts (marked stays only in the shared pipe) Kept local by design (investigated during B2): - HttpService/RpcService/ConnectionService — start-wrt uses an *aborting* per-request timeout (rxjs `timeout`) that surfaces a code-0 network error and tears down wedged HTTP/2 connections for the reconnect flow; the shared HttpService's race-based timeout does not abort, so swapping would regress the reconnect UX. - Error surfacing — ActionService/FormService route network drops into the global reconnect indicator with per-action copy; there is no ErrorService mirror to replace. - WorkspaceConfig (flat config.json), the WebSocket progress types (start-wrt ABI, not start-core's), and the i18n-routed validation-errors provider — no clean shared equivalent. Mark @start9labs/shared `sideEffects: false` so importing a few symbols from its barrel tree-shakes: without it, start-wrt's first-ever shared import dragged in ~875 kB of unused shared code (server-name-words, ansi-to-html, the monaco logs-window, …), pushing the embedded UI bundle to 1.85 MB (over its 1.5 MB budget). The lib is verified side-effect-free; the flag also shrinks the other apps' bundles. Verified: npm run check (whole workspace), build:wrt (974.93 kB, under budget), build:tunnel, cargo build -p startwrt-core --bin startwrt, and format:check all pass. * fix(start-wrt): keep UI Build field's git hash fresh and mark dirty builds The Settings → General "Build" field showed a stale git hash after the monorepo migration (frozen at the import commit). Two causes: 1. build.mk lost the wiring that refreshed build/env/GIT_HASH.txt every build and made it a prerequisite of web/config.json, so the stamp never re-ran when HEAD moved. Restore it: run check-git-hash.sh at parse time and list GIT_HASH.txt as a prereq of config.json. 2. The UI shortened the hash with slice(0, 12), dropping the trailing "-modified" dirty marker. shortGitHash now preserves any trailing marker, matching the "-dirty" indicator `startwrt verify` already prints. * fix(start-wrt): restore release CI dropped in the monorepo migration The standalone build-image.yaml published releases (S3 upload + GitHub Release) on a v* tag push, but the migration into the monorepo dropped that job — start-wrt.yaml could build the image yet never publish it. Restore publishing as a `deploy` job, following the canonical start-os pattern (startos-iso.yaml): gated on a manual workflow_dispatch with a `deploy: release` input rather than a tag push, since no product in the monorepo releases by tag. It uploads the built images to s3://startwrt-images and cuts a GitHub Release, reading the version from backend/ctrl/Cargo.toml (the web/package.json the standalone workflow read was removed when the UI folded into the root Angular workspace). Registry indexing/signing stays a deliberate local gate in scripts/manage-release.sh, re-pointed here at the new workflow and the `startwrt-openwrt-image` artifact name. Also restore the OpenWrt download-cache keying the migration had narrowed: the image job's cache key again includes build/feeds.conf (so changing the feed set busts the cache) and carries a restore-keys fallback for partial restores. Updates projects/start-wrt/CHANGELOG.md. * fix(start-wrt): guard against colliding profile subnets Changing the Router IP could strand the network on a subnet already owned by another profile. The LAN IPv4 page exposed a "Router IP" (3rd-octet) field that duplicated the Admin Security Profile's subnet field but, unlike it, had no collision guard — so pointing the router at an in-use /24 put two interfaces on the same subnet, producing overlapping routes that silently broke all access to the router (unrecoverable even by a keep-settings reflash). Remove the duplicate field: the LAN page now only selects the /16 network block, and the Admin profile is the single source of truth for the 3rd octet (routerOctet stays in the model, populated from the loaded IP, so a network-block change preserves the subnet and the summary can still show the router IP). Add a backend guard so a direct RPC/CLI call can't bypass the UI: profiles.create/profiles.edit now reject a gateway whose /24 collides with an existing profile (including the admin LAN), via a new SubnetCollision error kind. Edits that keep their own subnet are skipped, so no-op edits — and recovery from an already-broken config — still pass. * docs(start-wrt): add StartWRT user manual to the docs site Fold the StartWRT documentation book (previously in the standalone start-docs repo, branch feat/start-wrt) into the monorepo at projects/start-wrt/docs/, matching the layout of the other product books (start-os, start-tunnel, start-sdk). The 24-page mdBook covers install, setup, security profiles, WiFi/VPN/WAN/LAN, backups, and reference. Wire the book into the shared docs site: - versions.conf: register start-wrt=0.1.0.x (drives build, deploy, nginx) - build.sh: map the start-wrt book to its product dir - serve.sh + landing/index.html: add the StartWRT URL/card - generate-llms-txt.ts: add the StartWRT label/description - docs-deploy.yml: trigger deploy on projects/start-wrt/docs/** - .gitignore: ignore the in-place docs/book/ build output - book.toml: adapt to monorepo conventions (build-dir, git/edit URLs, shared theme; drop docs-agent assets absent from this theme) Correct doc claims that no longer match the shipped UI/backend, found by auditing every page against the Rust backend and Angular UI: remove the nonexistent LAN "Blacklist" access mode; move the LAN "Router IP" to the Admin profile subnet; fix the SLAAC-disable trigger; drop the phantom SSH-key "name" step; correct the DDNS status fields and provider label; soften "restarts the router" to a network reload; note the random Root CA name suffix; scope the "no separate database" claim; and fix the Wi-Fi password-preservation rationale. Also repair the theme symlink for the existing product books (start-os/start-tunnel/start-sdk): the monorepo migration pointed them at the nonexistent projects/docs/theme, breaking the docs build. Retarget all four to ../../start-docs/theme. Update start-docs/AGENTS.md and start-wrt/ARCHITECTURE.md to reflect the new book and docs/'s dual role. * fix(start-wrt): enforce 12-character password minimum on password change The Settings → Password form and its auth.set-password backend endpoint (also reached via `startwrt auth set-password`) accepted passwords shorter than 12 characters, even though first-time setup required it and the docs documented the minimum. A weak password could be set from the Settings tab or the CLI, contradicting set-initial-password and settings.md. Backend: extract a shared validate_password_length() helper (MIN_PASSWORD_LEN = 12) and call it from both reset_password_impl and set_initial_password_impl so the rule can't drift between the two endpoints; add boundary tests. Frontend: add Validators.minLength(12) to the new-password control and the existing 'minlength' i18n error to the settings password form, matching the setup screens (the error string is already in all five dictionaries). * fix(start-wrt): close the lan.ipv4-set gap in the subnet-collision guard The colliding-subnet fix (715f34364) guarded profiles.create/profiles.edit but left lan.ipv4-set unguarded: when only the 3rd octet changed, nothing stopped a direct RPC/CLI call (`startwrt lan ipv4-set`) from moving the LAN onto another profile's /24 — the exact stranded-router bug the commit fixed. The UI path was closed (the Router IP field is gone), but the backend vector the guard was added for remained open. Call guard_subnet_collision from ipv4_set before any config is mutated. On a network-block change the profiles keep their 3rd octet, so the new 3rd octet is tested inside the *current* block (subnet_guard_ip), which is equivalent to the post-move state — a block change that would land the LAN on a profile's relative /24 is rejected too, while no-op re-sets and same-octet block moves still pass. Integration + unit tests cover all four cases; API_CONTRACT.md documents the SubnetCollision error. Also update the /lan/ipv4 in-app help (all five languages), which still described the removed editable "Router IP" field: the router address is now explained as the gateway of the Admin profile's subnet, matching lan.md. * ci: stop cloning the openwrt submodule in jobs that don't need it Adding projects/start-wrt/openwrt gave the repo its first git submodule, and every pre-existing workflow checks out with `submodules: recursive` — a no-op until now, but a full clone of the multi-GB OpenWrt fork ever since. Worst hit is test.yaml, which runs both its jobs on every push/PR. Only start-wrt's image job needs the submodule (start-wrt.yaml already scopes it correctly); switch everything else to `submodules: false`. build.yml/release.yml are the reusable workflows executed in external service repos, so their `recursive` refers to those repos' submodules and stays. * fix(start-wrt): track shared-crate and full-web-dir build inputs in build.mk $(STARTWRT_BIN) only depended on the backend tree, but startwrt-core path-depends on start-core (aliased startos), rpc-toolkit, and imbl-value — so after editing shared crate code, `make startwrt`/`make startwrt-update` saw the binary as up to date and could deploy a stale build. Add those crates (via CORE_SRC, matching tunnelbox) plus Cargo.toml/Cargo.lock as prerequisites, which also re-syncs build.mk with the shared-libs paths the CI workflow already lists (root AGENTS.md "Coupled changes"). Also widen STARTWRT_WEB_SRC from web/src to the whole tracked web/ dir so web/assets and web/tsconfig.json retrigger the UI build, matching how the other apps' source globs work. * fix(start-wrt): namespace releases per product and point tooling/docs at the monorepo Releases now live on Start9Labs/start-technologies, which hosts every product's releases on independent cadences — so bare v* tags would collide across products (StartOS history already owns v0.x). Tag StartWRT releases startwrt/v<version> and name them "StartWRT v<version>" in the deploy job. manage-release.sh still pointed its gh calls at the dead standalone Start9Labs/start-wrt repo (despite the release-CI restore claiming it was re-pointed) — fix REPO and the release-tag references to match the new scheme. The user manual's download link, source-code pointer, and both issue-tracker links likewise moved from the standalone repo to the monorepo. * docs: add start-wrt to the root and shared-libs docs the migration missed The migration updated the root AGENTS.md/ARCHITECTURE.md product lists but missed the rest of the hierarchy: - README.md: StartWRT row in the product table + "rest of the monorepo" entry - AGENTS.md: "all five bins" -> six - CONTRIBUTING.md: startwrt build target, test-startwrt, format-startwrt - Makefile help: startwrt/startwrt-image and test-startwrt lines - start-core crate docs (AGENTS/README/ARCHITECTURE): startwrt as the sixth consumer, noting it is a full backend importing the crate aliased as `startos` rather than a MultiExecutable wrapper - shared-libs/AGENTS.md + ts-modules AGENTS/ARCHITECTURE: start-wrt in the Angular workspace app lists ("four apps" -> five; UI ships embedded in the startwrt binary) * fix(start-wrt): name release assets per the startos convention The release assets were the raw OpenWrt output names (openwrt-spacemit-k1-sbc-bananapi-f3-squashfs-{sdcard.img,sysupgrade.img.gz}) — no product, version, or hash, indistinguishable on a shared monorepo releases page. Rename them on the copy into results/ to follow start-os's basename convention (build/env/basename.sh: <project>-<version>-<githash7>_ <platform>), plus a role suffix since both artifacts share the .img/.img.gz extensions: startwrt-<version>-<githash7>_spacemit-k1-sdcard.img startwrt-<version>-<githash7>_spacemit-k1-sysupgrade.img.gz The basename is composed in build.mk from start-wrt's own stamps (basename.sh reads StartOS's PLATFORM/ENVIRONMENT/GIT_HASH build state and a manifest path that doesn't exist here); the version sed matches the workflow's Determine version step. Keeping the -sdcard.img / -sysupgrade.img.gz endings preserves every downstream match: manage-release.sh's suffix globs and cmd_notes regexes, the .img.gz -> .squashfs hardlink for registry kind inference, and the workflow's extension-based S3/gh-release globs. Only the image job's upload-artifact path glob keyed on the openwrt- prefix and is updated. installing.md now names the real download filename shape (keeping startwrt.img as an explicit placeholder in the checksum commands). * fix(start-wrt): unbreak the two failing CI jobs - build.rs now creates the web dist dir before compilation, so cargo test/check builds (CI's make test, fresh clones) no longer panic in include_dir! when the Angular app was never built. Real builds are unaffected: make startwrt populates the dir first via the $(STARTWRT_WEB_DIST) prerequisite. - The compile job installs binutils-riscv64-linux-gnu so build/verify-isa.sh finds a riscv64 objdump on the runner instead of failing the build after a successful cross-compile. * fix(start-core): tolerate empty scope dirs when bundling node_modules into dist npm 10 leaves empty scope directories (@emnapi, @napi-rs, @tybys) behind when it skips optional wasm-fallback packages, and cpy fails on them ("Cannot copy ...: the file doesn't exist"), breaking make dist and everything downstream (make test, make startwrt). Copy node_modules with cp -a instead; cpy still handles the lib globs. --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | 2 个月前 | |
chore(fmt): normalize formatting — one config per language, reproducible rustfmt (#3437) * chore(fmt): one config per language + reproducible rustfmt Our rustfmt.toml uses nightly-only options (group_imports, imports_granularity) but nothing pinned the nightly, so output drifted between contributors — and CI never even checked Rust formatting. Configs were also duplicated and partial. Consolidate to one config per language and make the output reproducible: - rustfmt: single root rustfmt.toml (was 5 duplicate copies + ~14 crates with none). Runs in start9/fmt-env — start9/cargo-zigbuild (the same image the Rust build uses) plus the pinned nightly + rustfmt component (build/fmt/fmtenv.Dockerfile) — via build/fmt/run-fmt.sh, which runs it --user so output stays host-owned. FMT_NATIVE=1 runs on the host against the same pinned toolchain (read from the Dockerfile ARG — one source of truth for the version). - prettier: single root .prettierrc.json (was 3-4 drifted copies, incl. container-runtime on double quotes). One repo-wide pass with a hardened .prettierignore (excludes .sqlx cache, the generated exver.ts parser, conformance vectors, locales, snapshots/fixtures, proxy.pac, patch-db/client). - taplo: single root taplo.toml over all TOML (was one stray copy), pinned via @taplo/cli. Only rustfmt needs the container; prettier and taplo run natively. - CI `make format-check` now covers rustfmt (previously unchecked) + taplo. - Remove the dead husky v4 / lint-staged pre-commit hook (no lint-staged config existed). Per-project `<project>-format` targets are kept (routed through the same tools/config). The formatting content itself lands in the next commit. * style: apply repo-wide formatting Mechanical output of `make format` using the configs from the previous commit (rustfmt in the pinned-nightly container, prettier and taplo). No behavior changes. * chore: ignore the repo-wide reformat in git blame | 2 个月前 | |
fix(start-wrt): hairpin published ports for every profile with Internet or LAN access (#3888) * fix(start-wrt): scope published-port NAT reflection to permitted zones Published-port redirects never set `reflection_zone`, so fw4 defaulted it to the redirect's `dest` zone and emitted hairpin rules matching only the target device's own subnet. A client on a different Security Profile — one already permitted to forward into the target's zone — hit the router's INPUT chain instead of the DNAT when it used the router's WAN address. Set `reflection_zone` to the target's zone plus every zone a `config forwarding` section already permits to forward into it. Not every zone: each reflection zone becomes the reflection redirect's `src`, which sets fw4's `dflags.dnat` and emits a blanket `ct status dnat accept` into that zone's forward chain ahead of the zone policy, so a reflected flow cannot be rejected afterwards. Also set `reflection '0'` on any port carrying a source restriction. fw4 builds the reflection redirect fresh and copies neither `src_ip` nor `src_mac` (`fw4.uc` ~2898), taking its saddr from the reflection zone's subnets — so a source-restricted forward was reachable by hairpin from any client in the reflection zone, including the same-subnet case that already worked before this change. * refactor(start-wrt): type FirewallRedirect.reflection as Option<bool> `FirewallZone.masq`/`masq6`/`mtu_fix` are `Option<bool>` and serialize as '1'/'0'; `reflection` is the same kind of fw4 boolean in the same struct family, so give it the same type instead of the older `Option<String>` idiom. The bytes written are unchanged. * fix(start-wrt): emit reflection zones only for existing, non-masq zones `reflection_zones` copied `config forwarding` src names verbatim, excluding only the literal "wan". fw4 parses each `reflection_zone` entry as a zone reference: one name that is not a parsed zone fails the whole option and drops the entire redirect — WAN-side DNAT included — and a `src '*'` forwarding (legal fw4) would crash ruleset rendering outright when fw4 dereferences the wildcard's subnets. Stock images never write such forwardings, but an adopted config can carry them, and a zone deleted after the list was written turns into exactly that invalid name. Filter the list against the `config zone` sections actually present, and exclude WAN-like zones by property (`masq` set) rather than by the name "wan"; the redirect's own src zone stays excluded as before. Also write down why cross-zone hairpin is DNAT-only: the reflection SNAT lands in the emitted zone's srcnat chain, which a hairpinned flow never enters, so the server sees the client's real address and replies via the router — intentional, not a missing rule. * fix(start-wrt): resync reflection zones on profile changes and at boot `reflection_zone` was written only by `published-ports set`, but it is derived from the `config forwarding` set, which changes underneath it: - Deleting a profile removed its zone but left the name in every list carrying it, and fw4 treats one unknown name as an invalid option — dropping the whole redirect, WAN-side DNAT included. Deleting a guest profile could silently kill an admin-LAN published port from the Internet until some unrelated re-save rewrote the pp_ sections. - Revoking a profile's Access left it in the lists, so its clients kept reaching the port via the WAN address — the exact reflect-then-reject hole the scoped list exists to prevent, with no rule able to stop it. - A profile granted Access later (or created with it) was missing from the lists, so its clients kept hitting the original bug. Add `sync_reflection_zones`, one pass re-deriving the list on every redirect tagged `_pp_id` or `_apf_label` (skipping `reflection '0'`), and run it wherever the inputs or the redirects change: at the end of `rewrite_firewall` and `delete_config` (both already restart the firewall afterwards), in `published-ports set` (replacing the inline computation), in `apply_forward` — automatic UPnP/PCP forwards now get the same scoped hairpin instead of fw4's dest-zone default, closing the canonical StartOS case of a phone on another profile reaching a UPnP-opened port by the server's public hostname — and once at boot, so routers already carrying stale lists heal (reloading the firewall only when the pass changed something). * fix(start-wrt): resolve a published device's zone by address, not guess `dest_zone` came from the neighbor table alone and fell back to "lan" whenever the device had no entry — and a device that is offline (or whose entry aged out) still passes validation, because its IPv4 resolves from the static reservation `set` itself creates. Since every save rewrites all rules, toggling any port while one target was offline silently rewrote that target's rule with the guessed zone; with scoped reflection the wrong guess also computes the wrong permitted set, e.g. handing an IoT profile with Access to the admin LAN a hairpin route to a server that actually lives in a guest zone. Resolve the zone from the device's IPv4 against the interface subnets of the non-masquerading firewall zones (every profile is a /24 at its gateway; the static reservation covers an offline device, and configs-only mode too), keeping the neighbor-table result as a logged cross-check only. A device that still cannot be placed keeps `dest 'lan'` — the WAN-side DNAT stays as it was — but gets `reflection '0'`: never hairpin into a guessed zone, and never refuse the save over one offline device. `set` now parses "network" alongside "firewall"/"dhcp"; the file is round-tripped unchanged. * docs(start-wrt): true up the hairpin section's wording and scope - "every other profile that profile permits to reach it" read as though the target's profile grants the access; the Access setting lives on the *other* profile (see security-profiles.md), so say so. - "answered by the router itself" becomes "the router answers instead of the device" — plainer. - Reflection matches only addresses actually on the WAN interface: behind CGNAT or another upstream router the public IP a domain resolves to never hairpins, so one clause now says so before a support thread has to. - Automatic (UPnP/PCP) forwards are scoped identically now, so the section says they are covered. * fix(start-wrt): fall back to the neighbor table for a device's zone Resolving the zone by address alone dropped the neighbor-table result that the previous code relied on, so a device with no IPv4 at all — an IPv6-only target, which has neither a lease nor a reservation to place it — fell to the "lan" guess even while the neighbor table knew its zone. The IPv6 forward rule's `dest` then named the wrong zone and fw4 bound it to the wrong egress interfaces. The same applied to an IPv4 outside every profile subnet. Pull the resolution into `device_zone`: the address decides when it resolves (an offline device with a reservation still places, and a save while one target is offline no longer rewrites its rule around a guess), and the neighbor table places what the address cannot. Only a device neither can place keeps the "lan" guess, and only that one is never hairpinned. Tests run non-effectful, so the helper is what makes the fallback testable. Claude-Session: https://claude.ai/code/session_01Mx4wjVx7EC29aBsQmSt5xv * fix(start-wrt): clear reflection zones on reflection-off redirects `sync_reflection_zones` left `reflection '0'` redirects untouched on the grounds that they carry no list. fw4 validates every `reflection_zone` name before it reads `reflection` or `enabled`, so a stale name on such a redirect still drops the whole section, WAN-side DNAT included. Our writer never emits that combination, but a hand-edited config can, and the boot heal exists precisely for configs we did not write. Empty the list on those redirects instead of skipping them; a redirect that is already clean still counts as unchanged. Claude-Session: https://claude.ai/code/session_01Mx4wjVx7EC29aBsQmSt5xv * fix(start-wrt): hairpin published ports for every profile with Internet or LAN access A published port is a public resource, so honoring profile isolation on the router's public address only blocked the one path the operator had already opened to the world (review on #3888). The hairpin now serves every zone that qualifies on either ground: Internet access — a forwarding into a masquerading zone with no unqualified REJECT toward it, which excludes WAN Access None, Whitelist's catch-all, and an active blackout window — or Access to the target's profile, which keeps the original fix for a permitted profile that has no Internet. Listing a zone in `reflection_zone` is not enough on its own: fw4 sets the `dnat` flag that emits `ct status dnat accept` only on the redirect's src and dest zones (fw4.uc:2748), never on the reflection zones, so a reflected flow from an isolated zone reached that zone's forward chain and was rejected by policy. The previous comment on `reflection_zones` claimed otherwise; the bench never exercised it because every zone benched had a forwarding. The daemon now installs one chain-pre include for fw4's top-level `forward` chain — `ct status dnat accept` — at boot, so binary-updated routers converge without a reflash. It matches only flows a DNAT rule rewrote, and the only DNAT into the LAN is a published port or an automatic forward. IPv6 has no DNAT, so each unrestricted `pp_*_v6` WAN rule is copied once per hairpin zone with only `src` changed, tagged `_pp_hairpin`, and rebuilt wholesale on every sync rather than tracked incrementally. `list` ignores the copies (src != wan); `reconcile` retargets them and `remove_ports_for_macs` drops them with the rule through `is_pp_v6_rule`. The WAN-schedule crontab rewrites the firewall behind the daemon's back, so both cron edges now run `startwrt-cli published-ports sync-hairpin` between `uci commit` and the reload; `evaluate_and_apply_schedules` syncs in-process. Claude-Session: https://claude.ai/code/session_01Ctt2FvqXEcPCJCtmGayHee * fix(start-wrt): read WAN Whitelist and Blacklist entries against the published address The Internet ground of the hairpin looked only at the shape of a profile's rules toward its egress zone: an unqualified REJECT cancelled the forwarding, anything else left it standing. That read a Whitelist as never having Internet access and a Blacklist as always having it, whatever their entries named — so a Whitelist holding the router's public address was denied the hairpin (closed against the operator's intent) and a Blacklist blocking that address was granted it (open against it). `hairpin_zones` now walks the zone's rules toward the egress zone in config order, as fw4 does ahead of the forwarding, and takes the first rule whose destination covers one of the port's public addresses: ACCEPT grants, REJECT or DROP cancels, no covering rule leaves the forwarding to decide. A rule narrowed by anything but a destination address, or with a destination that does not parse, covers nothing — the same strict reading unknown shapes had before. The blackout and catch-all cases fall out of the same walk, so `is_unqualified_reject` is gone. An IPv4 redirect is public at the router's WAN IPv4 addresses, which are not in the config: `sync_hairpin` takes them as an argument, callers obtain them through the new `CtrlContext::wan_ipv4_addrs` (ubus, empty when not effectful or unknown), and the two context-less daemon paths call `system::wan_ipv4_addrs` directly. An unknown address degrades to the previous behavior. An IPv6 rule is public at its own `dest_ip`, so its copies need no live input. Because the lists now depend on the WAN address, the `wan` hotplug hook runs `sync-hairpin` and a reload on ifup/ifupdate; the boot heal already covers restarts. Claude-Session: https://claude.ai/code/session_01EqVkyd1zVQc54m4S6HrHJV | 12 天前 | |
| 26 天前 | ||
fix(start-wrt): write the eMMC partition table instead of trusting the card's (#3930) The flash raw-copied sector 0 through the end of the squashfs from the microSD card to the eMMC and then expected the copied primary GPT header to parse. That holds only while the card still carries the image's own header, which describes a ~580 MB disk. Any partition tool that "fixes" the card (parted/gparted's Fix prompt, sgdisk -e, a write from fdisk) rewrites the header's last usable LBA to the card's real size; copied onto a 16 GB eMMC from a larger card, that header is rejected by libfdisk, the kernel and U-Boot alike, and all three fall back to whatever backup GPT the eMMC already held at its last sector. On a factory board that table has a rootfs and no rootfs_data, so the flash died with `rootfs_data partition not found on eMMC` and the board no longer booted from eMMC (`GPT: last_usable_lba incorrect: 76F4FDE > 1d1f000`). The hardware supplier hit this flashing v1.1.0 on 2026-09-11. After the copy, dump the card's table with sfdisk, drop the two lines that describe the card (`device`, `last-lba`) and the overlay's size, and write the result to the eMMC with `sfdisk --force`. sfdisk derives the last usable sector from the eMMC, writes both the primary and the backup copy, and extends rootfs_data to the free space, which the existing expansion step then pins to the last usable sector as before. Dropping the size covers a card whose rootfs_data was also grown to fill it, which sfdisk would otherwise refuse on the smaller eMMC. Bench-verified on a BPI-F3 with a 32 GB card: the relocated-header case reproduced the supplier's failure on 1.1.0 and flashed clean on this build, as did a card with rootfs_data grown to 29.4 GB; the eMMC booted after each. A pristine card is unaffected. Claude-Session: https://claude.ai/code/session_01C779xbxXwoX1AGUKchrcMz | 4 天前 | |
refactor: migrate StartWRT into the monorepo (projects/start-wrt) (#3385) * complete lan sections, improve wan sections based on lan innovations * outbound vpns * devices in decent shape * proxy fe requests for dev testing * chore: update to Taiga 5 candidate * auth module * update ipv4 for slash 16 * exec api * Revert "proxy fe requests for dev testing" This reverts commit 99014b11bc465c21cd5cf620690cfc0d94a3e9c3. * set SESSION_EXPIRY_DAYS to 1 * Only read STARTWRT_SESSION_PATH once * chore: refactor existing pages * feat: add forwarding route * use temp file for saving sessions * replace fs with tokio * make exec_command async * Set file permissions on create * setting tab progress * reset_pass and make the rest of auth.rs async * fix sibling call * atomically write to shadow file instead * set temp cookie file permissions to 600 * impl Deref for CliContext * finish up port forwarding * refactor password verification * Use sha512 for hashing algo * published ports instead of forwards and firewall * wrap up published ports with protections and mocks * feat: add ethernet route * wrap up ethernet plus other cleanup * refactor: cleanup published ports * feat: add ModalHelp component * refactor: cleaning things up * feat: add inbound route * finish inbound and add wifi * add wifi blackout and labels for inbound vpns * wifi, security profiles, and other refactors * draft proposal for init tool and reflash flow * Update init/reflash proposal with architectural changes - Separate WiFi and admin passwords (WiFi from factory sticker, admin user-chosen on first access) - eMMC stores WiFi PMK only (no admin credentials) - Mount path /mnt/persistent → /persistent - Smart serial dispatcher (microSD → login, no PMK → init, normal → login) - Captive portal for reflash flow (StartWRT-Setup AP with default password) - Admin password set in captive portal wizard during reflash - DNS hijacking on normal boot when admin password is unset - Package manager corrected to opkg (noted as open question) - Design notes: PMK rationale, identity PSK compatibility, WiFi key never in UI * refactor new UI * Revise init/reflash proposal with design decisions - Clarify captive portal vs DNS hijacking terminology - Remove temp AP (StartWRT-Setup) in favor of real StartWRT SSID for all flows - Add WiFi PMK precedence (baked-in first, then eMMC) for custom image support - Document BPI-F3 storage architecture (SPI NOR + eMMC partitions) - Add U-Boot manufacturing flash flow for initial firmware provisioning - Use sysupgrade conffiles for Update path overlay preservation - Require minimum 12-character admin password with confirmation - Resolve package management: StartWRT packages in firmware, user packages wiped - Fix boot detection diagram to show sequential checks not exclusive branches * Streamline manufacturing to single microSD boot session Boot from microSD directly (U-Boot tries SD first on BPI-F3) instead of raw-copying firmware at the bootloader level. Flash + WiFi init now happen in one session via serial, using the same image as end-user reflash. * chore: comments * asides for all dialogs * revert backend chnage * whitelist, blacklist, and outbound * re-arrange, readme, claudemd, and api contract * feat: blackout timeline UI * feat: edit, add and remove * feat: implement help * fill in todos and fix small bug * Build/openwrt image pipeline (#24) * Add Makefile-driven OpenWrt image build pipeline Introduce a build system that produces a complete SD card image with OpenWrt + StartWRT components (Rust binaries, web UI, UCI configs). Adds openwrt as a git submodule pinned to the bianbu branch, and reduces image size from 4GB to 512MB. * Makefile: - Fix Rust binary path (ctrl/target/ → target/) to match workspace layout - Copy final image to out/ directory - Add npm install before web build - Remove web UI from staging deps (skipped until Taiga UI fixed) - make clean now fully removes openwrt build artifacts (build_dir/, staging_dir/, tmp/, bin/) instead of relying on openwrt's make clean which left stale stamps build/feeds.conf: - Update LuCI from openwrt-23.05 to openwrt-24.10 build/openwrt-setup.sh: - Add kernel tarball pre-seed check with clear error message - Document that archive.spacemit.com's CDN is broken build/build-rust.sh, build/stage-files.sh: - Fix Rust target dir paths (ctrl/target/ → target/) - Skip web UI staging build/openwrt.diffconfig: - Remove dead LOCALMIRROR setting for archive.spacemit.com * make incremental builds more robust * mute feed errors for build in packages * add FE to build pipeline * Fix boot hang and harden build resource management Stage web UI to /www instead of /var/www — OpenWrt's /var is a symlink to /tmp, and creating a real /var directory overwrote it, breaking ubus/procd and hanging the system at boot. Also: - Add cgroup memory fence and JOBS budget to prevent OOM during builds - Disable CONFIG_KERNEL_WERROR to fix kernel build failures - Remove stale kernel tarball pre-seed check from openwrt-setup.sh * Update openwrt submodule: revert 2.2.9 kernel, fix libxml2 Point submodule to fix/replace-dead-spacemit-sources which reverts the 2.2.9 kernel switch (generic patches conflict with the vendor kernel) and fixes libxml2 host build failure on incremental builds. * Fix build resource management to prevent OOM crashes Replace fragile systemd-run cgroup wrapping with simpler, portable approach: derive JOBS from MemAvailable (not MemTotal), set oom_score_adj=500 so the build is the preferred OOM-kill target instead of the user session, and keep nice/ionice + load-average back-pressure. Also remove explicit kmod-sound-core from diffconfig since it is pulled in automatically via the ffmpeg → alsa-lib dependency chain. * Harden build resource limits to prevent session crashes - Increase per-job memory budget from 2 GB to 3 GB to cover peak linker phases (kernel, samba4, ffmpeg can each peak at 3-4 GB) - Cap JOBS at nproc/2 instead of full nproc, reserving cores for the host desktop/session - Restore cgroup memory fence via systemd-run MemoryMax so the build is killed before the host session starves (falls back gracefully with a warning when systemd-run is unavailable) * update submodule to the latest * factory init, flash, activate wifi, and captive portal * auth fixes, working captive portal, and more * remove unused stty and replace respawn with respawnlate * Fix captive portal: skip login on captive portal and redirect to completion page * Add setup wizard with auth, flash orchestration, and setup tests Implement the setup wizard flow end-to-end: setup mode detection, PMK resolution from SD/eMMC, conffiles backup/restore across flash, admin password hashing, and streaming flash progress via SSE. Add auth middleware, bake-password tool, and the Angular setup wizard frontend. Fix SetupEvent serialization to use camelCase field names (rename_all_fields) matching the frontend contract, and add 26 unit tests covering serialization, conffiles, backup/restore, password writing, and flash error handling. * Harden post-flash durability of persistent partition writes fsync the parent directory after PMK file rename in emmc.rs to ensure the directory entry survives a crash within the journal commit window. Propagate the persistent partition device path through FlashResult so setup.rs can remount /persistent if the hotplug handler races and unmounts it after partx -u. Unmount /persistent after writing the PMK to guarantee all data is flushed before signaling completion to the client. * Switch rootfs from ext4 to squashfs+overlay and add factory-reset API Replace the ext4 rootfs with a squashfs rootfs + ext4 rootfs_data overlay, matching standard OpenWrt architecture. This enables factory reset via (which wipes the overlay) and reduces flash copy size by reading squashfs bytes_used from the superblock instead of copying the full partition. - flash: read squashfs superblock to determine copy end offset, expand rootfs_data instead of rootfs, format overlay with mkfs.ext4, and mark it FS_STATE_READY so mount_root preserves it on first boot - setup: mount three-layer overlayfs (squashfs + ext4 upper + merged) for config backup/restore and post-flash customization - startwrt-bake-password: write PMK after squashfs data in the rootfs partition (4096-aligned) instead of to the persistent partition - system: add RPC endpoint (firstboot -y + reboot) - web: wire Factory Reset button with confirmation dialog - auth: skip session auth for loopback connections so startwrt-cli works over SSH without a token - firstboot_config/wireless: change radio1 from 6g to 5g - .gitignore: add __pycache__/ * Add atomic SSH deploy targets (update, update-rust, update-web) Rapid development workflow: tar + SSH pipeline deploys Rust binaries and/or web UI to the target device with atomic rename and automatic rollback on failure. Transfer progress via pv, GNU dd, or BSD dd. * update rust build location to ignore * Fix build config after rebase: squashfs rootfs, uhttpd port conflict, path fixes - Enable squashfs in diffconfig and reduce rootfs partition to 256 MB - Move uhttpd to port 8080/8443 so startwrt-ctrld can bind port 80 (change was in config_experiments/ but never applied to firstboot_config/) - Disable kmod-rtl8852bs per spacemit MT7915 setup docs - Fix build script paths for backend/ reorganization (Makefile, build-rust.sh, stage-files.sh) - Update call_remote signature for rpc_toolkit OrdMap API change - Track backend/Cargo.lock for reproducible builds * Rename persistent partition to key_backup Match the submodule's partition table rename across all backend code, build scripts, and firstboot UCI config. * Fix overlayfs umount failure during setup flash Sync before unmounting and fall back to lazy unmount for the underlying squashfs/ext4 layers. After the overlayfs is unmounted, the kernel may still hold cached dentry/inode references to the lower mounts, causing normal umount to fail with EBUSY. * update PROPOSAL-init-reflash for partition name change from persistent -> key_backup * Merge CLI + daemon into single startwrt binary Replace separate startwrt-cli and startwrt-ctrld binaries with a single startwrt binary using a MultiExecutable dispatcher (startbox pattern). The dispatcher routes by argv[0] (symlink name) then argv[1], with CLI as the default. Symlinks preserve backward compatibility for procd init script and serial dispatcher. Also embeds the web UI into the binary via include_dir (replacing tower-http ServeDir), eliminating the separate /www/startwrt staging. * Add GitHub Actions CI to build OpenWrt image on PRs BuildJet 32vCPU runner by default for fast builds (~30 min vs 2-3 hrs on standard runners). Falls back to ubuntu-latest via manual dispatch. Includes reusable setup-build composite action (disk cleanup, Node.js, OpenWrt build deps, sccache config) and caching for openwrt/dl/. Requires OPENWRT_DEPLOY_KEY repo secret for openwrt submodule access. * latest partitions changes from openwrt submodule * fix image build race condition and clean stale .config - openwrt submodule: restrict debX device to squashfs-only builds (FILESYSTEMS := squashfs). The packaging scripts write to hardcoded filenames in a shared temp dir, causing collisions when ext4 and squashfs variants build in parallel. - Makefile: delete openwrt/.config on rm -rf out rm -rf backend/target rm -rf web/dist web/.angular rm -rf openwrt/files rm -rf openwrt/build_dir openwrt/staging_dir openwrt/tmp openwrt/bin rm -f openwrt/.config so it is always regenerated from the diffconfig source of truth. * Fix service reloads, session races, and WiFi password labels Invert effectful() so ServerContext returns true (enabling service Previously the server never ran wifi reload, network reload, etc. Replace file-based session storage with an in-memory RwLock to eliminate write races from concurrent requests that caused random logouts. Disk persistence now only happens on login/logout. Add label field to WifiStation UCI type and persist user-assigned password labels through get/set round-trips. Frontend WiFi password dialog now fetches real profiles from the API instead of using hardcoded values. * Implement profile delete, admin bootstrap, and VLAN lifecycle fixes Complete the profile CRUD surface: removes a profile's UCI entries across all five configs (startwrt, network, firewall, dhcp, wireless) with conflict-retry and LAN-owner protection. A new registers the existing LAN infrastructure as the Admin profile on first boot and daemon startup (idempotent). VLAN lifecycle is tightened: creates a VLAN 1 entry for all bridge ports when the first bridge-vlan appears, preventing traffic loss. wifi-vlan sections are now owned by profile create/delete rather than wifi.set, avoiding accidental removal. wifi.set gains a fallback that creates missing wifi-vlans for legacy profiles and uses smart reload (full restart only when VLAN topology changes). Ethernet port assignment now defaults unassigned ports to the admin profile when VLAN filtering is active and skips the WAN port. The uciedit macro skips leading comment/empty lines so appended sections are readable without a dump+parse round-trip, and non-existent config files no longer produce bogus conflict timestamps. * Fix devices table: filter WAN neighbors, map profiles, and handle * hostname Filter parseArpOutput() to br-lan* interfaces only, excluding upstream router neighbors discovered via IPv6 NDP on the WAN port. Map each device's VLAN tag to its profile name via profilesList() instead of hardcoding 'Default'. Treat dnsmasq's '*' placeholder hostname as empty so unnamed devices fall through to the generated device-XXXXXX name. * Implement DNS override for security profiles Add per-profile dns_override field that creates firewall DNAT redirect rules to intercept all port 53 traffic and forward it to specified DNS servers. This enforces DNS even for devices that hardcode their own resolvers. Also fixes: Makefile PV fallback for make update, and crate path for bootstrap_admin_profile in daemon.rs. * Fix profiles.get returning Whitelist instead of All for LAN access When a profile had forwarding rules to every other existing profile, profiles.get still returned LanAccess::OtherProfiles (Whitelist) unless access_to_new_profiles was also true. Remove that extra guard so the check only looks at whether the profile forwards to all other profiles. * Implement system preferences and remote access firewall rules Add smart endpoints for system.info, set-preferences, newer-versions, and apply-remote-access. Remote access mode (default/never/always) dynamically manages WAN firewall rules based on IP type — private IPv4 or ULA IPv6 gets access rules, public IPs do not. A hotplug script re-evaluates on WAN changes. Frontend: add remoteAccess to SystemInfoRes, fix mock language value, and refactor theme selector to store API values directly with stringify for display. * serve start-wrt at router.lan * Implement Launch LuCi button * Implement system logs: RPC endpoint, authenticated WebSocket streaming, and live log viewer - Add logs.rs with logread parser, system.logs RPC endpoint, and /api/logs WebSocket - Gate WebSocket upgrade with session cookie validation (returns 401 if unauthenticated) - Extract extract_session_token() from SessionAuth middleware for reuse - Build logs page with initial RPC load, live WebSocket streaming, auto-scroll, reconnect, and download - Document both endpoints in API_CONTRACT.md * Fix eMMC overlayfs umount failure blocking "Keep settings" reflash completion During "Keep settings" reflash, the second umount_emmc_overlayfs() call could fail with EINVAL because the squashfs was already detached by the kernel after the first mount/unmount cycle left stale superblock state. This fatal error prevented the WiFi PMK from being written to key_backup and SetupEvent::Complete from reaching the frontend, even though the flash itself had succeeded. Three fixes: - Drop kernel dentry/inode caches after unmounting overlayfs merged mount so the squashfs lowerdir can be cleanly unmounted - Treat "not mounted" as success: if both umount and umount -l fail, check /proc/mounts before reporting an error - Make the post-configuration umount non-fatal since no subsequent operations depend on those mounts and reboot cleans them up * Make LockedConfig::dump() atomic via write-to-tmp + rename * Implement devices smart endpoints with real-time speed monitoring Replace frontend UCI manipulation with six backend RPC endpoints (devices.list, devices.update, devices.block, devices.unblock, devices.forget, devices.data-usage). Speed is computed from conntrack byte counters, data usage from nlbwmon, and WiFi detection from hostapd. ARP online check includes DELAY/PROBE states to prevent transient speed drops during neighbor revalidation. * Replace raw getUci call in profiles page with computed from existing data Derive LAN subnet base from the owns_lan profile's gateway_ip instead of making a separate getUci call to read the network UCI config. * guard on theme being undefined * use ubuntu-latest for CI * remove python3-distutils as it is included in python3 * remove config.json from gitignore * add dtc * Fix profile creation error when dns_override is omitted The frontend sends dns_override as optional, omitting it when empty. Without #[serde(default)], serde requires the field to be present, causing a "missing field `dns_override`" deserialization error. * Switch to buildjet runner * Revert to GH runner and remove unused package behind a brokend CDN * feat: implement transition for dynamic form fields * Add IPv6 smart endpoints (LAN/WAN) and enable IPv6 infrastructure Migrate LAN IPv6 config from direct UCI manipulation in the frontend to purpose-built lan.ipv6-get/set and wan.ipv6-get/set RPC endpoints. Profiles now propagate global IPv6 state (RA/DHCPv6/ip6assign) when created or updated. Backend: - Add lan.rs and wan.rs with ipv6-get/set handlers - Extend NetworkInterface and Dhcp UCI types with IPv6 fields - Add IPv6 multicast ping in devices.list for NDP neighbor discovery - Bind daemon to [::] for dual-stack access - Fix wan6 device to @wan alias in firstboot config - Bootstrap default preferences in admin profile setup Frontend: - Replace LanIpv6UciService with smart endpoint calls - Add lanIpv6Get/Set to API contract, live, and mock services - Fix missing await on firstValueFrom in http.service Build: - Enable odhcp6c, odhcpd-ipv6only, kmod-nf-reject6 - Add odhcpd and RA/DHCPv6 defaults to firstboot dhcp config * Suppress expected network errors during service restarts Operations like toggling IPv6 restart network services, briefly dropping connectivity. The in-flight RPC request and FormService polling both fail with status-0 network errors, producing spurious error toasts. Add NetworkRestartService with a time-bounded suppression window. When ActionService.run() is called with restart: true, network errors are treated as success and polling errors are silently skipped until the window expires. Non-network errors (RPC validation, 4xx/5xx) still propagate normally. Also fix a pre-existing bug: move catchError inside switchMap in FormService so a polling error no longer permanently kills the observable chain. * Add LAN IPv4 smart endpoints and migrate frontend from UCI Replace raw UCI get/set with purpose-built lan.ipv4-get and lan.ipv4-set RPC endpoints. When the network block (first two octets) changes, all profile interface IPs and routing rules are automatically updated, and services are restarted in the correct order (network → wifi → dnsmasq). The frontend now uses the smart endpoints, fixes the gateway IP format to X.Y.Z.1 (3rd octet editable, 4th always .1), and handles admin IP changes by redirecting the browser to the new address. Default LAN IP changed from 192.168.1.1 to 192.168.0.1 to match the frontend and API contract. * set validator min routerOctet to zero * Fix WiFi clients losing internet after network block change Replace `network restart` with per-interface ifdown/ifup to avoid disrupting WAN. Run the restart sequence in a background thread so the HTTP response returns before network disruption. Add a WiFi bounce at the end so clients disassociate and get fresh DHCP leases on the new subnet instead of holding stale ones. * Fix devices showing Online+Ethernet after WiFi disconnect Linux keeps neighbor table entries in STALE state indefinitely on small networks (GC only runs when table exceeds gc_thresh1=128 entries). When a WiFi client disconnects, hostapd drops it immediately but the STALE ARP entry persists, causing the device to appear Online with an Ethernet connection (the fallback when not in hostapd). Fix: ping STALE non-WiFi entries concurrently to determine reachability via exit code. Devices that don't respond are marked Offline. WiFi clients skip probing entirely since hostapd is authoritative. Also improve Devices page load time: - Parallelize IPv6 multicast pings (spawn all, wait all — 1s flat instead of N*1s sequential) - Run UCI config parsing concurrently with initial data gathering - Structure handler into phased pipeline where probing overlaps with nlbw/conntrack/lease reads, adding zero net latency * Add WAN smart endpoints and migrate frontend from UCI Backend: add RPC endpoints for WAN IPv4, IPv6, DNS, DDNS, and MAC settings (get/set). Add DdnsService and NetworkDevice types to uciedit. Frontend: replace direct UCI reads/writes with new API calls and delete all WAN uci/ service and mock files. * Add 'Copied' toast to summary component * Fix CLI auth bypass failing for IPv4 connections to IPv6-bound server The server binds to [::]:80 (IPv6), so IPv4 CLI connections from 127.0.0.1 appear as ::ffff:127.0.0.1. Rust's is_loopback() returns false for IPv4-mapped IPv6 addresses, causing all CLI commands to require authentication. Canonicalize the address before checking. * Fix devices resurrecting as Online+Ethernet via lingering IPv6 NDP The previous fix (6e3b493) probed only STALE IPv4 ARP entries, but `ip neigh show` includes IPv6 NDP entries too. After a WiFi device disconnected and its IPv4 entry expired, a lingering STALE IPv6 NDP entry would bypass probing entirely and resurrect the device as Online+Ethernet. Two fixes: probe REACHABLE IPv4 entries for non-WiFi MACs (catches the window before ARP ages to STALE), and treat MACs with only IPv6 neighbor entries as unreachable (prevents NDP ghosts). * Add published ports smart endpoints and migrate frontend from UCI Backend: - New published_ports module with list/set RPC endpoints - List enriches ports with device status (name, IPs, online state) - Set validates inputs, writes firewall redirect/rule sections with retry loop for UCI conflicts, and fire-and-forget firewall restart - Add FirewallRedirect, FirewallRule, DhcpHost typed sections to uciedit - Add PublishedPortNotFound error variant Frontend: - Replace direct UCI reads/writes with publishedPortsList/publishedPortsSet - Delete PublishedPortsUciService and uci/ directory entirely - Update wan/ipv6, lan/ipv6, and device detail to query ApiService directly - Remove manual firewall section parsing and exec-based restarts - Update dialog to work with API types directly * Fix IPv6 port-protection bugs from rebase Fix WAN IPv6 'disabled' mode handler never matching due to 'ddisabled' typo (introduced in cca6b7b). Make LAN IPv6 SLAAC lock hint conditional so it only appears when published ports actually use IPv6. * Add duplicate profile name validation on create and rename * Fix form error alerts displaying [object Object] instead of message text * Add kmod-br-netfilter to install sysctl disabling bridge filtering CONFIG_BRIDGE_NETFILTER is compiled as a kernel built-in, so bridge netfilter is always active. But the sysctl that sets bridge-nf-call-iptables=0 is only installed by the kmod-br-netfilter package. Without the package selected, the kernel default of 1 applies, causing bridged TCP between devices on the same profile to be rejected by fw3 zone rules that don't match bridged frames. * Add inbound VPN server smart endpoints and wire up frontend Backend: new vpn_server module with full CRUD for WireGuard server interfaces and peer management, including key generation (x25519-dalek), client config rendering, and UCI/service orchestration. New wg module for WireGuard key pair utilities. Frontend: replace stubbed profiles and endpoints with live data from smart endpoints. Show profile display names instead of interface names, hide Add when all profiles already have a VPN (the upsert API makes adding a duplicate redundant—just edit the existing one), add peer IP validation (range + uniqueness), and auto-prompt first client creation after adding a new server. * Add FE validator for Profile fullname uniqueness * Add VPN-connected peers to devices list with nullable mac handling Backend (devices.rs): - VPN peers discovered via wg show + UCI peer configs, shown as online with speed/data - Device.mac changed to Option<String> (VPN peers are L3, no MAC) - published_ports.rs: skip MAC-less devices when indexing Frontend: - DeviceFromApi.mac / DeviceTableItem.mac now string | null - All three device tables (online/offline/blocked): @if (item.mac) guards on links and action buttons, {{ item.mac || '-' }} display, track item.mac ?? item.ipv4 - published-ports/service.ts: optional chaining on d.mac?.toUpperCase() (crash fix) - published-ports/dialog.ts: filter out MAC-less devices from port forwarding device picker - devices/service.ts: fallback name 'VPN Device' for MAC-less peers - VPN shield icon added to online table and summary page * Make network restart handlers synchronous and add frontend loading indicators Backend: remove std::thread::spawn from reload_system(), reload_system_and_wifi(), and restart_network_services() so handlers block until the network restart completes. Frontend: remove pauseFor() polling delays (now unnecessary), add restart: true with loading/success messages to all endpoints that trigger a network restart (profiles create/update/delete, inbound VPN set/delete/addPeer/deletePeer), and increase NETWORK_RESTART_TIMEOUT_MS to 30s. * update profiles validator to allow 0 for the third octet * Add outbound VPN client smart endpoints and migrate frontend from UCI Backend: - Add vpn_client module with list/create/update/delete/set-enabled RPC endpoints that parse WireGuard .conf files, manage UCI config, and handle interface lifecycle (ifup/ifdown) - Add per-profile DNS forwarding via dedicated dnsmasq instances so VPN DNS servers resolve .lan locally instead of bypassing dnsmasq with direct DNAT (fixes broken .lan resolution when using VPN DNS) - Add local subnet route to policy routing tables so LAN traffic between devices on the same profile stays local instead of going through the VPN tunnel - Change dnsmasq reload to restart (required for new dnsmasq instances) - Add ProfileDnsmasq typed section and WIREGUARD InterfaceProto variant - Make WgInterface, UciVpnServer, and several profile helpers pub(crate) Frontend: - Replace OutboundUciService (direct UCI read/write) with smart endpoint calls through ApiService (vpnClientList/Create/Update/Delete/SetEnabled) - Delete outbound/uci/service.ts and outbound/uci/mocks.ts - Add duplicate label validation to add/edit VPN dialogs - Wire up used_by profile list display from backend data - Add interfaceNameLength and duplicateName validators * Add VPN chain validation, cycle detection, and endpoint routing Prevent deleting or disabling a VPN that other VPNs chain through. Cascade label renames to dependents. Validate targets exist and won't create routing cycles. Automatically manage static routes (vcr_*) so chained WireGuard endpoints traverse the correct tunnel. Frontend filters target dropdown to cycle-safe options and disables delete when dependents exist. * Add per-profile DNS hijacking and SmartDNS-backed DNS resolution The existing WAN DNS smart endpoint stored servers as plain strings on the network interface and relied on dnsmasq's native forwarding — which couldn't support per-profile DNS or DoH. This replaces that approach with a SmartDNS proxy layer and firewall DNAT hijacking that meets the full requirements. Backend: - Add dns.rs module with SmartDNS config generation, per-profile server groups (port 5300 + vlan_tag), and structured DnsServer type ({address, ssl}) - Add UciSystemDns typed section in /etc/config/startwrt (replaces storing DNS on the network interface) - Rewrite wan.dns-get/dns-set to use startwrt config instead of network interface DNS lists - Add DNS hijacking (firewall DNAT on port 53) and per-profile dnsmasq instances that forward to SmartDNS or VPN DNS - dns-set rewrites dnsmasq/firewall for all profiles so system DNS changes propagate immediately - Profile create/set/delete regenerate SmartDNS config - Add SmartDNS restart to reload_system() and reload_system_and_wifi() Frontend: - Change dns_override type from string[] to DnsServer[] across API types, profiles dialog, and WAN DNS forms - Remove @853 string parsing in favor of structured DnsServer objects - Rename "TLS" label to "Secure (DoH)" to reflect actual protocol - Add DNS field validators to profiles dialog - Fix updateDnsValidators to validate all three server fields Build: - Add smartdns package to openwrt.diffconfig - Add custom SmartDNS init script using our generated config * Disable IPv6 per-profile when outbound VPN lacks IPv6 support Most VPN providers (NordVPN, ExpressVPN, Surfshark, etc.) don't carry IPv6 through their tunnels. When a profile routes through such a VPN, IPv6 traffic would bypass the tunnel and leak via WAN. Add outbound_supports_ipv6() which checks the VPN's WireGuard addresses for IPv6 entries. Profile create, update, and the global IPv6 toggle now gate ip6assign and DHCPv6/RA on this check, so profiles using IPv4-only VPNs automatically get IPv6 disabled on their VLAN interface. * Add outbound VPN enable/disable toggle with profile reset and confirmation dialog * Add SSH keys smart endpoints and migrate frontend from UCI file access Backend ssh_keys module handles list/add/delete via openssh-keys crate with fingerprint-based key identification, duplicate detection, and proper file permissions. Frontend now uses RPC endpoints instead of directly reading/writing authorized_keys. * Add HTTPS with self-signed CA, LuCI reverse proxy, and CORS support Generate a local Root CA and server leaf certificate (ECDSA P-256) at startup, serving HTTPS on port 443 alongside HTTP on port 80. The server cert is auto-renewed when expiring or when the LAN IP changes. A CA wizard on the login page guides users on non-HTTPS connections to download and trust the Root CA. Move uhttpd to localhost:8080 (HTTP only, no TLS) and proxy LuCI requests (/cgi-bin/*, /luci-static/*, /ubus/*) through the axum server, handling redirect chains and cookie forwarding. Update remote access firewall rules to expose port 443 instead of 8080/8443. Add Root CA download to the general settings page and change the advanced settings LuCI link to use the reverse proxy path. * Add cancel/reset support to general settings form * Store WiFi password as plaintext, add per-band SSID broadcasting, and use PSK hot-reload for password-only changes Password storage: replace PBKDF2-SHA1 PMK derivation with plaintext passphrase storage throughout the stack. A PMK is derived from both the passphrase and SSID, so it becomes invalid when the SSID changes — storing plaintext allows SSID changes (including the new -5G suffix) without re-deriving. This also matches OpenWrt's default behavior of storing plaintext passwords in /etc/config/wireless (root-only access), and simplifies the codebase by removing pbkdf2, sha1, and hex dependencies. Updates init, setup, emmc, flash, daemon, startwrt-bake-password (SWRTPMK→SWRTPWD format), and PROPOSAL-init-reflash.md. Fixes documented charset to include lowercase 'i' exclusion (67 chars, ~72.3 bits entropy). Broadcast separately: add broadcastSeparately field to WiFi config so dual-band routers can advertise "{SSID}-5G" on the 5GHz radio while keeping the base SSID on 2.4GHz. Backend detects differing per-radio SSIDs on read and applies the -5G suffix on write. Frontend adds a toggle (visible when band is "Both"), SSID-change confirmation dialog, and a reconnect dialog that polls until the backend is reachable. WiFi restart optimization: replace the boolean vlans_created return with a WifiRestart enum (Full vs PskOnly). Track whether device or interface config actually changed (SSID, channel, enabled, hidden, encryption, key, dynamic_vlan) and only issue a full `wifi` restart when needed; password-only changes use `wifi reload` to avoid disconnecting clients. Adds PartialEq/Eq to WifiChannel for the comparison logic. * Fix SmartDNS stealing port 53 from dnsmasq when no DNS groups are configured Remove the SmartDNS config file instead of writing an empty one so the init script's guard prevents SmartDNS from starting. Without a bind directive, SmartDNS defaults to 0.0.0.0:53 which conflicts with dnsmasq. * Add system.restart smart endpoint and migrate frontend from exec Backend: add system.restart RPC handler that spawns a delayed reboot (same pattern as factory-reset). Frontend: replace generic exec('reboot') call with the smart endpoint, suppress poll errors for 90s during reboot, and poll systemInfo until the device goes down then comes back up so the spinner stays visible for the full reboot cycle. * Add ethernet smart endpoints and migrate frontend from UCI file access Backend: - Redesign ethernet.get/set API contract: return structured Ethernet object with wan_ipv6, wan_port, and ports map instead of flat port list - Extract find_lan_bridge() helper, eliminating duplicated bridge lookup logic across ethernet.rs and profiles.rs - Filter WiFi/phy interfaces from ethernet port listing - Preserve non-ethernet bridge ports (wlan, phy) during ethernet.set - Skip unnecessary bridge device writes when ports haven't changed - Use reload_system_and_wifi() instead of raw Command for service restarts - Change firewall reload to restart for reliable rule application - Add comprehensive test suite (~1000 lines) covering get, set, round-trip, WAN management, WiFi port preservation, and bridge lookup Frontend: - Replace UCI-based EthernetUciService with smart endpoint calls (ethernetGet/ethernetSet) - Delete ethernet/uci/ directory (mocks.ts, service.ts) - Use real ProfileId objects instead of stub profile strings - Add empty-state placeholder for ports table - Network restart is now handled server-side; remove client-side restart uciedit: - Default Token::from_string to single-quoted output for UCI consistency - Only use double-quoting when value contains single quotes - Update all test expectations for new quoting behavior * Replace rcgen with openssl and add intermediate CA to PKI chain Switch certificate generation from rcgen to the openssl crate (vendored) to reduce dependency count and gain finer control over X509 extensions. Introduce an intermediate CA between the root CA and server leaf cert, following standard PKI hierarchy (root signs intermediate, intermediate signs leaf). * Prefer GUA over ULA when selecting a device's IPv6 address * Bounce changed ethernet ports and defer reload to background thread When a port's VLAN assignment changes, connected clients stay on their old DHCP lease and subnet. Bring changed ports down for 2 s (IEEE 802.3 break_link_timer) then back up so link partners re-run DHCP. Move network/firewall reload to a background thread so the RPC response reaches the client before the L2 path switch causes a hang. Drop the wifi/dnsmasq/smartdns restarts — only bridge VLAN config changed, and restarting wifi would lose bridge VLAN 1 entries on recreated interfaces. * Detect wan_ipv6 by interface name instead of device match * Add config backup and restore endpoints with settings UI * Centralize network reconnect handling in ActionService Move reconnection polling and UI out of individual pages into ActionService. Actions with `restart: true` now automatically race against a timeout, poll for network drop, and show a generic ReconnectingDialog. Special cases (LAN IP change, profile gateway change, WiFi SSID change) bypass the generic flow with their own redirect/reconnect logic. - Add ReconnectingDialog component for post-restart reconnection - Simplify NetworkRestartService to boolean suppress/recovered - Remove per-service refreshAndWait() calls from save methods - Handle factory reset and backup restore via restart+reconnect flow * Fix VPN peer reachability with proxy ARP and /32 policy routes When a profile uses an outbound VPN, policy routing's /24 subnet route catches VPN peer IPs, sending locally-generated responses (DNS, HTTP) to the LAN bridge instead of back through the WireGuard tunnel. Add /32 host routes per peer to override via longest-prefix match. LAN devices also fail to reach VPN peers because they ARP directly for IPs behind the WireGuard tunnel. Enable proxy ARP on the profile's bridge VLAN interface so the router answers on behalf of VPN peers. A hotplug script ensures the sysctl persists across reboots since netifd does not honor the UCI proxy_arp option. * Add cross-subnet routes to VPN policy tables for local reachability When a profile uses an outbound VPN, its policy routing table has a default route through the tunnel. Responses from the router's own IP to devices on sibling VLANs matched the source-based ip rule and exited through the VPN instead of routing locally — making the admin gateway IP unreachable from guest profile devices. sync_cross_subnet_routes() adds sibling subnet routes (e.g. 192.168.8.0/24 dev br-lan.101) to each VPN profile's table so local cross-VLAN traffic takes precedence over the VPN default route. * Update all profile-dependent configs when LAN subnet block changes Previously only network interfaces and routing rules were updated. Now also updates policy routes, dnsmasq listen addresses, and DNS-Override firewall redirects to match the new subnet block. * Add activity logging system with RPC endpoints and frontend integration Track user-visible actions (login, profile/device/VPN/backup/SSH key changes, factory reset, etc.) in a JSON log file with list, delete, and clear RPC endpoints. Every mutating handler now records success or failure with a human-readable summary. The frontend activity page consumes the new endpoints. * Add support diagnostics bundle download endpoint and UI Introduces GET /api/diagnostics that collects system logs (logread) and activity history into a tar.gz archive served as a browser download. Wires up the existing "Download Support Diagnostics" button in the advanced settings page to fetch and save the bundle. * Add RPC continuations system, migrate backup/diagnostics/activity Introduce a one-shot continuation mechanism (modeled on start-os) for binary I/O over REST, replacing ad-hoc HTTP endpoints with proper RPC methods that return a GUID for subsequent file transfer. - Add continuations module with TimedResource (per-continuation tokio timeout), Guid newtype, RestHandler returning Result, session-bound kill signals (OpenAuthedContinuations), and cleanup-on-add - Migrate backup create/restore from /api/backup and /api/restore to backup.create and backup.restore RPC + /rest/rpc/{guid} - Migrate diagnostics from /api/diagnostics to diagnostics.create RPC, simplified from tar.gz archive to plain syslog text - Migrate activity log from JSON file to SQLite (rusqlite) - Add CLI handlers for backup download/upload and diagnostics download - Add ServerContext fields for continuations and open_authed_continuations - Update frontend to use RPC + continuation GUIDs for file transfers - Swap tar/flate2/once_cell deps for rusqlite/dirs - Bump default log_size 128→512 and add log_file in firstboot config * Fix conffiles backup to expand directory entries from keep.d OpenWrt's base-files keep.d includes `/etc/config/` (the whole directory), but backup_conffiles() only backed up exact file paths — directory entries were silently skipped. This caused /etc/config/startwrt (and any other config not explicitly listed by a package) to be lost on reflash with "keep settings". * default CliArgs host to http://router.lan/rpc/v1 * Use in-memory SQLite for activity DB in tests The activity LazyLock panics trying to open /etc/startwrt/activity.db which doesn't exist in test environments, poisoning 45 tests. * Silence noisy child process output and tighten default log level Service reload commands (firewall, dnsmasq, network, wifi, etc.) write informational output to stderr, which procd logs as daemon.err and floods the syslog. Add a run_quiet() helper that redirects child stdout/stderr to /dev/null and migrate all ~30 call sites to use it. Also raise the default tracing filter from info to warn (keeping activity=info), add a startwrt-activity prefix with OK/FAIL status to activity log lines for easier logread filtering, and disable Samba NetBIOS (unused). * Mask VPN client config and QR code by default for security * Show LAN Access as 'All' when only one profile exists 'Same profile' is meaningless with a single profile since there are no other profiles to exclude. * Fix disconnected WiFi clients showing as Online Ethernet A REACHABLE IPv6 NDP entry was suppressing the IPv4 ping probe for recently disconnected WiFi clients, so they were never detected as unreachable. Only consider IPv4 REACHABLE entries when deciding whether to skip STALE probes, since IPv6 entries cannot be probed. * Move session storage to /etc/startwrt/ to persist across reboots /var/run/ is a tmpfs cleared on every reboot, causing all sessions to be invalidated. Store sessions in /etc/startwrt/ instead so users stay logged in across router restarts. * Use bridge FDB to detect stale WiFi clients and bind pings to interface Cross-reference the bridge forwarding database with hostapd to catch WiFi clients whose ARP/driver state lingers after disconnection. Bind ping probes to the correct interface to prevent WAN leakage on overlapping subnets. Also probe DELAY/PROBE ARP states alongside STALE. * Remove Samba, GnuTLS, and Chinese locale packages from build Drop samba4, its dependencies (GnuTLS, libgmp, libnettle, libtasn1), wsdd2, audio libs (alsa-lib, fdk-aac, lame-lib), and zh-Hans locale packages. These are SpacemiT K1 target defaults not needed by StartWRT. * feat: bundle used icons * chore: fix spacing * Update web/package.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update captive portal IP to match default * pin feeds to 24.10 * Gate serial console setup on baked WiFi password presence Add `startwrt-cli has-baked-password` to check whether the boot image's rootfs contains a password written by startwrt-bake-password. The serial login script now uses this to either show the WiFi setup wizard prompt or fall through to the `manufacture` flow. * Add startwrt-cli verify command for factory QC Checks firmware integrity (squashfs superblock valid on eMMC) and WiFi SSID broadcast (hostapd running, SSID is "StartWRT"). Embeds git hash at build time for firmware version display. * Migrate to OpenWrt 25.12.1 with Armbian 6.18 vendor kernel Switch from SpacemiT vendor kernel 6.6 on OpenWrt 24.10 to Armbian's forward-ported vendor kernel 6.18.19 on OpenWrt 25.12.1. The Armbian kernel (github.com/jmontleon/linux-bianbu, branch linux-6.18.y) provides full BPI-F3 hardware support: SD card, dual GbE, USB 3.0, PCIe, PMIC. Build system: - Submodule based on upstream OpenWrt v25.12.1 (not SpacemiT fork) - Kernel via CONFIG_KERNEL_GIT_CLONE_URI (git-clone mechanism) - Feeds updated to openwrt-25.12 branches, written to feeds.conf (gitignored) instead of overwriting tracked feeds.conf.default - Removed spacemit_openwrt_feeds dependency - Simplified openwrt-setup.sh and stage-files.sh - Updated image name and path for bananapi-f3 target - Diffconfig: removed binutils override, updated device name, disabled kmod-crypto-sha512 (forced built-in by DRBG_HMAC) Kernel compat patches carried in submodule: - CFG80211_HEADERS for mac80211 backport wireless support - libcurve25519-generic compat module (vendor naming) - sha512_generic module rename (vendor naming) - SOCK_ASYNC compat for mac80211 on kernel 6.18 - NETFILTER_XTABLES_LEGACY for iptables support on 6.18 * Fix APK package feeds by aligning spacemit CPU_TYPE with upstream * Update openwrt submodule: build F2FS into kernel Build F2FS as built-in (=y) instead of a module so it is available at boot for mounting the root filesystem. * Update openwrt submodule: build 8021Q VLAN support into kernel Without this, netifd cannot create bridge VLAN sub-interfaces (br-lan.1) during early boot, breaking security profile isolation. * Update openwrt submodule: enable conntrack procfs for speed tracking * updated openwrt submodule to 25.12.2 * Add per-profile WAN schedules and system timezone support Profiles can now have scheduled WAN-block windows (e.g. block internet for Kids profile on school nights). Backend stores schedule data in UCI, generates crontab entries to toggle firewall REJECT rules, and evaluates current state at boot and after firewall reloads. System timezone is configurable via settings and auto-detected from the browser during initial setup. The backend writes the POSIX TZ string to /etc/TZ so cron and all time functions use local time — critical for schedule accuracy. * Autofocus Flash button in setup wizard so Enter confirms * Remove device blocking feature * Include LAN IPv6 address in server certificate SAN The server TLS certificate previously only included the LAN IPv4 address. This adds the LAN IPv6 (ULA) address as a Subject Alternative Name so browsers don't show certificate warnings when accessing the router over IPv6. The cert is now regenerated whenever the IPv6 configuration changes, and on startup if the SAN doesn't match. * Add local auth cookie for on-device CLI authentication The daemon generates a random token at startup and writes it to /run/startwrt/rpc.authcookie. CLI commands running on the router read this file and present it as a cookie, bypassing session auth. This replaces the previous loopback-only bypass with a cookie-based mechanism that works over any local transport. * Unify password charset and add server-side WiFi password generation Move the ambiguity-safe character set to a shared constant in lib.rs (PASSWORD_CHARS) so init.rs and startwrt-bake-password stay in sync. Add a PASSWORD_CHARS_ALNUM subset and generate_password() using rejection sampling. Expose wifi.generate-password RPC endpoint so the frontend generates passwords server-side with proper randomness instead of using Math.random in the browser. Charset changes: re-adds lowercase i/o, drops - and _. Adds ?. * Switch conntrack from procfs to netlink and improve nlbwmon config Replace /proc/net/nf_conntrack reads with `conntrack -L` (netlink API), allowing NF_CONNTRACK_PROCFS to be disabled in the kernel. Add the conntrack-tools package to the build. Fix nlbwmon configuration: move the database to persistent storage (/etc/nlbwmon/data), reduce the commit interval from 24h to 1h to limit data loss on unexpected reboots, and add all RFC 1918 subnets so it correctly accounts for LAN traffic across all security profile VLANs. * Flush ARP and DHCP lease when forgetting a device Previously, forgetting a device only reloaded dnsmasq, so the device would linger in the device list until its lease expired. Now we delete ARP neighbor entries and remove the lease line (stopping dnsmasq first to avoid it overwriting the file), so the device disappears immediately. * Add split/full tunnel routing option for inbound VPN peers Allow VPN clients to choose between routing all traffic (LAN + WAN) through the tunnel or only LAN traffic. Stored as a UCI flag per peer and reflected in generated WireGuard client configs. Also fixes client address mask from /24 to /32 and adds the missing DNS line to the frontend's config display for user-supplied-key peers. * Warn before deleting an outbound VPN that is in use by profiles * Warn before deleting inbound VPN when IP/subnet changes break peers Changing a profile's subnet or the router's LAN IP invalidates WireGuard peer allowed-IPs, silently breaking VPN clients. Add a guard that blocks the change unless force is set, with full VPN server teardown on force. The frontend catches the error, shows a confirmation dialog, and retries with force when the user accepts. * update tests to match changes in implementation code * Remove WPS * Fix "Manage clients" link to use correct route for inbound VPN The "Manage clients" dropdown option was navigating to /inbound/<port>, which didn't match any route. Changed it to use routerLink="client" with a port query param, matching the existing navigation pattern. Also removed the now-redundant link wrapper from the server label column. * Restructure documentation into ARCHITECTURE/CONTRIBUTING/README per component CLAUDE.md files were doing triple duty as architecture docs, contributing guides, and AI assistant references. Split them into purpose-specific files so each serves one audience: ARCHITECTURE.md for system design, CONTRIBUTING.md for developer onboarding, README.md for orientation, and CLAUDE.md as a slim quick-reference for AI tooling. Moves init-reflash proposal into docs/. * Update help text: add backup/schedule pages, fix DNS terminology, improve copy Add help content for backup settings, timezone, security certificate, WiFi enable toggle, and inbound VPN routing options. Rename DNS over TLS to DoH throughout. Tighten outbound VPN and profiles copy. Fix aside help lookup for dynamic profile schedule routes. * chore: bump Taiga to 5.0 * Fix DNAT reply routing for VPN-routed profiles with port forwards When a profile uses VPN policy routing, DNAT reply packets (from port forwards) were being captured by the source-based ip rules and sent through the VPN tunnel instead of back to the original client. Add a per-profile mangle MARK rule (conntrack --ctstate DNAT) and a shared ip rule (dnat_return) that routes fwmark 0x80 traffic via the main table. Assign explicit priorities (100 for DNAT return, 200 for VPN source routing) so the mark rule is always evaluated first. Also extend NetworkRule with optional src/mark/priority fields and FirewallRule with set_mark/extra fields to support mangle MARK targets. * Add static IPv6 LAN prefix delegation and harden IPv6 port forwarding - Add lan_prefix field to WAN IPv6 static mode for configuring the LAN delegation prefix (odhcpd ip6prefix), plumbed through API contract, backend, and frontend form - Skip IPv6 firewall rules for devices with only ULA addresses instead of blocking the entire port forward save; show warning in the dialog - Remove ip6assign from profile interfaces — only the admin LAN gets the delegated prefix until multi-prefix delegation is implemented - Enable kmod-ip6tables in diffconfig for IPv6 firewall rule support * Fix IPv6 LAN prefix delegation and clean up published-ports tests - lan.rs: skip LAN interface when stripping ip6assign so it retains its prefix delegation (the loop was incorrectly clearing it along with profile interfaces) - published_ports.rs: switch two tests to ipv4-only to match their actual assertions (v6 rule coverage exists elsewhere) - wan.rs: add lan_prefix field to all test request structs after the field was introduced in e7abf24 * Fix mock API, validation guards, and miscellaneous UI bugs (#33) * Guard against subnet changes when DHCP static hosts exist Add backend validation (DhcpStaticHostsInSubnet error) that rejects LAN IP or profile subnet changes when devices have static IP reservations in the affected range. On the frontend, proactively disable the Save button with a hint when static IPs are detected, and surface non-VPN backend errors as alert notifications instead of silently swallowing them. Update mock API to auto-reserve static IPs on port-forward enable. * Reserve static DHCP lease when re-enabling a published port Creating or editing a published port already reserves a static IP via the dialog flow, but toggling a disabled port back on in the table bypassed that — the device could lose its dynamic lease and break the forward. Call reserveDeviceIps from toggleEnabled on the frontend, and add a backend fallback that auto-creates missing DHCP reservations during published_ports.set for any enabled IPv4 port. * Refresh system info after saving general preferences The UI wasn't reflecting updated timezone/hostname after saving. Add SystemService.refresh() and call it after the preferences save. * Add IPv6 static reservation support to device detail and published ports * Validate unique subnet when creating or editing a profile * Filter radio band lookup to only enabled radios Prevents a disabled radio from shadowing the active one when populating the wifi settings form. * Overhaul mock API for correctness and interactivity Consolidate scattered mock device data into unified MockDeviceDef definitions with dynamic IP computation from profile gateways. Add cascade effects for profile rename/delete, VPN client delete/disable, and LAN IP changes. Log activity entries for all mutating operations. Fix WiFi reconnect dialog to complete dialog instead of reloading in mock mode, refresh WiFi state after reconnect, and check device IPv6 reservations instead of published ports for SLAAC lock. * Adopt start-os conventions and eliminate blocking I/O in async contexts (#34) * Adopt start-os conventions and eliminate blocking I/O in async contexts Aligns start-wrt's backend with start-os patterns by importing shared utilities from the startos crate and restructuring I/O to never block the async runtime. Foundation: - Rename startwrt-ctrl crate to startwrt-core (lib name startwrt) - Add start-os as a path dependency for direct reuse of its utilities - New error.rs modeled after startos::Error: #[repr(i32)] ErrorKind with 22 generic variants matching startos codes + 23 domain-specific variants at 1000+; Error { source, kind, info }; ResultExt/OptionExt - New prelude.rs with eyre!, instrument, Error, ErrorKind, etc. - From<crate::ErrorKind> for startos::ErrorKind and From<startos::Error> for crate::Error for seamless interop Imports from startos (code removed from start-wrt): - startos::util::Invoke replaces local Invoke impl (~280 lines) - startos::util::serde::{HandlerExtSerde, DisplaySerializable} replaces local versions (~130 lines) - startos::util::serde::StdinDeserializable used under the hood for multi-format (JSON/YAML/TOML/CBOR) stdin deserialization - startos::util::io::AtomicFile / write_file_atomic replace manual temp+rename patterns in ssl.rs, auth.rs, emmc.rs, setup.rs, backup.rs - startos::util::new_guid() replaces custom 128-bit hex Guid Error migration: - All ~30 handler modules converted from thiserror-based ErrorKind with fields to Error::new(eyre!("msg with {field}"), ErrorKind::Variant) - 300+ ErrorKind::Unknown usages replaced with specific kinds - RPC errors now serialize with numeric code + structured details (ErrorData) No blocking I/O on async threads: - Zero spawn_blocking wrappers around std::process::Command - Zero std::fs::* calls in async functions (all replaced with tokio::fs) - uciedit made async: parse_all, dump_all, Config::parse, Config::dump, LockedConfig::* all take tokio::fs::File; flock runs on blocking pool - uciedit adds read_all/ConfigBytes::parse + Configs::freeze/write_all splits so callers can avoid holding !Send Arena across awaits (used by init::configure_wifi so flash can spawn) - run_setup_flash runs on a dedicated thread with its own current_thread runtime (its future is !Send via transitive Arena) - All handlers that hold Arena across awaits registered via from_fn_async_local - files.rs handlers (get/set/dir_get) async; flock via spawn_blocking - CLI runtime switched from current_thread to multi_thread(1 worker) Tracing: - #[instrument(skip_all)] on every RPC handler function Tests: - 343 tests pass (326 ctrl + 17 uciedit) - Tests converted to #[tokio::test] where they call async handlers * Vendor start-os as a submodule Move the start-os path dependency from a sibling checkout (../../../start-os/core) to a submodule pinned at ../../start-os/core so fresh clones of start-wrt build without requiring a parallel start-os checkout. Track master on the submodule. start-os's build.rs reads build/env/GIT_HASH.txt, which isn't present in a fresh submodule checkout; build-rust.sh now generates it via start-os/build/env/check-git-hash.sh before invoking cargo. * Fixes/refactor regressions (#35) * Install rustls ring crypto provider explicitly in ctrld The start-os dep transitively enables rustls's `aws-lc-rs` feature via lettre, leaving rustls compiled with both `ring` and `aws-lc-rs`. Its auto-select then panics at runtime, so install the ring provider before anything touches rustls. * Fix service-reload hangs by redirecting child stdio to /dev/null The start-os Invoke trait pipes stdout/stderr and awaits wait_with_output(), which hangs indefinitely when init.d scripts spawn long-lived grandchildren (udhcpc, hotplug handlers) that inherit the pipe fds. Replace every init-script / ifup / wifi invocation in ctrl with a new run_quiet_async helper that nulls stdio and waits only on the direct child. * Pin RISC-V C builds to K1 ISA and fold in pending fixup work Build pipeline: - aws-lc-sys's cc_builder (selected over cmake because pregenerated bindings exist for riscv64gc-musl) only injects `-Wp,-U_FORTIFY_SOURCE` into its jitter-entropy sub-build when CFLAGS_<target> is present. `zig cc` rejects `-Wp,` passthrough, which broke the build. - Bake -mcpu into zigcc-k1.sh / zigcxx-k1.sh wrappers instead of exporting CFLAGS/CXXFLAGS, drop the dead AWS_LC_SYS_CMAKE_TOOLCHAIN_FILE env var (cmake never runs, so the toolchain file was never consulted), and verify the output binary contains no RVA23-only instructions via verify-isa.sh. - .DELETE_ON_ERROR: in Makefile avoids leaving truncated targets behind when a rule fails mid-write. Runtime: - setup.rs: replace tx.blocking_send with tx.try_send inside the flash progress callback so the async executor isn't blocked while the channel is full. Other: - Bump start-os submodule to 0.4.0-beta.6; propagate to backend/Cargo.lock and a peer-dep marker in web/package-lock.json. - startwrt-bake-password: allow a blank prompt to generate a random password matching the sticker rules. --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> --------- Co-authored-by: Dominion5254 <musashidisciple@proton.me> * fix: vpn addition dialog layout (#38) * Add release workflow, gitHash build stamping, and About section (#37) Install binutils-riscv64-linux-gnu in the CI build environment — previously missing, which was failing image builds on hosted runners. Stand up a GitHub Release job that publishes image artifacts for tag pushes and manual `deploy=release` dispatches, bumping the project to 0.1.0-beta.1. Gitignore web/config.json and generate it from config-sample.json at build time: `build/env/check-git-hash.sh` writes GIT_HASH.txt whenever git state changes, `web/update-config.sh` stamps it into config.json and forces useMocks=false for production, and `web/build-config.js` runs before `npm start`/`ng build` so dev builds also carry a hash. Surface the hash in Settings > General as a new About section (version + short gitHash) via a new GIT_HASH injection token. * build: chown backend/target on exit, clean root-owned files via docker (#39) Replace the post-build ownership fix in build-rust.sh with an EXIT trap so interrupted or failed Docker builds also leave backend/target owned by the host user. As a backstop for already-broken trees, make clean detects root-owned files and delegates removal to the cargo-zigbuild container. * refactor(devices): replace local cache with refreshAndWait DevicesService no longer maintains a parallel `devices` array; update and forget now refetch from the API after mutating, keeping the form state as the single source of truth. Drop unused getDevice/ getDevicesByStatus helpers. Editing a device now shows a spinner and success toast covering both the update call and the subsequent refresh. Mock API tracks device defs in a mutable instance field so forget actually removes them, and adds an offline mock device. * feat(wan/ipv6): show status badge in summary Display IPv6 mode as a badge (Disabled / Enabled + mode label) at the top of the WAN IPv6 summary so the current state is visible without inspecting individual fields. * feat: align UI with Start9 design guidelines * chore: address comment * refactor(ctrl): delegate cert generation to start-os ssl primitives Drop start-wrt's hand-rolled X509Builder code and reuse start-os's make_root_cert / make_int_cert / make_leaf_cert via the new CertBranding hook (start-os PR #3200), wired with a "StartWRT" CertBranding so the issued CN/OU strings match this product. Net diff in ssl.rs: -239 lines. Renewal threshold and serial-number generation now live in start-os too; start-wrt only retains the filesystem layout, LAN address discovery, and TLS config wiring. Also bumps the start-os submodule to master so the CertBranding API is available on the pinned commit. * refactor(profiles): inline WAN schedule editor into profile dialog (#44) Replace the standalone /profiles/:interface/schedule route with an embedded ProfileScheduleEditor component inside the profile add/edit dialog. Schedule windows are now loaded alongside the profile, edited in-place, and persisted as part of the same save flow. - Rename schedule/index.ts -> schedule/editor.ts and convert it from a routed page into a model()-based component - Delete schedule/service.ts; profileScheduleGet/Set are called directly from the profiles route and service - For admin-IP changes, write the schedule before the IP change so it survives the redirect to the new gateway - Drop the "WAN Schedule" row action and the dynamic-route help-path normalization in Aside; fold schedule help into the dialog help entry * Misc fixes: data-usage daily fan-out, session-expiry redirect, ethernet spinner hang (#43) * ethernet: align post-set reloads with backend conventions Drop the tokio::spawn wrapper around network/firewall reload — every other module (lan, profiles, dns, wan, wifi, system) awaits init.d calls inline after UCI writes. Spawning only papered over disconnects for users reassigning their own port; the unaffected case now gets a real success/failure response. Also switch `firewall restart` to `firewall reload` per backend/CLAUDE.md, preserving conntrack since only L2 changed. * devices: switch data-usage to daily nlbwmon fan-out, drop intra-day period The previous data_usage implementation called `nlbw -c json -g mac,interval` with a single start-date, which doesn't return per-day points the chart needs and silently produced empty results when archives were missing. Replace it with a per-day fan-out: - Fetch `nlbw -c list` once to learn which YYYY-MM-DD archives are retained, then run `nlbw -c json -g mac -t <date>` for each day in the requested window (8-way `buffer_unordered`). Days not in the archive set are zero-filled, so the series is always dense and oldest-first. - Drop the `Day` period — nlbwmon archives are daily, so intra-day points were never real. Periods are now week (7d), month (30d), and 3months (90d). - Add `ymd_to_days` / `parse_ymd_to_days` / `lookup_mac_bytes` helpers with unit tests covering round-trip dates, malformed input, and missing MACs. - Persist `/etc/nlbwmon/data/` across sysupgrade so history survives updates. Frontend: - Remove the `'day'` period from the type, dropdown, and mocks. - Render an empty-state message when every point is zero (real "no traffic" signal now that the backend zero-fills). - Render an error notification with a Retry button when the RPC fails. - Handle the single-point case as a flat segment instead of bailing. - Rotate weekday labels so the rightmost label is today. - Mock data_usage at the RPC level instead of faking the nlbw shell call, removing the period-from-date-range guessing. * auth: redirect on session expiry, mirroring start-os setUnverified The RPC error-34 handler only cleared the authenticated signal, so an expired session left the user on the protected route until they clicked something. Move the (clear signal + navigate) pair into AuthService.setUnverified() — matching start-os's auth.service — and call it from both the header logout and the RPC 401 path so session expiry actually redirects. * ethernet: fix spinner hang on profile change The pre-existing spinner hang lives in the FE poll loop, not the BE. pollUntilSettled awaits systemInfo() with no per-request timeout, so a poll wedged on a half-broken connection blocks the for-loop and the loading subscription never unsubscribes. Wrap each poll in a 5s Promise.race with a code: 0 throw so a wedged request transitions into the reconnecting dialog, which already polls concurrently and closes once the daemon recovers. 15ed46e tried to fix this on the BE by awaiting the reload sequence inline. That regressed badly for ethernet specifically — set_config rewrites every bridge-vlan on br-lan, briefly dropping wlan0's VLAN 1 membership, so the inline response rode the disrupted path. Revert to the spawned reload and `firewall restart`. Update the comment to flag why this endpoint diverges from the inline pattern used elsewhere. * chore: refactor schedules and other minor fixes * chore: fix * docs: distill CLAUDE.mds to AI-only; add Documentation section to CONTRIBUTING.mds Move framework intros, commands, and Key Files tables out of CLAUDE.mds (they duplicated content already in ARCHITECTURE/CONTRIBUTING). CLAUDE.md keeps only repo-specific gotchas: openwrt/ submodule warning, hours-long make image, no test framework wired in web/, etc. Add bulleted Documentation section near the top of CONTRIBUTING.md with the explicit doc-sync mandate. Workflow: skip OpenWrt image build on doc-only changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci: build only on tags and dispatch; private-repo minutes were burning quota Master pushes and PRs were triggering ~5h OpenWrt builds (~29k min in May alone), exhausting the org's free Actions pool. Triggers preserved as comments inline to restore once the repo is published. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(wifi): source password from EEPROM, drop /key_backup partition (#47) * feat: source WiFi password from EEPROM tag 0x2F, drop /key_backup partition The on-board I²C EEPROM (24c02 at bus 2 / 0x50) is now the canonical store for the per-device WiFi PMK, programmed by the hardware vendor during manufacture as an ONIE TLV record (tag 0x2F, 12 ASCII bytes). UCI remains the live source of truth: restore_wifi_if_needed only consults EEPROM when /etc/config/wireless has no key on the AP interface — first boot or after a factory reset wipes the overlay. No on-device init flow is required for a vendor-programmed board. This removes the eMMC /key_backup partition and the SD-card baked-password mechanism (SWRTPWD magic), along with the startwrt-cli init / manufacture / has-baked-password subcommands and the serial console dispatcher logic that drove them. The setup wizard's flash path no longer writes /etc/config/wireless into the new overlay — the post-reboot eMMC daemon populates it from EEPROM instead. For boards the vendor never programmed (DIY installs, corrupt blobs), the AP simply doesn't come up and the operator runs \`startwrt-cli set-wifi-password [--manual]\` over ethernet/serial to provision one into UCI. Adds i2c-tools / coreutils-timeout / python3 to the OpenWrt config for EEPROM diagnostics. Removes /etc/config/wireless from CONFFILES_EXCLUDE so user VLAN/profile config is preserved across Update flashes. * feat(wifi): validate PSK length, auto-enable radios on first admin password - Reject PSK < 8 or > 63 chars at the API boundary so hostapd doesn't silently refuse to start the AP. - On first admin-password add, flip factory-disabled + hidden radios to enabled + broadcasting. Without this, a fresh device with an unprovisioned EEPROM tag 0x2F leaves wireless sections disabled, so setting a password would write the key but broadcast no SSID. Only fires when no AP iface yet has a key; explicit toggles are respected on subsequent edits. - Default firstboot SSID: OpenWrt -> StartWRT. * refactor(ctrl): replace axum-server with start-os WebServer + TlsListener (#46) axum-server's accept loop had no defense against connection accumulation (half-open TCP sockets, silently-dead HTTP/2 streams, transient accept errors), pushing the daemon toward fd/slot exhaustion with no recovery path. start-os already solves this in `core::net::web_server::WebServer` and `core::net::tls::TlsListener`; reuse those primitives instead of hand-rolling a hyper-util loop. WebServer provides: - Tuned TCP keepalive on every accepted socket (60s idle + 6×10s probes ≈ 2 min half-open detection, via the shared `default_keepalive` helper landed in start-os #3213) - HTTP/2 PING keepalives (25s interval, 300s timeout) - Accept retry with backoff on transient errors (EMFILE/ENFILE) - GracefulShutdown connection tracking - RFC 8441 extended CONNECT (enable_connect_protocol) for h2 WebSocket upgrades TlsListener adds 5s ClientHello + 15s full-handshake timeouts and runs each handshake in a per-connection task so a stalled client cannot block accept. Cert hot-reload is now an `Arc<ArcSwap<TlsMaterials>>` consulted on each handshake; `regenerate_server_cert` reloads the on-disk PEMs and swaps in a fresh value so LAN IP / IPv6 changes take effect without a daemon restart, while existing connections keep their original cert until they close. `/api/logs` is registered with `any` (not `get`) so HTTP/2 CONNECT requests reach the WebSocket extractor — required because WebServer serves h2 by default. Auth middleware reads `TcpMetadata` from request extensions instead of axum's `ConnectInfo<SocketAddr>` — same data, different plumbing. Frontend logs view: open the live WebSocket synchronously in the constructor (was deferred until after the snapshot RPC, which silently dropped the socket on quick teardown) and stop discarding the first N live entries — the snapshot and `logread -f` stream don't actually overlap. Bumps the start-os submodule to master. Replaces #40. * rebase submodule onto 25.12.3 (#49) * bump deps * feat(profiles): restructure security-profile dialog; validation, blackout/DNS/outbound polish - Group the profile dialog into General / LAN / DNS / WAN-Internet sections; constrain the Name field and align the Subnet octet group and the Outbound/DNS controls. - Replace the "use custom DNS" toggle with an "Inherit from system" / "Custom" radio; replace Outbound Routing's WAN-or-VPN select with a "Direct" / "VPN" radio plus a conditional VPN-client picker (the VPN option is disabled when no clients exist). - Rename the WAN schedule to "Blackout times" everywhere (dialog, help, and the shared Add/Edit Blackout Window dialog); disable the blackout section when WAN access is "None" without dropping its windows. - Stop disabling Save: surface inline errors instead. Require a profile selection for LAN whitelist, at least one IP/CIDR for WAN whitelist/blacklist, and keep the subnet-locked check when devices have static reservations. - Show a 15-minute quick-pick dropdown on the blackout window time inputs (also used by the Wi-Fi blackout schedule). - Fix the schedule grid's uneven inter-day column gap. - Register the @tui icons that were referenced but unregistered (hard-drive-upload, activity, external-link, inbox, repeat); the Backup -> Restore button now uses @tui.hard-drive-upload. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: change profile modal design and few other things * Add signed OTA firmware updates (#53) * Port signed OTA update flow from feat/ota-update Manually port the OTA update feature from the (working) feat/ota-update branch onto a fresh branch off master, adapted to master's evolved shape. Diff base for the port is f5af239..feat/ota-update — that's the precise OTA-only delta. Cherry-picking against master would have dragged in the start-os WebServer/TlsListener refactor and other master-evolution noise unrelated to OTA. Backend: - New modules: progress, update, registry/{mod,asset,device_info,os,signer}, sign/{mod,ed25519,commitment}. Copied verbatim — they compile against master after a small Error::other shim in error.rs that wraps Error::new(eyre!(...), ErrorKind::Unknown). - continuations.rs: new WebSocketFuture + WebSocketHandler types, RpcContinuation enum-with-variants conversion, Guid::from_str. - lib.rs: register the four new modules; add signing_key field to ServerContext. - system.rs: real async newer_versions handler (replacing master's stub), semver comparison helpers, new 'update' subcommand. Both update and newer-versions registered with .with_call_remote::<CliContext>() so they're reachable via startwrt-cli, not just the daemon UI path. - daemon.rs: /ws/rpc/{guid} WebSocket route. Registered with any() not get() so HTTP/2 CONNECT (RFC 8441 extended CONNECT) reaches the upgrade extractor — same reason /api/logs uses any(). - error.rs: Error::other(msg) convenience constructor. - Cargo.toml: ed25519-dalek, blake3, url, der, pkcs8, futures-util, form_urlencoded, rand_core_06; reqwest gets +json +stream features. Frontend: - system.service.ts: full rewrite with updating/updateProgress/rebooting signals, WebSocket subscription, post-reboot polling. - api.service.ts + live/mock-api.service.ts: systemUpdate() abstract and impls; FullProgress/NamedProgress/Progress wire types. - settings/general/index.ts: update banner with three-state template (progress / rebooting / button-with-confirm). API_CONTRACT.md: system.update + system.newer-versions + /ws/rpc/{guid}. openwrt submodule: bump to start9/25.12.3-ota with two commits on top of 25.12.3: - spacemit: build sysupgrade image for BPI-F3 (reapplied from 8235008779 which lived on the abandoned 25.12.2 branch) - spacemit: declare SUPPORTED_DEVICES so sysupgrade metadata_check accepts OTAs ('spacemit,k1-x' + 'bananapi,bpi-f3' alongside 'bananapi-f3' to match what board_detect reports) Makefile: image target now produces both pack/sdcard.img AND sysupgrade.img.gz ($(OPENWRT_IMAGES) with grouped target rule). * Remove unused device signing key infrastructure; bump to beta.2 The port from feat/ota-update brought along a per-device Ed25519 key plumbed through ServerContext.signing_key, persisted at /etc/startwrt/device_key.pem (+ /etc/sysupgrade.conf entry to survive flashes), and attached as X-StartOS-Auth-Sig on registry RPC requests. This is dead end-to-end on this codebase: API_CONTRACT.md doesn't document any auth-sig header, the registry doesn't verify it, and firmware integrity is enforced by an independent path (Blake3 commitment + asset-side signers via RegistryAsset::validate). Remove the cruft to shed ~150 LOC of glue, the on-disk key file, the sysupgrade.conf entry, transitive trait methods, and 3 of 5 sign-module tests. What stays untouched (the real security primitives, all with active callers): Blake3Commitment, Digestable, AnyVerifyingKey, AnySignature, AnyDigest, AnyScheme, SIG_CONTEXT, AcceptSigners, RegistryAsset::validate + all_signers, and the verify()/verify_commitment() methods on SignatureScheme. Specifically removed: - update.rs: DEVICE_KEY_PATH, load_signing_key, generate_signing_key, ensure_sysupgrade_conf_entry; signing_key parameter from fetch_newer_versions. - lib.rs: signing_key field on ServerContext (struct + Default). - daemon.rs: load/generate bootstrap block at startup. - system.rs: ctx.signing_key.as_ref() arg in newer_versions handler. - registry/mod.rs: AUTH_SIG_HEADER, SignatureHeader struct + sign/ to_header_value impls, signing_key parameter on call_registry_rpc, the X-StartOS-Auth-Sig header injection block. - sign/mod.rs: AnySigningKey enum + all impls (FromStr, Display, Serialize, Deserialize, scheme, verifying_key); SignatureScheme::SigningKey associated type + sign() + sign_commitment() trait methods; AnyScheme::sign() impl. Three tests deleted (test_sign_and_verify_commitment, test_signing_key_pem_roundtrip, test_signature_pem_roundtrip). Two surviving tests rewritten to derive AnyVerifyingKey via raw ed25519_dalek without the wrapper. - sign/ed25519.rs: Ed25519::sign impl + SigningKey associated type. - sign/commitment.rs: RequestCommitment struct + impls (from_body, to_query_string, from_query, Digestable for RequestCommitment). Also bumps Cargo.toml version to 0.1.0-beta.2 to mark the cleaned post-port state. * Harden K1 OTA path: semver compare, no vector codegen Three independent fixes on the OTA update path: - Version comparison now uses the `semver` crate instead of a `(major, minor, patch)` tuple. The tuple compare stripped the pre-release suffix, collapsing every `0.1.0-beta.N` to `0.1.0` — so an OTA between two betas never registered as "newer". semver honours pre-release precedence (`beta.3 < beta.4 < 0.1.0`). - Drop `+v` (RISC-V Vector) from RUSTFLAGS and the zigcc/zigcxx `-mcpu` strings. K1 implements V 1.0 but traps on misaligned vector-element accesses the Bianbu 6.6 kernel doesn't emulate, so auto-vectorised code (blake3, memcpy, TLS) SIGBUSes with no Rust panic. verify-isa.sh now also fails the build on any `vset*vl*`. - Bump openwrt submodule for "rework K1 sysupgrade to write partitions in place". * Finish OTA update flow: progress fixes, boot confirmation, UI dialog Backend: - progress.rs: fix PhaseProgressTrackerHandle::complete() to flush remaining phase weight. Pre-assigning `contributed` made update_overall's change check skip the contribution, so phases that only start+complete (verify/apply) never counted toward overall. Add tests covering flush-on-complete and partial progress. - update.rs: call download_phase.start() before set_units/set_total — those are no-ops on a NotStarted phase, and start() reset them to None. Add a pending-update marker (/etc/startwrt/pending-update): written before sysupgrade, cleared if sysupgrade returns (failure), and confirmed on the next boot. Log update apply/success/failure to the Activity log. - daemon.rs: check the pending-update marker on normal-mode startup so a completed update is recorded once the new firmware is up. - system.rs: sort newer_versions() by semver precedence. Map iteration was lexicographic, mis-ranking multi-digit pre-releases (beta.10 before beta.9) while the frontend treats the last element as newest. - stage-files.sh: add the pending-update marker to keep.d so it survives the sysupgrade overlay wipe. Frontend: - Add UpdateProgressDialog: a blocking dialog that owns the update lifecycle (kicks off startUpdate, shows a spinner through update and reboot, self-closes on reconnect or start failure). Replaces the inline progress block in the general settings route. - system.service.ts: distinguish a clean update failure (no reboot, surface a toast) from a success reboot; suppress NetworkRestart poll errors for the update window; track whether the device actually went offline to tell a real reboot from a pre-reboot failure, and send the user to login after a confirmed reboot. * build: use npm ci for web build; revert package-lock.json churn The web build recipe ran `npm install`, which is free to rewrite package-lock.json — reconciling it against the registry and re-normalizing the lockfile format across npm versions. Commit 9fb067c carried 89 lines of exactly that churn (peer/optional flag normalization, re-added transitive optional deps) with no matching package.json change, so none of it was an intentional dependency update. - Makefile: switch the $(WEB_DIST) recipe from `npm install` to `npm ci`. `npm ci` installs strictly from the lockfile, never writes to it, and fails loudly if package.json and the lock drift instead of silently rewriting it. Mirrors start-os, which uses `npm ci` for its web build. - web/package-lock.json: revert to the pre-9fb067c state. The file is now byte-identical to master, so feat/ota-port carries zero net lockfile change. * bump start-wrt version in package.json and package-lock.json * build(web): restore optional/peer transitives in package-lock.json 47cbed4 reverted the lockfile to master's pre-9fb067c state, calling the 89 lines 9fb067c added unintentional churn. They weren't: master's lockfile was generated by an older npm that didn't materialize optional/peer-resolved transitives, while npm 10+ does. 9fb067c's `npm install` had correctly captured `@emnapi/core`, `@emnapi/runtime`, and `@types/semver` as peers of already-locked `@emnapi/wasi-threads` and `ng-morph`. `npm ci` (kept from 47cbed4) fails closed on the older-shape lock under any modern npm. Restore the 9fb067c shape, carrying the 0.1.0-beta.2 bump from 966f042 forward. * web(update-dialog): apply PR review cleanups - Replace .update-dialog wrapper with :host styling - Use global .g-secondary utility class for hint color - Swap <p> tags for <div> and drop the margin reset - Remove redundant `closed` latch; completeWith() synchronously destroys the dialog and tears down the effect Addresses review comments #4-#6 and #8 on PR #53. i18n/DialogService threads (#1-#3, #7) deferred to a follow-up; start-wrt has no i18n dictionaries or @start9labs/shared dependency yet. * web(update-dialog): use <small> for secondary hint Drop the <div> wrappers around dialog text — the host is already display: flex, so children lay out directly. Replace the .hint div with a native <small class="g-secondary">, which natively shrinks the font size and makes the custom .hint rule dead CSS. * Route IPv6 through outbound VPNs + migrate fw3→fw4/nftables (#54) * feat(vpn/ipv6): route IPv6 through outbound VPNs [UNTESTED] Profiles whose outbound is a v6-capable WireGuard VPN now route IPv6 the same way they route IPv4, instead of leaking it around the tunnel. Backend (ctrl/profiles.rs): - rewrite_routing emits a v6 leg gated on is_ipv6_enabled() AND the outbound VPN actually carrying v6 (outbound_supports_ipv6). Three new sections per profile: prt6_<iface> (::/0 in the per-VLAN table), prl6_<iface> (lookup main, suppress_prefixlength=0 — escape so cross-VLAN/link-local /64s stay local), and prr6_<iface> (per-VLAN VPN default). Can't reuse IPv4's src=<prefix> matcher since LAN /64s are dynamic under DHCPv6-PD, so we match on logical in-iface instead. - rewrite_dhcp forces ra_default=1 when v6 routes through a VPN (not for plain wan), so odhcpd advertises this router as the IPv6 default even when wan6 has no PD/default route. - reload_system{,_and_wifi} now restart odhcpd so RA/DHCPv6 changes take effect (it caches config in memory). Backend (ctrl/vpn_client.rs): - OutboundVpn gains supports_ipv6, derived from the WG interface having any IPv6 Address. - get_peer_endpoint_host strips surrounding [...] of bracketed IPv6 literals so chain endpoints parse as IpAddr. - rewrite_vpn_chain_routes emits a route6 /128 for IPv6 endpoints (was IPv4-only /32). Backend (uciedit/openwrt.rs): - New NetworkRoute6 / NetworkRule6 typed sections and Dhcp.ra_default. Build: enable ip6tables-mod-nat + kmod-ipt-nat6/nf-nat6. IPv6 SNAT is done out-of-band by /etc/firewall.startwrt-masq6 since fw3 has no masq6 UCI option. API: OutboundVpn.supports_ipv6 added to API_CONTRACT.md, api.service.ts, and mock-api.service.ts. Adds 9 unit tests covering the v6 routing gates, cleanup on outbound switch, bracket stripping, and route6 emission. End-to-end behavior on hardware is unverified. * fix(network): generate per-device ULA prefix instead of hardcoded /48 The firstboot network config shipped a hardcoded ULA prefix (fda7:5549:a8c::/48), so every device used the same ULA. Chaining start-wrt routers then collides: the WAN side learns the same /48 and shadows the LAN route, black-holing reverse-NAT'd replies such as IPv6 VPN return traffic. Set `option ula_prefix 'auto'` so the 12_network-generate-ula uci-default generates a unique random /48 per device at first boot (RFC 4193). * feat(firewall): migrate fw3/iptables → fw4/nftables; dedicated VPN egress zone Switch the image's firewall from fw3 (iptables) to fw4 (nftables) and rework VPN outbound routing to fit fw4's native feature set, replacing two iptables-era workarounds. Build: - Swap firewall→firewall4; drop ip{,6}tables + xtables packages and kmod-ipt-* / kmod-nf-nat6 in favor of kmod-nft-* (core/fib/nat/offload), libnftnl, nftables-json. - Bump openwrt submodule to ce8b3a0 (kmod-crypto-crc32c rename for 6.18), required for the nftables ruleset to build. VPN egress zone (ctrl/profiles.rs): - Replace "stuff the wg interface into the wan zone + out-of-band /etc/firewall.startwrt-masq6 ip6tables script" with a dedicated `vpn_<wg>` zone carrying masq=1 AND masq6=1. fw4 has a native masq6 UCI option, so NAT66 on VPN egress no longer needs an include script, and wan6's GUA path / inbound port-forwards stay untouched. - ensure_vpn_outbound_zone creates/maintains the zone; resolve_outbound_zone maps an outbound to its zone name ("wan" or "vpn_<wg>"). - rewrite_firewall now targets per-profile wan-access forwardings/rules at the resolved outbound zone instead of always "wan". - cleanup_orphaned_wan_vpns → cleanup_orphaned_vpn_zones: tears down orphaned `vpn_<X>` zones plus any forwardings/rules referencing them, and still strips stray pre-migration wg entries from the wan zone. DNAT-return marking: - fw4 has no UCI equivalent for `-m conntrack --ctstate DNAT`, so the per-profile mangle MARK rule moves to a static nftables chain shipped at /etc/nftables.d/10-startwrt-dnat-mark.nft (auto-included into inet fw4). The daemon now only ensures the matching `ip rule` (dnat_return → main). - Drop the now-unused FirewallRule.extra UCI field. - stage-files.sh copies backend/nftables/*.nft into /etc/nftables.d. Schedules: - Window-start/REJECT rules now target the profile's egress zone ("wan" or "vpn_<wg>") instead of hardcoded "wan", and a profile outbound change rewrites + restarts the schedule crontab so the next blackout boundary doesn't REJECT toward a stale zone. Tests updated for the dedicated-zone layout and the removal of the per-profile dnat_mark rule. Comments referencing fw3/iptables refreshed to fw4/nftables. End-to-end behavior on hardware is unverified. * feat(vpn): fail-closed kill switch for VPN-routed profiles Give the per-VLAN `dev <wg>` default route a low metric (1) and add an `unreachable` fallback default on loopback at a high metric (2048), for both v4 (prtb_) and v6 (prt6b_). While the tunnel is up the dev route wins; the moment the WG interface drops, the fallback catches traffic with ENETUNREACH instead of letting the ip rule fall through to the main table and leak out WAN. Install the v6 policy-routing rules (prl6_/prr6_) for every VPN-routed profile, independent of global IPv6 state or whether the VPN carries v6, so v6 always fails closed. The `dev <wg>` v6 default (prt6_) is still only added when the outbound actually carries v6. Add `metric` and `type` (kind) fields to NetworkRoute and `type` to NetworkRoute6 in uciedit to support metric-ordered and `unreachable` routes. * fix(vpn): rebuild WAN forwarding when a VPN is deleted or disabled After resetting an affected profile's outbound to "wan", re-apply its full config via the new profiles::reapply_profile_config (firewall + dhcp + dns + routing) instead of only rewrite_routing + rewrite_dns_forwarding. The old path left the profile's forwarding pointing at the torn-down vpn_<wg> zone with no `<zone> → wan` rule, so fw4 dropped all of its WAN traffic. Also run cleanup_orphaned_vpn_zones on the disable path, matching the delete path. * feat(vpn/ipv6): route inbound port-forward replies via wan6, not the VPN A device whose profile routes ::/0 through an outbound VPN must still be reachable on its native wan6-PD GUA via an IPv6 published port. The per-profile v6 policy rule (prr6_<iface>) captures the device's reply traffic too, so replies to externally-initiated connections would egress the VPN instead of wan6 — asymmetric routing, connection fails. IPv6 port-forwards are pure filter ACCEPTs (routable GUA, no DNAT), so unlike IPv4 there's no `ct status dnat` to key on. Instead, a new static nftables chain (11-startwrt-inbound6-mark.nft) connection-marks IPv6 flows initiated from WAN — defined by exclusion of the LAN bridge and WG tunnel interfaces, so it's immune to which physical port is WAN — and restores that mark onto every packet of the flow. The daemon ensures the matching ip6 rule (network.dnat_return6, fwmark 0x80 -> main, priority 100) for every VPN-routed profile, sitting ahead of prl6_ (150) and prr6_ (200) so marked replies leave via wan6. - profiles.rs: add ensure_dnat_return6_rule(), called from rewrite_routing alongside the v4 sibling; two tests covering VPN- vs wan-routed profiles - nftables/11-startwrt-inbound6-mark.nft: new prerouting/mangle chain * feat(vpn/ipv6): assign per-VLAN ULA /64 to v6-capable profiles Previously only the admin LAN got an ip6assign; profile interfaces had it stripped unconditionally, so non-admin VLANs never received IPv6. Now ipv6_set and profile create/edit sync each non-admin VLAN's ip6assign to its IPv6 eligibility: a profile whose outbound VPN carries v6 gets a /64 (a ULA carved from the device prefix, NAT66'd out the vpn_<X> zone's masq6), while a profile on a v4-only VPN gets none so v6 can't leak outside the tunnel. Supporting fixes: - set_config no longer clobbers the admin LAN's ip6assign. That prefix is owned by lan::ipv6_set (the LAN IPv6 page) and uses the user-configured value; editing the admin profile was resetting /60 -> /64. - Profile create/edit now does `network restart` rather than `reload` (reload_system_full / reload_system_and_wifi_full). netifd only recomputes IPv6 prefix distribution on a full restart, so a reload left a newly v6-eligible profile without its delegated /64 and odhcpd with nothing to advertise. Adds regression tests for the ip6assign sync, the admin-LAN prefix preservation, and documents a known limitation (TODO ipv6/nat66): a wan-routed non-admin profile on a /64-only ISP still has no v6 internet path, since the single GUA /64 goes to the admin LAN and the wan zone has no masq6. * fix(firewall): scope default-mode remote-access rules to private/ULA sources Default-mode remote access emitted ACCEPT rules for 80/443/22 restricted only by address family, with the private-vs-global decision made once at apply time from the current WAN address. The rules themselves carried no source restriction, so if a global address later appeared on the WAN (prefix change, DHCP lease swap, IPv6 GUA) without config being re-applied, those rules would expose the ports to the whole internet. Scope each default-mode rule by `src_ip` instead, so a globally-routable client can never match regardless of what address lands on the WAN: - IPv4: one rule per RFC1918 range (10/8, 172.16/12, 192.168/16). fw4's `option src_ip` holds a single value, so each range needs its own rule; a name suffix keeps the generated section names unique. - IPv6: the ULA supernet fc00::/7. `always` stays unscoped — it's the explicit opt-in to full exposure; `never` still opens nothing. Rework REMOTE_ACCESS_PORTS from (name, port) pairs to bare ports, since section names are now derived per rule (port + optional family suffix). Tests updated for the new rule counts (default IPv4-only 3->9, both families 12, etc.) and to assert the src_ip scoping per family. * feat(devices): persistent name cache; resolve display name server-side (#55) * feat(devices): persistent name cache; resolve display name server-side dnsmasq's lease file is RAM-backed and drops a client's hostname on lease expiry, dnsmasq restart, or renewals that omit DHCP option 12. Such devices reverted to a `device-<mac>` placeholder in the UI until they happened to re-advertise their name. Add a persistent, MAC-keyed cache (device_names.rs) that remembers the last DHCP-advertised hostname per device. It sits below the live UCI and DHCP-lease sources in the resolution chain, so a fresh live name always wins and the placeholder only ever shows for a never-seen device. - Cache holds DHCP-learned names only; authoritative UCI static names live in /etc/config/dhcp and are never cached or pruned. - Stored as JSON written atomically (temp + rename) so an external tar/read during `sysupgrade --create-backup` sees one complete doc; in-memory map is authoritative, disk rewritten only on change. - 60d retention and a 1000-entry cap (oldest-first by last_seen); last_seen bumps on unchanged entries are rate-limited to 1h to gate disk writes, so a quiet, stable network re-serializes at most hourly. - Listed in keep.d so it survives sysupgrade; dropped on `forget`. devices.list now resolves the full name chain (UCI -> DHCP -> cache -> placeholder) server-side and returns a non-optional `name`. The frontend stops generating names client-side; `DeviceFromApi.name` and `Device.name` become required. Updates API_CONTRACT.md and the mock service to mirror the server-side chain. * fix(devices): touch last_seen at most daily, not hourly The device-name cache bumped an unchanged entry's last_seen (and so rewrote the JSON to disk) at most once an hour. Against a 60-day retention window that cadence is far more disk churn than the LRU needs: a quiet, stable network was re-serializing the whole map every hour for no behavioral gain. Raise TOUCH_INTERVAL_SECS from 1h to 24h. New and renamed devices still flush immediately (they bypass the interval gate), so only the periodic keep-alive touch slows down; 24h of last_seen staleness against a 60-day prune cutoff cannot cause premature eviction. * fix(profiles): allocate interface ids that avoid reserved UCI names (#56) * fix(profiles): allocate interface ids that avoid reserved UCI names Interface id allocation only de-duplicated against live kernel netdevs via `ip link show <id>`. But a profile's interface id never becomes a literal netdev — the kernel device is `br-lan.<vlan>` — so that check never caught UCI-level collisions. Two profile names that sanitize to the same 5-char prefix, or a renamed-then-recreated name whose id is still reserved, would collide and hard-fail with InterfaceNameConflict. Gather the ids already in use from UCI (network interface sections plus startwrt profiles) before allocation, and have allocate_interface_name avoid them, falling back to a random id. Interface ids are opaque and never surface in the UI (profiles display fullname), so the random fallback carries no UX cost, and ids stay stable across renames. Also harden hint sanitizing: concatenate all alphanumerics instead of taking only the first segment, and drop leading digits (UCI ids map to shell variable names, which can't start with a digit). Mirror the same collision-safe allocation in the mock API. * fix(profiles): always randomize interface ids instead of seeding from name Interface id allocation previously seeded from the profile name and only fell back to random when the sanitized hint was empty or already taken. Since the id is opaque and never surfaces in the UI (profiles display fullname), there is no UX benefit to a name-derived id, and the seeding logic was the source of the collision the prior commit had to work around. Make allocate_interface_name always generate a random id, dropping the hint parameter and the sanitize_interface_hint helper. Keep the 'taken' set: a random 5-char id can still collide with a reserved UCI id (lan, wan, wg_*, another profile), which would hard-fail in create_config, so the allocator still retries against taken ids and live kernel netdevs. Mirror the same always-random allocation in the mock API. * Refactor/start os conventions (#57) * fix(cli): send JSON not CBOR for CLI→daemon RPC calls Regression from pulling start-os in as a submodule: it depends on rpc-toolkit with default features (`default = ["cbor"]`), and Cargo feature-unification then enabled `cbor` for the whole build. That silently switched `call_remote_http` to encode request bodies as CBOR, but the rpc-toolkit HTTP server only parses JSON — so the body fails to parse before any handler runs. The breakage wasn't noticed at the time. Replace `call_remote_http` with a hand-rolled POST that always speaks JSON, mirroring start-os's `signature::call_remote`. Auth still rides on the client cookie store (loopback / local auth cookie), so no signature header is needed. Also surface non-2xx HTTP responses as a clear Network error rather than feeding the (likely non-JSON) body to the JSON-RPC parser, since the server returns RPC-level errors as 200 + a JSON-RPC error body. * fix(ssl): give each Root CA a unique Subject DN to avoid browser collisions Browsers key trusted CAs by Subject DN. Every fresh-flashed build minted a Root CA with an identical DN but a different key, so Firefox/NSS verified the new chain against the old trusted CA's key and rejected it as SEC_ERROR_BAD_SIGNATURE ("Bad Signature"). Append a short random hex token (random_ca_suffix) to the Root CA CN at generation time so each minted CA is a distinct trust anchor — an old trusted CA now coexists harmlessly with a new one. Mirrors start-os, which embeds its per-device random hostname in the Root CA CN. * refactor(ssl): bake Root CA suffix into branding at construction Pass the per-CA random suffix into `startwrt_branding(root_ca_suffix)` instead of generating a base branding and then mutating `root_ca_cn` afterward in `generate_root_ca`. This mirrors start-os's `CertBranding::start_os(hostname)`, which embeds its per-device value in the CN at construction time. Intermediate/leaf generators pass `""` since they never read `root_ca_cn`. Tighten the test assertions: verify the Root CA CN carries the base label, and add `test_generate_root_ca_unique_subject_dn` to guard that two freshly-minted Root CAs get distinct Subject DNs (the collision that surfaces as "Bad Signature" on a reflashed device). No behavioral change to issued certs. * feat(schedule): support overnight windows + reject overlaps/equal times (#58) * feat(schedule): support overnight windows + reject overlaps/equal times Schedule windows (WiFi blackout and profile WAN) can now cross midnight: when end_time < start_time (e.g. 22:00-06:00) the window runs from start on its selected day(s) through end the following day. The closing cron edge (wifi up / firewall unblock) is shifted forward one weekday so it fires on the correct day, and the boot/reload reconciler (evaluate_and_apply_schedules) is made wrap-aware via a new window_contains helper gated on the previous day's mask for the after-midnight tail. Validation: equal start/end is rejected (ambiguous 0h/24h), and windows that overlap on the wrap-aware weekly timeline are rejected on *-set with InvalidValue. Overlap/shift/cron-day logic is factored into shared helpers in wifi.rs (windows_overlap, days_to_cron, shift_days_forward) reused by profiles.rs, with unit + async tests covering wrap, overlap, and equal-time cases. Frontend: the schedule timeline renders a wrapping window as a head block on its own day and a tail block on the next; blocks are edit-only (no drag/resize). The add/edit dialog allows end < start, rejects equal times, and runs a mirrored windowsOverlap check (web/src/app/utils/schedule.ts) to warn before submit. Time inputs now use 12-hour HH:MM AA display. Also adds a TODO noting WiFi blackout has no boot-time reconciler (unlike profile schedules), so an active overnight blackout is not reasserted across a reboot until the next cron edge. Updates API_CONTRACT.md and the mock API to document/exercise the new wrap and overlap semantics. * refactor(schedule): show per-block start/end times, single-click edit Each schedule block now displays its own range's start time at the top and end time at the bottom, replacing the ellipsis icons + full-window time that read identically on both halves of a wrapping window. Head ends at midnight, tail begins at midnight. Switch the edit gesture from double-click to single click and drop the now-removed `windowTime`/`getTime` helpers in favor of a `block()` factory that precomputes the formatted strings. Also: - Format a 24:00 (midnight) end as "12:00am" instead of "11:59pm". - Drop the "Are you sure?" confirmation dialog from window removal. * chore: fix schedule visuals a bit * chore: help text * chore: fix cards appearance * fix(schedule): render overnight window as one continuous segment An overnight window (e.g. 10pm Mon-6am Tue) splits into a head block on its own day and a tail block on the next. Both halves were labeling the shared midnight boundary, so the window read as two separate segments. Drop the head's midnight end label and the tail's midnight start label, leaving only the real start on the head and real end on the tail. Position the labels by their .start/.end class instead of :first-child/:last-child so a lone label still lands at the correct edge and keeps its backdrop band (a single remaining span otherwise matched both and stretched full height). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(schedule): allow equal start/end as a full 24h window Previously a window with end == start was rejected as ambiguous. Now it denotes a full 24-hour window (e.g. 09:00-09:00 next day), reusing the existing wrap-past-midnight machinery: end <= start spans into the next day, shifting the unblock/wifi-up edge forward by one weekday. Backend (profiles.rs, wifi.rs): - window_contains / windows_overlap / crontab regen treat end <= start as wrapping; drop the equal start/end rejection in schedule_set and blackout_set - add test_window_contains_full_day Frontend (schedule.ts, window.ts, schedule.ts util, blackout.html): - mirror end <= start wrap logic in windowsOverlap and block rendering (a midnight-start 24h window has no tail) - end-time picker offers a trailing 12:00 AM to close at end-of-day - drop the equalTimes validation error; update help text * fix(schedule): show end time for windows ending at midnight A window ending exactly at midnight (end == 0) closes on its own day and has no tail in the next column, yet it was rendered as a "head" block — whose end label is hidden so true overnight windows read as one segment. The result: such a window showed its start but never its 12:00am end (e.g. a 9:00pm-midnight block, or a full 24h midnight-to-midnight one). Distinguish "genuinely spills past midnight" (end > 0, splits into head + next-day tail) from "ends at midnight" (end == 0, stays a single "whole" block that keeps both labels). Only the former drops its boundary labels. The label-hiding was introduced in d80e86d; this surfaced more visibly once equal start/end 24h windows became allowed. * feat(schedule): deconflict cron edges and persist blackout windows in UCI Schedules built from adjacent or consecutive windows could race at a shared cron tick (one window's "up" edge firing at the same minute as the next window's "down" edge), briefly unblocking the resource. Project windows into deconflicted down/up edge maps keyed by minute-of-day and annihilate coincident down+up weekday bits, so back-to-back windows stay continuously blocked with no same-tick race. A fully-tiled week produces zero surviving edges, leaving cron nothing to execute, so reject full-week-no-gap coverage up front in both blackout_set and schedule_set (FE warns before submit via coversFullWeek). Move WiFi blackout windows into UCI (config wifi_blackout 'blackout') as the source of truth; the crontab becomes a disposable projection regenerated from UCI by regenerate_blackout_crontab. This drops the brittle round-trip that parsed windows back out of cron lines, and lets deconfliction merge/drop edges without losing the underlying windows. Factor the window serialize/parse/projection logic into shared wifi.rs helpers (serialize_windows, parse_windows, windows_to_minutes, deconflict_edges, covers_full_week) used by both the WiFi blackout and profile WAN-schedule paths; malformed entries and unparseable times are now dropped with a warning instead of silently. FE extracts toWeekSegments shared by windowsOverlap and the new coversFullWeek. Known gap (TODO retained): blackout is still edge-triggered with no boot-time reconciler, so a reboot mid-blackout won't reassert radio state until the next cron edge. * feat(schedule): reassert WiFi blackout on boot if inside active window WiFi blackout was edge-triggered by cron only. A reboot mid-window lost the edge: netifd raises the radios early in boot per the on-disk `disabled` flag (runtime `wifi down` doesn't persist), so WiFi came back up despite being inside an active blackout, staying up until the next cron edge. Add `reconcile_blackout_at_boot`, modelled on `profiles::evaluate_and_apply_schedules`. It recomputes the current in/out-of-window state (wrap-aware, reusing `window_contains`) and reasserts `wifi down` when inside a window. The out-of-window case is a deliberate no-op so we never re-enable a radio the user disabled. It runs after `restore_wifi_if_needed` so our `wifi down` is the final word over restore's `wifi reload`. The wrap-aware decision is split into a pure `windows_contain_now` helper with unit tests (non-wrap, overnight wrap, multiple/malformed, empty). Makes `window_contains` and `chrono_now` pub(crate) for reuse. This closes the steady-state gap, not the boot-window gap (netifd START=20 vs daemon START=99); that's documented as a follow-up TODO. * feat(schedule): regenerate cron projections from UCI on boot /etc/crontabs/root is a disposable projection of the UCI schedule stores and is wiped by sysupgrade (the stores persist), so schedule edges would stop firing after an upgrade. On boot, after the mid-window reconcilers run, rebuild both projections — WAN schedule and WiFi blackout — from UCI and restart cron once. This is a no-op when there are no windows and self-heals crontab drift on any boot. Both regenerators strip only their own tagged lines, so running them in sequence over the shared file is safe. --------- Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(lan): let users pick the /16 (second octet) within each RFC 1918 block (#59) * feat(lan): let users pick the second octet within each private block The LAN IPv4 form previously hardcoded the second octet per first octet (192->168, 10->0, 172->16), collapsing each RFC 1918 range to a single /16 and hiding the other 255 (10/8) and 15 (172.16/12) selectable blocks. Make the second octet a form control with per-block bounds: 192.168.0.0/16 -> locked to 168 (one /16) 172.16.0.0/12 -> 16..31 10.0.0.0/8 -> 0..255 The field is read-only for 192 and editable elsewhere; switching the first octet re-applies the block's min/max and snaps any out-of-range value back in. The wire contract is unchanged (the full address string already carried the octet). saveBlocked now also treats a second-octet change as a subnet change for the static-IP guard. * fix(lan): enforce RFC 1918 block boundaries server-side ipv4_set parsed any IPv4 and applied it with a /24 netmask, with no RFC-block validation — the per-/16 restriction was cosmetic (frontend only). The daemon RPC (and vestigial generic uci.set) would accept 8.8.8.8, out-of-range 172.x, any 192.x second octet, etc. Add validate_lan_block (10/8, 172.16/12, 192.168/16 with the same second-octet bounds the UI exposes) and call it before any config write. The admin (owns_lan) profile is a second path that sets the LAN /16, and non-admin profiles were unconstrained — an out-of-block VLAN subnet would escape the chosen range and break sync_cross_subnet_routes (which assumes siblings share the first two octets). Add validate_profile_block to set_config/create_config: admin must be a valid RFC 1918 selection; others must share the admin LAN's /16. All failures are ErrorKind::InvalidRequest. Documents the rules in API_CONTRACT.md (lan.ipv4-set, profiles.*). * test(lan): cover non-192.168 RFC 1918 block validation Add validate_profile_block_accepts_alternate_rfc1918_block, asserting a LAN in 10.42.0.0/16 accepts siblings within the block and a valid admin selection while still rejecting a sibling that escapes the block. Locks in the server-side boundary enforcement from 30fc874 for non-192.168 private blocks. Derive Debug on OldProfileState so test assertions can format it. * fix(lan): flag out-of-range second octet instead of silently snapping The second-octet field previously clamped any out-of-range value back into the active block's range, silently rewriting what the user typed. Replace the min/max validators with a dedicated block validator that flags the value instead, so saving is blocked and the allowed RFC 1918 range is surfaced to the user. - utils.ts: add secondOctetBlockValidator + isSecondOctetInRange; keep clampSecondOctet only for load-time normalization in parseIpToForm. - form/ip.ts: render the 192 block as a disabled display field, all other blocks as an editable number input with a signal-driven error hint that tracks both octets; swap the validator (not the bounds) on block change, only force-setting the value when there's a single legal choice (192 -> 168). - index.ts: extend saveBlocked to reject an out-of-range second octet and report the allowed min-max. * refactor(lan): apply PR #59 review feedback on the IPv4 block form Behavior-preserving response to the PR #59 reviews. The second-octet picker keeps the same per-block bounds, validation, and wire output — only the implementation is brought in line with the idioms the reviewers asked for, and the backend RFC 1918 check is simplified. - lan.rs: replace the hand-rolled match in validate_lan_block with Ipv4Addr::is_private(), which encodes exactly the same three blocks (10/8, 172.16/12, 192.168/16). Identical semantics; the existing validate_lan_block_* tests still pass. - form/ip.ts: remove the imperative effect (setValidators + force setValue) and the $-suffixed signal names. Derive the octet signals with tuiControlValue, bind the per-block validator declaratively via [tuiValidator], and resolve the locked 192 octet for display rather than mutating the control. Keep a display-only secondOctetError computed so the allowed-range hint still appears immediately on a block switch. - utils.ts: add resolveSecondOctet (collapses the single-value 192 block to its fixed octet) and route buildNetworkBlock/buildRouterIp through it; reduce the static secondOctet validator to just `required` (the block range is now applied by [tuiValidator]). - index.ts: resolve the second octet in saveBlocked before the range check and the subnet-change comparison, so the locked block's carried-over value is never falsely flagged. * Implement i18n - Translate the web UI into 5 languages (en/es/de/fr/pl) (#60) * feat(i18n): translate web UI into 5 languages (en/es/de/fr/pl) Port start-os's translation engine to localize the entire web frontend. Engine (web/src/app/i18n/): - i18n.service.ts: language switcher extending Taiga's TuiLanguageSwitcherService; maps POSIX locales (en_US, es_ES, …) to Taiga language names and lazy-loads the active dictionary. - i18n.providers.ts: I18N signal + I18N_LOADER injection tokens; wires Taiga's own widget strings and our dictionaries behind dynamic imports. - i18n.pipe.ts (`| i18n`): translates English keys via the active dict, falling back to the English key itself. - localize.pipe.ts (`| localize`) + locale-string.ts: render rich LocaleString values (plain string or per-locale map), mirroring start-os's T.LocaleString. - validation-errors.ts: provideTranslatedValidationErrors() routes <tui-error> messages through the pipe, with tpl() for interpolated templates; re-translates live on language change. Dictionaries: en.ts (source of truth, id->key) plus es/de/fr/pl, each lazy-loaded. Help content: replace the per-topic .html files with per-language TS modules (help/content/{en,es,de,fr,pl}.ts); update help.ts and modal-help.ts to resolve content by route and active language. Tooling: - scripts/check-i18n.mjs: validates that every `| i18n` / i18n.transform key exists in en.ts, every id is present in all dictionaries, and every help route is translated; run from the pre-commit hook. - package.json / angular.json wiring; utils/languages.ts defines the 5 supported languages with endonyms. Wiring: app.config.ts registers I18N_PROVIDERS; header adds a language switcher. Migrate all routes, components, and services to the i18n / localize pipes. * fix(i18n): apply saved theme/language globally and revert unsaved previews Treat saved system settings as the source of truth for both theme and language. The app-level effect now applies theme alongside language whenever system info loads or changes (boot and after Save), so the two preferences stay in sync with persisted state. On the General settings page, theme and language are previewed live on selection. If the user navigates away without saving, the DestroyRef hook now reverts both previews to the saved settings; a saved (pristine) form makes this a no-op. Mark the form pristine after a successful save so leaving the page afterwards doesn't trigger a spurious revert. * refactor(i18n): resolve help content to plain strings, drop LocalizePipe Help content is now resolved to the active language directly inside HelpService.content (returning Record<string, string>), instead of emitting LocaleString maps that are resolved later in the template via the `localize` pipe. This removes the indirection layer added for rich-content i18n: - delete LocaleString type and LocalizePipe - drop i18nService.localize() and the unused `loading` signal - header search filters on already-resolved strings - aside/modal-help templates drop the `| localize` step * rebase openwrt fork on 25.12.4 (#63) * fix(fonts): bundle Proxima Nova so the brand typeface actually loads (#62) * fix(fonts): bundle Proxima Nova so the brand typeface actually loads styles.scss overrode Taiga's --tui-typography-family-{text,display} to 'Proxima Nova', but the font was never shipped — no @font-face, no font files — so the UI silently fell back to system-ui and the brand typeface never loaded. Ported from start-os, which overrides the same vars but also ships the font. - Add the 7 Proxima Nova weights (100-900) under web/assets/fonts/Proxima_Nova/, served at /assets/fonts/. - Add matching @font-face declarations in styles.scss (mirrors start-os shared.scss). - Set font-family: 'Proxima Nova', system-ui on tui-root in main.ts (mirrors start-os app.component). The Taiga family vars only style text that uses Taiga typography tokens; this base rule makes all inherited text render in the brand font too. Visually subtle on Linux (system-ui is metrically close to Proxima Nova) but clearly different on macOS/Windows/mobile where system-ui differs. * fix(fonts): drop redundant font-family on tui-root Taiga components resolve their font from the --tui-typography-family-text and --tui-typography-family-display vars, which styles.scss already sets to 'Proxima Nova'. A raw font-family on the tui-root element is overridden by Taiga's typography tokens anyway (headings use the display var), so it added nothing the CSS vars don't already cover. Rely on the vars as the single source of truth so the brand font applies to both body text and headings. Addresses PR review feedback. * fix(fonts): ship only Proxima Nova 400 and 700 All Start9 designs use only normal and bold weights. Limiting the bundled faces to 400/700 keeps the UI on-spec — intermediate weights snap to the nearest shipped face instead of loading a distinct glyph. Drops the unused Thin/Light/Semibold/Extrabold/Black woffs. Addresses PR review feedback. * Fixes/ethernet devices vpn (#64) * fix(ethernet): use `firewall reload` after eth0 reassignment to avoid lockout Reassigning eth0 to a different profile ran `firewall restart` fire-and-forget after the network reload. The full fw4 table flush, under the default `input REJECT` policy, opened a window that (racing netifd's bridge work) intermittently locked out all management access until a manual reboot. netifd's `network reload` already applies the bridge VLAN/PVID change live via RTM_SETLINK/RTM_DELLINK netlink, and a port-VLAN move changes no zone<->interface binding, so an incremental `firewall reload` is sufficient. * fix(devices): list bridge-FDB-learned clients with no DHCP lease A device with an L2 link to the bridge but no DHCP lease and no IP-neighbor entry -- e.g. a static-IP or IPv6-only host reached through an external switch -- was omitted from devices.list entirely. Fold FDB-learned MACs into the membership set and mark them Online/Ethernet so physically-connected devices are visible. The bridge FDB ages (~300s default), so a just-unplugged device may linger briefly, which is preferable to a connected device never showing. * fix(dhcp): read all per-profile dnsmasq lease files, not just the base Profiles using custom or VPN DNS run their own dnsmasq instance writing /tmp/dhcp.leases.dns_<iface>; the base /tmp/dhcp.leases only holds the main instance's clients. The device list, the published-ports IPv4 fallback, and the lease flush/cleanup paths all read only the base file, so those clients were missing their DHCP hostname and lease IPv4. Add shared helpers (dhcp_lease_files / read_all_dhcp_leases) and route every reader through them. * feat(devices): recover device names via reverse mDNS Some devices never advertise a hostname via DHCP option 12, so they never land in the lease file and show up only as `device-<mac>`. They do still answer mDNS, so reverse-resolve their IPv4 over Bonjour to recover a display name. For any present, reachable device that no live source (UCI host, DHCP lease) or the name cache can name, query `avahi-resolve -a <ip>` against the local avahi daemon. Lookups are bounded — 1.5s timeout with kill_on_drop per query, fan-out capped at 8 concurrent — so a large LAN or a non-responder can't stall the device list. Hits are persisted to the existing name cache, so each device is queried at most once and a steady-state network produces no targets (no-op). The name cache now holds both DHCP- and mDNS-learned names, so rename `Observation::dhcp_hostname` to `hostname` to match. mDNS sits below DHCP and above the cache in the resolution chain. Enable the `avahi-utils` package (provides `avahi-resolve`); avahi-dbus-daemon was already enabled. * fix(devices): prefer static reservation IP over live ARP/lease When a device has a static IP reservation (UCI `host.ip`), surface that address as its `ipv4` rather than the live ARP neighbour or DHCP lease. The reserved address is the one the device is pinned to, so this stops the edit form from snapping back to the stale DHCP address after a reservation is saved — the client keeps its old lease (and thus its old ARP/lease entry) until it renews. Falls back to ARP, then the DHCP lease, when no reservation is set. * fix(devices): report the live IP/profile when a device roams VLANs A device that moves to another security profile picks up a lease on the new VLAN bridge but leaves a stale neighbor entry on its old one. Both entries share the MAC, and devices.list keyed everything by MAC then took the first ARP entry it found — so it reported the abandoned IP and the wrong security profile until the kernel aged the stale entry out. Use the active probe that already runs as ground truth: ping_unreachable_macs now also returns the IPs that actually replied (live_ipv4s) instead of collapsing to a per-MAC boolean. A new choose_ipv4_entry ranks a MAC's IPv4 neighbor entries — REACHABLE > probe-confirmed > DELAY/PROBE > STALE — so a confirmed-live STALE entry beats a stale-but-DELAY one (ranking on neighbor state alone would pick the wrong address). The displayed IPv4 and the VLAN-derived profile now both come from that single chosen entry, so they can't disagree, and the mDNS reverse-resolve target uses the same chooser so an unnamed roamed device is queried at its current address. Adds unit tests for the chooser (probe-confirmed-over-fresher-state, REACHABLE preference, IPv6/empty handling, deterministic tie-break). Status semantics and static-reservation precedence are unchanged. * fix(devices): cap mDNS reverse-resolve at one attempt per device per run The mDNS name-recovery pass was gated only on the name cache, so a device that suppresses DHCP option 12 *and* never answers Bonjour was re-queried on every poll — it never lands in the cache, so nothing stopped the retry. On a network with such devices, each list() call paid the avahi-resolve cost repeatedly. Track attempted MACs in a new MDNS_ATTEMPTED set: a device that answers is persisted to the name cache (gated out by the cache check); one that stays silent is recorded in MDNS_ATTEMPTED (gated out by the set). Either way a MAC is reverse-resolved at most once per daemon run. The set is cleared only on daemon restart, so a device that later starts answering Bonjour is picked up after the next restart. The lock is held only across the synchronous selection loop — no .await inside — and released before resolve_mdns_names(). * fix(vpn): route cross-profile peer replies through the tunnel, not the LAN bridge Two related gaps in inbound-VPN reachability across profiles: 1. Cross-profile peer routing. sync_peer_policy_routes only adds a peer's /32 to its own profile's policy table, while sync_cross_subnet_routes adds a sibling's whole /24 via the LAN bridge. In a vpn-routed sibling's table a peer IP then matches the /24-via-bridge route, so replies are sent onto the LAN bridge instead of into the WireGuard tunnel and the connection breaks. Add sync_vpn_peer_cross_routes, which installs a more-specific /32 via the peer's wg_<P> interface (named vxr_*) into every OTHER vpn-routed profile's table, overriding the /24. Recomputed idempotently and wired into every apply site that touches profiles or VPN servers (profiles create/set/delete, vpn_client delete/set_enabled, vpn_server set/delete/peer_add/peer_delete/remove). These routes only correct the L3 path; reachability stays governed by the firewall. 2. LAN-only client AllowedIPs. A "LAN only" peer previously got only the profile's own /24 in AllowedIPs, so it couldn't reach the other profiles that profile's lan_access permits. Add lan_only_allowed_ips, which builds the split-tunnel list from the profile's outbound lan_access (SameProfile / OtherProfiles / All). Computed before dump_all consumes cfgs in peer_add. Known gap documented inline (TODO reverse-parity): a profile permitted to initiate INTO a LAN-only peer's profile still can't reach the peer, since WireGuard drops inbound packets sourced outside the peer's AllowedIPs. * feat(vpn): give inbound VPN server peers first-class IPv6 When a profile serves IPv6 to its clients, inbound WireGuard server peers now get a stable v6 address alongside their v4 /32, instead of being v4-only. Peers can't sit in the profile's own /64 (DHCPv6-PD assigns it at runtime, non-deterministically) while WG client configs are issued once and must be static. So carve a dedicated, stable /64 from the device ULA /48 (network.globals.ula_prefix) using a high subnet-id band (0xf000 | vlan_tag) that stays clear of odhcpd's low sequential assignments: - wg_server_v6_groups() derives the /64, gated on the profile actually serving v6 (is_ipv6_enabled + outbound_supports_ipv6) and a concrete ULA prefix existing (None pre-first-boot when ula_prefix is still 'auto'). - set_wireguard_interface / add_single_peer give the interface its <wg64>::1 and each peer <wg64>::<v4-octet>, as a /128 in allowed_ips so route_allowed_ips installs it in the main table (how the router and sibling profiles reach the peer over v6 - no proxy_ndp needed). - peer_add writes the v6 /128 into the client config's Address and, for LAN-only peers, adds the device ULA /48 to AllowedIPs (v6 lan_access is enforced at the firewall; we can't scope to runtime-assigned sibling /64s). - sync_peer_policy_routes installs vsl6_/vsr6_ ip rules mirroring prl6_/prr6_ but keyed on 'in: wg_<X>', so peer v6 escapes locally for cross-VLAN/own-LAN and otherwise follows the per-VLAN tunnel - never leaking out wan6 on a vpn-routed profile (a v4-only tunnel drops it on the kill-switch). - ensure_server_v6_address keeps the interface consistent on peer_add if v6 was toggled on after the server was created. Also fix get_vpn_peer_configs / get_peers_for_interface to parse the v4 host from allowed_ips and ignore the trailing v6 /128, so the device list reports the IPv4 instead of letting the v6 entry clobber it. Adds NetworkGlobals to uciedit and exports VPN_ROUTING_PRIORITY / VPN_ROUTING_V6_LOCAL_PRIORITY from profiles for the new rules. * fix(ethernet): keep Admin VLAN 1 alive when its last port is reassigned Reassigning the last Admin (VID 1) ethernet port dropped the VLAN-1 bridge section, taking down br-lan.1 and the default WiFi SSID ("No route to host"). The AP netdevs are attached to br-lan at runtime by hostapd, so they never appear in `ethernet.ports` and can't keep VID 1 alive on their own. - Only drop an empty VID 1 section when VLAN filtering is off (flat bridge); when filtering is on, keep it so it carries br-lan.1 and the default SSID. - Re-run `wifi` after the network reload so the hostapd-attached AP netdevs re-acquire their PVID once the VLANs exist again. - Add a regression test for the single-ethernet-port hardware layout. * Align the device timezone with crond so scheduled jobs fire at the expected local time (#61) * fix(timezone): resolve POSIX TZ on-device from LuCI zoneinfo, drop bundled table The frontend shipped a hand-maintained IANA→POSIX table and sent both the IANA name and POSIX string to the backend. That table could drift from the device's actual tzdata and only covered ~80 curated zones. Move the source of truth onto the device: Backend: - `system.set-timezone` now takes only the IANA name and resolves the POSIX string via `ubus call luci getTimezones` (resolve_posix_tz); unknown zones error instead of silently writing garbage. Store zonename verbatim (with underscores) to match modern LuCI's writer and the zoneinfo keys. - Add `system.get-timezones` to back the settings dropdown with exactly the set the device can resolve (UTC first, then sorted table keys). - Restart crond after a timezone change so wall-clock schedules (WiFi blackout, WAN) re-base on the new /etc/TZ. - Carry the wizard's browser timezone into the fresh eMMC config on FreshStart (write_timezone), since the live set-timezone only reaches the throwaway microSD overlay. Record the outcome straight into the eMMC activity DB via the new activity::log_to helper. - Log timezone-updated activity entries (success / UTC-fallback). Frontend: - Delete the 600-line TIMEZONES table and getPosixTz/resolveTimezone; the dropdown now loads from getTimezones() and labels are formatted live via Intl (getTimezoneLabel). Default to UTC when the device zone is unset rather than masking it with the browser zone. - setTimezone params drop posixTz; setup wizard forwards the browser zone in the flash request. Updates API_CONTRACT.md, api.service.ts, and both live/mock services. * feat(timezone): make settings dropdown searchable, widen for long labels The on-device zone list (~400 entries) is too long to scan, and long labels like "(GMT-03:00) America/Argentina/Buenos Aires" were truncated in the narrow select. - Swap the timezone tuiSelect for tuiComboBox so the user can type to filter; the dropdown is fed through the tuiFilterByInput pipe. - Move the field to its own row (flex-basis: 100%, max 30rem) since the 50rem form section can't fit a box wide enough for the long labels alongside Theme + Language. tuiComboBox isn't matched by the global :has([tuiSelect]) sizing rule, so the width is set locally. * chore: cleanup --------- Co-authored-by: waterplea <alexander@inkin.ru> * Web UI polish: VPN path, viewport fixes, IP-reservation warning (#67) * feat(outbound): show client device at the head of the VPN connection path The Outbound VPN summary's connection-path graphic started at the VPN provider (e.g. Proton -> Internet), which obscured where traffic originates. Prepend a fixed "Client" node with a device icon so the chain reads Client -> VPN -> Internet (and Client -> Mullvad -> Proton -> Internet for multi-hop). The node is presentational only — rendered in the template, not added to buildConnectionPath() — so the VPN-chain/cycle logic and the loading fallback are untouched. Add the translatable "Client" string (id 508) to all five locale dictionaries. * fix(mock): show profile name, not UCI interface, in VPN "Used by" The Outbound VPN summary's "Used by" field rendered raw UCI interface names (lan, guest) when running against the mock API. The live backend (get_used_by_profiles in vpn_client.rs) already returns each profile's fullname, so the mock diverged from real-device behavior — and interface names are not user-meaningful, especially now that VPN interfaces are randomly generated. Map used-by profiles to p.fullname instead of p.interface so the mock matches live and the documented contract ("Which security profiles route through this VPN"). * fix(web): keep Settings tables and wide content within the viewport Several views ran off the right edge of the screen: - Settings > SSH Keys and Logs had no page-width cap (unlike sibling tabs such as Activity), so their tables overflowed horizontally. Cap the page with :host { max-width: 50rem } to match the established idiom. - The app shell sized the scroll area as calc(100vw - 3rem), which hardcodes the collapsed-sidebar width. With the sidebar expanded the scroll area was wider than the visible main column, so content on the right - the wide Published Ports table, its row actions, and the header Add button - was clipped and unreachable. Use min-width: 100% so the scroll area tracks the real main-column width in both sidebar states. This also lets wide tables scroll horizontally via the slim app-level scrollbar (matching the devices list). * feat(web): warn when a static IP reservation pins a new address When a user saves a device reservation that changes the IPv4 to a different address, show a dialog explaining the change only takes effect on the device's next DHCP request — the router can't push it to a connected client, so the fastest path is reconnect/reboot, and otherwise it applies within ~12h. Only warn when the address actually changes; merely making the current lease static (reserving the existing address) is silent. Add i18n strings (509/510) across en/de/es/fr/pl for the dialog body and dismiss button. * fix(web): restore full-contrast text for labeled avatars in VPN summary Taiga renders [tuiSubtitle] content in a muted color, which dimmed the device label shown alongside tui-avatar-labeled in the VPN connection path. Override the color to --tui-text-primary so the labeled avatar reads at full contrast. * fix(web): pin mobile content to viewport so nav expand slides, not squishes On mobile, expanding the side nav reflowed and squished the page content instead of sliding it off-screen. Pin tui-scrollbar to a min-width of calc(100vw - 3rem) and cap the page header to the same width under tui-root._mobile, mirroring start-os start-tunnel's outlet (desktop min: 100%, mobile: 100vw - 3rem). The desktop header max-width is relaxed back to 100% now that the mobile cap is scoped separately. * Unify reconnect UX into a global ConnectionService (#68) * Unify reconnect UX into a single global ConnectionService Replace the per-flow reconnect modals (RECONNECTING_DIALOG, ReconnectDialog) and the NetworkRestartService.suppress() plumbing with one root ConnectionService that owns all "router is unreachable" UX. Any network-level error (HttpError code 0) anywhere now funnels through reportUnreachable(): the first caller shows ONE sticky "Reconnecting" toast and starts ONE cancelling poll loop, and concurrent callers (the ~15 background form pollers) collapse into it. On recovery it dismisses the toast, confirms with a single "Connection restored" toast, and optionally runs a recovery intent (e.g. reload on a same-host IP change). Callers declare intent up front via expectDisruption() so whichever path observes the drop shows the right copy; the recovery intent is honored only from the call that actually observed the drop, so a stale context can't trigger an unexpected reload. Add per-request timeout plumbing through rpc/http: the RxJS timeout operator unsubscribes and aborts the in-flight XHR, so a wedged HTTP/2 connection (left dead after conntrack is flushed mid-restart) is torn down and the next probe opens a fresh socket, instead of multiplexing onto the dead one and stalling for the browser's full TCP timeout. Timeouts surface as network errors (code 0) so they flow through the same reconnect path. Update ActionService and all restart call sites (wifi, lan/ipv4, profiles, backup, advanced) to the new API; drop the old polling dialog and NetworkRestartService. Add "Reconnecting"/"Connection restored" strings to the en/es/de/fr/pl dictionaries. * Make reconnect recovery reliable across IP, reboot, and SSID changes Builds on the global ConnectionService to fix three recovery gaps where the indicator could get stuck or confirm too early: - IP / subnet change: add ConnectionService.reconnectAt(), which probes the destination cross-origin (no-cors fetch of /static/root-ca.crt) and auto-navigates there once it answers, with a 60s fallback force-redirect for untrusted-HTTPS / wedged connections. LAN IPv4 and profile admin-IP saves now suppress the indicator before the drop and hand off to it, replacing the old per-route "IP changed" dialogs. Scheme/port preserved; bare-IP targets the new address, hostnames re-resolve after DHCP renew. - Restart actions: defer the success toast ("WiFi settings saved") until the router answers again via successMessage, instead of toasting success while still unreachable. systemRestart polls with a 5s per-probe timeout so the drop surfaces promptly instead of stalling on the 60s race. - SSID change: replace the transient toast with a persistent ReconnectDialog that instructs the user to rejoin the new network and reloads on recovery; saveForSsidChange suppresses the global indicator and rethrows real backend failures instead of spinning. Supporting changes: new NetworkService (online/offline observable) drives a fresh probe on link/tab return so recovery isn't stuck on a wedged socket; preload all lazy route chunks at boot so an import() can't fail against the dead connection mid-restart. * Harden published-port forwarding against silent breakage (#70) * fix(published-ports): gate IPv6 forwards on a real global address (GUA) IPv6 port forwarding only works when the target is reachable from the WAN, which requires a Global Unicast Address (2000::/3). ULA (fc00::/7) and link-local (fe80::/10) are unreachable, so a forward to them is a silent no-op. Previously such forwards could be saved and would quietly never work. Backend: - Reject an enabled IPv6 port at save time (ErrorKind::MissingDeviceAddress) on any confident signal it can't work: the router has no delegated global prefix, the device resolved to a ULA, or the device is online with only a link-local address. A genuinely-offline device (no IPv6 seen) on a GUA-capable router is still deferred to the rule-creation GUA guard so a briefly-offline device doesn't fail an otherwise-valid save. - Consolidate the definition of "global" on system::has_global_ipv6 (parsed 2000::/3 range check). is_gua and devices::pick_ipv6 now delegate to it instead of string-prefix heuristics that misclassified deprecated scopes (e.g. site-local fec0::/10) as global. - Track ipv6_link_local_only on DeviceNetInfo to distinguish "offline" from "online but link-local only". - wan::ipv6_get now reports a GUA-preferred assigned_ipv6 (falls back to the first address on a ULA-only WAN) so every consumer sees the reachable scope. - Add unit tests for is_gua, pick_ipv6, and has_global_ipv6 boundaries. Frontend (published-ports dialog): - Replace disabled radio items + auto-correct with an inline <tui-error> and a reactive Save-disable driven by form validity, surfacing the specific reason (no IPv4, IPv6 not enabled, no GUA, or local-only address). - Mirror the backend GUA check in a shared isGua() helper; the route now offers IPv6 only when the WAN has a GUA prefix, not merely IPv6 enabled. - Add i18n strings (514/515) across en/es/de/fr/pl. Updates API_CONTRACT.md to document the new validation and GUA-preferred assigned_ipv6 behavior. * fix(vpn-routing): tie dnat_return ip-rule lifecycle to VPN profiles The global dnat_return / dnat_return6 ip rules (fwmark 0x80 -> main table, keeping inbound port-forward replies off the VPN tunnel) were created on a name-only existence check and never removed. Two consequences on already-provisioned devices: - A stale definition (old priority/mark from a prior release, or a manual edit) survived forever, silently breaking the priority ordering with the source-based VPN policy rules. - The rules lingered after the last VPN-routed profile was switched back to WAN / deleted / disabled, with no consumer. Rewrite both ensure_* helpers as remove-then-append so the rule is always re-emitted from the current constants, and add a cleanup step that drops both rules when no VPN-routed profile remains. The rules now exist iff at least one VPN-routed profile does, mirroring the vpn_<wg> zone lifecycle. Removal is safe: the static nft marking chains keep running but the fwmark->main rule is a no-op without source-based VPN rules. Add tests for stale-rule rewrite and teardown-on-last-VPN-removed. * feat(published-ports): warn when a published port is exposed despite VPN routing A published port is always reached over the public WAN IP, even when the owning device's security profile routes its outbound traffic through a VPN. Users can reasonably assume "behind a VPN" means "not on my real IP", so surface that gap inline in both directions: - Published-port dialog: when creating a new port for a device whose profile has a VPN outbound, show a warning naming the profile and VPN. Only on create (not edit), so we warn on the first save. - Profile dialog: when switching a profile's outbound to VPN, warn if any device in that profile already has published ports, listing up to 3. Wiring: - published-ports/index resolves profile -> VPN-label map (profilesList + vpnClientList + profileGet) and the default LAN-owning profile, passed into the dialog; failures are non-fatal (warning simply not shown). - profiles/index collects published-port labels for the profile's devices (devicesList + publishedPortsList in parallel, each guarded) and passes them to the dialog. - Export `fill()` from i18n/validation-errors so both dialogs can interpolate the translated strings. - Add strings 516 and 523 across en/de/es/fr/pl dictionaries. * feat(published-ports): confirm before breaking or exposing published ports A published port forwards to a device at its current subnet address. Two config changes can silently break or expose such a port, with no warning at the point of action: - Reassigning the device's security profile (moving an Ethernet port to a new VLAN, or reassigning/deleting a WiFi password) moves the device to a different subnet, leaving the port's DNAT rule pointing at an address the device no longer holds. - A port is always reached over the public WAN IP even when its device's profile routes outbound through a VPN — "behind a VPN" does not mean "off my real IP". Gate both behind an explicit confirmation dialog fired at the moment the change is made. Break-on-reassign (ethernet.set / wifi.set): - Add a `confirm_published_port_deletion` flag and a result carrying `pending_published_port_deletions`. Without the flag, set() is a dry run: it detects which ports would break and returns them, applying nothing. With the flag, it deletes those ports' firewall rules and now-stale DHCP reservations atomically with the bridge-VLAN / WiFi update, then reloads network + firewall (+ dnsmasq when reservations changed). - Ethernet attributes affected devices via the bridge FDB (port-precise); WiFi attributes them by reservation subnet of a "vacated" profile (one that lost its last password) intersected with currently-WiFi-connected devices. Ethernet devices on a vacated WiFi profile keep their IP, so they are correctly excluded. - Shared helpers in published_ports: AffectedPublishedPort, affected_ports_for_macs, affected_wifi_ports_for_vacated_profiles, remove_ports_for_macs; export get_bridge_fdb. CLI edit paths confirm implicitly (no dialog). Add unit tests. Expose-despite-VPN: replace the inline form warnings added in f9a1114 (which sat passively in the profile and published-port dialogs) with a blocking confirmPublishedPortExposed dialog fired uniformly wherever exposure is newly created — creating/editing a port, re-enabling a disabled one, or switching a profile's outbound to a VPN. The warning now fires once, at the moment of exposure, instead of perpetually in a form. Drop the dead updatePassword path and the unused publishedPortLabels/vpnProfiles dialog plumbing it replaced. Frontend: two new shared confirm helpers (published-port-deletion, vpn-exposed-port); wifi/ethernet services gain previewWifi/previewPorts dry-run methods; toggleEnabled re-reads the port list after the await to survive the 5s auto-refresh. Swap i18n strings 516/523 for 524-529 across en/de/es/fr/pl. Update API_CONTRACT.md for the new request/result shapes on ethernet.set and wifi.set. * feat(published-ports): keep IPv6 forwards working when your ISP changes prefix When an ISP rotates its delegated IPv6 prefix, every IPv6 published-port forward was left pointing at an address the router no longer owns and silently stopped working. Now: - A wan6 hotplug hook repairs all IPv6 forwards to the new prefix automatically when the prefix changes. - Enabled IPv6 ports pin a stable device address so forwards survive rotations (and don't break if the device is briefly offline). - A stranded forward is shown as "IPv6 address out of date" (Partial or Error) instead of a false-green Active status. Also fixes profile-vacate cleanup to catch IPv6-only ports and to preserve user-named DHCP reservations (clearing only the stale IPv4). * some taiga idioms * Outbound VPN: configurable MTU + WireGuard config validation (#71) * feat(vpn): configurable MTU for outbound WireGuard tunnels Add an optional MTU setting to outbound VPN clients so tunnels on obfuscated or :443 endpoints (whose path can't carry the 1420 kernel default) can be lowered to a working value. Unset inherits the kernel default. Backend: - Parse an uncommented `MTU` from the uploaded .conf into `option mtu`; a commented `#MTU` is ignored. - Validate MTU to 1280-1500 (1280 = IPv6 min link MTU / universal safe floor); accept it on create and update. - `update` writes/clears the `mtu` option (UCI is the single source of truth) and bounces the WG interface only when the value changed. - Expose `mtu` on `OutboundVpn` (list) and add the `WgInterface.mtu` UCI field. - Enable `option mtu_fix 1` (TCP MSS clamp) on the dedicated vpn_<X> egress zone as a backstop against a too-high MTU black-holing large packets. Frontend: - Add an optional MTU number field (1280-1500) to the VPN edit form with a hint to lower it to 1280 when the VPN connects but times out. - Thread `mtu` through the API types, update payload, and mock API. - Register the new UI strings across all locale dictionaries. API_CONTRACT, Rust handlers, api.service.ts and mock-api.service.ts updated together. * feat(vpn): validate WireGuard config, reject OpenVPN/malformed uploads Harden outbound VPN config parsing so non-WireGuard files fail fast with a clear message instead of the misleading "missing PrivateKey" error. - Backend: require [Interface]/[Peer] headers, an interface Address, and a peer Endpoint; detect OpenVPN configs for a specific message. Add tests. - Web: async validator mirrors the check (backend stays authoritative); fix file-input flicker by listening on statusChanges and treating PENDING as not-yet-valid. - Document the rules in API_CONTRACT.md. * Release toolkit + CI S3 publishing + beta.3 bump (#72) * Add local release toolkit + CI S3 publishing Introduce scripts/manage-release.sh, a human-run release gate modeled on start-os's manage-release.sh, and wire CI to publish build artifacts to S3. CI (build-image.yaml): - Build artifact and GitHub Release now carry both images: the sdcard .img (fresh install) and the sysupgrade .img.gz (OTA). - New step uploads images to s3://startwrt-images (DigitalOcean Spaces, nyc3) -- the only publishing done in CI. manage-release.sh: - download/pull/verify/upload/register/index/sign/cosign/notes and a full-release pipeline (download -> register -> index -> sign -> notes). - Registering, indexing, and signing stay a deliberate local gate run with the developer key, sitting between "built + uploaded" and "live in the registry". - sysupgrade .img.gz is indexed into the registry "squashfs" slot via a hardlink so start-cli resolves the asset kind; the indexed URL still points at the honestly-named .img.gz. Add scripts/startwrt-release.key.asc (public half of the release signing key) for out-of-band fingerprint confirmation. * Bump version to 0.1.0-beta.3 * Update scripts/manage-release.sh Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * update asc file to start9 key --------- Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> * Fix/board name (#73) * update board name to spacemit,k1-x so the update is visable to devices checking for updates * update default registry to use https * chore: update to Angular 22 (#69) * chore: update to Angular 22 * fix(web): resolve ng-qrcode peer against Angular 22 via npm overrides ng-qrcode@21 (latest) declares @angular/{core,common} ">=21 <22", which blocks a flag-free `npm ci`/`npm install` on Angular 22 — the exact step `make image` runs (web/Makefile line 113). Override its Angular peers to the root versions so install resolves cleanly with a single Angular 22 and no nested duplicate. Remove once ng-qrcode ships an Angular 22 peer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: small fixes --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(start-wrt): wire migrated product onto shared monorepo code Following the subtree import, dissolve start-wrt's standalone backend Cargo workspace into the root monorepo workspace. The backend (startwrt-core/ctrl, uciedit, uciedit_macros) now links the shared start-core crate (aliased as `startos`, zero source churn) plus the vendored rpc-toolkit and imbl-value, replacing the embedded start-os submodule (removed). openwrt becomes the repo's only git submodule. - build.mk (startwrt / startwrt-image / startwrt-openwrt-setup / startwrt-update / test-startwrt / clean+format), included by the root Makefile; build scripts made monorepo-root-relative (binary now lands in the workspace-root target/) - run-tests.sh + test-startwrt mirror start-core's containerized run-tests.sh, package-scoped so a bare cargo test no longer drags in startos-backup-fs/fuser - .github/workflows/start-wrt.yaml (riscv64 binary on PR, OpenWrt image on dispatch) - web kept standalone (its own package.json) this stage; folding it into the root Angular workspace + @start9labs/shared is the follow-up (Stage B) - docs: product/backend/web AGENTS.md (+ one-line CLAUDE.md), CONTRIBUTING, CHANGELOG; registered in root AGENTS.md + ARCHITECTURE.md Validated: cargo check + 451 unit tests green; make startwrt and make startwrt-image build; Tier 0-3 on-device validation passes on K1. * fix(start-wrt): submit outbound VPN dialog + re-embed UI on web-only rebuilds Two unrelated frontend/backend fixes plus doc touch-ups. - Outbound VPN dialog: adding a VPN silently did nothing. save() called tuiMarkControlAsTouchedAndValidate, which re-ran the WireGuard .conf async validator; the in-flight run is cancelled when the file input remounts during the PENDING phase, so the form stayed PENDING and the create request never fired. Now submit completes directly when the form is already valid, and falls back to markAllAsTouched() (no re-validation) to surface errors when it isn't. - ctrl/build.rs: emit cargo:rerun-if-changed for web/dist so web-only changes re-embed into the startwrt binary. The UI is baked in via include_dir!, which doesn't register embedded files as cargo deps, so a changed bundle was ignored unless a .rs file also changed — shipping a stale UI. - Docs: rename the deploy env var REMOTE -> STARTWRT_REMOTE across AGENTS.md, ARCHITECTURE.md, CONTRIBUTING.md, and add CHANGELOG entries for both fixes. * refactor(start-wrt): fold web into root Angular workspace (Stage B1) Move the StartWRT frontend from a standalone Angular app into the root Angular workspace, matching how ui/setup-wizard/start-tunnel/brochure are wired. Mechanical, no behavior change. - angular.json: add `start-wrt` project (application builder, outputPath dist/startwrt so the existing include_dir! embed path is unchanged, .html/.svg text loaders, taiga less styles, port 8300). - Collapse web/tsconfig.json + tsconfig.app.json into one tsconfig extending the root; redeclare paths (*, @taiga-ui/icons/*, @start9labs/shared). Keep noUncheckedIndexedAccess off (re-assert noImplicitReturns/isolatedModules) to preserve the app's original strictness during the fold-in. - package.json: add build:wrt / start:wrt / check:wrt / check:i18n:wrt; add the two check:*:wrt to the `check` aggregate; add the web dir to the format/format:check globs. No dependency changes (marked/ng-qrcode/taiga addons already satisfied by the root). - Delete standalone web scaffolding (package.json, package-lock.json, angular.json, tsconfig.app.json, .husky). build-config.js resolves paths via __dirname so it runs from the repo root. - build.mk: web dist now built via `npm run build:wrt` with the workspace build:deps prerequisites (WEB_SHARED_SRC + .angular/.updated); fold web prettier into format-web. - CI: add shared-libs/ts-modules/**, angular.json, package.json, package-lock.json, tsconfig.json to start-wrt.yaml paths. - Add root .prettierignore for build outputs (dist/.angular/out-tsc). - Docs/changelog: flip the "web is standalone" notes to "in the root workspace" across root + start-wrt docs. Verified: npm run build:wrt, check:wrt, check:i18n:wrt, format:check, and a host `cargo build -p startwrt-core --bin startwrt` (embeds the workspace-built UI) all pass. * refactor(start-wrt): adopt @start9labs/shared utilities (Stage B2) Replace the three hand-mirrored utilities that have clean shared equivalents: - pauseFor → @start9labs/shared (util/misc.util); delete local utils/pauseFor.ts - RELATIVE_URL token → @start9labs/shared (tokens/relative-url); drop the local token from http.service.ts and app.config.ts - MarkdownPipe → @start9labs/shared (pipes/markdown.pipe); delete local pipes/markdown.pipe.ts (marked stays only in the shared pipe) Kept local by design (investigated during B2): - HttpService/RpcService/ConnectionService — start-wrt uses an *aborting* per-request timeout (rxjs `timeout`) that surfaces a code-0 network error and tears down wedged HTTP/2 connections for the reconnect flow; the shared HttpService's race-based timeout does not abort, so swapping would regress the reconnect UX. - Error surfacing — ActionService/FormService route network drops into the global reconnect indicator with per-action copy; there is no ErrorService mirror to replace. - WorkspaceConfig (flat config.json), the WebSocket progress types (start-wrt ABI, not start-core's), and the i18n-routed validation-errors provider — no clean shared equivalent. Mark @start9labs/shared `sideEffects: false` so importing a few symbols from its barrel tree-shakes: without it, start-wrt's first-ever shared import dragged in ~875 kB of unused shared code (server-name-words, ansi-to-html, the monaco logs-window, …), pushing the embedded UI bundle to 1.85 MB (over its 1.5 MB budget). The lib is verified side-effect-free; the flag also shrinks the other apps' bundles. Verified: npm run check (whole workspace), build:wrt (974.93 kB, under budget), build:tunnel, cargo build -p startwrt-core --bin startwrt, and format:check all pass. * fix(start-wrt): keep UI Build field's git hash fresh and mark dirty builds The Settings → General "Build" field showed a stale git hash after the monorepo migration (frozen at the import commit). Two causes: 1. build.mk lost the wiring that refreshed build/env/GIT_HASH.txt every build and made it a prerequisite of web/config.json, so the stamp never re-ran when HEAD moved. Restore it: run check-git-hash.sh at parse time and list GIT_HASH.txt as a prereq of config.json. 2. The UI shortened the hash with slice(0, 12), dropping the trailing "-modified" dirty marker. shortGitHash now preserves any trailing marker, matching the "-dirty" indicator `startwrt verify` already prints. * fix(start-wrt): restore release CI dropped in the monorepo migration The standalone build-image.yaml published releases (S3 upload + GitHub Release) on a v* tag push, but the migration into the monorepo dropped that job — start-wrt.yaml could build the image yet never publish it. Restore publishing as a `deploy` job, following the canonical start-os pattern (startos-iso.yaml): gated on a manual workflow_dispatch with a `deploy: release` input rather than a tag push, since no product in the monorepo releases by tag. It uploads the built images to s3://startwrt-images and cuts a GitHub Release, reading the version from backend/ctrl/Cargo.toml (the web/package.json the standalone workflow read was removed when the UI folded into the root Angular workspace). Registry indexing/signing stays a deliberate local gate in scripts/manage-release.sh, re-pointed here at the new workflow and the `startwrt-openwrt-image` artifact name. Also restore the OpenWrt download-cache keying the migration had narrowed: the image job's cache key again includes build/feeds.conf (so changing the feed set busts the cache) and carries a restore-keys fallback for partial restores. Updates projects/start-wrt/CHANGELOG.md. * fix(start-wrt): guard against colliding profile subnets Changing the Router IP could strand the network on a subnet already owned by another profile. The LAN IPv4 page exposed a "Router IP" (3rd-octet) field that duplicated the Admin Security Profile's subnet field but, unlike it, had no collision guard — so pointing the router at an in-use /24 put two interfaces on the same subnet, producing overlapping routes that silently broke all access to the router (unrecoverable even by a keep-settings reflash). Remove the duplicate field: the LAN page now only selects the /16 network block, and the Admin profile is the single source of truth for the 3rd octet (routerOctet stays in the model, populated from the loaded IP, so a network-block change preserves the subnet and the summary can still show the router IP). Add a backend guard so a direct RPC/CLI call can't bypass the UI: profiles.create/profiles.edit now reject a gateway whose /24 collides with an existing profile (including the admin LAN), via a new SubnetCollision error kind. Edits that keep their own subnet are skipped, so no-op edits — and recovery from an already-broken config — still pass. * docs(start-wrt): add StartWRT user manual to the docs site Fold the StartWRT documentation book (previously in the standalone start-docs repo, branch feat/start-wrt) into the monorepo at projects/start-wrt/docs/, matching the layout of the other product books (start-os, start-tunnel, start-sdk). The 24-page mdBook covers install, setup, security profiles, WiFi/VPN/WAN/LAN, backups, and reference. Wire the book into the shared docs site: - versions.conf: register start-wrt=0.1.0.x (drives build, deploy, nginx) - build.sh: map the start-wrt book to its product dir - serve.sh + landing/index.html: add the StartWRT URL/card - generate-llms-txt.ts: add the StartWRT label/description - docs-deploy.yml: trigger deploy on projects/start-wrt/docs/** - .gitignore: ignore the in-place docs/book/ build output - book.toml: adapt to monorepo conventions (build-dir, git/edit URLs, shared theme; drop docs-agent assets absent from this theme) Correct doc claims that no longer match the shipped UI/backend, found by auditing every page against the Rust backend and Angular UI: remove the nonexistent LAN "Blacklist" access mode; move the LAN "Router IP" to the Admin profile subnet; fix the SLAAC-disable trigger; drop the phantom SSH-key "name" step; correct the DDNS status fields and provider label; soften "restarts the router" to a network reload; note the random Root CA name suffix; scope the "no separate database" claim; and fix the Wi-Fi password-preservation rationale. Also repair the theme symlink for the existing product books (start-os/start-tunnel/start-sdk): the monorepo migration pointed them at the nonexistent projects/docs/theme, breaking the docs build. Retarget all four to ../../start-docs/theme. Update start-docs/AGENTS.md and start-wrt/ARCHITECTURE.md to reflect the new book and docs/'s dual role. * fix(start-wrt): enforce 12-character password minimum on password change The Settings → Password form and its auth.set-password backend endpoint (also reached via `startwrt auth set-password`) accepted passwords shorter than 12 characters, even though first-time setup required it and the docs documented the minimum. A weak password could be set from the Settings tab or the CLI, contradicting set-initial-password and settings.md. Backend: extract a shared validate_password_length() helper (MIN_PASSWORD_LEN = 12) and call it from both reset_password_impl and set_initial_password_impl so the rule can't drift between the two endpoints; add boundary tests. Frontend: add Validators.minLength(12) to the new-password control and the existing 'minlength' i18n error to the settings password form, matching the setup screens (the error string is already in all five dictionaries). * fix(start-wrt): close the lan.ipv4-set gap in the subnet-collision guard The colliding-subnet fix (715f34364) guarded profiles.create/profiles.edit but left lan.ipv4-set unguarded: when only the 3rd octet changed, nothing stopped a direct RPC/CLI call (`startwrt lan ipv4-set`) from moving the LAN onto another profile's /24 — the exact stranded-router bug the commit fixed. The UI path was closed (the Router IP field is gone), but the backend vector the guard was added for remained open. Call guard_subnet_collision from ipv4_set before any config is mutated. On a network-block change the profiles keep their 3rd octet, so the new 3rd octet is tested inside the *current* block (subnet_guard_ip), which is equivalent to the post-move state — a block change that would land the LAN on a profile's relative /24 is rejected too, while no-op re-sets and same-octet block moves still pass. Integration + unit tests cover all four cases; API_CONTRACT.md documents the SubnetCollision error. Also update the /lan/ipv4 in-app help (all five languages), which still described the removed editable "Router IP" field: the router address is now explained as the gateway of the Admin profile's subnet, matching lan.md. * ci: stop cloning the openwrt submodule in jobs that don't need it Adding projects/start-wrt/openwrt gave the repo its first git submodule, and every pre-existing workflow checks out with `submodules: recursive` — a no-op until now, but a full clone of the multi-GB OpenWrt fork ever since. Worst hit is test.yaml, which runs both its jobs on every push/PR. Only start-wrt's image job needs the submodule (start-wrt.yaml already scopes it correctly); switch everything else to `submodules: false`. build.yml/release.yml are the reusable workflows executed in external service repos, so their `recursive` refers to those repos' submodules and stays. * fix(start-wrt): track shared-crate and full-web-dir build inputs in build.mk $(STARTWRT_BIN) only depended on the backend tree, but startwrt-core path-depends on start-core (aliased startos), rpc-toolkit, and imbl-value — so after editing shared crate code, `make startwrt`/`make startwrt-update` saw the binary as up to date and could deploy a stale build. Add those crates (via CORE_SRC, matching tunnelbox) plus Cargo.toml/Cargo.lock as prerequisites, which also re-syncs build.mk with the shared-libs paths the CI workflow already lists (root AGENTS.md "Coupled changes"). Also widen STARTWRT_WEB_SRC from web/src to the whole tracked web/ dir so web/assets and web/tsconfig.json retrigger the UI build, matching how the other apps' source globs work. * fix(start-wrt): namespace releases per product and point tooling/docs at the monorepo Releases now live on Start9Labs/start-technologies, which hosts every product's releases on independent cadences — so bare v* tags would collide across products (StartOS history already owns v0.x). Tag StartWRT releases startwrt/v<version> and name them "StartWRT v<version>" in the deploy job. manage-release.sh still pointed its gh calls at the dead standalone Start9Labs/start-wrt repo (despite the release-CI restore claiming it was re-pointed) — fix REPO and the release-tag references to match the new scheme. The user manual's download link, source-code pointer, and both issue-tracker links likewise moved from the standalone repo to the monorepo. * docs: add start-wrt to the root and shared-libs docs the migration missed The migration updated the root AGENTS.md/ARCHITECTURE.md product lists but missed the rest of the hierarchy: - README.md: StartWRT row in the product table + "rest of the monorepo" entry - AGENTS.md: "all five bins" -> six - CONTRIBUTING.md: startwrt build target, test-startwrt, format-startwrt - Makefile help: startwrt/startwrt-image and test-startwrt lines - start-core crate docs (AGENTS/README/ARCHITECTURE): startwrt as the sixth consumer, noting it is a full backend importing the crate aliased as `startos` rather than a MultiExecutable wrapper - shared-libs/AGENTS.md + ts-modules AGENTS/ARCHITECTURE: start-wrt in the Angular workspace app lists ("four apps" -> five; UI ships embedded in the startwrt binary) * fix(start-wrt): name release assets per the startos convention The release assets were the raw OpenWrt output names (openwrt-spacemit-k1-sbc-bananapi-f3-squashfs-{sdcard.img,sysupgrade.img.gz}) — no product, version, or hash, indistinguishable on a shared monorepo releases page. Rename them on the copy into results/ to follow start-os's basename convention (build/env/basename.sh: <project>-<version>-<githash7>_ <platform>), plus a role suffix since both artifacts share the .img/.img.gz extensions: startwrt-<version>-<githash7>_spacemit-k1-sdcard.img startwrt-<version>-<githash7>_spacemit-k1-sysupgrade.img.gz The basename is composed in build.mk from start-wrt's own stamps (basename.sh reads StartOS's PLATFORM/ENVIRONMENT/GIT_HASH build state and a manifest path that doesn't exist here); the version sed matches the workflow's Determine version step. Keeping the -sdcard.img / -sysupgrade.img.gz endings preserves every downstream match: manage-release.sh's suffix globs and cmd_notes regexes, the .img.gz -> .squashfs hardlink for registry kind inference, and the workflow's extension-based S3/gh-release globs. Only the image job's upload-artifact path glob keyed on the openwrt- prefix and is updated. installing.md now names the real download filename shape (keeping startwrt.img as an explicit placeholder in the checksum commands). * fix(start-wrt): unbreak the two failing CI jobs - build.rs now creates the web dist dir before compilation, so cargo test/check builds (CI's make test, fresh clones) no longer panic in include_dir! when the Angular app was never built. Real builds are unaffected: make startwrt populates the dir first via the $(STARTWRT_WEB_DIST) prerequisite. - The compile job installs binutils-riscv64-linux-gnu so build/verify-isa.sh finds a riscv64 objdump on the runner instead of failing the build after a successful cross-compile. * fix(start-core): tolerate empty scope dirs when bundling node_modules into dist npm 10 leaves empty scope directories (@emnapi, @napi-rs, @tybys) behind when it skips optional wasm-fallback packages, and cpy fails on them ("Cannot copy ...: the file doesn't exist"), breaking make dist and everything downstream (make test, make startwrt). Copy node_modules with cp -a instead; cpy still handles the lib globs. --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> | 2 个月前 | |
docs: delete scope-doc text with no live producer (#3728) * docs(sdk): drop the fleet-provenance clause from the description advice "Two more characters' worth of advice, both from descriptions already in the registries" — the provenance half is a claim about what the fleet's short descriptions look like right now, with nothing keeping it true. It is already only partly accurate: 4 of the 107 packages with an en_US short open with the service name, which is the pattern the first bullet tells you to avoid. The two bullets it introduces both have live producers and stay exactly as written. Split out from the rest of the packaging-guide cruft audit because this paragraph exists only on master — the published guide does not carry it, so it cannot be fixed on live-docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs: delete scope-doc text with no live producer Audited all 65 AGENTS.md / ARCHITECTURE.md / CONTRIBUTING.md files across every scope against one test: a sentence citing what not to do, what not to include, or a mistake once made must name a live producer — a scaffold that emits it, a neighbour someone would copy, a tool that does it unless you intervene, or an obvious-but-wrong fix someone would reach for. No producer, delete. Facts that are simply wrong: - start-registry's CONTRIBUTING said its Cargo version "tracks the OS release line — don't bump it independently", while its own AGENTS.md one directory over says the opposite. The crate is 1.0.2 and StartOS is 0.4.0.2, so the CONTRIBUTING rule is the retired one. Its "(currently 1.0.0)" had drifted too. - rpc-toolkit's CONTRIBUTING described a `rustfmt.toml` the crate does not ship; only the repo root has one. - start-sdk's build table documented `make dist`, which is not a target in that Makefile (start-core's `make dist`, referenced further down the same file, is real and stays). - The root ARCHITECTURE tree put `apt/` under projects/start-os/; it is at the repo root. - shared-libs' CONTRIBUTING said ts-modules' contents are Angular libraries; it also holds the non-Angular start-core. - start-registry's ARCHITECTURE counted "all five product binaries"; the root AGENTS.md counts six. - start-sdk's ARCHITECTURE listed AGENTS.md twice in Further reading. Migration narration whose migration is over: Four separate stale-path notes mapped the pre-monorepo root layout (`core/`, `web/`, `sdk/`, `patch-db/`, `container-runtime/`). The root AGENTS.md keeps one — `core/src/` is still referenced live in projects/start-os/DEV_TODO.md, so the mapping is still reachable — and the copies in start-cli, container-runtime and shared-libs go, along with "Internally unchanged from the old `core/` crate", the start-fs migration note, the retired start-os submodule's workspace Cargo.toml, and the `base/lib/...` import shape. The retired `next/patch` | `next/minor` | `next/major` prohibition goes from AGENTS.md; the mapping line in the root CONTRIBUTING.md stays, which is the one line the still-reachable-artifact rule allows. Counts and in-progress markers: `~430 tests` (twice), `11 modules` (twice), `~28 utility modules`, `~117-line`, `~2200 lines`, `all five product binaries`, `currently 1.0.0`, `3 small build-infra patches`, `(currently by start-core)`, `(currently just bitcoin-guides)`, `(currently the Angular libs shared and marketplace)` (twice), the SDK/OS version pair, `being replaced`, `being phased out`, the per-scope CONTRIBUTING migration tally (three copies), the patch-db repo "is being retired" (the repo is still live and was pushed to after the claim was written), and two notes recording which warnings a crate happened to emit. Also drops a commented-out list of ten aspirational locales, a prohibition against putting files directly in shared-libs/ (nothing but doc files ever has, across the directory's whole history), and a prohibition against nesting tab groups more than one level, which restates the positive rule in the sentence before it. Includes the fleet-provenance clause in the packaging guide's manifest.md, which can only be fixed here — the rest of that audit is on live-docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Matt Hill <9935159+MattDHill@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> | 27 天前 | |
chore(license): fixes from license hygiene audit (#3627) * chore(license): replace proprietary fonts with an open family Proxima Nova (Copyright (c) Mark Simonson, all rights reserved) and MBF Minimal Custom (Copyright (c) MoonBandit, all rights reserved) were tracked in-tree and copied into the ui, setup-wizard, start-tunnel, start-wrt and marketplace bundles by angular.json, under a repository that grants recipients MIT rights to distribute and sublicense. Replace them with Hanken Grotesk (SIL OFL 1.1). One variable face covers the whole 100-900 range the seven static Proxima weights were serving, so the two woff2 subsets are 54 KB against the 460 KB they replace. The MBF face was declared in start-tunnel but no rule ever applied the family, so it is dropped rather than replaced. * chore(license): carve the GPL-2.0 OpenWrt material out of start-wrt's MIT grant projects/start-wrt/LICENSE claimed MIT over the whole project, but sixteen files under openwrt-overlay/ carry an explicit SPDX-License-Identifier: GPL-2.0-only header naming SpaceMiT Ltd. or OpenWrt.org, and openwrt-patches/ are derivative works of GPL-2.0 OpenWrt sources. GPL-2.0 cannot be relicensed downstream, so the MIT grant was offering rights Start9 does not hold. Add COPYING (GPL-2.0) plus a README to each directory recording provenance and the GPL-2.0 §3 source obligation, and scope the MIT grant in the LICENSE and README to exclude them. CONTRIBUTING described openwrt-overlay/ as "the Start9 additions"; it is mostly SpaceMiT's BSP, so say so. * chore(license): attribute vendored third-party code imbl-value's src/de.rs is 84% line-identical to serde_json's src/value/de.rs (longest identical run 107 lines), ser.rs 80% and index.rs 74%, and macros.rs is serde_json's json! muncher with its comments intact — yet LICENSE named only Start9 and AGENTS.md asserted the impls were not serde_json's. All the upstreams here are MIT or MIT-compatible, so this is notice compliance, not relicensing. - imbl-value: credit Erick Tryzelaar and David Tolnay in LICENSE, record the derivation in README, and correct the AGENTS.md bullet. - patch-db/json-patch: a fork of idubrov/json-patch with serde_json swapped for imbl-value + json-ptr, shipping no license text despite declaring dual terms. Add LICENSE-MIT and the verbatim LICENSE-APACHE, a README recording the fork, and normalize the SPDX expression to "MIT OR Apache-2.0". - jsonpath: fill in the MIT template placeholders left as "[2019] [Changseok Han]" and credit the upstream in the README. - init_resize.sh: name RPi-Distro/raspi-config, which it derives from. json-patch and jsonpath_lib are also set publish = false: both sit at crates.io names owned by their upstreams, so publishing a diverged fork from here would be wrong even if it were possible. * chore(license): declare MIT on every manifest, ship it with published crates Five Cargo manifests (backup-fs, patch-db-util, the three start-wrt crates) and eleven package.json files declared no license at all, and the workspace root has no [workspace.package] table to inherit one from, so tooling reported them as unlicensed. Declare MIT on each. exver and rpc-toolkit are published to crates.io but carried no license text in their tarballs; give them a LICENSE. Normalize the copyright holder across the existing files, which variously read "Start9", "Start9 Labs" and "Start9 Labs, Inc.", and rename yasi's LICENSE.md so cargo packages it by convention. The upload-each action's committed ncc bundle inlined 97 dependencies with their notices stripped; build it with --license so the per-dependency notices are generated and committed alongside. The bundle itself is byte-identical. * chore(license): add NOTICE.md and state the licensing policy Nothing in the repo said what "everything is MIT" excludes, so the claim could not be checked. NOTICE.md is now the complete list of files under other terms — if something is not named there, it is MIT — and the root LICENSE, README and the StartOS architecture doc point at it. Also: - CONTRIBUTING gains an inbound-licensing statement and a rule that vendored code keeps its notice and lands in NOTICE.md in the same PR. - .deb packages are built with a usr/share/doc/<pkg>/copyright file, which Debian policy requires and which the hand-written control block omitted. - deny.toml moves to the workspace root and drops copyleft/unlicensed/ allow-osi-fsf-free, removed from cargo-deny's schema in 0.14. Note nothing in CI runs it yet, so it remains documentation rather than a gate. - The Intel BIOS capsule mirrored for the discontinued 2023 Server One is attributed to Intel on the page that serves it. It is Intel's, not ours, and the architecture doc no longer implies the shipped image is MIT end to end. * chore(license): sync lockfiles with the added license fields npm records the root package's license in package-lock.json, so declaring it in package.json leaves the lockfile out of sync — which is exactly what CI's `npm ci` drift gate rejects. Five lockfiles, one line each, no dependency churn. * chore(license): taplo-format deny.toml The relocated file landed at the repo root without going through taplo, whose reorder_keys sorts each [[licenses.clarify]] table's keys. Caught by CI's format-check, which I hadn't run locally because it needs the fmt container. * chore(license): make deny.toml describe the tree it actually governs Nothing had ever run this policy, so it had drifted from reality in both directions. Ran cargo-deny against the real dependency graph: - LGPL-3.0, OpenSSL and Unicode-DFS-2016 were allowed but appear nowhere. LGPL is the one that mattered: allowing it invited a copyleft dependency that would have been incompatible with shipping start-core as MIT. - The webpki and ring clarifications are dead — webpki is gone (rustls-webpki now) and ring declares "Apache-2.0 AND ISC" itself. - Four permissive licenses genuinely in the tree were missing, so 25 crates were rejected for no good reason: Unicode-3.0 (the ICU stack), CDLA-Permissive-2.0 (webpki-roots), BSL-1.0 (xxhash-rust, lazy-bytes-cast) and 0BSD (quoted_printable). MPL-2.0 stays allowed and is now explained: its copyleft is per-file, so linking imbl and friends into an MIT binary is fine as long as we don't modify them. This leaves two genuine GPL-3.0-or-later rejections, which need code changes rather than a policy change, so the CI gate lands with that fix. * chore(license): revert the overreach in this branch Review of my own decisions found several that were wrong or went further than the evidence supported. Reverting them. - Restore "LGPL-3.0" to deny.toml. My commit message claimed allowing it was "incompatible with shipping start-core as MIT". That is wrong: LGPL-3.0 §4 expressly permits combining the library with a work conveyed under other terms, subject to notice and relink conditions. Worse, the entry was not drift — 5b22d0a3b (Aiden McClelland, 2021-06-17) added it in the commit that created the file, as a deliberate carve-out. An unmatched allowance is only a warning, so removing it bought nothing and would have hard-rejected a future LGPL dependency that is in fact perfectly usable. - Restore the root LICENSE to byte-canonical MIT. Editing the license body to add a carve-out risks breaking automated license detection, and the carve-out already lives in README.md and NOTICE.md. Same for start-wrt's LICENSE. - Restore extract-ikconfig. Deleting a working debugging tool from the image was a functional change made for a licensing reason that a NOTICE entry solves. - Restore jsonpath's LICENSE verbatim. Filling in a third party's copyright placeholders is not ours to do; the clarification belongs in the README. - CONTRIBUTING lumped LGPL in with GPL/AGPL as forbidden. Corrected: GPL/AGPL can't be linked into our binaries, LGPL and MPL-2.0 can, with obligations. - NOTICE claimed to be "the complete list" and that anything unlisted is MIT. Softened — I can't guarantee completeness. - Moved brand marks out of "Not MIT" into their own Trademarks section. Trademark is not copyright and is not granted or withheld by a copyright license, so listing logos as a licensing exception was a category error. - The MPL note said obligations attach only if we modify the sources. They attach on distribution (§3.2), modified or not. - The overlay README asserted every unheadered file was GPL "including the Start9-authored ones, which are derivative works". A new file in an OpenWrt tree is not automatically derivative; that gave away Start9's own copyright by assertion. Now stated as a deliberate contribution choice. Also dropped "the one exception" (NOTICE lists several) and softened GPL-2.0-only to GPL-licensed, since the tree mixes -only and -or-later. - Restore the start-wrt font-weight design comment, reworded for the variable face rather than deleted. * refactor(core): replace socks5-impl with fast-socks5 socks5-impl is GPL-3.0-or-later, and it was a direct dependency of start-core, so every product binary linking start-core would have to be conveyed under GPL-3.0. No permissive version exists upstream — the current 0.9.6 is still GPL-3.0-or-later — so this is a swap rather than a bump. fast-socks5 (MIT) covers the same ground: read_command() hands back the target before connecting, which is what lets us keep intercepting .onion (tunnel via the tor service's SOCKS proxy) and .local (resolve over mDNS), and get_socket() unwraps the client tunnel to a plain TcpStream so the keepalive still applies. BIND and UDP ASSOCIATE are still answered CommandNotSupported. The proxy had no test coverage, so this adds three: a round trip through the proxy to an echo server, an unreachable target refused rather than hung, and BIND/UDP rejected. * refactor(start-wrt): replace tracing-rfc-5424 with syslog-tracing, gate licenses in CI tracing-rfc-5424 is GPL-3.0-or-later — the last crate in the tree whose terms we can't meet while conveying our binaries under MIT. syslog-tracing (MIT) writes through libc's syslog(), which lands in the same /dev/log that the old UnixSocket transport targeted, so logread still sees the entries. Its MakeWriter maps tracing levels to syslog severities via make_writer_for. init_logging took a name it then ignored; syslog-tracing can use it as the openlog identity, so startwrt-cli and startwrt-ctrld are now distinguishable in logread instead of sharing one tag. The startwrt-activity marker activity.rs greps for is in the message body, so it is unaffected. Timestamps and tag are dropped from the formatter because syslogd adds its own. With that, `cargo deny check licenses` passes, so it becomes a CI job. LGPL-3.0 stays allowed and simply reports as an unmatched allowance. | 1 个月前 | |
chore(license): fixes from license hygiene audit (#3627) * chore(license): replace proprietary fonts with an open family Proxima Nova (Copyright (c) Mark Simonson, all rights reserved) and MBF Minimal Custom (Copyright (c) MoonBandit, all rights reserved) were tracked in-tree and copied into the ui, setup-wizard, start-tunnel, start-wrt and marketplace bundles by angular.json, under a repository that grants recipients MIT rights to distribute and sublicense. Replace them with Hanken Grotesk (SIL OFL 1.1). One variable face covers the whole 100-900 range the seven static Proxima weights were serving, so the two woff2 subsets are 54 KB against the 460 KB they replace. The MBF face was declared in start-tunnel but no rule ever applied the family, so it is dropped rather than replaced. * chore(license): carve the GPL-2.0 OpenWrt material out of start-wrt's MIT grant projects/start-wrt/LICENSE claimed MIT over the whole project, but sixteen files under openwrt-overlay/ carry an explicit SPDX-License-Identifier: GPL-2.0-only header naming SpaceMiT Ltd. or OpenWrt.org, and openwrt-patches/ are derivative works of GPL-2.0 OpenWrt sources. GPL-2.0 cannot be relicensed downstream, so the MIT grant was offering rights Start9 does not hold. Add COPYING (GPL-2.0) plus a README to each directory recording provenance and the GPL-2.0 §3 source obligation, and scope the MIT grant in the LICENSE and README to exclude them. CONTRIBUTING described openwrt-overlay/ as "the Start9 additions"; it is mostly SpaceMiT's BSP, so say so. * chore(license): attribute vendored third-party code imbl-value's src/de.rs is 84% line-identical to serde_json's src/value/de.rs (longest identical run 107 lines), ser.rs 80% and index.rs 74%, and macros.rs is serde_json's json! muncher with its comments intact — yet LICENSE named only Start9 and AGENTS.md asserted the impls were not serde_json's. All the upstreams here are MIT or MIT-compatible, so this is notice compliance, not relicensing. - imbl-value: credit Erick Tryzelaar and David Tolnay in LICENSE, record the derivation in README, and correct the AGENTS.md bullet. - patch-db/json-patch: a fork of idubrov/json-patch with serde_json swapped for imbl-value + json-ptr, shipping no license text despite declaring dual terms. Add LICENSE-MIT and the verbatim LICENSE-APACHE, a README recording the fork, and normalize the SPDX expression to "MIT OR Apache-2.0". - jsonpath: fill in the MIT template placeholders left as "[2019] [Changseok Han]" and credit the upstream in the README. - init_resize.sh: name RPi-Distro/raspi-config, which it derives from. json-patch and jsonpath_lib are also set publish = false: both sit at crates.io names owned by their upstreams, so publishing a diverged fork from here would be wrong even if it were possible. * chore(license): declare MIT on every manifest, ship it with published crates Five Cargo manifests (backup-fs, patch-db-util, the three start-wrt crates) and eleven package.json files declared no license at all, and the workspace root has no [workspace.package] table to inherit one from, so tooling reported them as unlicensed. Declare MIT on each. exver and rpc-toolkit are published to crates.io but carried no license text in their tarballs; give them a LICENSE. Normalize the copyright holder across the existing files, which variously read "Start9", "Start9 Labs" and "Start9 Labs, Inc.", and rename yasi's LICENSE.md so cargo packages it by convention. The upload-each action's committed ncc bundle inlined 97 dependencies with their notices stripped; build it with --license so the per-dependency notices are generated and committed alongside. The bundle itself is byte-identical. * chore(license): add NOTICE.md and state the licensing policy Nothing in the repo said what "everything is MIT" excludes, so the claim could not be checked. NOTICE.md is now the complete list of files under other terms — if something is not named there, it is MIT — and the root LICENSE, README and the StartOS architecture doc point at it. Also: - CONTRIBUTING gains an inbound-licensing statement and a rule that vendored code keeps its notice and lands in NOTICE.md in the same PR. - .deb packages are built with a usr/share/doc/<pkg>/copyright file, which Debian policy requires and which the hand-written control block omitted. - deny.toml moves to the workspace root and drops copyleft/unlicensed/ allow-osi-fsf-free, removed from cargo-deny's schema in 0.14. Note nothing in CI runs it yet, so it remains documentation rather than a gate. - The Intel BIOS capsule mirrored for the discontinued 2023 Server One is attributed to Intel on the page that serves it. It is Intel's, not ours, and the architecture doc no longer implies the shipped image is MIT end to end. * chore(license): sync lockfiles with the added license fields npm records the root package's license in package-lock.json, so declaring it in package.json leaves the lockfile out of sync — which is exactly what CI's `npm ci` drift gate rejects. Five lockfiles, one line each, no dependency churn. * chore(license): taplo-format deny.toml The relocated file landed at the repo root without going through taplo, whose reorder_keys sorts each [[licenses.clarify]] table's keys. Caught by CI's format-check, which I hadn't run locally because it needs the fmt container. * chore(license): make deny.toml describe the tree it actually governs Nothing had ever run this policy, so it had drifted from reality in both directions. Ran cargo-deny against the real dependency graph: - LGPL-3.0, OpenSSL and Unicode-DFS-2016 were allowed but appear nowhere. LGPL is the one that mattered: allowing it invited a copyleft dependency that would have been incompatible with shipping start-core as MIT. - The webpki and ring clarifications are dead — webpki is gone (rustls-webpki now) and ring declares "Apache-2.0 AND ISC" itself. - Four permissive licenses genuinely in the tree were missing, so 25 crates were rejected for no good reason: Unicode-3.0 (the ICU stack), CDLA-Permissive-2.0 (webpki-roots), BSL-1.0 (xxhash-rust, lazy-bytes-cast) and 0BSD (quoted_printable). MPL-2.0 stays allowed and is now explained: its copyleft is per-file, so linking imbl and friends into an MIT binary is fine as long as we don't modify them. This leaves two genuine GPL-3.0-or-later rejections, which need code changes rather than a policy change, so the CI gate lands with that fix. * chore(license): revert the overreach in this branch Review of my own decisions found several that were wrong or went further than the evidence supported. Reverting them. - Restore "LGPL-3.0" to deny.toml. My commit message claimed allowing it was "incompatible with shipping start-core as MIT". That is wrong: LGPL-3.0 §4 expressly permits combining the library with a work conveyed under other terms, subject to notice and relink conditions. Worse, the entry was not drift — 5b22d0a3b (Aiden McClelland, 2021-06-17) added it in the commit that created the file, as a deliberate carve-out. An unmatched allowance is only a warning, so removing it bought nothing and would have hard-rejected a future LGPL dependency that is in fact perfectly usable. - Restore the root LICENSE to byte-canonical MIT. Editing the license body to add a carve-out risks breaking automated license detection, and the carve-out already lives in README.md and NOTICE.md. Same for start-wrt's LICENSE. - Restore extract-ikconfig. Deleting a working debugging tool from the image was a functional change made for a licensing reason that a NOTICE entry solves. - Restore jsonpath's LICENSE verbatim. Filling in a third party's copyright placeholders is not ours to do; the clarification belongs in the README. - CONTRIBUTING lumped LGPL in with GPL/AGPL as forbidden. Corrected: GPL/AGPL can't be linked into our binaries, LGPL and MPL-2.0 can, with obligations. - NOTICE claimed to be "the complete list" and that anything unlisted is MIT. Softened — I can't guarantee completeness. - Moved brand marks out of "Not MIT" into their own Trademarks section. Trademark is not copyright and is not granted or withheld by a copyright license, so listing logos as a licensing exception was a category error. - The MPL note said obligations attach only if we modify the sources. They attach on distribution (§3.2), modified or not. - The overlay README asserted every unheadered file was GPL "including the Start9-authored ones, which are derivative works". A new file in an OpenWrt tree is not automatically derivative; that gave away Start9's own copyright by assertion. Now stated as a deliberate contribution choice. Also dropped "the one exception" (NOTICE lists several) and softened GPL-2.0-only to GPL-licensed, since the tree mixes -only and -or-later. - Restore the start-wrt font-weight design comment, reworded for the variable face rather than deleted. * refactor(core): replace socks5-impl with fast-socks5 socks5-impl is GPL-3.0-or-later, and it was a direct dependency of start-core, so every product binary linking start-core would have to be conveyed under GPL-3.0. No permissive version exists upstream — the current 0.9.6 is still GPL-3.0-or-later — so this is a swap rather than a bump. fast-socks5 (MIT) covers the same ground: read_command() hands back the target before connecting, which is what lets us keep intercepting .onion (tunnel via the tor service's SOCKS proxy) and .local (resolve over mDNS), and get_socket() unwraps the client tunnel to a plain TcpStream so the keepalive still applies. BIND and UDP ASSOCIATE are still answered CommandNotSupported. The proxy had no test coverage, so this adds three: a round trip through the proxy to an echo server, an unreachable target refused rather than hung, and BIND/UDP rejected. * refactor(start-wrt): replace tracing-rfc-5424 with syslog-tracing, gate licenses in CI tracing-rfc-5424 is GPL-3.0-or-later — the last crate in the tree whose terms we can't meet while conveying our binaries under MIT. syslog-tracing (MIT) writes through libc's syslog(), which lands in the same /dev/log that the old UnixSocket transport targeted, so logread still sees the entries. Its MakeWriter maps tracing levels to syslog severities via make_writer_for. init_logging took a name it then ignored; syslog-tracing can use it as the openlog identity, so startwrt-cli and startwrt-ctrld are now distinguishable in logread instead of sharing one tag. The startwrt-activity marker activity.rs greps for is in the message body, so it is unaffected. Timestamps and tag are dropped from the formatter because syslogd adds its own. With that, `cargo deny check licenses` passes, so it becomes a CI job. LGPL-3.0 stays allowed and simply reports as an unmatched allowance. | 1 个月前 | |
feat(start-wrt): SNI hostname routes end to end — UPnP vendor actions, StartWRT dataplane, Remote Access coexistence (#3783) * feat(start-tunnel): UPnP vendor action for SNI hostname mappings Add X_START9_AddHostnameMapping / X_START9_DeleteHostnameMapping to the shared UPnP IGD server, giving UPnP parity with the PCP HOSTNAME option: a client that reaches a Start9 gateway over UPnP but not PCP no longer silently loses SNI demux. The StartOS port-map client falls back to the vendor action when no gateway grants the hostname over PCP, detecting support via the SCPD action list the discovery already fetched (no PatchDb field, no TS bindings). Refresh re-asserts the route without a remote delete — registration reclaims idempotently for the same target, so re-adding in place avoids a per-tick outage window. Unlike standard UPnP mappings, vendor-action routes are always lease-bearing (clamped to the server max): a permanent SNI binding is reserved for operator-created routes, and an unreaped device route would answer HostnameTaken (fault 800) to its legitimate owner forever. The delete action carries NewInternalPort because an SNI route's ownership is its full (peer, internal port) target, mirroring the PCP lifetime-0 MAP, and is gated on is_known_client like the PCP delete. Non-TCP requests are refused (the demux is TCP-only), and hostnames are validated client-side before being interpolated into the envelope. Both handlers guard on the backend having an SNI dataplane, faulting 801 HostnameNotSupported otherwise — the UPnP twin of the PCP path's RESULT_UNSUPP_HOSTNAME refusal. Served by StartTunnel today; a StartWRT gateway (which has no dataplane yet) advertises-but-refuses until its demux lands, then serves the action with no further edit. * feat(start-wrt): SNI hostname-route dataplane StartWRT's port-control gateway now serves TLS-SNI hostname routes end to end — PCP HOSTNAME and the X_START9_AddHostnameMapping UPnP vendor action both work against the router instead of faulting 801, so several devices (or several services on one StartOS server) share an external port such as 443, demuxed by ClientHello hostname. Dataplane: the shared SniDemux runs as-is; what StartWRT needed was the plumbing around it. The reply-path divert's nft half ships declaratively as an fw4 include (12-startwrt-sni-divert.nft, `mark or` to preserve the 0x80 DNAT-return bit) — fw4 re-renders includes on every reload, so no reload window can drop it. The iproute2 half is parameterized via a new shared DivertConfig (route table 5344 to clear the VLAN-tag table namespace, masked fwmark to match the or-set mark, manage_nft off); defaults reproduce StartOS/StartTunnel behavior bit-for-bit. The `socket transparent` expression needs kmod-nft-socket (+kmod-nf-socket), added to the image diffconfig in this same commit — without the module fw4 refuses the entire ruleset, so the include and the kmods must ship together. Admission: each demuxed port gets a WAN-input ACCEPT rule (apf_sni_<port>, tagged _apf_label 'SNI' via a new FirewallRule field), written inline under the write lock so a concurrent plain-forward scan can never miss it; the demux's on_change teardown drops it, the sweep heals strays and gaps, and daemon start purges leftovers (routes are demux-memory only — finite-lease, device-renewed — so rules must not outlive them). The rule also makes the port read as router-reserved, keeping plain auto forwards off a demuxed port for free. Conversely add_sni_forward refuses ports already DNAT-forwarded or answered by the router itself (Remote Access, VPN): the demux's specific (wan_ip, port) bind would beat their wildcard binds and capture traffic it has no route for. WAN re-key: listeners bind the WAN address itself, so a new address strands them. A new wan hotplug hook fires published-ports.wan-changed (hidden RPC, daemon-forwarded like reconcile) to re-key immediately via the new shared SniDemux::rekey_ipv4 — which never fires the teardown callback, since the port set is unchanged — with the sweep as a once-a-minute backstop. Visibility: published-ports.auto-list now appends one row per live route (label "SNI", new hostname field, device resolved from the target address) via the new shared SniDemux::snapshot; the Automatic table gains a Hostname column. API_CONTRACT, the user docs' Automatic Port Forwarding page, and the unreleased 1.1.0 changelog entry (which claimed StartWRT has no SNI demux) updated to match; build.mk's staging deps now cover backend/hotplug and backend/nftables (pre-existing gap). * fix(start-wrt): SNI demux binds beside the UI and coexists with Remote Access Bench testing found the StartWRT dataplane dead on 443 and worse than dead: the web UI wildcard-binds [::]:443, so the demux's specific (wan_ip, 443) bind failed EADDRINUSE forever in its spawned retry loop — while the grant had already succeeded and opened the apf_sni_443 WAN admit rule. WAN 443 traffic fell through to the UI's wildcard socket, serving the router admin interface to WAN clients with Remote Access set to Never. On a public-WAN router (where "behind NAT" mode writes no rules and so nothing conflict-refuses the route) that would have been the open internet. Three changes close it: - The demux listener and the daemon's UI 80/443 listeners all bind with SO_REUSEPORT (the DNS :53 pattern). TCP delivery prefers the most specific bound address, so the demux takes WAN-IP-destined connections and the UI wildcard keeps the LAN. - Grants are bind-gated: SniDemux::register/register_fallback bind inline and refuse with PCP NO_RESOURCES (UPnP fault 501) on failure, rolling back the registration — a grant can never outrun its socket and leave the admitted port served by whatever shares it. A re-key bind failure now drops that port's routes and fires the teardown callback rather than stranding the admit rule. - Remote Access coexists with hostname routes on 443 instead of reserving it (it is the default mode behind NAT, and demanding it be turned off to share 443 was untenable): its rules no longer count as SNI conflicts on 443; the daemon instead registers its own UI as the demuxed port's fallback, so no-SNI/unknown-SNI connections (browsing the router by IP sends no SNI) still reach the UI. The fallback leg is a plain connect (a source-preserving dial to ourselves would be martian-dropped) and enforces the same source scoping the displaced firewall rules encoded — any source in "always", RFC1918-only in "default" behind NAT, none in "never" — re-synced on route add, on a Remote Access change, and by the sweep, which also clears it when the last 443 route expires. SSH (server-speaks-first) and the port-80 redirect (plain HTTP) can't ride an SNI peek, so those Remote Access ports — and manual forwards and the VPN port — keep refusing routes. The IGD hostname stub tests move to an unprivileged external port: registration now really binds, and 443 needs root the runner lacks. * feat(start-wrt): router-port confirm dialog names the port's actual holder The publish-confirmation dialog said "Used by This Router" even when the colliding WAN-input rule was an SNI-demux admit rule — a port really held by a device's hostname routes. router_reserved_overlaps now classifies each overlapping rule, RouterPortCollision splits the specs into router_ports and sni_ports (the latter enriched from the live demux with the routed hostnames and owning devices, named the same way auto-list rows are), and the dialog composes its copy from the actual holders: router services, hostname routes, or both on a shared port. The override semantics are unchanged. * fix(start-wrt): revoking a device's permission drops its SNI hostname routes `close_device_forwards` removed a device's `apf_*` UCI redirects but never touched the SNI demux, so its hostname routes kept delivering WAN traffic — admit rule and all — until their lease lapsed, up to an hour after the toggle went off or the device was forgotten. StartTunnel's `clear_for_peer` already covers routes; this brings StartWRT in line. Routes are keyed by target address, not device, so rather than carry a grant-time address→MAC map that can drift, the owner is re-derived the way a grant derives it (neighbor table, else DHCP leases and static hosts) and any route whose device is no longer `_allow_pcp` is unregistered. That audit runs on every revocation and once a minute from the sweep, which also catches an address recycled to a different, unauthorized device. An address that maps to no device is left to its lease rather than reaped on a guess. * fix(start-wrt): hairpinned SNI clients get a working connection, not a hang A LAN client dialing the router's WAN address for a routed hostname hung. The demux opens the internal leg from the client's own source address, so the target — sitting on the same bridge as the client — answered it directly over that segment. The reply never came back through the router, and the client discarded it as coming from an address it never dialed. Drop source preservation for exactly that case and dial the target as ourselves instead, the trade the DNAT path already makes with the hairpin masquerade in `build/lib/scripts/forward-port` — and, on this router, the one fw4's redirect reflection already makes for every manual published port, so the two now behave alike. The demux learns a host's segments through `LocalPrefix`, the userspace equivalent of that script's `target_prefix`: StartWRT answers it from the `br-*` addresses it already parses for SSDP, so the test is per-bridge. A client on another profile's VLAN, or on the WAN, still routes its replies through the router and keeps its address. The tunnel supplies no resolver — every path through its WireGuard hub returns to it, so nothing there needs the trade. The check runs before the dial, never as a fallback after a failed one: the backend gates LAN-only addresses on the source being private, so a blanket plain-connect would present a WAN client as LAN-local. * docs(start-wrt): a hostname route is reachable from every Security Profile The trust note tells the reader to isolate an untrusted device on its own profile so it cannot act for the devices they trust — true of opening forwards, and easy to over-read as reach isolation. A profile's LAN Access setting is a forwarding control, and the router serves a hostname route from its own socket, so no profile boundary stands between a client and a routed hostname. Say so, alongside the fact that makes it uninteresting: the service is published to the Internet either way. * docs(rfcs): UPnP vendor-action status claims only Start9-internal acceptance Aiden's review flagged the bare "Status: accepted" as reading like a standards-status claim. The doc is not IETF-targeted — its own open question 4 notes a UPnP vendor action has no standards venue — so the status line now scopes the acceptance to Start9 and says where external documentation lives (the SCPD). Claude-Session: https://claude.ai/code/session_012peNJE6QiAEMDJEjWYDwjE * fix: address SNI hostname route review findings * fix: make SNI route grants transactional * fix(start-wrt): inline divert setup error mapping * fix(start-os): restore mapped external IP handling * chore: regenerate start-core TypeScript bindings * style(start-os): format port-map tests --------- Co-authored-by: Helix <267227783+helix-nine@users.noreply.github.com> | 13 天前 |
| 文件 | 最后提交记录 | 最后更新时间 |
|---|---|---|
| 2 个月前 | ||
| 4 天前 | ||
| 13 天前 | ||
| 11 小时前 | ||
| 26 天前 | ||
| 1 个月前 | ||
| 4 天前 | ||
| 11 天前 | ||
| 2 个月前 | ||
| 2 个月前 | ||
| 12 天前 | ||
| 26 天前 | ||
| 4 天前 | ||
| 2 个月前 | ||
| 27 天前 | ||
| 1 个月前 | ||
| 1 个月前 | ||
| 13 天前 |