diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..02e75135c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Repository Guidelines + +## Communication +- Always write responses in English, even if the prompt is in Portuguese or another language. + +## Project Structure & Module Organization +- `packages/`: OpenWrt package definitions and payload files. Runtime Lua code typically lives under `packages//files/usr/lib/lua/`, and package-level tests live under `packages//tests/`. +- Scope: always and only work with code under `packages/pirania/`; ignore all other items inside `packages/`. +- `tests/`: shared test utilities, fakes, and integration tests. +- `captive-portal-v0` … `captive-portal-v3`: captive portal assets by version. +- `Dockerfiles/`: container definitions (notably unit test image). +- `tools/`: helper scripts (e.g., `tools/dockertestshell`). +- `run_tests`: test runner script. +- `libremesh.mk`, `libremesh.sdk.config`: build/SDK integration files for OpenWrt. + +## Build, Test, and Development Commands +- `./run_tests`: run Lua unit tests inside Docker using `busted` and generate coverage. +- `LUA_ENABLE_LOGGING=1 ./run_tests`: enable verbose Lua logging during tests. +- `./tools/dockertestshell`: open a shell inside the test container to iterate quickly. +- Only run or modify workflows that target `packages/pirania/`. +- Image building is done via OpenWrt Buildroot/ImageBuilder; see `README.md` for full workflows. Example ImageBuilder usage (run inside the ImageBuilder directory): + ```sh + make image PROFILE= PACKAGES="lime-system lime-proto-babeld ..." FILES=files + ``` + +## Coding Style & Naming Conventions +- Preserve existing style within each file; there is no repo-wide formatter. +- Lua: follow current indentation in the file (tabs/spaces vary); keep module-level tables and `return` at end. +- Makefiles: use tabs for recipe lines (required by `make`). +- Shell: scripts such as `run_tests` use bash; avoid bashisms in `sh` scripts unless the file already uses bash. + +## Testing Guidelines +- Framework: `busted` with coverage via `luacov` (run by `./run_tests`). +- Test locations: + - Package tests: `packages//tests/test_*.lua` + - Shared/integration tests: `tests/test_*.lua` +- Focus tests on `packages/pirania/` only; do not add or modify tests for other packages. +- When code uses UCI, prefer `lime.config.get_uci_cursor()` and the helpers in `tests/utils` (see `TESTING.md`). + +## Commit & Pull Request Guidelines +- Branch from `master` and target `master` for PRs. Recommended branch format: `/` (e.g., `feature/new-ui`, `fix/bug-123`). +- Commit subjects in this repo are short and lowercase, often prefixed by area (`packages: ...`, `lime-system: ...`, or `fix(scope): ...`). Follow that pattern. +- PRs should include a clear description, reference issues when applicable, and mention test results (e.g., `./run_tests`). + +## Developer Notes +- Unit tests require Docker running and a non-root user; see `TESTING.md` for setup details. +- Changes and investigations should stay within `packages/pirania/` unless explicitly requested otherwise. +- For deeper context, consult `CONTRIBUTING.md`, `TESTING.md`, and `HACKING.md`. diff --git a/packages/pirania/ARQUITETURA.md b/packages/pirania/ARQUITETURA.md new file mode 100644 index 000000000..ae8ae2a46 --- /dev/null +++ b/packages/pirania/ARQUITETURA.md @@ -0,0 +1,852 @@ +# Pirania - Documentacao Tecnica Detalhada + +## Visao Geral + +O **Pirania** e um sistema de **Portal Cativo com Vouchers** para redes comunitarias que rodam sobre **OpenWrt**/**LibreMesh**. Ele permite controlar o acesso a internet atraves de: + +1. **Modo Voucher**: Usuarios precisam de um codigo (voucher) para acessar a internet +2. **Modo Read For Access**: Usuarios aguardam um tempo para ter acesso (sem voucher) + +--- + +## Glossario de Termos e Tecnologias + +> **Dica:** Termos tecnicos estao em **negrito** ao longo do documento. Consulte esta secao para ver as definicoes. + +### Linguagens de Programacao + +| Linguagem | Descricao | Uso no Pirania | +|-----------|-----------|----------------| +| **Lua** | Linguagem de script leve e rapida, muito usada em sistemas embarcados. Sintaxe simples, tipagem dinamica. | Backend principal: logica de vouchers, handlers CGI, API ubus | +| **Shell Script (sh)** | Linguagem de comandos do Unix/Linux para automacao. | Script `captive-portal` que configura firewall | +| **JavaScript (JS)** | Linguagem de programacao para web, roda no navegador. | Frontend: interacao com usuario, countdown, chamadas ubus | +| **HTML** | Linguagem de marcacao para estruturar paginas web. | Paginas do portal cativo | +| **CSS** | Linguagem de estilos para aparencia visual de paginas web. | Estilizacao das paginas do portal | +| **JSON** | Formato de dados texto, facil de ler e escrever. | Armazenamento de vouchers, configuracoes, comunicacao API | + +### Sistemas e Plataformas + +| Termo | Descricao | +|-------|-----------| +| **OpenWrt** | Sistema operacional Linux para roteadores. Leve, customizavel, base do LibreMesh. | +| **LibreMesh** | Firmware para redes mesh comunitarias, construido sobre OpenWrt. Permite criar redes descentralizadas. | +| **Roteador** | Dispositivo que conecta redes e direciona pacotes de dados entre elas. | +| **Firmware** | Software permanente gravado no hardware do roteador. | + +### Conceitos de Rede + +| Termo | Descricao | +|-------|-----------| +| **Portal Cativo** | Pagina web que aparece automaticamente quando voce conecta em uma rede WiFi, pedindo login ou aceitacao de termos. | +| **MAC Address** | Endereco fisico unico de cada placa de rede (ex: `AA:BB:CC:DD:EE:FF`). Usado para identificar dispositivos. | +| **IP Address** | Endereco logico de um dispositivo na rede (ex: `192.168.1.100` para IPv4, `fe80::1` para IPv6). | +| **IPv4** | Versao 4 do protocolo IP. Enderecos com 4 numeros (ex: `10.0.0.1`). | +| **IPv6** | Versao 6 do protocolo IP. Enderecos mais longos (ex: `fc00::1`). Suporta mais dispositivos. | +| **DNS** | Sistema que traduz nomes (ex: `google.com`) para IPs. Como uma "agenda telefonica" da internet. | +| **HTTP/HTTPS** | Protocolos para transferir paginas web. HTTPS e a versao segura (criptografada). | +| **Porta** | Numero que identifica um servico especifico (ex: porta 80 = HTTP, porta 443 = HTTPS). | +| **Firewall** | Sistema que filtra trafego de rede, permitindo ou bloqueando conexoes. | +| **Rede Mesh** | Rede onde cada no pode se conectar a varios outros, sem ponto central. Mais resiliente. | +| **ARP** | Protocolo que mapeia IPs para MACs na rede local. | +| **Bridge** | Conexao que une duas redes como se fossem uma so. | + +### Ferramentas e Servicos + +| Termo | Descricao | Uso no Pirania | +|-------|-----------|----------------| +| **nftables** | Framework moderno de firewall do Linux. Substitui iptables. | Captura e redireciona trafego de usuarios nao autorizados | +| **iptables** | Framework antigo de firewall do Linux (versao anterior usava). | Substituido por nftables | +| **dnsmasq** | Servidor DNS e DHCP leve. | `pirania-dnsmasq`: DNS que redireciona usuarios para o portal | +| **uhttpd** | Servidor HTTP leve para OpenWrt. | `pirania-uhttpd`: serve paginas do portal cativo | +| **ubus** | Sistema de comunicacao entre processos no OpenWrt. Como um "barramento" de mensagens. | API para LiMe-App e outros aplicativos | +| **rpcd** | Daemon que expoe funcoes via ubus. | Expoe API do pirania | +| **procd** | Sistema de init e gerenciamento de processos do OpenWrt. | Gerencia servicos pirania-dnsmasq e pirania-uhttpd | +| **cron** | Agendador de tarefas periodicas no Linux. | Atualiza MACs autorizados a cada 10 minutos | +| **shared-state** | Sistema LibreMesh para sincronizar dados entre nos da rede mesh. | Sincroniza vouchers entre todos os roteadores | + +### Estrutura de Arquivos Linux/OpenWrt + +| Caminho | Descricao | +|---------|-----------| +| `/etc/config/` | Arquivos de configuracao UCI (formato especifico do OpenWrt) | +| `/etc/init.d/` | Scripts de inicializacao de servicos. Controlam start/stop de programas. | +| `/etc/uci-defaults/` | Scripts executados uma vez apos instalacao de pacote. | +| `/usr/bin/` | Programas executaveis disponiveis para todos usuarios. | +| `/usr/lib/lua/` | Bibliotecas Lua compartilhadas. | +| `/usr/libexec/rpcd/` | Scripts que expoe funcoes via ubus/rpcd. | +| `/usr/share/rpcd/acl.d/` | Arquivos de controle de acesso para API ubus. | +| `/www/` | Arquivos servidos pelo servidor web (paginas HTML, CSS, JS). | +| `/www/cgi-bin/` | Scripts CGI executados pelo servidor web. | +| `/tmp/` | Arquivos temporarios (perdidos ao reiniciar). | + +### Conceitos de Programacao + +| Termo | Descricao | +|-------|-----------| +| **CGI** | Common Gateway Interface. Forma de executar scripts no servidor quando uma URL e acessada. | +| **API** | Interface de Programacao. Forma padronizada de um programa se comunicar com outro. | +| **Handler** | Funcao que "trata" ou "responde" a um evento ou requisicao. | +| **Callback** | Funcao passada como parametro para ser executada depois. | +| **Hook** | Ponto onde codigo externo pode ser executado. Permite extensibilidade. | +| **Modulo** | Arquivo de codigo que pode ser importado e reutilizado. | +| **Closure** | Funcao que "lembra" variaveis do contexto onde foi criada. | +| **JSON-RPC** | Protocolo para chamar funcoes remotamente usando JSON. | +| **Redirect 302** | Resposta HTTP que diz ao navegador para ir para outra URL. | +| **Query String** | Parte da URL apos `?` com parametros (ex: `?voucher=ABC123`). | +| **Timestamp** | Numero que representa data/hora (segundos desde 1970). | + +### Termos Especificos do Pirania + +| Termo | Descricao | +|-------|-----------| +| **Voucher** | Codigo que da acesso a internet por tempo limitado. | +| **Voucher ID** | Identificador interno unico do voucher (6 caracteres). | +| **Voucher Code** | Codigo secreto que o usuario digita para ativar (6 caracteres). | +| **Ativacao** | Momento em que um voucher e associado a um MAC e comeca a contar tempo. | +| **Invalidacao** | Cancelamento de um voucher (soft delete - mantem registro). | +| **Pruning** | Limpeza automatica de vouchers muito antigos do banco de dados. | +| **Allowlist** | Lista de IPs que sempre tem acesso (redes locais, mesh). | +| **Read For Access** | Modo sem voucher: usuario aguarda X segundos e ganha acesso temporario. | +| **thisnode.info** | Dominio especial que sempre aponta para o roteador local. | +| **anygw** | IP do gateway compartilhado na rede mesh LibreMesh. | + +### Siglas + +| Sigla | Significado | +|-------|-------------| +| **UCI** | Unified Configuration Interface - sistema de config do OpenWrt | +| **ACL** | Access Control List - lista de controle de acesso | +| **DHCP** | Dynamic Host Configuration Protocol - atribui IPs automaticamente | +| **NAT** | Network Address Translation - traduz IPs privados para publicos | +| **TCP** | Transmission Control Protocol - protocolo de transporte confiavel | +| **UDP** | User Datagram Protocol - protocolo de transporte rapido | +| **RFC** | Request for Comments - documentos que definem padroes da internet | + +--- + +## Arquitetura de Alto Nivel + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PIRANIA - ARQUITETURA │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────────────────┐ │ +│ │ Cliente │────▶│ nftables │────▶│ Captive Portal (porta 59080)│ │ +│ │ (Browser) │ │ (filtro) │ │ pirania-uhttpd │ │ +│ └─────────────┘ └──────────────┘ └──────────────┬──────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ /www/pirania-redirect/redirect │ │ +│ │ (Redireciona para portal de autenticacao) │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────┼────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────┐ ┌───────────────────┐ ┌────────────────────┐ │ +│ │ /portal/auth.html │ │/portal/info.html │ │/portal/read_for_ │ │ +│ │ (Form voucher) │ │ (Info comunidade)│ │ access.html │ │ +│ └──────────┬───────────┘ └─────────┬─────────┘ └────────┬───────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ CGI Scripts (/cgi-bin/pirania/) │ │ +│ │ preactivate_voucher │ activate_voucher │ authorize_mac │ client_ip │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ Bibliotecas Lua (/usr/lib/lua/) │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌────────────────┐ ┌──────────┐ │ │ +│ │ │ voucher/ │ │ portal/ │ │read_for_access/│ │ ubus API │ │ │ +│ │ │ vouchera │ │ portal │ │read_for_access │ │ pirania │ │ │ +│ │ │ store │ │ │ │ cgi_handlers │ │ │ │ │ +│ │ │ config │ └─────────────┘ └────────────────┘ └──────────┘ │ │ +│ │ │ utils │ │ │ +│ │ │ hooks │ │ │ +│ │ │ functools │ │ │ +│ │ │cgi_handlers │ │ │ +│ │ └─────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ Banco de Dados (JSON files) │ │ +│ │ /etc/pirania/vouchers/*.json │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────────────┐ │ +│ │ shared-state-pirania │ │ +│ │ (Sincronizacao entre nos da rede mesh) │ │ +│ └──────────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Componentes Macro + +### 1. Captura de Trafego (`/usr/bin/captive-portal`) + +Script **Shell** que configura regras **nftables** para interceptar trafego. + +**Dependencias:** `nftables`, `liblucihttp0`, `liblucihttp-lua`, `uhttpd`, `uhttpd-mod-lua`, `uhttpd-mod-ubus` + +**Funcionalidades:** +- `start`: Inicia servicos DNS/HTTP, limpa tabelas e configura regras nftables +- `stop/clean`: Remove tabela nftables do pirania +- `update`: Atualiza sets com MACs autorizados +- `enabled`: Habilita pirania na config UCI + +**Sets nftables criados (tabela `inet pirania`):** +- `pirania-auth-macs`: MACs autorizados (type ether_addr) +- `pirania-allowlist-ipv4`: IPs IPv4 permitidos sem autenticacao (type ipv4_addr, flags interval) +- `pirania-allowlist-ipv6`: IPs IPv6 permitidos sem autenticacao (type ipv6_addr, flags interval) + +**Chains nftables:** +- `prerouting`: type nat hook prerouting priority 0 +- `input`: type filter hook input priority 0 +- `forward`: type filter hook forward priority 0 + +**Regras principais (chain prerouting):** +1. MACs autorizados (`@pirania-auth-macs`) → ACCEPT +2. Destino em allowlist IPv4 (`@pirania-allowlist-ipv4`) → ACCEPT +3. Destino em allowlist IPv6 (`@pirania-allowlist-ipv6`) → ACCEPT +4. DNS (porta 53) de MACs nao autorizados → redirect para porta 59053 +5. HTTP (porta 80) de MACs nao autorizados → redirect para porta 59080 +6. HTTPS (porta 443) de MACs nao autorizados → DROP + +### 2. Servidor HTTP do Portal (`pirania-uhttpd`) + +**Init script:** `/etc/init.d/pirania-uhttpd` + +Inicia um servidor **uhttpd** na porta **59080** que responde qualquer requisicao com um **redirecionamento 302** para o portal de autenticacao. + +**Comando:** `uhttpd -k 0 -f -h /www/pirania-redirect/ -E / -l / -L /www/pirania-redirect/redirect -n 20 -p 59080` + +### 3. Servidor DNS do Portal (`pirania-dnsmasq`) + +**Init script:** `/etc/init.d/pirania-dnsmasq` + +Inicia um **dnsmasq** na porta **59053** que: +- Resolve `thisnode.info` para o IP do no (**anygw**) +- Resolve outros dominios para `1.2.3.4` (forcando redirecionamento) + +### 4. Sistema de Vouchers (Lua) + +Gerencia criacao, ativacao, desativacao e validacao de vouchers. + +### 5. Sistema Read For Access (Lua) + +Alternativa ao voucher - autoriza MAC por tempo apos aguardar periodo. + +### 6. API ubus/RPCD + +Expoe funcionalidades via **ubus** para integracao com LiMe-App. + +### 7. Sincronizacao entre Nos (`shared-state-pirania`) + +Sincroniza banco de vouchers entre todos os nos da rede mesh usando **shared-state**. + +--- + +## Estrutura de Arquivos Detalhada + +### `/packages/pirania/` + +``` +pirania/ +├── Makefile # Build do pacote OpenWrt +├── Readme.md # Documentacao em ingles +├── Leeme.md # Documentacao em espanhol +├── PIRANIA_SYSTEM.md # Documentacao tecnica (EN) +├── PIRANIA_SYSTEM.pt.md # Documentacao tecnica (PT) +├── PIRANIA_FLUXO_SIMPLIFICADO.pt.md # Fluxo simplificado (PT) +│ +├── files/ +│ ├── etc/ +│ │ ├── config/ +│ │ │ └── pirania # Configuracao UCI principal +│ │ │ +│ │ ├── init.d/ +│ │ │ ├── pirania # Init script principal +│ │ │ ├── pirania-dnsmasq # Init do DNS captivo +│ │ │ └── pirania-uhttpd # Init do HTTP captivo +│ │ │ +│ │ ├── pirania/ +│ │ │ └── portal.json # Conteudo default do portal (logo, texto) +│ │ │ +│ │ └── uci-defaults/ +│ │ └── 90-captive-portal-cron # Configura cron para update +│ │ +│ ├── usr/ +│ │ ├── bin/ +│ │ │ ├── captive-portal # Script nftables (shell) +│ │ │ ├── voucher # CLI para vouchers (lua) +│ │ │ └── pirania_authorized_macs # Lista MACs autorizados (lua) +│ │ │ +│ │ ├── lib/lua/ +│ │ │ ├── voucher/ # Modulo principal de vouchers +│ │ │ │ ├── vouchera.lua # Logica principal de vouchers +│ │ │ │ ├── store.lua # Persistencia (JSON files) +│ │ │ │ ├── config.lua # Carrega config UCI +│ │ │ │ ├── utils.lua # Utilitarios (IP, MAC, URL) +│ │ │ │ ├── functools.lua # Funcoes funcionais (map, filter) +│ │ │ │ ├── hooks.lua # Sistema de hooks +│ │ │ │ └── cgi_handlers.lua # Handlers para CGI +│ │ │ │ +│ │ │ ├── portal/ +│ │ │ │ └── portal.lua # Configuracao e estado do portal +│ │ │ │ +│ │ │ └── read_for_access/ +│ │ │ ├── read_for_access.lua # Logica de acesso sem voucher +│ │ │ └── cgi_handlers.lua # Handlers CGI para read_for_access +│ │ │ +│ │ ├── libexec/rpcd/ +│ │ │ └── pirania # API ubus/RPCD +│ │ │ +│ │ └── share/rpcd/acl.d/ +│ │ └── pirania.json # ACL para API ubus +│ │ +│ └── www/ +│ ├── pirania-redirect/ +│ │ └── redirect # Script lua de redirecionamento +│ │ +│ ├── cgi-bin/pirania/ +│ │ ├── preactivate_voucher # Pre-ativacao de voucher +│ │ ├── activate_voucher # Ativacao de voucher +│ │ ├── authorize_mac # Autorizacao MAC (read_for_access) +│ │ └── client_ip # Retorna IP/MAC do cliente +│ │ +│ └── portal/ +│ ├── auth.html # Pagina de entrada de voucher +│ ├── info.html # Pagina de informacao da comunidade +│ ├── read_for_access.html # Pagina para modo sem voucher +│ ├── authenticated.html # Pagina de sucesso +│ ├── fail.html # Pagina de erro +│ ├── css/ +│ │ ├── main.css +│ │ ├── normalize.css +│ │ └── loader2.css +│ └── js/ +│ ├── content.js # Carrega conteudo do portal +│ ├── int.js # Internacionalizacao +│ └── ubusFetch.js # Cliente ubus para frontend +│ +└── tests/ + ├── pirania_test_utils.lua + ├── test_vouchera.lua + ├── test_cgi_handlers.lua + ├── test_pirania_rpcd.lua + ├── test_portal.lua + ├── test_read_for_access.lua + └── test_redirect.lua +``` + +### `/packages/shared-state-pirania/` + +``` +shared-state-pirania/ +├── Makefile +└── files/ + └── etc/ + ├── pirania/hooks/ + │ ├── db_change/ + │ │ ├── 01-publish_db # Publica no shared-state + │ │ └── 02-sync_db # Sincroniza com outros nos + │ └── start/ + │ └── 01-publish_and_sync # Na inicializacao + │ + ├── shared-state/ + │ ├── hooks/pirania-vouchers/ + │ │ └── generate_vouchers # Importa vouchers do shared-state + │ └── publishers/ + │ └── shared-state-publish_vouchers # Exporta vouchers + │ + └── uci-defaults/ + └── 90-pirania-cron +``` + +--- + +## Analise Detalhada por Arquivo + +### `/usr/bin/captive-portal` (Shell Script - nftables) + +**Proposito:** Configura regras nftables para captura de trafego. + +**Funcoes:** + +| Funcao | Descricao | +|--------|-----------| +| `clean_tables()` | Remove tabela `inet pirania` se existir | +| `set_nftables()` | Cria tabela, chains e regras de captura | +| `update_ipsets()` | Atualiza sets com MACs autorizados e IPs da allowlist | + +**Fluxo de `set_nftables()`:** +``` +1. Cria tabela: nft create table inet pirania +2. Cria chains: prerouting (nat), input (filter), forward (filter) +3. Cria sets: pirania-auth-macs, pirania-allowlist-ipv4, pirania-allowlist-ipv6 +4. Adiciona regras de ACCEPT para MACs/IPs autorizados +5. Adiciona regras de redirect para DNS (59053) e HTTP (59080) +6. Adiciona regra de DROP para HTTPS (443) +``` + +**Fluxo de `update_ipsets()`:** +``` +1. Para cada MAC de pirania_authorized_macs: + nft add element inet pirania pirania-auth-macs {$mac} +2. Flush e repopula allowlist IPv4 e IPv6 da config UCI +``` + +**Comandos disponiveis:** +- `captive-portal start` - Inicia servicos + configura nftables +- `captive-portal stop/clean` - Remove tabela nftables +- `captive-portal update` - Atualiza MACs autorizados +- `captive-portal enabled` - Habilita na config UCI + +--- + +### Bibliotecas Lua + +#### `/usr/lib/lua/voucher/vouchera.lua` + +**Proposito:** Modulo principal para gerenciamento de vouchers. + +**Imports:** +```lua +local store = require('voucher.store') +local config = require('voucher.config') +local utils = require('lime.utils') +local portal = require('portal.portal') +local hooks = require('voucher.hooks') +``` + +**Constantes:** +- `vouchera.ID_SIZE = 6` - Tamanho do ID do voucher +- `vouchera.CODE_SIZE = 6` - Tamanho do codigo secreto + +**Estrutura do Voucher:** +```lua +voucher = { + id, -- Identificador unico (6 chars) + name, -- Nome descritivo + code, -- Codigo secreto para ativacao + mac, -- MAC do dispositivo (nil se nao ativado) + duration_m, -- Duracao em minutos (nil = permanente) + mod_counter, -- Contador de modificacoes + creation_date, -- Timestamp de criacao + activation_date, -- Timestamp de ativacao + activation_deadline, -- Prazo limite para ativacao + invalidation_date, -- Timestamp de invalidacao + author_node -- Hostname do no criador +} +``` + +**Metodos do Voucher (closure):** +- `tostring()` - Representacao string do voucher +- `expiration_date()` - Calcula data de expiracao +- `is_active()` - Verifica se esta ativo +- `is_invalidated()` - Verifica se foi invalidado +- `is_expired()` - Verifica se expirou +- `is_activable()` - Verifica se pode ser ativado +- `status()` - Retorna status: 'available', 'active', 'expired', 'invalidated' + +**Funcoes Principais:** + +| Funcao | Descricao | Retorno | +|--------|-----------|---------| +| `init(cfg)` | Inicializa modulo, carrega DB, faz pruning automatico | void | +| `add(obj)` | Adiciona novo voucher | voucher ou nil, errmsg | +| `create(basename, qty, duration_m, deadline)` | Cria multiplos vouchers | lista de {id, code} | +| `get_by_id(id)` | Busca voucher por ID | voucher ou nil | +| `activate(code, mac)` | Ativa voucher com MAC | voucher ou false | +| `deactivate(id)` | Desativa voucher | voucher | +| `invalidate(id)` | Invalida voucher (soft delete) | voucher | +| `remove_locally(id)` | Remove do DB local | true ou nil, errmsg | +| `is_mac_authorized(mac)` | Verifica se MAC esta autorizado | boolean | +| `is_activable(code)` | Verifica se codigo e ativavel | voucher ou false | +| `should_be_pruned(voucher)` | Verifica se deve ser removido | boolean | +| `rename(id, new_name)` | Renomeia voucher | voucher | +| `list()` | Lista todos vouchers formatados | lista de vouchers | +| `get_authorized_macs()` | Lista MACs autorizados | lista de MACs | +| `gen_code()` | Gera codigo aleatorio | string (6 chars uppercase) | + +--- + +#### `/usr/lib/lua/voucher/store.lua` + +**Proposito:** Persistencia de vouchers em arquivos JSON. + +**Imports:** +```lua +local fs = require("nixio.fs") +local json = require("luci.jsonc") +local hooks = require('voucher.hooks') +local utils = require("voucher.utils") +``` + +**Funcoes:** + +| Funcao | Descricao | +|--------|-----------| +| `load_db(db_path, voucher_init)` | Carrega todos `.json` do diretorio | +| `add_voucher(db_path, voucher, voucher_init)` | Salva voucher em arquivo JSON | +| `remove_voucher(db_path, voucher)` | Remove arquivo e registra em removed.txt | + +**Formato do arquivo:** `{db_path}/{voucher_id}.json` + +--- + +#### `/usr/lib/lua/voucher/config.lua` + +**Proposito:** Carrega configuracoes do UCI. + +**Configuracoes carregadas:** +```lua +config = { + db_path, -- Caminho da base de vouchers + hooksDir, -- Diretorio de hooks + prune_expired_for_days -- Dias para manter vouchers expirados +} +``` + +--- + +#### `/usr/lib/lua/voucher/utils.lua` + +**Proposito:** Utilitarios para manipulacao de IPs, MACs e URLs. + +**Imports:** +```lua +local nixio = require('nixio') +local lhttp = require('lucihttp') +``` + +**Funcoes:** + +| Funcao | Descricao | +|--------|-----------| +| `log(...)` | Wrapper para syslog | +| `getIpv4AndMac(ip_address)` | Obtem IPv4 e MAC de um IP (via ARP ou ip neigh) | +| `urldecode_params(url, tbl)` | Parseia query string | +| `urlencode(value)` | Codifica URL | +| `urldecode(value)` | Decodifica URL | + +**Nota:** Para IPv6, usa `ip neigh` em vez de `ip neighbor` (corrigido nesta branch). + +--- + +#### `/usr/lib/lua/voucher/functools.lua` + +**Proposito:** Funcoes utilitarias de programacao funcional. + +**Funcoes:** + +| Funcao | Descricao | +|--------|-----------| +| `curry(func, num_args)` | Currying de funcoes | +| `reverse(...)` | Inverte ordem de argumentos | +| `map(func, tbl)` | Map sobre tabela | +| `filter(func, tbl)` | Filtra tabela | +| `search(func, tbl)` | Busca em tabela | + +--- + +#### `/usr/lib/lua/voucher/hooks.lua` + +**Proposito:** Sistema de **hooks** para eventos. + +**Eventos disponiveis:** +- `db_change` - Quando a base de vouchers muda +- `start` - Quando o pirania inicia +- `stop` - Quando o pirania para + +**Funcionamento:** Executa todos os scripts em `{hooksDir}/{action}/` em background. + +--- + +#### `/usr/lib/lua/voucher/cgi_handlers.lua` + +**Proposito:** Handlers para requisicoes CGI de vouchers. + +**Imports:** +```lua +local vouchera = require('voucher.vouchera') +local utils = require('voucher.utils') +``` + +**Funcoes:** + +| Funcao | Descricao | +|--------|-----------| +| `preactivate_voucher()` | Pre-ativacao: valida codigo, redireciona para info ou ativa | +| `activate_voucher()` | Ativacao final: associa MAC ao voucher | + +--- + +#### `/usr/lib/lua/portal/portal.lua` + +**Proposito:** Configuracao e estado do portal cativo. + +**Imports:** +```lua +local utils = require('lime.utils') +local config = require('lime.config') +local shared_state = require("shared-state") +local read_for_access = require("read_for_access.read_for_access") +``` + +**Funcoes:** + +| Funcao | Descricao | +|--------|-----------| +| `get_config()` | Retorna {activated, with_vouchers} | +| `set_config(activated, with_vouchers)` | Configura e inicia/para captive-portal | +| `get_page_content()` | Obtem conteudo do portal (shared-state ou local) | +| `set_page_content(...)` | Define conteudo do portal (via shared-state) | +| `get_authorized_macs()` | Lista MACs autorizados (voucher ou read_for_access) | +| `update_captive_portal(daemonized)` | Atualiza nftables | + +**Nota:** O `update_captive_portal` redireciona stdout/stderr para `/dev/null` para evitar erro 502 Bad Gateway (corrigido nesta branch). + +--- + +#### `/usr/lib/lua/read_for_access/read_for_access.lua` + +**Proposito:** Modo de acesso sem voucher (**Read For Access**). + +**Arquivo de MACs:** `/tmp/pirania/read_for_access/auth_macs` + +**Formato:** `MAC TIMESTAMP_EXPIRACAO` + +**Funcoes:** + +| Funcao | Descricao | +|--------|-----------| +| `set_workdir(workdir)` | Define diretorio de trabalho | +| `authorize_mac(mac)` | Autoriza MAC por tempo definido em config | +| `get_authorized_macs()` | Lista MACs nao expirados | + +**Nota:** Tambem redireciona stdout/stderr para `/dev/null` no `captive-portal update` (corrigido nesta branch). + +--- + +### Configuracao UCI + +#### `/etc/config/pirania` + +``` +config base_config 'base_config' + option enabled '0' # Portal ativo + option prune_expired_for_days '30' # Dias para manter expirados + option portal_domain 'thisnode.info' # Dominio do portal + option url_auth '/portal/auth.html' # Pagina de autenticacao + option url_authenticated '/portal/authenticated.html' + option url_info '/portal/info.html' + option url_fail '/portal/fail.html' + option db_path '/etc/pirania/vouchers/' + option hooks_path '/etc/pirania/hooks/' + option append_nft_rules '0' # (nao usado atualmente) + option with_vouchers '0' # Modo voucher ativo + list allowlist_ipv4 '10.0.0.0/8' # IPs sempre permitidos + list allowlist_ipv4 '172.16.0.0/12' + list allowlist_ipv4 '192.168.0.0/16' + list allowlist_ipv6 'fc00::/7' + list allowlist_ipv6 'fe80::/64' + list allowlist_ipv6 '2a00:1508:0a00::/40' + list catch_interfaces 'br-lan' # Interfaces a capturar + list catch_bridged_interfaces 'wlan0-ap' + +config access_mode 'read_for_access' + option url_portal '/portal/read_for_access.html' + option duration_m '15' # Duracao do acesso em minutos +``` + +--- + +### API ubus/RPCD + +**Arquivo:** `/usr/libexec/rpcd/pirania` + +**Metodos disponiveis:** + +| Metodo | Parametros | Descricao | +|--------|------------|-----------| +| `get_portal_config` | - | Retorna configuracao do portal | +| `set_portal_config` | activated, with_vouchers | Configura portal | +| `show_url` | - | Retorna URL do portal | +| `change_url` | url | Altera URL do portal | +| `add_vouchers` | name, qty, duration_m, deadline, permanent | Cria vouchers | +| `list_vouchers` | - | Lista vouchers | +| `rename` | id, name | Renomeia voucher | +| `invalidate` | id | Invalida voucher | +| `get_portal_page_content` | - | Obtem conteudo do portal | +| `set_portal_page_content` | title, main_text, logo, link_title, link_url, background_color | Define conteudo | + +**ACL:** `/usr/share/rpcd/acl.d/pirania.json` +- `unauthenticated`: acesso a `show_url`, `get_portal_page_content` +- `lime-app`: acesso a `show_url`, `get_portal_config`, `get_portal_page_content` +- `root`: acesso total + +--- + +## Fluxos de Uso + +### Fluxo: Usuario com Voucher (com JavaScript) + +``` +1. Usuario conecta no WiFi +2. Tenta acessar http://example.com +3. nftables redireciona para porta 59080 +4. pirania-uhttpd roda /www/pirania-redirect/redirect +5. Redirect 302 para http://thisnode.info/portal/auth.html?prev=... +6. Usuario entra codigo do voucher +7. Form envia GET para /cgi-bin/pirania/preactivate_voucher?voucher=CODE&nojs=false +8. preactivate_voucher valida codigo +9. Se valido: Redirect 302 para /portal/info.html?voucher=CODE +10. info.html mostra informacoes e countdown de 15s +11. Apos countdown, form envia para /cgi-bin/pirania/activate_voucher +12. activate_voucher: + - Obtem MAC do cliente via ARP + - Chama vouchera.activate(code, mac) + - Atualiza nftables (captive-portal update) + - Dispara hook db_change (sincroniza com rede) +13. Redirect para URL original ou /portal/authenticated.html +``` + +> **Termos:** nftables | uhttpd | Redirect 302 | thisnode.info | CGI | MAC | ARP | hook + +### Fluxo: Usuario sem Voucher (Read For Access) + +``` +1-5. Mesmo que acima, mas redirect vai para /portal/read_for_access.html +6. Pagina mostra countdown de 15s +7. Apos countdown, form envia para /cgi-bin/pirania/authorize_mac +8. authorize_mac: + - Obtem MAC do cliente + - Salva em /tmp/pirania/read_for_access/auth_macs + - Atualiza nftables (captive-portal update) +9. Redirect para URL original +10. Apos duration_m (15min por padrao), MAC e removido da lista +``` + +> **Termos:** MAC | nftables | CGI + +--- + +## Mapa de Dependencias + +``` +vouchera.lua +├── voucher/store.lua +│ ├── nixio.fs +│ ├── luci.jsonc +│ ├── voucher/hooks.lua +│ └── voucher/utils.lua +├── voucher/config.lua +│ └── uci +├── lime.utils +├── portal/portal.lua +│ ├── lime.utils +│ ├── lime.config +│ ├── shared-state +│ └── read_for_access/read_for_access.lua +└── voucher/hooks.lua + ├── voucher/config.lua + └── nixio.fs + +voucher/cgi_handlers.lua +├── voucher/vouchera.lua +└── voucher/utils.lua + ├── nixio + └── lucihttp + +read_for_access/cgi_handlers.lua +├── voucher/utils.lua +├── read_for_access/read_for_access.lua +├── portal/portal.lua +└── lime.config + +pirania (rpcd) +├── ubus +├── luci.jsonc +├── uci +├── voucher/vouchera.lua +├── lime.utils +├── lime.config +└── portal/portal.lua +``` + +--- + +## Diferencas da Versao Anterior (iptables → nftables) + +| Aspecto | Versao Antiga (iptables) | Versao Nova (nftables) | +|---------|--------------------------|------------------------| +| Firewall | iptables, ip6tables, ebtables | nftables | +| Sets | ipset | nft sets nativos | +| Tabelas | Multiplas (mangle, nat, filter) | Tabela unica `inet pirania` | +| HTTPS | Rejeitado | Dropped (bloqueado) | +| Dependencias | ip6tables-mod-nat, ipset | nftables | +| Init start | Nao iniciava servicos | Inicia pirania-dnsmasq e pirania-uhttpd | +| Bug 502 | Presente | Corrigido (redirect stdout/stderr) | + +--- + +## Resumo das Portas + +| Porta | Servico | Descricao | +|-------|---------|-----------| +| 59053 | DNS (dnsmasq) | DNS que resolve tudo para portal | +| 59080 | HTTP (uhttpd) | Redireciona para pagina de autenticacao | +| 80 | HTTP (uhttpd principal) | Serve paginas do portal | +| 443 | HTTPS | Bloqueado para MACs nao autorizados | + +--- + +## Dependencias do Pacote (Makefile) + +```makefile +DEPENDS:=+nftables +shared-state +shared-state-pirania \ + +uhttpd-mod-lua +lime-system +luci-lib-jsonc \ + +liblucihttp-lua +luci-lib-nixio +libubus-lua +libuci-lua +``` + +**Nota:** A versao nftables depende do pacote `nftables`; `ip6tables-mod-nat` e `ipset` nao sao mais necessarios. + +--- + +## Testes + +Os testes estao em `/packages/pirania/tests/` e usam **busted** (framework de testes Lua). + +| Arquivo | Cobertura | +|---------|-----------| +| `test_vouchera.lua` | Criacao, ativacao, invalidacao, pruning de vouchers | +| `test_cgi_handlers.lua` | Handlers de voucher CGI | +| `test_pirania_rpcd.lua` | API ubus | +| `test_portal.lua` | Configuracao do portal | +| `test_read_for_access.lua` | Modo sem voucher | +| `test_redirect.lua` | Redirecionamento HTTP | + +--- + +## Possiveis Melhorias para o Hackathon + +1. **Remover dependencias antigas** - `ip6tables-mod-nat` e `ipset` nao sao mais necessarios +2. **Melhor UX mobile** - Paginas do portal nao sao totalmente responsivas +3. **Suporte a HTTPS** - Atualmente so HTTP (HTTPS e bloqueado) +4. **Dashboard de administracao** - Interface web para gerenciar vouchers +5. **Metricas de uso** - Logs de ativacoes, tempo de uso +6. **Vouchers QR Code** - Gerar QR codes para vouchers +7. **Notificacoes** - Avisar quando voucher esta prestes a expirar +8. **Rate limiting** - Limitar velocidade por voucher (usando nftables meters) +9. **Integracao com pagamento** - Venda automatica de vouchers +10. **Captive portal detection** - Melhor suporte a deteccao automatica (RFC 8910) +11. **Usar `nft flush set`** - Para MACs tambem, em vez de so adicionar +12. **Filtro por interface** - `catch_bridged_interfaces` configurado mas nao usado nas regras diff --git a/packages/pirania/IMPLEMENTATION.md b/packages/pirania/IMPLEMENTATION.md new file mode 100644 index 000000000..7e4c8e158 --- /dev/null +++ b/packages/pirania/IMPLEMENTATION.md @@ -0,0 +1,186 @@ +# IMPLEMENTATION - itens faltando ou quebrados + +Este documento responde: **quais itens precisam ser implementados para o Pirania funcionar como esperado** e detalha o que falta em cada um. + +Resumo rapido: + +- **Para o portal funcionar no basico (voucher e/ou ler-para-acesso):** nada aqui e obrigatorio. +- **Para cumprir o que a configuracao promete ou para compatibilidade com integracoes antigas:** os itens abaixo precisam de implementacao ou ajuste. + +--- + +## 1) Regras por interface (catch_interfaces / catch_bridged_interfaces) + +**Status atual:** configuracao existe, mas nao e aplicada nas regras nftables. + +**Onde esta o problema:** + +- `packages/pirania/files/etc/config/pirania` define `catch_interfaces` e `catch_bridged_interfaces`. +- `packages/pirania/files/usr/bin/captive-portal` le `catch_bridged_interfaces` mas nao usa no nftables. + +**Impacto:** + +- O portal captura trafego de todas as interfaces, mesmo quando o UCI define quais interfaces deveriam ser capturadas. +- Quem configura isso espera limitar a captura apenas para interfaces especificas. + +**O que implementar:** + +Opcoes possiveis (escolher uma abordagem consistente): + +1) **Aplicar filtro diretamente nas regras** (mais simples): + - Criar um set `pirania-catch-ifaces` (tipo `ifname`). + - Carregar interfaces de `catch_interfaces` e/ou `catch_bridged_interfaces` no set. + - Adicionar `iifname @pirania-catch-ifaces` nas regras de redirect (DNS/HTTP/HTTPS). + +2) **Separar o fluxo com `jump`** (mais limpo para manter): + - Criar um chain principal e um chain `pirania_capture`. + - No chain principal, fazer `jump` para `pirania_capture` apenas se `iifname` estiver no set. + - Colocar as regras DNS/HTTP/HTTPS no chain `pirania_capture`. + +**Decisao de semantica:** + +- Definir se `catch_interfaces` e `catch_bridged_interfaces` somam ou se um sobrescreve o outro. +- Documentar claramente no README e no UCI. + +**Criterio de pronto (aceitacao):** + +- Se a interface nao estiver na lista, o trafego nao passa pelas regras do Pirania. +- Se estiver, o comportamento continua o mesmo de hoje. + +--- + +## 2) Opcao append_nft_rules + +**Status atual:** opcao existe, mas a logica esta comentada e o comportamento nao muda. + +**Onde esta o problema:** + +- `packages/pirania/files/etc/config/pirania` tem `append_nft_rules`. +- `packages/pirania/files/usr/bin/captive-portal` tem codigo comentado para decidir entre add/insert. +- O script sempre recria a tabela, entao **append vs insert nao faz diferenca**. + +**Impacto:** + +- Quem tenta usar `append_nft_rules` nao ve efeito algum. +- A opcao passa a ser enganosa. + +**O que implementar (duas possibilidades):** + +A) **Remover a opcao** (se ela nao faz sentido no desenho atual). + +B) **Tornar a opcao funcional** (mais trabalho): + +- Parar de apagar a tabela inteira a cada start/update. +- Se `append_nft_rules=0`, inserir regras no inicio do chain (ex.: `nft insert rule ... position 0`). +- Se `append_nft_rules=1`, adicionar regras no fim (`nft add rule ...`). +- Garantir que as regras de Pirania nao sejam duplicadas (verificar antes de inserir). + +**Criterio de pronto (aceitacao):** + +- Ao alternar a opcao, a ordem de regras muda e o efeito e observavel. +- Documentacao atualizada explicando quando usar. + +--- + +## 3) portal_url via ubus/rpcd + +**Status atual:** API existe, mas a configuracao `portal_url` nao aparece no UCI e nao e usada pelo fluxo real. + +**Onde esta o problema:** + +- `packages/pirania/files/usr/libexec/rpcd/pirania` expoe `show_url` e `change_url` para `pirania.base_config.portal_url`. +- Essa chave nao existe em `packages/pirania/files/etc/config/pirania`. +- O redirecionamento real usa `portal_domain` + `url_auth`/`url_portal`. + +**Impacto:** + +- Ferramentas que usam `show_url/change_url` nao conseguem alterar o comportamento real do portal. + +**O que implementar (escolher um caminho):** + +A) **Remover a API legacy** (se nao e usada). + +B) **Reativar `portal_url`** (compatibilidade retro): + +- Adicionar `option portal_url` no UCI. +- Atualizar `packages/pirania/files/www/pirania-redirect/redirect` para usar `portal_url` quando definido. +- Definir prioridade clara: `portal_url` sobrescreve `portal_domain + url_*` ou apenas complementa. + +C) **Migrar API para o modelo atual**: + +- Substituir `show_url/change_url` por getters/setters de `portal_domain` + `url_auth`/`url_portal`. +- Manter nomes antigos mas implementar dentro da nova logica, se houver dependencia de compatibilidade. + +**Criterio de pronto (aceitacao):** + +- Alterar via ubus reflete no redirect do portal. +- UCI e README documentam o comportamento final. + +--- + +## 4) CGI `client_ip` legado + +**Status atual:** endpoint referenciado no pacote, mas depende de modulos que nao existem. + +**Onde esta o problema:** + +- `packages/pirania/files/www/cgi-bin/pirania/client_ip` usa `voucher.logic` e `voucher.db`, que nao existem. + +**Impacto:** + +- O endpoint esta quebrado se for chamado. +- Pode gerar erros 500 e confundir integracoes antigas. + +**O que implementar:** + +A) **Atualizar para as bibliotecas atuais**: + +- Trocar para `voucher.utils` e `voucher.vouchera`. +- Usar `utils.getIpv4AndMac(os.getenv('REMOTE_ADDR'))`. +- Determinar `valid` com `portal.get_authorized_macs()` ou `vouchera.is_mac_authorized` (dependendo de `with_vouchers`). +- Manter o mesmo formato JSON: `{ ip, mac, valid }`. + +B) **Remover o endpoint** se nao houver consumo real. + +**Criterio de pronto (aceitacao):** + +- O CGI responde JSON valido com `ip`, `mac`, `valid`. +- Nao depende de modulos inexistentes. + +--- + +## 5) Hooks para sincronizacao de vouchers + +**Status atual:** o mecanismo existe no Pirania, mas o pacote nao inclui scripts de hook. + +**Contexto:** + +- `packages/pirania/files/usr/lib/lua/voucher/hooks.lua` executa scripts em `hooks_path`. +- O `hooks_path` default e `/etc/pirania/hooks/`. +- O pacote `shared-state-pirania` (dependencia declarada) costuma prover os scripts reais. + +**Impacto:** + +- Se `shared-state-pirania` nao estiver instalado ou nao configurar hooks, os vouchers nao sao sincronizados entre nos. +- O portal funciona, mas os vouchers ficam locais. + +**O que implementar (se a sincronizacao for esperada):** + +- Garantir que o pacote `shared-state-pirania` instale scripts em `/etc/pirania/hooks/`. +- Confirmar que o `hooks_path` do UCI aponta para esse local. +- Documentar claramente essa dependencia no README. + +**Criterio de pronto (aceitacao):** + +- Ao criar/invalidar voucher em um no, a mudanca aparece nos demais (via shared-state). + +--- + +## O que e realmente necessario para o Pirania funcionar + +**Funciona no basico sem implementar nada acima**, porque: + +- O portal captura trafego com nftables e redireciona corretamente. +- O modo voucher e o modo ler-para-acesso funcionam. + +Os itens acima sao **complementos esperados** por configuracoes, integracoes ou uso legado. Se a expectativa do projeto inclui esses comportamentos, eles precisam de implementacao. diff --git a/packages/pirania/Leeme.md b/packages/pirania/Leeme.md index 777e0ceba..ea7442926 100644 --- a/packages/pirania/Leeme.md +++ b/packages/pirania/Leeme.md @@ -17,7 +17,7 @@ Estas son las características implementadas hasta ahora: ## Requisitos previos -Este software corre sobre la distribución OpenWrt (ya que utiliza [UCI](https://openwrt.org/docs/techref/uci) para su configuración). Los paquetes `ip6tables-mod-nat` y `ipset` deben estar instalados. +Este software corre sobre la distribución OpenWrt (ya que utiliza [UCI](https://openwrt.org/docs/techref/uci) para su configuración). Debe estar instalado el paquete `nftables` (provee el comando `nft` y los módulos del kernel necesarios). ## Instalar @@ -26,7 +26,7 @@ Este software corre sobre la distribución OpenWrt (ya que utiliza [UCI](https:/ # Cómo funciona -Utiliza las reglas de iptables para filtrar el tráfico hacia fuera de la red mesh. +Utiliza las reglas de nftables para filtrar el tráfico hacia fuera de la red mesh. ## Vista general de la jerarquía y funciones de los archivos @@ -40,7 +40,7 @@ La siguiente lista tiene como objetivo explicar qué funcionalidad de Pirania es * `/usr/lib/lua/voucher/` contiene bibliotecas lua que son utilizadas por /usr/bin/voucher * `/usr/bin/voucher``/usr/bin/voucher` es una interfaz de línea de comandos (CLI) que maneja una base de datos (que incluye funciones de muestra como `show_active, show_authorized_macs, add, activate, deactivate e is_mac_authorized)` -* `/usr/bin/captive-portal` configura las reglas de iptables para la captura de tráfico +* `/usr/bin/captive-portal` configura las reglas de nftables para la captura de tráfico * `/usr/libexec/rpcd/pirania` ubus de pirania (utilizada por el frontend web) * `/usr/share/rpcd/acl.d/pirania.json` Lista de control de accesos (ACL) para la API de pirania @@ -49,8 +49,8 @@ La siguiente lista tiene como objetivo explicar qué funcionalidad de Pirania es * `/usr/lib/lua/read_for_access` contiene la librería que usa `/usr/lib/lua/portal` para manejar el modo "leer para acceder" (es decir, sin vouchers) ### Captura de tráfico -`/usr/bin/captive-portal` configura las reglas de iptables para captura de tráfico. -Crea un grupo de reglas que se aplican a tres grupos de direcciones IPs (usando el módulo ipset de iptables) habilitados: +`/usr/bin/captive-portal` configura las reglas de nftables para captura de tráfico. +Crea un grupo de reglas que se aplican a tres conjuntos nativos de nftables habilitados: * `pirania-auth-macs`: la lista de mac autorizadas. comienza vacía. * `pirania-allowlist-ipv4`: contiene los miembros de clientes permitidos `allowlist` en el archivo de configuración (`10.0.0.0/8`, `192.168.0.0/16`, `172.16.0.0/12``172.16.0.0/12`) * `pirania-allowlist-ipv6`: lo mismo que la lista anterior pero para ipv6 diff --git a/packages/pirania/Makefile b/packages/pirania/Makefile index 50fe40a4e..5ad38b10e 100644 --- a/packages/pirania/Makefile +++ b/packages/pirania/Makefile @@ -6,9 +6,9 @@ define Package/$(PKG_NAME) CATEGORY:=Network MAINTAINER:=Asociación Civil AlterMundi TITLE:=Captive portal with vouchers. - DEPENDS:=+ip6tables-mod-nat +ipset +shared-state +shared-state-pirania \ + DEPENDS:=+nftables +kmod-nft-bridge +shared-state +shared-state-pirania \ +uhttpd-mod-lua +lime-system +luci-lib-jsonc \ - +liblucihttp-lua +luci-lib-nixio +libubus-lua +libuci-lua + +luci-lib-nixio +libubus-lua +libuci-lua PKGARCH:=all endef diff --git a/packages/pirania/PIRANIA_FLUXO_SIMPLIFICADO.pt.md b/packages/pirania/PIRANIA_FLUXO_SIMPLIFICADO.pt.md new file mode 100644 index 000000000..220d032d2 --- /dev/null +++ b/packages/pirania/PIRANIA_FLUXO_SIMPLIFICADO.pt.md @@ -0,0 +1,93 @@ +# Pirania: como funciona (bem simples) + +Este texto explica o Pirania como se fosse para uma crianca pequena, mas sem perder o caminho real das pecas do sistema. + +## A ideia geral (bem simples) + +Pense no Pirania como um **porteiro** de um predio: + +- Se voce **nao tem permissao**, o porteiro te leva para a recepcao (o portal). +- La voce **mostra seu voucher** ou **espera um tempinho** (ler-para-acesso). +- Depois disso, o porteiro **coloca seu nome na lista** e voce pode entrar. + +## As pecas principais (quem faz o que) + +- **Capturador de trafego (nftables)**: o porteiro que decide quem pode passar. +- **DNS local (pirania-dnsmasq)**: o guia que responde “o portal fica aqui”. +- **Redirecionador HTTP (pirania-uhttpd + redirect)**: o guia que aponta a porta certa. +- **Portal (paginas em /www/portal/)**: o lugar onde a pessoa digita o voucher ou espera o tempo. +- **CGI handlers**: os ajudantes que escrevem na lista de permitidos. +- **Banco de vouchers (arquivos JSON)**: a caixinha onde os vouchers ficam guardados. +- **Lista de MACs autorizados**: a lista que libera os dispositivos. + +## Fluxo de um pacote (o que acontece quando um pacote chega) + +1. **Chega um pacote do dispositivo da pessoa**. +2. O **nftables** pergunta: “Esse MAC esta autorizado?” +3. Se **nao** estiver autorizado: + - DNS vai para **porta 59053** (pirania-dnsmasq). + - HTTP vai para **porta 59080** (pirania-uhttpd). + - HTTPS e bloqueado. +4. O **pirania-uhttpd** redireciona a pessoa para o portal. +5. A pessoa **digita o voucher** ou **espera o tempo**. +6. O CGI **autoriza o MAC**. +7. O Pirania **atualiza o nftables**. +8. O proximo pacote **passa normalmente**. + +## Diagrama Mermaid (fluxo simples) + +```mermaid +flowchart TD + A[Dispositivo envia pacote] --> B{MAC autorizado?} + + B -- sim --> C[Pacote passa
Internet normal] + + B -- nao --> D[Captura no nftables] + D --> E[DNS -> 59053
pirania-dnsmasq] + D --> F[HTTP -> 59080
pirania-uhttpd] + D --> G[HTTPS bloqueado] + + F --> H[Redirect para portal
/portal/*] + H --> I{Modo de acesso} + + I -- voucher --> J[Pessoa digita voucher] + I -- ler-para-acesso --> K[Pessoa espera tempo] + + J --> L[CGI ativa voucher
associa MAC] + K --> M[CGI autoriza MAC
por tempo] + + L --> N[Atualiza nftables
captive-portal update] + M --> N + N --> C +``` + +## O mesmo fluxo, so que com palavras super simples + +- O Pirania olha quem chegou. +- Se a pessoa **nao esta na lista**, ele mostra o portal. +- A pessoa faz a “tarefa” (voucher ou esperar). +- O Pirania coloca o MAC na lista. +- Pronto: agora o acesso passa livre. + +## Diferencas entre os dois modos + +- **Voucher**: precisa de um codigo. O MAC fica autorizado enquanto o voucher estiver ativo. +- **Ler-para-acesso**: so espera um tempo. O MAC fica autorizado por alguns minutos. + +## Onde cada parte vive no projeto (referencia) + +- Captura de trafego: `packages/pirania/files/usr/bin/captive-portal` +- DNS local: `packages/pirania/files/etc/init.d/pirania-dnsmasq` +- Redirect HTTP: `packages/pirania/files/etc/init.d/pirania-uhttpd` +- Script de redirect: `packages/pirania/files/www/pirania-redirect/redirect` +- Portal (paginas): `packages/pirania/files/www/portal/` +- CGI handlers: + - `packages/pirania/files/www/cgi-bin/pirania/preactivate_voucher` + - `packages/pirania/files/www/cgi-bin/pirania/activate_voucher` + - `packages/pirania/files/www/cgi-bin/pirania/authorize_mac` +- Logica de vouchers: `packages/pirania/files/usr/lib/lua/voucher/` +- Logica ler-para-acesso: `packages/pirania/files/usr/lib/lua/read_for_access/` + +--- + +Se quiser, posso adicionar um diagrama mais tecnico (com portas, processos e arquivos) ou um diagrama separado so para o modo voucher. diff --git a/packages/pirania/PIRANIA_SYSTEM.md b/packages/pirania/PIRANIA_SYSTEM.md new file mode 100644 index 000000000..f42cae49d --- /dev/null +++ b/packages/pirania/PIRANIA_SYSTEM.md @@ -0,0 +1,187 @@ +# Pirania system overview + +This document explains how Pirania is organized, what each component does, and how the captive-portal flow works in both voucher and read-for-access modes. It is based on the current code under `packages/pirania/`. + +## 1. What Pirania is + +Pirania is a captive portal for OpenWrt/LibreMesh nodes. It controls Internet access by MAC address and provides two access modes: + +- **Voucher mode**: users must enter a voucher code; the voucher is then bound to their device MAC. +- **Read-for-access mode**: users view a portal page and wait a short countdown; their MAC is temporarily authorized. + +The authorization list is stored locally and synchronized into nftables rules so that authorized devices bypass the portal. + +## 2. High-level architecture + +``` +Client device + -> nftables rules (captures DNS/HTTP/HTTPS for unauthorized MACs) + -> local DNS on port 59053 (pirania-dnsmasq) + -> local HTTP redirect on port 59080 (pirania-uhttpd) + -> portal pages on /www/portal/ + -> CGI handlers authorize MAC + -> captive-portal update (refresh nftables MAC set) + -> normal Internet access +``` + +The central behavior is implemented by: + +- `packages/pirania/files/usr/bin/captive-portal` +- `packages/pirania/files/etc/init.d/pirania-dnsmasq` +- `packages/pirania/files/etc/init.d/pirania-uhttpd` +- `packages/pirania/files/www/pirania-redirect/redirect` +- `packages/pirania/files/usr/lib/lua/portal/portal.lua` + +## 3. Configuration (UCI) + +The main configuration file is `packages/pirania/files/etc/config/pirania`. + +Key options in `base_config`: + +- `enabled`: whether the portal is active at boot +- `with_vouchers`: toggle voucher vs read-for-access mode +- `portal_domain`: domain used for portal URLs (default `thisnode.info`) +- `url_auth`, `url_authenticated`, `url_info`, `url_fail`: portal page paths +- `db_path`: voucher database directory (JSON files) +- `hooks_path`: directory for hook scripts (e.g., shared-state sync) +- `allowlist_ipv4`, `allowlist_ipv6`: ranges that bypass the captive portal + +Access-mode options live in `config access_mode 'read_for_access'`: + +- `url_portal`: path to the read-for-access page +- `duration_m`: authorization duration in minutes + +## 4. Services and startup + +- `packages/pirania/files/etc/init.d/pirania` starts the portal if enabled and runs hooks. +- `packages/pirania/files/etc/init.d/pirania-dnsmasq` runs a dedicated dnsmasq on port 59053. +- `packages/pirania/files/etc/init.d/pirania-uhttpd` runs a small uhttpd on port 59080. +- `packages/pirania/files/etc/uci-defaults/90-captive-portal-cron` installs a cron job to refresh nftables every 10 minutes. + +## 5. Traffic capture (nftables) + +`packages/pirania/files/usr/bin/captive-portal` sets up nftables rules in the `inet pirania` table: + +- Creates sets for authorized MACs (`pirania-auth-macs`) and allowlisted IPv4/IPv6 destination ranges. +- Redirects DNS (UDP/53) to port 59053 for unauthorized MACs. +- Redirects HTTP (TCP/80) to port 59080 for unauthorized MACs. +- Drops HTTPS (TCP/443) for unauthorized MACs. +- Allows traffic for MACs in `pirania-auth-macs`, and allows allowlisted destinations to bypass redirects and HTTPS blocking. + +Authorized MACs come from `packages/pirania/files/usr/bin/pirania_authorized_macs`, which delegates to the Lua portal library and returns either voucher-based or read-for-access MACs. + +## 6. DNS hijack + +`packages/pirania/files/etc/init.d/pirania-dnsmasq` starts a dnsmasq instance that: + +- Answers `thisnode.info` with the node IP. +- Uses shared-state hosts from `/var/hosts/shared-state-dnsmasq_hosts`. +- Sends unknown domains to a fallback IP (1.2.3.4). + +This ensures the portal domain resolves locally when the user is captured. + +## 7. HTTP redirect service + +`packages/pirania/files/etc/init.d/pirania-uhttpd` starts an HTTP server on port 59080 serving `packages/pirania/files/www/pirania-redirect/redirect`. + +The redirect script: + +- Builds a `prev` URL from the original request. +- Picks the portal entry point based on `with_vouchers`: + - Voucher mode: `base_config.url_auth` + - Read-for-access mode: `read_for_access.url_portal` +- Sends a 302 redirect to `http://?prev=`. + +## 8. Portal pages and assets + +Static portal pages are under `packages/pirania/files/www/portal/`: + +- `auth.html` (voucher entry) +- `info.html` (waiting/info screen) +- `authenticated.html` (success) +- `fail.html` (error) +- `read_for_access.html` (non-voucher flow) + +Portal content (title, text, logo, colors) is stored in `packages/pirania/files/etc/pirania/portal.json`. The Lua module `packages/pirania/files/usr/lib/lua/portal/portal.lua` can read/write this content and also synchronize it via shared-state (`pirania_persistent`). + +## 9. Voucher subsystem + +Voucher logic is implemented in `packages/pirania/files/usr/lib/lua/voucher/` and exposed via the CLI `packages/pirania/files/usr/bin/voucher`. + +Key files: + +- `vouchera.lua`: main voucher model and operations (create, activate, invalidate, list, status checks). +- `store.lua`: JSON file storage (`db_path/.json`). +- `config.lua`: reads `db_path`, `hooks_path`, pruning settings. +- `hooks.lua`: executes hook scripts under `hooks_path//` on database changes. +- `utils.lua`: URL parsing and IP/MAC lookup via ARP/neigh tables. + +Voucher lifecycle: + +1. **Create**: `voucher add` calls `vouchera.create`, which writes a JSON file and triggers `hooks.run('db_change')`. +2. **Activate**: voucher code is bound to a MAC and `captive-portal update` refreshes nftables. +3. **Invalidate**: sets `invalidation_date`, keeping the record for pruning; also refreshes nftables if needed. +4. **Prune**: old expired/invalidated vouchers are removed when `vouchera.init()` runs. + +The CLI wraps these operations in `packages/pirania/files/usr/bin/voucher`. + +## 10. Read-for-access subsystem + +Read-for-access mode uses: + +- `packages/pirania/files/usr/lib/lua/read_for_access/read_for_access.lua` +- `packages/pirania/files/usr/lib/lua/read_for_access/cgi_handlers.lua` + +MACs are stored in `/tmp/pirania/read_for_access/auth_macs` with an expiration timestamp (based on system uptime). When a user completes the portal wait, their MAC is added and `captive-portal update` refreshes nftables. + +## 11. CGI endpoints + +Portal pages call CGI scripts under `packages/pirania/files/www/cgi-bin/pirania/`: + +- `preactivate_voucher`: validates voucher and either redirects to `info.html` (JS flow) or activates immediately (no-JS flow). +- `activate_voucher`: final activation endpoint, binds voucher to MAC. +- `authorize_mac`: used by read-for-access to authorize a MAC for a limited time. +- `client_ip`: legacy endpoint that references old modules and is not used by current voucher flow. + +## 12. Ubus/rpcd API + +The ubus service is implemented in `packages/pirania/files/usr/libexec/rpcd/pirania` and exposed via ACLs in `packages/pirania/files/usr/share/rpcd/acl.d/pirania.json`. + +Supported calls include: + +- `get_portal_config`, `set_portal_config` +- `add_vouchers`, `list_vouchers`, `invalidate`, `rename` +- `get_portal_page_content`, `set_portal_page_content` + +These are consumed by Lime-App or other management tools. + +## 13. Tests + +Pirania tests live under `packages/pirania/tests/` and cover portal flows, voucher logic, rpcd handlers, and CGI helpers. + +## 14. End-to-end flow summary + +Voucher mode: + +1. User hits an external site; DNS/HTTP are redirected to Pirania. +2. User lands on `auth.html` and submits a voucher code. +3. `preactivate_voucher` checks the code; if valid, the user waits on `info.html` (JS flow) and then calls `activate_voucher`. +4. Voucher binds to MAC and nftables set is refreshed. +5. User is redirected to the original URL or `authenticated.html`. + +Read-for-access mode: + +1. User hits an external site; DNS/HTTP are redirected to Pirania. +2. User lands on `read_for_access.html` and waits the countdown. +3. `authorize_mac` stores the MAC with a short TTL and refreshes nftables. +4. User is redirected to the original URL or `authenticated.html`. + +## 15. Notes and caveats + +- The current implementation uses **nftables** (not iptables) via `captive-portal`. +- `catch_interfaces`/`catch_bridged_interfaces` are present in UCI config but are not currently applied to nftables rules. +- The `client_ip` CGI script appears to depend on legacy modules (`voucher.logic`, `voucher.db`). + +--- + +If you want, I can add a short “operator guide” section (common commands, troubleshooting, or a flow diagram) based on how you deploy Pirania. diff --git a/packages/pirania/PIRANIA_SYSTEM.pt.md b/packages/pirania/PIRANIA_SYSTEM.pt.md new file mode 100644 index 000000000..61101c898 --- /dev/null +++ b/packages/pirania/PIRANIA_SYSTEM.pt.md @@ -0,0 +1,187 @@ +# Visao geral do sistema Pirania + +Este documento explica como o Pirania esta organizado, o que cada componente faz e como o fluxo do portal cativo funciona nos modos de voucher e de ler-para-acesso. Ele se baseia no codigo atual em `packages/pirania/`. + +## 1. O que e o Pirania + +Pirania e um portal cativo para nos OpenWrt/LibreMesh. Ele controla o acesso a Internet por endereco MAC e oferece dois modos de acesso: + +- **Modo voucher**: a pessoa usuaria precisa informar um codigo de voucher; o voucher fica associado ao MAC do dispositivo. +- **Modo ler-para-acesso**: a pessoa usuaria ve a pagina do portal e espera uma contagem regressiva; o MAC fica temporariamente autorizado. + +A lista de autorizacao e armazenada localmente e sincronizada com as regras do nftables para que os dispositivos autorizados passem direto pelo portal. + +## 2. Arquitetura de alto nivel + +``` +Dispositivo cliente + -> regras nftables (capturam DNS/HTTP/HTTPS para MACs nao autorizados) + -> DNS local na porta 59053 (pirania-dnsmasq) + -> redirecionamento HTTP local na porta 59080 (pirania-uhttpd) + -> paginas do portal em /www/portal/ + -> handlers CGI autorizam o MAC + -> captive-portal update (atualiza o set de MACs no nftables) + -> acesso normal a Internet +``` + +O comportamento central e implementado por: + +- `packages/pirania/files/usr/bin/captive-portal` +- `packages/pirania/files/etc/init.d/pirania-dnsmasq` +- `packages/pirania/files/etc/init.d/pirania-uhttpd` +- `packages/pirania/files/www/pirania-redirect/redirect` +- `packages/pirania/files/usr/lib/lua/portal/portal.lua` + +## 3. Configuracao (UCI) + +O arquivo principal de configuracao e `packages/pirania/files/etc/config/pirania`. + +Opcoes principais em `base_config`: + +- `enabled`: se o portal esta ativo no boot +- `with_vouchers`: alterna entre modo voucher e modo ler-para-acesso +- `portal_domain`: dominio usado nas URLs do portal (padrao `thisnode.info`) +- `url_auth`, `url_authenticated`, `url_info`, `url_fail`: caminhos das paginas do portal +- `db_path`: diretorio do banco de vouchers (arquivos JSON) +- `hooks_path`: diretorio de scripts de hook (ex.: sincronizacao shared-state) +- `allowlist_ipv4`, `allowlist_ipv6`: faixas que bypassam o portal cativo + +Opcoes do modo de acesso ficam em `config access_mode 'read_for_access'`: + +- `url_portal`: caminho para a pagina de ler-para-acesso +- `duration_m`: duracao da autorizacao em minutos + +## 4. Servicos e inicializacao + +- `packages/pirania/files/etc/init.d/pirania` inicia o portal se estiver habilitado e executa hooks. +- `packages/pirania/files/etc/init.d/pirania-dnsmasq` roda um dnsmasq dedicado na porta 59053. +- `packages/pirania/files/etc/init.d/pirania-uhttpd` roda um uhttpd pequeno na porta 59080. +- `packages/pirania/files/etc/uci-defaults/90-captive-portal-cron` instala um cron para atualizar o nftables a cada 10 minutos. + +## 5. Captura de trafego (nftables) + +`packages/pirania/files/usr/bin/captive-portal` configura regras nftables na tabela `inet pirania`: + +- Cria sets para MACs autorizados (`pirania-auth-macs`) e faixas IPv4/IPv6 de destino na allowlist. +- Redireciona DNS (UDP/53) para a porta 59053 para MACs nao autorizados. +- Redireciona HTTP (TCP/80) para a porta 59080 para MACs nao autorizados. +- Bloqueia HTTPS (TCP/443) para MACs nao autorizados. +- Libera trafego para MACs em `pirania-auth-macs`, e permite destinos na allowlist bypassarem redirecionamentos e o bloqueio de HTTPS. + +Os MACs autorizados vem de `packages/pirania/files/usr/bin/pirania_authorized_macs`, que delega para a biblioteca Lua do portal e retorna MACs do modo voucher ou do modo ler-para-acesso. + +## 6. Sequestro de DNS + +`packages/pirania/files/etc/init.d/pirania-dnsmasq` inicia uma instancia de dnsmasq que: + +- Resolve `thisnode.info` para o IP do no. +- Usa hosts do shared-state em `/var/hosts/shared-state-dnsmasq_hosts`. +- Envia dominios desconhecidos para um IP de fallback (1.2.3.4). + +Isso garante que o dominio do portal resolva localmente quando a pessoa usuaria estiver capturada. + +## 7. Servico de redirecionamento HTTP + +`packages/pirania/files/etc/init.d/pirania-uhttpd` inicia um servidor HTTP na porta 59080 servindo `packages/pirania/files/www/pirania-redirect/redirect`. + +O script de redirecionamento: + +- Monta a URL `prev` a partir da requisicao original. +- Escolhe a pagina de entrada do portal com base em `with_vouchers`: + - Modo voucher: `base_config.url_auth` + - Modo ler-para-acesso: `read_for_access.url_portal` +- Envia um redirect 302 para `http://?prev=`. + +## 8. Paginas e assets do portal + +As paginas estaticas ficam em `packages/pirania/files/www/portal/`: + +- `auth.html` (entrada de voucher) +- `info.html` (espera/informacao) +- `authenticated.html` (sucesso) +- `fail.html` (erro) +- `read_for_access.html` (fluxo sem voucher) + +O conteudo do portal (titulo, texto, logo, cores) fica em `packages/pirania/files/etc/pirania/portal.json`. O modulo Lua `packages/pirania/files/usr/lib/lua/portal/portal.lua` pode ler/atualizar esse conteudo e sincroniza-lo via shared-state (`pirania_persistent`). + +## 9. Subsistema de vouchers + +A logica de vouchers fica em `packages/pirania/files/usr/lib/lua/voucher/` e e exposta pela CLI `packages/pirania/files/usr/bin/voucher`. + +Arquivos principais: + +- `vouchera.lua`: modelo e operacoes de voucher (criar, ativar, invalidar, listar, status). +- `store.lua`: armazenamento em JSON (`db_path/.json`). +- `config.lua`: le `db_path`, `hooks_path`, configuracao de pruning. +- `hooks.lua`: executa hooks em `hooks_path//` quando ha mudancas. +- `utils.lua`: parsing de URL e descoberta de IP/MAC via ARP/neigh. + +Ciclo de vida do voucher: + +1. **Criar**: `voucher add` chama `vouchera.create`, grava um JSON e dispara `hooks.run('db_change')`. +2. **Ativar**: o codigo e associado a um MAC e `captive-portal update` atualiza o nftables. +3. **Invalidar**: define `invalidation_date`, mantendo o registro para pruning; atualiza o nftables se necessario. +4. **Prunar**: vouchers expirados/invalidos sao removidos quando `vouchera.init()` roda. + +A CLI encapsula essas operacoes em `packages/pirania/files/usr/bin/voucher`. + +## 10. Subsistema de ler-para-acesso + +O modo ler-para-acesso usa: + +- `packages/pirania/files/usr/lib/lua/read_for_access/read_for_access.lua` +- `packages/pirania/files/usr/lib/lua/read_for_access/cgi_handlers.lua` + +MACs sao armazenados em `/tmp/pirania/read_for_access/auth_macs` com timestamp de expiracao (baseado em uptime). Quando a pessoa usuaria conclui a espera no portal, o MAC e adicionado e `captive-portal update` atualiza o nftables. + +## 11. Endpoints CGI + +As paginas do portal chamam scripts CGI em `packages/pirania/files/www/cgi-bin/pirania/`: + +- `preactivate_voucher`: valida voucher e redireciona para `info.html` (fluxo com JS) ou ativa imediatamente (fluxo sem JS). +- `activate_voucher`: endpoint final de ativacao, associa voucher ao MAC. +- `authorize_mac`: usado no modo ler-para-acesso para autorizar um MAC por tempo limitado. +- `client_ip`: endpoint legado que referencia modulos antigos e nao e usado no fluxo atual de voucher. + +## 12. API Ubus/rpcd + +O servico ubus esta em `packages/pirania/files/usr/libexec/rpcd/pirania` e e exposto via ACLs em `packages/pirania/files/usr/share/rpcd/acl.d/pirania.json`. + +Chamadas suportadas incluem: + +- `get_portal_config`, `set_portal_config` +- `add_vouchers`, `list_vouchers`, `invalidate`, `rename` +- `get_portal_page_content`, `set_portal_page_content` + +Essas chamadas sao consumidas pela Lime-App ou outras ferramentas de gestao. + +## 13. Testes + +Os testes do Pirania ficam em `packages/pirania/tests/` e cobrem fluxos do portal, logica de vouchers, handlers rpcd e helpers CGI. + +## 14. Resumo do fluxo ponta a ponta + +Modo voucher: + +1. A pessoa usuaria acessa um site externo; DNS/HTTP sao redirecionados para o Pirania. +2. A pessoa usuaria chega em `auth.html` e envia o codigo de voucher. +3. `preactivate_voucher` valida o codigo; se valido, a pessoa usuaria espera em `info.html` (fluxo JS) e entao chama `activate_voucher`. +4. O voucher e associado ao MAC e o nftables e atualizado. +5. A pessoa usuaria e redirecionada para a URL original ou para `authenticated.html`. + +Modo ler-para-acesso: + +1. A pessoa usuaria acessa um site externo; DNS/HTTP sao redirecionados para o Pirania. +2. A pessoa usuaria chega em `read_for_access.html` e espera a contagem. +3. `authorize_mac` grava o MAC com TTL curto e atualiza o nftables. +4. A pessoa usuaria e redirecionada para a URL original ou para `authenticated.html`. + +## 15. Observacoes e ressalvas + +- A implementacao atual usa **nftables** (nao iptables) via `captive-portal`. +- `catch_interfaces`/`catch_bridged_interfaces` existem no UCI mas nao sao aplicadas nas regras nftables atualmente. +- O CGI `client_ip` parece depender de modulos legados (`voucher.logic`, `voucher.db`). + +--- + +Se quiser, posso adicionar uma secao curta de guia operacional (comandos comuns, troubleshooting, ou um diagrama de fluxo) baseada no seu modo de deploy do Pirania. diff --git a/packages/pirania/Readme.md b/packages/pirania/Readme.md index bbf447bea..9246dcc3c 100644 --- a/packages/pirania/Readme.md +++ b/packages/pirania/Readme.md @@ -21,7 +21,7 @@ This are the currently implemented features: * Can be used without vouchers. ## Prerequisites -This software assumes that will be running on a OpenWRT/LEDE distribution (because uses uci for config). Needs `nftables` and `ipset` packages installed. +This software assumes that will be running on a OpenWRT/LEDE distribution (because uses uci for config). Needs the `nftables` package installed (provides the `nft` command and kernel modules). ## Install @@ -118,7 +118,7 @@ Ex.: `voucher renew_voucher Qzt3WF 1619126965` # How it works -It uses iptables rules to filter inbound connections outside the mesh network. +It uses nftables rules to filter inbound connections outside the mesh network. ## General overview of file hierarchy and function @@ -130,7 +130,7 @@ files/ /usr/lib/lua/voucher/ contains lua libraries used by /usr/bin/voucher /usr/bin/voucher is a CLI to manage the db (has functions list, list_active, show_authorized_macs, add, activate, deactivate and is_mac_authorized) - /usr/bin/captive-portal sets up iptables rules to capture traffic + /usr/bin/captive-portal sets up nftables rules to capture traffic /usr/libexec/rpcd/pirania ubus pirania API (this is used by the web frontend) /usr/share/rpcd/acl.d/pirania.json ACL for the ubus pirania API @@ -177,8 +177,8 @@ Q3TJZS san-notebook ZRJUXN xx:xx:xx:xx:xx:xx Wed Sep 8 23:47:40 2021 60 ## Under the hood ### Trafic capture -`/usr/bin/captive-portal` sets up iptables rules to capture traffic. -It creates a set of rules that apply to 3 allowed "ipsets": +`/usr/bin/captive-portal` sets up nftables rules to capture traffic. +It creates a set of rules and native nft sets: * pirania-auth-macs: authorized macs go into this rule. starts empty. * pirania-allowlist-ipv4: with the members of the allowlist in the config file (10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12) * pirania-allowlist-ipv6: same as ipv4 but for ipv6 diff --git a/packages/pirania/TODO.md b/packages/pirania/TODO.md new file mode 100644 index 000000000..61a90968e --- /dev/null +++ b/packages/pirania/TODO.md @@ -0,0 +1,30 @@ +# TODO - partes nao implementadas ou sem efeito + +Este arquivo lista pontos encontrados no codigo do Pirania que parecem nao implementados, legados ou sem efeito hoje. + +## Itens + +1) Regras por interface (catch_interfaces / catch_bridged_interfaces) +- Configuracao existe em `packages/pirania/files/etc/config/pirania`. +- Em `packages/pirania/files/usr/bin/captive-portal` a variavel `catch_bridged_interfaces` e lida, mas nao e usada para filtrar regras nftables. +- Resultado: a captura nao e limitada por interface, apesar das opcoes no UCI. + +2) Opcao append_nft_rules +- Configuracao existe em `packages/pirania/files/etc/config/pirania`. +- Em `packages/pirania/files/usr/bin/captive-portal` a logica de `append_nft_rules` esta comentada. +- Resultado: a opcao nao tem efeito (regras sao sempre adicionadas com `nft add rule`). + +3) portal_url via ubus/rpcd +- O ubus exposto em `packages/pirania/files/usr/libexec/rpcd/pirania` implementa `show_url` e `change_url` para `pirania.base_config.portal_url`. +- Essa chave nao aparece no arquivo UCI (`packages/pirania/files/etc/config/pirania`) e nao e usada pelo redirecionamento do portal. +- Resultado: a API parece legada ou sem efeito real no fluxo do portal. + +4) CGI `client_ip` legado +- `packages/pirania/files/www/cgi-bin/pirania/client_ip` depende de modulos `voucher.logic` e `voucher.db`. +- Esses modulos nao existem em `packages/pirania/files/usr/lib/lua/voucher/`. +- Resultado: o endpoint parece quebrado/legado e nao faz parte do fluxo atual. + +5) Hooks sem scripts no pacote +- `packages/pirania/files/usr/lib/lua/voucher/hooks.lua` executa scripts em `hooks_path`. +- O pacote nao inclui scripts sob `/etc/pirania/hooks/`. +- Resultado: o mecanismo de hooks existe, mas vem vazio neste pacote. diff --git a/packages/pirania/docs/nft-catch-interfaces-plan.md b/packages/pirania/docs/nft-catch-interfaces-plan.md new file mode 100644 index 000000000..896f88f85 --- /dev/null +++ b/packages/pirania/docs/nft-catch-interfaces-plan.md @@ -0,0 +1,543 @@ +# Plan: Implement catch_interfaces and catch_bridged_interfaces (nftables) + +## Goals + +- Scope Pirania to explicitly configured ingress interfaces only. +- Stop captive-portal from affecting mesh interfaces by default. +- Keep behavior aligned with the legacy iptables+ebtables model. +- Minimize structural changes to current nftables script. + +## Constraints / Fit + +- Keep the dedicated `inet pirania` table (no fw4 refactor). +- Use interface-name matching (stable for dynamic devices). +- Preserve existing Tranca/allowlist logic and rule ordering. + +## Operator Guidance (names + defaults) + +- **Values are ifnames**, not UCI network names. Use `ip link` and `bridge link` + to confirm actual interface names (e.g. `br-lan`, `wlan0-ap`, `bat0`). +- **Default behavior** should be safe: if both lists are empty, portal should + catch nothing (avoid blocking mesh/infrastructure). +- In mesh deployments, **avoid** catching mesh/backbone interfaces (`bat0`, + `mesh0`, `wlan0-mesh`, etc) unless explicitly desired. +- Typical default: `catch_bridged_interfaces` contains AP ifnames; `catch_interfaces` + contains the bridge (`br-lan`) only if you _intend_ to portal all LAN traffic. + +## UCI Schema Changes + +The default config (`/etc/config/pirania`) must include `catch_bridged_interfaces`; +`catch_interfaces` is optional and intentionally commented out in the mesh-safe default. + +**Current state** (problematic - catches mesh via br-lan): + +``` +list catch_interfaces 'br-lan' +``` + +**New default** for `packages/pirania/files/etc/config/pirania`: + +``` +# Interface catch lists (ifnames, not UCI network names) +# catch_interfaces: L3 interfaces for direct nftables matching +# catch_bridged_interfaces: L2 bridged interfaces for bridge-family marking +# +# MESH-SAFE DEFAULT: Only catch Wi-Fi AP interfaces, not br-lan. +# br-lan typically includes bat0 (mesh backbone) which must NOT be caught. +# Uncomment catch_interfaces only if you explicitly want to portal wired LAN +# or understand that br-lan may include mesh interfaces. +# +# list catch_interfaces 'br-lan' +list catch_bridged_interfaces 'wlan0-ap' +``` + +**Rationale**: On LibreMesh, `br-lan` contains `bat0` (the mesh backbone). +Catching `br-lan` would block mesh traffic, breaking the network. The safe +default catches only Wi-Fi AP interfaces at L2 (bridge family), leaving +mesh and wired infrastructure unaffected. + +**Migration note**: Existing deployments with sysupgrade retain their config. +Operators should: + +1. Remove or comment out `list catch_interfaces 'br-lan'` +2. Add `list catch_bridged_interfaces 'wlan0-ap'` (adjust ifname as needed) + +**Optional: Catching wired LAN clients** + +If wired LAN clients should also go through the portal (and mesh is on a +separate interface or VLAN), operators can uncomment `catch_interfaces`: + +``` +list catch_interfaces 'br-lan' +list catch_bridged_interfaces 'wlan0-ap' +``` + +**Warning**: Only do this if you've verified `br-lan` does not include mesh +interfaces, or if you explicitly want to portal all bridge traffic. + +## Best-practice notes (nftables/OpenWrt) + +- Prefer `iifname` (ifname type) for dynamic interfaces; avoid `iif` for devices + that may be created/destroyed (ppp, tun/tap, veth, etc). +- Use `type ifname` sets for interface lists. +- Use base-chain priorities via keywords where possible (e.g. `dstnat`, `filter`). +- **NAT base chains** must use priority `> -200` (conntrack runs at -200). +- **Bridge family prerouting** can use `dstnat` priority (`-300`) to run early. +- OpenWrt supports nftables drop-ins in `/usr/share/nftables.d/` (packages) + and `/etc/nftables.d/` (admin), but we keep the existing script to avoid + refactoring across fw4. + +## Dependencies (bridge-family nftables) + +The L2 marking via `table bridge pirania` requires bridge-family nftables support: + +- **Kernel**: `CONFIG_NFT_BRIDGE` must be enabled +- **OpenWrt package**: `kmod-nft-bridge` + +**Package Makefile change** (`packages/pirania/Makefile`): + +```makefile +DEPENDS:=... +kmod-nft-bridge +``` + +**Implementation should verify** bridge family is available at runtime: + +```sh +if ! nft list tables bridge >/dev/null 2>&1; then + echo "Error: bridge-family nftables not available (missing kmod-nft-bridge?)" + exit 1 +fi +``` + +**Fallback behavior**: If bridge-family is unavailable, the entire +`catch_bridged_interfaces` mechanism breaks. Recommended behavior is to +fail hard with a clear error rather than silently degrading - silent +degradation could lead to unexpected portal behavior (e.g., catching +nothing, or catching everything via a misconfigured `catch_interfaces`). + +Hard-fail is safer than silent failure; operators must either install +`kmod-nft-bridge` or remove `catch_bridged_interfaces` from the config +and use `catch_interfaces` instead (L3-only mode, with the mesh-safety +caveats noted in the UCI section). + +## Proposed Design + +1. **Interface sets (name-based)** + - Add nft sets for interface names: + - `inet pirania pirania-catch-ifaces` (type `ifname`) + - `bridge pirania pirania-catch-bridge-ifaces` (type `ifname`) + - **Note**: Sets are table-local. Each set exists only in its own table + and cannot be referenced from other tables. + - Populate from UCI lists: + - `pirania.base_config.catch_interfaces` → `pirania-catch-ifaces` + - `pirania.base_config.catch_bridged_interfaces` → `pirania-catch-bridge-ifaces` + +2. **Bridge-side marking (L2 ingress)** + - Create `table bridge pirania` with a `prerouting` base chain. + - For packets arriving on `@pirania-catch-bridge-ifaces`, set a mark + (reuse the legacy mark constant to preserve semantics). + - This replaces the old ebtables mark step. + - Use a `dstnat`-priority base chain so marking happens early. + +3. **Inet-side gating (L3 ingress)** + - Introduce two regular chains to hold existing rules: + - `pirania_prerouting` (nat redirect + prefilter rules) + - `pirania_forward` (filter rules) + - Base chains (`prerouting`, `forward`) only jump into these chains when: + - `iifname` is in `@pirania-catch-ifaces`, OR + - `meta mark` matches the bridge mark. + - If both sets are empty, the base chains effectively no-op. + + **Empty set behavior**: In nftables, `iifname @empty-set` matches nothing + (the rule never triggers). This is the desired safe-by-default behavior. + Verification: `nft add rule ... iifname @pirania-catch-ifaces jump ...` + with an empty set will never jump. Must be tested during implementation. + +4. **Set population lifecycle** + - Extend `update_ipsets()` to: + - `nft flush set` and re-add `catch_*` elements. + - Handle empty/missing UCI lists cleanly (leave set empty). + - Ensure `clean_tables()` removes both `inet` and `bridge` tables. + +5. **Mark handling** + - Use `meta mark` for the gating bit (simple and stateless). + - **Decision**: Do NOT clear the mark _before_ `pirania_forward` (gating would fail). + - **Decision**: Clear the mark after Pirania processing completes. + Rationale: The bridge mark is set globally on the skb and can affect other + subsystems (policy routing via `ip rule fwmark`, tc/qdiscs, other nftables + tables). Clearing after Pirania processing avoids unintended side effects. + + **Mark-clearing implementation** (nftables behavior note): + Terminal verdicts (`accept`, `drop`) immediately exit the chain - a "final rule" + at the end of a chain is never reached by packets that matched a terminal rule. + Two approaches to reliably clear the mark: + + **(a) Clear before each accept** (verbose but explicit): + + ``` + chain pirania_forward { + ether saddr @pirania-auth-macs meta mark set 0 accept + ip daddr @pirania-allowlist-ipv4 meta mark set 0 accept + # ... drop rules don't need clearing (packet is gone) + drop + } + ``` + + **(b) Use `return` instead of `accept`, clear in base chain** (recommended): + + ``` + chain forward { + type filter hook forward priority 0; policy accept; + iifname @pirania-catch-ifaces jump pirania_forward + meta mark 0x9124714 jump pirania_forward + # Packets that returned from pirania_forward reach here; clear mark + meta mark 0x9124714 meta mark set 0 + } + + chain pirania_forward { + ether saddr @pirania-auth-macs return # not accept + ip daddr @pirania-allowlist-ipv4 return # not accept + # ... unauthorized ... + drop # dropped packets never return, mark is irrelevant + } + ``` + + **Recommendation**: Use approach (b). Single mark-clear location, cleaner logic. + Dropped packets don't return so mark clearing is unnecessary for them. + +## Complete Chain Structures + +This section shows the full nftables structure for both tables. + +### Bridge table (L2 marking) + +``` +table bridge pirania { + # Set for bridged interface names (populated from UCI) + set pirania-catch-bridge-ifaces { + type ifname + } + + # Mark packets arriving on catch interfaces + chain prerouting { + type filter hook prerouting priority dstnat; + iifname @pirania-catch-bridge-ifaces meta mark set 0x9124714 + } +} +``` + +**Note**: Bridge family uses `type filter` (not `nat`). Priority `dstnat` (-300) +ensures marking happens early before other processing. + +### Inet table (L3 gating and rules) + +``` +table inet pirania { + # Set for L3 interface names (populated from UCI) + set pirania-catch-ifaces { + type ifname + } + + # Existing sets (unchanged) + set pirania-auth-macs { type ether_addr; } + set pirania-allowlist-ipv4 { type ipv4_addr; flags interval; } + set pirania-allowlist-ipv6 { type ipv6_addr; flags interval; } + set pirania-tranca-allowlist-ipv4 { type ipv4_addr; flags interval; } + set pirania-unrestricted-macs { type ether_addr; } + + # --- PREROUTING (NAT) --- + chain prerouting { + type nat hook prerouting priority -100; + # Gate: only process caught interfaces or marked packets + iifname @pirania-catch-ifaces jump pirania_prerouting + meta mark 0x9124714 jump pirania_prerouting + # Non-caught traffic flows through without redirect + } + + chain pirania_prerouting { + # Allowlist destinations pass through (no redirect) + ip daddr @pirania-allowlist-ipv4 accept + ip6 daddr @pirania-allowlist-ipv6 accept + + # DHCP must always work + udp dport { 67, 68 } accept + udp dport { 546, 547 } accept + + # Authorized MACs pass through + ether saddr @pirania-auth-macs accept + + # Redirect HTTP to portal + tcp dport 80 redirect to :59080 + + # Redirect DNS to captive DNS + udp dport 53 redirect to :59053 + + # Drop other unauthorized traffic + drop + } + + # --- FORWARD (filter) --- + chain forward { + type filter hook forward priority 0; policy accept; + # Gate: only process caught interfaces or marked packets + iifname @pirania-catch-ifaces jump pirania_forward + meta mark 0x9124714 jump pirania_forward + # Clear bridge mark for packets that returned + meta mark 0x9124714 meta mark set 0 + # Non-caught traffic accepted by policy + } + + chain pirania_forward { + # Rules here use 'return' for accept, 'drop' for block + # (see Tranca Redes section for normal vs Tranca mode) + } +} +``` + +**Note on prerouting**: Uses `accept` (not `return`) because: + +- Redirected packets go to local input chain, not forward (mark clearing N/A) +- Accepted packets continue to forward chain where mark gets cleared +- Dropped packets are terminated (mark irrelevant) + +**Set table-locality**: Sets are local to their table. `pirania-catch-ifaces` +exists only in `inet pirania`; `pirania-catch-bridge-ifaces` exists only in +`bridge pirania`. They cannot be referenced across tables. + +## Tranca Redes Integration + +Tranca Redes mode changes forward chain rule ordering. Both normal and Tranca +rules must live inside `pirania_forward` (the regular chain), gated by the +base chain's interface/mark check. + +**Chain structure with Tranca support** (forward chain detail): + +``` +table inet pirania { + # Base chain - only jumps on interface match or bridge mark + chain forward { + type filter hook forward priority 0; policy accept; + iifname @pirania-catch-ifaces jump pirania_forward + meta mark 0x9124714 jump pirania_forward + # Clear bridge mark for packets that returned (accepted) + meta mark 0x9124714 meta mark set 0 + } + + # Regular chain - uses return for accept, drop for block + chain pirania_forward { + # --- Rules inserted here depend on Tranca active state --- + # Normal mode: auth-macs return, DNS return, allowlist return, drop + # Tranca mode: allowlist return, DNS return, unrestricted return, + # auth-macs + tranca-allowlist return, auth-macs drop + } +} +``` + +**Note**: Using `return` instead of `accept` allows the base chain to clear +the bridge mark after Pirania processing. Dropped packets never return, so +mark clearing is unnecessary for them. + +**Mark-clearing rule behavior**: The rule `meta mark 0x9124714 meta mark set 0` +clears the mark for _any_ packet carrying that mark value, not exclusively +packets that returned from `pirania_forward`. This is acceptable because: +(a) the mark value `0x9124714` is unique to Pirania and should not appear +from other sources, and (b) any packet with this mark has been processed +by Pirania (or is about to be) and should have the mark cleared regardless. +The rule must be placed _after_ the jump rules to avoid clearing before gating. +If any other component uses the same mark value, choose a different dedicated +mark or use `ct mark`/flags to clear only after a confirmed jump. + +**Rule placement logic** (in `set_nftables()`): + +- Check `uci -q get pirania.tranca_redes.active` +- If active: insert Tranca-specific rules into `pirania_forward` +- If inactive: insert normal rules into `pirania_forward` +- The base `forward` chain remains unchanged regardless of Tranca state + +**Tranca state changes** (`captive-portal update`): + +- Detect state change by checking if Tranca rules are present +- If state changed: `clean_tables` + `set_nftables` (rebuild both tables) +- The gating logic (interface sets) persists through rebuild + +## Legacy iptables/ebtables behavior (for reference) + +- **catch_bridged_interfaces**: + - `ebtables -t nat -A PREROUTING -i -j mark --mark-set 0x9124714` + - Then iptables mangle PREROUTING matched `-m mark --mark 0x9124714 -j pirania`. +- **catch_interfaces**: + - `iptables -t mangle -A PREROUTING -i -j pirania`. +- The `pirania` chain then handled allowlists, marking, and redirects. + +## Practical Examples (to validate behavior) + +- **Wi-Fi only (bridge AP)**: + - `catch_bridged_interfaces = wlan0-ap` + - `catch_interfaces` empty + - Expect: only Wi-Fi clients are captive; wired LAN and mesh are free. +- **LAN + Wi-Fi**: + - `catch_interfaces = br-lan` + - `catch_bridged_interfaces = wlan0-ap` + - Expect: everything entering LAN/bridge is captive (including wired). +- **Guest only**: + - `catch_interfaces = br-guest` + - `catch_bridged_interfaces = wlan1-ap` + - Expect: only guest SSID and guest bridge are captive. +- **Multiple access points**: + - `catch_bridged_interfaces = wlan0-ap wlan1-ap` + - Expect: both APs captive; no mesh impact if mesh is separate. + +## Pre-Implementation Checklist (router) + +- Confirm ifnames: + - `ip link` + - `bridge link` + - `uci show network` (map UCI networks to ifnames) +- Confirm mesh/backbone devices to exclude from catch lists. +- If you have router access, capture current ifnames: + - `ssh 10.29.0.1 'ip link'` + - `ssh 10.29.0.1 'bridge link'` (fallback: `ssh 10.29.0.1 'brctl show'`) + - `ssh 10.29.0.1 'uci show network'` + +## Router snapshot (10.29.0.1) + +- `ip link` key ifnames: `br-lan`, `bat0`, `wlan0-ap`, `wlan0-apname`, + `wlan0-mesh`, `eth0`, `eth1`, `anygw@br-lan`, `eth0_45`, `eth1_45`, + `wlan0-mesh_45`, `eth0_17`, `eth1_17`, `wlan0-mesh_17`. +- `bridge link` not available (no `bridge` tool); `brctl show` reports + `br-lan` contains: `bat0`, `eth1`, `wlan0-ap`, `wlan0-apname`. +- `uci show network` shows `br-lan` is a bridge with ports `bat0` and `eth1`, + and mesh uses `bat0` with VLAN hardifs (`eth0_45`, `eth1_45`, `wlan0-mesh_45`). +- **Suggested initial catch list for this router**: + - `catch_bridged_interfaces = wlan0-ap wlan0-apname` + - `catch_interfaces` empty (unless you explicitly want wired LAN captive). + - Avoid `bat0`/`wlan0-mesh*` to prevent mesh blocking. + +**Note**: The default config uses only `wlan0-ap`. If `wlan0-apname` should also +be caught, add it to the UCI config: +```sh +uci add_list pirania.base_config.catch_bridged_interfaces='wlan0-apname' +uci commit pirania +captive-portal update +``` + +## Implementation Steps (high level) + +1. Add new sets (inet + bridge) and bridge table/chain creation in `set_nftables()`. +2. Move existing prerouting/forward rules into new regular chains. +3. Add gating rules in base chains that jump only on interface match or mark. +4. Update `update_ipsets()` to populate the new sets from UCI. +5. Update `clean_tables()` to remove the bridge table too. + +## Implementation Notes (post-implementation) + +**Commit**: `eef46ef1` on branch `hotfix/pirania` + +**Critical ordering requirement**: Regular chains (`pirania_prerouting`, `pirania_forward`) +**MUST** be created before base chains that reference them with `jump` rules. nftables +returns "No such file or directory" if you try to jump to a non-existent chain. + +Correct order in `set_nftables()`: + +```sh +# 1. Create regular chains FIRST +nft add chain inet pirania pirania_prerouting +nft add chain inet pirania pirania_forward + +# 2. THEN create base chains with jump rules +nft add chain inet pirania prerouting { type nat hook prerouting priority -100; } +nft add rule inet pirania prerouting iifname @pirania-catch-ifaces jump pirania_prerouting +nft add rule inet pirania prerouting meta mark $PIRANIA_MARK jump pirania_prerouting +``` + +**Bridge table conditional creation**: The bridge table is only created when +`catch_bridged_interfaces` is configured. This avoids unnecessary kernel module +usage when L2 marking isn't needed. + +## clean_tables() Specification + +Must remove both `inet` and `bridge` family tables: + +```sh +clean_tables () { + echo "Cleaning captive-portal rules if there's any" + # Remove bridge table first (marking) + if nft list tables bridge | grep -q "pirania"; then + nft delete table bridge pirania + fi + # Remove inet table (rules) + if nft list tables inet | grep -q "pirania"; then + nft delete table inet pirania + fi +} +``` + +**Order**: Bridge table deleted first (marking) then inet table (rules), +though order doesn't matter functionally since they're independent. + +## Validation / Checks + +### Structural validation + +- [x] `nft list ruleset` shows both `inet pirania` and `bridge pirania` tables +- [x] `nft list set inet pirania pirania-catch-ifaces` shows expected interfaces (empty when L3 not configured) +- [x] `nft list set bridge pirania pirania-catch-bridge-ifaces` shows expected interfaces (`wlan0-ap`) +- [x] Base chains have correct priorities (prerouting: -100, forward: 0, bridge: dstnat/-300) + +### Functional validation - interface filtering + +- [ ] Traffic from mesh interface (`bat0`) is NOT caught (no redirect, free access) +- [ ] Traffic from `wlan0-mesh*` is NOT caught +- [ ] Traffic from `wlan0-ap` IS caught and redirected to portal +- [ ] Traffic from `wlan0-apname` IS caught (if in catch list) +- [ ] Wired LAN traffic behavior matches configuration intent + +### Functional validation - empty sets + +- [ ] With both `catch_*` lists empty: no clients are caught, all traffic passes +- [x] With only `catch_bridged_interfaces` populated: only bridged AP clients caught +- [ ] With only `catch_interfaces` populated: only L3 interface traffic caught + +### Functional validation - Tranca Redes + +- [ ] Tranca activation rebuilds rules correctly with interface gating preserved +- [ ] Tranca deactivation restores normal rules with interface gating preserved +- [ ] Unrestricted MACs bypass Tranca restrictions (when active) +- [ ] Authorized MACs limited to category allowlist (when Tranca active) + +### Functional validation - IPv6 + +- [ ] IPv6 client from `wlan0-ap` is correctly caught and redirected +- [ ] IPv6 allowlist destinations are accessible + +### Regression validation + +- [ ] Existing authorized MACs still work (voucher/read-for-access) +- [ ] Allowlist IPv4/IPv6 destinations still accessible +- [ ] DNS redirect to 59053 still works for unauthorized clients +- [ ] HTTP redirect to 59080 still works for unauthorized clients +- [ ] HTTPS (443) blocked for unauthorized clients + +## Decisions (best practices) + +- **Mark value**: reuse legacy `0x9124714` for continuity and easy rollback. +- **Mark type**: use `meta mark` (stateless, matches legacy ebtables mark). +- **Bridge chain priority**: use `dstnat` (`-300`) to ensure early marking. + +## Branch references + +- Current branch: `luandro/hotfix/pirania`. +- Legacy reference for consultation: `bb0b5a207bd8817ef89405024023959f9bdf5dc4`. + +## References + +- nftables wiki: Matching packet metainformation + - https://wiki.nftables.org/wiki-nftables/index.php/Matching_packet_metainformation +- nftables wiki: Sets + - https://wiki.nftables.org/wiki-nftables/index.php/Sets +- nftables wiki: Netfilter hooks / priorities + - https://wiki.nftables.org/wiki-nftables/index.php/Netfilter_hooks +- nftables wiki: Configuring chains + - https://wiki.nftables.org/wiki-nftables/index.php/Configuring_chains +- OpenWrt wiki: firewall configuration (nftables.d drop-in includes) + - https://openwrt.org/docs/guide-user/firewall/firewall_configuration diff --git a/packages/pirania/files/etc/config/pirania b/packages/pirania/files/etc/config/pirania index 147d1990b..d5b72fb36 100644 --- a/packages/pirania/files/etc/config/pirania +++ b/packages/pirania/files/etc/config/pirania @@ -13,13 +13,49 @@ config base_config 'base_config' list allowlist_ipv4 '10.0.0.0/8' list allowlist_ipv4 '172.16.0.0/12' list allowlist_ipv4 '192.168.0.0/16' + list allowlist_ipv4_url 'https://raw.githubusercontent.com/HybridNetworks/whatsapp-cidr/refs/heads/main/WhatsApp/whatsapp_cidr_ipv4.txt' + option allowlist_ipv4_url_insecure '1' list allowlist_ipv6 'fc00::/7' list allowlist_ipv6 'fe80::/64' list allowlist_ipv6 '2a00:1508:0a00::/40' - list catch_interfaces 'br-lan' - # list catch_interfaces 'anygw' + # Interface catch lists (ifnames, not UCI network names) + # catch_interfaces: L3 interfaces for direct nftables matching + # catch_bridged_interfaces: L2 bridged interfaces for bridge-family marking + # + # MESH-SAFE DEFAULT: Only catch Wi-Fi AP interfaces, not br-lan. + # br-lan typically includes bat0 (mesh backbone) which must NOT be caught. + # Uncomment catch_interfaces only if you explicitly want to portal wired LAN + # or understand that br-lan may include mesh interfaces. + # + # list catch_interfaces 'br-lan' list catch_bridged_interfaces 'wlan0-ap' config access_mode 'read_for_access' option url_portal '/portal/read_for_access.html' option duration_m '15' + +# Tranca Redes - Second-layer access control mode +# When enabled and active (during scheduled hours), restricts internet access: +# - Authorized MACs can only reach allowlist_category destinations +# - Unrestricted voucher holders bypass Tranca restrictions entirely +# - Unauthorized MACs remain blocked as usual +config tranca_redes 'tranca_redes' + option enabled '0' + option active '0' + # Schedule: start_time and end_time in 24h format (HH:MM) + # If end_time < start_time, schedule wraps across midnight + option start_time '20:00' + option end_time '07:00' + # Days when Tranca Redes is active (mon, tue, wed, thu, fri, sat, sun) + list days 'mon' + list days 'tue' + list days 'wed' + list days 'thu' + list days 'fri' + # Categories to allow during Tranca Redes (references allowlist_category sections) + list allowlist_category 'messenger' + +# Allowlist category definitions for Tranca Redes +# Each category can have multiple ipv4_url sources +config allowlist_category 'messenger' + list ipv4_url 'https://raw.githubusercontent.com/nickoppen/libremesh-whatsapp-ipv4-allowlist/refs/heads/main/whatsapp_ipv4_cidr.txt' diff --git a/packages/pirania/files/etc/pirania/portal.json b/packages/pirania/files/etc/pirania/portal.json index a30e2a2c4..7989e6aca 100644 --- a/packages/pirania/files/etc/pirania/portal.json +++ b/packages/pirania/files/etc/pirania/portal.json @@ -1,6 +1,6 @@ { "title": "Pirania", - "main_text": "In order to have access to Interet you must enter a valid voucher. In the meantime you can continue using the local network services.", + "main_text": "In order to have access to Internet you must enter a valid voucher. In the meantime you can continue using the local network services.", "logo": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAJYAAABrCAMAAACi04sXAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTExIDc5LjE1ODMyNSwgMjAxNS8wOS8xMC0wMToxMDoyMCAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgTWFjaW50b3NoIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjc0MjEyOEY5NUEyOTExRTlBNTMwOTU0MjBCQTA4RDNCIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjc0MjEyOEZBNUEyOTExRTlBNTMwOTU0MjBCQTA4RDNCIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6NzQyMTI4Rjc1QTI5MTFFOUE1MzA5NTQyMEJBMDhEM0IiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6NzQyMTI4Rjg1QTI5MTFFOUE1MzA5NTQyMEJBMDhEM0IiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz4ENYiSAAAAM1BMVEUQEBBAQEDv7+9/f3+/v79gYGDPz8+fn5/f398wMDAgICBwcHCvr6+Pj49QUFAAAAD///9/TN52AAAAEXRSTlP/////////////////////ACWtmWIAAAttSURBVHjazFvZguMoDOQGA8b9/1+7SOL0Fbund6b90JvJJnahoyQVhH1993J8y9cSzNf/cLFvf1Nu5eLql8HSEi22afF7YNltc19fwvqMi62/BpZgm8YXEYAl8UtgfYVtiwQwv9q8+SWwXHZesZHJBmPqd8CCoOfVoykbTP4OWF/LtoX6eoWM/B2wDKvhlS+V/2H/GSwlpVU17WIPLwJp/xGsQMzupankxb/+B1xvYbFacjaW1mw1PiIx+W3zT2BlJliV1R3dlIDZeMz9C1jgKKg0ppTDbfZb5oxF/IuQB1ze4qONlYHLOZrSz9DEe4IwCwTWFXWKhaz593lLSH9DnWBN83dhOSuBtZRKNymXuWz5q7D0Nl7qhtrkX4RlJlT8MuGEf8WquV1j9k+sBbG+SOOyF2+jR71hL3KB+wNYQiKL8mtMgJhCjz+85wojyjEi3mWiWJEdjv4TKvLJx9v6yF4O6NchLDPWsdcEEQ/RLqLcQaIrxUcuNGCxfF/whP8erBjAWl6M9muQFk7soaxMnvqMT5FPY8qSGQWq6QLwXsMqobXx7h5XOIPLuAs4tyIyHz9FlgJH2kzCi1t6RD6HBWUFAPTnKwTlwwWDKbTj7aTmwW0hByvLLlAD3z2HlZsDFoY4NvhUfmcOBAaTWnY1XFIcAl4CNs3BanrgCfYq1rvgINBSy6chDAKZGbfU1nHd+9AANg4l1o119kVsxYSxZepg+KhVANcvgE3LdCgOemM0MpEvhzL7LhPXQsio1izmeUhib4gJwsxYNhJ2aODCHF78u3Tq0UIl+C9iWWkO1uGr6M5fxum7BZDAu4ELAq7UfhOWRfcjKhbvR6PeKsqxLKBPh9ppABvelHUufQtLggsR1aUQgvm6KhVDaRXd3BZCeyH7Z/Nfjh+bjfUalkFUl2NE7MEDSajhqXP3pXq/wDGapG4MplaZONfqvRPTLSoxmgbaZ20PvWpoRDAAzr1cCG3K0+9gCfriNXHriTQMO2tj4SaiQFFjiiNR8BTAzXtYbtXJ3jV4dwOX2JlGbWNXMITCWqCI4b025+Xw4+zgpruGWN+rRWrfxfNtkJrGHotGymV8j9u+uMT239jYdfB80rDk3mNgriOTcDITG9dg3HgXy47hqNjFw+0nZS0NbmkIYlOgRH+KwqRVFwGcO4r9XVRJoW+gyllebqdUJwxyouDdBxRcYWNXa5Nf7GxxcTsZqQBVus/UOlnwFp6ivkVVtN2J+pmLEpq73wOsUCHEI6oPYowp38YE5LMB+SDuKFifOos6KppmT6eyrkjvE9t8RtUSMQyjXyov0iCFKQCtz3woa4vBDg93tVeIOzt+HElVMQdZZp2SU86wgjiJU9ynIe+zQ+cSaqxq6kJzs6ujvRMdBli+w5L1PrI5dms9xSoP2kocN4/YoWD5KSiW1qeEz7Vpo4kqzJaRdcF1ZTlCItvP3WhO7c47CNfiknZ0oDgngvZAeyyfigMsO7zoabClg/H5NOmxIwPzIa2oJcKN1gfaYyVP37OuE79KQQzKDz7FTZk+pBQ7YSfXZ7gaAtdlZ61PIwy6BlJJkOWoz5GP4V09Kkh6xMVOhPdQPyaIYmqTEi6E1HY3U31tZFFG3JGDqTuSpfAPjhtxMRz48kDg+lpYH8S/moR2vkWR31x8f7Df21QfG5JQux0SCoahYGgZWWotoZuDnnLINpVUzcNUfW+Fb5ghBOxRQBxHN1tbwwDGWcEJYuhQdN8sCSQElUmlamaCvKYbGjtrNeU++BE+1NkBF84TaZqmbSWbEuIwcLN2aEHXvGXkM1JjcFVtBiB4EAolMvUhTiR+xHUoOICEYSpc5s6TyD7jyTFRkkKO+koqK2SVP4WsFaNuXnLydOwkdNgKIP7IeP3Uv4OEIlDPwRgmESWP+4Fkr2x9+FglLlcEqoXrRk+sVxXMKi1a0HN6JpUNHAzdfisA+INHsGhrNMUok9dhxPpZOgdUcpLz2FxMmO7hj3dcnClINcGC6lgdqXYzRIaVb2hK1tbVF6NsXu10RMYxivl60mcqGhLLQpgbidJieJbBt3go/0fgDjmcDZG7VWaDSJjl+ZylLj/jRNM1ehgFP2wXyPFDZbTD0C4MT76EGTkHCjbaM285ny2YQ/3jgQPLKd3j8nn7hZUpqz6Lzhmhh6mc1dkP0iqN1NI2UhS8BZ5P7pPwStqhYx9le1botjPeygpTESxbsx9IqOSpGTsOT/MSpluy530Geq9LnOERLCoDrdfJ0Sd68Q9U5pwtNItu5mN1sWXDmtLtqMY73GPwg3Cqn8EirX7eusSDM1JBiciL9X1CMGn8JJoLSNSijEkEZbuypuRyUOgfOrErtG6nLVJnVDYEdL115FrM5jKt5kRd9w7gogTy84k4uLV8BqvEessmHNc6ss3rOCWFHJfuqRaGJq4M5MlGIb/dmomnsCjWa3EjEcpZFPVA9YaIxq0xjC3vJkq1VAp62pjMW3CtR9JQy5PDEmMbiPK5rXwkanOUQVHBRDZb9mo8qZ7jEHY3HOlte7KrN3WnQOasdA++9sprUeFBXT/w1myuD/sHZl0eUfwAy6yhhLbqTU2OG6bKnK+wGOHmzJnEgkLgcn2uEgkGIeVwkA9hDRUek6xQO00yawln2F/TZ50zFkqU+8nX6x64kS17/CSZ3sKigpOHZynLESiHswIUYjrHZgsTiNNqRuaSsIZSUpdQgl0oGwq/sKCwAzdPzkkwyi2227xC6WaF+w+laJkbmLm/odBPm02dG6aTAKV1C+tRTT2DBai4O9FuV7f1bU0S3PnFLT302TBAr3kwgGcPlJdJH/KZlUbX+yeDMIOMLx26spLXPUtkBviz2C64i6skol0XKEG4tZtNCsuQUa0YTZimsZ03jk9g0UThaghUssZbtFmJQj1eyjZoLhgbymliHN+apgtNLeCW2Oc+8GGGhaJSjYZla9vYduh7FcWG7nvcp+bKsD0FvVGUKGoryp8ElGnLVePR+VRWiZDqvti6SEfxgQbjNKPdVH40F46kKMfAkQtyH9QLOH+RrZZv7oR8dD6CwUCUL7PfkEEsnmaeosHFm+pC5spLYYwB4fAN9US55TvamNfKNh0f+e9EGpnO9oGJsBFbE1FNOJ6F2ZtLod8xhaVEhdThFAKvNv78INwEqx78a7DKqeBCov5uMlBkLjK3RK4H0c+jWpDfi9ubY5Zs7hrH9fA2kFF6mvvzTyX+qFHFfQfa6Yylr2XPzwNNsKA/wwIdeFIUJ2SHMgKHe6FSEXiavPNfh2SnaHB3uI3vvgMrkGRgq/7UVkf6Z35I+iRROooEFr+MwRwptIIay/riRCobV8sqKoBlmkcd3ld+ksALuY1CWCwK7+uj9GyeFTAXSTdZeyh5KkSfdmp5ef5efFPvD+6yQb3OxsLNnoCwUqcD+Uw4aMJJPKv+34MFCY5jmaXN+Km1WtkjXO28GmlS5gdgSapnzOJmhAG7rbMj1u2TDUwftXAhfnU/AKtsT1Hx90dKx2yQdyQx1K5yCM0nqTn3ueTGb8Gi+a/0oXz8+cdOhb87WSdGlZwOGQ8jcPxOyJtaryV1OuwsNEgOUndRP37PRFD48rUktt1V1PtS3WrQBar8P9P9GTd7+c31zZH1My4SmrHrX6vRkb9LV9rt5Cxw6d/YH8H6dBlNA5a5wnU+CIbL3vZnYDUp/dyXUNrPzp3I/x0WGKVIbCfhj2fHjqZM/7MTm/7OrpDpE0e6Pw3551ckod2ve9tAVfTqrO/5G7CaYrWxZN2czXO6uvTqNwd/DAt+Z1rGTJ/WoYdBImEBWi6n9KuW+UdgkXrFmiAS5EoSupl+vPHqZ6g/A4uEuzT+hCvjq0ozgnrXoP4cLNqCkpqzw4F++bq/+VlYvUJLTleSMn7jR2X/CTAA8COGKzk+uG4AAAAASUVORK5CYII=", "background_color": "#f0e5de", "link_title": "", diff --git a/packages/pirania/files/etc/uci-defaults/90-captive-portal-cron b/packages/pirania/files/etc/uci-defaults/90-captive-portal-cron index 81f106db4..c1df62400 100755 --- a/packages/pirania/files/etc/uci-defaults/90-captive-portal-cron +++ b/packages/pirania/files/etc/uci-defaults/90-captive-portal-cron @@ -6,7 +6,7 @@ unique_append() } unique_append \ - '*/10 * * * * ((captive-portal update &> /dev/null)&)'\ + '*/10 * * * * [ "$(uci -q get pirania.base_config.enabled)" = "1" ] && ((captive-portal update &> /dev/null)&)'\ /etc/crontabs/root exit 0 diff --git a/packages/pirania/files/etc/uci-defaults/91-tranca-redes-cron b/packages/pirania/files/etc/uci-defaults/91-tranca-redes-cron new file mode 100644 index 000000000..6eba73646 --- /dev/null +++ b/packages/pirania/files/etc/uci-defaults/91-tranca-redes-cron @@ -0,0 +1,14 @@ +#!/bin/sh +# Install Tranca Redes scheduler cron entry +# Runs every minute to evaluate schedule and toggle active state + +unique_append() +{ + grep -qF "$1" "$2" || echo "$1" >> "$2" +} + +unique_append \ + '* * * * * /usr/bin/tranca-redes-scheduler'\ + /etc/crontabs/root + +exit 0 diff --git a/packages/pirania/files/usr/bin/captive-portal b/packages/pirania/files/usr/bin/captive-portal index 99d3adae7..b9dbd9c47 100755 --- a/packages/pirania/files/usr/bin/captive-portal +++ b/packages/pirania/files/usr/bin/captive-portal @@ -1,67 +1,272 @@ #!/bin/sh -# requires nftables, liblucihttp0, liblucihttp-lua, uhttpd, uhttpd-mod-lua, uhttpd-mod-ubus +# requires nftables, uhttpd, uhttpd-mod-lua, uhttpd-mod-ubus +LOG_TAG="captive-portal" + +log_info() { + logger -t "$LOG_TAG" "$1" +} + +log_warn() { + logger -t "$LOG_TAG" -p user.warning "WARNING: $1" +} clean_tables () { echo "Cleaning captive-portal rules if there's any" + # Remove bridge table first (marking) + if nft list tables bridge 2>/dev/null | grep -q "pirania"; then + nft delete table bridge pirania + fi + # Remove inet table (rules) if nft list tables inet | grep -q "pirania"; then nft delete table inet pirania fi - } -set_nftables () { - echo "Apply captive-portal rules" - # Detect wheter add or insert rules - #append_nft_rules=$(uci get pirania.base_config.append_nft_rules 2> /dev/null) - #if [ "$append_nft_rules" = "1" ] ; then - # op="add rule" - #else - # op="insert rule" - #fi - - # Create pirania tables - nft create table inet pirania - # Create default tables and chains - nft add table inet pirania - nft add chain inet pirania prerouting { type nat hook prerouting priority 0 \; } - nft add chain inet pirania input { type filter hook input priority 0 \; } - nft add chain inet pirania forward { type filter hook forward priority 0 \; } - - # Add mac-adress set - nft add set inet pirania pirania-auth-macs { type ether_addr\; } - - # Create ipv4 set on pirania table - nft add set inet pirania pirania-allowlist-ipv4 { type ipv4_addr \; flags interval \; comment \"allow ipv4 list\" \; } - # Create ipv6 set on pirania table - nft add set inet pirania pirania-allowlist-ipv6 { type ipv6_addr \; flags interval \; comment \"allow ipv6 list\" \; } - - # Only accept packets from interfaces defined in catch_bridged_interfaces - catch_interfaces=$(uci get pirania.base_config.catch_bridged_interfaces | sed 's/ /,/g') - - # stop processing the chain for authorized macs and allowed ips (so they are accepted) - nft add rule inet pirania prerouting ether saddr @pirania-auth-macs ct state new,established,related counter log prefix "ValidSMAC" accept - nft add rule inet pirania prerouting ip daddr @pirania-allowlist-ipv4 ct state new,established,related counter log prefix "ACCEPT-ipv4" accept - nft add rule inet pirania prerouting ip6 daddr @pirania-allowlist-ipv6 ct state new,established,related counter log prefix "ACCEPT-ipv6" accept - - # send DNS requests, that are not from valid ips or macs, to our own captive portal DNS at 59053 - nft add rule inet pirania prerouting meta l4proto udp udp dport 53 ether saddr != @pirania-auth-macs ct state new,established,related counter log prefix "SMACDNS" redirect to :59053 - # redirect packets with dest port 80 to port 59080 of this host (the captive portal page). - nft add rule inet pirania prerouting meta l4proto tcp tcp dport 80 ether saddr != @pirania-auth-macs ct state new,established,related counter log prefix "SMACHTTP" redirect to :59080 - - #nft add rule inet pirania prerouting meta l4proto tcp tcp dport 80 ip saddr @pirania-allowlist-ipv4 ct state new,established,related counter log prefix "IPv4HTTP" redirect to :59080 - #nft add rule inet pirania prerouting meta l4proto tcp tcp dport 80 ip6 saddr @pirania-allowlist-ipv6 ct state new,established,related counter log prefix "IPV6HTTP" redirect to :59080 - - #nft add rule inet pirania prerouting meta l4proto udp udp dport 53 ip saddr @pirania-allowlist-ipv4 ct state new,established,related counter redirect to :59053 - #nft add rule inet pirania prerouting meta l4proto udp udp dport 53 ip6 saddr @pirania-allowlist-ipv6 ct state new,established,related counter redirect to :59053 - - - # reject - - #nft add rule inet pirania prerouting drop - #nft add rule inet pirania forward meta mark 0x11/0x11 counter reject with tcp reset - #nft add rule inet pirania forward meta mark 0x11/0x11 counter reject +set_nftables () { + echo "Apply captive-portal rules" + + # Legacy mark constant (compatible with previous ebtables implementation) + PIRANIA_MARK="0x9124714" + + # Get interface configuration + catch_interfaces=$(uci -q get pirania.base_config.catch_interfaces) + catch_bridged_interfaces=$(uci -q get pirania.base_config.catch_bridged_interfaces) + + # Check if bridge-family nftables is available (required for catch_bridged_interfaces) + if [ -n "$catch_bridged_interfaces" ]; then + if ! nft list tables bridge >/dev/null 2>&1; then + log_warn "Bridge-family nftables not available (missing kmod-nft-bridge?)" + log_warn "catch_bridged_interfaces configured but cannot be used" + echo "Error: bridge-family nftables not available (missing kmod-nft-bridge?)" + exit 1 + fi + fi + + # Previous cleanup + nft delete table bridge pirania 2>/dev/null + nft delete table inet pirania 2>/dev/null + + # ========================================================================== + # BRIDGE TABLE (L2 marking for bridged interfaces) + # ========================================================================== + if [ -n "$catch_bridged_interfaces" ]; then + nft add table bridge pirania + nft add set bridge pirania pirania-catch-bridge-ifaces \{ type ifname\; \} + nft add chain bridge pirania prerouting \{ type filter hook prerouting priority dstnat\; \} + nft add rule bridge pirania prerouting iifname @pirania-catch-bridge-ifaces meta mark set $PIRANIA_MARK + fi + + # ========================================================================== + # INET TABLE (L3 gating and rules) + # ========================================================================== + nft add table inet pirania + + # --- Sets --- + nft add set inet pirania pirania-catch-ifaces \{ type ifname\; \} + nft add set inet pirania pirania-auth-macs \{ type ether_addr\; \} + nft add set inet pirania pirania-allowlist-ipv4 \{ type ipv4_addr\; flags interval\; comment \"allow ipv4 list\"\; \} + nft add set inet pirania pirania-allowlist-ipv6 \{ type ipv6_addr\; flags interval\; comment \"allow ipv6 list\"\; \} + nft add set inet pirania pirania-tranca-allowlist-ipv4 \{ type ipv4_addr\; flags interval\; comment \"tranca redes category allowlist\"\; \} + nft add set inet pirania pirania-unrestricted-macs \{ type ether_addr\; comment \"unrestricted voucher MACs\"\; \} + + # --- Regular chains (MUST be created BEFORE base chains that jump to them) --- + nft add chain inet pirania pirania_prerouting + nft add chain inet pirania pirania_forward + + # --- Base chains with gating logic --- + nft add chain inet pirania prerouting \{ type nat hook prerouting priority -100\; \} + nft add rule inet pirania prerouting iifname @pirania-catch-ifaces jump pirania_prerouting + nft add rule inet pirania prerouting meta mark $PIRANIA_MARK jump pirania_prerouting + + nft add chain inet pirania forward \{ type filter hook forward priority 0\; policy accept\; \} + nft add rule inet pirania forward iifname @pirania-catch-ifaces jump pirania_forward + nft add rule inet pirania forward meta mark $PIRANIA_MARK jump pirania_forward + nft add rule inet pirania forward meta mark $PIRANIA_MARK meta mark set 0 + + # --- Prerouting rules --- + nft add rule inet pirania pirania_prerouting ip daddr @pirania-allowlist-ipv4 accept + nft add rule inet pirania pirania_prerouting ip6 daddr @pirania-allowlist-ipv6 accept + nft add rule inet pirania pirania_prerouting udp dport \{ 67, 68 \} accept + nft add rule inet pirania pirania_prerouting udp dport \{ 546, 547 \} accept + nft add rule inet pirania pirania_prerouting ether saddr @pirania-auth-macs accept + nft add rule inet pirania pirania_prerouting tcp dport 80 redirect to :59080 + nft add rule inet pirania pirania_prerouting udp dport 53 redirect to :59053 + nft add rule inet pirania pirania_prerouting drop + + # --- Forward rules --- + tranca_active=$(uci -q get pirania.tranca_redes.active) + + if [ "$tranca_active" = "1" ]; then + nft add rule inet pirania pirania_forward ip daddr @pirania-allowlist-ipv4 return + nft add rule inet pirania pirania_forward ip6 daddr @pirania-allowlist-ipv6 return + nft add rule inet pirania pirania_forward udp dport 53 return + nft add rule inet pirania pirania_forward ether saddr @pirania-unrestricted-macs return + nft add rule inet pirania pirania_forward ether saddr @pirania-auth-macs ip daddr @pirania-tranca-allowlist-ipv4 return + nft add rule inet pirania pirania_forward ether saddr @pirania-auth-macs drop + else + nft add rule inet pirania pirania_forward ether saddr @pirania-auth-macs return + nft add rule inet pirania pirania_forward udp dport 53 return + nft add rule inet pirania pirania_forward ip daddr @pirania-allowlist-ipv4 return + nft add rule inet pirania pirania_forward ip6 daddr @pirania-allowlist-ipv6 return + fi + + nft add rule inet pirania pirania_forward tcp dport 443 ether saddr != @pirania-auth-macs drop + + echo "Captive-portal rules applied successfully" +} + +download_allowlist_ipv4 () { + allowlist_urls=$(uci -q get pirania.base_config.allowlist_ipv4_url) + if [ -z "$allowlist_urls" ]; then + return + fi + allowlist_insecure=$(uci -q get pirania.base_config.allowlist_ipv4_url_insecure) + tmpfile="/tmp/messengers_allowlist.txt" + echo "Downloading IPv4 allowlist URLs to $tmpfile" + : > "$tmpfile" + + for url in $allowlist_urls ; do + if ! wget -q -O - "$url" >> "$tmpfile" ; then + if [ "$allowlist_insecure" = "1" ]; then + echo "Retrying IPv4 allowlist download without certificate check: $url" + if ! wget --no-check-certificate -q -O - "$url" >> "$tmpfile" ; then + echo "Failed to download IPv4 allowlist from $url" + fi + else + echo "Failed to download IPv4 allowlist from $url" + fi + else + echo "" >> "$tmpfile" + fi + done + echo "IPv4 allowlist downloads completed" +} + +# Download Tranca Redes category allowlists +download_tranca_allowlist_ipv4 () { + log_info "Starting Tranca Redes category allowlist download" + local tmpfile="/tmp/tranca_allowlist_ipv4.txt" + : > "$tmpfile" + + local allowlist_insecure + allowlist_insecure=$(uci -q get pirania.base_config.allowlist_ipv4_url_insecure) + + # Get list of enabled categories + local categories + categories=$(uci -q show pirania.tranca_redes.allowlist_category 2>/dev/null | grep -oE "'[^']+'" | tr -d "'") + + if [ -z "$categories" ]; then + log_warn "No allowlist categories configured for Tranca Redes" + return + fi + + local total_entries=0 + + for category in $categories; do + echo "Processing category: $category" + log_info "Downloading allowlist for category: $category" + + # Get URLs for this category + local urls + urls=$(uci -q show "pirania.$category.ipv4_url" 2>/dev/null | grep -oE "'[^']+'" | tr -d "'") + + if [ -z "$urls" ]; then + log_warn "No URLs configured for category: $category" + continue + fi + + for url in $urls; do + echo " Downloading: $url" + if ! wget -q -O - "$url" >> "$tmpfile" 2>/dev/null; then + if [ "$allowlist_insecure" = "1" ]; then + echo " Retrying without certificate check: $url" + if ! wget --no-check-certificate -q -O - "$url" >> "$tmpfile" 2>/dev/null; then + log_warn "Failed to download Tranca allowlist: $url" + fi + else + log_warn "Failed to download Tranca allowlist: $url" + fi + else + echo "" >> "$tmpfile" + fi + done + done + + # Count entries for logging + if [ -f "$tmpfile" ]; then + total_entries=$(grep -cE '([0-9]{1,3}\.){3}[0-9]{1,3}' "$tmpfile" 2>/dev/null || echo "0") + fi + + log_info "Tranca Redes allowlist download completed: $total_entries entries" +} + +# Get Tranca allowlist tokens for nftables +get_tranca_allowlist_ipv4_tokens () { + if [ -f /tmp/tranca_allowlist_ipv4.txt ]; then + tr -d '\r' < /tmp/tranca_allowlist_ipv4.txt | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[12][0-9]|3[0-2]))?' + fi +} + +# Add Tranca allowlist elements to nftables set (batched) +add_tranca_allowlist_elements () { + local batch="" + local count=0 + + while read -r token; do + if [ -z "$token" ]; then + continue + fi + if [ -z "$batch" ]; then + batch="$token" + else + batch="$batch,$token" + fi + count=$((count + 1)) + if [ "$count" -ge 200 ]; then + nft add element inet pirania pirania-tranca-allowlist-ipv4 {$batch} + batch="" + count=0 + fi + done + + if [ -n "$batch" ]; then + nft add element inet pirania pirania-tranca-allowlist-ipv4 {$batch} + fi +} + +add_allowlist_ipv4_elements () { + batch="" + count=0 + + while read -r token; do + if [ -z "$token" ]; then + continue + fi + if [ -z "$batch" ]; then + batch="$token" + else + batch="$batch,$token" + fi + count=$((count + 1)) + if [ "$count" -ge 200 ]; then + nft add element inet pirania pirania-allowlist-ipv4 {$batch} + batch="" + count=0 + fi + done + + if [ -n "$batch" ]; then + nft add element inet pirania pirania-allowlist-ipv4 {$batch} + fi +} + +get_allowlist_ipv4_tokens () { + uci -q get pirania.base_config.allowlist_ipv4 | tr ' ' '\n' + if [ -f /tmp/messengers_allowlist.txt ]; then + tr -d '\r' < /tmp/messengers_allowlist.txt | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}(/([0-9]|[12][0-9]|3[0-2]))?' + fi } update_ipsets () { @@ -69,23 +274,62 @@ update_ipsets () { # Create tables and sets echo "Updating captive-portal rules" + # --- Update interface catch sets --- + nft flush set inet pirania pirania-catch-ifaces + catch_interfaces=$(uci -q get pirania.base_config.catch_interfaces) + for iface in $catch_interfaces; do + nft add element inet pirania pirania-catch-ifaces \{$iface\} + echo "Adding L3 catch interface: $iface" + done + + catch_bridged_interfaces=$(uci -q get pirania.base_config.catch_bridged_interfaces) + if [ -n "$catch_bridged_interfaces" ] && nft list set bridge pirania pirania-catch-bridge-ifaces >/dev/null 2>&1; then + nft flush set bridge pirania pirania-catch-bridge-ifaces + for iface in $catch_bridged_interfaces; do + nft add element bridge pirania pirania-catch-bridge-ifaces \{$iface\} + echo "Adding L2 catch interface: $iface" + done + fi + # Add authorized MAC addresses + nft flush set inet pirania pirania-auth-macs for mac in $(pirania_authorized_macs) ; do nft add element inet pirania pirania-auth-macs {$mac} - echo "Adicionando enderecos:" $mac + echo "Adding authorized MAC: $mac" done # Update pirania-allowlist sets for ipv4 and ipv6 nft flush set inet pirania pirania-allowlist-ipv4 nft flush set inet pirania pirania-allowlist-ipv6 - # Add allowed ip/prefixes # Get values from allowlist_ipvX and add to pirania-allowlist-ipvX set - ipv4allowlist=$(uci get pirania.base_config.allowlist_ipv4 | sed 's/ /,/g') - nft add element inet pirania pirania-allowlist-ipv4 {$ipv4allowlist} + get_allowlist_ipv4_tokens | add_allowlist_ipv4_elements ipv6allowlist=$(uci get pirania.base_config.allowlist_ipv6 | sed 's/ /,/g') nft add element inet pirania pirania-allowlist-ipv6 {$ipv6allowlist} + + # --- Tranca Redes handling --- + tranca_active=$(uci -q get pirania.tranca_redes.active) + + # Always flush Tranca sets first + nft flush set inet pirania pirania-tranca-allowlist-ipv4 2>/dev/null + nft flush set inet pirania pirania-unrestricted-macs 2>/dev/null + + if [ "$tranca_active" = "1" ]; then + log_info "Tranca Redes is active, updating category allowlists" + + # Download and populate Tranca category allowlists + download_tranca_allowlist_ipv4 + get_tranca_allowlist_ipv4_tokens | add_tranca_allowlist_elements + + # Update unrestricted MACs + for mac in $(pirania_authorized_macs --unrestricted) ; do + nft add element inet pirania pirania-unrestricted-macs {$mac} + echo "Adding unrestricted MAC: $mac" + done + + log_info "Tranca Redes ipsets updated" + fi } # check if captive-portal is enabled in /etc/config/pirania @@ -97,10 +341,38 @@ if [ "$1" = "start" ]; then /etc/init.d/pirania-uhttpd start clean_tables set_nftables + echo "Starting IPv4 allowlist download" + download_allowlist_ipv4 + echo "IPv4 allowlist download finished" update_ipsets exit elif [ "$1" = "update" ] ; then + if [ "$enabled" != "1" ]; then + echo "Captive-portal disabled, skipping update" + exit + fi echo "Captive-portal updating rules" + # Check if nftables rules need rebuilding due to Tranca state change + # Rules are conditional on Tranca active state + tranca_active=$(uci -q get pirania.tranca_redes.active) + tranca_rules_present=$(nft list chain inet pirania forward 2>/dev/null | grep -c "TRANCA_BLOCK_AUTH_MAC" || echo "0") + + needs_rebuild=0 + if [ "$tranca_active" = "1" ] && [ "$tranca_rules_present" = "0" ]; then + # Tranca is active but rules are not present - need rebuild + needs_rebuild=1 + log_info "Tranca Redes activated, rebuilding nftables rules" + elif [ "$tranca_active" != "1" ] && [ "$tranca_rules_present" != "0" ]; then + # Tranca is inactive but rules are still present - need rebuild + needs_rebuild=1 + log_info "Tranca Redes deactivated, rebuilding nftables rules" + fi + + if [ "$needs_rebuild" = "1" ]; then + clean_tables + set_nftables + download_allowlist_ipv4 + fi update_ipsets exit elif [ "$1" = "clean" ] || [ "$1" = "stop" ] ; then @@ -109,7 +381,8 @@ elif [ "$1" = "clean" ] || [ "$1" = "stop" ] ; then elif [ "$enabled" = "1" ]; then echo "Captive-portal already enabled, reloading rules" clean_tables -# set_nftables + set_nftables # required for "/etc/init.d/pirania start|restart|reload" if "uci get pirania.base_config.enabled" is "1" + download_allowlist_ipv4 update_ipsets exit elif [ "$1" = "enabled" ]; then @@ -120,4 +393,4 @@ elif [ "$1" = "enabled" ]; then else echo "Pirania captive-portal is disabled. Try running captive-portal start" exit -fi \ No newline at end of file +fi diff --git a/packages/pirania/files/usr/bin/pirania_authorized_macs b/packages/pirania/files/usr/bin/pirania_authorized_macs index 48fa90511..ecbb91dca 100755 --- a/packages/pirania/files/usr/bin/pirania_authorized_macs +++ b/packages/pirania/files/usr/bin/pirania_authorized_macs @@ -2,6 +2,22 @@ local portal = require('portal.portal') -for _, mac in pairs(portal.get_authorized_macs()) do +-- Check for --unrestricted flag +local unrestricted_only = false +for i, arg in ipairs(arg) do + if arg == "--unrestricted" or arg == "-u" then + unrestricted_only = true + break + end +end + +local macs +if unrestricted_only then + macs = portal.get_unrestricted_macs() +else + macs = portal.get_authorized_macs() +end + +for _, mac in pairs(macs) do print(mac) end diff --git a/packages/pirania/files/usr/bin/tranca-redes-scheduler b/packages/pirania/files/usr/bin/tranca-redes-scheduler new file mode 100755 index 000000000..2bdcbc7a9 --- /dev/null +++ b/packages/pirania/files/usr/bin/tranca-redes-scheduler @@ -0,0 +1,261 @@ +#!/bin/sh +# Tranca Redes Scheduler +# Evaluates schedule every minute and toggles active state +# Run via cron: * * * * * /usr/bin/tranca-redes-scheduler + +PIRANIA_CFG="pirania" +TRANCA_SECTION="tranca_redes" +LOG_TAG="tranca-redes" +LOCK_FILE="/tmp/tranca-redes-scheduler.lock" + +log_info() { + logger -t "$LOG_TAG" "$1" +} + +# Prevent concurrent execution +acquire_lock() { + if [ -f "$LOCK_FILE" ]; then + # Check if the process is still running + local pid + pid=$(cat "$LOCK_FILE" 2>/dev/null) + if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then + # Previous instance still running + return 1 + fi + # Stale lock file, remove it + rm -f "$LOCK_FILE" + fi + echo $$ > "$LOCK_FILE" + return 0 +} + +release_lock() { + rm -f "$LOCK_FILE" +} + +log_warn() { + logger -t "$LOG_TAG" -p user.warning "WARNING: $1" +} + +# Check if system time is valid (year >= 2020) +# Prevents misfire during boot before NTP sync +check_valid_time() { + current_year=$(date +%Y) + if [ "$current_year" -lt 2020 ]; then + log_warn "System time invalid (year=$current_year), skipping evaluation" + return 1 + fi + return 0 +} + +# Validate time format (HH:MM) +validate_time_format() { + local time="$1" + echo "$time" | grep -qE '^[0-2]?[0-9]:[0-5][0-9]$' +} + +# Convert HH:MM to minutes since midnight +time_to_minutes() { + local time="$1" + local hours="${time%%:*}" + local minutes="${time##*:}" + # Remove leading zeros to avoid octal interpretation + hours=$(echo "$hours" | sed 's/^0*//' | sed 's/^$/0/') + minutes=$(echo "$minutes" | sed 's/^0*//' | sed 's/^$/0/') + echo $((hours * 60 + minutes)) +} + +# Get current weekday abbreviation (mon, tue, wed, thu, fri, sat, sun) +# Uses LANG=C to ensure English day names regardless of system locale +get_current_day() { + LANG=C date +%a | tr '[:upper:]' '[:lower:]' +} + +# Check if current day is in the configured days list +is_day_active() { + local current_day="$1" + local days + days=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.days") + + if [ -z "$days" ]; then + return 1 + fi + + # Iterate through configured days + local idx=0 + while true; do + local day + day=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.days" 2>/dev/null | awk -v i=$idx '{print $(i+1)}') + if [ -z "$day" ]; then + # Try alternative method using list index + day=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.days.$idx" 2>/dev/null) + fi + + if [ -z "$day" ]; then + break + fi + + if [ "$day" = "$current_day" ]; then + return 0 + fi + + idx=$((idx + 1)) + done + + # Alternative: check using show + for day in $(uci -q show "$PIRANIA_CFG.$TRANCA_SECTION.days" 2>/dev/null | grep -oE "'[^']+'" | tr -d "'"); do + if [ "$day" = "$current_day" ]; then + return 0 + fi + done + + return 1 +} + +# Check if current time is within schedule +# Handles overnight wrap (e.g., 20:00 to 07:00) +is_time_in_schedule() { + local start_time="$1" + local end_time="$2" + local current_time="$3" + + local start_min end_min current_min + start_min=$(time_to_minutes "$start_time") + end_min=$(time_to_minutes "$end_time") + current_min=$(time_to_minutes "$current_time") + + if [ "$end_min" -gt "$start_min" ]; then + # Same-day schedule (e.g., 09:00 to 17:00) + [ "$current_min" -ge "$start_min" ] && [ "$current_min" -lt "$end_min" ] + else + # Overnight schedule (e.g., 20:00 to 07:00) + # Active if: (now >= start) OR (now < end) + [ "$current_min" -ge "$start_min" ] || [ "$current_min" -lt "$end_min" ] + fi +} + +# Main evaluation logic +evaluate_schedule() { + # Check if Tranca Redes is enabled + local enabled + enabled=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.enabled") + + if [ "$enabled" != "1" ]; then + # Not enabled, ensure active is 0 + local current_active + current_active=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.active") + if [ "$current_active" = "1" ]; then + uci set "$PIRANIA_CFG.$TRANCA_SECTION.active=0" + uci commit "$PIRANIA_CFG" + log_info "Tranca Redes disabled, deactivating" + captive-portal update &>/dev/null & + fi + return 0 + fi + + # Get schedule configuration + local start_time end_time + start_time=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.start_time") + end_time=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.end_time") + + if [ -z "$start_time" ] || [ -z "$end_time" ]; then + log_warn "Schedule not configured (start_time or end_time missing)" + return 1 + fi + + # Validate time format + if ! validate_time_format "$start_time"; then + log_warn "Invalid start_time format: $start_time (expected HH:MM)" + return 1 + fi + if ! validate_time_format "$end_time"; then + log_warn "Invalid end_time format: $end_time (expected HH:MM)" + return 1 + fi + + # Get current time and day + local current_time current_day + current_time=$(date +%H:%M) + current_day=$(get_current_day) + + # Determine if Tranca should be active + local should_be_active=0 + + if is_day_active "$current_day"; then + if is_time_in_schedule "$start_time" "$end_time" "$current_time"; then + should_be_active=1 + fi + fi + + # Handle overnight schedule that spans into next day + # If schedule wraps midnight (end < start), check if we're in the "morning" portion + local start_min end_min + start_min=$(time_to_minutes "$start_time") + end_min=$(time_to_minutes "$end_time") + + if [ "$end_min" -lt "$start_min" ]; then + # Overnight schedule - also check previous day + local current_min + current_min=$(time_to_minutes "$current_time") + + if [ "$current_min" -lt "$end_min" ]; then + # We're in the "morning" portion after midnight + # Check if yesterday was an active day + local yesterday + yesterday=$(LANG=C date -d "yesterday" +%a 2>/dev/null | tr '[:upper:]' '[:lower:]') + # Fallback for busybox date without -d support + if [ -z "$yesterday" ]; then + yesterday=$(LANG=C date -D %s -d $(($(date +%s) - 86400)) +%a 2>/dev/null | tr '[:upper:]' '[:lower:]') + fi + + if [ -n "$yesterday" ] && is_day_active "$yesterday"; then + should_be_active=1 + fi + fi + fi + + # Get current active state + local current_active + current_active=$(uci -q get "$PIRANIA_CFG.$TRANCA_SECTION.active") + [ -z "$current_active" ] && current_active=0 + + # Update state if changed + if [ "$should_be_active" != "$current_active" ]; then + uci set "$PIRANIA_CFG.$TRANCA_SECTION.active=$should_be_active" + uci commit "$PIRANIA_CFG" + + if [ "$should_be_active" = "1" ]; then + log_info "Tranca Redes ACTIVATED (day=$current_day, time=$current_time, schedule=$start_time-$end_time)" + else + log_info "Tranca Redes DEACTIVATED (day=$current_day, time=$current_time, schedule=$start_time-$end_time)" + fi + + # Trigger captive-portal update in background + captive-portal update &>/dev/null & + fi +} + +# Main entry point +main() { + # Skip entirely if Pirania itself is disabled + local pirania_enabled + pirania_enabled=$(uci -q get "$PIRANIA_CFG.base_config.enabled") + if [ "$pirania_enabled" != "1" ]; then + exit 0 + fi + + # Prevent concurrent execution + if ! acquire_lock; then + exit 0 + fi + trap release_lock EXIT + + # Validate system time first + if ! check_valid_time; then + return 0 + fi + + evaluate_schedule +} + +main "$@" diff --git a/packages/pirania/files/usr/bin/voucher b/packages/pirania/files/usr/bin/voucher index 5847ccb11..2ddbc72c9 100755 --- a/packages/pirania/files/usr/bin/voucher +++ b/packages/pirania/files/usr/bin/voucher @@ -72,12 +72,13 @@ captive_portal.invalidate = function(id) return vouchera.invalidate(id) end -captive_portal.add = function(name, duration_m, activation_deadline) +captive_portal.add = function(name, duration_m, activation_deadline, unrestricted) if type(duration_m) == "string" then duration_m = tonumber(duration_m) end local qty = 1 - res, msg = vouchera.create(name, qty, tonumber(duration_m), tonumber(activation_deadline)) + local is_unrestricted = (unrestricted == true or unrestricted == "true" or unrestricted == "1") + res, msg = vouchera.create(name, qty, tonumber(duration_m), tonumber(activation_deadline), is_unrestricted) if res ~= nil then local voucher = vouchera.get_by_id(res[1].id) print(voucher.tostring()) @@ -97,14 +98,46 @@ captive_portal.show_authorized_macs = function() return true end +--! Parse command line flags +local function parse_flags(arguments) + local flags = {} + local positional = {} + local i = 1 + while i <= #arguments do + local arg = arguments[i] + if arg == "--unrestricted" or arg == "-u" then + flags.unrestricted = true + elseif arg == "--duration" or arg == "-d" then + i = i + 1 + flags.duration = arguments[i] + elseif arg == "--deadline" then + i = i + 1 + flags.deadline = arguments[i] + elseif arg:sub(1, 2) ~= "--" and arg:sub(1, 1) ~= "-" then + table.insert(positional, arg) + end + i = i + 1 + end + return positional, flags +end + --! if is main if debug.getinfo(2).name == nil then local arguments = { ... } local action = table.remove(arguments, 1) - local context = arguments + local positional, flags = parse_flags(arguments) local f = captive_portal[action] if f ~= nil then - res, msg = f(unpack(context)) + local res, msg + if action == "add" then + -- Special handling for add command with flag support + local name = positional[1] + local duration_m = flags.duration or positional[2] + local deadline = flags.deadline or positional[3] + res, msg = f(name, duration_m, deadline, flags.unrestricted) + else + res, msg = f(unpack(positional)) + end if not res then print(msg or 'error') os.exit(1) diff --git a/packages/pirania/files/usr/lib/lua/portal/portal.lua b/packages/pirania/files/usr/lib/lua/portal/portal.lua index a40e70847..63f247ddf 100644 --- a/packages/pirania/files/usr/lib/lua/portal/portal.lua +++ b/packages/pirania/files/usr/lib/lua/portal/portal.lua @@ -60,11 +60,25 @@ function portal.get_authorized_macs() return auth_macs end +--! Returns MACs with active unrestricted vouchers +--! Only works in voucher mode; read_for_access doesn't support unrestricted flag +function portal.get_unrestricted_macs() + local unrestricted_macs = {} + local with_vouchers = portal.get_config().with_vouchers + if with_vouchers then + local vouchera = require("voucher.vouchera") + vouchera.init() + unrestricted_macs = vouchera.get_unrestricted_macs() + end + return unrestricted_macs +end + function portal.update_captive_portal(daemonized) if daemonized then utils.execute_daemonized('captive-portal update') else - os.execute('captive-portal update') + -- redirects stdout and stderr to /dev/null to not trigger 502 Bad Gateway after voucher portal auth + os.execute('captive-portal update > /dev/null 2>&1') end end diff --git a/packages/pirania/files/usr/lib/lua/read_for_access/read_for_access.lua b/packages/pirania/files/usr/lib/lua/read_for_access/read_for_access.lua index 1d3873704..d9d3d9115 100644 --- a/packages/pirania/files/usr/lib/lua/read_for_access/read_for_access.lua +++ b/packages/pirania/files/usr/lib/lua/read_for_access/read_for_access.lua @@ -42,7 +42,8 @@ function read_for_access.authorize_mac(mac) content = content:gsub("(" .. mac .. ") %d+", "%1 " .. timestamp) utils.write_file(read_for_access.AUTH_MACS_FILE, content) end - os.execute('/usr/bin/captive-portal update') + -- redirects stdout and stderr to /dev/null to not trigger 502 Bad Gateway after read for access portal + os.execute('/usr/bin/captive-portal update > /dev/null 2>&1') end function read_for_access.get_authorized_macs() diff --git a/packages/pirania/files/usr/lib/lua/voucher/utils.lua b/packages/pirania/files/usr/lib/lua/voucher/utils.lua index a4f0973e2..ea237a6be 100644 --- a/packages/pirania/files/usr/lib/lua/voucher/utils.lua +++ b/packages/pirania/files/usr/lib/lua/voucher/utils.lua @@ -1,6 +1,5 @@ #!/bin/lua local nixio = require('nixio') -local lhttp = require('lucihttp') local utils = {} @@ -8,6 +7,19 @@ function utils.log(...) nixio.syslog(...) end +--! Pure Lua URL encoding/decoding utilities +--! Replaces lucihttp dependency for OpenWrt compatibility + +--! Convert a character to its percent-encoded hex representation +local function char_to_hex(c) + return string.format("%%%02X", string.byte(c)) +end + +--! Convert a percent-encoded hex pair back to its character +local function hex_to_char(x) + return string.char(tonumber(x, 16)) +end + local function checkIfIpv4(ip) if ip == nil or type(ip) ~= "string" then return 0 @@ -54,48 +66,70 @@ function utils.getIpv4AndMac(ip_address) end end ---! from given url or string. Returns a table with urldecoded values. +--! URL-encode a string value +--! Encodes all characters except alphanumeric, hyphen, underscore, period, and tilde +--! Spaces are encoded as %20 (not +) for broader compatibility +function utils.urlencode(value) + if value == nil then + return nil + end + local str = tostring(value) + --! Encode all characters except unreserved ones (RFC 3986) + --! Unreserved: A-Z a-z 0-9 - _ . ~ + str = str:gsub("([^%w%-_%.~])", char_to_hex) + return str +end + +--! URL-decode a string value +--! Decodes percent-encoded sequences and optionally converts + to space +function utils.urldecode(value) + if value == nil then + return nil + end + local str = tostring(value) + --! Convert + to space (common in query strings) + str = str:gsub("+", " ") + --! Decode percent-encoded sequences + str = str:gsub("%%(%x%x)", hex_to_char) + return str +end + +--! Parse URL-encoded query string into a table +--! From given url or string. Returns a table with urldecoded values. --! Simple parameters are stored as string values associated with the parameter --! name within the table. Parameters with multiple values are stored as array --! containing the corresponding values. function utils.urldecode_params(url, tbl) - local parser, name - local params = tbl or { } - - parser = lhttp.urlencoded_parser(function (what, buffer, length) - if what == parser.TUPLE then - name, value = nil, nil - elseif what == parser.NAME then - name = lhttp.urldecode(buffer) - elseif what == parser.VALUE and name then - params[name] = lhttp.urldecode(buffer) or "" - end - - return true - end) + local params = tbl or {} - if parser then - parser:parse((url or ""):match("[^?]*$")) - parser:parse(nil) + if url == nil then + return params end - return params -end + --! Extract query string part (after ?) + local query = url:match("[^?]*$") or "" -function utils.urlencode(value) - if value ~= nil then - local str = tostring(value) - return lhttp.urlencode(str, lhttp.ENCODE_IF_NEEDED + lhttp.ENCODE_FULL) or str - end - return nil -end + --! Parse key=value pairs separated by & or ; + for pair in query:gmatch("[^&;]+") do + local key, value = pair:match("^([^=]+)=?(.*)") + if key then + key = utils.urldecode(key) + value = utils.urldecode(value) or "" -function utils.urldecode(value) - if value ~= nil then - local str = tostring(value) - return lhttp.urldecode(str, lhttp.DECODE_IF_NEEDED) or str + --! Handle multiple values for same key + if params[key] then + --! Convert to array if not already + if type(params[key]) ~= "table" then + params[key] = { params[key] } + end + table.insert(params[key], value) + else + params[key] = value + end + end end - return nil + + return params end return utils diff --git a/packages/pirania/files/usr/lib/lua/voucher/vouchera.lua b/packages/pirania/files/usr/lib/lua/voucher/vouchera.lua index 9fc45eb0b..e7772cc86 100644 --- a/packages/pirania/files/usr/lib/lua/voucher/vouchera.lua +++ b/packages/pirania/files/usr/lib/lua/voucher/vouchera.lua @@ -70,6 +70,12 @@ function voucher_init(obj) voucher.mod_counter = obj.mod_counter or 1 + -- Unrestricted voucher flag (bypasses Tranca Redes restrictions) + if not (type(obj.unrestricted) == "nil" or type(obj.unrestricted) == "boolean") then + return nil, "invalid unrestricted type" + end + voucher.unrestricted = obj.unrestricted or false + --! tostring must reflect all the state of a voucher (so vouchers can be compared reliably using tostring) voucher.tostring = function() local v = voucher @@ -78,8 +84,9 @@ function voucher_init(obj) if v.expiration_date() then expiration = os.date("%c", v.expiration_date()) end - return(string.format('%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s', v.id, v.name, v.code, v.mac or 'xx:xx:xx:xx:xx:xx', - creation, v.duration_m or 'perm', expiration, v.mod_counter)) + local unrestricted_str = v.unrestricted and 'unrestricted' or 'normal' + return(string.format('%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s', v.id, v.name, v.code, v.mac or 'xx:xx:xx:xx:xx:xx', + creation, v.duration_m or 'perm', expiration, v.mod_counter, unrestricted_str)) end voucher.expiration_date = function() @@ -167,7 +174,7 @@ function vouchera.get_by_id(id) return vouchera.vouchers[id] end -function vouchera.create(basename, qty, duration_m, activation_deadline) +function vouchera.create(basename, qty, duration_m, activation_deadline, unrestricted) local vouchers = {} for n=1, qty do local name @@ -177,7 +184,7 @@ function vouchera.create(basename, qty, duration_m, activation_deadline) name = basename .. "-" .. tostring(n) end local v = {name=name, code=vouchera.gen_code(), duration_m=duration_m, - activation_deadline=activation_deadline} + activation_deadline=activation_deadline, unrestricted=unrestricted} local voucher, msg = vouchera.add(v) if voucher == nil then return nil, msg @@ -320,13 +327,14 @@ function vouchera.list() activation_deadline=v.activation_deadline, author_node=v.author_node, status=v.status(), + unrestricted=v.unrestricted, }) end return vouchers end function vouchera.get_authorized_macs() - local auth_macs = {} + local auth_macs = {} for _, voucher in pairs(vouchera.vouchers) do if voucher.is_active() then table.insert(auth_macs, voucher.mac) @@ -335,6 +343,18 @@ function vouchera.get_authorized_macs() return auth_macs end +--! Returns MACs with active vouchers that have unrestricted flag set +--! These MACs bypass Tranca Redes restrictions +function vouchera.get_unrestricted_macs() + local unrestricted_macs = {} + for _, voucher in pairs(vouchera.vouchers) do + if voucher.is_active() and voucher.unrestricted then + table.insert(unrestricted_macs, voucher.mac) + end + end + return unrestricted_macs +end + vouchera.voucher = voucher_init return vouchera diff --git a/packages/pirania/files/usr/libexec/rpcd/pirania b/packages/pirania/files/usr/libexec/rpcd/pirania index 3c3f00e9f..32afc1d86 100755 --- a/packages/pirania/files/usr/libexec/rpcd/pirania +++ b/packages/pirania/files/usr/libexec/rpcd/pirania @@ -65,8 +65,13 @@ end local function add_vouchers(msg) - local vouchers, errmsg = vouchera.create(msg.name, msg.qty, msg.duration_m, - msg.activation_deadline, msg.permanent) + -- Handle permanent vouchers (nil duration_m means permanent) + local duration_m = msg.duration_m + if msg.permanent then + duration_m = nil + end + local vouchers, errmsg = vouchera.create(msg.name, msg.qty, duration_m, + msg.activation_deadline, msg.unrestricted) if vouchers then return utils.printJson({ status = 'ok', vouchers = vouchers}) else @@ -96,7 +101,7 @@ local methods = { disable = { no_params = 0 }, show_url = { no_params = 0 }, change_url = { url = 'value' }, - add_vouchers = { name='str', qty='int', duration_m='int', activation_deadline='timestamp', permanent='bool'}, + add_vouchers = { name='str', qty='int', duration_m='int', activation_deadline='timestamp', permanent='bool', unrestricted='bool'}, list_vouchers = { no_params = 0 }, rename = { id = 'str', name = 'str' }, invalidate = { id = 'str' }, diff --git a/packages/pirania/files/www/portal/js/int.js b/packages/pirania/files/www/portal/js/int.js index a673abf98..55468e60b 100644 --- a/packages/pirania/files/www/portal/js/int.js +++ b/packages/pirania/files/www/portal/js/int.js @@ -25,6 +25,8 @@ const int = { } } +// 'en' fallback if user language is not available +int[lang] = int[lang] || int['en']; Object.keys(int[lang]).map(text => { Array.from(document.getElementsByClassName(`int-${text}`)).map( element => { @@ -35,4 +37,4 @@ Object.keys(int[lang]).map(text => { } } ) -}) +}) \ No newline at end of file diff --git a/packages/pirania/tests/test_portal.lua b/packages/pirania/tests/test_portal.lua index 6a2434d58..840d6c2e2 100644 --- a/packages/pirania/tests/test_portal.lua +++ b/packages/pirania/tests/test_portal.lua @@ -52,6 +52,15 @@ describe('Pirania portal tests #portal', function() end) + it('get_unrestricted_macs returns empty when not in voucher mode', function() + local default_cfg = io.open('./packages/pirania/files/etc/config/pirania'):read("*all") + test_utils.write_uci_file(uci, 'pirania', default_cfg) + + -- Default config has with_vouchers='0' + local macs = portal.get_unrestricted_macs() + assert.is.equal(0, #macs) + end) + before_each('', function() snapshot = assert:snapshot() test_dir = test_utils.setup_test_dir() diff --git a/packages/pirania/tests/test_read_for_access.lua b/packages/pirania/tests/test_read_for_access.lua index 634a3a2a4..57248ecd0 100644 --- a/packages/pirania/tests/test_read_for_access.lua +++ b/packages/pirania/tests/test_read_for_access.lua @@ -25,7 +25,7 @@ describe('read_for_access tests #readforaccess', function() it('calls captive-portal-update on authorize_mac', function() stub(os, 'execute', function() end) read_for_access.authorize_mac('AA:BB:CC:DD:EE:FF') - assert.stub(os.execute).was_called_with('/usr/bin/captive-portal update') + assert.stub(os.execute).was_called_with('/usr/bin/captive-portal update > /dev/null 2>&1') end) it('let us re-authorize a mac', function() diff --git a/packages/pirania/tests/test_url_utils.lua b/packages/pirania/tests/test_url_utils.lua new file mode 100644 index 000000000..ba9bd4d55 --- /dev/null +++ b/packages/pirania/tests/test_url_utils.lua @@ -0,0 +1,203 @@ +--! Unit tests for URL encoding/decoding utilities in voucher/utils.lua +--! Tests the pure Lua implementation that replaces lucihttp + +local utils = require('voucher.utils') + +-- Override log function for tests +function utils.log(...) + print(...) +end + +describe('URL utilities #urlutils', function() + + describe('urlencode', function() + it('returns nil for nil input', function() + assert.is_nil(utils.urlencode(nil)) + end) + + it('does not encode unreserved characters (RFC 3986)', function() + -- Unreserved: A-Z a-z 0-9 - _ . ~ + assert.is.equal('abcXYZ', utils.urlencode('abcXYZ')) + assert.is.equal('0123456789', utils.urlencode('0123456789')) + assert.is.equal('-_.~', utils.urlencode('-_.~')) + end) + + it('encodes spaces as %20', function() + assert.is.equal('hello%20world', utils.urlencode('hello world')) + assert.is.equal('%20%20%20', utils.urlencode(' ')) + end) + + it('encodes special characters', function() + assert.is.equal('%40', utils.urlencode('@')) + assert.is.equal('%23', utils.urlencode('#')) + assert.is.equal('%24', utils.urlencode('$')) + assert.is.equal('%26', utils.urlencode('&')) + assert.is.equal('%3D', utils.urlencode('=')) + assert.is.equal('%3F', utils.urlencode('?')) + assert.is.equal('%2F', utils.urlencode('/')) + assert.is.equal('%3A', utils.urlencode(':')) + end) + + it('encodes complex URLs', function() + local url = 'http://example.com/path?a=1&b=2' + local encoded = utils.urlencode(url) + assert.is.equal('http%3A%2F%2Fexample.com%2Fpath%3Fa%3D1%26b%3D2', encoded) + end) + + it('converts numbers to strings', function() + assert.is.equal('123', utils.urlencode(123)) + end) + + it('encodes unicode characters', function() + -- UTF-8 bytes get encoded + local encoded = utils.urlencode('café') + assert.is_not_nil(encoded) + assert.is_true(encoded:find('%%') ~= nil) + end) + end) + + describe('urldecode', function() + it('returns nil for nil input', function() + assert.is_nil(utils.urldecode(nil)) + end) + + it('decodes percent-encoded sequences', function() + assert.is.equal('@', utils.urldecode('%40')) + assert.is.equal('#', utils.urldecode('%23')) + assert.is.equal('/', utils.urldecode('%2F')) + assert.is.equal(':', utils.urldecode('%3A')) + assert.is.equal('?', utils.urldecode('%3F')) + assert.is.equal('=', utils.urldecode('%3D')) + assert.is.equal('&', utils.urldecode('%26')) + end) + + it('decodes spaces encoded as %20', function() + assert.is.equal('hello world', utils.urldecode('hello%20world')) + end) + + it('decodes plus signs as spaces', function() + assert.is.equal('hello world', utils.urldecode('hello+world')) + end) + + it('handles mixed encoding', function() + assert.is.equal('a b+c', utils.urldecode('a+b%2Bc')) + end) + + it('handles lowercase hex digits', function() + assert.is.equal(' ', utils.urldecode('%20')) + assert.is.equal(' ', utils.urldecode('%2a'):gsub('%*', ' ') or utils.urldecode('%2a')) + end) + + it('decodes complex URLs', function() + local encoded = 'http%3A%2F%2Fexample.com%2Fpath%3Fa%3D1%26b%3D2' + local decoded = utils.urldecode(encoded) + assert.is.equal('http://example.com/path?a=1&b=2', decoded) + end) + + it('leaves unencoded strings unchanged', function() + assert.is.equal('hello', utils.urldecode('hello')) + assert.is.equal('test123', utils.urldecode('test123')) + end) + end) + + describe('urlencode/urldecode roundtrip', function() + it('roundtrips simple strings', function() + local original = 'hello world' + -- Note: urlencode uses %20, urldecode handles both %20 and + + assert.is.equal(original, utils.urldecode(utils.urlencode(original))) + end) + + it('roundtrips URLs', function() + local original = 'http://example.com/path?a=1&b=2' + assert.is.equal(original, utils.urldecode(utils.urlencode(original))) + end) + + it('roundtrips special characters', function() + local original = 'email@example.com' + assert.is.equal(original, utils.urldecode(utils.urlencode(original))) + end) + end) + + describe('urldecode_params', function() + it('returns empty table for nil input', function() + local params = utils.urldecode_params(nil) + assert.is.same({}, params) + end) + + it('parses simple key=value pairs', function() + local params = utils.urldecode_params('a=1&b=2') + assert.is.equal('1', params.a) + assert.is.equal('2', params.b) + end) + + it('parses URL with query string', function() + local params = utils.urldecode_params('http://example.com?foo=bar&baz=qux') + assert.is.equal('bar', params.foo) + assert.is.equal('qux', params.baz) + end) + + it('decodes URL-encoded keys and values', function() + local params = utils.urldecode_params('key%20name=value%20data') + assert.is.equal('value data', params['key name']) + end) + + it('decodes plus signs as spaces in values', function() + local params = utils.urldecode_params('msg=hello+world') + assert.is.equal('hello world', params.msg) + end) + + it('handles empty values', function() + local params = utils.urldecode_params('key=') + assert.is.equal('', params.key) + end) + + it('handles keys without values', function() + local params = utils.urldecode_params('key') + assert.is.equal('', params.key) + end) + + it('supports semicolon as separator', function() + local params = utils.urldecode_params('a=1;b=2') + assert.is.equal('1', params.a) + assert.is.equal('2', params.b) + end) + + it('supports mixed separators', function() + local params = utils.urldecode_params('a=1&b=2;c=3') + assert.is.equal('1', params.a) + assert.is.equal('2', params.b) + assert.is.equal('3', params.c) + end) + + it('handles multiple values for same key', function() + local params = utils.urldecode_params('key=val1&key=val2') + assert.is.same({'val1', 'val2'}, params.key) + end) + + it('uses provided table as base', function() + local base = {existing = 'value'} + local params = utils.urldecode_params('new=data', base) + assert.is.equal('value', params.existing) + assert.is.equal('data', params.new) + end) + + it('parses voucher query strings correctly', function() + -- Real-world use case from pirania + local qs = 'voucher=secret_code&prev=http%3A%2F%2Fexample.com%2F' + local params = utils.urldecode_params(qs) + assert.is.equal('secret_code', params.voucher) + assert.is.equal('http://example.com/', params.prev) + end) + + it('parses complex prev URLs', function() + -- Test case from existing test_cgi_handlers.lua + local original_url = 'http://original.url/baz?a=1&b=2' + local encoded_url = utils.urlencode(original_url) + local qs = 'voucher=code&prev=' .. encoded_url + local params = utils.urldecode_params(qs) + assert.is.equal('code', params.voucher) + assert.is.equal(original_url, params.prev) + end) + end) + +end) diff --git a/packages/pirania/tests/test_vouchera.lua b/packages/pirania/tests/test_vouchera.lua index fdb534369..f3b69a45c 100644 --- a/packages/pirania/tests/test_vouchera.lua +++ b/packages/pirania/tests/test_vouchera.lua @@ -375,6 +375,105 @@ describe('Vouchera tests #vouchera', function() assert.is.equal('available', listed[1].status) end) + -- Tranca Redes: Unrestricted voucher tests + it('test create unrestricted voucher', function() + vouchera.init() + local voucher = vouchera.add({name='unrestricted_voucher', code='unrestricted_code', unrestricted=true}) + assert.is.equal(true, voucher.unrestricted) + end) + + it('test create normal voucher has unrestricted false', function() + vouchera.init() + local voucher = vouchera.add({name='normal_voucher', code='normal_code'}) + assert.is.equal(false, voucher.unrestricted) + end) + + it('test create batch with unrestricted flag', function() + vouchera.init() + local created_vouchers = vouchera.create('unrestricted', 3, 60, nil, true) + assert.is.equal(3, #created_vouchers) + for _, created in ipairs(created_vouchers) do + local v = vouchera.get_by_id(created.id) + assert.is.equal(true, v.unrestricted) + end + end) + + it('test create batch without unrestricted flag', function() + vouchera.init() + local created_vouchers = vouchera.create('normal', 3, 60) + assert.is.equal(3, #created_vouchers) + for _, created in ipairs(created_vouchers) do + local v = vouchera.get_by_id(created.id) + assert.is.equal(false, v.unrestricted) + end + end) + + it('test get_unrestricted_macs returns only unrestricted active MACs', function() + vouchera.init() + + -- Create normal voucher + local normal = vouchera.add({name='normal', code='normal_code', duration_m=100}) + vouchera.activate('normal_code', "aa:bb:cc:dd:ee:ff") + + -- Create unrestricted voucher + local unrestricted = vouchera.add({name='unrestricted', code='unrestricted_code', duration_m=100, unrestricted=true}) + vouchera.activate('unrestricted_code', "11:22:33:44:55:66") + + -- get_authorized_macs should return both + local auth_macs = vouchera.get_authorized_macs() + assert.is.equal(2, #auth_macs) + + -- get_unrestricted_macs should return only unrestricted + local unrestricted_macs = vouchera.get_unrestricted_macs() + assert.is.equal(1, #unrestricted_macs) + assert.is.equal("11:22:33:44:55:66", unrestricted_macs[1]) + end) + + it('test get_unrestricted_macs returns empty for inactive unrestricted voucher', function() + vouchera.init() + + -- Create unrestricted voucher but don't activate + local unrestricted = vouchera.add({name='unrestricted', code='unrestricted_code', duration_m=100, unrestricted=true}) + + local unrestricted_macs = vouchera.get_unrestricted_macs() + assert.is.equal(0, #unrestricted_macs) + end) + + it('test list includes unrestricted field', function() + vouchera.init() + vouchera.add({name='normal', code='normal_code'}) + vouchera.add({name='unrestricted', code='unrestricted_code', unrestricted=true}) + + local listed = vouchera.list() + assert.is.equal(2, #listed) + + local has_normal = false + local has_unrestricted = false + for _, v in ipairs(listed) do + if v.name == 'normal' then + has_normal = true + assert.is.equal(false, v.unrestricted) + elseif v.name == 'unrestricted' then + has_unrestricted = true + assert.is.equal(true, v.unrestricted) + end + end + assert.is_true(has_normal) + assert.is_true(has_unrestricted) + end) + + it('test unrestricted voucher persists after reload', function() + vouchera.init() + local voucher = vouchera.add({id='unrestricted_voucher', name='unrestricted', code='unrestricted_code', unrestricted=true}) + assert.is.equal(true, voucher.unrestricted) + + -- Reload database + vouchera.init() + local reloaded = vouchera.get_by_id('unrestricted_voucher') + assert.is.not_nil(reloaded) + assert.is.equal(true, reloaded.unrestricted) + end) + before_each('', function() test_utils_pirania.fake_for_tests() snapshot = assert:snapshot()