$npx -y skills add tikoci/routeros-skills --skill routeros-hotspotRouterOS hotspot captive portal for wired/wireless access control. Use when: configuring hotspot on RouterOS, setting up captive portal, writing hotspot profiles or instances, configuring walled garden, setting DHCP option 114 (RFC 8910 captive portal URI), integrating RADIUS wit
| 1 | # RouterOS Hotspot |
| 2 | |
| 3 | ## How Hotspot Chains Work |
| 4 | |
| 5 | Hotspot traffic intercept runs **before** the regular firewall input/forward chains. This is the single most important fact to internalize: |
| 6 | |
| 7 | - `/ip/hotspot` binds to a bridge or interface — all traffic on that interface enters the hotspot chain first |
| 8 | - Firewall rules blocking TCP 80/443 from the hotspot interface do **NOT** block the captive portal login page — hotspot handles it before the firewall sees it |
| 9 | - RouterOS automatically injects dynamic firewall rules (`hs-unauth`, `hs-auth` chains) — do not manually create, remove, or interfere with these hotspot-managed rules |
| 10 | |
| 11 | **Common mistake:** Adding a DROP rule for port 443 from bridge-hotspot to "fix a security gap" — this breaks the HTTPS login page silently. |
| 12 | |
| 13 | ## Hotspot Profile |
| 14 | |
| 15 | ```routeros |
| 16 | /ip/hotspot/profile/add \ |
| 17 | name=my-profile \ |
| 18 | hotspot-address=10.20.0.1 \ |
| 19 | login-by=https,mac,http-pap \ |
| 20 | mac-auth-mode=mac-as-username-and-password \ |
| 21 | dns-name=login.example.com \ |
| 22 | ssl-certificate=login.example.com.crt_0 \ |
| 23 | nas-port-type=ethernet \ |
| 24 | use-radius=yes \ |
| 25 | radius-accounting=yes \ |
| 26 | html-directory-override=hotspot-files |
| 27 | ``` |
| 28 | |
| 29 | Key properties: |
| 30 | |
| 31 | - `ssl-certificate=` — reference the name after import (RouterOS appends `_0` to imported certificate names) |
| 32 | - `nas-port-type=` — use `ethernet` for wired hotspots and `wireless-ieee-802-11-g` for wireless hotspots |
| 33 | - `html-directory-override=` — must match the exact folder name on the router's filesystem |
| 34 | - `login-by=https` — serves the login page over HTTPS; requires `www-ssl` service enabled with the same certificate |
| 35 | - `use-radius=yes` — when set, **local `/ip/hotspot/user` entries are bypassed**; adding them has no effect |
| 36 | |
| 37 | ## Hotspot Instance |
| 38 | |
| 39 | ```routeros |
| 40 | /ip/hotspot/add \ |
| 41 | name=hotspot1 \ |
| 42 | interface=bridge-hotspot \ |
| 43 | profile=my-profile \ |
| 44 | address-pool=pool-hotspot \ |
| 45 | addresses-per-mac=2 \ |
| 46 | idle-timeout=5m \ |
| 47 | keepalive-timeout=none \ |
| 48 | disabled=no |
| 49 | ``` |
| 50 | |
| 51 | **Note:** `keepalive-timeout=none` disables the keepalive. `keepalive-timeout=0` is NOT valid — it is ignored. |
| 52 | |
| 53 | **Note:** RouterOS hotspot relies on NAT and is **IPv4-only**. IPv6 clients are not supported by the hotspot subsystem. |
| 54 | |
| 55 | ## DHCP Option 114 — Captive Portal API (RFC 8910) |
| 56 | |
| 57 | Option 114 (standardized 2020, RFC 8910) signals the captive portal URI to clients. It is underrepresented in LLM training data. |
| 58 | |
| 59 | ```routeros |
| 60 | # force=yes is REQUIRED — without it, clients whose DHCP Parameter Request |
| 61 | # List does not include code 114 (e.g. iOS, Android) silently skip the option |
| 62 | /ip/dhcp-server/option/add \ |
| 63 | name=captive-portal \ |
| 64 | code=114 \ |
| 65 | force=yes \ |
| 66 | value="'https://login.example.com/api'" |
| 67 | |
| 68 | /ip/dhcp-server/option/sets/add \ |
| 69 | name=captive-portal-set \ |
| 70 | options=captive-portal |
| 71 | |
| 72 | /ip/dhcp-server/set my-dhcp-server dhcp-option-set=captive-portal-set |
| 73 | ``` |
| 74 | |
| 75 | **Option value syntax:** outer double quotes, inner single quotes — `"'https://...'"`. Missing inner quotes cause the option to be sent as a binary blob, not a string URI. |
| 76 | |
| 77 | **api.json timing:** RouterOS creates `hotspot/api.json` only after the **first client CAPPORT probe** — not at hotspot enable time. Move it to the html-directory-override folder after first client connects: |
| 78 | |
| 79 | ```routeros |
| 80 | # Handles both flash/ and non-flash storage layouts |
| 81 | :local srcPath "hotspot/api.json" |
| 82 | :local dstPath "hotspot-files/api.json" |
| 83 | :if ([:len [/file find name="flash"]] > 0) do={ |
| 84 | :set srcPath "flash/hotspot/api.json" |
| 85 | :set dstPath "flash/hotspot-files/api.json" |
| 86 | } |
| 87 | :if ([:len [/file find name=$srcPath]] > 0) do={ |
| 88 | /file set [find name=$srcPath] name=$dstPath |
| 89 | } else={ |
| 90 | :log warning "api.json not yet created — run after first client CAPPORT probe" |
| 91 | } |
| 92 | ``` |
| 93 | |
| 94 | ## Walled Garden |
| 95 | |
| 96 | Use a consistent `comment=` tag for idempotent add/remove. Without it, repeated script runs accumulate duplicate entries. |
| 97 | |
| 98 | ```routeros |
| 99 | # Remove only our entries, not manually-added ones |
| 100 | /ip/hotspot/walled-garden/ip/remove [find comment="my-wg"] |
| 101 | |
| 102 | /ip/hotspot/walled-garden/ip/add dst-host=example.com action=accept comment="my-wg" |
| 103 | /ip/hotspot/walled-garden/ip/add dst-host=*.example.com action=accept comment="my-wg" |
| 104 | ``` |
| 105 | |
| 106 | **IP vs HTTP walled garden:** |
| 107 | |
| 108 | - `/ip/hotspot/walled-garden/ip` — layer 3 match by destination host, applied BEFORE authentication. Use for HTTPS destinations. |
| 109 | - `/ip/hotspot/walled-garden` — layer 7 URL pattern match, requires HTTP. Does NOT work for HTTPS. |
| 110 | |
| 111 | ## SSL Certificate Note |
| 112 | |
| 113 | The hotspot profile references `ssl-certificate=name.crt_0` (RouterOS appends `_0` on import). Enable `www-ssl` with the same certificate: |
| 114 | |
| 115 | ```routeros |
| 116 | /ip/service/set www-ssl disab |