From a94f49a3b0c401665061c5c5db4d5429dcf58b4f Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Mon, 6 Apr 2026 16:18:06 -0300 Subject: [PATCH] Initial commit --- .env.example | 11 + .gitea/workflows/deploy_corp01.yml | 24 ++ .gitea/workflows/deploy_corp02.yml | 24 ++ .gitea/workflows/deploy_corp03.yml | 24 ++ .gitea/workflows/deploy_corp04.yml | 24 ++ .gitea/workflows/deploy_corp05.yml | 24 ++ .gitea/workflows/deploy_corp06.yml | 24 ++ .gitea/workflows/deploy_corp07.yml | 24 ++ .gitignore | 1 + README.md | 66 +++++ coleta_registro.py | 406 +++++++++++++++++++++++++++++ requirements.txt | 1 + 12 files changed, 653 insertions(+) create mode 100644 .env.example create mode 100644 .gitea/workflows/deploy_corp01.yml create mode 100644 .gitea/workflows/deploy_corp02.yml create mode 100644 .gitea/workflows/deploy_corp03.yml create mode 100644 .gitea/workflows/deploy_corp04.yml create mode 100644 .gitea/workflows/deploy_corp05.yml create mode 100644 .gitea/workflows/deploy_corp06.yml create mode 100644 .gitea/workflows/deploy_corp07.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 coleta_registro.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7adab34 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +# FreeSWITCH +FS_CLI_PATH=fs_cli +FS_PROFILE=internal +FS_CLI_TIMEOUT=10 + +# PostgreSQL / FusionPBX +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=fusionpbx +DB_USER=fusionpbx +DB_PASSWORD=CHANGE_ME diff --git a/.gitea/workflows/deploy_corp01.yml b/.gitea/workflows/deploy_corp01.yml new file mode 100644 index 0000000..0d7fb1f --- /dev/null +++ b/.gitea/workflows/deploy_corp01.yml @@ -0,0 +1,24 @@ +name: Deploy corp01 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, corp01] + steps: + - name: Atualizar repo + run: | + cd /opt/collect-registry + git pull origin main + + - name: Copiar .env + run: | + cp /home/sothis/.envs/collect/.env /opt/collect-registry/.env + + - name: Instalar dependencias + run: | + cd /opt/collect-registry + /usr/bin/python3 -m pip install -r requirements.txt diff --git a/.gitea/workflows/deploy_corp02.yml b/.gitea/workflows/deploy_corp02.yml new file mode 100644 index 0000000..91f67f1 --- /dev/null +++ b/.gitea/workflows/deploy_corp02.yml @@ -0,0 +1,24 @@ +name: Deploy corp02 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, corp02] + steps: + - name: Atualizar repo + run: | + cd /opt/collect-registry + git pull origin main + + - name: Copiar .env + run: | + cp /home/sothis/.envs/collect/.env /opt/collect-registry/.env + + - name: Instalar dependencias + run: | + cd /opt/collect-registry + /usr/bin/python3 -m pip install -r requirements.txt diff --git a/.gitea/workflows/deploy_corp03.yml b/.gitea/workflows/deploy_corp03.yml new file mode 100644 index 0000000..fab1c57 --- /dev/null +++ b/.gitea/workflows/deploy_corp03.yml @@ -0,0 +1,24 @@ +name: Deploy corp03 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, corp03] + steps: + - name: Atualizar repo + run: | + cd /opt/collect-registry + git pull origin main + + - name: Copiar .env + run: | + cp /home/sothis/.envs/collect/.env /opt/collect-registry/.env + + - name: Instalar dependencias + run: | + cd /opt/collect-registry + /usr/bin/python3 -m pip install -r requirements.txt diff --git a/.gitea/workflows/deploy_corp04.yml b/.gitea/workflows/deploy_corp04.yml new file mode 100644 index 0000000..a17acea --- /dev/null +++ b/.gitea/workflows/deploy_corp04.yml @@ -0,0 +1,24 @@ +name: Deploy corp04 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, corp04] + steps: + - name: Atualizar repo + run: | + cd /opt/collect-registry + git pull origin main + + - name: Copiar .env + run: | + cp /home/sothis/.envs/collect/.env /opt/collect-registry/.env + + - name: Instalar dependencias + run: | + cd /opt/collect-registry + /usr/bin/python3 -m pip install -r requirements.txt diff --git a/.gitea/workflows/deploy_corp05.yml b/.gitea/workflows/deploy_corp05.yml new file mode 100644 index 0000000..66cf30b --- /dev/null +++ b/.gitea/workflows/deploy_corp05.yml @@ -0,0 +1,24 @@ +name: Deploy corp05 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, corp05] + steps: + - name: Atualizar repo + run: | + cd /opt/collect-registry + git pull origin main + + - name: Copiar .env + run: | + cp /home/sothis/.envs/collect/.env /opt/collect-registry/.env + + - name: Instalar dependencias + run: | + cd /opt/collect-registry + /usr/bin/python3 -m pip install -r requirements.txt diff --git a/.gitea/workflows/deploy_corp06.yml b/.gitea/workflows/deploy_corp06.yml new file mode 100644 index 0000000..08d73db --- /dev/null +++ b/.gitea/workflows/deploy_corp06.yml @@ -0,0 +1,24 @@ +name: Deploy corp06 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, corp06] + steps: + - name: Atualizar repo + run: | + cd /opt/collect-registry + git pull origin main + + - name: Copiar .env + run: | + cp /home/sothis/.envs/collect/.env /opt/collect-registry/.env + + - name: Instalar dependencias + run: | + cd /opt/collect-registry + /usr/bin/python3 -m pip install -r requirements.txt diff --git a/.gitea/workflows/deploy_corp07.yml b/.gitea/workflows/deploy_corp07.yml new file mode 100644 index 0000000..604a45e --- /dev/null +++ b/.gitea/workflows/deploy_corp07.yml @@ -0,0 +1,24 @@ +name: Deploy corp07 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: [self-hosted, corp07] + steps: + - name: Atualizar repo + run: | + cd /opt/collect-registry + git pull origin main + + - name: Copiar .env + run: | + cp /home/sothis/.envs/collect/.env /opt/collect-registry/.env + + - name: Instalar dependencias + run: | + cd /opt/collect-registry + /usr/bin/python3 -m pip install -r requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..e663012 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# Coleta de Registros FreeSWITCH + FusionPBX + +Este script coleta os registros SIP ativos do FreeSWITCH, cruza com os ramais cadastrados no FusionPBX (PostgreSQL) e gera CSVs por domínio contendo status, dispositivo, troncos e DDR. + +## O que ele gera + +- Pasta `csv_registrations/` com um CSV por domínio. +- Colunas: Ramal, Domínio, Status, Dispositivo, Troncos, DDR. + +## Requisitos + +- Python 3.8+ +- Acesso local ao `fs_cli` no servidor do FreeSWITCH +- Acesso local ao banco PostgreSQL do FusionPBX +- Dependência Python: + - `psycopg2-binary` + +## Configuração + +1. Copie o arquivo `.env.example` para `.env` e ajuste os valores. +2. Garanta que o usuário do banco tenha permissão de leitura nas tabelas do FusionPBX. + +### Variáveis do `.env` + +- `FS_CLI_PATH`: caminho do `fs_cli` (ex.: `fs_cli` ou `/usr/bin/fs_cli`) +- `FS_PROFILE`: perfil do FreeSWITCH (ex.: `internal`) +- `FS_CLI_TIMEOUT`: timeout em segundos do comando `fs_cli` (ex.: `10`) +- `DB_HOST`: host do Postgres +- `DB_PORT`: porta do Postgres +- `DB_NAME`: nome do banco +- `DB_USER`: usuário +- `DB_PASSWORD`: senha + +## Como rodar + +```bash +pip install -r requirements.txt +python3 coleta_registro.py +``` + +## Replicar para outros servidores + +1. Clone este repositório no servidor que roda o FreeSWITCH. +2. Configure o `.env` com os parâmetros daquele servidor. +3. Instale dependências e execute. + +Esse fluxo é o mais confiável porque o `fs_cli` normalmente precisa rodar localmente no servidor do FreeSWITCH. + +## Deploy com Gitea Actions (runner no servidor) + +Os workflows em `.gitea/workflows/` usam labels por servidor (ex.: `corp01`, `corp02`, etc.). +Para cada servidor: + +1. Instale e registre o runner no próprio servidor. +2. Atribua a label correspondente (ex.: `corp03`). +3. Faça o clone em `/opt/collect-registry`. + +No push, o runner com a label correta executa o deploy localmente, sem SSH. + +## Exemplo de crontab (rodar todos os dias às 02h) + +```cron +0 2 * * * /usr/bin/python3 /opt/collect-registry/coleta_registro.py >> /var/log/coleta_registro.log 2>&1 +``` + +Ajuste o caminho do Python e do projeto conforme o seu servidor. diff --git a/coleta_registro.py b/coleta_registro.py new file mode 100644 index 0000000..966ba8b --- /dev/null +++ b/coleta_registro.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +import subprocess +import csv +import os +import json +from datetime import datetime +import psycopg2 + + +def log(message): + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] {message}") + + +def load_env(path=".env"): + """Carrega variáveis de ambiente de um arquivo .env simples.""" + if not os.path.exists(path): + return + try: + with open(path, "r", encoding="utf-8") as f: + for raw_line in f: + line = raw_line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key and key not in os.environ: + os.environ[key] = value + except Exception as e: + log(f"Aviso: erro ao carregar .env: {e}") + + +def get_db_connection(): + """Cria conexão com o Postgres usando variáveis de ambiente.""" + return psycopg2.connect( + host=os.getenv("DB_HOST", "localhost"), + port=int(os.getenv("DB_PORT", "5432")), + user=os.getenv("DB_USER", "fusionpbx"), + password=os.getenv("DB_PASSWORD", ""), + database=os.getenv("DB_NAME", "fusionpbx"), + ) + +def get_registrations(): + """Executa o comando do FreeSWITCH para pegar registros SIP""" + try: + fs_cli = os.getenv("FS_CLI_PATH", "fs_cli") + fs_profile = os.getenv("FS_PROFILE", "internal") + fs_timeout = int(os.getenv("FS_CLI_TIMEOUT", "10")) + result = subprocess.run( + [fs_cli, '-x', f'sofia status profile {fs_profile} reg'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=fs_timeout + ) + if result.returncode != 0: + log(f"Erro ao executar o comando fs_cli: {result.stderr.strip()}") + return "" + return result.stdout + except subprocess.TimeoutExpired: + log("Timeout ao executar fs_cli") + return "" + except Exception as e: + log(f"Erro ao executar subprocesso: {e}") + return "" + +def parse_registrations(output): + """Faz o parsing da saída do comando e organiza os dados em JSON""" + registros = [] + lines = output.splitlines() + current_record = {} + + for line in lines: + line = line.strip() + if line.startswith("Call-ID:"): + if current_record: # Salva o registro anterior + registros.append(current_record) + current_record = {} + current_record["Call-ID"] = line.split(":", 1)[1].strip() + elif line.startswith("User:"): + user = line.split(":", 1)[1].strip() + current_record["User"] = user.split("@")[0] + current_record["Domain"] = user.split("@")[1] if "@" in user else "unknown" + elif line.startswith("Auth-Realm:"): + if "Domain" not in current_record or current_record["Domain"] == "unknown": + current_record["Domain"] = line.split(":", 1)[1].strip() + elif line.startswith("Agent:"): + current_record["Agent"] = line.split(":", 1)[1].strip() + elif line.startswith("Status:"): + status = line.split(":", 1)[1].strip() + if "Registered" in status: + exp_start = status.find("EXP(") + 4 + exp_end = status.find(")", exp_start) + exp_time = status[exp_start:exp_end] if exp_start > 3 else "desconhecido" + current_record["Status"] = f"Registrado desde {exp_time}" + else: + current_record["Status"] = "Sem registro" + + if current_record: + registros.append(current_record) + + return registros + +def save_to_csv(registros): + """Salva os registros em arquivos CSV com colunas formatadas""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = "csv_registrations" + os.makedirs(output_dir, exist_ok=True) + + filename = f"{output_dir}/registrations_{timestamp}.csv" + with open(filename, "w", newline="", encoding="utf-8") as csvfile: + writer = csv.writer(csvfile) + # Escreve o cabeçalho formatado + writer.writerow(["Ramal", "Domínio", "Status"]) + + # Remove duplicatas e escreve os registros + seen = set() + for registro in registros: + key = (registro["User"], registro["Domain"]) # Identifica duplicatas por Ramal e Domínio + if key not in seen: + seen.add(key) + writer.writerow([ + registro.get("User", ""), + registro.get("Domain", ""), + registro.get("Status", "") + ]) + + log(f"CSV gerado: {filename}") + +def save_to_csv_by_auth_realm(registros): + """Salva os registros em arquivos CSV separados por Auth-Realm com colunas específicas""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = "csv_registrations" + os.makedirs(output_dir, exist_ok=True) + + # Group registros by Auth-Realm + grouped_by_realm = {} + for registro in registros: + auth_realm = registro.get("Auth-Realm", "unknown") + if auth_realm not in grouped_by_realm: + grouped_by_realm[auth_realm] = [] + grouped_by_realm[auth_realm].append(registro) + + # Create a CSV file for each Auth-Realm + for auth_realm, realm_registros in grouped_by_realm.items(): + sanitized_realm = auth_realm.replace("/", "_").replace("\\", "_") # Sanitize filename + filename = f"{output_dir}/registrations_{sanitized_realm}_{timestamp}.csv" + with open(filename, "w", newline="", encoding="utf-8-sig") as csvfile: # Use utf-8-sig encoding + # Use csv.writer with explicit delimiter + writer = csv.writer(csvfile, delimiter=";") # Use semicolon as delimiter + # Write header with updated column names + writer.writerow(["Usuário", "Registrado em", "Status", "Domínio"]) + for registro in realm_registros: + # Write only the selected columns with updated names + writer.writerow([ + registro.get("User", ""), + registro.get("Agent", ""), + registro.get("Status", ""), + registro.get("Auth-Realm", "") + ]) + + log(f"CSV gerado para Auth-Realm '{auth_realm}': {filename}") + +def save_to_csv_by_domain(registros, domain_mapping, trunks_by_domain, destinations_by_domain): + """Salva os registros em arquivos CSV separados por domínio, incluindo troncos e DDR.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = "csv_registrations" + os.makedirs(output_dir, exist_ok=True) + + # Agrupa os registros por domínio + grouped_by_domain = {} + for registro in registros: + domain_uuid = registro.get("Domain", "unknown") + if domain_uuid not in grouped_by_domain: + grouped_by_domain[domain_uuid] = [] + grouped_by_domain[domain_uuid].append(registro) + + # Cria um arquivo CSV para cada domínio + for domain_uuid, domain_registros in grouped_by_domain.items(): + domain_info = domain_mapping.get(domain_uuid, {"name": "unknown", "description": "unknown"}) + domain_description = domain_info["description"] + sanitized_description = domain_description.replace("/", "_").replace("\\", "_").replace(" ", "_") # Sanitiza a descrição + filename = f"{output_dir}/registrations_{sanitized_description}_{timestamp}.csv" + + # Obtém os troncos para o domínio + trunks = trunks_by_domain.get(domain_uuid, []) + trunks_str = ", ".join(trunks) if trunks else "Nenhum tronco registrado" + + # Obtém os destinos para o domínio + destinations = destinations_by_domain.get(domain_uuid, []) + destinations_str = ", ".join(destinations) if destinations else "Nenhum DDR registrado" + + with open(filename, "w", newline="", encoding="utf-8-sig") as csvfile: # Use utf-8-sig para compatibilidade com Excel + writer = csv.writer(csvfile, delimiter=";") # Use ; como delimitador + # Escreve o cabeçalho corrigido + writer.writerow(["Ramal", "Domínio", "Status", "Dispositivo", "Troncos", "DDR"]) + # Escreve os registros + seen = set() + for registro in domain_registros: + key = (registro["User"], domain_uuid) # Identifica duplicatas por Ramal e UUID do Domínio + if key not in seen: + seen.add(key) + writer.writerow([ + registro.get("User", ""), + f"{domain_info['name']} ({domain_description})", # Nome e descrição no campo "Domínio" + registro.get("Status", ""), + registro.get("Agent", ""), # <-- Adiciona o dispositivo + trunks_str, # Adiciona os troncos + destinations_str # Adiciona os DDR + ]) + + log(f"CSV gerado para domínio '{domain_description}': {filename}") + +def save_to_json(registros): + """Salva os registros em um arquivo JSON""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_dir = "json_registrations" + os.makedirs(output_dir, exist_ok=True) + + filename = f"{output_dir}/registrations_{timestamp}.json" + with open(filename, "w", encoding="utf-8") as jsonfile: + json.dump(registros, jsonfile, indent=4, ensure_ascii=False) + + log(f"JSON gerado: {filename}") + +def get_all_users_from_db(): + """Obtém todos os ramais e seus domínios do banco de dados.""" + connection = None + cursor = None + try: + connection = get_db_connection() + cursor = connection.cursor() + query = """ + SELECT v_extensions.extension, v_extensions.domain_uuid, v_domains.domain_name + FROM v_extensions + JOIN v_domains ON v_extensions.domain_uuid = v_domains.domain_uuid + """ + cursor.execute(query) + results = cursor.fetchall() + all_users = [{"User": row[0], "DomainUUID": row[1], "Domain": row[2]} for row in results] + return all_users + except Exception as e: + log(f"Erro ao consultar o banco de dados: {e}") + return [] + finally: + try: + if cursor: + cursor.close() + if connection: + connection.close() + except: + pass + +def get_domain_mapping(): + """Obtém o mapeamento de domain_uuid para nomes e descrições de domínio.""" + connection = None + cursor = None + try: + connection = get_db_connection() + cursor = connection.cursor() + query = """ + SELECT domain_uuid, domain_name, domain_description + FROM v_domains + """ + cursor.execute(query) + results = cursor.fetchall() + domain_mapping = { + row[0]: {"name": row[1], "description": row[2] or "unknown"} for row in results + } + return domain_mapping + except Exception as e: + log(f"Erro ao consultar o banco de dados para domínios: {e}") + return {} + finally: + try: + if cursor: + cursor.close() + if connection: + connection.close() + except: + pass + +def merge_registered_and_unregistered(all_users, registered_users): + """Combina ramais registrados e não registrados, filtrando por domínio (nome).""" + # O registered_map usa (User, Domain) onde Domain é o NOME do domínio + registered_map = { + (user["User"], user["Domain"]): user + for user in registered_users + } + + merged_users = [] + for user in all_users: + # Compare pelo nome do domínio + reg = registered_map.get((user["User"], user["Domain"])) + merged_users.append({ + "User": user["User"], + "Domain": user["DomainUUID"], # Para agrupamento e uso do UUID + "Status": reg["Status"] if reg else "Sem registro", + "Agent": reg["Agent"] if reg and "Agent" in reg else "" + }) + + return merged_users + +def get_trunks_by_domain(): + """Obtém os troncos registrados por domínio.""" + connection = None + cursor = None + try: + connection = get_db_connection() + cursor = connection.cursor() + query = """ + SELECT domain_uuid, gateway, enabled + FROM v_gateways + WHERE enabled = 'true' + """ + cursor.execute(query) + results = cursor.fetchall() + trunks_by_domain = {} + for row in results: + domain_uuid = row[0] + gateway = row[1] + if domain_uuid not in trunks_by_domain: + trunks_by_domain[domain_uuid] = [] + trunks_by_domain[domain_uuid].append(gateway) + return trunks_by_domain + except Exception as e: + log(f"Erro ao consultar os troncos no banco de dados: {e}") + return {} + finally: + try: + if cursor: + cursor.close() + if connection: + connection.close() + except: + pass + +def get_destinations_by_domain(): + """Obtém os destinos diretos de ramal (DDR) por domínio, sem duplicidades.""" + connection = None + cursor = None + try: + connection = get_db_connection() + cursor = connection.cursor() + query = """ + SELECT domain_uuid, destination_number, destination_enabled + FROM v_destinations + """ + cursor.execute(query) + results = cursor.fetchall() + destinations_by_domain = {} + for row in results: + domain_uuid = row[0] + destination_number = row[1] + enabled = row[2] + destination_entry = f"{destination_number} ({'Habilitado' if enabled == 'true' else 'Desabilitado'})" + if domain_uuid not in destinations_by_domain: + destinations_by_domain[domain_uuid] = set() # Usar um conjunto para evitar duplicidades + destinations_by_domain[domain_uuid].add(destination_entry) # Adiciona ao conjunto + + # Converte os conjuntos para listas ordenadas + for domain_uuid in destinations_by_domain: + destinations_by_domain[domain_uuid] = sorted(destinations_by_domain[domain_uuid]) + + return destinations_by_domain + except Exception as e: + log(f"Erro ao consultar os destinos no banco de dados: {e}") + return {} + finally: + try: + if cursor: + cursor.close() + if connection: + connection.close() + except: + pass + +def main(): + load_env() + # Obter registros do FreeSWITCH + output = get_registrations() + registered_users = parse_registrations(output) if output else [] + + # Obter todos os ramais do banco de dados + all_users = get_all_users_from_db() + + # Obter o mapeamento de domain_uuid para nomes e descrições de domínio + domain_mapping = get_domain_mapping() + + # Obter os troncos registrados por domínio + trunks_by_domain = get_trunks_by_domain() + + # Obter os destinos diretos de ramal (DDR) por domínio + destinations_by_domain = get_destinations_by_domain() + + # Combinar ramais registrados e não registrados + all_users_with_status = merge_registered_and_unregistered(all_users, registered_users) + + # Salvar os resultados em arquivos CSV separados por domínio + save_to_csv_by_domain(all_users_with_status, domain_mapping, trunks_by_domain, destinations_by_domain) + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..37ec460 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary