feat: Add pfSense hybrid template with OpenVPN monitoring, enhance YAML validation, and remove old DHCP execution plan.
This commit is contained in:
parent
05ffb40a9b
commit
fc05977de0
|
|
@ -1,105 +0,0 @@
|
||||||
# Plano de Execução: Monitoramento DHCP Multi-Instância pfSense
|
|
||||||
|
|
||||||
Este documento guia o agente de IA na implementação do monitoramento granular de DHCP no pfSense, conforme definido no plano de implementação.
|
|
||||||
|
|
||||||
**Arquivo Alvo:** `templates_gold/template_app_pfsense_snmp.yaml`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Enriquecimento da Coleta SNMP (Item)
|
|
||||||
|
|
||||||
**Objetivo:** Adicionar a OID de parâmetros (`hrSWRunParameters`) ao item de coleta de software para permitir diferenciar processos `dhcpd`.
|
|
||||||
|
|
||||||
- **Ação:** Localizar o item com `key: pfsense.sw.walk`.
|
|
||||||
- **Alteração:** Adicionar a OID `1.3.6.1.2.1.25.4.2.1.5` à lista `snmp_oid`.
|
|
||||||
- **Código Atual (Referência):**
|
|
||||||
```yaml
|
|
||||||
snmp_oid: walk[1.3.6.1.2.1.25.4.2.1.2,1.3.6.1.2.1.25.4.2.1.7]
|
|
||||||
```
|
|
||||||
- **Código Novo:**
|
|
||||||
```yaml
|
|
||||||
snmp_oid: walk[1.3.6.1.2.1.25.4.2.1.2,1.3.6.1.2.1.25.4.2.1.7,1.3.6.1.2.1.25.4.2.1.5]
|
|
||||||
```
|
|
||||||
- **Atualização do Preprocessing:** Adicionar o passo para mapear a nova OID para um nome JSON (ex: `hrSWRunParameters`).
|
|
||||||
```yaml
|
|
||||||
- type: SNMP_WALK_TO_JSON
|
|
||||||
parameters:
|
|
||||||
...
|
|
||||||
- hrSWRunParameters
|
|
||||||
- 1.3.6.1.2.1.25.4.2.1.5
|
|
||||||
- '0'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Implementação da Regra de Descoberta (LLD)
|
|
||||||
|
|
||||||
**Objetivo:** Criar uma nova regra de descoberta para detectar instâncias individuais do `dhcpd`.
|
|
||||||
|
|
||||||
- **Local:** Seção `discovery_rules`.
|
|
||||||
- **Nova Regra:**
|
|
||||||
```yaml
|
|
||||||
- uuid: (gerar_novo_uuid_v4)
|
|
||||||
name: 'Descoberta de Processos DHCP'
|
|
||||||
type: DEPENDENT
|
|
||||||
key: pfsense.dhcp.discovery
|
|
||||||
delay: '0'
|
|
||||||
description: 'Descobre instâncias do DHCP Server (dhcpd) diferenciadas por parâmetros (ex: IPv4 vs IPv6).'
|
|
||||||
master_item:
|
|
||||||
key: pfsense.sw.walk
|
|
||||||
filter:
|
|
||||||
evaltype: AND
|
|
||||||
conditions:
|
|
||||||
- macro: '{#HR_SW_NAME}'
|
|
||||||
value: 'dhcpd'
|
|
||||||
formulaid: A
|
|
||||||
lld_macro_paths:
|
|
||||||
- lld_macro: '{#HR_SW_NAME}'
|
|
||||||
path: '$.hrSWRunName'
|
|
||||||
- lld_macro: '{#HR_SW_PARAMS}'
|
|
||||||
path: '$.hrSWRunParameters'
|
|
||||||
- lld_macro: '{#HR_SW_STATUS}'
|
|
||||||
path: '$.hrSWRunStatus'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Protótipos de Itens e Triggers
|
|
||||||
|
|
||||||
**Objetivo:** Monitorar o status de cada instância descoberta.
|
|
||||||
|
|
||||||
- **Item Prototype:**
|
|
||||||
```yaml
|
|
||||||
- uuid: (gerar_novo_uuid_v4)
|
|
||||||
name: 'Status do Processo DHCP: {#HR_SW_PARAMS}'
|
|
||||||
type: DEPENDENT
|
|
||||||
key: 'pfsense.dhcp.process.status[{#HR_SW_PARAMS}]'
|
|
||||||
delay: '0'
|
|
||||||
description: 'Status da instância DHCP rodando com argumentos: {#HR_SW_PARAMS}'
|
|
||||||
valuemap:
|
|
||||||
name: 'Services status'
|
|
||||||
preprocessing:
|
|
||||||
- type: JSONPATH
|
|
||||||
parameters:
|
|
||||||
- '$[?(@.hrSWRunName == "{#HR_SW_NAME}" && @.hrSWRunParameters == "{#HR_SW_PARAMS}")].hrSWRunStatus.first()'
|
|
||||||
master_item:
|
|
||||||
key: pfsense.sw.walk
|
|
||||||
tags:
|
|
||||||
- tag: component
|
|
||||||
value: application
|
|
||||||
```
|
|
||||||
|
|
||||||
- **Trigger Prototype:**
|
|
||||||
```yaml
|
|
||||||
- uuid: (gerar_novo_uuid_v4)
|
|
||||||
expression: 'last(/PFSense by SNMP/pfsense.dhcp.process.status[{#HR_SW_PARAMS}])=0'
|
|
||||||
name: '🚨 DHCP Parado: Instância {#HR_SW_PARAMS} não está rodando'
|
|
||||||
priority: HIGH
|
|
||||||
description: 'A instância do DHCP com parâmetros "{#HR_SW_PARAMS}" parou de responder.'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Validação Automática (Mandatório)
|
|
||||||
|
|
||||||
**Objetivo:** Garantir que o template resultante seja válido e siga padrões (UUIDs únicos, etc).
|
|
||||||
|
|
||||||
- **Comando:**
|
|
||||||
```powershell
|
|
||||||
python validate_zabbix_template.py templates_gold/template_app_pfsense_snmp.yaml
|
|
||||||
```
|
|
||||||
- **Critério de Sucesso:** O script deve retornar `[SUCCESS] YAML Structure & UUIDs are VALID.` sem erros fatais.
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
# Documentação: PFSense by SNMP
|
|
||||||
|
|
||||||
**Template:** PFSense by SNMP
|
|
||||||
**Descrição:**
|
|
||||||
Template para monitoramento do pfSense via SNMP
|
|
||||||
Configuração:
|
|
||||||
1. Habilite o daemon SNMP em Services na interface web do pfSense: https://docs.netgate.com/pfsense/en/latest/services/snmp.html
|
|
||||||
2. Configure a regra de firewall para permitir acesso do Zabbix Proxy/Server via SNMP: https://docs.netgate.com/pfsense/en/latest/firewall/index.html#managing-firewall-rules
|
|
||||||
3. Associe o template ao host.
|
|
||||||
|
|
||||||
MIBs usadas:
|
|
||||||
BEGEMOT-PF-MIB
|
|
||||||
HOST-RESOURCES-MIB
|
|
||||||
|
|
||||||
Gerado originalmente pela ferramenta oficial "Templator", Otimizado para Padrão Gold (Arthur)
|
|
||||||
|
|
||||||
## Itens Monitorados
|
|
||||||
|
|
||||||
### Itens Globais
|
|
||||||
- **Coleta Raw (SNMP): Interfaces de Rede PF** (`net.if.pf.walk`)
|
|
||||||
- **Coleta Raw (SNMP): Interfaces de Rede (IF-MIB)** (`net.if.walk`)
|
|
||||||
- **Status do servidor DHCP** (`pfsense.dhcpd.status`)
|
|
||||||
- **Status do servidor DNS** (`pfsense.dns.status`)
|
|
||||||
- **Estado do processo Nginx (Web)** (`pfsense.nginx.status`)
|
|
||||||
- **Pacotes com offset incorreto (Bad Offset)** (`pfsense.packets.bad.offset`)
|
|
||||||
- **Pacotes Fragmentados** (`pfsense.packets.fragment`)
|
|
||||||
- **Pacotes correspondentes a uma regra de filtro** (`pfsense.packets.match`)
|
|
||||||
- **Pacotes descartados por limite de memória** (`pfsense.packets.mem.drop`)
|
|
||||||
- **Pacotes Normalizados** (`pfsense.packets.normalize`)
|
|
||||||
- **Pacotes Curtos (Short Packets)** (`pfsense.packets.short`)
|
|
||||||
- **Status de execução do Packet Filter** (`pfsense.pf.status`)
|
|
||||||
- **Coleta Raw (SNMP): Contadores PF (pfCounter)** (`pfsense.pf_counters.walk`)
|
|
||||||
- **Contagem de regras de Firewall** (`pfsense.rules.count`)
|
|
||||||
- **Tabela de Rastreamento: Origens Atuais (Source Tracking)** (`pfsense.source.tracking.table.count`)
|
|
||||||
- **Tabela de Rastreamento: Limite (Limit)** (`pfsense.source.tracking.table.limit`)
|
|
||||||
- **Tabela de Rastreamento: Utilização (%)** (`pfsense.source.tracking.table.pused`)
|
|
||||||
- **Tabela de Estados: Atual (State Table)** (`pfsense.state.table.count`)
|
|
||||||
- **Tabela de Estados: Limite (Limit)** (`pfsense.state.table.limit`)
|
|
||||||
- **Tabela de Estados: Utilização (%)** (`pfsense.state.table.pused`)
|
|
||||||
- **Coleta Raw (SNMP): Software Instalado (hrSWRun)** (`pfsense.sw.walk`)
|
|
||||||
- **Disponibilidade do Agente SNMP** (`zabbix[host,snmp,available]`)
|
|
||||||
|
|
||||||
### Regras de Descoberta (LLD)
|
|
||||||
|
|
||||||
#### Descoberta de Processos DHCP (`pfsense.dhcp.discovery`)
|
|
||||||
- **Protótipos de Itens:**
|
|
||||||
- Processo DHCP [{#HR_SW_PARAMS}]: Status (`pfsense.dhcp.process.status[{#HR_SW_PARAMS}]`)
|
|
||||||
- **Protótipos de Triggers:**
|
|
||||||
- [HIGH] **🚨 DHCP Parado: Instância {#HR_SW_PARAMS} não está rodando**
|
|
||||||
|
|
||||||
#### Descoberta de interfaces de rede (`pfsense.net.if.discovery`)
|
|
||||||
- **Protótipos de Itens:**
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv4 de entrada bloqueado (`net.if.in.block.v4.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes IPv4 de entrada bloqueados (`net.if.in.block.v4.pps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv6 de entrada bloqueado (`net.if.in.block.v6.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes IPv6 de entrada bloqueados (`net.if.in.block.v6.pps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes de entrada descartados (`net.if.in.discards[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes de entrada com erros (`net.if.in.errors[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv4 de entrada permitido (`net.if.in.pass.v4.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes IPv4 de entrada permitidos (`net.if.in.pass.v4.pps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv6 de entrada permitido (`net.if.in.pass.v6.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes IPv6 de entrada permitidos (`net.if.in.pass.v6.pps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Bits recebidos (`net.if.in[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv4 de saída bloqueado (`net.if.out.block.v4.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes IPv4 de saída bloqueados (`net.if.out.block.v4.pps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv6 de saída bloqueado (`net.if.out.block.v6.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes de saída descartados (`net.if.out.discards[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes de saída com erros (`net.if.out.errors[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv4 de saída permitido (`net.if.out.pass.v4.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes IPv4 de saída permitidos (`net.if.out.pass.v4.pps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tráfego IPv6 de saída permitido (`net.if.out.pass.v6.bps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Pacotes IPv6 de saída permitidos (`net.if.out.pass.v6.pps[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Bits enviados (`net.if.out[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Contagem de referências de regras (`net.if.rules.refs[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Velocidade (`net.if.speed[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Status operacional (`net.if.status[{#SNMPINDEX}]`)
|
|
||||||
- Interface [{#IFNAME}({#IFALIAS})]: Tipo de interface (`net.if.type[{#SNMPINDEX}]`)
|
|
||||||
|
|
||||||
## Alertas (Triggers)
|
|
||||||
|
|
||||||
### Triggers Globais
|
|
||||||
- [AVERAGE] **🚨 DHCP Parado: Servidor DHCP não está em execução**
|
|
||||||
- [AVERAGE] **🚨 DNS Parado: Servidor DNS (Unbound) não está em execução**
|
|
||||||
- [AVERAGE] **🚨 WebServer Parado: Nginx não está rodando**
|
|
||||||
- [WARNING] **🧩 Fragmentação Excessiva IPv4**
|
|
||||||
- [WARNING] **🛡️ Possível Ataque/Scan: Pico de Bloqueios**
|
|
||||||
- [HIGH] **🚨 Firewall Desligado: Packet Filter inativo**
|
|
||||||
- [WARNING] **⚠️ Tabela de Rastreamento Cheia: Uso elevado ({ITEM.LASTVALUE1}%)**
|
|
||||||
- [HIGH] **⏳ Source Tracking cheia em < 1h (Previsão)**
|
|
||||||
- [WARNING] **🔥 Tabela de Estados Crítica: Risco de bloqueio ({ITEM.LASTVALUE1}%)**
|
|
||||||
- [HIGH] **⏳ Tabela de Estados cheia em < 1h (Previsão)**
|
|
||||||
- [WARNING] **🚨 Falha SNMP: Sem coleta de dados no pfSense**
|
|
||||||
|
|
||||||
### Protótipos de Triggers (LLD)
|
|
||||||
|
|
||||||
**Regra: Descoberta de interfaces de rede**
|
|
||||||
- [WARNING] **🐢 Congestionamento: Descartes na interface {#IFNAME}**
|
|
||||||
- [WARNING] **⚠️ PFSense: Interface [{#IFNAME}({#IFALIAS})]: Alta taxa de erros de entrada**
|
|
||||||
- [WARNING] **⚠️ PFSense: Interface [{#IFNAME}({#IFALIAS})]: Alta taxa de erros de saída**
|
|
||||||
- [AVERAGE] **🔌 PFSense: Interface [{#IFNAME}({#IFALIAS})]: Link indisponível**
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
# INSTRUÇÕES DE INSTALAÇÃO - AGENTE ZABBIX P/ OPENVPN
|
||||||
|
=====================================================
|
||||||
|
|
||||||
|
Para que o monitoramento do OpenVPN funcione no Template Hybrid Gold, você deve realizar os passos abaixo em CADA firewall pfSense.
|
||||||
|
|
||||||
|
REQUISITOS
|
||||||
|
----------
|
||||||
|
1. Acesso SSH ao pfSense (Opção 8 no console).
|
||||||
|
2. Pacote "Zabbix Agent" instalado no pfSense (System > Package Manager).
|
||||||
|
|
||||||
|
PASSO 1: INSTALAR O SCRIPT DE DESCOBERTA
|
||||||
|
----------------------------------------
|
||||||
|
Este script é usado pelo Zabbix para descobrir automaticamente os usuários conectados.
|
||||||
|
|
||||||
|
1. Crie a pasta se não existir:
|
||||||
|
mkdir -p /opt/zabbix/
|
||||||
|
|
||||||
|
2. Copie o arquivo 'files/openvpn-discovery.sh' para '/opt/zabbix/openvpn-discovery.sh' no firewall.
|
||||||
|
(Você pode usar SCP ou criar o arquivo com 'vi' e colar o conteúdo).
|
||||||
|
|
||||||
|
3. Dê permissão de execução:
|
||||||
|
chmod +x /opt/zabbix/openvpn-discovery.sh
|
||||||
|
|
||||||
|
PASSO 2: CONFIGURAR O AGENTE
|
||||||
|
----------------------------
|
||||||
|
Este arquivo ensina o Zabbix a usar o script acima e ler os logs.
|
||||||
|
|
||||||
|
1. Copie o arquivo 'files/userparameter_openvpn.conf' para '/usr/local/etc/zabbix50/zabbix_agentd.conf.d/userparameter_openvpn.conf'
|
||||||
|
|
||||||
|
⚠️ NOTA: O caminho pode variar dependendo da versão do pacote Zabbix Agent (ex: zabbix60, zabbix40). Verifique com 'ls /usr/local/etc/'.
|
||||||
|
|
||||||
|
PASSO 3: VERIFICAÇÃO DE CAMINHOS DE LOG
|
||||||
|
---------------------------------------
|
||||||
|
O script assume que os logs de status do OpenVPN estão no padrão:
|
||||||
|
/var/log/openvpn/status*.log
|
||||||
|
|
||||||
|
Se o seu pfSense estiver configurado para salvar em outro lugar (verifique em OpenVPN > Servers > Edit > Advanced Settings ou Logs), você DEVE editar os dois arquivos (.sh e .conf) e corrigir o caminho antes de instalar.
|
||||||
|
|
||||||
|
PASSO 4: REINICIAR O SERVIÇO
|
||||||
|
----------------------------
|
||||||
|
Após copiar os arquivos, reinicie o agente para aplicar as mudanças:
|
||||||
|
|
||||||
|
service zabbix_agentd restart
|
||||||
|
|
||||||
|
TESTE
|
||||||
|
-----
|
||||||
|
No terminal do pfSense, teste se o agente consegue ler a versão do OpenVPN:
|
||||||
|
zabbix_agentd -t openvpn.version
|
||||||
|
|
||||||
|
Se retornar [t|2.x.x], está funcionando!
|
||||||
|
|
@ -0,0 +1,199 @@
|
||||||
|
# AI Agent Execution Plan: OpenVPN Hybrid Integration
|
||||||
|
|
||||||
|
**Objective**: transform the `pfsense_hybrid_snmp_agent` folder into a production-ready Hybrid solution.
|
||||||
|
**Context**: The files `C:\Users\joao.goncalves\Desktop\zabbix-itguys\templates_gold\pfsense_hybrid_snmp_agent\files\openvpn-discovery.sh` and `C:\Users\joao.goncalves\Desktop\zabbix-itguys\templates_gold\pfsense_hybrid_snmp_agent\files\userparameter_openvpn.conf` are currently raw copies. The YAML template is the original SNMP version located at `C:\Users\joao.goncalves\Desktop\zabbix-itguys\templates_gold\pfsense_hybrid_snmp_agent\template_app_pfsense_snmp.yaml`.
|
||||||
|
|
||||||
|
|
||||||
|
## Execution Strategy & Safety Context
|
||||||
|
**Goal**: We are building a "Hybrid" Zabbix Template for pfSense that combines standard SNMP monitoring with advanced OpenVPN metrics collected via Zabbix Agent Custom UserParameters.
|
||||||
|
**Key Features**:
|
||||||
|
1. **Dynamic Grouping**: Group VPN users by "Company" (Server Name) derived from log filenames.
|
||||||
|
2. **Security Triggers**: Detect Exfiltration (>10GB/h), Zombie Sessions (>24h), and Session Hijacking (IP Change).
|
||||||
|
3. **S2S vs User**: Distinguish Site-to-Site tunnels from human users to apply different alert rules via Macros and Discovery Overrides.
|
||||||
|
|
||||||
|
**CRITICAL INSTRUCTION**: If any step in this runbook is ambiguous, fails validation, or if you encounter unexpected file structures, **STOP IMMEDIATELY**. Do not guess. Ask the user for clarification before proceeding to the next step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Script & Agent Configuration
|
||||||
|
|
||||||
|
### 1.1. Rewrite `files/openvpn-discovery.sh`
|
||||||
|
**Path**: `C:\Users\joao.goncalves\Desktop\zabbix-itguys\templates_gold\pfsense_hybrid_snmp_agent\files\openvpn-discovery.sh`
|
||||||
|
**Logic**:
|
||||||
|
- Scan `/var/log/openvpn/status*.log`.
|
||||||
|
- Extract `{#VPN.SERVER}` from filename (Regex: `status_(.*).log` -> Group 1).
|
||||||
|
- Extract Users, Real IP, Byte Counts from content.
|
||||||
|
- **Critical**: Output JSON standard for Zabbix LLD.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/sh
|
||||||
|
# OpenVPN Discovery Script (Arthur's Gold Standard)
|
||||||
|
# Outputs: {#VPN.USER}, {#VPN.SERVER}, {#VPN.REAL_IP}
|
||||||
|
|
||||||
|
JSON_OUTPUT="{\"data\":["
|
||||||
|
FIRST_ITEM=1
|
||||||
|
|
||||||
|
# Loop through all status logs
|
||||||
|
for logfile in /var/log/openvpn/status*.log; do
|
||||||
|
[ -e "$logfile" ] || continue
|
||||||
|
|
||||||
|
# Extract Server Name from Filename "status_SERVERNAME.log"
|
||||||
|
# Note: Busybox filename parsing
|
||||||
|
filename=$(basename "$logfile")
|
||||||
|
# Remove prefix "status_" and suffix ".log"
|
||||||
|
server_name=$(echo "$filename" | sed -e 's/^status_//' -e 's/\.log$//')
|
||||||
|
|
||||||
|
# Read the file and parse "CLIENT_LIST" lines
|
||||||
|
# Format: CLIENT_LIST,CommonName,RealAddress,VirtualAddress,BytesReceived,BytesSent,Since,Since(time_t),Username,ClientID,PeerID
|
||||||
|
while IFS=, read -r type common_name real_address virtual_address bytes_rx bytes_tx since since_unix username client_id peer_id; do
|
||||||
|
if [ "$type" = "CLIENT_LIST" ] && [ "$common_name" != "Common Name" ]; then
|
||||||
|
# Extract IP only from RealAddress (IP:PORT)
|
||||||
|
real_ip=$(echo "$real_address" | cut -d: -f1)
|
||||||
|
|
||||||
|
# Append to JSON
|
||||||
|
if [ $FIRST_ITEM -eq 0 ]; then JSON_OUTPUT="$JSON_OUTPUT,"; fi
|
||||||
|
JSON_OUTPUT="$JSON_OUTPUT{\"{#VPN.USER}\":\"$common_name\",\"{#VPN.SERVER}\":\"$server_name\",\"{#VPN.REAL_IP}\":\"$real_ip\"}"
|
||||||
|
FIRST_ITEM=0
|
||||||
|
fi
|
||||||
|
done < "$logfile"
|
||||||
|
done
|
||||||
|
|
||||||
|
JSON_OUTPUT="$JSON_OUTPUT]}"
|
||||||
|
echo "$JSON_OUTPUT"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2. Update `files/userparameter_openvpn.conf`
|
||||||
|
**Path**: `C:\Users\joao.goncalves\Desktop\zabbix-itguys\templates_gold\pfsense_hybrid_snmp_agent\files\userparameter_openvpn.conf`
|
||||||
|
**Logic**:
|
||||||
|
- Simplify. The discovery script now does the heavy lifting of finding users.
|
||||||
|
- The Items need to fetch data. Since we have multiple files, `grep` needs to search ALL of them.
|
||||||
|
- **Optimization**: `grep -h` (no filename) to search all generic status logs.
|
||||||
|
|
||||||
|
```conf
|
||||||
|
UserParameter=openvpn.discovery,/opt/zabbix/openvpn-discovery.sh
|
||||||
|
# Fetch raw metrics for a specific user (Usernames must be unique across servers or we grab the first match)
|
||||||
|
UserParameter=openvpn.user.bytes_received.total[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f6
|
||||||
|
UserParameter=openvpn.user.bytes_sent.total[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f7
|
||||||
|
UserParameter=openvpn.user.connected_since[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f9
|
||||||
|
UserParameter=openvpn.user.real_address.new[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f3 | cut -d: -f1
|
||||||
|
UserParameter=openvpn.user.status[*],if grep -q "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null; then echo 1; else echo 0; fi
|
||||||
|
UserParameter=openvpn.version,openvpn --version 2>&1 | head -1 | awk '{print $2}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Template Configuration (YAML)
|
||||||
|
|
||||||
|
**Target**: `templates_gold/pfsense_hybrid_snmp_agent/template_pfsense_hybrid_gold.yaml`
|
||||||
|
|
||||||
|
### 2.1. Add Macros
|
||||||
|
Append to `macros` section:
|
||||||
|
```yaml
|
||||||
|
- macro: '{$VPN.S2S.PATTERN}'
|
||||||
|
value: '^S2S_'
|
||||||
|
description: 'Regex para identificar túneis Site-to-Site.'
|
||||||
|
- macro: '{$VPN.DATA.LIMIT}'
|
||||||
|
value: '10737418240'
|
||||||
|
description: 'Limite de Download (10GB) para alerta de Exfiltração.'
|
||||||
|
- macro: '{$VPN.WORK.START}'
|
||||||
|
value: '080000'
|
||||||
|
- macro: '{$VPN.WORK.END}'
|
||||||
|
value: '180000'
|
||||||
|
- macro: '{$VPN.ZOMBIE.LIMIT}'
|
||||||
|
value: '86400'
|
||||||
|
description: 'Tempo máximo (24h) para considerar sessão zumbi.'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2. Add Discovery Rule
|
||||||
|
**Name**: `Descoberta de Usuários OpenVPN`
|
||||||
|
**Key**: `openvpn.discovery`
|
||||||
|
**Type**: `ZABBIX_ACTIVE` (Preferred for Agents behind NAT/Firewall)
|
||||||
|
|
||||||
|
**Overrides**:
|
||||||
|
```yaml
|
||||||
|
overrides:
|
||||||
|
- name: 'Site-to-Site (S2S)'
|
||||||
|
step: '1'
|
||||||
|
filter:
|
||||||
|
conditions:
|
||||||
|
- macro: '{#VPN.USER}'
|
||||||
|
value: '{$VPN.S2S.PATTERN}'
|
||||||
|
operator: REGEXP
|
||||||
|
operations:
|
||||||
|
- operationobject: ITEM_PROTOTYPE
|
||||||
|
operator: REGEXP
|
||||||
|
value: 'Stats Year|Forecast'
|
||||||
|
status: ENABLED
|
||||||
|
- operationobject: TRIGGER_PROTOTYPE
|
||||||
|
operator: REGEXP
|
||||||
|
value: 'Exfiltração|Horário|Zombie|IP Change'
|
||||||
|
status: DISABLED
|
||||||
|
- operationobject: ITEM_PROTOTYPE
|
||||||
|
operator: LIKE
|
||||||
|
value: ''
|
||||||
|
tags:
|
||||||
|
- tag: Type
|
||||||
|
value: S2S
|
||||||
|
- name: 'User (Colaborador)'
|
||||||
|
step: '2'
|
||||||
|
filter:
|
||||||
|
conditions:
|
||||||
|
- macro: '{#VPN.USER}'
|
||||||
|
value: '{$VPN.S2S.PATTERN}'
|
||||||
|
operator: NOT_REGEXP
|
||||||
|
operations:
|
||||||
|
- operationobject: ITEM_PROTOTYPE
|
||||||
|
operator: REGEXP
|
||||||
|
value: 'Stats Year|Forecast'
|
||||||
|
status: DISABLED
|
||||||
|
- operationobject: TRIGGER_PROTOTYPE
|
||||||
|
operator: REGEXP
|
||||||
|
value: 'Exfiltração|Horário|Zombie|IP Change'
|
||||||
|
status: ENABLED
|
||||||
|
- operationobject: ITEM_PROTOTYPE
|
||||||
|
operator: LIKE
|
||||||
|
value: ''
|
||||||
|
tags:
|
||||||
|
- tag: Type
|
||||||
|
value: User
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3. Add Item Prototypes (Selected)
|
||||||
|
|
||||||
|
**Common Tags**: `Company: {{#VPN.SERVER}.regsub("(?:CLIENT_|S2S_)?(.*)", "\1")}`
|
||||||
|
|
||||||
|
1. **Download Total (Raw)**
|
||||||
|
- Key: `openvpn.user.bytes_received.total[{#VPN.USER}]`
|
||||||
|
- Type: ZABBIX_ACTIVE
|
||||||
|
- Units: B
|
||||||
|
|
||||||
|
2. **Download Rate (Calculated/Dependent?)**
|
||||||
|
- Actually, simpler to let Zabbix standard "Simple Change" preprocessing handle rate on a Dependent Item, or just use `Change per Second` preprocessing.
|
||||||
|
- **Decision**: Create `openvpn.user.bytes_received.rate[{#VPN.USER}]` dependent on Total, with `Change per Second`.
|
||||||
|
|
||||||
|
3. **Real IP (Inventory)**
|
||||||
|
- Key: `openvpn.user.real_address.new[{#VPN.USER}]`
|
||||||
|
- Populates Host Inventory field? No, this is an item prototype. Just keep as value.
|
||||||
|
|
||||||
|
**Reporting Metrics (Calculated)**
|
||||||
|
- **Stats Week**: `trendsum(//openvpn.user.bytes_received.total[{#VPN.USER}], 1w:now/w)`
|
||||||
|
- **Stats Month**: `trendsum(..., 1M:now/M)`
|
||||||
|
- **Forecast**: `(last(Month) / dayat()) * dayofmonth(end)` (Simplified)
|
||||||
|
|
||||||
|
### 2.4. Add Triggers
|
||||||
|
|
||||||
|
1. **Exfiltração**: `(last(Total) - last(Total, 1h)) > {$VPN.DATA.LIMIT}`
|
||||||
|
2. **IP Change**: `diff(RealIP)=1 and last(Status)=1`
|
||||||
|
3. **Zombie**: `last(ConnectedSince) < now() - {$VPN.ZOMBIE.LIMIT}`
|
||||||
|
|
||||||
|
### 2.5. Add Dashboard
|
||||||
|
Insert `dashboards:` block at root (or modify existing if any).
|
||||||
|
- Use `type: svggraph` for Trends.
|
||||||
|
- Use `type: tophosts` for Tables (Columns: Name, Latest Value of Metric).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: Validation
|
||||||
|
|
||||||
|
1. **Lint**: `validate_zabbix_template.py`.
|
||||||
|
2. **Docs**: `generate_template_docs.py`.
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# OpenVPN Discovery Script (Arthur's Gold Standard)
|
||||||
|
# Outputs: {#VPN.USER}, {#VPN.SERVER}, {#VPN.REAL_IP}
|
||||||
|
|
||||||
|
JSON_OUTPUT="{\"data\":["
|
||||||
|
FIRST_ITEM=1
|
||||||
|
|
||||||
|
# Loop through all status logs
|
||||||
|
for logfile in /var/log/openvpn/status*.log; do
|
||||||
|
[ -e "$logfile" ] || continue
|
||||||
|
|
||||||
|
# Extract Server Name from Filename "status_SERVERNAME.log"
|
||||||
|
# Note: Busybox filename parsing
|
||||||
|
filename=$(basename "$logfile")
|
||||||
|
# Remove prefix "status_" and suffix ".log"
|
||||||
|
server_name=$(echo "$filename" | sed -e 's/^status_//' -e 's/\.log$//')
|
||||||
|
|
||||||
|
# Read the file and parse "CLIENT_LIST" lines
|
||||||
|
# Format: CLIENT_LIST,CommonName,RealAddress,VirtualAddress,BytesReceived,BytesSent,Since,Since(time_t),Username,ClientID,PeerID
|
||||||
|
while IFS=, read -r type common_name real_address virtual_address bytes_rx bytes_tx since since_unix username client_id peer_id; do
|
||||||
|
if [ "$type" = "CLIENT_LIST" ] && [ "$common_name" != "Common Name" ]; then
|
||||||
|
# Extract IP only from RealAddress (IP:PORT)
|
||||||
|
real_ip=$(echo "$real_address" | cut -d: -f1)
|
||||||
|
|
||||||
|
# Append to JSON
|
||||||
|
if [ $FIRST_ITEM -eq 0 ]; then JSON_OUTPUT="$JSON_OUTPUT,"; fi
|
||||||
|
JSON_OUTPUT="$JSON_OUTPUT{\"{#VPN.USER}\":\"$common_name\",\"{#VPN.SERVER}\":\"$server_name\",\"{#VPN.REAL_IP}\":\"$real_ip\"}"
|
||||||
|
FIRST_ITEM=0
|
||||||
|
fi
|
||||||
|
done < "$logfile"
|
||||||
|
done
|
||||||
|
|
||||||
|
JSON_OUTPUT="$JSON_OUTPUT]}"
|
||||||
|
echo "$JSON_OUTPUT"
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
# OpenVPN UserParameters for Zabbix Agent (Arthur's Gold Standard)
|
||||||
|
# Compatible with: Zabbix 7.0+
|
||||||
|
# Installation: Copy to /usr/local/etc/zabbix72/zabbix_agentd.conf.d/
|
||||||
|
|
||||||
|
UserParameter=openvpn.discovery,/opt/zabbix/openvpn-discovery.sh
|
||||||
|
|
||||||
|
# Fetch raw metrics for a specific user (Usernames must be unique across servers or we grab the first match)
|
||||||
|
UserParameter=openvpn.user.bytes_received.total[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f5
|
||||||
|
UserParameter=openvpn.user.bytes_sent.total[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f6
|
||||||
|
UserParameter=openvpn.user.connected_since[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f8
|
||||||
|
UserParameter=openvpn.user.real_address.new[*],grep -h "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null | head -1 | cut -d, -f3 | cut -d: -f1
|
||||||
|
UserParameter=openvpn.user.status[*],if grep -q "^CLIENT_LIST,$1," /var/log/openvpn/status*.log 2>/dev/null; then echo 1; else echo 0; fi
|
||||||
|
|
||||||
|
# General OpenVPN Instance Metrics
|
||||||
|
UserParameter=openvpn.version,openvpn --version 2>&1 | head -1 | awk '{print $2}'
|
||||||
|
UserParameter=openvpn.user.count,grep -h "^CLIENT_LIST" /var/log/openvpn/status*.log 2>/dev/null | grep -v "Common Name" | wc -l
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Documentação: PFSense Hybrid: SNMP + OpenVPN Agent
|
||||||
|
|
||||||
|
**Template:** PFSense Hybrid: SNMP + OpenVPN Agent
|
||||||
|
**Descrição:**
|
||||||
|
Template Híbrido para monitoramento do pfSense.
|
||||||
|
|
||||||
|
SNMP: Monitoramento de interfaces, firewall, serviços (DHCP, DNS, Nginx).
|
||||||
|
Agent: Monitoramento avançado de OpenVPN (usuários, túneis S2S, tráfego).
|
||||||
|
|
||||||
|
Requisitos:
|
||||||
|
1. Habilite o daemon SNMP em Services na interface web do pfSense.
|
||||||
|
2. Instale o Zabbix Agent e configure os UserParameters OpenVPN (ver INSTRUCOES_AGENTE.txt).
|
||||||
|
3. Associe o template ao host.
|
||||||
|
|
||||||
|
MIBs: BEGEMOT-PF-MIB, HOST-RESOURCES-MIB
|
||||||
|
Keys Agent: openvpn.*
|
||||||
|
|
||||||
|
Gerado pelo Padrão Gold (Arthur) - ITGuys
|
||||||
|
|
||||||
|
## Itens Monitorados
|
||||||
|
|
||||||
|
### Itens Globais
|
||||||
|
- **Coleta Raw (SNMP): Interfaces de Rede PF** (`net.if.pf.walk`)
|
||||||
|
- **Coleta Raw (SNMP): Interfaces de Rede (IF-MIB)** (`net.if.walk`)
|
||||||
|
- **Status do servidor DHCP** (`pfsense.dhcpd.status`)
|
||||||
|
- **Status do servidor DNS** (`pfsense.dns.status`)
|
||||||
|
- **Estado do processo Nginx (Web)** (`pfsense.nginx.status`)
|
||||||
|
- **Pacotes com offset incorreto (Bad Offset)** (`pfsense.packets.bad.offset`)
|
||||||
|
- **Pacotes Fragmentados** (`pfsense.packets.fragment`)
|
||||||
|
- **Pacotes correspondentes a uma regra de filtro** (`pfsense.packets.match`)
|
||||||
|
- **Pacotes descartados por limite de memória** (`pfsense.packets.mem.drop`)
|
||||||
|
- **Pacotes Normalizados** (`pfsense.packets.normalize`)
|
||||||
|
- **Pacotes Curtos (Short Packets)** (`pfsense.packets.short`)
|
||||||
|
- **Status de execução do Packet Filter** (`pfsense.pf.status`)
|
||||||
|
- **Coleta Raw (SNMP): Contadores PF (pfCounter)** (`pfsense.pf_counters.walk`)
|
||||||
|
- **Contagem de regras de Firewall** (`pfsense.rules.count`)
|
||||||
|
- **Tabela de Rastreamento: Origens Atuais (Source Tracking)** (`pfsense.source.tracking.table.count`)
|
||||||
|
- **Tabela de Rastreamento: Limite (Limit)** (`pfsense.source.tracking.table.limit`)
|
||||||
|
- **Tabela de Rastreamento: Utilização (%)** (`pfsense.source.tracking.table.pused`)
|
||||||
|
- **Tabela de Estados: Atual (State Table)** (`pfsense.state.table.count`)
|
||||||
|
- **Tabela de Estados: Limite (Limit)** (`pfsense.state.table.limit`)
|
||||||
|
- **Tabela de Estados: Utilização (%)** (`pfsense.state.table.pused`)
|
||||||
|
- **Coleta Raw (SNMP): Software Instalado (hrSWRun)** (`pfsense.sw.walk`)
|
||||||
|
- **Disponibilidade do Agente SNMP** (`zabbix[host,snmp,available]`)
|
||||||
|
|
||||||
|
### Regras de Descoberta (LLD)
|
||||||
|
|
||||||
|
#### Descoberta de Usuários OpenVPN (`openvpn.discovery`)
|
||||||
|
- **Protótipos de Itens:**
|
||||||
|
- OpenVPN [{#VPN.USER}]: Download Total (Bytes) (`openvpn.user.bytes_received.total[{#VPN.USER}]`)
|
||||||
|
- OpenVPN [{#VPN.USER}]: Upload Total (Bytes) (`openvpn.user.bytes_sent.total[{#VPN.USER}]`)
|
||||||
|
- OpenVPN [{#VPN.USER}]: IP Real (`openvpn.user.real_address.new[{#VPN.USER}]`)
|
||||||
|
- OpenVPN [{#VPN.USER}]: Status (`openvpn.user.status[{#VPN.USER}]`)
|
||||||
|
- OpenVPN [{#VPN.USER}]: Tempo Conectado (Unix) (`openvpn.user.connected_since[{#VPN.USER}]`)
|
||||||
|
|
||||||
|
## Alertas (Triggers)
|
||||||
|
|
||||||
|
### Triggers Globais
|
||||||
|
- [AVERAGE] **🚨 DHCP Parado: Servidor DHCP não está em execução**
|
||||||
|
- [AVERAGE] **🚨 DNS Parado: Servidor DNS (Unbound) não está em execução**
|
||||||
|
- [AVERAGE] **🚨 WebServer Parado: Nginx não está rodando**
|
||||||
|
- [WARNING] **🧩 Fragmentação Excessiva IPv4**
|
||||||
|
- [WARNING] **🛡️ Possível Ataque/Scan: Pico de Bloqueios**
|
||||||
|
- [HIGH] **🚨 Firewall Desligado: Packet Filter inativo**
|
||||||
|
- [WARNING] **⚠️ Tabela de Rastreamento Cheia: Uso elevado ({ITEM.LASTVALUE1}%)**
|
||||||
|
- [HIGH] **⏳ Source Tracking cheia em < 1h (Previsão)**
|
||||||
|
- [WARNING] **🔥 Tabela de Estados Crítica: Risco de bloqueio ({ITEM.LASTVALUE1}%)**
|
||||||
|
- [HIGH] **⏳ Tabela de Estados cheia em < 1h (Previsão)**
|
||||||
|
- [WARNING] **🚨 Falha SNMP: Sem coleta de dados no pfSense**
|
||||||
|
|
||||||
|
### Protótipos de Triggers (LLD)
|
||||||
|
|
||||||
|
**Regra: Descoberta de Usuários OpenVPN**
|
||||||
|
|
@ -190,10 +190,83 @@ def validate_dashboard_references(content, graph_names):
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def check_duplicate_yaml_keys(file_path):
|
||||||
|
"""
|
||||||
|
Check for duplicate YAML keys at the same level (e.g., two 'macros:' sections).
|
||||||
|
This is a Zabbix import killer - YAML parsers silently merge, but Zabbix rejects.
|
||||||
|
Uses regex-based scanning since yaml.safe_load silently handles duplicates.
|
||||||
|
Returns list of errors.
|
||||||
|
|
||||||
|
Note: Only checks for duplicates at template-level (indent 4) since nested
|
||||||
|
keys like 'triggers:' can legitimately appear multiple times in different
|
||||||
|
item contexts.
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"[FILE ERROR] Could not read file: {e}")
|
||||||
|
return errors
|
||||||
|
|
||||||
|
# Track keys at template level (indent 4) only
|
||||||
|
# Key: key_name -> list of line numbers
|
||||||
|
template_level_keys = {}
|
||||||
|
|
||||||
|
# Critical keys that should never be duplicated at template level
|
||||||
|
critical_keys = {'macros', 'items', 'discovery_rules', 'dashboards',
|
||||||
|
'graphs', 'valuemaps', 'value_maps'}
|
||||||
|
|
||||||
|
for line_num, line in enumerate(lines, 1):
|
||||||
|
# Skip comments and empty lines
|
||||||
|
stripped = line.lstrip()
|
||||||
|
if not stripped or stripped.startswith('#'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate indentation (spaces before content)
|
||||||
|
indent = len(line) - len(line.lstrip())
|
||||||
|
|
||||||
|
# Only check template-level keys (indent 4 for " macros:")
|
||||||
|
if indent != 4:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Match YAML key pattern: "key:" or "key: value"
|
||||||
|
import re
|
||||||
|
match = re.match(r'^(\s*)([a-zA-Z_][a-zA-Z0-9_]*):', line)
|
||||||
|
if match:
|
||||||
|
key_name = match.group(2)
|
||||||
|
|
||||||
|
# Only track critical keys
|
||||||
|
if key_name in critical_keys:
|
||||||
|
if key_name not in template_level_keys:
|
||||||
|
template_level_keys[key_name] = []
|
||||||
|
template_level_keys[key_name].append(line_num)
|
||||||
|
|
||||||
|
# Report duplicates
|
||||||
|
for key_name, line_numbers in template_level_keys.items():
|
||||||
|
if len(line_numbers) > 1:
|
||||||
|
lines_str = ', '.join(map(str, line_numbers))
|
||||||
|
errors.append(f"[DUPLICATE KEY] '{key_name}:' appears {len(line_numbers)} times at template level (lines: {lines_str})")
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
def validate_yaml(file_path):
|
def validate_yaml(file_path):
|
||||||
print(f"Validating {file_path}...")
|
print(f"Validating {file_path}...")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
|
# ========== 0. Check for duplicate YAML keys (pre-parse) ==========
|
||||||
|
print("\n[0/5] Checking for duplicate YAML keys...")
|
||||||
|
duplicate_key_errors = check_duplicate_yaml_keys(file_path)
|
||||||
|
if duplicate_key_errors:
|
||||||
|
print(f" ❌ Found {len(duplicate_key_errors)} duplicate key issues")
|
||||||
|
for err in duplicate_key_errors:
|
||||||
|
print(f" ❌ {err}")
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
print(" ✅ No duplicate YAML keys detected")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(file_path, 'r', encoding='utf-8') as f:
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
content = yaml.safe_load(f)
|
content = yaml.safe_load(f)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue