feat: sistema de login automático no portal captivo UniFi UNICEP

This commit is contained in:
Murilo Scolari 2026-10-15 19:49:24 -03:00
commit f621f2ed80
7 changed files with 565 additions and 0 deletions

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
# Arquivos de dump de rede e tráfego
dump.txt
*.dump
*.pcap
*.log
# Cache do Python
__pycache__/
*.py[cod]
*$py.class
# Arquivos de ambiente e temporários
*.swp
*.swo
*~
.DS_Store

22
99-captive-portal.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# NetworkManager Dispatcher Script para auto-login no portal captivo
# Para instalar a nível de sistema (requer root):
# sudo cp 99-captive-portal.sh /etc/NetworkManager/dispatcher.d/99-captive-portal.sh
# sudo chmod +x /etc/NetworkManager/dispatcher.d/99-captive-portal.sh
IFACE="$1"
ACTION="$2"
# Ajuste o caminho abaixo se necessário (ou instale em /usr/local/bin/login_portal.py)
SCRIPT_PATH="/usr/local/bin/login_portal.py"
if [ ! -x "$SCRIPT_PATH" ]; then
# Tenta localizar o script no diretório do usuário
SCRIPT_PATH="$(find /home -maxdepth 4 -name login_portal.py -executable 2>/dev/null | head -n 1)"
fi
if [ "$ACTION" = "up" ] || [ "$ACTION" = "connectivity-change" ]; then
if [ -n "$SCRIPT_PATH" ] && [ -x "$SCRIPT_PATH" ]; then
"$SCRIPT_PATH" --interface "$IFACE" --quiet > /dev/null 2>&1 &
fi
fi

128
README.md Normal file
View file

@ -0,0 +1,128 @@
# Login Automático no Portal Captivo UniFi (UNICEP)
Script e serviço de automação para conexão e liberação automática no portal captivo UniFi da faculdade (UNICEP).
---
## 🔍 Como o Login Funciona
Ao analisar o fluxo de rede do portal captivo em roteadores Ubiquiti UniFi:
1. **Detecção e Interceptação**:
- Ao se conectar ao Wi-Fi (ex: `A5_Alunos_Livre`), a primeira tentativa de navegação (como a verificação de conectividade do sistema operacional em `http://connectivitycheck.gstatic.com/generate_204`) é interceptada pelo AP UniFi.
- O AP responde com um redirecionamento HTTP 302 para o daemon de redirecionamento interno:
```
http://10.0.1.2:8882/redirect?ec=<token_criptografado>
```
- O daemon na porta 8882 decodifica a sessão e redireciona (HTTP 302) para a interface web na porta 8880:
```
http://10.0.1.2:8880/guest/s/default/?ap=6c:63:f8:9d:76:12&ec=<token>
```
*(Onde `ap` é o endereço MAC do ponto de acesso e `ec` é o token criptografado da sessão).*
2. **Configuração do Portal (`auth: none`)**:
- A página consulta `GET /guest/s/default/hotspotconfig`, que retorna:
```json
{
"auth": "none",
"payment_enabled": false,
"radius_enabled": false,
"password_enabled": false,
"voucher_enabled": false,
"title": "Rede WiFi UNICEP",
"button_text": "Login"
}
```
- Trata-se de uma autenticação **"click-through" (auth: none)**: não há usuário, senha nem vouchers.
3. **A Requisição de Login**:
- Quando o botão de login é pressionado, a página dispara uma requisição POST:
```http
POST http://10.0.1.2:8880/guest/s/default/login HTTP/1.1
Referer: http://10.0.1.2:8880/guest/s/default/?ap=<ap_mac>&ec=<token>
```
- **Corpo da requisição**: Vazio (`null`).
- **Cookies**: Desnecessários (`credentials: omit`).
- O controlador UniFi recebe a requisição, valida o cliente e o AP via cabeçalho `Referer`, libera o endereço MAC nas tabelas de firewall do AP e responde com redirecionamento HTTP 302. A internet passa a fluir normalmente.
4. **Tratamento Especial para VPN / WireGuard**:
- Se houver uma VPN ativa (como WireGuard com rota padrão), a VPN tenta rotear todo o tráfego antes da liberação do portal, travando conexões locais.
- O script resolve isso vinculando todas as requisições de teste e login diretamente à interface Wi-Fi (`--interface wlan0` / `SO_BINDTODEVICE`), contornando a VPN até que a rede local esteja liberada.
---
## 📁 Estrutura do Projeto
* `login_portal.py`: Script principal em Python (suporta modo pontual ou modo `--daemon`).
* `install.sh`: Script de instalação rápida do serviço de usuário no `systemd`.
* `uninstall.sh`: Script para desativar e remover o serviço.
* `unifi-portal.service`: Arquivo de unidade `systemd --user`.
* `99-captive-portal.sh`: Script alternativo para NetworkManager Dispatcher (nível de sistema).
---
## 🚀 Instalação Rápida (Serviço de Usuário)
Clone o repositório e execute o instalador:
```bash
git clone https://github.com/muriloscolari/LoginAutomaticoUnicep.git
cd LoginAutomaticoUnicep
./install.sh
```
O instalador irá:
1. Tornar os scripts executáveis.
2. Configurar o serviço `systemd --user`.
3. Habilitar a inicialização automática junto com seu login no sistema.
### Gerenciando o Serviço
```bash
# Ver status do serviço
systemctl --user status unifi-portal.service
# Acompanhar logs em tempo real
journalctl --user -u unifi-portal.service -f
# Reiniciar ou parar o serviço
systemctl --user restart unifi-portal.service
systemctl --user stop unifi-portal.service
```
---
## 🛠️ Uso Manual (Terminal)
Você também pode executar o script manualmente a qualquer momento:
```bash
# Checa se a internet precisa de liberação e efetua login se necessário
./login_portal.py
# Força o envio do POST mesmo se a internet já parecer ativa
./login_portal.py --force
# Especificar interface manualmente
./login_portal.py -i wlan0
```
---
## 💡 Como a Detecção Funciona
O script possui 3 camadas de segurança:
1. **Teste de Conectividade (`generate_204`)**:
- No Wi-Fi de casa, o Google responde `HTTP 204 No Content`. O script encerra instantaneamente sem fazer nada.
- Na faculdade (bloqueado), o AP retorna `HTTP 302` interceptando a conexão.
2. **Identificação da Assinatura UniFi**:
- Segue os redirecionamentos para confirmar se o destino é um portal UniFi (portas `8880`/`8882` e rota `/guest/s/`).
3. **Filtro de SSID**:
- Se a internet cair em uma rede residencial (ex: problema na operadora), o script reconhece que o SSID não é da faculdade e que não há portal UniFi, evitando tentativas inválidas.
---
## 📄 Licença
MIT License. Sinta-se livre para usar, modificar e compartilhar.

45
install.sh Executable file
View file

@ -0,0 +1,45 @@
#!/usr/bin/env bash
set -e
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT_PATH="$DIR/login_portal.py"
SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_FILE="$SERVICE_DIR/unifi-portal.service"
echo "==> Configurando Login Automático UniFi (UNICEP)..."
if [ ! -f "$SCRIPT_PATH" ]; then
echo "Erro: '$SCRIPT_PATH' não encontrado!" >&2
exit 1
fi
chmod +x "$SCRIPT_PATH"
mkdir -p "$SERVICE_DIR"
cat > "$SERVICE_FILE" <<EOF
[Unit]
Description=Auto-Login Portal Captivo UniFi (Faculdade)
After=network.target
[Service]
Type=simple
Environment=PYTHONUNBUFFERED=1
ExecStart=$SCRIPT_PATH --daemon
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
EOF
echo "==> Recarregando systemd user daemon..."
systemctl --user daemon-reload
echo "==> Habilitando e iniciando o serviço unifi-portal.service..."
systemctl --user enable --now unifi-portal.service
echo ""
echo "✓ Instalação concluída com sucesso!"
echo "Status do serviço:"
systemctl --user status unifi-portal.service --no-pager

317
login_portal.py Executable file
View file

@ -0,0 +1,317 @@
#!/usr/bin/env python3
"""
login_portal.py - Automatização de login no portal captivo UniFi (UNICEP)
Funciona tanto em modo único (CLI) quanto em modo daemon / serviço.
"""
import sys
import os
# Forçar flush de logs imediatamente no systemd / terminal
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(line_buffering=True)
import subprocess
import time
import argparse
import urllib.parse
import re
import select
CHECK_URLS = [
"http://connectivitycheck.gstatic.com/generate_204",
"http://detectportal.firefox.com/canonical.html",
]
DEFAULT_FALLBACK_PORTAL = "http://10.0.1.2:8880/guest/s/default/"
def notify(title, message, icon="network-wireless"):
"""Envia notificação gráfica no desktop do usuário."""
try:
subprocess.run(
["notify-send", "-a", "Portal Wi-Fi", "-i", icon, title, message],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
except Exception:
pass
def get_active_wifi_interface():
"""Detecta a interface Wi-Fi ativa."""
try:
out = subprocess.check_output(
["nmcli", "-t", "-f", "DEVICE,TYPE,STATE", "device"],
text=True,
stderr=subprocess.DEVNULL
)
for line in out.strip().splitlines():
parts = line.split(":")
if len(parts) >= 3 and parts[1] == "wifi" and "conectado" in parts[2]:
return parts[0]
except Exception:
pass
try:
out = subprocess.check_output(["ip", "route", "show", "default"], text=True, stderr=subprocess.DEVNULL)
m = re.search(r"dev\s+(\S+)", out)
if m:
return m.group(1)
except Exception:
pass
return "wlan0"
def get_active_ssid():
"""Obtém o nome (SSID) da rede Wi-Fi conectada."""
try:
out = subprocess.check_output(
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show", "--active"],
text=True,
stderr=subprocess.DEVNULL
)
for line in out.strip().splitlines():
parts = line.split(":")
if len(parts) >= 2 and parts[1] in ("802-11-wireless", "wifi"):
return parts[0]
except Exception:
pass
return None
def is_likely_college_network(ssid):
"""
Verifica se o SSID atual corresponde à rede da faculdade.
Exemplos: A5_Alunos_Livre, UNICEP, Alunos, etc.
"""
if not ssid:
return True # Se não puder identificar o SSID, confia na detecção do portal
keywords = ["alunos", "unicep", "livre", "faculdade"]
return any(k in ssid.lower() for k in keywords)
def curl_cmd(args, iface=None, timeout=5):
"""
Executa o curl vinculando à interface Wi-Fi (--interface <iface>).
Isso é crucial caso o WireGuard / VPN esteja ativo, impedindo que a VPN
sequestre o tráfego antes de a rede local ter sido liberada.
"""
cmd = ["curl", "-s"]
if iface:
cmd.extend(["--interface", iface])
cmd.extend(["--connect-timeout", str(timeout), "--max-time", str(timeout * 2)])
cmd.extend(args)
return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
def check_internet(iface):
"""
Verifica se a internet já está acessível através da interface.
Retorna (True, '204', url) se o acesso livre foi confirmado.
"""
for url in CHECK_URLS:
res = curl_cmd(["-o", "/dev/null", "-w", "%{http_code}", url], iface=iface, timeout=4)
code = res.stdout.strip()
if code == "204":
return True, code, url
return False, code or "000", CHECK_URLS[0]
def discover_portal(iface):
"""
Descobre o endereço do portal captivo seguindo os redirecionamentos (302/307).
No UniFi, o AP redireciona para a porta 8882 (/redirect?ec=...)
e depois para a porta 8880 (/guest/s/default/?ap=...&ec=...).
"""
for url in CHECK_URLS:
res = curl_cmd([
"-L",
"-o", "/dev/null",
"-w", "%{url_effective} | %{http_code}",
url
], iface=iface, timeout=6)
output = res.stdout.strip()
if " | " in output:
final_url, code = output.split(" | ", 1)
final_url = final_url.strip()
if "8880" in final_url or "8882" in final_url or "/guest/s/" in final_url:
return final_url
return None
def perform_login(portal_url, iface, quiet=False):
"""
Realiza a requisição POST para autorizar o cliente no portal UniFi.
"""
parsed = urllib.parse.urlparse(portal_url)
# Se a URL encontrada for o redirect intermediário na 8882, segue para a 8880
if ":8882" in portal_url or "/redirect" in parsed.path:
res = curl_cmd(["-L", "-o", "/dev/null", "-w", "%{url_effective}", portal_url], iface=iface, timeout=5)
portal_url = res.stdout.strip() or portal_url
parsed = urllib.parse.urlparse(portal_url)
# Monta a URL de login mantendo o caminho do site (ex: /guest/s/default/login)
path = parsed.path
if not path.endswith("/"):
path_dir = path[:path.rfind("/") + 1]
else:
path_dir = path
login_endpoint = f"{parsed.scheme}://{parsed.netloc}{path_dir}login"
if not quiet:
print(f"[*] Enviando requisição de login para: {login_endpoint}")
print(f"[*] Referer: {portal_url}")
# No UniFi com 'auth: none', o login é um POST com body vazio (null)
# e o Referer apontando para a página onde constam os parâmetros 'ap' e 'ec'
headers = [
"-H", f"Referer: {portal_url}",
"-H", "Accept: */*",
"-H", "User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
]
res = curl_cmd([
"-X", "POST",
"-o", "/dev/null",
"-w", "%{http_code}",
*headers,
login_endpoint
], iface=iface, timeout=6)
return res.stdout.strip()
def attempt_login(iface, force=False, quiet=False):
"""
Fluxo completo: verifica conectividade, descobre portal, envia POST e valida.
Retorna True se conectado com sucesso, False caso contrário.
"""
ssid = get_active_ssid()
if not force:
has_internet, code, check_url = check_internet(iface)
if has_internet:
if not quiet:
print(f"[✓] Conexão com a internet já está ativa (HTTP {code} em {check_url}). Login desnecessário.")
return True
else:
if not quiet:
print(f"[!] Sem internet livre (HTTP {code}). Iniciando login no portal...")
else:
if not quiet:
print("[!] Modo --force: enviando requisição de login...")
portal_url = discover_portal(iface)
if not portal_url:
# Se não detectou portal e o SSID não tem relação com a faculdade, ignora
if not force and not is_likely_college_network(ssid):
if not quiet:
print(f"[i] Rede conectada ('{ssid}') não possui portal captivo da faculdade detectado. Nenhuma ação necessária.")
return True
if not quiet:
print(f"[!] Não foi possível detectar a URL por redirecionamento. Tentando fallback: {DEFAULT_FALLBACK_PORTAL}")
portal_url = DEFAULT_FALLBACK_PORTAL
else:
if not quiet:
print(f"[✓] Portal detectado em: {portal_url}")
login_code = perform_login(portal_url, iface, quiet=quiet)
if not quiet:
print(f"[i] Resposta do login: HTTP {login_code}")
# Aguarda 1.5s para aplicação das regras no AP UniFi
time.sleep(1.5)
has_internet, code, _ = check_internet(iface)
if has_internet:
if not quiet:
print("[✓] Login efetuado com sucesso! Internet liberada.")
notify("Wi-Fi Conectado", f"Login efetuado com sucesso na rede {ssid or 'da faculdade'}!")
return True
else:
if not quiet:
print(f"[✗] Falha na liberação da internet após login (HTTP {code}).")
notify("Falha no Login Wi-Fi", f"Não foi possível liberar o acesso na rede {ssid or 'da faculdade'}.", icon="dialog-error")
return False
def run_daemon(iface, interval=60):
"""
Executa como serviço em background:
1. Monitora eventos do NetworkManager via 'nmcli monitor'
2. Executa checagem periódica a cada 'interval' segundos (caso a sessão expire)
"""
print(f"[+] Iniciando daemon de login automático (intervalo: {interval}s)...")
last_check_time = 0
COOLDOWN = 10 # Não tenta logar mais de 1x a cada 10s
# Testa imediatamente ao iniciar
active_iface = iface or get_active_wifi_interface()
attempt_login(active_iface, quiet=False)
last_check_time = time.time()
# Inicia o monitor do NetworkManager
proc = subprocess.Popen(
["nmcli", "monitor"],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True
)
try:
while True:
# Espera por eventos do nmcli ou até o timeout do intervalo periódico
r, _, _ = select.select([proc.stdout], [], [], interval)
now = time.time()
if r:
line = proc.stdout.readline()
if not line:
# Se o nmcli monitor fechou por algum motivo, reinicia
time.sleep(2)
proc = subprocess.Popen(["nmcli", "monitor"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True)
continue
line_str = line.strip()
# Se for evento de conectividade ou rede
if any(w in line_str.lower() for w in ["conectado", "conectividade", "connectivity", "connection", "portal"]):
if now - last_check_time >= COOLDOWN:
time.sleep(2) # Dá tempo do DHCP estabilizar
active_iface = iface or get_active_wifi_interface()
print(f"[*] Evento de rede detectado: {line_str}")
attempt_login(active_iface, quiet=False)
last_check_time = time.time()
else:
# Timeout periódico: checagem de saúde
if now - last_check_time >= interval:
active_iface = iface or get_active_wifi_interface()
has_internet, _, _ = check_internet(active_iface)
if not has_internet:
print("[*] Checagem periódica detectou perda de acesso. Efetuando login...")
attempt_login(active_iface, quiet=False)
last_check_time = time.time()
except KeyboardInterrupt:
print("\n[+] Encerrando daemon...")
finally:
try:
proc.terminate()
except Exception:
pass
def main():
parser = argparse.ArgumentParser(description="Auto-login no portal captivo UniFi (UNICEP).")
parser.add_argument("-i", "--interface", help="Interface de rede Wi-Fi (ex: wlan0)")
parser.add_argument("-f", "--force", action="store_true", help="Força o login mesmo se a internet parecer ativa")
parser.add_argument("-q", "--quiet", action="store_true", help="Modo silencioso (sem prints informativos)")
parser.add_argument("-d", "--daemon", action="store_true", help="Executa continuamente como serviço em background")
parser.add_argument("--interval", type=int, default=60, help="Intervalo de checagem periódica no modo daemon (padrão: 60s)")
args = parser.parse_args()
iface = args.interface or get_active_wifi_interface()
if args.daemon:
run_daemon(iface, interval=args.interval)
return 0
success = attempt_login(iface, force=args.force, quiet=args.quiet)
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())

13
unifi-portal.service Normal file
View file

@ -0,0 +1,13 @@
[Unit]
Description=Auto-Login Portal Captivo UniFi (Faculdade)
After=network.target
[Service]
Type=simple
Environment=PYTHONUNBUFFERED=1
ExecStart=%h/Documentos/loginAutomatico/login_portal.py --daemon
Restart=always
RestartSec=10
[Install]
WantedBy=default.target

24
uninstall.sh Executable file
View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -e
SERVICE_FILE="$HOME/.config/systemd/user/unifi-portal.service"
echo "==> Desinstalando unifi-portal.service..."
if systemctl --user is-active --quiet unifi-portal.service 2>/dev/null; then
echo "==> Parando o serviço..."
systemctl --user stop unifi-portal.service
fi
if systemctl --user is-enabled --quiet unifi-portal.service 2>/dev/null; then
echo "==> Desabilitando o serviço..."
systemctl --user disable unifi-portal.service
fi
if [ -f "$SERVICE_FILE" ]; then
rm -f "$SERVICE_FILE"
echo "==> Arquivo de serviço removido."
fi
systemctl --user daemon-reload
echo "✓ Desinstalação concluída."