UHM — UniFi Hotspot Manager
|
Many small and medium businesses, cybercafés, and other environments decide to deploy a captive portal and choose Ubiquiti UniFi technology (APs, switches, etc.). However, these setups need DHCP functions, traffic control, access policies, and filtering, among others, which normally require dedicated management hardware from the brand, but but the high cost of this hardware can make it unaffordable. So, they opt to use the UniFi Network self-hosted software on a PC, but they still need third-party hardware or software to provide these functions. UHM fills this gap, extending the capabilities of UniFi Network self-hosted under Linux. It provides the DHCP service required for the Third-Party Gateway scenario, respects UniFi's captive portal and vouchers, and adds an additional layer of access policies via ACLs and filtering via |
Muchas pequeñas y medianas empresas, cibercafés y otros entornos, deciden implementar un portal cautivo y eligen tecnología Ubiquiti UniFi (APs, switches, etc.). Sin embargo, estas instalaciones necesitan funciones de DHCP, control de tráfico, políticas de acceso y filtrado, entre otras, que normalmente requieren hardware dedicado de administración de la marca, pero que, por su alto costo, no siempre pueden adquirir. Entonces, optan por utilizar el software UniFi Network self-hosted en un PC, pero igualmente siguen necesitando hardware o software de terceros que proporcione estas funciones. UHM llena este vacío, ampliando las capacidades de UniFi Network self-hosted bajo Linux. Proporciona el servicio DHCP necesario para el escenario Third-Party Gateway, respeta el portal cautivo y los vouchers de UniFi, y añade una capa adicional de políticas de acceso mediante ACLs y filtrado mediante |
UniFi gateway alone:
| Stage | Description | Descripción |
|---|---|---|
| Joins the SSID | DHCP lease from the gateway | Lease DHCP del gateway |
| Before redeeming a voucher | Held at the captive portal by the AP. Tracked only as an unauthorized guest session | Retenido en el portal cautivo por el AP. Solo se rastrea como sesión de invitado no autorizada |
| Redeems a valid voucher | Marked authorized; keeps whatever IP it already had | Queda autorizado; conserva la IP que ya tenía |
| While authorized | Full access until the voucher expires | Acceso completo hasta que expire el voucher |
| Voucher expires | Back to the captive portal; must redeem another one | Vuelve al portal cautivo; debe canjear otro |
| Never redeems a voucher | Stays at the portal indefinitely, retrying forever and holding a DHCP lease the whole time | Se queda en el portal indefinidamente, reintentando por siempre y ocupando un lease DHCP todo ese tiempo |
| Admin unauthorizes / deletes the voucher | Client returns to the portal | El cliente vuelve al portal |
| Corporate / infrastructure devices | Need a separate SSID, VLAN or manual per-client authorization | Requieren un SSID aparte, una VLAN o autorización manual por cliente |
| Durable record of voucher activity | stat/voucher drops a voucher once it expires or its quota runs out |
stat/voucher descarta un voucher cuando expira o se agota su cuota |
| Hardware required | UDM, UDM-Pro, Cloud Key or equivalent gateway | UDM, UDM-Pro, Cloud Key o gateway equivalente |
Unifi Hotspot Manager - UHM:
| Stage | Description | Descripción |
|---|---|---|
| Joins the SSID | DHCP lease from pydhcpd, taken from the block pool range (SERV_INI_RANGE_BLOCK-SERV_END_RANGE_BLOCK) |
Lease DHCP de pydhcpd, tomado del rango del pool de bloqueo (SERV_INI_RANGE_BLOCK-SERV_END_RANGE_BLOCK) |
| Before redeeming a voucher | Written into uhm-grace.txt with a first-seen timestamp. The macgrace ipset limits it to the portal ports and DNS to the configured resolvers only |
Se escribe en uhm-grace.txt con timestamp de primer contacto. El ipset macgrace lo limita a los puertos del portal y al DNS de los resolvers configurados |
| Redeems a valid voucher | Promoted to uhm-auth.txt, assigned a fixed IP in the hotspot range, lease released and client kicked so it reconnects on the new IP |
Promovido a uhm-auth.txt, se le asigna una IP fija del rango hotspot, se libera su lease y se lo desasocia para que reconecte con la IP nueva |
| While authorized | Same, plus firewall enforcement via the machotspot ipset and optional Squid/proxy routing |
Igual, más la aplicación de firewall vía el ipset machotspot y el enrutamiento opcional por Squid/proxy |
| Voucher expires | Removed from uhm-auth.txt, lease released, re-enters uhm-grace.txt with a fresh grace timer — same as a brand-new client |
Se elimina de uhm-auth.txt, se libera su lease y vuelve a entrar a uhm-grace.txt con un temporizador de gracia nuevo — igual que un cliente recién llegado |
| Never redeems a voucher | After BLOCKDHCP_GRACE_SECONDS (default 24h) it moves permanently to blockdhcp.txt and pydhcpd stops issuing it any lease at all |
Tras BLOCKDHCP_GRACE_SECONDS (default 24h) pasa permanentemente a blockdhcp.txt y pydhcpd deja de entregarle lease alguno |
| Admin unauthorizes / deletes the voucher | Removed from uhm-auth.txt and sent back through the grace cycle. The stale UniFi session it leaves behind cannot re-authorize it — only a new voucher can |
Se elimina de uhm-auth.txt y vuelve al ciclo de gracia. La sesión residual que UniFi deja atrás no puede reautorizarlo: solo un voucher nuevo puede |
| Corporate / infrastructure devices | Listed in mac-*.txt: fixed address and no timer at the DHCP level, plus automatic authorize-guest in UniFi every cycle so the AP never holds them at the portal on a Guest/Hotspot LAN |
Se listan en mac-*.txt: dirección fija y sin temporizador a nivel DHCP, más authorize-guest automático en UniFi cada ciclo para que el AP nunca los retenga en el portal en una WLAN Guest/Hotspot |
| Durable record of voucher activity | /var/log/uhm.log keeps the full history, and uhmunifi.sh cross-references it against the live controller |
/var/log/uhm.log conserva el historial completo, y uhmunifi.sh lo cruza contra el controlador en vivo |
| Hardware required | One UniFi AP plus a Linux host running the self-hosted controller | Un AP UniFi más un host Linux corriendo el controlador self-hosted |
| Resource | Minimum |
|---|---|
| CPU | 2 cores |
| RAM | 4 GB |
| Disk | 8 GB |
Approximate values, dominated by UniFi Network self-hosted -- can vary depending on version, number of managed devices, and environment.
UHMitself (uhmd.sh/uhmleases.sh/pydhcpd.py) adds negligible overhead.Valores aproximados, dominados por UniFi Network self-hosted -- pueden variar según la versión, la cantidad de dispositivos gestionados y el entorno.
UHMen sí (uhmd.sh/uhmleases.sh/pydhcpd.py) agrega una sobrecarga mínima.
| Component | Tested Version |
|---|---|
| UniFi OS Server | 5.1.15 |
| UniFi Network (self-hosted) | 10.4.57 |
iptables |
1.8.10 |
ipset |
7.19 |
pydhcpd |
latest |
UHMonly verifies UniFi Network self-hosted / UniFi OS Server — it does not install either. If neither is installed yet, useunifisetup.shto install it first, then runuhmsetup.sh.
UHMsólo verifica UniFi Network self-hosted / UniFi OS Server — no instala ninguno de los dos. Si aún no está instalado, useunifisetup.shpara instalarlo primero, y luego ejecuteuhmsetup.sh.
UHM is designed around a single guest network. Each UHM installation supports exactly:
pydhcp (the DHCP backend) and UHM's own network management are both designed to operate on exactly one Network and one IPv4 subnet.
Additional Networks, VLANs, or ESSIDs can exist on the same UniFi Controller, but they are outside the scope of that UHM installation — they must be provided and managed by separate third-party infrastructure (DHCP, routing, firewall, etc), not by pydhcp/UHM.
In other words, UHM does not provide multi-network management. A single UniFi Controller can contain both the one Network managed by UHM and other Networks managed independently — the important distinction is that only one Network belongs to the UHM deployment.
|
UHM está diseñado alrededor de una sola red de invitados. Cada instalación de UHM soporta exactamente:
pydhcp (el backend DHCP) y la gestión de red propia de UHM están diseñados para operar sobre exactamente una Network y una subred IPv4.
Pueden existir otras Networks, VLANs o ESSIDs en el mismo controlador UniFi, pero quedan fuera del alcance de esa instalación de UHM — deben ser provistas y gestionadas por infraestructura de terceros independiente (DHCP, routing, firewall, etc), no por pydhcp/UHM.
En otras palabras, UHM no provee gestión multi-red. Un mismo controlador UniFi puede contener tanto la única Network gestionada por UHM como otras Networks gestionadas de forma independiente — la distinción importante es que solo una Network pertenece al despliegue de UHM.
|
UniFi Controller
|
+-------------+--------------+
| |
Network: Default Other Networks
Third-Party Gateway VLANs / Networks
| |
Hotspot |
| |
Guest ESSID |
| |
v v
+-------------------+ +-------------------+
| UHM | | Third-party |
| | | infrastructure |
| 1 Network | | |
| 1 ESSID | | DHCP / Routing |
| 1 Hotspot | | Firewall / etc. |
| 1 IPv4 range | | |
+-------------------+ +-------------------+
|
v
├── DHCP
├── Firewall
├── Guest management
└── Optional:
├── Squid Proxy
├── Apache2
├── Suricata
└── Unbound...
| Component | Used by | Purpose | Propósito |
|---|---|---|---|
| UniFi Network (self-hosted) | uhmd, uhmunifi.sh |
Captive portal SSID, vouchers, and the API Site must be Third-Party Gateway. Local admin account. See Instance above for the single-Network limitation | SSID de portal cautivo, vouchers, y el Site de la API debe ser Third-Party Gateway. Cuenta de admin local. Ver Instance arriba para la limitación de Network única |
| pydhcp | uhmd (verified at startup) |
DHCP backend. Exactly one must be active | Backend DHCP. Exactamente uno debe estar activo |
| iptables + ipset | system administrator | Firewall enforcement of ACL files (must be configured manually) | Aplicación de firewall de los archivos ACL (debe configurarse manualmente) |
| bash, curl, jq | uhmd, uhmunifi.sh, uhmleases.sh |
Script runtime, UniFi API, JSON parsing | Runtime de scripts, API de UniFi, parseo de JSON |
| openssl | uhmsetup.sh (install time only) |
Computes UNIFI_CERT_PIN from the controller's TLS certificate |
Calcula UNIFI_CERT_PIN a partir del certificado TLS del controlador |
bsdextrautils (column) |
uhmunifi.sh |
Formats table output | Formatea la salida en tablas |
| python3 | uhmleases.sh (runtime), uhmsetup.sh (install time) |
Range arithmetic: checks that SERVER_IP does not fall inside the block pool or the hotspot range, and that the hotspot range is inside the network and does not overlap pydhcp's pool |
Aritmética de rangos: verifica que SERVER_IP no caiga dentro del pool de bloqueo ni del rango del hotspot, y que el rango del hotspot esté dentro de la red y no se solape con el pool de pydhcp |
mawk (awk), coreutils, grep |
all bash scripts in the project | Text/field parsing (MAC/IP/ACL lines, DHCP config, logs) | Parseo de texto/campos (líneas MAC/IP/ACL, config DHCP, logs) |
| sed | uhmd.sh, uhmleases.sh, uhmwatch.sh, uhmunifi.sh, uhmacl.sh, uhmwebmin.sh |
In-place ACL/config file edits | Edición in-place de archivos ACL/config |
util-linux (flock) |
all bash scripts in the project | Per-script instance locking, prevents overlapping runs | Bloqueo de instancia por script, evita ejecuciones superpuestas |
iproute2 (ip) |
uhmsetup.sh (install time only) |
Detects network interfaces during the setup wizard | Detecta interfaces de red durante el wizard de instalación |
ncurses-bin (clear) |
uhmacl.sh, uhmwebmin.sh |
Clears the terminal between screen refreshes | Limpia la terminal entre refrescos de pantalla |
libc-bin (getent) |
uhmleases.sh |
Checks that the pydhcpd user and group exist |
Verifica que el usuario y grupo pydhcpd existan |
findutils (find) |
uhmsetup.sh |
Clears the install directory on uninstall, preserving bak/ |
Vacía el directorio de instalación al desinstalar, conservando bak/ |
procps (sysctl) |
uhmiptables.sh |
Enables IPv4 forwarding | Habilita el forwarding IPv4 |
systemd (systemctl) |
uhmd, uhmreload.sh, uhmwatch.sh, uhmleases.sh, uhmalert.sh, uhmwebmin.sh |
Manages/checks the uhmd/pydhcpd/UniFi services |
Gestiona/verifica los servicios uhmd/pydhcpd/UniFi |
| cron | uhmwatch.sh (mandatory, installed automatically) |
Runs the services watchdog every minute | Corre el vigilante de servicios cada minuto |
| Component | When it's needed | Cuándo se necesita |
|---|---|---|
| squid, apache2, DHCP option 252 (WPAD) | Only if your network uses proxymon (Squid-based filtering) — apache2 hosts the WPAD/PAC file, and WPAD lets clients auto-discover the proxy. See that project for installation and configuration details. |
Solo si su red usa proxymon (filtrado basado en Squid) — apache2 sirve el archivo WPAD/PAC, y WPAD permite que los clientes descubran el proxy automáticamente. Consulte ese proyecto para detalles de instalación y configuración. |
# Required packages
sudo apt update
sudo apt install -y bash curl jq iptables ipset cron python3 openssl bsdextrautils mawk coreutils util-linux iproute2 grep sed systemd ncurses-bin libc-bin findutils procps
# DHCP backend — install pydhcp:
# • pydhcp — https://github.com/maravento/pydhcp
# Optional
sudo apt install -y squid apache2Without UniFi reachable or without
pydhcpdrunning (beyond their respective startup grace windows),UHMrefuses to start. Without a workinguhmiptables.sh, the daemon still starts and keeps classifying clients (grace/authorized/blocked) normally, but firewall enforcement is skipped with a log warning until it's configured. These are hard dependencies for full functionality.Sin UniFi alcanzable o sin
pydhcpdcorriendo (más allá de sus respectivas ventanas de gracia de arranque),UHMse niega a arrancar. Sin unuhmiptables.shfuncional, el daemon igual arranca y sigue clasificando clientes (gracia/autorizado/bloqueado) normalmente, pero se salta la aplicación del firewall con una advertencia en el log hasta que se configure. Son dependencias duras para la funcionalidad completa.
What UHM does:
|
Lo que UHM hace:
|
Out of scope (not implemented):
|
Fuera de alcance (no implementado):
|
This is the layout of the cloned repository (git clone ... && cd uhm), not the installed path — uhmsetup.sh and tools/uhmiptables_example.txt never leave the clone; everything else under core/ and tools/ (except the example) is deployed by uhmsetup.sh to the matching subdirectory under /etc/uhm/.
|
Esta es la estructura del repositorio clonado (git clone ... && cd uhm), no la ruta instalada — uhmsetup.sh y tools/uhmiptables_example.txt nunca salen del clon; todo lo demás bajo core/ y tools/ (salvo el ejemplo) lo despliega uhmsetup.sh en el subdirectorio correspondiente bajo /etc/uhm/.
|
uhm/ # as cloned -- see note above
├── acl/ # UHM's own data files -- empty templates in the repo,
│ # deployed once by uhmsetup.sh and never overwritten again
│ ├── uhm-auth.txt # authenticated clients, each with a voucher (fixed hotspot IP)
│ ├── uhm-grace.txt # clients still in the grace period, no voucher yet
│ └── uhm-queue.txt # MACs queued for lease removal, drained on the next run
├── core/ # the reload mechanism, plus uhmwatch -- UHM cannot
│ # function correctly without any of these four
│ ├── uhmd.sh # main daemon: polls the UniFi API and manages ACLs (systemd)
│ ├── uhmleases.sh # rebuilds pydhcpd.conf and manages DHCP leases/ACLs,
│ │ # with UniFi Hotspot support built in
│ ├── uhmreload.sh # wrapper uhmd calls after an ACL change -- runs
│ │ # uhmleases.sh, then reloads the affected services
│ └── uhmwatch.sh # mandatory watchdog for uhmd, pydhcpd and the UniFi
│ # backend -- installed automatically by uhmsetup.sh
│ # with its own cron entry; lives here, not in tools/,
│ # because it's mandatory
├── service/
│ └── uhmd.service # systemd unit for uhmd
├── tools/ # independent, optional utilities -- UHM runs
│ # fine without any of these
│ ├── uhmacl.sh # interactive menu to check MAC consistency across
│ │ # every local ACL source
│ ├── uhmalert.sh # optional watcher that tails the log and pushes
│ │ # notifications via ntfy.sh
│ ├── uhmiptables.sh # minimal template (IPv4 forwarding + NAT) -- deployed
│ │ # only if missing, never overwritten afterward
│ ├── uhmiptables_example.txt # full reference ruleset (ipsets, iptables, redirects)
│ │ # -- not deployed by uhmsetup.sh; copy it by hand over
│ │ # tools/uhmiptables.sh and adapt it
│ ├── uhmunifi.sh # audits UniFi clients and vouchers
│ └── uhmwebmin.sh # installs/uninstalls the Webmin module -- a real-time
│ # log viewer for uhmd (AJAX polling, dark mode, level
│ # badges, search)
└── uhmsetup.sh # installer / updater / uninstaller (interactive);
# run from here, never deployed to /etc/uhm/
UHM integrates three independent projects (UniFi, pydhcp, and the administrator's own iptables/ipset setup), each with its own ACL path. UHM reads/writes each one at its own location and never relocates files it does not own.
|
UHM integra tres proyectos independientes (UniFi, pydhcp y la configuración de iptables/ipset propia del administrador), cada uno con su propia ruta de ACL. UHM lee y escribe cada una en su ubicación y nunca reubica archivos que no le pertenecen.
|
/etc/uhm/acl/ # UHM's OWN data files (generated by this project;
# shipped as empty templates in the repo's acl/ folder,
# deployed once by uhmsetup.sh, never overwritten again)
├── uhm-auth.txt # voucher-authorized clients (fixed hotspot IP)
├── uhm-queue.txt # internal working file (uhmd.sh / uhmleases.sh only)
└── uhm-grace.txt # grace-period clients (no voucher yet)
/etc/acl/mac/ # pydhcp's namespace -- NOT generated by UHM
├── mac-limited.txt # user-maintained; UHM only reads it
└── mac-unlimited.txt # user-maintained; UHM only reads it
/etc/pydhcp/acl/ # pydhcp's own namespace -- NOT generated by UHM
└── blockdhcp.txt # permanently blocked MACs; pydhcp/pyleases.sh concept,
# reused (not owned) by uhmleases.sh
ACL_MAC_PATH (/etc/acl/mac), ACL_DHCP_PATH (/etc/pydhcp/acl) and their file variables are configurable in uhm.env precisely because those directories belong to other projects — UHM must respect whatever path the administrator already has configured for pydhcp/iptables, not impose its own. uhm.env itself lives at /etc/uhm/ (not inside acl/, since it is configuration, not a data list). Only /etc/uhm/acl/ is this project's own and moves together with it (see Remove / Update).
Naming convention: the config variables for this project's own three lists are named after the file each one points at and all start with U — UHM_MACAUTH, UHM_GRACE, UHM_QUEUE. Variables for files owned by other projects keep the ACL_ prefix (ACL_MAC_LIMITED, ACL_MAC_UNLIMITED, ACL_BLOCK_FILE, ACL_MAC_PATH, ACL_DHCP_PATH, ACL_PATH). The prefix alone tells you who owns the file, which is what decides whether UHM may create it: uhmd.sh and uhmleases.sh each create their own three lists empty if missing, but never create blockdhcp.txt or any mac-*.txt — a missing blockdhcp.txt aborts the daemon with a pointer to pydhcp's own pysetup.sh.
Convención de nombres: las variables de configuración de las tres listas propias de este proyecto se nombran según el archivo al que apuntan y todas empiezan por U — UHM_MACAUTH, UHM_GRACE, UHM_QUEUE. Las variables de archivos que pertenecen a otros proyectos conservan el prefijo ACL_ (ACL_MAC_LIMITED, ACL_MAC_UNLIMITED, ACL_BLOCK_FILE, ACL_MAC_PATH, ACL_DHCP_PATH, ACL_PATH). El prefijo por sí solo indica de quién es el archivo, que es lo que decide si UHM puede crearlo: uhmd.sh y uhmleases.sh crean vacías sus tres listas propias si faltan, pero nunca crean blockdhcp.txt ni ningún mac-*.txt — un blockdhcp.txt ausente aborta el daemon indicando el pysetup.sh de pydhcp.
| ACL | Priority Level | Description | Descripción |
|---|---|---|---|
mac-unlimited.txt |
1 | List maintained by hand by the administrator. Designed for communications hardware, servers and other essential equipment, not subject to firewall restrictions. A malformed line aborts with ERROR. |
Lista mantenida manualmente por el administrador. Está diseñada para hardware de comunicaciones, servidores y otros equipos esenciales, no sujetos a restricciones del firewall. Una línea malformada aborta con ERROR. |
mac-limited.txt |
2 | List maintained by hand by the administrator. Designed for equipment joining the local network. May be subject to firewall, proxy and other restrictions. A malformed line aborts with ERROR. |
Lista mantenida manualmente por el administrador. Está diseñada para los equipos que se integran a una red local. Puede estar sujeta a restricciones de firewall, proxy, etc. Una línea malformada aborta con ERROR. |
uhm-auth.txt |
3 | List operated by the UHM daemon. Designed for clients that entered with a valid UniFi voucher. May be subject to firewall, proxy and other restrictions. A malformed line aborts with ERROR. |
Lista operada por el demonio UHM. Está diseñada para los clientes que ingresan con voucher válido de UniFi. Puede estar sujeta a restricciones de firewall, proxy, etc. Una línea malformada aborta con ERROR. |
uhm-grace.txt |
0 | List operated by the UHM daemon. Designed for clients seen on the network that have not entered a voucher yet, during their grace period. Authorizes nothing on its own. A malformed line is dropped with INFO and the reload continues. |
Lista operada por el demonio UHM. Está diseñada para los clientes vistos en la red que aún no ingresan un voucher, durante su período de gracia. No autoriza nada por sí sola. Una línea malformada se elimina con INFO y el reload continúa. |
blockdhcp.txt |
0 | List operated by the pydhcp daemon and written by uhmleases.sh. Designed for clients denied a DHCP lease outright. Authorizes nothing on its own. A malformed line is dropped with INFO and the reload continues. |
Lista operada por el demonio pydhcp y escrita por uhmleases.sh. Está diseñada para los clientes a los que se les niega el lease DHCP por completo. No autoriza nada por sí sola. Una línea malformada se elimina con INFO y el reload continúa. |
uhm-queue.txt |
0 | Internal working list operated by the UHM daemon. Designed to hold the MACs whose lease must be removed on the next reload; emptied once processed. Authorizes nothing on its own. A malformed line is dropped with INFO and the reload continues. |
Lista de trabajo interna operada por el demonio UHM. Está diseñada para guardar las MAC cuyo lease hay que quitar en el siguiente reload; se vacía una vez procesada. No autoriza nada por sí sola. Una línea malformada se elimina con INFO y el reload continúa. |
Lines starting with
#are treated as deactivated and get blocked. Only applies to the ACLs with Priority Level 1, 2 and 3.Las líneas que comienzan con
#se consideran desactivadas y serán bloqueadas. Solo aplica a las ACL con Priority Level 1, 2 y 3.
UHM is glue between UniFi (state of truth), the DHCP backend (lease assignment), and the firewall (enforcement). It only writes ACL files; everything else is invoked through UHM_RELOAD.
|
UHM es código pegamento entre UniFi (estado verdadero), el backend DHCP (asignación de leases) y el firewall (aplicación). Solo escribe archivos ACL; todo lo demás se invoca a través del UHM_RELOAD.
|
uhmd.sh (systemd daemon — every POLL_INTERVAL seconds, default 20)
│
▼
UHM_RELOAD
│
├── DHCP lease reload
│ └── uhmleases.sh
│
└── Firewall/ipset reload
└── administrator-defined
Before running uhmd, in the UniFi Network controller:
|
Antes de ejecutar uhmd, en el controlador UniFi Network:
|
Remote Access via unifi.ui.com
Acceso remoto vía unifi.ui.com
| UHM can coexist with UniFi Remote Access. This can be enabled on a locally-administered self-hosted UniFi Network Server (default: Admin + password) that is managed by UHM. This way, the UniFi console will be available both locally and from https://unifi.ui.com (default: email + password + MFA Login Authentication), with no conflict for UHM. However, enabling 2FA OTP (generated by an authenticator app) will break UHM's authentication against the UniFi API. | UHM puede coexistir con UniFi Remote Access. Este puede habilitarse en un UniFi Network self-hosted con administración local (default: Admin + password) y gestionado por UHM. De esta manera, la consola UniFi estará disponible tanto localmente como desde https://unifi.ui.com (default: email + password + MFA Login Authentication), sin conflicto con UHM. Sin embargo, si se activa 2FA OTP (generado por una aplicación autenticadora), romperá la autenticación de UHM contra la API de UniFi. |
UHM also coexists without conflict with Multi-Site Management enabled on the same console.
UHM también coexiste sin conflicto con Multi-Site Management activado en la misma consola.
Clone the repository with git clone and run the installer. uhmsetup.sh handles dependency verification, DHCP backend detection, file deployment, interactive setup wizard (WAN interface, hotspot IP range as two full addresses, UniFi credentials, controller auto-discovery, guest SSID, optional managed MAC lists -- network values are read from pydhcp.env, not asked; UHM supports a single controller and a single guest SSID, both auto-detected via the UniFi API -- see below for exactly how each is resolved), logrotate config, systemd service registration, cleanup of any stale @hourly cron entry from installs done before the daemon handled its own safety-net reload, and unconditional installation of uhmwatch (mandatory -- see uhmwatch below for why), plus two yes/no prompts (default no) for the truly optional components: uhmalert right there instead of as a separate manual step afterward, and the Webmin log viewer module — only asked if Webmin is actually detected on the system, skipped with a message otherwise. Make sure every item in Requirements (particularly the Mandatory dependencies) is in place before running the installer — none of it is installed automatically, and pydhcp must already be running with /etc/pydhcp/pydhcp.env present and complete (uhmsetup.sh reads its network values from there instead of asking again).
|
Clone el repositorio con git clone y ejecute el instalador. uhmsetup.sh se encarga de verificar dependencias, detectar el backend DHCP, desplegar archivos, correr el wizard interactivo (interfaz WAN, rango IP del hotspot como dos direcciones completas, credenciales UniFi, autodescubrimiento del controlador, SSID de invitados, listas opcionales de MACs gestionadas -- los valores de red se leen de pydhcp.env, no se preguntan; UHM soporta un solo controlador y un solo SSID de invitados, ambos autodetectados vía la API de UniFi -- ver abajo el detalle exacto de cómo se resuelve cada uno), configurar logrotate, registrar el servicio systemd, limpiar cualquier entrada de cron @hourly residual de instalaciones anteriores a que el daemon manejara su propio reload de seguridad, e instalación incondicional de uhmwatch (obligatorio -- ver uhmwatch más abajo para el porqué), más dos preguntas sí/no (default no) para los componentes realmente opcionales: uhmalert ahí mismo en vez de como paso manual separado después, y el módulo visor de log de Webmin — solo se pregunta si Webmin está realmente detectado en el sistema, si no se salta con un mensaje. Asegúrese de tener listos, antes de ejecutar el instalador, todo lo de Requirements (en particular las dependencias de Mandatory) — nada se instala automáticamente, y pydhcp ya debe estar corriendo con /etc/pydhcp/pydhcp.env presente y completo (uhmsetup.sh lee sus valores de red desde ahí en vez de volver a preguntarlos).
|
git clone --depth=1 https://github.com/maravento/uhm.git
cd uhm
sudo bash uhmsetup.sh
The installer checks for required apt dependencies (curl, jq, iptables, ipset, python3, openssl, bsdextrautils, mawk, coreutils, util-linux, iproute2, cron, grep, sed, systemd, ncurses-bin, libc-bin, findutils, procps) and aborts if any is missing — none of them are installed automatically. It also aborts if pydhcp is not active. uhm.env holds only UHM's own keys. pydhcp's values stay in pydhcp.env and every component reads that file first and uhm.env after, so a change made there reaches UHM without a re-install and the same key never lives in two files. It deploys uhmd.sh, uhmreload.sh and uhmleases.sh to /etc/uhm/core/, the mandatory uhmwatch.sh to /etc/uhm/core/ as well, and the optional tools to /etc/uhm/tools/, installs uhmd.service to /etc/systemd/system/, and enables and starts the daemon via systemctl enable + restart uhmd. No files are copied to /etc/pydhcp.
|
El instalador verifica las dependencias apt requeridas (curl, jq, iptables, ipset, python3, openssl, bsdextrautils, mawk, coreutils, util-linux, iproute2, cron, grep, sed, systemd, ncurses-bin, libc-bin, findutils, procps) y aborta si falta alguna — ninguna se instala automáticamente. También aborta si pydhcp no está activo. uhm.env contiene solo las claves propias de UHM. Los valores de pydhcp se quedan en pydhcp.env y cada componente lee primero ese archivo y después uhm.env, así que un cambio hecho allí llega a UHM sin reinstalar y la misma clave nunca vive en dos archivos. Despliega uhmd.sh, uhmreload.sh y uhmleases.sh en /etc/uhm/core/, el obligatorio uhmwatch.sh también en /etc/uhm/core/, y las herramientas opcionales en /etc/uhm/tools/, instala uhmd.service en /etc/systemd/system/ y habilita e inicia el daemon con systemctl enable + restart uhmd. No se copian archivos a /etc/pydhcp.
|
The systemd service drives the main hotspot loop (every POLL_INTERVAL seconds, default 20, set in uhm.env). No crontab entry is registered — the daemon triggers its own safety-net reload internally (see below).
|
El servicio systemd conduce el ciclo principal del hotspot (cada POLL_INTERVAL segundos, default 20, configurado en uhm.env). No se registra ninguna entrada de crontab — el daemon dispara su propio reload de seguridad internamente (ver abajo).
|
Controller and SSID resolution: UHM supports exactly one UniFi controller and one guest SSID, so neither is ever asked as blind free text. Controller: tried against SERVER_IP (from pydhcp.env, this host's own LAN IP) on ports 8443/11443 with the UniFi credentials just entered; found → used directly, not found → the installer aborts (check credentials, and that the controller runs on this same host, then restart the installation). Guest SSID: once logged in, the installer lists the controller's configured SSIDs (rest/wlanconf); exactly one → used directly, several → pick from a numbered menu (the administrator must know which one is the captive portal SSID), none → aborts the same way as a missing controller. Neither prompt accepts manual free-text entry — this avoids a typo in a value that must match UniFi exactly.
|
Resolución de controlador y SSID: UHM soporta exactamente un controlador UniFi y un SSID de invitados, así que ninguno de los dos se pregunta jamás como texto libre a ciegas. Controlador: se prueba contra SERVER_IP (de pydhcp.env, la propia IP LAN de este host) en los puertos 8443/11443 con las credenciales UniFi recién ingresadas; si se encuentra, se usa directamente; si no, el instalador aborta (revise credenciales y que el controlador corra en este mismo host, luego reinicie la instalación). SSID de invitados: una vez logueado, el instalador lista los SSID configurados en el controlador (rest/wlanconf); si hay exactamente uno, se usa directamente; si hay varios, se elige de un menú numerado (el administrador debe saber cuál es el SSID del portal cautivo); si no hay ninguno, aborta igual que un controlador no encontrado. Ninguna de las dos preguntas acepta entrada de texto libre manual — esto evita un error de tipeo en un valor que debe coincidir exactamente con UniFi.
|
Purpose: keep the ACL lists up to date and the reload chain active even during periods of no client activity. Every cycle, uhmd.sh forces a reload — regardless of whether any ACL file changed — if more than RELOAD_SAFETY_INTERVAL_SECONDS (default 3600, one hour) have passed since the last one, so expired grace entries still get promoted to blockdhcp.txt even on idle networks where no new client would otherwise trigger a reload. uhmd.sh is the only caller of uhmreload.sh — no external cron entry is registered — so there is no possibility of two independent callers racing for uhmreload.sh's own instance lock.
|
Propósito: mantener las listas ACL actualizadas y la cadena de reload activa incluso en periodos sin actividad de clientes. En cada ciclo, uhmd.sh fuerza un reload — sin importar si alguna ACL cambió — si pasaron más de RELOAD_SAFETY_INTERVAL_SECONDS (default 3600, una hora) desde el último, para que las entradas de gracia expiradas se promuevan a blockdhcp.txt incluso en redes inactivas donde ningún cliente nuevo dispararía un reload. uhmd.sh es el único invocador de uhmreload.sh — no se registra ninguna entrada de cron externa — así que no existe posibilidad de que dos invocadores independientes compitan por el lock de instancia de uhmreload.sh.
|
| Verify the daemon status with: | Verifique el estado del daemon con: |
systemctl status uhmd
journalctl -u uhmd -f
To update scripts while never touching existing configuration or ACL data:
|
Para actualizar los scripts sin tocar nunca la configuración ni los datos ACL ya existentes:
|
cd uhm
sudo bash uhmsetup.sh --update| The installer also supports uninstall. A single confirmation, preceded by a full warning of everything that will be removed, gates the whole operation — from there, uninstall removes absolutely everything with no further prompts, since that is what uninstalling means. Package dependencies (curl, jq, iptables, ipset, etc.) and firewall rules/ipsets are not touched — you must flush the latter manually as documented at the end of the removal summary. | El instalador también soporta desinstalación. Una única confirmación, precedida de una advertencia completa de todo lo que se eliminará, controla toda la operación — de ahí en adelante, desinstalar elimina absolutamente todo sin más preguntas, porque eso es lo que significa desinstalar. Las dependencias de paquetes (curl, jq, iptables, ipset, etc.) y las reglas de firewall/ipsets no se tocan — estas últimas debe limpiarlas manualmente como se documenta al final del resumen de remoción. |
cd uhm
sudo bash uhmsetup.sh --remove| # | Description (single confirmation up front, then unconditional) | Descripción (una sola confirmación al inicio, luego incondicional) |
|---|---|---|
| 1 | Stop and disable uhmd.service and remove /etc/systemd/system/uhmd.service |
Detiene y deshabilita uhmd.service y elimina /etc/systemd/system/uhmd.service |
| 2 | Remove the @hourly cron entry for /etc/uhm/core/uhmreload.sh (or the pre-restructure /etc/uhm/tools/uhmreload.sh path, if upgrading from an older install) |
Elimina la entrada de cron @hourly para /etc/uhm/core/uhmreload.sh (o la ruta previa a la reestructuración /etc/uhm/tools/uhmreload.sh, si se actualiza desde una instalación anterior) |
| 3 | Remove the uhmwatch cron entry, and stop/disable/remove uhmalert.service if installed |
Elimina la entrada de cron de uhmwatch, y detiene/deshabilita/elimina uhmalert.service si está instalado |
| 4 | Uninstall the Webmin module (uhmwebmin.sh uninstall, if installed) |
Desinstala el módulo de Webmin (uhmwebmin.sh uninstall, si está instalado) |
| 5 | Remove /etc/logrotate.d/uhm |
Elimina /etc/logrotate.d/uhm |
| 6 | Remove /etc/uhm/ and all its contents including uhm.env, ACL files, your uhmiptables.sh, and bak/ (script backups accumulated by --update runs) |
Elimina /etc/uhm/ y todo su contenido, incluyendo uhm.env, archivos ACL, su uhmiptables.sh, y bak/ (backups de scripts acumulados por corridas de --update) |
| 7 | Remove /var/log/uhm.log, rotated archives, /var/log/uhmunifi.log, /var/log/uhmleases-failure.trace and /var/log/uhmiptables-failure.trace |
Elimina /var/log/uhm.log, los archivos rotados, /var/log/uhmunifi.log, /var/log/uhmleases-failure.trace y /var/log/uhmiptables-failure.trace |
| Path | Description | Descripción |
|---|---|---|
/etc/uhm/core/uhmd.sh |
Main daemon | Daemon principal |
/etc/systemd/system/uhmd.service |
Systemd service unit | Unidad de servicio systemd |
/etc/uhm/core/uhmreload.sh |
Reload wrapper | Wrapper de reload |
/etc/uhm/core/uhmleases.sh |
Hotspot-aware DHCP leases manager | Gestor de leases DHCP con hotspot |
/etc/uhm/tools/uhmunifi.sh |
Audit tool | Herramienta de auditoría |
/etc/uhm/uhm.env |
Configuration (IPs, credentials, ports) | Configuración |
/etc/uhm/acl/uhm-grace.txt |
Grace-period clients (no voucher yet) | Clientes en período de gracia |
/etc/uhm/acl/uhm-auth.txt |
Authorized clients (active voucher) | Autorizados |
/etc/uhm/acl/uhm-queue.txt |
Lease removal queue — path set by the UHM_QUEUE config variable; internal working file for uhmd.sh/uhmleases.sh, not an ACL — do not edit its contents manually |
Cola de remociones de leases — la ruta la fija la variable de configuración UHM_QUEUE; archivo de trabajo interno de uhmd.sh/uhmleases.sh, no es una ACL — no debe editarse su contenido manualmente |
/var/log/uhm.log |
Log file (unified) | Archivo de log (unificado) |
/etc/logrotate.d/uhm |
Logrotate config | Config de logrotate |
/etc/uhm/core/uhmwatch.sh |
Services watchdog (mandatory) | Vigilante de servicios (obligatorio) |
/run/uhmwatch/ |
Watchdog recovery-attempt timestamps — cleared on reboot, not persistent | Marcas de tiempo de intentos de recuperación del vigilante — se limpian en cada reinicio, no persisten |
/etc/uhm/tools/uhmwebmin.sh |
Webmin log viewer module | Módulo visor de log para Webmin |
| Variable | Description | Descripción |
|---|---|---|
| (WAN interface) | Not a uhm.env key. uhmsetup.sh asks for it during setup and replaces the eth0 placeholder directly in tools/uhmiptables.sh (and tools/uhmiptables_example.txt once copied over it) with sed -i, the only place it is used |
No es una clave de uhm.env. uhmsetup.sh la pregunta durante la instalación y reemplaza el placeholder eth0 directamente en tools/uhmiptables.sh (y en tools/uhmiptables_example.txt una vez copiado sobre él) con sed -i, el único lugar donde se usa |
INTERFACESv4 |
pydhcp's own value -- the LAN interface pydhcpd listens on, read from /etc/pydhcp/pydhcp.env at runtime; read by tools/uhmiptables_example.txt as its $lan; the minimal template does not use it |
Valor propio de pydhcp -- la interfaz LAN en la que escucha pydhcpd, leída desde /etc/pydhcp/pydhcp.env en cada ejecución; usada por tools/uhmiptables_example.txt como su $lan; la plantilla mínima no la usa |
SERVER_IP |
This machine's IP on the LAN, read from /etc/pydhcp/pydhcp.env at runtime (also the DHCP server IP; used by uhmleases.sh and uhmiptables.sh) |
IP de esta máquina en la LAN, leída desde /etc/pydhcp/pydhcp.env en cada ejecución (también la IP del servidor DHCP; usado por uhmleases.sh y uhmiptables.sh) |
UHM_INI_RANGE, UHM_END_RANGE |
First and last address of the fixed-IP range handed to voucher-authorized guests, as two complete IPv4 addresses -- same shape as pydhcp's own SERV_INI_RANGE_BLOCK/SERV_END_RANGE_BLOCK, so no netmask is assumed |
Primera y última dirección del rango de IP fijas que se entrega a los invitados autorizados por voucher, como dos direcciones IPv4 completas -- misma forma que el propio SERV_INI_RANGE_BLOCK/SERV_END_RANGE_BLOCK de pydhcp, así que no se asume ninguna máscara |
UHM_ESSID |
Guest SSID name; must match UniFi exactly | Nombre del SSID de invitados; debe coincidir exactamente con UniFi |
UNIFI_CONTROLLER_URL |
e.g. https://192.168.1.1:8443 |
ej. https://192.168.1.1:8443 |
UNIFI_USERNAME, UNIFI_PASSWORD |
Local UniFi admin | Admin local de UniFi |
UNIFI_SITE |
Defaults to default; update if the site was renamed |
Por defecto default; actualizar si el sitio fue renombrado |
UNIFI_TYPE |
Either unifi-os or classic — sets the API path, login endpoint, session cookie name, and CSRF extraction method used by uhmd.sh |
unifi-os o classic — define la ruta de la API, el endpoint de login, el nombre de la cookie de sesión y el método de extracción de CSRF que usa uhmd.sh |
UNIFI_CERT_PIN |
SHA-256 pin of the controller's TLS public key (format sha256//<base64>), computed by uhmsetup.sh at install time. Used by uhmd.sh with curl --pinnedpubkey to detect a swapped certificate; empty if openssl failed during setup, in which case the connection falls back to unpinned -k |
Pin SHA-256 de la clave pública TLS del controlador (formato sha256//<base64>), calculado por uhmsetup.sh durante la instalación. Usado por uhmd.sh con curl --pinnedpubkey para detectar un certificado reemplazado; vacío si openssl falló durante la instalación, en cuyo caso la conexión cae a -k sin pin |
UHM_RELOAD |
Path to uhmreload.sh |
Ruta a uhmreload.sh |
UHM_LEASES |
Path to uhmleases.sh, invoked by uhmreload.sh as its first step (default /etc/uhm/core/uhmleases.sh) |
Ruta a uhmleases.sh, invocado por uhmreload.sh como su primer paso (default /etc/uhm/core/uhmleases.sh) |
UHM_IPTABLES |
Path to the administrator's firewall script, invoked by uhmreload.sh as its second step (default /etc/uhm/tools/uhmiptables.sh) |
Ruta al script de firewall del administrador, invocado por uhmreload.sh como su segundo paso (default /etc/uhm/tools/uhmiptables.sh) |
UHM_LEASES_TIMEOUT_SECONDS |
Max seconds uhmreload.sh waits for uhmleases.sh before killing it (default 120) |
Segundos máximos que uhmreload.sh espera a uhmleases.sh antes de matarlo (default 120) |
UHM_IPTABLES_TIMEOUT_SECONDS |
Max seconds uhmreload.sh waits for uhmiptables.sh before killing it (default 60) |
Segundos máximos que uhmreload.sh espera a uhmiptables.sh antes de matarlo (default 60) |
SERV_MASK |
Network mask, read from pydhcp.env at runtime |
Máscara de red, leída desde pydhcp.env en cada ejecución |
SERV_SUBNET |
Network address, read from pydhcp.env at runtime |
Dirección de red, leída desde pydhcp.env en cada ejecución |
SERV_BROADCAST |
Broadcast address, read from pydhcp.env at runtime |
Dirección de broadcast, leída desde pydhcp.env en cada ejecución |
SERV_DNS |
DNS servers for clients, read from pydhcp.env at runtime |
Servidores DNS para clientes, leída desde pydhcp.env en cada ejecución |
SERV_INI_RANGE_BLOCK, SERV_END_RANGE_BLOCK |
DHCP pool range for new/unknown clients, read from pydhcp.env at runtime |
Rango del pool DHCP para clientes nuevos/desconocidos, leída desde pydhcp.env en cada ejecución |
ACL_PATH |
Base ACL directory, read from pydhcp.env at runtime |
Directorio base de ACL, leída desde pydhcp.env en cada ejecución |
ACL_MAC_PATH |
Managed MAC lists directory, read from pydhcp.env at runtime |
Directorio de listas de MAC gestionadas, leída desde pydhcp.env en cada ejecución |
ACL_DHCP_PATH |
DHCP-related ACL files directory, read from pydhcp.env at runtime |
Directorio de archivos ACL relacionados con DHCP, leída desde pydhcp.env en cada ejecución |
UHM_PATH |
UHM installation/data directory (default /etc/uhm) |
Directorio de instalación/datos de UHM (default /etc/uhm) |
ACL_MAC_LIMITED |
Managed proxy MAC list, read from pydhcp.env at runtime |
Lista de MAC gestionadas forzadas por proxy, leída desde pydhcp.env en cada ejecución |
ACL_MAC_UNLIMITED |
Managed unrestricted MAC list, read from pydhcp.env at runtime |
Lista de MAC gestionadas sin restricciones, leída desde pydhcp.env en cada ejecución |
UHM_MACAUTH |
Active hotspot-authorized MAC list -- UHM's own (default /etc/uhm/acl/uhm-auth.txt) |
Lista de MAC autorizadas activas del hotspot -- propia de UHM (default /etc/uhm/acl/uhm-auth.txt) |
ACL_BLOCK_FILE |
Permanently blocked MAC list, read from pydhcp.env at runtime |
Lista de MAC bloqueadas permanentemente, leída desde pydhcp.env en cada ejecución |
PYDHCPD_LEASES |
pydhcpd's own leases file path, read from pydhcp.env at runtime; read by uhmd.sh and uhmleases.sh (default /etc/pydhcp/core/pydhcpd.leases) |
Ruta del archivo de leases de pydhcpd, leída desde pydhcp.env en cada ejecución; usada por uhmd.sh y uhmleases.sh (default /etc/pydhcp/core/pydhcpd.leases) |
UHM_GRACE |
Grace-period MAC list -- UHM's own (default /etc/uhm/acl/uhm-grace.txt) |
Lista de MAC en período de gracia -- propia de UHM (default /etc/uhm/acl/uhm-grace.txt) |
UHM_QUEUE |
Path to the internal lease-removal queue file, an UHM working file (not an ACL) consumed by uhmd.sh and uhmleases.sh (default /etc/uhm/acl/uhm-queue.txt) |
Ruta del archivo interno de cola de remoción de leases, un archivo de trabajo de UHM (no una ACL) consumido por uhmd.sh y uhmleases.sh (default /etc/uhm/acl/uhm-queue.txt) |
POLL_INTERVAL |
Daemon cycle interval in seconds (default 20) |
Intervalo del ciclo del daemon en segundos (default 20) |
RELOAD_SAFETY_INTERVAL_SECONDS |
Force a reload even without an ACL change after this many seconds (default 3600 = 1h, minimum 3x UHM_LEASES_TIMEOUT_SECONDS + UHM_IPTABLES_TIMEOUT_SECONDS and never below 600; uhmd aborts below that) |
Fuerza un reload aunque no haya cambio de ACL tras esta cantidad de segundos (default 3600 = 1h, mínimo 3x UHM_LEASES_TIMEOUT_SECONDS + UHM_IPTABLES_TIMEOUT_SECONDS y nunca menos de 600; uhmd aborta por debajo) |
STARTUP_GRACE_SECONDS |
Grace window (seconds) for uhmd.sh's initial UniFi login retry and its wait for pydhcpd to come up (default 120). Also read by uhmwatch.sh to give its own functional login check (uosserver.service/unifi.service) the same exemption during this window; uhmalert.sh has its own separate key, UHM_ALERT_QUIET_PERIOD_SECONDS |
Ventana de gracia (segundos) para el reintento inicial de login a UniFi de uhmd.sh y su espera a que pydhcpd arranque (default 120). También la lee uhmwatch.sh para darle a su propio chequeo funcional de login (uosserver.service/unifi.service) la misma excepción durante esta ventana; uhmalert.sh tiene su propia clave separada, UHM_ALERT_QUIET_PERIOD_SECONDS |
UHM_ALERT_QUIET_PERIOD_SECONDS |
Grace window (seconds) for suppressing uhmalert.sh connectivity alerts right after uhmd.service starts (default 120) |
Ventana de gracia (segundos) para suprimir alertas de conectividad de uhmalert.sh justo después de que arranca uhmd.service (default 120) |
RECOVERY_COOLDOWN_SECONDS |
Minimum seconds uhmwatch.sh (mandatory) waits between recovery attempts on the same service after one fails to fix it -- prevents hammering a persistently broken service (e.g. controller genuinely down) with a restart every single cron tick (default 600 = 10 min) |
Segundos mínimos que uhmwatch.sh (obligatorio) espera entre intentos de recuperación sobre el mismo servicio después de que uno no lo arregla -- evita machacar con un restart en cada corrida de cron a un servicio persistentemente roto (ej. el controlador realmente caído) (default 600 = 10 min) |
CLEANUP_INTERVAL |
pydhcp's own value -- DHCP pool lease time in seconds, read from pydhcp.env at runtime (default 60) |
Valor propio de pydhcp -- tiempo de lease del pool DHCP en segundos, leída desde pydhcp.env en cada ejecución (default 60) |
AUTHORIZED_LEASE_TIME |
pydhcp's own value -- DHCP lease time for authorized clients in seconds, read from pydhcp.env at runtime (default 2592000 = 30 days) |
Valor propio de pydhcp -- tiempo de lease DHCP para clientes autorizados en segundos, leída desde pydhcp.env en cada ejecución (default 2592000 = 30 días) |
QUARANTINE_DURATION |
pydhcp's own value -- seconds an IP is held out of the pool after a DHCPDECLINE or ping-check conflict, read from pydhcp.env at runtime; written into pydhcpd.conf as abandon-lease-time (default 60) |
Valor propio de pydhcp -- segundos que una IP se aparta del pool tras un DHCPDECLINE o un conflicto de ping-check, leída desde pydhcp.env en cada ejecución; escrito en pydhcpd.conf como abandon-lease-time (default 60) |
BLOCKDHCP_GRACE_SECONDS |
Grace period before unknown MACs are blocked (default 86400 = 24h) |
Período de gracia antes de bloquear MACs desconocidas (default 86400 = 24h) |
WPAD_ENABLED |
pydhcp's own value -- true to enable WPAD/PAC via DHCP option 252, requires Apache2 serving wpad.pac on WPAD_PORT, read from pydhcp.env at runtime (default false) |
Valor propio de pydhcp -- true para habilitar WPAD/PAC vía la opción DHCP 252, requiere Apache2 sirviendo wpad.pac en WPAD_PORT, leída desde pydhcp.env en cada ejecución (default false) |
WPAD_PORT |
pydhcp's own value -- TCP port of the Apache VirtualHost serving wpad.pac, read from pydhcp.env at runtime (default 18100). The full example firewall reads it too, for the rules that allow PAC access per ACL group |
Valor propio de pydhcp -- puerto TCP del VirtualHost de Apache que sirve wpad.pac, leída desde pydhcp.env en cada ejecución (default 18100). Si lo cambia, El ejemplo completo del firewall también la lee, para las reglas que permiten el acceso al PAC por grupo ACL |
PING_CHECK_ENABLED |
pydhcp's own value -- false to disable pydhcpd ping-check before OFFER, set if ICMP is blocked, read from pydhcp.env at runtime (default true) |
Valor propio de pydhcp -- false para deshabilitar el ping-check de pydhcpd antes del OFFER, usar si ICMP está bloqueado, leída desde pydhcp.env en cada ejecución (default true) |
PING_TIMEOUT_SECONDS |
pydhcp's own value -- seconds to wait for the ICMP reply before giving up and sending the OFFER, read from pydhcp.env at runtime; written into pydhcpd.conf as ping-timeout (default 1) |
Valor propio de pydhcp -- segundos a esperar la respuesta ICMP antes de desistir y enviar el OFFER, leída desde pydhcp.env en cada ejecución; escrito en pydhcpd.conf como ping-timeout (default 1) |
UHM_NTFY_TOPIC |
ntfy.sh topic used by uhmalert.sh (optional component). Auto-generated by uhmalert.sh install; absent if uhmalert is not installed |
Topic de ntfy.sh que usa uhmalert.sh (componente opcional). Lo autogenera uhmalert.sh install; ausente si uhmalert no está instalado |
UHM_API_FAIL_THRESHOLD |
Consecutive failing cycles uhmalert.sh requires before alerting (default 3). Written by uhmalert.sh install |
Ciclos fallidos consecutivos que uhmalert.sh exige antes de alertar (default 3). Lo escribe uhmalert.sh install |
Every variable above that isn't strictly required (network/UniFi credentials) falls back to the default shown if missing from
uhm.env— scripts never fail silently or use an undocumented value.Toda variable de arriba que no sea estrictamente requerida (red/credenciales UniFi) usa el default mostrado si falta en
uhm.env— los scripts nunca fallan en silencio ni usan un valor no documentado.
Example /etc/pydhcp/pydhcp.env (written by pydhcp's own pysetup.sh). UHM reads these values from here at runtime and never copies them:
|
Ejemplo de /etc/pydhcp/pydhcp.env (lo escribe el propio pysetup.sh de pydhcp). UHM lee estos valores de aquí en cada ejecución y nunca los copia:
|
# =============================================================================
# PYDHCP
# /etc/pydhcp/pydhcp.env
# =============================================================================
# -- Daemon defaults (pydhcpd.py / init.d/pydhcpd / pywebmin.sh) --------------
DHCPDv4_CONF=/etc/pydhcp/core/pydhcpd.conf
DHCPDv4_BIN=/usr/bin/python3
DHCPDv4_SCRIPT=/etc/pydhcp/core/pydhcpd.py
PYDHCPD_LEASES=/etc/pydhcp/core/pydhcpd.leases
INTERFACESv4="eth1"
DAEMON_USER="pydhcpd"
DAEMON_GROUP="pydhcpd"
# -- Network values (chosen by the administrator during install) --------------
SERVER_IP=192.168.0.10
SERV_SUBNET=192.168.0.0
SERV_BROADCAST=192.168.0.255
SERV_MASK=255.255.255.0
SERV_INI_RANGE_BLOCK=192.168.0.230
SERV_END_RANGE_BLOCK=192.168.0.239
SERV_DNS=8.8.8.8,1.1.1.1
# -- ACL paths, administrator's own lists (edited by hand) --------------------
ACL_PATH=/etc/acl
ACL_MAC_PATH=/etc/acl/mac
ACL_MAC_LIMITED=/etc/acl/mac/mac-limited.txt
ACL_MAC_UNLIMITED=/etc/acl/mac/mac-unlimited.txt
# -- ACL paths, pydhcp's own list (written by pyleases.sh) --------------------
ACL_DHCP_PATH=/etc/pydhcp/acl
ACL_BLOCK_FILE=/etc/pydhcp/acl/blockdhcp.txt
# -- Lease timers (pyleases.sh -> pydhcpd.conf pool/subnet directives) --------
CLEANUP_INTERVAL=60
AUTHORIZED_LEASE_TIME=2592000
QUARANTINE_DURATION=60
# -- Optional features (pyleases.sh -> pydhcpd.conf wpad/ping-check) ----------
WPAD_ENABLED=false
WPAD_PORT=18100
PING_CHECK_ENABLED=true
PING_TIMEOUT_SECONDS=1
# -- pydhcp-only features (no isc-dhcp-server equivalent) ---------------------
PING_CACHE_TTL_SECONDS=120
RATE_LIMIT_WINDOW_SECONDS=60
RATE_LIMIT_MAX=5
RESERVATION_TTL_SECONDS=30
# =============================================================================
Example /etc/uhm/uhm.env (as written by uhmsetup.sh). Holds only UHM's own keys; pydhcp's values stay in the file above. uhmalert.sh install appends the last block.
|
Ejemplo de /etc/uhm/uhm.env (como lo escribe uhmsetup.sh). Contiene solo las claves propias de UHM; los valores de pydhcp se quedan en el archivo de arriba. uhmalert.sh install agrega el último bloque.
|
# =============================================================================
# UHM
# /etc/uhm/uhm.env
# =============================================================================
# -- UniFi keys ---------------------------------------------------------------
# Guest SSID
UHM_ESSID="EXAMPLE_SSID"
# Unifi Access
UNIFI_CONTROLLER_URL="https://192.168.0.10:11443"
UNIFI_USERNAME="admin"
UNIFI_PASSWORD="mypass"
UNIFI_SITE="default"
# Unifi type (classic or unifi-os)
UNIFI_TYPE="unifi-os"
# Cert
UNIFI_CERT_PIN="sha256//AbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCDE="
# -- Hotspot keys -------------------------------------------------------------
# Hotspot Range
UHM_INI_RANGE=192.168.0.180
UHM_END_RANGE=192.168.0.220
# Daemon timers (UHM's own)
POLL_INTERVAL=20
STARTUP_GRACE_SECONDS=120
RELOAD_SAFETY_INTERVAL_SECONDS=3600
BLOCKDHCP_GRACE_SECONDS=86400
RECOVERY_COOLDOWN_SECONDS=600
# -- Scripts ------------------------------------------------------------------
UHM_RELOAD="/etc/uhm/core/uhmreload.sh"
UHM_LEASES="/etc/uhm/core/uhmleases.sh"
UHM_IPTABLES="/etc/uhm/tools/uhmiptables.sh"
# Timeouts (uhmd -> uhmreload -> uhmleases.sh/uhmiptables.sh)
UHM_LEASES_TIMEOUT_SECONDS=120
UHM_IPTABLES_TIMEOUT_SECONDS=60
# -- ACLs (UHM's own; read by uhmd.sh / uhmleases.sh) -------------------------
UHM_PATH=/etc/uhm
UHM_GRACE=/etc/uhm/acl/uhm-grace.txt
UHM_MACAUTH=/etc/uhm/acl/uhm-auth.txt
UHM_QUEUE=/etc/uhm/acl/uhm-queue.txt
# =============================================================================
# =============================================================================
# UHM ALERT
# =============================================================================
UHM_NTFY_TOPIC="uhm-alert-x7k2m9qv"
UHM_API_FAIL_THRESHOLD=3
UHM_ALERT_QUIET_PERIOD_SECONDS=120
# =============================================================================New keys added later (e.g. by
uhmalert.sh install, or a backfill frompyleases.sh/pysetup.shon an older install) arrive as a complete block — its own# =====...=====opening and closing lines included — appended right after the last delimiter already in the file, so the file always ends on a delimiter.Las claves que se agregan después (por ejemplo con
uhmalert.sh install, o un relleno depyleases.sh/pysetup.shen una instalación anterior) llegan como un bloque completo — con sus propias líneas# =====...=====de apertura y cierre — añadido justo después del último delimitador que ya haya en el archivo, de modo que el archivo siempre termina en un delimitador.
uhmwebmin.sh installs a native Webmin module (Networking → UHM Log Viewer) that replaces tail -f for monitoring /var/log/uhm.log. It uses AJAX byte-offset polling — reading only new bytes since the last position — so it never stalls on log rotation. The module is written as a self-contained bash installer following the same pattern as servicemon.sh and squidmon.sh.
|
uhmwebmin.sh instala un módulo nativo de Webmin (Networking → UHM Log Viewer) que reemplaza a tail -f para monitorear /var/log/uhm.log. Usa polling AJAX por byte offset — leyendo solo los bytes nuevos desde la última posición — así nunca se atasca con la rotación de logs. El módulo está escrito como un instalador bash autocontenido siguiendo el mismo patrón que servicemon.sh y squidmon.sh.
|
| Light | Dark |
![]() |
![]() |
| Feature | Description | Descripción |
|---|---|---|
| Live polling | AJAX polling by byte offset (1s–30s configurable). Never stalls on log rotation. | Polling AJAX por byte offset (1s–30s configurable). No se atasca con la rotación de logs. |
| Dark / Light mode | Toggle with moon/sun button. Preference saved in localStorage. |
Alternancia con botón luna/sol. Preferencia guardada en localStorage. |
| Level badges | Color-coded badges, one distinctive color per level: INFO (#d1ecf1/#0c5460), WARNING (#fff3cd/#856404), ERROR (#f8d7da/#721c24), FIX (#d4edda/#155724), ALERT (#e2d9f3/#432874), STATUS (#e2e3e5/#383d41). |
Badges con color, un color distintivo por nivel: INFO (#d1ecf1/#0c5460), WARNING (#fff3cd/#856404), ERROR (#f8d7da/#721c24), FIX (#d4edda/#155724), ALERT (#e2d9f3/#432874), STATUS (#e2e3e5/#383d41). |
| Full-log grep | Searches the entire log file via grep -Fia. Results highlighted inline. |
Busca en el archivo completo vía grep -Fia. Resultados resaltados inline. |
| Cycle stats bar | Parses the last stats line and shows Vouchers, Authorized, Grace, New Auth, Revoked as pills. | Parsea la última línea de stats y muestra Vouchers, Authorized, Grace, New Auth, Revoked como pills. |
| Service status | Shows PID, uptime, and memory from systemctl status uhmd. |
Muestra PID, uptime y memoria desde systemctl status uhmd. |
| Text filter | Live filter on visible rows (plain substring match, case-insensitive). | Filtro en vivo sobre filas visibles (coincidencia de subcadena literal, sin distinguir mayúsculas/minúsculas). |
| Level filter | Dropdown to show only INFO / WARNING / ERROR / ALERT / FIX / STATUS. | Dropdown para mostrar solo INFO / WARNING / ERROR / ALERT / FIX / STATUS. |
| Configurable | Log file path editable from Webmin module config (gear icon). | Ruta del log editable desde la configuración del módulo Webmin (icono engranaje). |
# Install
sudo bash tools/uhmwebmin.sh install
# Uninstall
sudo bash tools/uhmwebmin.sh uninstallRequires Webmin installed (
/usr/share/webmin). After install, log out and back into Webmin. The module appears under Networking. Access is granted to the Webminrootaccount and to the detected local sudo user -- for any other Webmin user, grant it from Webmin → Webmin Users.Requiere Webmin instalado (
/usr/share/webmin). Tras instalar, hacer logout y login en Webmin. El módulo aparece bajo Networking. El acceso se concede a la cuentarootde Webmin y al usuario local con sudo detectado -- para cualquier otro usuario de Webmin, concederlo desde Webmin → Webmin Users.
To reconfigure, edit /etc/uhm/uhm.env directly. To start over from scratch, uninstall first with uhmsetup.sh --remove, then re-run the installer -- deleting only the config file is not enough, the installer refuses to run again while the deployed scripts are still present.
|
Para reconfigurar, edite /etc/uhm/uhm.env directamente. Para empezar de cero, desinstale primero con uhmsetup.sh --remove y luego vuelva a ejecutar el instalador -- borrar solo el archivo de config no basta, el instalador se niega a correr de nuevo mientras los scripts desplegados sigan presentes.
|
# Edit any value (credentials, interfaces, range, ports, SSID, etc.)
sudo nano /etc/uhm/uhm.env
# Or: force a fresh interactive setup
cd uhm && sudo bash uhmsetup.sh --remove
sudo bash uhmsetup.sh| (For full uninstall, see the Remove section above.) | (Para desinstalar por completo, vea la sección Remove más arriba.) |
The daemon executes a full cycle every POLL_INTERVAL seconds (default 20, configured in uhm.env). Each cycle executes ten steps. Two independent mechanisms run inside the same cycle without being numbered steps -- see Independent Mechanisms below.
|
El daemon ejecuta un ciclo completo cada POLL_INTERVAL segundos (default 20, configurado en uhm.env). Cada ciclo ejecuta diez pasos. Dos mecanismos independientes corren dentro del mismo ciclo sin ser pasos numerados -- ver Independent Mechanisms más abajo.
|
mac-*.txt change watcher (independent, not a numbered step): every cycle, right after snapshot, fingerprints all mac-*.txt files with a combined md5 (existence + content, no MAC/status parsing) and compares it to the previous cycle's. If it changed, the reload isn't triggered immediately — it's flagged for the reload step to pick up next cycle, so it never causes a second, separate uhmreload.sh invocation in the same run as one already triggered by the ACL files above.
This is why an edit always produces two log lines, one cycle apart, not one — they mark two different moments, not a duplicate: 2026-07-23 22:01:28 INFO: mac-*.txt changed -- reload scheduled for next cycle2026-07-23 22:01:31 INFO: mac-*.txt change from previous cycle -- reloading now2026-07-23 22:01:31 INFO: invoking /etc/uhm/core/uhmreload.sh
The first line is the watcher noticing the change (this cycle); the second is the reload step actually acting on it (next cycle), immediately followed by the actual invocation. Seeing only the first without a follow-up second line one cycle later would itself be a sign something is wrong. authorize_managed_macs (independent, not a numbered step): runs right after revoke, using the same stat/sta already fetched that cycle. For every active MAC in mac-*.txt that stat/sta currently reports authorized=false, it calls UniFi's authorize-guest (duration derived from AUTHORIZED_LEASE_TIME / 60, i.e. the same lease time pydhcp already gives these devices -- 30 days by default). This exists because on a WLAN configured as Guest/Hotspot, the AP holds a client at the captive portal based on UniFi's own per-client authorized flag, regardless of pydhcpd's fixed-address DHCP bypass or uhmiptables.sh's firewall rules -- confirmed by direct stat/sta queries showing is_guest=true/authorized=false for a mac-*.txt device with an otherwise fully correct fixed IP. It touches only UniFi's own state, never uhm-auth.txt or any local ACL file, and is naturally self-healing: no separate "already authorized" cache is kept, so it re-authorizes on its own if UniFi's state ever lapses.
|
Watcher de cambios en mac-*.txt (independiente, no es un paso numerado): cada ciclo, justo después de snapshot, calcula una huella md5 combinada de todos los mac-*.txt (existencia + contenido, sin parsear MAC/estado) y la compara con la del ciclo anterior. Si cambió, el reload no se dispara de inmediato — queda marcado para que el paso reload lo recoja en el siguiente ciclo, de modo que nunca provoca una segunda invocación separada de uhmreload.sh en la misma corrida que otra ya disparada por los archivos ACL de arriba.
Por eso una edición siempre produce dos líneas de log, separadas por un ciclo, no una — marcan dos momentos distintos, no una duplicación: 2026-07-23 22:01:28 INFO: mac-*.txt changed -- reload scheduled for next cycle2026-07-23 22:01:31 INFO: mac-*.txt change from previous cycle -- reloading now2026-07-23 22:01:31 INFO: invoking /etc/uhm/core/uhmreload.sh
La primera línea es el watcher notando el cambio (este ciclo); la segunda es el paso de reload actuando sobre él (ciclo siguiente), seguida de inmediato por la invocación real. Ver solo la primera sin una segunda línea de seguimiento un ciclo después sería en sí misma una señal de que algo anda mal. authorize_managed_macs (independiente, no es un paso numerado): corre justo después de revoke, usando el mismo stat/sta ya obtenido ese ciclo. Para cada MAC activa de mac-*.txt que stat/sta reporta actualmente como authorized=false, llama a authorize-guest de UniFi (duración derivada de AUTHORIZED_LEASE_TIME / 60, o sea el mismo lease time que pydhcp ya le da a estos dispositivos -- 30 días por defecto). Esto existe porque en una WLAN configurada como Guest/Hotspot, el AP retiene a un cliente en el portal cautivo según su propio flag authorized por cliente en UniFi, sin importar el bypass DHCP de dirección fija de pydhcpd ni las reglas de firewall de uhmiptables.sh -- confirmado con consultas directas a stat/sta que mostraban is_guest=true/authorized=false para un dispositivo de mac-*.txt con una IP fija por lo demás totalmente correcta. Solo toca el estado propio de UniFi, nunca uhm-auth.txt ni ninguna ACL local, y es autorreparable por diseño: no mantiene una caché separada de "ya autorizado", así que se vuelve a autorizar por su cuenta si el estado de UniFi alguna vez decae.
|
Client flow: a new client connecting to the SSID receives a pool DHCP lease from pydhcpd. On the daemon's new leases step (every POLL_INTERVAL cycle, not waiting for a separate trigger), uhmd scans pydhcpd.leases directly and writes the MAC into uhm-grace.txt with a timestamp — writing that file is what triggers the reload, which then runs uhmleases.sh to do the actual classification/expiry/blocking. If the client enters a voucher, uhmd promotes it to uhm-auth.txt and assigns a fixed hotspot-range IP. Regardless of subsequent reconnections, once BLOCKDHCP_GRACE_SECONDS elapses without a voucher the MAC is permanently moved to blockdhcp.txt. When a voucher expires, the MAC is simply released from uhm-auth.txt — nothing preserves it elsewhere; on reconnect it is treated as a brand-new client and re-enters uhm-grace.txt with a fresh grace timer, same as any other unclassified MAC. The only way out of blockdhcp.txt is manual removal or addition to mac-*.
Record format: a;MAC;IP;HOSTNAME;END_TIME_EPOCH; in uhm-auth.txt. a;MAC;IP;HOSTNAME;FIRST_SEEN_EPOCH; in uhm-grace.txt. The leading a means "active" and is what marks a well-formed entry — any other leading character is malformed. There is no opposite value: to deactivate an entry, comment out the whole line by prefixing it with # instead of editing the a itself. In uhm-auth.txt specifically, commenting out a line only changes its DHCP-level treatment (fixed address → blockdhcp class, same as a commented mac-*.txt entry) — it does not pause END_TIME_EPOCH: the expire step still removes the line once the voucher's time is up, active or commented.
Malformed uhm-grace.txt lines: uhmleases.sh's expire_grace_entries() discards, rather than keeps, any line with a bad status/MAC/epoch field. This is intentional: the only writer of this file always writes a valid entry, so a dropped MAC is simply re-added correctly on its next DHCP lease renewal — keeping a malformed line instead would block that self-repair, since the file's own MAC-match check would treat it as already tracked and never write a fresh, valid entry for it.
Auth resilience: the CSRF token is extracted from the UniFi OS JWT payload ( csrfToken field, unifi-os) or from the response header (classic) after login, and persisted to /run/uhmd_session so it survives across $(...) subshell boundaries. On HTTP 401 from any API call, the daemon re-authenticates once and retries automatically.
Re-authorizing a client from the UniFi UI: after a client has been revoked (UniFi reported it as authorized=false), re-authorizing it from the UniFi UI takes one extra cycle to take effect — one POLL_INTERVAL, 20 seconds with the default. This is not a delay in UniFi, it is the order of the daemon's own cycle: the sessions step (7) runs before stat/sta is queried for the revoke step (8), so the record that blocks re-authorization is only cleared after sessions has already run. The client is picked up on the following cycle. That ordering is deliberate and documented in run_cycle: querying stat/sta earlier would let a stale reading undo a voucher redeemed moments before. Redeeming a new voucher is not affected — it carries a different end_time and is honoured on the very next cycle.
|
Flujo del cliente: un cliente nuevo que se conecta al SSID recibe un lease DHCP de pool de pydhcpd. En el paso de clientes nuevos del daemon (cada ciclo de POLL_INTERVAL, sin esperar un disparador aparte), uhmd escanea pydhcpd.leases directamente y escribe la MAC en uhm-grace.txt con un timestamp — escribir ese archivo es lo que dispara el reload, que a su vez ejecuta uhmleases.sh para hacer la clasificación/expiración/bloqueo real. Si el cliente introduce un voucher, uhmd lo promueve a uhm-auth.txt y le asigna una IP fija del rango hotspot. Sin importar las reconexiones posteriores, una vez transcurrido BLOCKDHCP_GRACE_SECONDS sin voucher el MAC pasa permanentemente a blockdhcp.txt. Cuando un voucher expira, la MAC simplemente se libera de uhm-auth.txt — nada la preserva en otro lado; al reconectarse se trata como cliente completamente nuevo y vuelve a entrar en uhm-grace.txt con un temporizador de gracia nuevo, igual que cualquier otra MAC sin clasificar. La única salida de blockdhcp.txt es la eliminación manual o su incorporación a mac-*.
Formato de registro: a;MAC;IP;HOSTNAME;END_TIME_EPOCH; en uhm-auth.txt. a;MAC;IP;HOSTNAME;FIRST_SEEN_EPOCH; en uhm-grace.txt. La a inicial significa "active" (activo) y es lo que marca una entrada bien formada — cualquier otro carácter inicial es malformado. No existe un valor opuesto: para desactivar una entrada, comenta la línea completa agregando # al inicio en vez de editar la a misma. En uhm-auth.txt específicamente, comentar una línea solo cambia su tratamiento a nivel DHCP (dirección fija → clase blockdhcp, igual que una entrada comentada de mac-*.txt) — no pausa END_TIME_EPOCH: el paso expire igual elimina la línea una vez que se cumple el tiempo del voucher, esté activa o comentada.
Líneas malformadas en uhm-grace.txt: expire_grace_entries() de uhmleases.sh descarta, en vez de conservar, cualquier línea con status/MAC/epoch inválido. Es intencional: el único proceso que escribe este archivo siempre escribe una entrada válida, así que una MAC descartada simplemente se vuelve a agregar correctamente en su siguiente renovación de lease DHCP — conservar la línea malformada en cambio bloquearía esa autoreparación, porque el chequeo de coincidencia por MAC del archivo la trataría como ya rastreada y nunca escribiría una entrada nueva y válida para ella.
Resiliencia de auth: el token CSRF se extrae del payload JWT de UniFi OS (campo csrfToken, unifi-os) o del header de respuesta (classic) tras el login, y se persiste en /run/uhmd_session para que sobreviva el límite de subshells $(...). Ante HTTP 401 de cualquier llamada API, el daemon re-autentica una vez y reintenta automáticamente.
Reautorizar un cliente desde la UI de UniFi: después de que un cliente fue revocado (UniFi lo reportó como authorized=false), reautorizarlo desde la UI de UniFi tarda un ciclo extra en surtir efecto — un POLL_INTERVAL, 20 segundos con el valor por defecto. No es una demora de UniFi, es el orden del propio ciclo del daemon: el paso sessions (7) corre antes de que se consulte stat/sta para el paso revoke (8), así que el registro que bloquea la reautorización recién se descarta cuando sessions ya se ejecutó. El cliente se recoge en el ciclo siguiente. Ese orden es deliberado y está documentado en run_cycle: consultar stat/sta antes permitiría que una lectura obsoleta deshiciera un voucher canjeado instantes atrás. Canjear un voucher nuevo no se ve afectado — trae otro end_time y se respeta en el ciclo inmediatamente siguiente.
|
The firewall is managed independently by the administrator via /etc/uhm/tools/uhmiptables.sh (see Scope), invoked by uhmreload.sh after every ACL change. The script flushes and rebuilds all ipsets and iptables rules from scratch on each run. Variables are loaded exclusively from uhm.env — no hardcoded network-specific values (interfaces, IPs, DNS). The UniFi ports listed below are fixed protocol requirements, not environment-specific, and are intentionally hardcoded.
The exact ipsets, rule order, and redirects are defined in tools/uhmiptables_example.txt — read that file directly rather than a copy here, since it changes independently of this document and a duplicated excerpt would inevitably drift out of sync with the real rules.
Note: uhmiptables.sh is invoked automatically by uhmreload.sh — never run it manually during normal operation. The script flushes ALL iptables rules and ipsets on every run. Variables ($lan, $wan, $localnet, $netmask, $serverip, $cpd_tcp, $SERV_DNS) are loaded at runtime exclusively from uhm.env.
Minimal template: uhmsetup.sh deploys tools/uhmiptables.sh as a minimal but fully working template: it enables IPv4 forwarding and adds a NAT MASQUERADE rule on the WAN interface, neither of which Ubuntu does by default and without which LAN clients get a lease but reach nothing. Its rules live in a dedicated UHM_NAT chain, flushed and rebuilt on every run so they never accumulate; nothing outside that chain is touched, so a firewall managed elsewhere is left alone. It does not redirect to the proxy, filter ports, bind MAC to IP, or build any ipset — copy tools/uhmiptables_example.txt over it and adapt it for that. The file is deployed only when absent and never overwritten afterwards, since it becomes the administrator's own once customized. Client classification (grace/authorized/blocked) is done by uhmd, blocked MACs are denied a lease by pydhcpd, and the captive portal is enforced by UniFi's own per-client authorized flag: all three keep working regardless of this file. If it is missing, uhmreload.sh logs a warning and continues instead of treating it as a reload failure. See uhmreload in the CORE section for exactly how failures of this script (and of uhmleases.sh) are handled.
|
El firewall es gestionado independientemente por el administrador vía /etc/uhm/tools/uhmiptables.sh (ver Scope), invocado por uhmreload.sh tras cada cambio de ACL. El script vacía y reconstruye todos los ipsets y reglas iptables desde cero en cada ejecución. Las variables se cargan exclusivamente desde uhm.env — sin valores hardcodeados específicos del entorno (interfaces, IPs, DNS). Los puertos de UniFi listados abajo son requisitos fijos de protocolo, no específicos del entorno, y están hardcodeados intencionalmente.
Los ipsets exactos, el orden de reglas y las redirecciones están definidos en tools/uhmiptables_example.txt — consulte ese archivo directamente en vez de una copia aquí, ya que cambia independientemente de este documento y un extracto duplicado inevitablemente quedaría desincronizado de las reglas reales.
Nota: uhmiptables.sh es invocado automáticamente por uhmreload.sh — nunca ejecutarlo manualmente durante operación normal. El script vacía TODAS las reglas iptables e ipsets en cada ejecución. Las variables ($lan, $wan, $localnet, $netmask, $serverip, $cpd_tcp, $SERV_DNS) se cargan en tiempo de ejecución exclusivamente desde uhm.env.
Plantilla mínima: uhmsetup.sh despliega tools/uhmiptables.sh como una plantilla mínima pero plenamente funcional: habilita el reenvío IPv4 y añade una regla NAT MASQUERADE en la interfaz WAN, cosas que Ubuntu no hace por defecto y sin las cuales los clientes LAN obtienen lease pero no alcanzan nada. Sus reglas viven en una cadena dedicada UHM_NAT, vaciada y reconstruida en cada ejecución para que nunca se acumulen; nada fuera de esa cadena se toca, así que un firewall gestionado por otra vía queda intacto. No redirige al proxy, no filtra puertos, no ata MAC a IP ni construye ningún ipset — para eso copie tools/uhmiptables_example.txt sobre este archivo y adáptelo. El archivo se despliega solo si falta y nunca se sobrescribe después, ya que pasa a ser propiedad del administrador una vez personalizado. La clasificación de clientes (gracia/autorizado/bloqueado) la hace uhmd, a las MAC bloqueadas pydhcpd les niega el lease, y el portal cautivo lo aplica el propio flag authorized por cliente de UniFi: las tres cosas siguen funcionando independientemente de este archivo. Si falta, uhmreload.sh registra un warning y continúa en vez de tratarlo como fallo de reload. Ver uhmreload en la sección CORE para el detalle exacto de cómo se maneja el fallo de este script (y el de uhmleases.sh).
|
⚠️ WARNING: Keep large blocklists out of this script. Useiptablesfor this project's own purposes, such as allowing or denying traffic by MAC/IP and port, as well as the captive-portal redirects. Do not useiptablesto manage large lists of domains, IP addresses, reputation or content. For that kind of filtering, specialized tools are recommended, such asFail2ban,Unbound,Squid,Suricata, among others. Bear in mind thatuhmiptables.shruns in full on every reload, and every reload stops and startspydhcpd. Large lists can slow those cycles down and increase the risk of collisions while they run.
⚠️ WARNING: Mantenga las listas de bloqueo grandes fuera de este script. Useiptablespara las funciones propias de este proyecto, como permitir o denegar tráfico por MAC/IP y puerto, así como las redirecciones del portal cautivo. No utiliceiptablespara gestionar grandes listas de dominios, direcciones IP, reputación o contenido. Para este tipo de filtrado se recomienda utilizar herramientas especializadas, comoFail2ban,Unbound,Squid,Suricata, entre otras. Tenga en cuenta queuhmiptables.shse ejecuta completamente en cada reload, y cada reload detiene y vuelve a iniciarpydhcpd. La presencia de listas grandes puede ralentizar estos ciclos y aumentar el riesgo de colisiones durante su ejecución.
Required UniFi ports (hardcoded in uhmiptables.sh):
| Port | Proto | Direction | Purpose | Propósito |
|---|---|---|---|---|
| 8080 | TCP | LAN → controller | AP-to-controller communication | Comunicación AP-controlador |
| 8880 | TCP | LAN → controller | Captive portal HTTP | Portal cautivo HTTP |
| 8881 | TCP | LAN → controller | Captive portal HTTP alternate | Portal cautivo HTTP alternativo |
| 8882 | TCP | LAN → controller | Captive portal HTTP alternate | Portal cautivo HTTP alternativo |
| 8843 | — | not opened | Captive portal HTTPS -- not used: UHM only serves the captive portal over plain HTTP, never HTTPS (see UNIFI PRE-CONFIGURATION above) |
No usado: UHM sirve el portal cautivo solo por HTTP plano, nunca HTTPS (ver UNIFI PRE-CONFIGURATION arriba) |
| 6789 | TCP | LAN → controller | UniFi speed test / throughput measurement | Prueba de velocidad UniFi / medición de throughput |
| 10001 | UDP | LAN ↔ APs | Device discovery | Descubrimiento de dispositivos |
| 3478 | UDP | LAN → WAN | STUN for APs behind NAT | STUN para APs detrás de NAT |
| 123 | UDP | LAN → WAN | NTP time sync | Sincronización NTP |
For the full list of UniFi required ports see: help.ui.com/hc/en-us/articles/218506997
Para la lista completa de puertos requeridos por UniFi, consulte: help.ui.com/hc/en-us/articles/218506997
core/ holds the reload mechanism itself (see Scope). uhmd.sh/uhmd.service run the daemon, uhmreload.sh is the wrapper it invokes on every ACL change, and uhmleases.sh is the actual ACL/lease reconciliation uhmreload.sh calls. tools/ (next section) holds independent, optional utilities UHM runs fine without. uhmiptables.sh is the one exception living under tools/: required for firewall enforcement, but its absence does not stop uhmd from starting or from classifying clients correctly — see Failure handling under uhmreload below for exactly how failures of each script are handled.
|
core/ contiene el mecanismo de reload en sí (ver Scope). uhmd.sh/uhmd.service ejecutan el daemon, uhmreload.sh es el wrapper que este invoca en cada cambio de ACL, y uhmleases.sh es la reconciliación real de ACLs/leases que uhmreload.sh llama. tools/ (siguiente sección) contiene utilidades independientes y opcionales sin las cuales UHM funciona igual. uhmiptables.sh es la única excepción que vive bajo tools/: necesario para la aplicación del firewall, pero su ausencia no impide que uhmd arranque o clasifique clientes correctamente — ver Failure handling bajo uhmreload abajo para el detalle exacto de cómo se maneja el fallo de cada script.
|
uhmd.sh is the persistent systemd daemon — the entry point of the whole mechanism. It runs a full management cycle every POLL_INTERVAL seconds (default 20), polling the UniFi controller and reconciling ACL files. See Daemon Cycle above for the full 10-step breakdown.
Installed at /etc/uhm/core/uhmd.sh.
|
uhmd.sh es el daemon systemd persistente — el punto de entrada de todo el mecanismo. Ejecuta un ciclo de gestión completo cada POLL_INTERVAL segundos (default 20), consultando el controlador UniFi y reconciliando los archivos ACL. Ver Daemon Cycle arriba para el detalle completo de los 10 pasos.
Instalado en /etc/uhm/core/uhmd.sh.
|
Right after a host reboot, the login endpoint typically answers before the UniFi controller's data endpoints (stat/voucher, stat/guest, stat/sta) finish initializing. A login success does not by itself mean the backend is fully usable yet — the log shows both milestones separately:
|
Justo después de un reinicio del host, el endpoint de login típicamente responde antes de que los endpoints de datos del controlador UniFi (stat/voucher, stat/guest, stat/sta) terminen de inicializar. Que el login tenga éxito no significa por sí solo que el backend ya esté completamente operativo — el log muestra ambos hitos por separado:
|
2026-07-12 21:41:10 INFO: UniFi login failed (HTTP 000), retry in grace
2026-07-12 21:41:20 INFO: UniFi login failed (HTTP 000), retry in grace
2026-07-12 21:41:30 INFO: UniFi login failed (HTTP 000), retry in grace
2026-07-12 21:41:50 INFO: UniFi login OK
2026-07-12 21:41:51 WARNING: Could not load vouchers (rc=empty)
2026-07-12 21:41:56 INFO: sessions step, stat/guest unavailable -- skip
2026-07-12 21:41:56 INFO: revoke step, stat/sta unavailable -- skip
2026-07-12 21:42:11 WARNING: Could not load vouchers (rc=empty)
2026-07-12 21:42:16 INFO: sessions step, stat/guest unavailable -- skip
2026-07-12 21:42:16 INFO: revoke step, stat/sta unavailable -- skip
2026-07-12 21:42:31 INFO: UniFi backend ready (voucher/guest/sta OK)
Both parts are expected and self-resolving. The login retries are
uhmd.shwaiting outSTARTUP_GRACE_SECONDSwhile UniFi OS itself is still coming up. The couple of data-endpoint failures right after a successful login happen because UniFi OS brings its auth endpoint up slightly before the rest of its API is ready to serve — a few seconds of lag, not a real failure.UniFi backend readylogs exactly once, on the transition from any ofstat/voucher/stat/guest/stat/stafailing to all three succeeding together — the single line to watch for "the daemon is now fully operational" instead of inferring it from the absence of further warnings.Ambas partes son esperadas y se resuelven solas. Los reintentos de login son
uhmd.shesperando a que termineSTARTUP_GRACE_SECONDSmientras UniFi OS todavía está iniciando. Los fallos en los endpoints de datos justo después de un login exitoso ocurren porque UniFi OS activa su endpoint de autenticación un poco antes de que el resto de su API esté lista para responder — unos segundos de retraso, no un fallo real.UniFi backend readyse registra exactamente una vez, en la transición de cualquiera destat/voucher/stat/guest/stat/stafallando a los tres respondiendo juntos — la línea a observar para saber "el daemon ya está completamente operativo" en vez de inferirlo por la ausencia de más advertencias.
mac-*.txt files are entirely optional. uhmsetup.sh only creates the empty /etc/acl/mac directory; it never creates any mac-*.txt file itself. uhmleases.sh does create mac-limited.txt and mac-unlimited.txt (empty) on its first run if they're missing, but an admin who never writes an actual entry into either is running a fully supported configuration: with no managed MACs, every client goes through the normal guest flow (grace → voucher → captive portal), with no exceptions. Nothing in uhmd.sh or uhmleases.sh requires a non-empty mac-*.txt to function — every place that reads them (a glob with nullglob, or a fixed path already guaranteed to exist) degrades cleanly to "nothing is managed" when they're empty or absent.
|
Los archivos mac-*.txt son totalmente opcionales. uhmsetup.sh solo crea el directorio vacío /etc/acl/mac; nunca crea ningún archivo mac-*.txt por sí mismo. uhmleases.sh sí crea mac-limited.txt y mac-unlimited.txt (vacíos) en su primera ejecución si faltan, pero un administrador que nunca escribe una entrada real en ninguno de los dos está corriendo una configuración totalmente soportada: sin MACs gestionadas, todo cliente pasa por el flujo normal de invitados (gracia → voucher → portal cautivo), sin excepciones. Nada en uhmd.sh ni uhmleases.sh requiere que un mac-*.txt tenga contenido para funcionar — cada lugar que los lee (un glob con nullglob, o una ruta fija ya garantizada existente) degrada limpiamente a "nada está gestionado" cuando están vacíos o ausentes.
|
Recommendation: infrastructure equipment that gets its DHCP lease from the same pydhcpd instance as the guest network (APs, switches, and similar communications gear on the same subnet) should be listed in mac-unlimited.txt. Without an entry, such a device is indistinguishable from any unknown guest client: it enters uhm-grace.txt on first lease, and once BLOCKDHCP_GRACE_SECONDS elapses without a voucher — which infrastructure gear has no way to redeem, since it never opens the captive portal itself — uhmleases.sh moves it to blockdhcp.txt, and pydhcpd denies it any further lease. That is a verified mechanism, not a guess; whether losing DHCP renewal actually degrades that specific device (reboot loop, lost management access, etc.) depends on the device itself and is outside what this project's code can determine — the safe default is simply not to let infrastructure gear go through the same unknown-client path guests do.
|
Recomendación: el equipo de infraestructura que obtiene su lease DHCP del mismo pydhcpd que la red de invitados (APs, switches y equipos de comunicaciones similares en la misma subred) debería estar listado en mac-unlimited.txt. Sin una entrada, ese dispositivo es indistinguible de cualquier cliente invitado desconocido: entra a uhm-grace.txt en su primer lease, y una vez que pasa BLOCKDHCP_GRACE_SECONDS sin voucher — que el equipo de infraestructura no tiene forma de canjear, ya que nunca abre el portal cautivo por sí mismo — uhmleases.sh lo mueve a blockdhcp.txt, y pydhcpd le niega cualquier lease posterior. Ese es un mecanismo verificado, no una suposición; si perder la renovación DHCP realmente degrada a ese dispositivo en particular (bucle de reinicio, pérdida de acceso de gestión, etc.) depende del propio equipo y queda fuera de lo que el código de este proyecto puede determinar — lo seguro por defecto es simplemente no dejar que el equipo de infraestructura pase por el mismo camino de cliente desconocido que los invitados.
|
Editing any mac-*.txt file — adding, removing, commenting (#a;…) or uncommenting (a;…) a line, changing an IP/hostname — is detected by the independent watcher described in Daemon Cycle (a combined md5 of the whole mac-*.txt set, compared across cycles). It never parses which MAC changed or what changed about it — only that the set as a whole differs from the previous cycle. The change is flagged in the cycle it's detected, and the reload itself fires on the next cycle:
|
Editar cualquier archivo mac-*.txt — agregar, quitar, comentar (#a;…) o descomentar (a;…) una línea, cambiar una IP/hostname — es detectado por el watcher independiente descrito en Daemon Cycle (un md5 combinado de todo el conjunto mac-*.txt, comparado entre ciclos). Nunca parsea qué MAC cambió ni qué cambió en ella — solo que el conjunto completo difiere del ciclo anterior. El cambio se marca en el ciclo donde se detecta, y el reload en sí se dispara en el ciclo siguiente:
|
2026-07-23 14:13:45 INFO: mac-*.txt changed -- reload scheduled for next cycle
2026-07-23 14:14:05 INFO: mac-*.txt change from previous cycle -- reloading now
2026-07-23 14:14:05 INFO: invoking /etc/uhm/core/uhmreload.sh
Whatever the edit actually was (block/reactivate/add/remove/IP change), uhmleases.sh is what interprets it on that reload: an active (a;) line gets a fixed-address DHCP entry; a commented (#a;) line joins the same blockdhcp deny class as blockdhcp.txt, so pydhcpd denies it a lease outright.
|
Sea cual sea la edición real (bloqueo/reactivación/alta/baja/cambio de IP), uhmleases.sh es quien la interpreta en ese reload: una línea activa (a;) recibe una entrada DHCP de dirección fija; una línea comentada (#a;) entra en la misma clase de denegación blockdhcp que blockdhcp.txt, así que pydhcpd le niega el lease directamente.
|
Systemd unit for uhmd.sh. Restart=always with RestartSec=10 restarts the daemon on any crash; StartLimitIntervalSec=300 / StartLimitBurst=10 (in [Unit]) cap it at 10 restarts per 5 minutes before systemd marks it start-limit-hit and stops trying — a general crash-loop guard, not specific to any one failure mode. After=network.target pydhcpd.service / Wants=pydhcpd.service order startup after the DHCP backend, though uhmd.sh still tolerates pydhcpd coming up late via its own startup grace (see Daemon Cycle).
Installed at /etc/systemd/system/uhmd.service, deployed from the repo's service/uhmd.service.
Note — sandboxing: PrivateTmp=yes, ProtectHome=read-only, ProtectControlGroups=yes, ProtectClock=yes, ProtectHostname=yes, ProtectKernelLogs=yes, LockPersonality=yes, RestrictRealtime=yes and RestrictSUIDSGID=yes are applied — none of them intersect any path or syscall this daemon or its reload chain actually uses (PrivateTmp gives uhmreload.sh's trace files and uhmleases.sh's mktemp calls an isolated /tmp, with no downside since nothing outside the reload chain needs to see them). One more common hardening directive is intentionally not set, because it would break real functionality: ProtectSystem=strict would make /etc read-only, but uhmleases.sh rewrites /etc/pydhcp/core/pydhcpd.conf and pydhcpd.leases on every reload, and the admin-supplied uhmiptables.sh is arbitrary code that may need to write anywhere on the system (persistent ipset/iptables rule files, etc.) — a static ReadWritePaths allowlist can't be correct in general for a script the admin fully controls.
|
Unit systemd para uhmd.sh. Restart=always con RestartSec=10 reinicia el daemon ante cualquier caída; StartLimitIntervalSec=300 / StartLimitBurst=10 (en [Unit]) lo limitan a 10 reinicios cada 5 minutos antes de que systemd lo marque start-limit-hit y deje de intentarlo — una protección general contra crash-loops, no específica de un solo modo de fallo. After=network.target pydhcpd.service / Wants=pydhcpd.service ordenan el arranque después del backend DHCP, aunque uhmd.sh igual tolera que pydhcpd arranque tarde gracias a su propio período de gracia al inicio (ver Daemon Cycle).
Instalado en /etc/systemd/system/uhmd.service, desplegado desde service/uhmd.service del repositorio.
Nota — sandboxing: se aplican PrivateTmp=yes, ProtectHome=read-only, ProtectControlGroups=yes, ProtectClock=yes, ProtectHostname=yes, ProtectKernelLogs=yes, LockPersonality=yes, RestrictRealtime=yes y RestrictSUIDSGID=yes — ninguna interseca con ninguna ruta o syscall que el daemon o su cadena de reload usen realmente (PrivateTmp le da a los trace files de uhmreload.sh y a los mktemp de uhmleases.sh un /tmp aislado, sin ninguna desventaja ya que nada fuera de la cadena de reload necesita verlos). Una directiva de hardening común se deja intencionalmente fuera, porque rompería funcionalidad real: ProtectSystem=strict dejaría /etc de solo lectura, pero uhmleases.sh reescribe /etc/pydhcp/core/pydhcpd.conf y pydhcpd.leases en cada reload, y el uhmiptables.sh que provee el administrador es código arbitrario que puede necesitar escribir en cualquier parte del sistema (archivos de persistencia de ipset/iptables, etc.) — una whitelist estática de ReadWritePaths no puede ser correcta en general para un script que el administrador controla por completo.
|
uhmreload.sh is the reload wrapper — invoked by uhmd after every ACL change, or on its own safety-net cadence (RELOAD_SAFETY_INTERVAL_SECONDS, default 1h) even without a diff, so idle networks still get grace→block promotion and firewall self-healing. It can also be run manually for troubleshooting, but only while uhmd.service is active -- it aborts otherwise. It runs uhmleases.sh (lease/ACL rebuild) and then uhmiptables.sh (firewall rules), in that order — but the two are not treated the same on failure (see table below).
This asymmetry reflects what each script actually is: uhmleases.sh is the core ACL/lease reconciliation step — nothing downstream can be trusted without it. uhmiptables.sh only enforces at the firewall level, and ships as a minimal working template (see Firewall Rules) that a normal install always has in place. Only its absence is tolerated, with a warning; a genuine execution failure of uhmiptables.sh still aborts.
Installed at /etc/uhm/core/uhmreload.sh.
|
uhmreload.sh es el wrapper de reload — invocado por uhmd tras cada cambio de ACL, o en su propia cadencia de respaldo (RELOAD_SAFETY_INTERVAL_SECONDS, default 1h) incluso sin diff, para que las redes inactivas sigan teniendo la promoción gracia→bloqueo y la auto-reparación del firewall. También puede ejecutarse manualmente para diagnóstico, pero solo mientras uhmd.service esté activo -- de lo contrario aborta. Ejecuta uhmleases.sh (reconstrucción de leases/ACL) y luego uhmiptables.sh (reglas de firewall), en ese orden — pero los dos no reciben el mismo trato ante un fallo (ver tabla abajo).
Esta asimetría refleja lo que cada script realmente es: uhmleases.sh es el paso central de reconciliación de ACLs/leases — nada aguas abajo es confiable sin él. uhmiptables.sh solo aplica a nivel de firewall, y se despliega como una plantilla mínima funcional (ver Firewall Rules) que toda instalación normal tiene en su sitio. Solo su ausencia se tolera, con una advertencia; un fallo real de ejecución de uhmiptables.sh sigue abortando.
Instalado en /etc/uhm/core/uhmreload.sh.
|
Two separate triggers invoke uhmreload.sh, each logged differently so the reason is clear from the log alone: / Dos disparadores distintos invocan uhmreload.sh, cada uno con un log diferente para que la razón sea clara solo con leerlo:
| Trigger | Log line | Description | Descripción |
|---|---|---|---|
| Cycle | 2026-07-23 22:01:31 INFO: invoking /etc/uhm/core/uhmreload.sh |
The normal case: an ACL file actually changed (or RELOAD_SAFETY_INTERVAL_SECONDS elapsed), detected in check_and_reload_if_changed() every POLL_INTERVAL |
El caso normal: una ACL realmente cambió (o venció RELOAD_SAFETY_INTERVAL_SECONDS), detectado en check_and_reload_if_changed() en cada POLL_INTERVAL |
| Startup | 2026-08-11 07:53:05 INFO: Startup -- invoking uhmreload (ACLs + firewall) |
On every uhmd.sh start, regardless of ACL state: iptables/ipset rules don't survive a reboot even if the ACL files themselves didn't change, so this one fires unconditionally instead of waiting for a diff |
En cada inicio de uhmd.sh, sin importar el estado de las ACLs: las reglas de iptables/ipset no sobreviven un reboot aunque los archivos ACL no hayan cambiado, así que esta se dispara sin condición en vez de esperar un diff |
| Script | Condition | Description | Descripción |
|---|---|---|---|
uhmleases.sh |
Missing | Abort reload (ERROR + exit 1) |
Aborta el reload (ERROR + exit 1) |
uhmleases.sh |
Fails during execution | Abort reload (ERROR + exit 1) |
Aborta el reload (ERROR + exit 1) |
uhmiptables.sh |
Missing | Warn and continue -- reload still counts as done | Avisa y continúa -- el reload igual cuenta como hecho |
uhmiptables.sh |
Fails during execution | Abort reload (ERROR + exit 1) |
Aborta el reload (ERROR + exit 1) |
uhmd.sh waits for uhmreload.sh with no time limit of its own. uhmreload.sh bounds each step individually instead: UHM_LEASES_TIMEOUT_SECONDS (default 120) and UHM_IPTABLES_TIMEOUT_SECONDS (default 60), both adjustable in uhm.env. A step that exceeds its limit is killed, its trace saved to /var/log/-failure.trace, and the reload aborts the same way as any other failure. This is a single fixed-name file per step (uhmleases-failure.trace, uhmiptables-failure.trace), overwritten on every new failure of that step -- not one file per attempt, so it never accumulates. A successful run leaves the previous trace (if any) untouched; the file only reflects the most recent failure.
|
uhmd.sh espera a uhmreload.sh sin ningún límite de tiempo propio. uhmreload.sh acota cada paso por separado: UHM_LEASES_TIMEOUT_SECONDS (default 120) y UHM_IPTABLES_TIMEOUT_SECONDS (default 60), ambos ajustables en uhm.env. Un paso que excede su límite se mata, su trace se guarda en /var/log/-failure.trace, y el reload aborta igual que cualquier otro fallo. Es un único archivo de nombre fijo por paso (uhmleases-failure.trace, uhmiptables-failure.trace), sobrescrito en cada nueva falla de ese paso — no un archivo por intento, así que nunca se acumula. Una corrida exitosa deja el trace anterior (si existe) intacto; el archivo solo refleja la falla más reciente.
|
uhmleases.sh is a reimplementation of the pyleases.sh shipped by default with pydhcp, with built-in UniFi Hotspot integration. The original version manages DHCP leases and ACLs but has no awareness of the UniFi captive portal. This version adds the UniFi Hotspot Integration module: uhmleases reads /etc/uhm/acl/uhm-auth.txt and /etc/uhm/acl/uhm-grace.txt as authoritative classification lists during lease processing, applies a grace period for unseen MACs (BLOCKDHCP_GRACE_SECONDS, default 24h), and synchronizes hotspot-related ACL entries.
The script runs from /etc/uhm/core/uhmleases.sh and detects the existence of /etc/pydhcp (required). Configuration is read exclusively from /etc/uhm/uhm.env (generated and managed by uhmsetup.sh). To reconfigure, edit uhm.env directly or re-run uhmsetup.sh.
|
uhmleases.sh es una reimplementación del pyleases.sh que viene por defecto con pydhcp, con integración UniFi Hotspot incorporada. La versión original gestiona leases DHCP y ACLs pero no sabe nada del portal cautivo de UniFi. Esta versión añade el módulo UniFi Hotspot Integration: uhmleases lee /etc/uhm/acl/uhm-auth.txt y /etc/uhm/acl/uhm-grace.txt como listas autoritativas de clasificación durante el procesamiento de leases, aplica un período de gracia para MACs nuevas (BLOCKDHCP_GRACE_SECONDS, default 24h), y sincroniza entradas ACL relacionadas con el hotspot.
El script se ejecuta desde /etc/uhm/core/uhmleases.sh y detecta la existencia de /etc/pydhcp (requerido). La configuración se lee exclusivamente desde /etc/uhm/uhm.env (generado y gestionado por uhmsetup.sh). Para reconfigurar, edite uhm.env directamente o vuelva a correr uhmsetup.sh.
|
⚠️ WARNING:uhmleases.shandpyleases.shboth fully rebuild the same/etc/pydhcp/core/pydhcpd.conffrom ACL sources on every run. They are mutually exclusive on the same installation — running both (e.g. one from cron, the other viauhmreload.sh) makes each overwrite the other's rebuild, silently discarding whichever directives the other one doesn't know about (the UniFi Hotspot ACL entries fromuhmleases.sh, or any change made throughpyleases.sh). If you installUHM, useuhmleases.shexclusively and do not runpyleases.shon the same host. Classes and pools: thepydhcpddaemon supports severalpool { }blocks and any number ofclass/subclassdeclarations, exactly asisc-dhcp-serverdoes.uhmleases.sh, by design, only ever writes what this project documents: one pool withdeny members of "blockdhcp";, plus thefixed-addressreservations from the ACL lists. Any extra class or pool added by hand topydhcpd.confis discarded on the next run. This is not a hard limit:uhmleases.shis a plain shell script, so anyone who needs extra classes or pools can edit the block that writespydhcpd.confand emit them there — the daemon will honour whatever the file ends up containing. Keep your own copy of any such change:uhmsetup.sh --updatereplaces the script with the shipped version, and although it saves the previous one under/etc/uhm/bak/<YYYYMMDD_HHMMSS>/, the edit has to be reapplied by hand after every update.
⚠️ WARNING:uhmleases.shypyleases.shreconstruyen completamente el mismo/etc/pydhcp/core/pydhcpd.confa partir de fuentes ACL en cada ejecución. Son mutuamente excluyentes en la misma instalación — correr ambos (por ejemplo uno desde cron y el otro víauhmreload.sh) hace que cada uno sobrescriba la reconstrucción del otro, descartando en silencio las directivas que el otro no conoce (las entradas ACL de UniFi Hotspot deuhmleases.sh, o cualquier cambio hecho mediantepyleases.sh). Si instalaUHM, use exclusivamenteuhmleases.shy no ejecutepyleases.shen el mismo host. Clases y pools: el demoniopydhcpdsoporta varios bloquespool { }y cualquier cantidad de declaracionesclass/subclass, igual queisc-dhcp-server.uhmleases.sh, por diseño, solo escribe lo que este proyecto documenta: un pool condeny members of "blockdhcp";, más las reservasfixed-addressde las listas ACL. Cualquier clase o pool agregado a mano apydhcpd.confse descarta en la siguiente ejecución. No es una camisa de fuerza:uhmleases.shes un script de shell corriente, así que quien necesite clases o pools adicionales puede editar el bloque que escribepydhcpd.confy emitirlos ahí — el demonio va a respetar lo que el archivo termine conteniendo. Guarde su propia copia de ese cambio:uhmsetup.sh --updatereemplaza el script por la versión del repositorio y, aunque respalda el anterior en/etc/uhm/bak/<AAAAMMDD_HHMMSS>/, la edición hay que volver a aplicarla a mano tras cada actualización.
ACL sources consumed by uhmleases:
| Path | Role | Rol |
|---|---|---|
/etc/acl/mac/mac-limited.txt |
Authorized — forced through Squid | Autorizados — forzados por Squid |
/etc/acl/mac/mac-unlimited.txt |
Authorized — bypass restrictions | Autorizados — sin restricciones |
/etc/pydhcp/acl/blockdhcp.txt |
Blocked clients | Clientes bloqueados |
/etc/uhm/acl/uhm-grace.txt |
Grace-period clients | Período de gracia |
/etc/uhm/acl/uhm-auth.txt |
Hotspot — voucher active | Hotspot — voucher activo |
Entry format:
Standard : a;MAC;IP;HOSTNAME;
Hotspot : a;MAC;IP;HOSTNAME;END_TIME_EPOCH;
Grace : a;MAC;IP;HOSTNAME;FIRST_SEEN_EPOCH;
| Notation | Meaning | Significado |
|---|---|---|
Leading a |
Marks a well-formed, active entry -- any other leading character is treated as malformed (see ACL priority order). There is no opposite value (no i/d/etc.) |
Marca una entrada activa y bien formada -- cualquier otro carácter inicial se trata como malformado (ver ACL priority order). No existe un valor opuesto (no hay i/d/etc.) |
Leading # (comment out) |
Deactivates an entry -- comment out the whole line (e.g. #a;MAC;IP;HOSTNAME;) instead of changing the a itself. Only valid in mac-*.txt and uhm-auth.txt, the only two lists that ever produce a fixed-address host { } block in pydhcpd.conf; a commented entry there loses its fixed address and joins the same blockdhcp deny class as blockdhcp.txt. In uhm-auth.txt, this only affects DHCP-level treatment -- it does NOT exempt the entry from expiring by END_TIME_EPOCH (see clean_expired_macs); mac-*.txt has no such field, so there's nothing to expire there |
Desactiva una entrada -- comenta la línea completa (p.ej. #a;MAC;IP;HOSTNAME;) en vez de cambiar la a misma. Solo es válido en mac-*.txt y uhm-auth.txt, las únicas dos listas que producen un bloque host { } de dirección fija en pydhcpd.conf; una entrada comentada ahí pierde su dirección fija y entra en la misma clase de denegación blockdhcp que blockdhcp.txt. En uhm-auth.txt, esto solo afecta el tratamiento a nivel DHCP -- NO exime a la entrada de vencer por END_TIME_EPOCH (ver clean_expired_macs); mac-*.txt no tiene ese campo, así que ahí no hay nada que vencer |
# in blockdhcp.txt, uhm-grace.txt, lease removal queue |
Not supported -- these lists have no active/inactive concept (blockdhcp.txt is already a terminal deny state, uhm-grace.txt is purely temporary/self-expiring, and the lease removal queue is a working list with no a;/#a; syntax at all). A #-prefixed line in any of them is treated as malformed and dropped from the file, same as any other invalid line |
No soportado -- estas listas no tienen concepto de activo/inactivo (blockdhcp.txt ya es un estado terminal de denegación, uhm-grace.txt es puramente temporal y autoexpira, y la cola de remoción de leases es una lista de trabajo sin sintaxis a;/#a; en absoluto). Una línea con # en cualquiera de ellas se trata como malformada y se elimina del archivo, igual que cualquier otra línea inválida |
Both are covered per list in ACL priority order -- which list aborts the reload and which one drops the line and continues, and which side loses a duplicate. Not repeated here. Apart from that check, uhmd.sh makes its own pass every cycle, far more often than a reload:
|
Ambos están cubiertos por lista en ACL priority order -- qué lista aborta el reload y cuál descarta la línea y continúa, y qué lado pierde un duplicado. No se repite aquí. Aparte de esa verificación, uhmd.sh hace su propia pasada en cada ciclo, mucho más frecuente que un reload:
|
| File | Description | Descripción |
|---|---|---|
blockdhcp.txt |
The dedup step recovers a line if MAC/IP/hostname can still be parsed out validly (e.g. a missing trailing ;); otherwise it discards it rather than writing it back broken. |
El paso dedup recupera la línea si aún se pueden extraer MAC, IP y hostname válidos (ej. falta el ; final); si no, la descarta en vez de reescribirla rota. |
uhm-auth.txt |
The expire step releases a line with a malformed END_TIME_EPOCH like an expired one. With no readable expiry the entry cannot be sustained, and keeping it would hold a hotspot IP forever if the client never reassociates. It repairs itself: a client whose voucher is still valid is promoted again next cycle, with an END_TIME_EPOCH from UniFi. |
El paso expire libera una línea con END_TIME_EPOCH malformado igual que una vencida. Sin vencimiento legible la entrada no se puede sostener, y conservarla retendría una IP del hotspot para siempre si el cliente no vuelve a asociarse. Se autorrepara: un cliente cuyo voucher sigue vigente vuelve a promoverse en el ciclo siguiente, con un END_TIME_EPOCH que viene de UniFi. |
mac-*.txt, uhm-queue.txt |
Never rewritten by uhmd.sh. |
uhmd.sh nunca las reescribe. |
| Aspect | Description | Descripción |
|---|---|---|
| Reason for stop/start | Stopping guarantees exclusive access to the leases file while it's rewritten, avoiding a race with a lease the daemon might be persisting at that instant | Detenerlo garantiza acceso exclusivo al archivo de leases mientras se reescribe, evitando una carrera con un lease que el daemon pudiera estar persistiendo en ese instante |
| Trade-off | Brief DHCP downtime on every ACL change, accepted for write safety | Breve corte de DHCP en cada cambio de ACL, aceptado a cambio de seguridad en la escritura |
Install (already covered in the Install section above):
# uhmleases.sh is deployed automatically by uhmsetup.sh to /etc/uhm/core/
# Configuration is read from /etc/uhm/uhm.env (managed by uhmsetup.sh)
# No manual setup required — run uhmsetup.sh to configure everythingConfiguration variables (in uhm.env):
| Variable | Default | Description | Descripción |
|---|---|---|---|
SERVER_IP |
(from pydhcp.env) | DHCP server IP address | Dirección IP del servidor DHCP |
SERV_SUBNET |
(from pydhcp.env) | Network subnet | Subred de red |
SERV_BROADCAST |
(from pydhcp.env) | Broadcast address | Dirección de broadcast |
SERV_MASK |
(from pydhcp.env) | Netmask | Máscara de red |
SERV_INI_RANGE_BLOCK |
(from pydhcp.env) | Start of block pool IP range | Inicio del rango de IP del pool de bloqueo |
SERV_END_RANGE_BLOCK |
(from pydhcp.env) | End of block pool IP range | Fin del rango de IP del pool de bloqueo |
SERV_DNS |
(from pydhcp.env) | DNS servers (comma-separated) | Servidores DNS (separados por coma) |
ACL_PATH |
(from pydhcp.env) | Base path for ACL directories | Ruta base para los directorios ACL |
ACL_MAC_PATH |
(from pydhcp.env) | MAC-based ACL directory | Directorio ACL basado en MAC |
ACL_DHCP_PATH |
(from pydhcp.env) | DHCP ACL directory | Directorio ACL de DHCP |
UHM_PATH |
/etc/uhm | Hotspot working directory | Directorio de trabajo del hotspot |
ACL_MAC_LIMITED |
(from pydhcp.env) | Proxy-forced clients | Clientes forzados por proxy |
ACL_MAC_UNLIMITED |
(from pydhcp.env) | Unrestricted clients | Clientes sin restricciones |
UHM_MACAUTH |
/etc/uhm/acl/uhm-auth.txt | Hotspot authorized -- UHM's own | Autorizados del hotspot -- propia de UHM |
ACL_BLOCK_FILE |
(from pydhcp.env) | Blocked clients | Clientes bloqueados |
UHM_GRACE |
/etc/uhm/acl/uhm-grace.txt | Grace period clients -- UHM's own | Clientes en período de gracia -- propia de UHM |
BLOCKDHCP_GRACE_SECONDS |
86400 | Grace period duration (seconds, 24h) | Duración del período de gracia (segundos, 24h) |
| (derived) | AUTHORIZED_LEASE_TIME / 60 |
authorize-guest duration in minutes for mac-*.txt MACs UniFi reports unauthorized -- taken from pydhcp's own lease time, not a separate UHM value |
Duración de authorize-guest en minutos para MACs de mac-*.txt que UniFi reporta sin autorizar -- tomada del propio lease time de pydhcp, no es un valor aparte de UHM |
CLEANUP_INTERVAL |
(from pydhcp.env) | Cleanup frequency and pool lease time (seconds) | Frecuencia de limpieza y tiempo de lease del pool (segundos) |
AUTHORIZED_LEASE_TIME |
(from pydhcp.env) | Lease duration for authorized clients (30 days) | Duración del lease para clientes autorizados (30 días) |
QUARANTINE_DURATION |
(from pydhcp.env) | Seconds an IP is held out of the pool after a DHCPDECLINE or a ping-check conflict, written into pydhcpd.conf as abandon-lease-time (default 60) |
Segundos que una IP se aparta del pool tras un DHCPDECLINE o un conflicto de ping-check, escrito en pydhcpd.conf como abandon-lease-time (default 60) |
WPAD_ENABLED |
(from pydhcp.env) | Enable WPAD/PAC via DHCP option 252. Only takes effect if the PAC URL actually answers HTTP 200 (see WPAD/PAC in Operational Details) |
Habilitar WPAD/PAC vía la opción DHCP 252. Solo tiene efecto si la URL del PAC responde realmente HTTP 200 (ver WPAD/PAC en Operational Details) |
WPAD_PORT |
(from pydhcp.env) | TCP port of the Apache VirtualHost serving wpad.pac (default 18100). Keep it in sync with the PAC port hardcoded in uhmiptables.sh |
Puerto TCP del VirtualHost de Apache que sirve wpad.pac (default 18100). Manténgalo sincronizado con el puerto del PAC que uhmiptables.sh lleva fijo |
PING_CHECK_ENABLED |
(from pydhcp.env) | Ping IP before OFFER to detect conflicts. Set to false in environments with strict ICMP firewall rules |
Hacer ping a la IP antes del OFFER para detectar conflictos. Configurar en false en entornos con reglas de firewall ICMP estrictas |
PING_TIMEOUT_SECONDS |
(from pydhcp.env) | Seconds to wait for the ICMP reply before giving up and sending the OFFER, written into pydhcpd.conf as ping-timeout (default 1) |
Segundos a esperar la respuesta ICMP antes de desistir y enviar el OFFER, escrito en pydhcpd.conf como ping-timeout (default 1) |
Variables marked (from pydhcp.env) live in
/etc/pydhcp/pydhcp.envand are read from there at runtime -- they are never copied intouhm.env, so a change in that file reaches uhm without a re-install.uhmsetup.shnever asks for them. Most other variables have sensible defaults and can be modified directly inuhm.env, but the ACL paths, the lease file andBLOCKDHCP_GRACE_SECONDShave none:uhmacl.shaborts if any of them is missing.Las variables marcadas como (from pydhcp.env) viven en
/etc/pydhcp/pydhcp.envy se leen de ahí en cada ejecución -- nunca se copian auhm.env, así que un cambio en ese archivo llega a uhm sin reinstalar.uhmsetup.shnunca las pregunta. La mayoría de las demás tienen valores predeterminados sensatos y pueden modificarse directamente enuhm.env, pero las rutas de ACL, el archivo de concesiones yBLOCKDHCP_GRACE_SECONDSno los tienen:uhmacl.shaborta si falta alguna.
| Directive | Description | Descripción |
|---|---|---|
authoritative; |
Server sends NAK to clients with foreign leases | El servidor envía NAK a clientes con leases ajenos |
cleanup-interval N; |
How often (seconds) expired leases are removed from memory (controlled via CLEANUP_INTERVAL in uhm.env) |
Frecuencia (segundos) con que se eliminan leases expirados de memoria (controlado via CLEANUP_INTERVAL en uhm.env) |
abandon-lease-time N; |
Seconds an IP is held out of the pool after a DHCPDECLINE or ping-check conflict (controlled via QUARANTINE_DURATION in uhm.env) |
Segundos que una IP se aparta del pool tras un DHCPDECLINE o un conflicto de ping-check (controlado via QUARANTINE_DURATION en uhm.env) |
server-identifier IP; |
IP the server uses to identify itself in DHCP replies | IP con la que el servidor se identifica en las respuestas DHCP |
deny duplicates; |
Reject requests from a MAC that already holds a lease | Rechaza solicitudes de una MAC que ya tiene un lease |
deny declines; |
Ignore DHCPDECLINE messages | Ignora mensajes DHCPDECLINE |
ping-check true|false; |
Ping IP before OFFER to detect conflicts (controlled via PING_CHECK_ENABLED in uhm.env) |
Ping a la IP antes del OFFER para detectar conflictos (controlado via PING_CHECK_ENABLED en uhm.env) |
ping-timeout N; |
Seconds to wait for the ICMP reply before giving up and sending the OFFER (controlled via PING_TIMEOUT_SECONDS in uhm.env); default 1 |
Segundos a esperar la respuesta ICMP antes de desistir y enviar el OFFER (controlado via PING_TIMEOUT_SECONDS en uhm.env); default 1 |
option wpad ...; |
WPAD/PAC proxy auto-configuration (controlled via WPAD_ENABLED in uhm.env) |
Autoconfiguración de proxy WPAD/PAC (controlado via WPAD_ENABLED en uhm.env) |
subnet ... { pool { ... } } |
Subnet declaration with dynamic block pool | Declaración de subred con pool de bloqueo dinámico |
host NAME { hardware ethernet MAC; fixed-address IP; } |
Static host reservation from ACL files | Reserva estática de host desde archivos ACL |
class "blockdhcp" { ... } / subclass "blockdhcp" ... |
MAC-based DHCP block list | Lista de bloqueo DHCP por MAC |
min-lease-time, default-lease-time, max-lease-time |
Lease duration controls | Control de duración de leases |
option routers, option broadcast-address, option domain-name-servers |
Standard DHCP options | Opciones DHCP estándar |
uhmleases.sh fully rebuilds /etc/pydhcp/core/pydhcpd.conf on every run from its ACL files and uhm.env. Any manual edits to pydhcpd.conf — including custom lease times, pools, or directives — will be lost. If you manage pydhcpd.conf manually, do not use uhmleases.sh. |
uhmleases.sh reconstruye completamente /etc/pydhcp/core/pydhcpd.conf en cada ejecución a partir de sus archivos ACL y uhm.env. Cualquier edición manual a pydhcpd.conf — incluyendo lease times, pools o directivas personalizadas — se perderá. Si gestiona pydhcpd.conf manualmente, no utilice uhmleases.sh. |
Deactivating a managed MAC: commenting out a line in a mac-*.txt file (prefixing it with #) keeps it in place, IP included, but gives it the exact same treatment as a blockdhcp.txt entry — uhmleases.sh adds it to the "blockdhcp" DHCP class in pydhcpd.conf, so pydhcpd denies it a lease outright. It never physically enters blockdhcp.txt. |
Desactivar una MAC gestionada: comentar una línea en un archivo mac-*.txt (agregando # al inicio) la deja en su lugar, con su IP incluida, pero recibe exactamente el mismo tratamiento que una entrada de blockdhcp.txt — uhmleases.sh la agrega a la clase DHCP "blockdhcp" en pydhcpd.conf, así que pydhcpd le niega la lease directamente. Nunca entra físicamente a blockdhcp.txt. |
| Aspect | Description | Descripción |
|---|---|---|
| Scope | check_duplicate() is the single guard against duplicate ACL entries in uhmleases.sh — no other function detects or removes one. |
check_duplicate() es la única guarda contra entradas ACL duplicadas en uhmleases.sh — ninguna otra función detecta ni elimina una. |
| When it runs | Twice: right after normalization, to catch a hand-edited file before anything touches it, and again at the very end of the run, to catch a mistake made by the script's own processing in between. | Dos veces: justo después de la normalización, para atrapar un archivo editado a mano antes de que nada lo toque, y otra vez al final de la corrida, para atrapar un error del propio procesamiento del script. |
| Which list wins | See ACL priority order. | Ver ACL priority order. |
| Comparison | On the value alone — a commented (#a;) line counts the same as an active one. |
Solo por el valor — una línea comentada (#a;) cuenta igual que una activa. |
2026-07-18 20:32:50 ERROR: duplicate IP 192.168.0.198
2026-07-18 20:32:50 ERROR: mac-*.txt duplicate entry -- abort
2026-08-25 10:00:00 INFO: dup MAC 'aa:bb:cc:dd:ee:01' removed from blockdhcp.txt
A separate guard, unrelated to duplicate detection and never merged into check_duplicate() — each function has a single purpose. Called alongside check_duplicate(), at the same two points (beginning and end of the script). Checks that no mac-*.txt IP falls inside a range reserved for something else. uhm.env only defines two IP ranges — UHM_INI_RANGE/UHM_END_RANGE (for uhm-auth.txt) and SERV_INI_RANGE_BLOCK/SERV_END_RANGE_BLOCK (the pydhcp pool used by uhm-grace.txt/blockdhcp.txt). mac-*.txt files are administrator-created and administrator-addressed — nothing in uhm.env reserves a range for them, so an IP picked by hand can land outside the LAN subnet, on the network/broadcast address, on SERVER_IP itself, or inside either of the other two ranges. This is always a misconfiguration, whether or not a guest currently holds that exact IP -- reported with a precise ERROR: line, then exit 1.
If neither guard finds a problem on the first pass, the script proceeds into is_pydhcp() (the stop→modify→start pydhcpd cycle) as usual.
|
Una guardia separada, sin relación con la detección de duplicados y nunca fusionada dentro de check_duplicate() — cada función cumple un solo propósito. Se llama junto a check_duplicate(), en los mismos dos puntos (comienzo y final del script). Verifica que ninguna IP de mac-*.txt caiga dentro de un rango reservado para otra cosa. uhm.env solo define dos rangos de IP — UHM_INI_RANGE/UHM_END_RANGE (para uhm-auth.txt) y SERV_INI_RANGE_BLOCK/SERV_END_RANGE_BLOCK (el pool de pydhcp usado por uhm-grace.txt/blockdhcp.txt). Los archivos mac-*.txt son creados y direccionados por el administrador — nada en uhm.env les reserva un rango, así que una IP elegida a mano puede caer fuera de la subred LAN, en la dirección de red/broadcast, sobre el propio SERVER_IP, o dentro de cualquiera de los otros dos rangos. Esto siempre es un error de configuración, sin importar si en ese momento un guest tiene o no esa IP exacta — se reporta con una línea ERROR: puntual, luego exit 1.
Si ninguna de las dos guardias encuentra un problema en la primera pasada, el script continúa directo a is_pydhcp() (el ciclo detener→modificar→arrancar de pydhcpd) normalmente.
|
2026-07-18 20:32:50 ERROR: aa:bb:cc:dd:ee:01: IP inside hotspot range
2026-07-18 20:32:50 ERROR: mac-*.txt IP conflict -- abort
2026-07-18 20:32:50 ERROR: aa:bb:cc:dd:ee:02: IP inside blockdhcp pool
2026-07-18 20:32:50 ERROR: mac-*.txt IP conflict -- abort
| Independent, optional utilities — UHM runs fine without any of these. See CORE above for the reload mechanism itself. | Utilidades independientes y opcionales — UHM funciona igual sin ninguna de estas. Ver CORE arriba para el mecanismo de reload en sí. |
uhmunifi.sh — Authenticates against UniFi OS ( /api/auth/login) by default, or classic controllers ( /api/login) when UNIFI_TYPE=classic, and pulls three datasets: stat/sta (live clients), stat/guest (voucher-redeemed guests), and stat/voucher (full voucher inventory). Cross-references them against uhm-auth.txt, then presents a menu with a Reports submenu (five reports) and an Actions submenu (six actions) -- see the tables below. Audits what UniFi itself reports; for local ACL file consistency, see uhmacl.sh below. In the Authorized report, STATUS is MULTI/VALID for a voucher still listed in stat/voucher, CONSUMED for one UniFi already auto-purged on quota exhaustion, and NO-VOUCHER(origin) when the entry has no code in its hostname and UniFi reports its stat/guest session with authorized_by other than voucher — the signature of an authorization granted outside the voucher flow, which uhmd.sh no longer promotes. The Guest sessions report shows this same signal for every active session UniFi reports, regardless of whether it made it into uhm-auth.txt. Logs to /var/log/uhmunifi.log — only the login/fetch summary and every action taken; report tables are terminal-only, on demand. Reads credentials from /etc/uhm/uhm.env. Required variables: UNIFI_CONTROLLER_URL, UNIFI_USERNAME, UNIFI_PASSWORD, UHM_ESSID. Optional: UNIFI_SITE (defaults to default).
|
uhmunifi.sh — Se autentica contra UniFi OS ( /api/auth/login) por defecto, o contra controladores classic ( /api/login) cuando UNIFI_TYPE=classic, y consulta tres datasets: stat/sta (clientes en vivo), stat/guest (invitados con voucher canjeado), y stat/voucher (inventario completo de vouchers). Los cruza contra uhm-auth.txt, y presenta un menú con un submenú de Reports (cinco reportes) y uno de Actions (seis acciones) -- ver las tablas más abajo. Audita lo que UniFi mismo reporta; para consistencia de archivos ACL locales, ver uhmacl.sh más abajo. En el reporte Autorizados, STATUS es MULTI/VALID para un voucher que sigue en stat/voucher, CONSUMED para uno que UniFi ya purgó automáticamente al agotarse su cuota, y NO-VOUCHER(origen) cuando la entrada no tiene código en su hostname y UniFi reporta su sesión de stat/guest con un authorized_by distinto de voucher — la firma de una autorización concedida fuera del flujo de vouchers, que uhmd.sh ya no promueve. El reporte Guest sessions muestra esta misma señal para toda sesión activa que UniFi reporte, se haya colado o no en uhm-auth.txt. Registra en /var/log/uhmunifi.log — solo el resumen de login/consulta y cada acción ejecutada; las tablas de reporte son solo de terminal, bajo demanda. Lee las credenciales de /etc/uhm/uhm.env. Variables requeridas: UNIFI_CONTROLLER_URL, UNIFI_USERNAME, UNIFI_PASSWORD, UHM_ESSID. Opcional: UNIFI_SITE (default default).
|
| Report | Description | Descripción |
|---|---|---|
| [1] Connection status | Login + fetch summary for stat/sta, stat/guest and stat/voucher -- rc and entry count for each. |
Resumen de login + consulta para stat/sta, stat/guest y stat/voucher -- rc y conteo de entradas de cada uno. |
| [2] Authorized | uhm-auth.txt enriched with voucher code and status. STATUS is MULTI/VALID for a voucher still in stat/voucher, CONSUMED for one auto-purged on quota exhaustion, NO-VOUCHER(origin) when the hostname has no code and UniFi reports authorized_by other than voucher. |
uhm-auth.txt enriquecido con código de voucher y estado. STATUS es MULTI/VALID para un voucher que sigue en stat/voucher, CONSUMED para uno purgado automáticamente al agotarse la cuota, NO-VOUCHER(origen) cuando el hostname no tiene código y UniFi reporta authorized_by distinto de voucher. |
| [3] Vouchers | Full voucher list from stat/voucher with usage stats. |
Lista completa de vouchers desde stat/voucher con estadísticas de uso. |
| [4] Guest sessions | Every active stat/guest session, split into SYSADMIN MANAGED (mac-*.txt, never touched by actions), VOUCHER AUTHORIZED (uhm-auth.txt), and UNKNOWN (warning) (neither -- verify and, if illegitimate, delete). Each row is self-labeled in ORIGIN: (managed) or (!). |
Toda sesión activa de stat/guest, dividida en SYSADMIN MANAGED (mac-*.txt, nunca tocado por acciones), VOUCHER AUTHORIZED (uhm-auth.txt), y UNKNOWN (warning) (ninguna de las anteriores -- verificar y, si es ilegítima, eliminar). Cada fila se etiqueta a sí misma en ORIGIN: (managed) o (!). |
| [5] Unauthorized | Clients connected to the hotspot ESSID that stat/sta reports as NOT authorized. |
Clientes conectados al ESSID del hotspot que stat/sta reporta como NO autorizados. |
| Action | Description | Descripción |
|---|---|---|
| [1] Delete unused vouchers | Removes vouchers with used=0 (never activated). Safe — no sessions to clean. |
Elimina vouchers con used=0 (nunca activados). Seguro — no hay sesiones que limpiar. |
| [2] Forget clients no voucher | Forgets guests who connected to portal but never submitted a voucher. Only affects clients not currently on the SSID, with no voucher record, and not a mac-*.txt device. |
Olvida invitados que se conectaron al portal pero nunca ingresaron un voucher. Solo afecta clientes no conectados actualmente al SSID, sin registro de voucher, y que no sean un dispositivo de mac-*.txt. |
| [3] Delete expired vouchers | Deletes vouchers past end_time, then unauthorizes active sessions and forgets all client history linked to them. |
Elimina vouchers cuya end_time ya pasó, luego desautoriza sesiones activas y olvida todo el historial de clientes vinculados. |
| [4] Revoke by voucher code | Surgical revocation: delete voucher (if exists), unauthorize active sessions, forget all client history for that code. Addresses an observed UniFi inconsistency: when a voucher is manually deleted from the UniFi UI, stat/guest still retains session records with that voucher_code, allowing affected clients to reconnect without re-entering a code. Cleans everything regardless of whether the voucher still exists in stat/voucher or not. |
Revocación quirúrgica: elimina el voucher (si existe), desautoriza sesiones activas, olvida todo el historial de clientes para ese código. Aborda una inconsistencia observada en UniFi: cuando se elimina manualmente un voucher desde la UI de UniFi, stat/guest retiene registros de sesión con ese voucher_code, permitiendo que los clientes afectados se reconecten sin volver a ingresar un código. Limpia todo independientemente de si el voucher aún existe en stat/voucher o no. |
| [5] Forget sessions (!) | Unauthorizes and forgets every active stat/guest session whose authorized_by is not voucher and is not a mac-*.txt device (the UNKNOWN category from report [4]). Independent of whether the entry ever reached uhm-auth.txt. |
Desautoriza y olvida toda sesión activa de stat/guest cuyo authorized_by no sea voucher y no sea un dispositivo de mac-*.txt (la categoría UNKNOWN del reporte [4]). Independiente de si la entrada llegó a uhm-auth.txt. |
| [6] Purge everything | DESTROYS all vouchers, disconnects all active guests, erases all client history -- excluding mac-*.txt devices, always. Requires typing YES to confirm. Cannot be undone. |
DESTRUYE todos los vouchers, desconecta todos los invitados activos, borra todo el historial de clientes -- excluyendo siempre los dispositivos de mac-*.txt. Requiere escribir YES para confirmar. No se puede deshacer. |
sudo bash /etc/uhm/tools/uhmunifi.sh| Description | Descripción |
|---|---|
| Startup (login + fetch), then a short top-level menu -- the fetch summary is no longer printed loose at startup (it would scroll off before anything else is ever shown); it's report [1] in the Reports submenu instead: | Arranque (login + fetch), luego un menú principal corto -- el resumen del fetch ya no se imprime suelto al arrancar (se iría de pantalla antes de que se muestre cualquier otra cosa); ahora es el reporte [1] del submenú de Reports: |
2026-07-30 15:04:01 uhmunifi start...
============================================================================
AVAILABLE OPTIONS
============================================================================
[1] Reports
[2] Actions
[q] Quit
Select option [q]:
[1] Reports
Select option [q]: 1
============================================================================
REPORTS
============================================================================
[1] Connection status - login + fetch summary
[2] Authorized - uhm-auth.txt
[3] Vouchers - stat/voucher
[4] Guest sessions - stat/guest, by category
[5] Unauthorized - stat/sta, authorized=false
[b] Back
Select option [b]:
Report [1] — Connection status
Select option [b]: 1
============================================================================
CONNECTION STATUS -- login + fetch summary
============================================================================
stat/sta -> ok (5 entries)
stat/guest -> ok (3 entries)
stat/voucher -> ok (2 entries)
Report [2] — Authorized
Select option [b]: 2
============================================================================
AUTHORIZED -- uhm-auth.txt
============================================================================
MAC IP CODE STATUS EXPIRES ON
02:00:00:aa:bb:01 192.168.20.101 0000000001 MULTI 08-02 15:04 NO
02:00:00:aa:bb:02 192.168.20.102 0000000001 MULTI 08-02 15:04 NO
02:00:00:aa:bb:03 192.168.20.103 0000000002 VALID 08-02 16:20 YES
Report [3] — Vouchers
Select option [b]: 3
============================================================================
VOUCHERS -- stat/voucher
============================================================================
CODE STATUS DURATION QUOTA USED EXPIRES
0000000002 MULTI 2160h 5 2 08-02 21:17
0000000001 MULTI 2160h 6 5 07-29 18:59
Report [4] — Guest sessions
| Description | Descripción |
|---|---|
Split into three mutually exclusive categories by where the MAC lives, not by authorized_by -- so a genuine anomaly is never buried under routine noise. Classified in this order: SYSADMIN MANAGED (in mac-*.txt) takes priority, then VOUCHER AUTHORIZED (in uhm-auth.txt), then everything else is UNKNOWN (warning) -- the one to verify and, if illegitimate, delete. Every row is also self-labeled in the ORIGIN column -- (managed)/(!) -- so a row read in isolation, out of its section, is never ambiguous. |
Dividido en tres categorías mutuamente excluyentes según dónde vive la MAC, no según authorized_by -- así una anomalía real nunca queda enterrada bajo ruido rutinario. Clasificado en este orden: SYSADMIN MANAGED (en mac-*.txt) tiene prioridad, luego VOUCHER AUTHORIZED (en uhm-auth.txt), y todo lo demás es UNKNOWN (warning) -- el que hay que verificar y, si es ilegítimo, eliminar. Cada fila se etiqueta además a sí misma en la columna ORIGIN -- (managed)/(!) -- para que una fila leída aislada, fuera de su sección, nunca sea ambigua. |
Select option [b]: 4
============================================================================
GUEST SESSIONS -- SYSADMIN MANAGED (mac-*.txt)
============================================================================
MAC ORIGIN CODE EXPIRES ON
02:00:00:aa:bb:50 api(managed) N/A 07-04 12:53 YES
============================================================================
GUEST SESSIONS -- VOUCHER AUTHORIZED (uhm-auth.txt)
============================================================================
MAC ORIGIN CODE EXPIRES ON
02:00:00:aa:bb:01 voucher 0000000001 08-02 15:04 NO
============================================================================
GUEST SESSIONS -- UNKNOWN (warning)
============================================================================
MAC ORIGIN CODE EXPIRES ON
02:00:00:aa:bb:99 api(!) N/A 09-01 10:20 YES
LEGEND:
(managed) mac-*.txt, never touched
(!) unknown record, verify and delete
Report [5] — Unauthorized
Select option [b]: 5
============================================================================
UNAUTHORIZED -- stat/sta, clients on hotspot-example NOT authorized by UniFi
============================================================================
MAC HOSTNAME IP LAST_SEEN
02:00:00:aa:bb:07 no-hostname 192.168.20.240 1752700000
[2] Actions
Select option [q]: 2
============================================================================
ACTIONS
============================================================================
[1] Delete unused vouchers - never activated
[2] Forget clients no voucher - never used, not connected now
[3] Delete expired vouchers - remove + forget clients
[4] Revoke by voucher code - invalidate one voucher
[5] Forget sessions (!) - unauthorize + forget non-voucher
[6] Purge everything - DELETE all vouchers + history
[b] Back
Select option [b]:
| Description | Descripción |
|---|---|
None of the six actions above ever touch a mac-*.txt MAC -- only the AUTHORIZED/OTHER categories from report [4] are ever eligible. |
Ninguna de las seis acciones de arriba toca jamás una MAC de mac-*.txt -- solo las categorías AUTHORIZED/OTHER del reporte [4] son elegibles. |
uhmacl.sh -- Interactive diagnostic tool that verifies the presence and consistency of MAC addresses across every local DHCP/ACL data source used by pydhcpd and UHM: uhm-auth.txt, uhm-grace.txt, blockdhcp.txt, mac/*.txt, pydhcpd.leases and (options 1 and 4) the UniFi controller's stat/sta/stat/guest. For auditing what UniFi itself reports (authorized sessions, vouchers), see uhmunifi.sh above. Launched with no arguments, it presents a menu with four operations:
0 on normal termination and 1 on any abort -- not root, already running, missing dependency, unreadable or incomplete configuration, unreadable data file, temp file failure, or UniFi query failure. Requires root because the underlying files are owned by root/pydhcpd.
|
uhmacl.sh -- Herramienta interactiva de diagnostico que verifica la presencia y consistencia de direcciones MAC en todas las fuentes de datos DHCP/ACL locales usadas por pydhcpd y UHM: uhm-auth.txt, uhm-grace.txt, blockdhcp.txt, mac/*.txt, pydhcpd.leases y (opciones 1 y 4) el stat/sta/stat/guest del controlador UniFi. Para auditar lo que UniFi mismo reporta (sesiones autorizadas, vouchers), ver uhmunifi.sh más arriba. Lanzada sin argumentos, presenta un menu con cuatro operaciones:
0 en terminacion normal y 1 en cualquier aborto -- no root, ya en ejecucion, dependencia faltante, configuracion ilegible o incompleta, archivo de datos ilegible, fallo al crear temporal, o fallo en la consulta a UniFi. Requiere root porque los archivos subyacentes pertenecen a root/pydhcpd.
|
UniFi fields shown by Check MAC (option 1) / Campos de UniFi que muestra Check MAC (opción 1):
| Parameter | Description | Descripción |
|---|---|---|
authorized |
The only field that answers "is the AP currently holding this client at the captive portal". true/false, decided by UniFi itself, independent of any local ACL file. This is the field that revealed the bug this section documents (see below) |
El único campo que responde "¿el AP está reteniendo a este cliente en el portal cautivo ahora mismo?". true/false, decidido por UniFi mismo, independiente de cualquier ACL local. Es el campo que reveló el bug que documenta esta sección (ver abajo) |
is_guest |
Not per-client -- reflects whether the WLAN itself is configured as a Guest/Hotspot network. Only relevant together with authorized: is_guest=true is what makes authorized meaningful at all (on a non-guest network, UniFi doesn't enforce authorized and its value is irrelevant). authorized=false only means the client is stuck at the portal when is_guest=true too |
No es por-cliente -- refleja si la WLAN misma está configurada como red Guest/Hotspot. Solo es relevante junto con authorized: is_guest=true es lo que hace que authorized tenga algún significado (en una red que no es Guest, UniFi no aplica authorized y su valor es irrelevante). authorized=false solo significa que el cliente está atrapado en el portal cuando is_guest=true también |
essid |
Which network (SSID) the client is actually connected to -- useful to confirm it's not on the wrong one | A qué red (SSID) está realmente conectado el cliente -- útil para confirmar que no está en la equivocada |
ip, hostname |
Cross-check against what pydhcpd/mac-*.txt/uhm-auth.txt expect -- not related to the portal decision itself |
Cruce contra lo que espera pydhcpd/mac-*.txt/uhm-auth.txt -- no tiene relación con la decisión del portal en sí |
voucher_code (from stat/guest) |
Only present for uhm-auth.txt clients (redeemed a voucher); always empty for mac-*.txt devices, since those are authorized via authorize-guest, not a voucher |
Solo presente en clientes de uhm-auth.txt (canjearon un voucher); siempre vacío para dispositivos de mac-*.txt, ya que esos se autorizan vía authorize-guest, no con un voucher |
Neither
stat/stanorstat/guestexpose when an authorization expires -- UniFi tracks that internally and only returns the currenttrue/false. That's whyauthorize_managed_macs()inuhmd.shre-checks every cycle instead of trying to predict an expiry.Ni
stat/stanistat/guestexponen cuándo vence una autorización -- UniFi lo controla internamente y solo devuelve eltrue/falseactual. Por esoauthorize_managed_macs()enuhmd.shrevisa en cada ciclo en vez de intentar predecir un vencimiento.
sudo bash /etc/uhm/tools/uhmacl.sh###################################
# uhmacl -- Local ACL Diagnostic Tool #
###################################
1. Check MAC
2. Grace period status
3. Consistency check + system summary
4. Search by IP or hostname
5. Exit
Select option [1-5]:
Option 1 -- Check MAC
Select option [1-5]: 1
Enter MAC address (XX:XX:XX:XX:XX:XX, empty to cancel): 02:00:00:aa:bb:01
=== 02:00:00:aa:bb:01 ===
uhm-auth.txt: N
uhm-grace.txt: Y
blockdhcp.txt: N
mac/*.txt: N
pydhcpd.leases: N
Querying https://192.168.0.10:11443...
Connected to UniFi API
UniFi (stat/sta): MAC not associated to any AP (no live session)
Grace expires in: 6h 39m
[i] In uhm-grace without active lease
[i] This is normal with a short pool lease or a limited range
Same option, this time for a managed (mac-*.txt) device -- the UniFi query is what confirms the AP isn't holding it at the captive portal: / La misma opción, esta vez para un dispositivo gestionado (mac-*.txt) -- la consulta a UniFi es lo que confirma que el AP no lo está reteniendo en el portal cautivo:
Select option [1-5]: 1
Enter MAC address (XX:XX:XX:XX:XX:XX, empty to cancel): 02:00:00:aa:bb:99
=== 02:00:00:aa:bb:99 ===
uhm-auth.txt: N
uhm-grace.txt: N
blockdhcp.txt: N
mac/*.txt: Y
/etc/acl/mac/mac-limited.txt
pydhcpd.leases: N
Querying https://192.168.0.10:11443...
Connected to UniFi API
UniFi (stat/sta): connected
essid=GUESTS
authorized=true
is_guest=true
ip=192.168.20.240
hostname=DESKTOP-ABC123
Option 2 -- Grace period status
Select option [1-5]: 2
MAC IP NAME EXPIRES IN
----------------------------------------------------------------------------
02:00:00:aa:bb:01 192.168.20.236 laptop-example-01 6h 40m
02:00:00:aa:bb:02 192.168.20.231 desktop-example-02 5h 55m
02:00:00:aa:bb:03 192.168.20.235 pc-example-03 9h 40m
02:00:00:aa:bb:04 192.168.20.234 no_name_example04 7h 40m
02:00:00:aa:bb:05 192.168.20.238 phone-example-05 23h 33m
Total: 5 | Expired: 0 | Active: 5
Option 3 -- Consistency check + system summary
Select option [1-5]: 3
Collecting all MACs from all data sources...
=== SYSTEM SUMMARY ===
MACs found total : 197
Grace period : 21
Blocked : 19
ACL permanent : 141
Hotspot auth : 14
In leases file : 2
Warnings : 0
Option 4 -- Search by IP or hostname
Select option [1-5]: 4
Enter IP address or hostname: 192.168.20.55
Searching for: 192.168.20.55
Found 1 MAC(s):
=== 02:00:00:aa:bb:99 ===
uhm-auth.txt: N
uhm-grace.txt: N
blockdhcp.txt: N
mac/*.txt: Y
/etc/acl/mac/mac-limited.txt
pydhcpd.leases: N
Blocked -- must appear in blockdhcp.txt only. Warns if also in mac, uhm-grace, or leases |
Bloqueada -- debe aparecer solo en blockdhcp.txt. Advierte si tambien esta en mac, uhm-grace o leases |
Grace period -- uhm-grace present, leases may be absent briefly (60 s pool lease, limited range) |
Periodo de gracia -- uhm-grace presente, leases puede estar ausente momentaneamente (lease de pool de 60 s, rango limitado) |
ACL permanent -- mac present, must NOT be in blockdhcp |
ACL permanente -- mac presente, NO debe estar en blockdhcp |
Hotspot auth -- uhm-auth present, must NOT remain in uhm-grace (removed by check_duplicate once promoted; briefly both right after promotion, until the next reload, is expected) |
Hotspot autenticado -- uhm-auth presente, NO debe permanecer en uhm-grace (removida por check_duplicate al ser promovida; que este brevemente en ambas justo tras la promocion, hasta el proximo reload, es esperado) |
uhmalert.sh is an optional, standalone alert watcher. It tails /var/log/uhm.log in real time and sends a push notification via ntfy.sh on three kinds of events: (1) loss of connectivity to the UniFi controller, after UHM_API_FAIL_THRESHOLD consecutive cycles (default 3), followed by a recovery notice once it's back; (2) any other ERROR or WARNING line in the shared log (from uhmd.sh or the uhmreload.sh/uhmleases.sh/uhmiptables.sh chain) — fires immediately, no threshold; and (3) any FIX: line, written only by uhmwatch.sh (installed by default) when it successfully recovers a service — closes out the corresponding WARNING alert with confirmation it was resolved.
Runs as its own systemd service ( uhmalert.service), independent of uhmd.sh — it never reads or modifies the daemon or its source, only tails the log file it already writes. uhmd.sh stays byte-identical to upstream whether uhmalert is installed or not, and the daemon runs the same with or without it.
|
uhmalert.sh es un vigilante de alertas opcional e independiente. Sigue /var/log/uhm.log en tiempo real y envia una notificacion push via ntfy.sh ante tres tipos de eventos: (1) perdida de conectividad con el controlador UniFi, tras UHM_API_FAIL_THRESHOLD ciclos consecutivos (default 3), seguido de un aviso de recuperacion cuando vuelve; (2) cualquier otra linea ERROR o WARNING en el log compartido (de uhmd.sh o la cadena uhmreload.sh/uhmleases.sh/uhmiptables.sh) -- dispara de inmediato, sin umbral; y (3) cualquier linea FIX:, escrita solo por uhmwatch.sh (instalado por defecto) cuando recupera un servicio con éxito -- cierra la alerta WARNING correspondiente confirmando que se resolvió.
Corre como su propio servicio systemd ( uhmalert.service), independiente de uhmd.sh -- nunca lee ni modifica el daemon ni su codigo fuente, solo sigue el archivo de log que ya escribe. uhmd.sh se mantiene identico al original este o no instalado uhmalert, y el daemon funciona igual con o sin el.
|
Push notifications via ntfy.sh — See Real Example
Notificaciones push vía ntfy.sh — Ver sección Real Example
Install:
sudo /etc/uhm/tools/uhmalert.sh install==================================
Installing uhmalert (UHM alert)
==================================
Added UHM_NTFY_TOPIC, UHM_API_FAIL_THRESHOLD and
UHM_ALERT_QUIET_PERIOD_SECONDS to /etc/uhm/uhm.env
Deploying script to /etc/uhm/tools/uhmalert.sh...
Writing systemd unit (/etc/systemd/system/uhmalert.service)...
Installed and started. Check with: systemctl status uhmalert
==================================
ntfy topic: uhm-alert-x7k2m9qv
==================================
Install the free 'ntfy' app (Android/iOS) and subscribe to the
topic above to start receiving alerts on this device.
Uninstall:
sudo /etc/uhm/tools/uhmalert.sh uninstall
Detection logic: Successful uhmd cycles are silent (no log output), so there is no positive "cycle OK" line to anchor on. Instead, uhmalert.sh anchors on "Could not load vouchers" -- a line load_all_vouchers() logs exactly once per cycle when the controller is unreachable. Two such lines less than GAP_LIMIT apart count as consecutive failing cycles; a larger gap means cycles succeeded silently in between, and the streak resets (the same GAP_LIMIT is also the read timeout used to detect recovery). GAP_LIMIT = POLL_INTERVAL + 3*API_MAX_TIME + MARGIN (default 20 + 3*30 + 10 = 120s) -- the 3*API_MAX_TIME term covers the worst case of a failed cycle still making up to three 30s-capped API calls (vouchers, guest, sta) before it ends.
Any other line starting with ERROR: or WARNING: fires immediately, no threshold -- the log already classifies severity ("TIMESTAMP LEVEL: message"), shared by uhmd.sh and the uhmreload.sh/uhmleases.sh/uhmiptables.sh chain. Excludes lines already covered by the connectivity streak above (so it still waits for the threshold, not the first failure) and "cycle lock held unexpectedly" (expected, not a bug).
Startup grace: uhmalert.sh itself starts at boot (systemd). If the connectivity threshold is reached while uhmd.service has been active for less than UHM_ALERT_QUIET_PERIOD_SECONDS, the alert is suppressed — UniFi Network/UniFi OS can take a while to come back up after a reboot, and the daemon's very first cycles fail before the controller is even ready to answer. Checked against uhmd's own start time (via systemd), not uhmalert's — so this applies correctly whether the whole machine rebooted or just uhmd restarted on its own. A real outage later on still alerts at the normal threshold, unaffected.
This only covers the run_cycle connectivity streak. The daemon's own initial login (before the first cycle even runs) is handled separately inside uhmd.sh itself, using its own STARTUP_GRACE_SECONDS window — a distinct key from uhmalert.sh's (same default value, 120, but tuning one never silently affects the other) — see the "Daemon Cycle" section below. Startup login retries log at INFO, not ERROR, so they never reach this catch-all in the first place.
Recovery notice guard: a "recovered" notice fires only if uhmd.service is still active when the GAP_LIMIT silence window elapses. Silence has two indistinguishable causes — cycles actually recovered, or the daemon stopped writing to the log entirely (manual stop, crash, start-limit-hit) — and without this check the second case would still send a false "recovered" notice while the controller could still be down and the daemon not even running.
|
Lógica de detección: Los ciclos exitosos de uhmd son silenciosos (sin salida en el log), por lo que no hay una linea positiva de "ciclo OK" en la cual anclarse. En cambio, uhmalert.sh se ancla en "Could not load vouchers" -- una linea que load_all_vouchers() registra exactamente una vez por ciclo cuando el controlador es inalcanzable. Dos de esas lineas separadas por menos de GAP_LIMIT cuentan como ciclos fallidos consecutivos; un salto mayor implica que hubo ciclos exitosos silenciosos en el medio, y la racha se reinicia (el mismo GAP_LIMIT es también el timeout de lectura usado para detectar la recuperación). GAP_LIMIT = POLL_INTERVAL + 3*API_MAX_TIME + MARGIN (default 20 + 3*30 + 10 = 120s) -- el término 3*API_MAX_TIME cubre el peor caso de un ciclo fallido que aún así hace hasta tres llamadas API con límite de 30s (vouchers, guest, sta) antes de terminar.
Cualquier otra linea que empiece con ERROR: o WARNING: dispara de inmediato, sin umbral -- el log ya clasifica la severidad ("TIMESTAMP NIVEL: mensaje"), compartido entre uhmd.sh y la cadena uhmreload.sh/uhmleases.sh/uhmiptables.sh. Excluye las lineas ya cubiertas por la racha de conectividad de arriba (para que siga esperando el umbral, no el primer fallo) y "cycle lock held unexpectedly" (esperado, no es un bug).
Gracia de arranque: uhmalert.sh arranca junto con el sistema (systemd). Si el umbral de conectividad se cumple mientras uhmd.service lleva menos de UHM_ALERT_QUIET_PERIOD_SECONDS activo, la alerta se suprime -- UniFi Network/UniFi OS puede tardar en volver a estar disponible tras un reinicio, y los primeros ciclos del daemon fallan antes de que el controlador siquiera esté listo para responder. Se verifica contra el propio inicio de uhmd (vía systemd), no el de uhmalert -- asi aplica correctamente ya sea que se haya reiniciado el equipo completo o solo uhmd por su cuenta. Un fallo real más adelante sigue alertando con el umbral normal, sin verse afectado.
Esto solo cubre la racha de conectividad de run_cycle. El login inicial del daemon (antes de que corra el primer ciclo) se maneja aparte, dentro del propio uhmd.sh, usando su propia ventana STARTUP_GRACE_SECONDS -- una clave distinta a la de uhmalert.sh (mismo valor por defecto, 120, pero ajustar una nunca afecta a la otra en silencio) -- ver la sección "Daemon Cycle" más abajo. Los reintentos de login de arranque quedan en nivel INFO, no ERROR, así que nunca llegan a este catch-all.
Verificación antes del aviso de recuperación: un aviso de "recovered" solo se envía si uhmd.service sigue activo cuando se cumple la ventana de silencio GAP_LIMIT. El silencio tiene dos causas indistinguibles -- los ciclos realmente se recuperaron, o el daemon dejó de escribir en el log por completo (detención manual, crash, start-limit-hit) -- y sin este chequeo el segundo caso igual mandaría un falso "recovered" mientras el controlador podría seguir caído y el daemon ni siquiera estar corriendo.
|
A brief controller outage (restart/update) triggers exactly the sequence shown in the screenshot above. The daemon degrades gracefully on every failed cycle — sessions step ... -- skip/revoke step ... -- skip — instead of acting on partial data, alerts once the 3-cycle threshold is hit, and re-authenticates automatically once the controller is reachable again:
|
Una caída breve del controlador (reinicio/actualización) dispara exactamente la secuencia del pantallazo de arriba. El daemon se degrada de forma segura en cada ciclo fallido — sessions step ... -- skip/revoke step ... -- skip — en vez de actuar con datos parciales, alerta al llegar al umbral de 3 ciclos, y se re-autentica solo apenas el controlador vuelve a responder:
|
2026-07-12 00:40:26 WARNING: API GET stat/voucher -> HTTP 502
2026-07-12 00:40:26 WARNING: Could not load vouchers (rc=empty)
2026-07-12 00:40:28 WARNING: API GET stat/guest -> HTTP 000
2026-07-12 00:40:28 INFO: sessions step, stat/guest unavailable -- skip
2026-07-12 00:40:29 WARNING: API GET stat/sta -> HTTP 000
2026-07-12 00:40:29 INFO: revoke step, stat/sta unavailable -- skip
[... cycles keep failing every ~POLL_INTERVAL, same pattern ...]
2026-07-12 00:41:11 WARNING: Could not load vouchers (rc=empty)
2026-07-12 00:41:11 ALERT: sent -- 3 consecutive cycle failures
2026-07-12 00:41:11 ALERT: latest at 2026-07-12 00:41:11
[... failures continue while the controller is still down ...]
2026-07-12 00:42:43 INFO: Session expired -- re-authenticating
2026-07-12 00:42:43 INFO: UniFi login OK
2026-07-12 00:43:13 ALERT: recovery notice sent
The first HTTP 502 (proxy up, backend not yet) followed immediately by HTTP 000 on every subsequent request (connection itself unreachable) is the fingerprint of a UniFi OS controller restart, not a network/firewall problem on the UHM side — worth checking the controller's own system log for that window if it happens outside a planned update.
A server reboot shows a different, unrelated-looking pattern instead — quiet INFO-level login retries while UniFi OS is still booting, followed by a login success, followed by a few data-endpoint failures before the backend settles — with no alert firing, since uhmalert.sh is also inside its own startup grace window at that point. See uhmd above for that log sequence in full.
|
El primer HTTP 502 (proxy activo, backend aún no) seguido de inmediato por HTTP 000 en cada petición posterior (la conexión misma es inalcanzable) es la firma de un reinicio del controlador UniFi OS, no un problema de red/firewall del lado de UHM — vale la pena revisar el log propio del sistema del controlador en esa ventana si ocurre fuera de una actualización planificada.
Un reinicio del servidor muestra un patrón distinto y aparentemente no relacionado — reintentos de login silenciosos en nivel INFO mientras UniFi OS todavía está arrancando, seguidos de un login exitoso, seguidos de algunos fallos en los endpoints de datos antes de que el backend se asiente — sin que se dispare ninguna alerta, ya que uhmalert.sh también está dentro de su propia ventana de gracia de arranque en ese momento. Ver uhmd arriba para esa secuencia de log completa.
|
Configuration variables (in uhm.env, written automatically by install):
| Variable | Default | Description | Descripción |
|---|---|---|---|
UHM_NTFY_TOPIC |
(auto-generated) | ntfy.sh topic name, e.g. uhm-alert-x7k2m9qv. Treat as a shared secret — anyone who knows it can publish to it. Never overwritten by a re-install. |
Nombre del topic de ntfy.sh, ej. uhm-alert-x7k2m9qv. Trátelo como un secreto compartido — cualquiera que lo conozca puede publicar en él. Nunca se sobrescribe en una reinstalación. |
UHM_API_FAIL_THRESHOLD |
3 | Consecutive failing cycles required before sending an alert | Ciclos fallidos consecutivos requeridos antes de enviar una alerta |
UHM_ALERT_QUIET_PERIOD_SECONDS |
120 | Suppresses the connectivity alert while uhmd.service has been active for less than this long — UniFi Network/UniFi OS can take a while to come back up after a reboot, and this host often boots alongside it. Written to uhm.env by uhmalert.sh install. Separate from uhmd.sh's own STARTUP_GRACE_SECONDS (same default, different key, tuning one never affects the other). This is an estimate, not a measured value: tune it to how long your UniFi Network/UniFi OS instance actually takes to come back up after a restart. Only the startup window is affected — a real outage later in the day still alerts at the normal threshold, undiminished. |
Suprime la alerta de conectividad mientras uhmd.service ha estado activo por menos de este tiempo — UniFi Network/UniFi OS puede tardar en volver tras un reinicio, y este host suele arrancar junto con él. Escrito en uhm.env por uhmalert.sh install. Separada de la propia STARTUP_GRACE_SECONDS de uhmd.sh (mismo default, clave distinta, ajustar una nunca afecta a la otra). Esto es una estimación, no un valor medido: ajústelo a lo que realmente tarda su instancia de UniFi Network/UniFi OS en volver tras un reinicio. Solo afecta la ventana de arranque — un corte real más tarde en el día sigue alertando en el umbral normal, sin disminución. |
POLL_INTERVALis read from the sameuhm.envused byuhmd.sh(falls back to 20 if unset) — no separate configuration needed.
POLL_INTERVALse lee del mismouhm.envque usauhmd.sh(default 20 si no esta definido) -- no requiere configuracion aparte.
uhmwatch.sh is a mandatory, standalone services watchdog — installed automatically by uhmsetup.sh, not offered as a yes/no prompt like uhmalert/uhmwebmin. Every unit it watches already has its own systemd Restart= policy, but that alone gives up permanently once its StartLimitBurst is exhausted, with no further attempt and no alert of its own (see below). uhmwatch is the last line of defense against that — it runs every minute, independent of whatever state systemd itself gave up in, so UHM's essential services don't stay down indefinitely just because systemd stopped trying. Checks every service UHM depends on, restarting whichever is down: uhmd.service (always), uhmalert.service (only if installed), pydhcpd.service (always -- external dependency UHM cannot function without, watched here since pydhcp's own Restart=on-failure gives up silently after its burst with no alerting of its own), and the UniFi backend (uosserver.service for UNIFI_TYPE=unifi-os, or unifi.service for classic). Each check is fully independent — one check's failure never skips or blocks the others in the same run. Each recovery attempt runs systemctl reset-failed right before start/restart — every unit already carries its own Restart= policy with a StartLimitBurst, and once that burst is exhausted systemd stops trying on its own and stays quiet about it, which would otherwise make this watchdog's own restart attempt fail silently right when it's needed most. To avoid then hammering a persistently broken service every single minute, each restart attempt (successful or not) is timestamped per-service under /run/uhmwatch/ (cleared on reboot), and a new attempt is skipped — logged only, not acted on — until RECOVERY_COOLDOWN_SECONDS (default 600s / 10 min) has passed since the last one.
Standalone — never reads or modifies uhmd.sh, only manages services via systemctl. Writes to the same shared /var/log/uhm.log as the rest of UHM (no separate log file or logrotate of its own). Silent on a healthy run — nothing is logged unless a check finds a problem or takes a fix action.
The pydhcpd.service check specifically skips its "OFFLINE" verdict (no WARNING, no restart) if uhmleases.sh currently holds the same cycle lock uhmd.sh uses (/var/lock/uhmd-cycle.lock) — a normal reload stops/reconfigures/starts pydhcpd itself for a few seconds, and a cron tick landing in that window would otherwise "fix" a service that isn't actually broken, restarting it out from under uhmleases.sh's own pending restart and aborting that reload.
|
uhmwatch.sh es un vigilante de servicios obligatorio e independiente — se instala automáticamente con uhmsetup.sh, no se ofrece como pregunta sí/no como uhmalert/uhmwebmin. Cada unidad que vigila ya tiene su propia política Restart= de systemd, pero eso solo se rinde para siempre en cuanto agota su StartLimitBurst, sin más intentos y sin aviso propio (ver más abajo). uhmwatch es la última línea de defensa contra eso — corre cada minuto, independiente del estado en que systemd se haya rendido, para que los servicios esenciales de UHM no queden caídos indefinidamente solo porque systemd dejó de intentarlo. Verifica cada servicio del que depende UHM, reiniciando el que esté caído: uhmd.service (siempre), uhmalert.service (solo si está instalado), pydhcpd.service (siempre -- dependencia externa sin la cual UHM no puede funcionar, vigilada acá porque el propio Restart=on-failure de pydhcp se rinde en silencio tras agotar su cupo, sin ningún aviso propio), y el backend de UniFi (uosserver.service para UNIFI_TYPE=unifi-os, o unifi.service para classic). Cada chequeo es completamente independiente — el fallo de uno nunca salta ni bloquea a los demás en la misma corrida. Cada intento de recuperación corre systemctl reset-failed justo antes de start/restart — cada unidad ya trae su propia política Restart= con un StartLimitBurst, y una vez agotado ese cupo systemd deja de reintentar por su cuenta y no avisa — lo que de otro modo haría fallar en silencio el intento de este vigilante justo cuando más se lo necesita. Para no machacar después con un restart cada minuto a un servicio persistentemente roto, cada intento de recuperación (exitoso o no) queda con marca de tiempo por servicio bajo /run/uhmwatch/ (se limpia en cada reinicio), y un nuevo intento se salta -- solo se loguea, no se actúa -- hasta que pasen RECOVERY_COOLDOWN_SECONDS (default 600s / 10 min) desde el último.
Independiente — nunca lee ni modifica uhmd.sh, solo gestiona servicios vía systemctl. Escribe al mismo /var/log/uhm.log compartido con el resto de UHM (sin log ni logrotate propio). Silencioso en una corrida sana — no registra nada salvo que un chequeo encuentre un problema o tome una acción de reparación.
El chequeo de pydhcpd.service específicamente se salta el veredicto "OFFLINE" (sin WARNING, sin restart) si uhmleases.sh tiene tomado en ese momento el mismo lock de ciclo que usa uhmd.sh (/var/lock/uhmd-cycle.lock) — un reload normal detiene/reconfigura/arranca pydhcpd él mismo durante unos segundos, y una corrida de cron que caiga en esa ventana de otro modo "arreglaría" un servicio que no está realmente roto, reiniciándolo por debajo del restart que uhmleases.sh ya tenía pendiente y abortando ese reload.
|
Install:
sudo /etc/uhm/core/uhmwatch.sh install==================================
Installing uhmwatch (UHM services watchdog)
==================================
Deploying script to /etc/uhm/core/uhmwatch.sh...
Cron entry registered: * * * * * /etc/uhm/core/uhmwatch.sh
Installed. First run happens on the next minute mark.
Check the log with: tail -f /var/log/uhm.log
uhmwatch.sh is silent on a healthy run -- nothing is logged unless a check finds a problem or takes a fix action. Example of what a detected-and-fixed failure looks like in /var/log/uhm.log / uhmwatch.sh es silencioso en una corrida sana -- no registra nada a menos que un chequeo encuentre un problema o tome una acción de arreglo. Ejemplo de cómo se ve una falla detectada y corregida en /var/log/uhm.log:
2026-07-29 21:18:18 WARNING: uhmd OFFLINE
2026-07-29 21:18:18 FIX: uhmd restarted
If uhmalert.sh is also installed, both lines reach your phone as separate push notifications — uhmalert.sh alerts on any WARNING:/ERROR: line (the problem) as well as any FIX: line (confirmation it was resolved), from any of the services uhmwatch.sh manages, not just uhmd. uhmwatch.sh and uhmalert.sh are independent, but this is what having both installed together looks like in practice / Si uhmalert.sh también está instalado, ambas líneas te llegan al teléfono como notificaciones push separadas — uhmalert.sh alerta ante cualquier línea WARNING:/ERROR: (el problema) y también ante cualquier línea FIX: (confirmación de que se resolvió), de cualquiera de los servicios que gestiona uhmwatch.sh, no solo uhmd. uhmwatch.sh y uhmalert.sh son independientes, pero así se ve en la práctica tenerlos instalados juntos:
uhmwatch fixing a downed service, relayed to your phone by uhmalert
uhmwatch arreglando un servicio caído, retransmitido a tu teléfono por uhmalert
The notification app may not display messages in chronological order (it can group same-minute notifications arbitrarily). Since it's only a notification, the recommendation is to check
/var/log/uhm.logfor the actual event order.Es posible que la app de notificaciones no muestre los mensajes en orden cronológico (puede agrupar notificaciones del mismo minuto de forma arbitraria). Al ser solo una notificación, se recomienda revisar
/var/log/uhm.logpara ver el orden real de los eventos.
Uninstall:
sudo /etc/uhm/core/uhmwatch.sh uninstall
UniFi backend check: a plain systemctl is-active only proves the process is up, not that the application itself is healthy — the container's (or subprocess's) embedded MongoDB can fail to come up while the process keeps running, leaving every real API call broken. So once the service is confirmed active, uhmwatch.sh performs the same real login uhmd.sh itself relies on (UNIFI_USERNAME/UNIFI_PASSWORD from uhm.env, credentials via jq env and payload via curl stdin — never in argv). HTTP 200 = healthy. HTTP 000 (unreachable) or 5xx (server error) = unresponsive, restarts the service. HTTP 429 means the controller itself is rate-limiting login attempts — logged as a distinct warning, no restart (see Controller lockout below). Any other 4xx means credentials rejected but service online — logged as a warning, no restart. Possible causes: wrong UNIFI_USERNAME/UNIFI_PASSWORD in uhm.env, or an account that is locked, expired, or has 2FA enabled (see 2FA and Remote Access above). If UNIFI_USERNAME/UNIFI_PASSWORD aren't set, falls back to a process/port-only check instead of skipping it.
|
Chequeo del backend UniFi: un simple systemctl is-active solo prueba que el proceso está arriba, no que la aplicación esté sana — el MongoDB embebido del contenedor (o subproceso) puede fallar al iniciar mientras el proceso sigue corriendo, dejando rota cualquier llamada real a la API. Por eso, una vez confirmado que el servicio está activo, uhmwatch.sh hace el mismo login real que usa uhmd.sh (UNIFI_USERNAME/UNIFI_PASSWORD de uhm.env, credenciales vía env de jq y payload vía stdin de curl — nunca en argv). HTTP 200 = sano. HTTP 000 (inalcanzable) o 5xx (error de servidor) = no responde, reinicia el servicio. HTTP 429 significa que el propio controlador está limitando la tasa de intentos de login — se registra como advertencia distinta, sin reiniciar (ver Bloqueo del controlador abajo). Cualquier otro 4xx significa credenciales rechazadas pero servicio online — se registra como advertencia, sin reiniciar. Posibles causas: UNIFI_USERNAME/UNIFI_PASSWORD incorrecto en uhm.env, o cuenta bloqueada, caducada, o con 2FA activo (ver 2FA and Remote Access arriba). Si UNIFI_USERNAME/UNIFI_PASSWORD no están configuradas, cae de vuelta a un chequeo de solo proceso/puerto en vez de omitirlo.
|
Wrong password / Contraseña incorrecta:
2026-07-15 17:21:03 WARNING: credentials rejected (HTTP 403)
2026-07-15 17:21:03 Check uhm.env - UOS itself is responding
Controller lockout (HTTP 429) / Bloqueo del controlador (HTTP 429):
# from uhmd.sh, repeating every 10s during its own startup retry loop:
2026-07-31 23:57:13 INFO: UniFi login failed (HTTP 429), retry in grace
2026-07-31 23:57:23 INFO: UniFi login failed (HTTP 429), retry in grace
...
2026-07-31 23:59:04 INFO: UniFi login failed (HTTP 429), retry in grace
2026-07-31 23:59:04 ERROR: no UniFi login in 120s -- abort
# from uhmwatch.sh, on its next check:
2026-07-31 23:59:15 WARNING: rate limited (HTTP 429), not a credentials issue
2026-07-31 23:59:15 Stop uhmd+uhmwatch cron before restarting (see README)
HTTP 429 means the controller is throttling login attempts -- it is not a wrong password, and restarting a service will not fix it, it can make it worse. It typically happens after several rapid failed login attempts in a short window (UniFi's own anti-brute-force protection), and it is self-sustaining: uhmd.service ships with Restart=always/RestartSec=10, and uhmd.sh itself retries login every 10s for up to STARTUP_GRACE_SECONDS (default 120s) before exiting -- if the controller is already rate-limiting, this loop keeps re-triggering the lockout indefinitely, and uhmwatch.sh's own 1-minute restart of uhmd.service (if it finds it down) feeds the same loop.
Recovery procedure:
|
HTTP 429 significa que el controlador está limitando la tasa de intentos de login -- no es una contraseña incorrecta, y reiniciar un servicio no lo arregla, puede empeorarlo. Suele ocurrir después de varios intentos fallidos rápidos en poco tiempo (protección anti-fuerza-bruta propia de UniFi), y es autosostenido: uhmd.service viene con Restart=always/RestartSec=10, y uhmd.sh reintenta el login cada 10s durante hasta STARTUP_GRACE_SECONDS (default 120s) antes de salir -- si el controlador ya está limitando la tasa, este loop sigue disparando el bloqueo indefinidamente, y el propio reinicio de uhmd.service que hace uhmwatch.sh cada minuto (si lo encuentra caído) alimenta el mismo loop.
Procedimiento de recuperación:
|
Normal operation / Operación normal:
(nothing — a healthy run writes no log lines / nada — una corrida sana no escribe líneas de log)
uhm.log — All output from every component (uhmd, uhmreload.sh, uhmleases.sh, uhmwatch.sh, uhmalert.sh, uhmiptables.sh) is unified in /var/log/uhm.log and rotated via /etc/logrotate.d/uhm (daily, 7 rotations, compressed). The log follows one rule throughout: stay silent on no-op cycles, log once when something actually changes, always log errors and warnings. Idle cycles (no ACL change) produce zero lines. Every component classifies every line as INFO:, WARNING:, ERROR:, or (for uhmalert.sh) ALERT: — including continuation lines, since a message split across two physical lines to respect the 80-column limit always carries the same level on both. The Webmin viewer (uhmwebmin.sh) groups the few genuinely level-less lines (the compact field=value|field=value counters, and each sub-script's own "<name> start..."/"<name> done" boundary markers) under a generic STATUS level. uhmd's own log() also writes an 80-dash delimiter line as the very first line of any cycle that logs anything at all (idle cycles still produce none), so consecutive active cycles are visually separated in the file.
|
uhm.log — Toda la salida de cada componente (uhmd, uhmreload.sh, uhmleases.sh, uhmwatch.sh, uhmalert.sh, uhmiptables.sh) se unifica en /var/log/uhm.log y se rota vía /etc/logrotate.d/uhm (diario, 7 rotaciones, comprimido). El log sigue una sola regla: silencio en ciclos sin cambios, un registro cuando algo realmente cambia, y siempre errores y advertencias. Los ciclos inactivos (sin cambio de ACL) no producen ninguna línea. Cada componente clasifica cada línea como INFO:, WARNING:, ERROR: o (en uhmalert.sh) ALERT: — incluidas las líneas de continuación, ya que un mensaje partido en dos líneas físicas por el límite de 80 columnas siempre lleva el mismo nivel en ambas. El visor de Webmin (uhmwebmin.sh) agrupa las pocas líneas genuinamente sin nivel (los contadores compactos campo=valor|campo=valor, y las marcas de inicio/cierre "<nombre> start..."/"<nombre> done" de cada sub-script) bajo un nivel genérico STATUS. El propio log() de uhmd también escribe una línea separadora de 80 guiones como primera línea de cualquier ciclo que registre algo (los ciclos inactivos siguen sin producir ninguna), para separar visualmente ciclos activos consecutivos en el archivo.
|
| Level | Description | Descripción |
|---|---|---|
ERROR: |
Exclusively for a message that aborts the current flow -- the script or the calling function stops right there, nothing after it runs. Always paired with the -- abort suffix. |
Exclusivo para un mensaje que aborta el flujo actual -- el script o la función que lo invoca se detiene ahí mismo, nada después corre. Siempre acompañado del sufijo -- abort. |
WARNING: |
Something is seriously wrong and needs the administrator's immediate attention, but execution does not abort. Paired with -- alert (a live condition needing supervision, e.g. a possible attack or resource saturation) or -- fallback (the administrator supplied a bad/out-of-range value in the config, and the script used a built-in default instead -- the value must be corrected). |
Algo anda mal y requiere atención inmediata del administrador, pero la ejecución no aborta. Acompañado de -- alert (una condición en vivo que amerita supervisión, ej. un posible ataque o saturación de recursos) o -- fallback (el administrador puso un valor malo o fuera de rango en la configuración, y el script usó un valor por defecto en su lugar -- ese valor debe corregirse). |
INFO: |
Routine state changes and notifications -- everything else, including anything skipped or defaulted without needing administrator attention. Paired with -- skip (an action was discarded, for any reason) or -- degraded (a system/environment limitation -- not a bad config value -- left the script running without an optimization or protection it would normally have; nothing for the administrator to fix). |
Cambios de estado rutinarios y notificaciones -- todo lo demás, incluyendo lo omitido o resuelto con un valor por defecto sin necesitar atención del administrador. Acompañado de -- skip (se descartó una acción, por cualquier razón) o -- degraded (una limitación del sistema/entorno -- no un valor malo de configuración -- dejó el script funcionando sin una optimización o protección que normalmente tendría; no hay nada que el administrador deba corregir). |
ALERT: |
uhmalert.sh only -- confirms a push notification was actually sent for an ERROR:/WARNING:/FIX: line it picked up. |
Exclusivo de uhmalert.sh -- confirma que se envió una notificación push por una línea ERROR:/WARNING:/FIX: detectada. |
FIX: |
A prior problem (ERROR:/WARNING:) is now confirmed resolved -- e.g. a service uhmwatch.sh restarted came back healthy. |
Un problema previo (ERROR:/WARNING:) ya se confirmó resuelto -- ej. un servicio que uhmwatch.sh reinició volvió a estar sano. |
STATUS (no prefix) |
Level-less lines: each script's own "<name> start..."/"<name> done" boundary markers, and the compact field=value|field=value counters -- grouped under this generic label only by the Webmin viewer (uhmwebmin.sh), not written as STATUS: in the log itself. |
Líneas sin nivel: las marcas de inicio/cierre "<nombre> start..."/"<nombre> done" de cada script, y los contadores compactos campo=valor|campo=valor -- agrupadas bajo esta etiqueta genérica solo por el visor de Webmin (uhmwebmin.sh), no se escriben como STATUS: en el log real. |
uhmalert.shsends push notifications only forERROR:/WARNING:/FIX:lines. For pydhcp's own log format and levels, see pydhcp -- Log levels.
uhmalert.shenvía notificaciones push solo para líneasERROR:/WARNING:/FIX:.Para el formato y niveles de log propios de pydhcp, ver pydhcp -- Log levels.
| Level | What happens | Qué ocurre | Example |
|---|---|---|---|
| (no level) | Start/end markers and per-cycle totals | Marcas de inicio y fin, y totales por ciclo | uhmleases start... · blockdhcp=67|limited=105|... |
INFO: |
One line per state change | Una línea por cambio de estado | new client X -> grace · Authorized X · kicked X |
INFO: ... -- skip |
The step is skipped and retried next cycle | El paso se salta y se reintenta en el siguiente ciclo | API GET stat/sta -> HTTP 000 -- skip |
INFO: |
Logged once, when all three endpoints answer together | Se registra una vez, cuando los tres endpoints responden juntos | UniFi backend ready (voucher/guest/sta OK) |
WARNING: ... -- fallback |
The documented default is used | Se usa el valor por defecto documentado | no CLEANUP_INTERVAL in pydhcp.env -- fallback |
WARNING: ... -- alert |
Repaired automatically | Reparado automáticamente | uhm.env perms fixed -- alert |
WARNING: ... -- alert |
The MACs stay queued and are harmlessly reprocessed next cycle -- never a permissions issue (runs as root); check free space, a read-only mount, or the immutable attribute (lsattr, cleared with chattr -i) |
Los MACs quedan en cola y se reprocesan sin efecto en el siguiente ciclo -- nunca es un problema de permisos (corre como root); revise espacio libre, montaje de solo lectura, o el atributo de inmodificable (lsattr, se quita con chattr -i) |
cannot empty uhm-queue.txt -- alert |
WARNING: ... -- alert |
The previous config is restored; the next cycle retries | Se restaura la configuración anterior; el siguiente ciclo reintenta | uhmreload.sh failed (code 1), backing off -- alert |
WARNING: ... -- alert |
uhmwatch.sh found the service down |
uhmwatch.sh encontró el servicio caído |
pydhcpd OFFLINE · uhmd restart FAILED -- alert |
FIX: |
Closes out the WARNING: that reported it |
Cierra el WARNING: que lo reportó |
pydhcpd restarted |
ALERT: |
A push notification was sent or withheld | Se envió o se retuvo una notificación push | sent -- WARNING: ... · dup alert suppressed |
ERROR: ... -- abort |
The script stops before touching anything | El script se detiene antes de tocar nada | missing dependency 'jq' -- abort · uhm.env not found -- abort |
ERROR: ... -- abort |
Every offending entry is listed before aborting | Se listan todas las entradas implicadas antes de abortar | mac-*.txt IP conflict -- abort |
--------------------------------------------------------------------------------
2026-07-01 06:47:35 INFO: new client 02:00:00:aa:bb:10, ip=192.168.0.231 host=no_name_fde07d34be -> grace
2026-07-01 06:47:35 INFO: added 1 new client(s) to uhm-grace
2026-07-01 06:47:35 INFO: uhm-grace.txt changed
2026-07-01 06:47:35 INFO: invoking /etc/uhm/core/uhmreload.sh
2026-07-01 06:47:35 uhmreload start...
2026-07-01 06:47:35 uhmleases start...
2026-07-01 06:47:36 INFO: 02:00:00:aa:bb:11 expired (age=43346s)
2026-07-01 06:47:36 INFO: add 02:00:00:aa:bb:11 to blockdhcp
2026-07-01 06:47:36 INFO: queued removal for 02:00:00:aa:bb:11
2026-07-01 06:47:40 blockdhcp=67|limited=105|unlimited=35|hotspot=17|grace=8
2026-07-01 06:47:40 uhmleases done at: Wed Jul 1 06:47:40 -05 2026
2026-07-01 06:47:40 uhmiptables start...
2026-07-01 06:47:42 uhmiptables done at: Wed Jul 1 06:47:42 -05 2026
2026-07-01 06:47:42 uhmreload done at: Wed Jul 1 06:47:42 -05 2026
2026-07-01 06:47:42 vouchers=3|auth=17|grace=8|new_auth=0|revoked=0
When no client connects, no voucher is redeemed, and no grace entry expires, the log between two cycles is simply empty -- nothing is written.
Cuando no hay cliente conectado, ningún voucher canjeado, ni ninguna entrada de gracia expirada, el log entre dos ciclos queda simplemente vacío: no se escribe nada.
| Reload failure and backoff | Fallo de reload y backoff |
A safety backoff against an error in some line of the scripts uhmreload.sh invokes (especially uhmiptables.sh, which is outside the scope of this project). If UHM_RELOAD (uhmreload.sh) fails or times out, uhmd logs the failure and switches to "backing off to safety-net cadence": it will not retry on the next cycle (every POLL_INTERVAL) — it waits the full RELOAD_SAFETY_INTERVAL_SECONDS (default 3600s = 1h) before invoking the reload chain again, so a persistent failure does not spam the log or re-alert every cycle. The same backoff also fires if UHM_RELOAD is missing. Any line prefixed WARNING: or ERROR: in uhm.log is picked up by uhmalert.sh (see uhmalert), which forwards it as a push notification prefixed with ALERT: sent -- followed by the original line — that prefix is uhmalert.sh confirming it already notified you, not a separate problem.
|
Un backoff de seguridad ante un error en alguna línea de los scripts que invoca uhmreload.sh (especialmente uhmiptables.sh, que está fuera del alcance de este proyecto). Si UHM_RELOAD (uhmreload.sh) falla o hace timeout, uhmd registra el fallo y pasa a "backing off to safety-net cadence": no reintenta en el siguiente ciclo (cada POLL_INTERVAL) — espera el RELOAD_SAFETY_INTERVAL_SECONDS completo (default 3600s = 1h) antes de invocar de nuevo la cadena de reload, para que un fallo persistente no sature el log ni vuelva a alertar en cada ciclo. El mismo backoff también ocurre si UHM_RELOAD falta. Cualquier línea con prefijo WARNING: o ERROR: en uhm.log es detectada por uhmalert.sh (ver uhmalert), que la reenvía como notificación push con el prefijo ALERT: sent -- seguido de la línea original — ese prefijo es uhmalert.sh confirmando que ya te avisó, no un problema aparte.
|
2026-07-27 20:45:28 WARNING: uhmreload.sh failed (code 1), backing off -- alert
2026-07-27 20:45:29 ALERT: sent -- WARNING: uhmreload.sh failed (code 1), backing
| Field | Type | Description | Descripción |
|---|---|---|---|
vouchers |
total | Vouchers currently in UniFi (stat/voucher) |
Vouchers presentes en UniFi |
auth |
total | MACs in uhm-auth.txt at end of cycle |
MACs en uhm-auth.txt al final del ciclo |
grace |
total | MACs in uhm-grace.txt at end of cycle |
MACs en uhm-grace.txt al final del ciclo |
new_auth |
delta | MACs processed by the sessions step this cycle: new promotions to uhm-auth.txt and voucher renewals of MACs already in it (only new promotions get kicked — see step 10) |
MACs procesadas por el paso de sesiones en este ciclo: promociones nuevas a uhm-auth.txt y renovaciones de voucher de MACs ya presentes en él (solo las promociones nuevas reciben kick — ver paso 10) |
revoked |
delta | MACs removed from uhm-auth.txt this cycle (authorized=false in UniFi) |
MACs eliminadas de uhm-auth.txt en este ciclo |
uhmleases output — Written to /var/log/uhm.log (unified log). Only real state changes on uhm-grace.txt are logged: a MAC added on first contact, one expired to blockdhcp.txt after BLOCKDHCP_GRACE_SECONDS, or one removed by check_duplicate() when found in another ACL list. Entries that are simply preserved during their grace period produce no output — nothing to log means nothing changed.
|
Salida de uhmleases — Se escribe en /var/log/uhm.log (log unificado). Solo se registran cambios reales de estado sobre uhm-grace.txt: una MAC agregada al primer contacto, una expirada a blockdhcp.txt tras BLOCKDHCP_GRACE_SECONDS, o una removida por check_duplicate() al encontrarse en otra lista ACL. Las entradas que simplemente se preservan durante su período de gracia no producen ninguna salida — nada que registrar significa que nada cambió.
|
2026-07-01 06:47:36 INFO: 02:00:00:aa:bb:11 expired (age=43346s)
2026-07-01 06:47:36 INFO: add 02:00:00:aa:bb:11 to blockdhcp
2026-07-01 06:47:36 INFO: queued removal for 02:00:00:aa:bb:11
| UniFi controller access log | Log de acceso del controlador UniFi |
Separate from /var/log/uhm.log. UniFi OS Server runs inside a Podman container (uosserver), so its own portal access log lives at /data/unifi/logs/access.log inside that container, not on the host. Useful to confirm whether a client's captive-portal probe actually reached the AP's native redirect (look for ap=, id=, ssid= in the URL — their absence means the hit didn't come from the AP redirect). It's a binary-ish log file, so use grep -a.
|
Distinto de /var/log/uhm.log. UniFi OS Server corre dentro de un contenedor Podman (uosserver), así que su propio log de acceso al portal vive en /data/unifi/logs/access.log dentro de ese contenedor, no en el host. Útil para confirmar si el sondeo de portal cautivo de un cliente realmente llegó al redirect nativo del AP (busque ap=, id=, ssid= en la URL — su ausencia significa que el hit no vino del redirect del AP). Es un archivo de log cuasi-binario, use grep -a.
|
# Tail live, filtering only captive-portal hits (/guest/)
sudo -u uosserver podman exec uosserver tail -f /data/unifi/logs/access.log \
| grep --line-buffered -a "/guest/"
# Example line this produces (302 = AP redirect worked, params present):
# [2026-07-04T15:02:33,854-05:00] [ 192.168.0.231 -> portal-82 ] GET 200 3ms \
# /guest/s/default/?ap=02:00:00:aa:bb:12&id=02:00:00:aa:bb:13&t=1783195353&url=http://netcts.cdn-apple.com%2F&ssid=EXAMPLE_SSID
# Search the full history for a specific client MAC (not IP — IPs rotate every DHCP renewal)
sudo -u uosserver podman exec uosserver grep -a "id=02:00:00:aa:bb:13" /data/unifi/logs/access.log
# Confirm the portal itself is reachable and serving (run from the gateway host)
sudo -u uosserver podman exec uosserver curl -v http://192.168.0.10:8880/guest/s/default/| Note | Description | Descripción |
|---|---|---|
| Synchronization | UHM depends on correct synchronization between UniFi Network, the DHCP server, and the user-maintained firewall script. It is not guaranteed to work on every Linux system. |
UHM depende de la correcta sincronización entre UniFi Network, el servidor DHCP y el script firewall que mantiene el usuario. No se garantiza su funcionamiento en todos los sistemas Linux. |
| Lease queue | The script queues lease removals for MACs it manages (via uhm-queue.txt). Actual removal is performed by uhmleases.sh during its safe DHCP stop→modify→start cycle. Leases for hotspot MACs are short-lived by design. uhm-queue.txt's path comes from the UHM_QUEUE config variable; it is an internal working file consumed by both scripts, not an ACL — do not edit its contents manually. |
El script encola remociones de leases para los MACs que gestiona (vía uhm-queue.txt). La remoción real la ejecuta uhmleases.sh durante su ciclo seguro de detener→modificar→arrancar DHCP. Los leases para MACs del hotspot son de corta vida por diseño. La ruta de uhm-queue.txt la fija la variable de configuración UHM_QUEUE; es un archivo de trabajo interno que consumen ambos scripts, no una ACL — no debe editarse su contenido manualmente. |
| Firewall scope | Both uhm-grace.txt and uhm-auth.txt clients must be reachable via your DHCP server. Only uhm-auth.txt clients should be granted full Internet by your firewall; grace-period clients (macgrace ipset) should only reach the captive portal ports. |
Los clientes de uhm-grace.txt y uhm-auth.txt deben ser alcanzables por su servidor DHCP. Solo uhm-auth.txt debe tener Internet completo vía firewall; los clientes en período de gracia (ipset macgrace) solo deben llegar a los puertos del portal cautivo. |
| Script header | Read the script header before deploying — it documents the full flow and any newly added behavior. | Lea el header del script antes de desplegarlo — documenta el flujo completo y cualquier comportamiento recién añadido. |
| Testing | Always test in a non-production environment first. | Pruebe siempre en un entorno no productivo primero. |
| WPAD/PAC | uhmleases.sh generates /etc/pydhcp/core/pydhcpd.conf dynamically on every run. Set WPAD_ENABLED=true in uhm.env to enable WPAD/PAC via DHCP option 252, and WPAD_PORT to the port your Apache VirtualHost listens on (default 18100). Prerequisites, to be in place before setting true: Apache2 installed, a VirtualHost listening on WPAD_PORT with that port declared in Apache's ports.conf as Listen SERVER_IP:PORT, and a valid wpad.pac in its document root. Guard: uhmleases.sh never trusts WPAD_ENABLED=true on its own — on every run it fetches http://SERVER_IP:WPAD_PORT/wpad.pac and writes the option wpad lines only on HTTP 200; otherwise it logs a WARNING, leaves them commented out and continues. This prevents every WPAD-aware client on the LAN from stalling on an unreachable PAC URL, a fault that raises no server-side error and only shows up as "the network is slow" everywhere at once. Check it yourself with curl -fsS --noproxy '*' --max-time 5 -o /dev/null "http://SERVER_IP:WPAD_PORT/wpad.pac"; echo $? — 0 means it will be activated. |
uhmleases.sh genera /etc/pydhcp/core/pydhcpd.conf dinámicamente en cada ejecución. Establezca WPAD_ENABLED=true en uhm.env para activar WPAD/PAC vía DHCP option 252, y WPAD_PORT al puerto en que escucha su VirtualHost de Apache (default 18100). Requisitos, que deben estar listos antes de poner true: Apache2 instalado, un VirtualHost escuchando en WPAD_PORT con ese puerto declarado en el ports.conf de Apache como Listen SERVER_IP:PORT, y un wpad.pac válido en su document root. Guarda: uhmleases.sh nunca confía en WPAD_ENABLED=true por sí solo — en cada ejecución descarga http://SERVER_IP:WPAD_PORT/wpad.pac y escribe las líneas option wpad solo si obtiene HTTP 200; si no, registra un WARNING, las deja comentadas y continúa. Esto evita que todos los clientes de la red que atienden WPAD se queden esperando una URL PAC inalcanzable, una avería que no genera ningún error en el servidor y que solo se manifiesta como "la red está lenta" en todas partes a la vez. Compruébelo con curl -fsS --noproxy '*' --max-time 5 -o /dev/null "http://SERVER_IP:WPAD_PORT/wpad.pac"; echo $? — un 0 significa que se activará. |
| WPAD/PAC scope | pydhcpd is ACL-agnostic — when WPAD_ENABLED=true it sends DHCP option 252 to every client, including mac-unlimited. Since unlimited devices must never go through the proxy, uhmiptables.sh blocks them from reaching port 18100 (the PAC file) at the firewall level; the PAC's own ; DIRECT fallback makes the browser proceed without a proxy for them. |
pydhcpd no distingue ACLs — cuando WPAD_ENABLED=true envía la opción DHCP 252 a todos los clientes, incluyendo mac-unlimited. Como los dispositivos unlimited nunca deben pasar por el proxy, uhmiptables.sh les bloquea el acceso al puerto 18100 (el archivo PAC) a nivel de firewall; el fallback ; DIRECT del propio PAC hace que el navegador siga sin proxy para ellos. |
| ping-check | ping-check true is enabled by default in the pydhcpd.conf generated by uhmleases.sh, along with ping-timeout (default 1s, controlled via PING_TIMEOUT_SECONDS in uhm.env). The daemon pings each IP before an OFFER to detect conflicts. In environments with strict ICMP firewall rules the ping will always time out silently and have no effect. Set PING_CHECK_ENABLED=false in uhm.env to disable it. |
ping-check true está activado por defecto en el pydhcpd.conf generado por uhmleases.sh, junto con ping-timeout (default 1s, controlado via PING_TIMEOUT_SECONDS en uhm.env). El demonio hace ping a cada IP antes del OFFER para detectar conflictos. En entornos con reglas de firewall estrictas que bloquean ICMP el ping siempre expirará sin efecto. Establezca PING_CHECK_ENABLED=false en uhm.env para desactivarlo. |
| Preventive guards | Checked unconditionally, every run, regardless of whether anything is actually wrong. Cheap when the scenario they guard against never happens (the normal case); their fallback behavior only activates if it does. Different in kind from reactive recovery (backup-config restore in uhmleases, the reload-failure backoff in uhmd) -- those only run after a failure is already detected, to recover from it. The guards below exist so a rare or unproven scenario degrades gracefully instead of cascading into a bigger failure (an aborted reload, a wrongly-promoted MAC, a silently corrupted ACL file). |
Se revisan sin condición, en cada corrida, sin importar si realmente hay algo mal. No cuestan nada cuando el escenario que protegen nunca ocurre (el caso normal); su comportamiento de fallback solo se activa si ocurre. Son de otra naturaleza que la recuperación reactiva (restauración de config de respaldo en uhmleases, el backoff por fallo de reload en uhmd) -- esas solo corren después de que ya se detectó un fallo, para recuperarse de él. Las guardas de abajo existen para que un escenario raro o no comprobado degrade con gracia en vez de encadenar una falla mayor (un reload abortado, una MAC promovida por error, un archivo ACL corrompido en silencio). |
| Voucher hostname length cap | process_sessions() (uhmd.sh) checks whether guestN-<voucher_code> would exceed 63 chars (the limit uhmleases.sh::_normalize_acl_file() enforces on uhm-auth.txt) before writing it. voucher_code comes from UniFi's API with no length guarantee from our side -- no known UniFi version has ever been observed returning one long enough to trigger this (real codes are short and numeric), but nothing rules it out for good. If it ever happened without this guard, the oversized line would abort normalization for the entire uhm-auth.txt file, not just that one client. With the guard, the voucher code is simply omitted from that one hostname (kept as plain guestN) and a WARNING is logged -- everything else proceeds normally. |
process_sessions() (uhmd.sh) revisa si guestN-<voucher_code> superaría los 63 caracteres (el límite que uhmleases.sh::_normalize_acl_file() exige en uhm-auth.txt) antes de escribirlo. voucher_code viene de la API de UniFi sin garantía de longitud de nuestro lado -- no se ha observado ninguna versión de UniFi que devuelva uno lo bastante largo como para disparar esto (los códigos reales son cortos y numéricos), pero nada lo descarta para siempre. Si pasara sin esta guarda, la línea de más de 63 caracteres abortaría la normalización de todo uhm-auth.txt, no solo la de ese cliente. Con la guarda, el código simplemente se omite de ese hostname puntual (queda como guestN plano) y se registra un WARNING -- todo lo demás sigue normal. |
is_managed_mac() live check |
Read fresh from disk on every call inside process_sessions/kick_newly_authorized/process_new_leases (uhmd.sh) -- guards against a stale or externally-granted UniFi guest session ever promoting a mac-*.txt device into uhm-auth.txt. In normal operation this never fires (managed devices don't go through the voucher flow at all); it only matters the day a residual session, a manual UniFi authorization, or a voucher redeemed before the device was added to mac-*.txt would otherwise slip through. |
Se lee en vivo del disco en cada llamada dentro de process_sessions/kick_newly_authorized/process_new_leases (uhmd.sh) -- protege contra que una sesión de invitado de UniFi residual o concedida por fuera alguna vez promueva a un dispositivo de mac-*.txt a uhm-auth.txt. En operación normal nunca se activa (los dispositivos gestionados ni pasan por el flujo de voucher); solo importa el día que una sesión residual, una autorización manual en UniFi, o un voucher canjeado antes de agregar el dispositivo a mac-*.txt se colarían si no estuviera. |
uhmwatch.sh reload-in-progress check |
_uhm_reload_in_progress() probes uhmd's cycle lock (non-blocking) before check_pydhcpd() declares the service OFFLINE. uhmleases.sh legitimately stops/reconfigures/starts pydhcpd for a few seconds on every real reload -- almost every cron tick (every minute) lands outside that window and never touches this guard's fallback path. It only matters the rare time a tick lands squarely inside it, where declaring OFFLINE and restarting would collide with uhmleases.sh's own pending restart and abort that reload. |
_uhm_reload_in_progress() prueba (sin bloquear) el lock de ciclo de uhmd antes de que check_pydhcpd() declare el servicio OFFLINE. uhmleases.sh legítimamente detiene/reconfigura/arranca pydhcpd por unos segundos en cada reload real -- casi todas las corridas de cron (cada minuto) caen fuera de esa ventana y nunca tocan el camino de fallback de esta guarda. Solo importa la rara vez que una corrida cae justo dentro, donde declarar OFFLINE y reiniciar chocaría con el restart que uhmleases.sh ya tenía pendiente y abortaría ese reload. |
mac-*.txt IP range conflict check |
check_mac_ip_ranges() (uhmleases.sh) validates, on every reload, that no admin-picked mac-*.txt IP falls inside UHM_INI_RANGE-UHM_END_RANGE or the block-pool range. Never fires as long as mac-*.txt IPs are chosen outside both ranges (the documented, expected setup); it only matters the day a typo or a copy-pasted IP lands inside one, where it aborts the reload with a specific ERROR: instead of silently corrupting DHCP behavior for both the conflicting device and whoever else was assigned that same range. |
check_mac_ip_ranges() (uhmleases.sh) valida, en cada reload, que ninguna IP de mac-*.txt elegida por el admin caiga dentro de UHM_INI_RANGE-UHM_END_RANGE ni del rango del pool de bloqueo. Nunca se activa mientras las IPs de mac-*.txt se elijan fuera de ambos rangos (la configuración esperada y documentada); solo importa el día que un typo o una IP copiada y pegada caiga dentro de uno, donde aborta el reload con un ERROR: puntual en vez de corromper en silencio el comportamiento DHCP tanto del dispositivo en conflicto como de quien más tuviera asignado ese mismo rango. |
| ACL file-swap count checks | clean_expired_macs() (uhmd.sh) and drain_lease_queue() (uhmleases.sh) both count entries before and after rewriting a file, and refuse to commit the swap (keep the original, log an ERROR) if the counts don't reconcile with what was actually expired/removed. Never fires when the rewrite logic behaves as expected (the normal case, every cycle); it only matters the day a parsing edge case would otherwise silently drop entries during a file rewrite. |
clean_expired_macs() (uhmd.sh) y drain_lease_queue() (uhmleases.sh) cuentan entradas antes y después de reescribir un archivo, y se niegan a confirmar el cambio (conservan el original, registran un ERROR) si los conteos no cuadran con lo que realmente se expiró/removió. Nunca se activa cuando la lógica de reescritura se comporta como se espera (el caso normal, en cada ciclo); solo importa el día que un caso límite de parseo, de no estar esto, descartaría entradas en silencio al reescribir un archivo. |
| These are platform and device limitations, not defects in this project. | Estas son limitaciones de plataforma y dispositivo, no defectos de este proyecto. |
| Limitation | Description | Limitación | Descripción |
|---|---|---|---|
| WPAD not supported | Android and iOS ignore DHCP option 252. The proxy must be configured manually on each device. | WPAD no soportado | Android e iOS ignoran la opción DHCP 252. El proxy debe configurarse manualmente en cada dispositivo. |
| Captive portal probes | Android probes connectivitycheck.gstatic.com; iOS probes captive.apple.com. If blocked or intercepted, the device reports "connected without internet" even when the proxy works. Whitelist these in Squid without auth. |
Sondas del portal cautivo | Android sondea connectivitycheck.gstatic.com; iOS sondea captive.apple.com. Si están bloqueados o interceptados, el dispositivo reporta "conectado sin internet" aunque el proxy funcione. Agréguelos a la whitelist de Squid sin autenticación. |
| App proxy bypass | Most apps on Android and iOS bypass the system proxy and connect directly. Only browsers reliably honor a manual proxy. Without SSL bump, direct HTTPS traffic cannot be redirected. | Apps que bypasean el proxy | La mayoría de las apps en Android e iOS bypasean el proxy del sistema y se conectan directamente. Solo los navegadores respetan de forma confiable un proxy manual. Sin SSL bump, el tráfico HTTPS directo no puede ser redirigido. |
| MAC randomization | Android 10+ and iOS 14+ randomize the MAC per network by default. A randomized MAC will never match an ACL entry and will appear as unauthorized on every connection. Users must disable MAC randomization for the SSID before connecting. | Aleatorización de MAC | Android 10+ e iOS 14+ aleatorizan la MAC por red por defecto. Una MAC aleatorizada nunca coincidirá con una entrada ACL y aparecerá como no autorizada en cada conexión. El usuario debe deshabilitar la aleatorización de MAC para el SSID antes de conectarse. |
This behavior applies only to the optional proxy architecture described in uhmiptables_example.txt (iptables HTTP redirection to Squid, optionally using PAC via DHCP Option 252). It is not a defect in this project.
|
Este comportamiento aplica únicamente a la arquitectura opcional con proxy descrita en uhmiptables_example.txt (redirección HTTP mediante iptables hacia Squid, opcionalmente usando PAC mediante la Opción 252 de DHCP). No es un defecto de este proyecto.
|
| Limitation | Description | Limitación | Descripción |
|---|---|---|---|
| Windows NCSI probe | Windows periodically requests http://www.msftconnecttest.com/connecttest.txt to determine Internet connectivity. When HTTP traffic is transparently redirected to Squid (REDIRECT 80 → 3128), NCSI may receive an HTTP 404 response after successful voucher authentication. This does not affect normal Internet access. |
Sonda NCSI de Windows | Windows consulta periódicamente http://www.msftconnecttest.com/connecttest.txt para determinar la conectividad a Internet. Cuando el tráfico HTTP se redirige transparentemente hacia Squid (REDIRECT 80 → 3128), NCSI puede recibir una respuesta HTTP 404 después de una autenticación exitosa mediante voucher. Esto no afecta el acceso normal a Internet. |
| This is a structural limitation of MAC-based classification, not a code defect — see mitigation below. | Esta es una limitación estructural de la clasificación basada en MAC, no un defecto de código — ver mitigación abajo. |
| Limitation | Description | Limitación | Descripción |
|---|---|---|---|
mac-*.txt IP range is administrator-defined, not a config variable |
uhm.env only defines two IP ranges: UHM_INI_RANGE/UHM_END_RANGE for uhm-auth.txt, and SERV_INI_RANGE_BLOCK/SERV_END_RANGE_BLOCK for the pydhcp pool (uhm-grace.txt/blockdhcp.txt). mac-*.txt files (mac-limited.txt, mac-unlimited.txt) don't exist by default — uhmsetup.sh only creates the /etc/acl/mac directory; the administrator creates these files and picks their IPs manually, with no dedicated range enforced by uhm.env itself. uhmleases.sh's check_mac_ip_ranges() validates this on every run: any mac-*.txt IP landing inside either reserved range aborts the reload with a specific ERROR: log line — see uhmleases below for examples — but the safest practice is keeping every mac-*.txt IP outside both ranges from the start. |
El rango de IP de mac-*.txt es decisión del administrador, no una variable de configuración |
uhm.env solo define dos rangos de IP: UHM_INI_RANGE/UHM_END_RANGE para uhm-auth.txt, y SERV_INI_RANGE_BLOCK/SERV_END_RANGE_BLOCK para el pool de pydhcp (uhm-grace.txt/blockdhcp.txt). Los archivos mac-*.txt (mac-limited.txt, mac-unlimited.txt) no existen por defecto — uhmsetup.sh solo crea el directorio /etc/acl/mac; el administrador crea estos archivos y elige sus IPs manualmente, sin rango dedicado impuesto por uhm.env. check_mac_ip_ranges() en uhmleases.sh valida esto en cada corrida: cualquier IP de mac-*.txt que caiga dentro de alguno de los dos rangos reservados aborta el reload con una línea ERROR: puntual — ver uhmleases más abajo para ejemplos — pero lo más seguro es mantener siempre las IPs de mac-*.txt fuera de ambos rangos desde el principio. |
| Indefinite MAC rotation bypasses grace→block promotion | uhm-grace.txt classification is keyed exclusively by MAC address (see MAC randomization above). A client that presents a new MAC on each reconnection is treated as a brand-new client every time: it receives a fresh BLOCKDHCP_GRACE_SECONDS timer and never accumulates enough grace-period age to be promoted to blockdhcp.txt. pydhcpd's own DHCP rate-limiting (keyed per-MAC) does not mitigate this — it throttles request volume from a single identity, not the number of distinct identities a client can present, so the pattern is unaffected by any per-MAC threshold. DHCP client-hostname (option 12) cannot serve as a secondary identity signal either: it is client-supplied, unauthenticated (trivially spoofable), and not always present in pydhcpd.leases to begin with. There is no way to correlate rotated MACs to the same physical device from pydhcpd.leases alone; that would require device fingerprinting at the AP/802.11 layer, outside the scope of a DHCP-lease-based tool. Impact is bounded by firewall scope, not eliminated: the macgrace ipset only grants DNS resolution and captive-portal ports — the same access any new, first-time client already receives — so rotating a MAC indefinitely does not grant more network access than a single legitimate connection would, provided the macgrace DNS rule is restricted to the configured resolvers (SERV_DNS), as in the reference uhmiptables_example.txt. If that rule instead accepts DNS to any destination, grace-state clients gain an unrestricted DNS channel that can be used for DNS tunneling — combined with indefinite MAC rotation, this becomes a persistent internet bypass that never requires redeeming a voucher. The residual cost of MAC rotation even with the DNS rule restricted is operational, not a security bypass: uhm-grace.txt/blockdhcp.txt accumulate entries for MACs that are never reused, and each rotation consumes a DHCP pool lease. |
Rotación indefinida de MAC evade la promoción grace→block | La clasificación en uhm-grace.txt se basa exclusivamente en la dirección MAC (ver Aleatorización de MAC arriba). Un cliente que presenta una MAC nueva en cada reconexión es tratado como cliente completamente nuevo cada vez: recibe un temporizador BLOCKDHCP_GRACE_SECONDS fresco y nunca acumula suficiente antigüedad en gracia como para ser promovido a blockdhcp.txt. El propio rate-limiting DHCP de pydhcpd (por MAC) no mitiga esto — limita el volumen de solicitudes de una sola identidad, no la cantidad de identidades distintas que un cliente puede presentar, así que el patrón no se ve afectado por ningún umbral por-MAC. El hostname DHCP (opción 12) tampoco puede servir como señal secundaria de identidad: lo provee el cliente, no está autenticado (trivialmente falsificable), y ni siquiera está siempre presente en pydhcpd.leases. No hay forma de correlacionar MACs rotadas con el mismo dispositivo físico solo desde pydhcpd.leases; eso requeriría fingerprinting de dispositivo a nivel de AP/802.11, fuera del alcance de una herramienta basada en leases DHCP. El impacto está acotado por el alcance del firewall, no eliminado: el ipset macgrace solo otorga resolución DNS y los puertos del portal cautivo — el mismo acceso que ya recibe cualquier cliente nuevo de primera vez — así que rotar la MAC indefinidamente no otorga más acceso de red del que ya tendría una sola conexión legítima, siempre que la regla DNS de macgrace esté restringida a los resolvers configurados (SERV_DNS), como en el uhmiptables_example.txt de referencia. Si esa regla en cambio acepta DNS a cualquier destino, los clientes en estado grace ganan un canal DNS sin restricción utilizable para DNS tunneling — combinado con rotación indefinida de MAC, esto se convierte en un bypass de internet persistente que nunca requiere canjear un voucher. El costo residual de la rotación de MAC incluso con la regla DNS restringida es operativo, no un bypass de seguridad: uhm-grace.txt/blockdhcp.txt acumulan entradas de MACs que nunca se reutilizan, y cada rotación consume un lease del pool DHCP. |
| These are UniFi platform/API behaviors, not defects in this project. | Estos son comportamientos de la plataforma/API de UniFi, no defectos de este proyecto. |
| Limitation | Description | Limitación | Descripción |
|---|---|---|---|
stat/guest doesn't distinguish deleted vs. quota-exhausted vouchers |
When a voucher is deleted manually from the UniFi UI, stat/guest still retains session records tagged with that voucher_code, indistinguishable from a voucher whose quota simply ran out. This lets affected clients reconnect without re-entering a code. Reported to Ubiquiti: community.ui.com/31faff3e. Mitigated in uhmunifi.sh by Revoke by voucher code (action 4), which cleans stat/guest/stat/sta directly instead of relying on stat/voucher state. |
stat/guest no distingue vouchers eliminados de vouchers con cuota agotada |
Cuando un voucher se elimina manualmente desde la UI de UniFi, stat/guest sigue reteniendo registros de sesión con ese voucher_code, indistinguibles de un voucher cuya cuota simplemente se agotó. Esto permite que los clientes afectados se reconecten sin volver a ingresar un código. Reportado a Ubiquiti: community.ui.com/31faff3e. Mitigado en uhmunifi.sh mediante Revoke by voucher code (acción 4), que limpia stat/guest/stat/sta directamente sin depender del estado de stat/voucher. |
stat/voucher has no historical record of expired vouchers |
UniFi does not retain a voucher in stat/voucher once it expires or its quota is fully consumed; the entry disappears entirely instead of being marked expired. Verified directly against a live controller: five vouchers confirmed issued and consumed via /var/log/uhm.log (Authorized/Expired lines) returned zero matches when queried by code against stat/voucher after expiry. As a result, uhmunifi.sh's Vouchers section and Delete expired vouchers (action 3) can only ever act on what the controller still tracks at query time — they cannot produce a historical report of all vouchers ever issued. The only durable record of past voucher activity is /var/log/uhm.log. |
stat/voucher no tiene registro histórico de vouchers expirados |
UniFi no retiene un voucher en stat/voucher una vez que expira o su cuota se consume por completo; la entrada desaparece por completo en vez de marcarse como expirada. Verificado directamente contra un controlador en vivo: cinco vouchers confirmados como emitidos y consumidos vía /var/log/uhm.log (líneas Authorized/Expired) devolvieron cero coincidencias al consultarlos por código contra stat/voucher después de expirar. Como consecuencia, la sección Vouchers de uhmunifi.sh y Delete expired vouchers (acción 3) solo pueden actuar sobre lo que el controlador todavía rastrea al momento de la consulta — no pueden producir un reporte histórico de todos los vouchers emitidos alguna vez. El único registro duradero de actividad histórica de vouchers es /var/log/uhm.log. |
kick-sta can fail with HTTP 400 right after a successful authorization |
The voucher redemption itself always succeeds independently of this: the client is already promoted to uhm-auth.txt with its fixed hotspot IP in step 7 (sessions), well before kick_newly_authorized() runs in step 10. The kick-sta call is a best-effort convenience against the UniFi API (cmd/stamgr) to force the client to re-associate immediately with its new IP; if UniFi rejects that specific request with HTTP 400 (typically a race between the just-granted authorization and what stat/sta still reports for that MAC at that instant), the client simply keeps its old pool-range IP until its own DHCP renewal timer fires, and the client-facing symptom can be an HTTP 400/404 from UniFi's own captive-portal web layer while the browser tries to continue on the stale IP — a separate HTTP exchange from the kick-sta call, on a different endpoint, that just happens to surface around the same time. Nothing in this project's ACLs or firewall rules is at fault; both log lines are written by kick_newly_authorized() itself, not by uhmleases.sh/uhmiptables.sh. Example from /var/log/uhm.log: WARNING: failed to kick / WARNING: 02:00:00:aa:bb:20 (HTTP 400) followed by WARNING: client may keep its stale IP / WARNING: until its own DHCP renewal (each logged as two lines, per the 80-column limit on log messages). The current code only logs the HTTP status code, not UniFi's response body, so the controller's exact rejection reason isn't recoverable from uhm.log alone. |
kick-sta puede fallar con HTTP 400 justo después de una autorización exitosa |
La redención del voucher en sí siempre tiene éxito de forma independiente a esto: el cliente ya quedó promovido a uhm-auth.txt con su IP fija de hotspot en el paso 7 (sessions), mucho antes de que kick_newly_authorized() se ejecute en el paso 10. La llamada a kick-sta es un intento de conveniencia (best-effort) contra la API de UniFi (cmd/stamgr) para forzar al cliente a reasociarse de inmediato con su nueva IP; si UniFi rechaza esa petición puntual con HTTP 400 (típicamente una condición de carrera entre la autorización recién otorgada y lo que stat/sta todavía reporta para ese MAC en ese instante), el cliente simplemente conserva su IP vieja del rango de pool hasta que su propio temporizador de renovación DHCP se cumpla, y el síntoma visible para el cliente puede ser un HTTP 400/404 de la propia capa web del portal cautivo de UniFi mientras el navegador intenta continuar con la IP vieja — un intercambio HTTP distinto al de kick-sta, sobre un endpoint diferente, que solo coincide en el tiempo. No hay ninguna falla en las ACLs ni en las reglas de firewall de este proyecto; ambas líneas de log las escribe el propio kick_newly_authorized(), no uhmleases.sh/uhmiptables.sh. Ejemplo de /var/log/uhm.log: WARNING: failed to kick / WARNING: 02:00:00:aa:bb:20 (HTTP 400) seguido de WARNING: client may keep its stale IP / WARNING: until its own DHCP renewal (cada uno logueado en dos líneas, por el límite de 80 columnas en mensajes de log). El código actual solo registra el código HTTP, no el cuerpo de la respuesta de UniFi, así que el motivo exacto del rechazo del controlador no se puede recuperar solo con uhm.log. |
Both unifi-os and classic run MongoDB embedded (container, or subprocess of unifi.service on port 27117). The standalone mongod.service in classic is disabled by default. The issue below only occurs if that instance is shared with another application.
|
Tanto unifi-os como classic ejecutan MongoDB embebido (contenedor, o subproceso de unifi.service en el puerto 27117). La unidad independiente mongod.service de classic está deshabilitada por defecto. El problema descrito a continuación solo puede ocurrir si esa instancia se comparte con otra aplicación.
|
| Issue | Description | Problema | Descripción |
|---|---|---|---|
| MongoDB cannot write to its data directory | Clients cannot reach the captive portal. MongoDB logs (sudo journalctl -u mongod -f) show code=dumped, status=6/ABRT, code=exited, or status=14/n/a, indicating that MongoDB cannot write to its data directory.Fix: systemctl stop mongodchown mongodb:mongodb /var/lib/mongodb/WiredTiger.turtlechown mongodb:mongodb /var/lib/mongodb/WiredTiger.wtchown -R mongodb:mongodb /var/lib/mongodbsystemctl start mongodVerify: sudo systemctl status mongod |
MongoDB no puede escribir en su directorio de datos | Los clientes no pueden acceder al portal cautivo. Los registros de MongoDB (sudo journalctl -u mongod -f) muestran code=dumped, status=6/ABRT, code=exited o status=14/n/a, indicando que MongoDB no puede escribir en su directorio de datos.Solución: systemctl stop mongodchown mongodb:mongodb /var/lib/mongodb/WiredTiger.turtlechown mongodb:mongodb /var/lib/mongodb/WiredTiger.wtchown -R mongodb:mongodb /var/lib/mongodbsystemctl start mongodVerificar: sudo systemctl status mongod |
| This project is designed to run locally and be accessed over a LAN. It is not recommended to expose it to the internet, as it lacks the hardening required for public-facing deployments. If you choose to publish it despite this warning, it is strongly recommended to do so through an on-demand tunnel rather than opening ports directly. This approach lets you start and stop public access at will, without permanently exposing your server. | Este proyecto está diseñado para ejecutarse localmente y ser accedido en red LAN. No se recomienda exponerlo a internet, ya que no cuenta con el endurecimiento necesario para despliegues públicos. Si decide publicarlo a pesar de esta advertencia, se recomienda hacerlo a través de un túnel bajo demanda en lugar de abrir puertos directamente. Este enfoque le permite iniciar y detener el acceso público a voluntad, sin exponer el servidor de forma permanente. |
Optional tunnel:
This repository
|
Este repositorio
|
| This project uses a dual-licensing model to balance software freedom with content protection: | Este proyecto utiliza un modelo de licencia dual para equilibrar la libertad del software con la protección del contenido: |
| Content | Licensed Under |
|---|---|
| Scripts, Binaries, Infrastructure | |
| RAG, Workers, Specialized Modules, Docs |
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.





