#!/usr/bin/env python3 """Apply local-first captive portal on RB2011 Hub ONLY. Never touch RB4011.""" from __future__ import annotations import time from pathlib import Path from mk_hub_exec import HOST, PASSWORD, USER, import_rsc, run ROOT = Path(__file__).resolve().parents[1] KIT = ROOT / "deploy" / "saas" / "nux-custom" / "ui" / "ui_custom" / "hs-kit" SLUG = "otantik-hub" VPS = "83.147.38.107" HTML_FILES = [ "login.html", "alogin.html", "status.html", "error.html", "logout.html", "ticket.html", "chat.html", "apropos.html", "services.html", "code.html", "pay.html", "md5.js", ] # Fetched from VPS then recompressed locally before upload (CNA-friendly). 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"), ] ASSET_REL = [ "styles/bootstrap.css", "styles/style.css", "fonts/bootstrap-icons.css", "fonts/bootstrap-icons.woff2", "fonts/bootstrap-icons.woff", "scripts/bootstrap.min.js", "scripts/custom.js", "md5.js", ] RSC = r""" :put "HUB-LOCAL-PORTAL START" /system backup save name=wo-before-local-portal :do { /ipv6 nd set [find where interface=all] ra-lifetime=none advertise-mac-address=no } on-error={} :if ([:len [/ipv6 nd find where interface=bridge-hotspot]] = 0) do={ :do { /ipv6 nd add interface=bridge-hotspot ra-lifetime=none advertise-mac-address=no } on-error={} } else={ :do { /ipv6 nd set [find where interface=bridge-hotspot] 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.8.192.1 comment=wifi-otantik-hs } on-error={} :do { /ip dns static set [find where name="hotspot.lan"] address=10.8.192.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={} } # Cookie court (1h) ; dns-name vide pour accès IP. Ne pas toucher trial ici # (login-by / trial-user-profile gérés par push_trial / portail captif). :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={} # WebFig office only + brand DNS → hotspot (pas GoDaddy / pas WebFig). :do { /ip service set www address=192.168.88.0/24 } 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.8.192.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.8.192.1 comment=wifi-otantik-hs } on-error={} # Keep wo-byp:* (intentional). Empty dns-name + static DNS for brand on-LAN. # No QUIC block here: hotspot=!auth matcher is often invalid on this ROS, # and a broad UDP/443 drop breaks HTTP/3 for logged-in clients. :do { /ip firewall filter remove [find where comment="wo-hs-block-quic"] } on-error={} :put "HUB-LOCAL-PORTAL RSC DONE" /ip hotspot profile print where name=hs-otantik /ip dns static print where name=hotspot.lan /ipv6 nd print /ip firewall filter print where comment="wo-hs-block-quic" """ def sftp_put_bytes(sftp, remote: str, data: bytes) -> None: remote = remote.replace("\\", "/") parts = remote.split("/") # MikroTik SFTP: create parent dirs one by one if needed 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-hub/1.0"}) with urllib.request.urlopen(req, timeout=45) as resp: return resp.read() def main() -> None: print("identity:", run("/system identity print")) idn = run("/system identity print") if "Otantik Hub" not in idn or "Nkoabang" in idn: raise SystemExit("refusing: not Hub RB2011") print(import_rsc("wo-local-portal.rsc", RSC, timeout=90)) import paramiko mk = paramiko.SSHClient() mk.set_missing_host_key_policy(paramiko.AutoAddPolicy()) mk.connect(HOST, username=USER, password=PASSWORD, timeout=25, allow_agent=False, look_for_keys=False) sftp = mk.open_sftp() print("uploading kit assets to hotspot/ ...") for rel in ASSET_REL: src = KIT.joinpath(*rel.split("/")) if not src.is_file(): print("MISS", rel) continue data = src.read_bytes() # normalize newlines for text assets if src.suffix.lower() in {".css", ".js", ".html", ".svg"}: data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n") remote = "hotspot/" + rel sftp_put_bytes(sftp, remote, data) print("put", remote, len(data)) print("compressing + uploading portal 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) sftp_put_bytes(sftp, f"hotspot/{name}", lite) print(f"put hotspot/{name} {len(raw)} -> {len(lite)} bytes") except Exception as e: print(f"skip {name}: {e}") print("fetching HTML from VPS via MikroTik ...") fetch_cmds = [] for f in HTML_FILES: url = f"http://{VPS}/hotspot/{SLUG}/{f}" dst = f"hotspot/{f}" fetch_cmds.append( f':do {{ /tool fetch url="{url}" mode=http dst-path="{dst}" check-certificate=no }} on-error={{ :put "skip {f}" }}' ) fetch_rsc = ":put FETCH-START\n" + "\n".join(fetch_cmds) + "\n:delay 2s\n:put FETCH-DONE\n" sftp_put_bytes(sftp, "wo-fetch-portal.rsc", fetch_rsc.encode("utf-8")) sftp.close() print(run("/import file-name=wo-fetch-portal.rsc", timeout=180)) # quick verify print(run(r""":put "===DST==="; :put [/file get [/file find name="hotspot/login.html"] size];""", timeout=30)) mk2 = paramiko.SSHClient() mk2.set_missing_host_key_policy(paramiko.AutoAddPolicy()) mk2.connect(HOST, username=USER, password=PASSWORD, timeout=25, allow_agent=False, look_for_keys=False) sftp2 = mk2.open_sftp() with sftp2.file("hotspot/login.html", "r") as f: body = f.read() if isinstance(body, bytes): body = body.decode("utf-8", "replace") print("has link-status dst", 'name="dst" value="$(link-status)"' in body) print("has local slide-1.jpg", "slide-1.jpg" in body) print("has local bundle.jpg", "bundle.jpg" in body) print("has remote 83.147 image?", "83.147.38.107" in body and ("slide-" in body or "bundle" in body)) print("has woHotspotLite?", "woHotspotLite" in body) for name, *_ in IMAGE_SPECS: try: sz = sftp2.stat(f"hotspot/{name}").st_size print(f"size {name}", sz) except OSError: print(f"MISS {name}") sftp2.close() mk2.close() print("DONE Hub-only local portal") if __name__ == "__main__": main()