#!/usr/bin/env python3 """Apply the same local-first captive portal as Hub RB2011 onto RB4011 Nkoabang. Does not touch the RB2011 (10.88.0.6). Reaches RB4011 via VPS WireGuard 10.88.0.4. Nested hs-kit CSS/JS 404 on ROS7 ARM; splash uses flat wo.css / wo.js like the VPS generator, plus compressed images on the box. """ from __future__ import annotations from pathlib import Path from mk_nko_wg_exec import connect_mk, import_rsc, run ROOT = Path(__file__).resolve().parents[1] SLUG = "otantik-hub-nkoabang" VPS = "83.147.38.107" HS_IP = "10.5.53.1" HTML_FILES = [ "login.html", "alogin.html", "status.html", "error.html", "logout.html", "ticket.html", "chat.html", "apropos.html", "services.html", "code.html", "pay.html", "menu-main.html", "menu-highlights.html", "menu-bell.html", "md5.js", "wo.css", "wo.js", ] IMAGE_SPECS = [ ("logo.png", 176, 70, "PNG"), ("favicon.png", 64, 70, "PNG"), ("bundle.jpg", 720, 62, "JPEG"), ("slide-1.jpg", 640, 58, "JPEG"), ("slide-2.jpg", 640, 58, "JPEG"), ("slide-3.jpg", 640, 58, "JPEG"), ] RSC = r""" :put "NKO-LOCAL-PORTAL START" /system backup save name=wo-before-local-portal-4011 :do { /ipv6 nd set [find where interface=all] ra-lifetime=none advertise-mac-address=no } on-error={} :if ([:len [/ipv6 nd find where interface=hs-bridge]] = 0) do={ :do { /ipv6 nd add interface=hs-bridge ra-lifetime=none advertise-mac-address=no } on-error={} } else={ :do { /ipv6 nd set [find where interface=hs-bridge] ra-lifetime=none advertise-mac-address=no } on-error={} } :do { /ip dns static remove [find where name="hotspot.lan" and comment="wifi-otantik-hs"] } on-error={} :do { /ip dns static add name=hotspot.lan address=10.5.53.1 comment=wifi-otantik-hs } on-error={} :do { /ip dns static set [find where name="hotspot.lan"] address=10.5.53.1 } on-error={} :foreach h in={"www.gstatic.com";"*.gstatic.com"} do={ :do { /ip hotspot walled-garden remove [find where dst-host=$h] } on-error={} } :do { /ip hotspot profile set [find name=hsprof1] dns-name="" html-directory=hotspot } on-error={} :do { /ip hotspot profile set [find name=hsprof1] http-cookie-lifetime=1h } on-error={} :do { /ip hotspot profile set [find name=hs-otantik] dns-name="" html-directory=hotspot } on-error={} :do { /ip hotspot profile set [find name=hs-otantik] http-cookie-lifetime=1h } on-error={} :do { /ip service set www address=172.30.0.0/16 } on-error={} :do { /ip dns static remove [find where name="otantik.net" and comment="wifi-otantik-hs"] } on-error={} :do { /ip dns static add name=otantik.net address=10.5.53.1 comment=wifi-otantik-hs } on-error={} :do { /ip dns static remove [find where name="www.otantik.net" and comment="wifi-otantik-hs"] } on-error={} :do { /ip dns static add name=www.otantik.net address=10.5.53.1 comment=wifi-otantik-hs } on-error={} :do { /ip firewall filter remove [find where comment="wo-hs-block-quic"] } on-error={} :put "NKO-LOCAL-PORTAL RSC DONE" /ip hotspot profile print where name=hsprof1 /ip dns static print where comment="wifi-otantik-hs" /ip service print where name=www """ def sftp_put_bytes(sftp, remote: str, data: bytes) -> None: remote = remote.replace("\\", "/") parts = remote.split("/") if len(parts) > 1: cur = "" for p in parts[:-1]: cur = f"{cur}/{p}" if cur else p try: sftp.stat(cur) except OSError: try: sftp.mkdir(cur) except OSError: pass with sftp.file(remote, "wb") as f: f.write(data) def compress_image(data: bytes, max_w: int, quality: int, fmt: str) -> bytes: from io import BytesIO from PIL import Image im = Image.open(BytesIO(data)) if fmt == "JPEG": im = im.convert("RGB") elif im.mode not in ("RGB", "RGBA"): im = im.convert("RGBA") w, h = im.size if w > max_w: nh = max(1, int(round(h * (max_w / float(w))))) im = im.resize((max_w, nh), Image.Resampling.LANCZOS) out = BytesIO() if fmt == "JPEG": im.save(out, format="JPEG", quality=quality, optimize=True, progressive=True) else: im.save(out, format="PNG", optimize=True) return out.getvalue() def fetch_url(url: str) -> bytes: import urllib.request req = urllib.request.Request(url, headers={"User-Agent": "wifi-otantik-nko/1.0"}) with urllib.request.urlopen(req, timeout=45) as resp: return resp.read() def main() -> None: idn = run("/system identity print") print("identity:", idn) if "Nkoabang" not in idn: raise SystemExit("refusing: not RB4011 Nkoabang") board = run("/system resource get board-name") print("board:", board) if "4011" not in board and "4001" not in board: print("WARN board-name unexpected, continuing because identity matched") print(import_rsc("wo-local-portal-4011.rsc", RSC, timeout=90)) print("downloading portal files from VPS ...") blob: dict[str, bytes] = {} for name in HTML_FILES: url = f"http://{VPS}/hotspot/{SLUG}/{name}" try: data = fetch_url(url) if name.endswith((".html", ".css", ".js")): data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") blob[name] = data print("got", name, len(data)) except Exception as e: print("skip", name, e) print("compressing images ...") for name, max_w, quality, fmt in IMAGE_SPECS: url = f"http://{VPS}/hotspot/{SLUG}/{name}" try: raw = fetch_url(url) lite = compress_image(raw, max_w, quality, fmt) blob[name] = lite print(f"img {name} {len(raw)} -> {len(lite)}") except Exception as e: print("skip", name, e) print("uploading to RB4011 hotspot/ ...") jump, mk = connect_mk() try: sftp = mk.open_sftp() for name, data in blob.items(): sftp_put_bytes(sftp, "hotspot/" + name, data) print("put", name, len(data)) sftp.close() finally: mk.close() jump.close() jump, mk = connect_mk() try: sftp = mk.open_sftp() with sftp.file("hotspot/login.html", "r") as f: body = f.read() if isinstance(body, bytes): body = body.decode("utf-8", "replace") print("has wo.css", 'href="wo.css"' in body) print("has woHotspotLite", "woHotspotLite" in body) print("has local slide-1.jpg", "slide-1.jpg" in body) print("has portal kit CDN?", "ui/ui_custom/hs-kit" in body) for name, *_ in IMAGE_SPECS: try: print("size", name, sftp.stat(f"hotspot/{name}").st_size) except OSError: print("MISS", name) try: print("size wo.css", sftp.stat("hotspot/wo.css").st_size) print("size wo.js", sftp.stat("hotspot/wo.js").st_size) except OSError as e: print("MISS css/js", e) sftp.close() finally: mk.close() jump.close() print("DONE RB4011 local portal (RB2011 untouched)") if __name__ == "__main__": main()