helio.loureiro.eng.br
  • Home
  • Unix
  • Linux
  • Blog
  • Python
  • Programação
  • Tudo
  • Suécia
  1. You are here:  
  2. Home

Os artigos mais lidos de 2025

  • Configurando o grafana alloy pra monitorar VMs
  • Configurando traefik com ssh
  • Configurando o teclado Keychron C3 no Linux
  • Parâmetros de compilação pra Go!
  • Script pra verificar e atualizar os certificados dos domínios com letsencrypt

Home

Bugigangas que comprei no Aliexpress e deram certo

Details
Written by: Helio Loureiro
Category: Blog
Published: September 21, 2025
Hits: 692

Já faz algum tempo que venho trocando o hábito de comprar na Amazon pelo de comprar na Aliexpress. A vantagem é que geralmente é mais barato na Aliexpress. A desvantagem é que nem tudo dá pra trocar ou devolver. E demora mais pra chegar, se bem que os chineses estão fazendo um excelente trabalho nesse ponto. E tem o fator qualidade.

Então essas compras invariavelmente te levam a adquirir coisas que não duram muito ou estragam rapidamente.

Mas isso está mudando. E muito. Então vou listar aqui coisas que comprei ultimamente e estão sendo muito úteis e a qualidade é excelente. Vou botar os links mas... na Aliexpress os vendedores podem desaparecer do dia pra noite. Faz parte do "ethos" da Aliexpress. Vamos então às dicas.

Cobertura pro capacete em dias de chuva

Não é lá um produto que exigem muita coisa, mas é algo que ajuda bastante nos dias de chuva pra não deixar sua cabeça molhada. E custa pouco.

https://www.aliexpress.com/item/1005006420341877.html

Bomba de gás CO2 portátil

Essa bomba portátil ajudou a diminuir o peso e volume da bomba elétrica USB que eu carregava antes. Eu sempre levo uma outra bomba manual pro caso de ficar sem gás já que usou o cilindro uma vez, ele não segura o gás depois.

https://www.aliexpress.com/item/1005007418856267.html

Mochila traseira

Eu tenho uma mochila maior que levo nas viagens mais longas. Mas uso essa pras pedaladas mais próximas. Como eu tenho bagageiro, prendo nele ao invés de prender no banco.

https://www.aliexpress.com/item/1005004987586525.html

Fita do guidão

Conforme vamos pedalando, essa fita do guidão vai gastando. Troquei por essa da Aliexpress e não tenho reclamações. Fuciona bem e a opção de cores é maior.

https://www.aliexpress.com/item/1005007149961570.html

Garrafa térmica de 1l

Quando pedalamos no verão, depois de 2 ou 3 horas, a água das garrafas viram chá. Então eu comprei essa térmica pra experimentar. E funciona! Mantém o líquido gelado por mais de 12 horas. O problema foi tamanho: diâmetro maior que o que cabia no porta-garrafa da bike. Atualmente eu carrego ela como segunda garrafa na mochina traseira.

https://www.aliexpress.com/item/1005005084573855.html

Extensão térmica

Eu não sei muito bem como descrever essas peças de roupa de ciclismo em português que em tradução literal ficariam como "aquecedores de pernas e braços", mas são extensões de roupas pra te manter quente. Talvez não muito necessário em climas quentes como do Brasil, mas essenciais aqui na Europa nórdica. E ocupam pouco espaço na mala.

https://www.aliexpress.com/item/32643446846.html

Camiseta de primeira camada

Eu uso essa camiseta como primeira camada. Não tem outra funcionalidade além da de absorver suor e evitar aquela gotícula que escorre das costas e entra no seu rego e só vai parar quando chega no seu c*. Pra quem não usa bib pra pedalar, aquela roupa de ciclismo, ou usa essa roupa com roupas de baixo (calcinha ou cueca) - o que não deveriam fazer, não é algo tão necessário assim.

https://www.aliexpress.com/item/1005006062287648.html

Fita de acabamento

Essa é a fita que uso pra fazer o acabamento da outra fita, que descrevi acima, no guidão. Usei recentemente pra cobrir o cabo do power bank, que é laranja, e deixar quase todo preto.

https://www.aliexpress.com/item/1005006149448568.html

Garrafa térmica de 750ml

Como a outra garrafa térmica que comprei primeiro tem o diâmetro muito grande, acabei comprando essa. Ela é hoje em dia minha garrafa principal e segura bem a temperatura. Talvez por até 10h ou mais. E com o diâmetro menor, encaixa sem problemas no porta-garrafas da bike.

https://www.aliexpress.com/item/1005004787987002.html

Porta-garrafa lateral

A maioria dos porta-garrafas você tira por cima a garrafa. Como eu uso um porta trecos na parte de baixo do frame da bike, pra carregar o power bank, precisei comprar um que eu pudesse tirar e colocar a garrafa de lado. Esse permite garrafas com diâmetro maior mas eu achei que a garrafa térmica de 1l não fica firme nela e resolvi não arriscar.

https://www.aliexpress.com/item/32836646942.html

Paralamas

Item obrigatório aqui, que chove bastante. A vantagem desses paralamas é que são presos por borrachas. Então é fácil colocar e tirar.

https://www.aliexpress.com/item/1005005707762706.html

Porta trecos parafusado

Deve ter nome melhor que "porta-trecos" mas essa mochila que é um porta-trecos tem o diferencial de poder parafusar no frame. E como minha bike, uma Trek, tem essa entrada pros parafusos, esse porta-treco funcionou maravilhosamente.

https://www.aliexpress.com/item/1005007830666331.html

Power bank e luz

Eu fiz um review no Aliexpress sentando a lenha nesse power bank. Fui muito rápido no meu teste pra fazer tal review. O problema é que o cabo não entra muito bem e precisa encaixar certo. Feito isso corretamente, segura bem a carga. Alimenta a minha GoPro por 12h. Até mais. Tem a funcionalidade de lâmpada, mas eu não uso. E encaixa no conector de Garmin. A parte de cima, onde coloco o computador, não é muito firme e faz barulho. Mas não cai. Nem solta.

https://www.aliexpress.com/item/1005007893596572.html

Pulseira de velcro pra Garmin

Não é bem relacionado com a bike, mas como uso um setup Garmin, digamos que até faz parte. Eu não gosto de usar a pulseira de borracha e prefiro essas de velcro.

https://www.aliexpress.com/item/1005007055798900.html

Camiseta de ciclismo

Acho que nem preciso descrever muito aqui. Talvez só uma nota: meu amigo comprou uma semelhante pela Temu e a qualidade parece inferior.

https://www.aliexpress.com/item/1005008670708885.html

Boné de ciclismo

Também acho que não preciso descrever muito aqui. Funciona bem pra absorver o suor.

https://www.aliexpress.com/item/1005005325484422.html

Campainha de ciclismo

Aqui capacete não é ítem obrigatório. Mas campainha é. E essa fica legal em drop bar de road e gravel bikes.

https://www.aliexpress.com/item/1005008598528788.html

Óculos de ciclismo

Se realmente protege contra raios UV, eu não sei. Mas tem funcionado. Tem uns vãos nas bordas que incomodam no início. Mas depois de algumas pedaladas você acostuma e não nota. Eu não costumo botar as alças laterais atrás da orelha porque vão machucando ao longo do caminho. Eu coloco ao lado e preciso que fique firme pra não cair (o capacete ajuda a segurar). E esses óculos funcionaram bem pra isso.

https://www.aliexpress.com/item/1005005715547360.html

Espelho

Esse espelho ajuda muito quando estamos na estrada. Posso ver se quem está atrás não está distante e ver se carros estão chegando. E quando fica fechado, não fica aparente que está ali no guidão.

https://www.aliexpress.com/item/1005007112559742.html

Base de válvula presta

Esse aqui não tem um uso muito prático. É só estético mesmo. Eu tenho a tampa das válvulas de alumínio azul também. Então combina e fica charmoso.

https://www.aliexpress.com/item/1005008284674262.html

Cabo USB enrolado

Quando ligo o power bank ou no telefone ou na GoPro, esse é o cabo que uso. Ele estica bastante e não fica solto.

https://www.aliexpress.com/item/1005008135645495.html

Meias de ciclismo

Comprei só pra combinar com a cor da bike. Funcionam bem.

https://www.aliexpress.com/item/1005006741629823.html

Garmin mount preso no guidão

Eu passei a usar esse mount de Garmin pra segurar o telefone. Fica firme. E posso encaixar o computador também. E os power banks que tenho. Podia ter em azul pra combinar com a bike.

https://www.aliexpress.com/item/1005007234622515.html

Cadarço elástico

Mais fácil pra prender o tênis.

https://www.aliexpress.com/item/1005005616313292.html?

Pedais

Eu preciso trocar os pedais a cada 6 meses. Talvez até em menos tempo. Esses são baratos e leves.

https://www.aliexpress.com/item/1005004987770744.html

Meias de ciclismo

São engraçadas. Mas não absorvem bem o suor. Mas são engraçadas.

https://www.aliexpress.com/item/1005008802685826.html

Luvas com alcochoamento extra

Essas luvas com alcochoamento mais grosso (6mm) ajudam bastante nas pedaladas mais longas.

https://www.aliexpress.com/item/1005005576718047.html

Case pra segurar telefones com mount de Gamin

Como eu tenho mount de Garmin na bike, uso esse case pra segurar o telefone. Fica firme e absorve bem a trepidação.

https://www.aliexpress.com/item/1005008156893382.html

Menção de honra

Um dos produtos desapareceu. Então vou deixar ele aqui pra caso apareça de novo. É um power bank com lâmpada. Tenho usado mais como lâmpada frontal.

A descrição era: ThinkRider Bicycle Light For Garmin Gopro Bike Headlight Type-C Usb Charging Bike Flashlight Bicycle Lamp Black, 100-300 Lumen.

Agora não tem mais desculpa pra nãp pedalar. Bugigangas legais é que não vão faltar.

Update

Agora dando uma olhada na Aliexpress, eu achei o powerbank com lâmpada que mencionei. Mudou de loja/fabricante mas parece o mesmo produto.

https://www.aliexpress.com/item/1005009116049345.html

Monitorando a temperatura da sala dos servidores

Details
Written by: Helio Loureiro
Category: Linux
Published: September 12, 2025
Hits: 702
  • grafana
  • alloy
  • temperature
  • temperatura

Por motivos que não cabem aqui, temos alguns servidores instalados em uma sala na empresa. E essa sala tem um ar-condicionado pra manter a temperatura sob controle.

Um belo dia estou olhando os dados no Grafana e noto que essas máquinas não reportaram dados (não temos alertas enviados pelo Grafana, mas o porquê disso fica pra um outro dia). Entro na sala e a temperatura estava simplemente... 32°C. Era verão na Suécia, que é curto mas tem seus dias bem quentes. E o hardware das máquinas desligaram pra proteção.

Entre entrar em contato com técnico do ar-condicionado e deixar a sala aberta pra ventilar, ficamos com aquele gosto amargo de não ter nenhum dado sobre a temperatura.

A solução? raspberrypi!

Ele tem um sensor que é vendido na Internet.

https://thepihut.com/products/temper2-usb-dual-temperature-sensor

O sensor já chegou mas não o raspberrypi. O motivo deve ser porque compramos um modelo que funciona como KVM e tem algumas coisas a mais.

Então aqui a descrição de como botar o serviço pra funcionar em Linux.

Dados via python

Existe um software descrito na página do produto que aponta pro seguinte repositório no GitHub:

https://github.com/modmypi/temper

Mas o repositório parece abandonado. Já faz 7 anos que ninguém manda nenhum commit. E o código não funciona com a versão mais moderna do sensor.

O que fazer? Patch!

Então corrigi o programa e criei um fork do repo original.

https://github.com/helioloureiro/temper

Então temos o software pronto pra funcionar. Ou quase.

Antes é preciso corrigir as permissões de leitura e escrita do dispositivo. E pra isso eu criei uma pequena regra no udev em /etc/udev/rules.d/90-temperature-sensor.rules

    
KERNEL=="hidraw[0-9]*", SUBSYSTEM=="hidraw", SUBSYSTEMS=="usb", ATTRS{idVendor}=="3553", ATTRS{idProduct}=="a001", MODE="0666", SYMLINK+="temper"      
    
  

Pegando a saída do kernel:

    
# dmesg | grep -i temper
[    5.152423] usb 1-10.4: Product: TEMPer2
[    6.769788] input: PCsensor TEMPer2 as /devices/pci0000:00/0000:00:14.0/usb1/1-10/1-10.4/1-10.4:1.0/0003:3553:A001.0005/input/input14
[    6.826541] hid-generic 0003:3553:A001.0005: input,hidraw4: USB HID v1.11 Keyboard [PCsensor TEMPer2] on usb-0000:00:14.0-10.4/input0
[    6.826720] input: PCsensor TEMPer2 as /devices/pci0000:00/0000:00:14.0/usb1/1-10/1-10.4/1-10.4:1.1/0003:3553:A001.0006/input/input15
[    6.827067] hid-generic 0003:3553:A001.0006: input,hidraw5: USB HID v1.10 Device [PCsensor TEMPer2] on usb-0000:00:14.0-10.4/input1
[  939.507362] usb 1-10.4: Product: TEMPer2
[  939.521617] input: PCsensor TEMPer2 as /devices/pci0000:00/0000:00:14.0/usb1/1-10/1-10.4/1-10.4:1.0/0003:3553:A001.000B/input/input26
[  939.580825] hid-generic 0003:3553:A001.000B: input,hidraw4: USB HID v1.11 Keyboard [PCsensor TEMPer2] on usb-0000:00:14.0-10.4/input0
[  939.581808] input: PCsensor TEMPer2 as /devices/pci0000:00/0000:00:14.0/usb1/1-10/1-10.4/1-10.4:1.1/0003:3553:A001.000C/input/input27
[  939.582035] hid-generic 0003:3553:A001.000C: input,hidraw5: USB HID v1.10 Device [PCsensor TEMPer2] on usb-0000:00:14.0-10.4/input1
    
  

É possível ver que o dispositivo aparece como dois devices: /dev/hidraw4 e /dev/hidraw5.

Eu tentei usar a permissão 0644 primeiro, mas essa não funcionou pra ler os dados. Então tive de mudar pra 0666 mesmo sendo algo que só lê informação.

Feita essa etapa, ainda não estamos prontos pra rodar o programa temper.py. Ainda é preciso instalar a dependência: serial.

Se seu sistema é baseado em debian/ubuntu:

    
> sudo apt install -y python3-serial
    
  

Se não for, talvez seja mais fácil fazer com virtualenv. E pra isso eu atualmente uso o uv

   
> uv venv venv
> source venv/bin/activate     
(venv)> uv pip install serial
   
 

Tendo tudo pronto, chegamos ao momento da verdade:

   
(venv)> ./temper.py 
Bus 001 Dev 011 3553:a001 TEMPer2_V4.1 25.6C 78.0F - 22.8C 73.1F -
   
 

Pegando a saída como JSON permite ver melhor o que é cada um desses resultados.

   
(venv)>  ./temper.py --json
[
    {
        "vendorid": 13651,
        "productid": 40961,
        "manufacturer": "PCsensor",
        "product": "TEMPer2",
        "busnum": 1,
        "devnum": 11,
        "devices": [
            "hidraw4",
            "hidraw5"
        ],
        "firmware": "TEMPer2_V4.1",
        "hex_firmware": "54454d506572325f56342e3100000000",
        "hex_data": "808009f64e200000800108e94e200000",
        "internal temperature": 25.5,
        "external temperature": 22.81
    }
]     
   
 

Então a primeira temperatura lida, de /dev/hidraw4, é 25.5°C interna do dispositivo. A segunda, /dev/hidraw5, é de 22.81°C e externa, do cabo.

Temos as leituras e os dados. Como mandar isso pro Grafana?

Exportando as métricas pro Grafana

Eu primeiramente tentei fazer em shell script e mandar o dados pro mimir, que é onde eu agrego as métricas.

Fracassei miseravelmente.

Não existe uma forma muito fácil de enviar um dados pro lá. O formato que o alloy usa é protobuf, que é um dado comprimido em snappy, etc.

Qual outra alternativa?

Expor o dado como open metric pro alloy pegar e enviar.

Pode parecer simples mas... precisamos de um servidor web pra isso. Algo que temos fácil em python. Então usando uvicorn e fastapi podemos ter tudo funcionando. E é possível importar o temper.py como módulo.

E é preciso incrementar nosso virtualenv (ou pacotes) com esses pacotes:

  
> sudo apt install -y python3-uvicorn python3-fastapi
  
ou
  
(venv)> uv pip install uvicorn
(venv)> uv pip install fastapi
  

Sem mais delongas, eis aqui o código do monitor.py:

   
#! /usr/bin/env python3

import subprocess
import argparse
import logging
import threading
import time

try:
    import temper
except ImportError as e:
    print(f"Error importing temper module: {e}")
    print("Make sure python3-serial is installed: sudo apt-get install python3-serial")
    exit(1)

import uvicorn
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse


CELSIUS = "\u2103"
DEFAULT_PORT = 8000
TEMPERATURE_MAX = 25.0

logger = logging.getLogger(__file__)
consoleOutputHandler = logging.StreamHandler()
formatter = logging.Formatter(
    fmt="[%(asctime)s] (%(levelname)s) %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S"
)
consoleOutputHandler.setFormatter(formatter)
logger.addHandler(consoleOutputHandler)
logger.setLevel(logging.INFO)

def shellExec(command: str) -> str:
    'run a command and return its output'
    try:
        return subprocess.getoutput(command)
    except Exception as e:
        logger.error(f"Error executing shell command '{command}': {e}")
        return f"Error: {e}"

app = FastAPI()

# Global temperature monitor instance (will be set in main)
temperature_monitor = None

@app.get("/metrics", response_class=PlainTextResponse)
async def metrics():
    if temperature_monitor is None or temperature_monitor.temperature_current is None:
        return ""
        
    temperature_current = temperature_monitor.temperature_current
    logger.info(f"/metrics: {temperature_current}{CELSIUS}")
    data_lines = list()
    data_lines.append("#HELP server_room_temperature_celsius the room with servers current temperature")
    data_lines.append("#TYPE server_room_temperature_celsius gauge")
    data_lines.append(f"server_room_temperature_celsius {temperature_current}")
    data_lines.append("")
    return "\n".join(data_lines)

class TemperatureMonitor:
    'A class that handle the temperature monitoring'
    port: int = DEFAULT_PORT
    temperature_max: float = TEMPERATURE_MAX
    temperature_current: float|None = None
    alert_lock: bool = False

    def __init__(self, port=None, temperature_max=None) -> None:
        if port:
            self.port = port
        if temperature_max:
            self.temperature_max = temperature_max

    def monitor(self) -> None:
        th = threading.Thread(target=self.webserver)
        th.daemon = True  # Make it a daemon thread
        th.start()
        try:
            while True:
                self.update()
                time.sleep(15)
        except KeyboardInterrupt:
            logger.info("Monitoring stopped by user")
        except Exception as e:
            logger.error(f"Error in monitoring loop: {e}")
            raise
     
    def webserver(self) -> None:
        uvicorn.run(app, host="127.0.0.1", port=self.port)

    def update(self) -> None:
        'Read the output from the command'
        try:
            tp = temper.Temper().read()
            if not tp or len(tp) == 0:
                logger.warning("No temperature devices found")
                self.temperature_current = None
                return
            
            self.temperature_current = tp[0].get('external temperature')
            if self.temperature_current is not None:
                logger.info(f"🌡️ current temperature: {self.temperature_current}{CELSIUS}")
            else:
                logger.warning("External temperature reading is None")
        except Exception as e:
            logger.error(f"Error reading temperature sensor: {e}")
            self.temperature_current = None

if __name__ == '__main__':
    parse = argparse.ArgumentParser(description="script to monitor temperature")
    parse.add_argument("--loglevel", default="info", help="the logging level (default=info)")
    parse.add_argument("--tempmax", type=float, default=TEMPERATURE_MAX, help="maximum temperature before raising alert")
    parse.add_argument("--port", type=int, default=DEFAULT_PORT, help="port to listen the service")
    args = parse.parse_args()

    # Validate log level
    valid_log_levels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL']
    if args.loglevel.upper() not in valid_log_levels:
        print(f"Invalid log level: {args.loglevel}. Valid options: {', '.join(valid_log_levels)}")
        exit(1)
        
    if args.loglevel.upper() != "INFO":
        logger.setLevel(args.loglevel.upper())

    # Validate temperature threshold
    if args.tempmax <= 0:
        print("Temperature threshold must be greater than 0")
        exit(1)
        
    # Validate port number
    if not (1 <= args.port <= 65535):
        print("Port must be between 1 and 65535")
        exit(1)

    # Create temperature monitor instance
    temperature_monitor = TemperatureMonitor(args.port, args.tempmax)
    temperature_monitor.monitor()     
   
 

Eu tenho integrado uma parte de alerta que usa outro sistema, mas removi pra deixar o código fazendo somente o que é preciso.

No alloy, adicionei as seguintes linhas:

  
discovery.relabel "temperature_sensor" {
        targets = array.concat(
                [{
                __address__ = "localhost:8000",
                }],
        )

        rule {
                source_labels = ["__address__"]
                target_label  = "instance"
                replacement   = "temper"
        }
}

prometheus.scrape "temperature_sensor" {
        targets    = discovery.relabel.temperature_sensor.output
        forward_to = [prometheus.remote_write.prod.receiver]
        job_name   = "agent"
}    
  

Tudo pronto. Ou quase. Falta rodar o monitor.py que mostrei acima como serviço. E pra isso usamos o systemd. Basta criar o arquivo /etc/systemd/system/temperature-monitor.service e iniciar.

Serviço no systemd

  
[Unit]
Description=Temperature monitoring service
After=network.target

[Service]
User=helio
Group=helio
WorkingDirectory=/home/helio/temperature-sensor
ExecStart=/home/helio/temperature-sensor/monitor.sh
Restart=always

[Install]
WantedBy=multi-user.target    
  

O script monitor.sh é pra somente ler o virtualenv corretamente:

  
#! /usr/bin/env bash

die() {
	echo "ERROR: $@" >&2
	echo "[$(date)] exiting with error"
	exit 1
}

program="$0"
root_dir=$(readlink -f $program)
root_dir=$(dirname $root_dir)

cd $root_dir

source venv/bin/activate || \
    die "failed to read virtualenv"
exec ./monitor.py    
  

E iniciando o serviço:

  
> sudo systemctl daemon-reload
> sudo systemctl enable --now temperature-monitor    
  

O que resta é criar um gráfico pra métrica server_room_temperature_celsius e partir pro abraço.

Update: [Fri Sep 12 05:30:00 PM CEST 2025] acabo de perceber que o repositório que fiz fork é na verdade um fork de outro, que parece ser bem mais completo.

https://github.com/urwen/temper

As métricas do nginx no grafana

Details
Written by: Helio Loureiro
Category: Monitoração
Published: August 25, 2025
Hits: 698
  • grafana
  • alloy
  • nginx

Seguindo o artigo expondo as métricas do nginx pro prometheus, aqui está o resultado olhado no grafana.

Como os valores server_accepts_total, server_handled_total e server_requests_total são do tipo counter, eu usei um irate(__variável__[5m]) pra mostrar no gráfico da forma acima.

E como coletei esses dados? Eu já tinha comentado no artigo configurando o grafana alloy pra monitorar VMs que uso o alloy. Então foi adicionando uma entrada extra nele.

  
[...]
discovery.relabel "metrics_agent" {
        targets = array.concat(
                [{
                        __address__ = "localhost:9090",
                }],
        )

        rule {
                source_labels = ["__address__"]
                target_label  = "instance"
        }
}
[...]
prometheus.scrape "nginx_metrics" {
        targets    = discovery.relabel.metrics_agent.output
        forward_to = [prometheus.remote_write.prod.receiver]
}
  

Agora fica mais fácil entender o que acontece com as páginas servidas e se tem realmente alguma lentidão.

Expondo as métricas do nginx pro prometheus

Details
Written by: Helio Loureiro
Category: Go
Published: August 22, 2025
Hits: 1020
  • grafana
  • alloy
  • nginx
  • open metrics
  • prometheus

Esses dias eu peguei um problema no servidor web, nginx. Não nele especificamente. Mas um usuário estava reclamando que estava super lento pra carregar as páginas.

A questão então é como ver como e quanto está o nginx. Infelizmente a versão open source não fornece muita coisa. Só uma versão texto de estatísticas.

https://nginx.org/en/docs/http/ngx_http_stub_status_module.html
https://docs.nginx.com/nginx-one/nginx-configs/metrics/enable-metrics/

Não é grande coisa mas pelo menos já é ALGUMA COISA.

Agora como monitorar isso no Grafana?

A resposta são open metrics. E isso não tem.

Não tinha.

Fiz um programa em Go que converte essas estatísticas em open metrics e expõe na porta 9090 no endpoint /metrics.

https://github.com/helioloureiro/nginx-open-metrics-service

Pra ter isso funcionando, é preciso primeiro subir a configuração de estatísticas no nginx.


server {
    listen 127.0.0.1:8080;
    location /api {
        stub_status;
        allow 127.0.0.1;
        deny all;
    }
}  

Eu salvei no arquivo statistics.conf e coloquei em /etc/nginx/conf.d. E bastou um reload pra ter funcionado.

  
❯ curl localhost:8080/api
Active connections: 2 
server accepts handled requests
 21 21 322 
Reading: 0 Writing: 1 Waiting: 1   
  

Agora é rodar o programa e apontar pra esse endpoint.

  
❯ ./nginx-openmetrics/nginx-openmetrics --service=http://localhost:8080/api
[2025-08-22T14:11:45] (INFO): 🚚 fetching data from:http://localhost:8080/api
[2025-08-22T14:11:45] (INFO): 🎬 starting service at port:9090    
  

E a porta fica exposta pras métricas serem coletadas pelo prometheus ou grafana alloy. Ou qualquer outro programa que faça scrape de dados no padrão open metrics.

  
❯ curl localhost:9090/metrics
# HELP active_connections The number of active connections
# TYPE active_connections gauge
active_connections 1
# HELP reading_connections The number of active reading connections
# TYPE reading_connections gauge
reading_connections 0
# HELP server_accepts_total The total number of server accepted connections
# TYPE server_accepts_total counter
server_accepts_total 22
# HELP server_handled_total The total number of server handled connections
# TYPE server_handled_total counter
server_handled_total 22
# HELP server_requests_total The total number of server requests
# TYPE server_requests_total counter
server_requests_total 333
# HELP waiting_connections The number of waiting connections
# TYPE waiting_connections gauge
waiting_connections 0
# HELP writing_connections The number of active writing connections
# TYPE writing_connections gauge
writing_connections 1  
  

E fica exposto em todas as interfaces.

  
❯ netstat -nat | grep 9090 | grep -i listen
tcp6       0      0 :::9090                 :::*                    LISTEN         
  

O programa faz o update dos dados a cada 15 segundos. Pra não sobrecarregar.

E ainda falta dar uma melhorada com a entrada como serviço do systemd. Devo fazer isso hoje.

Próximo passo será gerar um pacote debian dele pra instalar fácil.

Update: Fri Aug 22 04:23:45 PM CEST 2025
Tá lá o arquivo pro systemd. E está funcionando no sistema que estou testando.

  
root@server:/# systemctl status nginx-openmetrics.service 
● nginx-openmetrics.service - nginx open metrics service
     Loaded: loaded (/etc/systemd/system/nginx-openmetrics.service; enabled; vendor preset: enabled)
     Active: active (running) since Fri 2025-08-22 14:08:17 UTC; 16min ago
   Main PID: 314061 (nginx-openmetri)
      Tasks: 7 (limit: 19076)
     Memory: 4.0M
        CPU: 47ms
     CGroup: /system.slice/nginx-openmetrics.service
             └─314061 /usr/sbin/nginx-openmetrics --service=http://localhost:8080/api

Aug 22 14:08:17 internal systemd[1]: Started nginx open metrics service.
Aug 22 14:08:17 internal nginx-openmetrics[314061]: [2025-08-22T14:08:17.78433] (INFO): nginx-open-metrics-service (1.0-9)
Aug 22 14:08:17 internal nginx-openmetrics[314061]: [2025-08-22T14:08:17.78441] (INFO): 🚚 fetching data from:http://localhost:8080/api
Aug 22 14:08:17 internal nginx-openmetrics[314061]: [2025-08-22T14:08:17.78442] (INFO): 🎬 starting service at port:9090    
  

Convertendo os logs do servidor web pra json

Details
Written by: Helio Loureiro
Category: Apache
Published: August 20, 2025
Hits: 837
  • json
  • logs
  • logging
  • nginx

Confesso que pra escrever o script pra lers os logs do servidor web, aquele que mostrei em acessos de robôs nos logs web, foi algo próximo do vudu. Tudo porque o formato gerado não é lá muito amigável.

Idem pras máquinas do trabalho. Então hoje eu resolvi dar uma olhada se tinha como escrever esses mesmos logs em json.

E tem.

nginx

O primeiro que olhei foi no nginx. E é bem fácil de fazer.

  
log_format logger-json escape=json '{"source": "nginx", "time": "$time_iso8601", "resp_body_size": $body_bytes_sent, "host": "$http_host", "address": "$remote_addr", "request_length": $request_length, "method": "$request_method", "uri": "$request_uri", "status": $status,  "user_agent": "$http_user_agent", "resp_time": $request_time, "upstream_addr": "$upstream_addr"}';  
[...]
server {
    listen 443 ssl;
    server_name api.company.com;
    ...

    access_log /var/log/nginx/access.log logger-json;
    ...
}
  

Eu segui a receita desses dois links aqui:

https://www.velebit.ai/blog/nginx-json-logging/

https://nginx.org/en/docs/http/ngx_http_log_module.html#log_format

Apache

Pro Apache não tem um módulo que já gera tudo meio mastigado como no nginx. Mas você pode criar o formato do log como quiser.

  
LogFormat "{ \"time\":\"%{%Y-%m-%d}tT%{%T}t.%{msec_frac}tZ\", \"process\":\"%D\", \"filename\":\"%f\", \"remoteIP\":\"%a\", \"host\":\"%V\", \"request\":\"%U\", \"query\":\"%q\", \"method\":\"%m\", \"status\":\"%>s\", \"userAgent\":\"%{User-agent}i\", \"referer\":\"%{Referer}i\" }" combined
ErrorLogFormat "{ \"time\":\"%{%Y-%m-%d}tT%{%T}t.%{msec_frac}tZ\", \"function\" : \"[%-m:%l]\" , \"process\" : \"[pid %P:tid %T]\" , \"message\" : \"%M\" ,\ \"referer\"\ : \"%{Referer}i\" }"    
  

E esse veio de dica do stack-overflow:

https://stackoverflow.com/questions/39592834/how-to-make-apache-output-its-logs-in-json-instead-of-its-default-logging-format

Eu não segui os mesmos parâmetros entre apache e nginx. Por enquanto estou só testando e estar com os campos bem definidos já é o suficiente pra mim.

Resultado

  
> tail -1 /var/log/apache2/linux-br-access.log | jq .
{
  "time": "2025-08-20T09:38:59.369Z",
  "process": "241797",
  "filename": "/var/www/linux-br.org/index.php",
  "remoteIP": "54.36.149.72",
  "host": "linux-br.org",
  "request": "/date/2024/03/page/3/",
  "query": "",
  "method": "GET",
  "status": "200",
  "userAgent": "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)",
  "referer": "-"
}
  

Agora ficou fácil de filtrar e pegar só os campos que interessam. E usando "jq".

  
> tail -1 /var/log/apache2/linux-br-access.log | jq ".userAgent"
"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"
> tail -3 /var/log/apache2/linux-br-access.log | jq ".userAgent"
"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"
"Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html)"
"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"
  

Problema com zfs no Ubuntu

Details
Written by: Helio Loureiro
Category: Linux
Published: August 15, 2025
Hits: 800
  • zfs

Eu tenho um laptop pessoal que é um Thinkpad T480. Escrevi sobre o mesmo aqui: entrei pra moda do laptop refurbished. Como não estou usando ele pra muita coisa e tenho também um Thinkpad pro trabalho, deixo o meu pra rodar o último Ubuntu.

E estava com o oneiric, 24.10, quando tentei fazer upgrade pro plucky, 25.04. E tive um belo dum crash no zfs.

  
------------[ cut here ]------------
WARNING: CPU: 0 PID: 227 at drivers/usb/typec/ucsi/ucsi.c:1390 ucsi_reset_ppm+0x1ad/0x1c0 [typec_ucsi]
Modules linked in: zfs(PO) spl(O) dm_crypt hid_multitouch hid_generic cdc_ncm cdc_ether usbnet uas mii usbhid hid usb_storage crct10dif_pclmul crc32_pclmul polyval_clmulni polyval_generic nvme ghash_clmulni_intel snd sha256_ssse3 soundcore sha1_ssse3 nvme_core e1000e video thunderbolt ucsi_acpi psmouse nvme_auth xhci_pci typec_ucsi typec xhci_pci_renesas sparse_keymap platform_profile wmi aesni_intel crypto_simd cryptd
CPU: 0 UID: 0 PID: 227 Comm: kworker/0:2 Tainted: P           O       6.11.0-25-generic #25-Ubuntu
Tainted: [P]=PROPRIETARY_MODULE, [O]=OOT_MODULE
Hardware name: LENOVO 20L6S4G700/20L6S4G700, BIOS N24ET76W (1.51 ) 02/27/2024
Workqueue: events_long ucsi_init_work [typec_ucsi]
RIP: 0010:ucsi_reset_ppm+0x1ad/0x1c0 [typec_ucsi]
Code: ff 8b 55 bc 81 e2 00 00 00 08 0f 85 33 ff ff ff 4c 89 75 c8 48 8b 05 72 9d 4a cb 49 39 c5 79 94 b8 92 ff ff ff e9 19 ff ff ff <0f> 0b e9 57 ff ff ff e8 17 1b 17 ca 0f 1f 80 00 00 00 00 90 90 90
RSP: 0018:ffffba53c03a3d80 EFLAGS: 00010206
RAX: 0000000008000000 RBX: ffff9d0102192800 RCX: 0000000000000000
RDX: 00000000fffb83c0 RSI: 0000000000000000 RDI: 0000000000000000
RBP: ffffba53c03a3dd0 R08: 0000000000000000 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000000 R12: ffffba53c03a3d8c
R13: 00000000fffb83be R14: ffff9d0101a4fc00 R15: ffff9d01021928c0
FS:  0000000000000000(0000) GS:ffff9d0666200000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: 00005799b59ce675 CR3: 00000003afe3e004 CR4: 00000000003706f0
DR0: 0000000000000000 DR1: 0000000000000000 DR2: 0000000000000000
DR3: 0000000000000000 DR6: 00000000fffe0ff0 DR7: 0000000000000400
Call Trace:
 <TASK>
 ? show_trace_log_lvl+0x1be/0x310
 ? show_trace_log_lvl+0x1be/0x310
 ? ucsi_init+0x32/0x310 [typec_ucsi]
 ? show_regs.part.0+0x22/0x30
 ? show_regs.cold+0x8/0x10
 ? ucsi_reset_ppm+0x1ad/0x1c0 [typec_ucsi]
 ? __warn.cold+0xa7/0x101
 ? ucsi_reset_ppm+0x1ad/0x1c0 [typec_ucsi]
 ? report_bug+0x114/0x160
 ? handle_bug+0x6e/0xb0
 ? exc_invalid_op+0x18/0x80
 ? asm_exc_invalid_op+0x1b/0x20
 ? ucsi_reset_ppm+0x1ad/0x1c0 [typec_ucsi]
 ucsi_init+0x32/0x310 [typec_ucsi]
 ucsi_init_work+0x18/0x90 [typec_ucsi]
 process_one_work+0x174/0x350
 worker_thread+0x31a/0x450
 ? _raw_spin_lock_irqsave+0xe/0x20
 ? __pfx_worker_thread+0x10/0x10
 kthread+0xe1/0x110
 ? __pfx_kthread+0x10/0x10
 ret_from_fork+0x44/0x70
 ? __pfx_kthread+0x10/0x10
 ret_from_fork_asm+0x1a/0x30 
</TASK>
---[ endtrace 0000000000000000 ]---   
WARNING: CPU: 0 PID: 978 at /build/linux-Ajk80v/linux-6.11.0/debian/build/build-generic/____________________________________________________________________________dkms/build/zfs/2.2.6/build/module/zfs/zfs_log.c:817 zfs_log_setsaxattr+0x140/0x150 [zfs]
Modules linked in: msr(+) parport_pc ppdev lp parport efi_pstore nfnetlink dmi_sysfs ip_tables x_tables autofs4 typec_displayport zfs(PO) spl(O) dm_crypt hid_multitouch hid_generic cdc_ncm cdc_ether usbnet uas mii usbhid hid usb_storage crct10dif_pclmul crc32_pclmul polyval_clmulni polyval_generic nvme ghash_clmulni_intel snd sha256_ssse3 soundcore sha1_ssse3 nvme_core e1000e video thunderbolt ucsi_acpi psmouse nvme_auth xhci_pci typec_ucsi typec xhci_pci_renesas sparse_keymap platform_profile wmi aesni_intel crypto_simd cryptd
CPU: 0 UID: 0 PID: 978 Comm: systemd-random- Tainted: P        W  O       6.11.0-25-generic #25-Ubuntu
Tainted: [P]=PROPRIETARY_MODULE, [W]=WARN, [O]=OOT_MODULE
Hardware name: LENOVO 20L6S4G700/20L6S4G700, BIOS N24ET76W (1.51 ) 02/27/2024
RIP: 0010:zfs_log_setsaxattr+0x140/0x150 [zfs]
Code: ff ff ff 31 c9 48 c7 c2 c0 94 e5 c0 4c 89 f6 4c 89 55 c0 48 c7 c7 68 8e e5 c0 4c 89 4d d0 c6 05 4a 50 13 00 01 e8 a0 c3 a9 c8 <0f> 0b 4c 8b 55 c0 4c 8b 4d d0 e9 30 ff ff ff 90 90 90 90 90 90 90
RSP: 0018:ffffba53c192f748 EFLAGS: 00010246
RAX: 0000000000000000 RBX: ffff9d0107f7bdb0 RCX: 0000000000000000
RDX: 0000000000000000 RSI: 0000000000000000 RDI: 0000000000000000
RBP: ffffba53c192f790 R08: 0000000000000000 R09: 0000000000000000
R10: 0000000000000000 R11: 0000000000000000 R12: ffff9d011613e800
R13: ffff9d010b3499c0 R14: 000000000000001c R15: 0000000000000000
FS:  00007a57caef8980(0000) GS:ffff9d0666200000(0000) knlGS:0000000000000000
CS:  0010 DS: 0000 ES: 0000 CR0: 0000000080050033
CR2: 00007a57cbf071e0 CR3: 000000010a016003 CR4: 00000000003706f0
DR0: 0000000000000000 DR1: 0000000000000000 DR2: 0000000000000000
DR3: 0000000000000000 DR6: 00000000fffe0ff0 DR7: 0000000000000400
Call Trace:
 <TASK>
 ? show_trace_log_lvl+0x1be/0x310
 ? show_trace_log_lvl+0x1be/0x310
 ? zfs_sa_set_xattr+0x34a/0x3b0 [zfs]
 ? show_regs.part.0+0x22/0x30
 ? show_regs.cold+0x8/0x10
 ? zfs_log_setsaxattr+0x140/0x150 [zfs]
 ? __warn.cold+0xa7/0x101
 ? zfs_log_setsaxattr+0x140/0x150 [zfs]
 ? report_bug+0x114/0x160
 ? handle_bug+0x6e/0xb0
 ? exc_invalid_op+0x18/0x80
 ? asm_exc_invalid_op+0x1b/0x20
 ? zfs_log_setsaxattr+0x140/0x150 [zfs]
 ? zfs_log_setsaxattr+0x140/0x150 [zfs]
 zfs_sa_set_xattr+0x34a/0x3b0 [zfs]
 zpl_xattr_set_sa+0x102/0x200 [zfs]
 zpl_xattr_set+0x21c/0x290 [zfs]
 __zpl_xattr_user_set+0x128/0x170 [zfs]
 zpl_xattr_user_set+0x22/0x40 [zfs]
 __vfs_removexattr+0x81/0xd0
 __vfs_removexattr_locked+0xe5/0x1a0
 ? touch_atime+0xbe/0x120  
vfs_removexattr+0x59/0x110
 __do_sys_fremovexattr+0x130/0x1c0
 __x64_sys_fremovexattr+0x15/0x20
 x64_sys_call+0x1fc7/0x22b0
 do_syscall_64+0x7e/0x170
 ? filemap_map_pages+0x34f/0x570
 ? xa_load+0x73/0xb0
 ? do_read_fault+0xfd/0x200
 ? do_fault+0x183/0x210
 ? generic_file_llseek+0x24/0x40
 ? zpl_llseek+0x32/0xd0 [zfs]
 ? ksys_lseek+0x7d/0xd0
 ? syscall_exit_to_user_mode+0x4e/0x250
 ? do_syscall_64+0x8a/0x170
 ? __count_memcg_events+0x86/0x160
 ? count_memcg_events.constprop.0+0x2a/0x50
 ? handle_mm_fault+0x1bb/0x2d0
 ? do_user_addr_fault+0x5e9/0x7e0
 ? irqentry_exit_to_user_mode+0x43/0x250
 ? irqentry_exit+0x43/0x50
 ? exc_page_fault+0x96/0x1c0
 entry_SYSCALL_64_after_hwframe+0x76/0x7e
RIP: 0033:0x7a57cba5d4eb
Code: 73 01 c3 48 8b 0d 0d 79 0e 00 f7 d8 64 89 01 48 83 c8 ff c3 66 2e 0f 1f 84 00 00 00 00 00 90 f3 0f 1e fa b8 c7 00 00 00 0f 05 <48> 3d 01 f0 ff ff 73 01 c3 48 8b 0d dd 78 0e 00 f7 d8 64 89 01 48
RSP: 002b:00007ffe118d0498 EFLAGS: 00000246 ORIG_RAX: 00000000000000c7
RAX: ffffffffffffffda RBX: 0000000000000004 RCX: 00007a57cba5d4eb
RDX: 000000000000001a RSI: 00006015c386c08b RDI: 0000000000000005
RBP: 00007ffe118d05d0 R08: 00007a57cbb45b20 R09: 00000000000000c0
R10: 0000601603619fc0 R11: 0000000000000246 R12: 0000000000000005
R13: 0000000000000001 R14: 0000000000000000 R15: 0000000000000001
 </TASK> 
 

E ficava nisso. Tinha de mandar um zfs rollback nos volumes pra conseguir voltar a usar. E tentar o upgrade novamente.

Depois de muito tentar, resolvi abrir um bug report no launchpad. Meu bug foi marcado como duplicado e passei então a interagir no bug onde o problema foi reportado primeiramente.

https://bugs.launchpad.net/ubuntu/+source/ubuntu-release-upgrader/+bug/2110891

tl;dr: o bug era do zfs no kernel padrão que o plucky instala. A correção exige upgrade tanto do zfs quanto do kernel antes de ir pro upgrade do plucky.

Da primeira vez eu errei esse upgrade. E precisei recuperar o zfs pra voltar o snapshot.

E mais um problema já que não existe um procedimento bem descritivo de como fazer isso. Ou tem?

Sim tem. E mais de um.

https://gist.github.com/faustinoaq/d267102dd004651801c13fae9d7973ec
https://toabctl.wordpress.com/2023/05/10/mounting-an-encrypted-zfs-filesystem/
https://develmonk.com/2022/05/20/mount-ubuntu-22-04-zfs-partitions-using-live-iso-for-disaster-recovery/

Tive de recuperar algumas vezes o sistema. Então fiquei meio que craque em fazer isso. O esquema está abaixo:

    
root@ubuntu:~# lsblk -f
NAME        FSTYPE     FSVER            LABEL                    UUID                                 FSAVAIL FSUSE% MOUNTPOINTS
loop0       squashfs   4.0                                                                                  0   100% /rofs
loop1       squashfs   4.0                                                                                           
loop2       squashfs   4.0                                                                                           
loop3       squashfs   4.0                                                                                  0   100% /snap/bare/5
loop4       squashfs   4.0                                                                                  0   100% /snap/core22/1748
loop5       squashfs   4.0                                                                                  0   100% /snap/firefox/5751
loop6       squashfs   4.0                                                                                  0   100% /snap/firmware-updater/167
loop7       squashfs   4.0                                                                                  0   100% /snap/gnome-42-2204/202
loop8       squashfs   4.0                                                                                  0   100% /snap/gtk-common-themes/1535
loop9       squashfs   4.0                                                                                  0   100% /snap/snap-store/1248
loop10      squashfs   4.0                                                                                  0   100% /snap/thunderbird/644
loop11      squashfs   4.0                                                                                  0   100% /snap/ubuntu-desktop-bootstrap/315
loop12      squashfs   4.0                                                                                  0   100% /snap/snapd-desktop-integration/253
loop13      squashfs   4.0                                                                                  0   100% /snap/snapd/23545
sda         iso9660    Joliet Extension Ubuntu 24.04.2 LTS amd64 2025-02-15-09-15-26-00                              
├─sda1      iso9660    Joliet Extension Ubuntu 24.04.2 LTS amd64 2025-02-15-09-15-26-00                     0   100% /cdrom
├─sda2      vfat       FAT12            ESP                      B5A5-8010                                           
├─sda3                                                                                                               
└─sda4      ext4       1.0              writable                 5729a291-83ad-4b15-91b1-09a17bfc9504    1.3G     4% /var/crash
                                                                                                                     /var/log
sdb                                                                                                                  
nvme0n1                                                                                                              
├─nvme0n1p1 vfat       FAT32                                     C399-15AF                                           
├─nvme0n1p2 zfs_member 5000             bpool                    4626876014803904226                                 
├─nvme0n1p3                                                                                                          
└─nvme0n1p4 zfs_member 5000             rpool                    15334588309526604034                                

root@ubuntu:~# zpool import -f rpool
root@ubuntu:~# zpool list
NAME    SIZE  ALLOC   FREE  CKPOINT  EXPANDSZ   FRAG    CAP  DEDUP    HEALTH  ALTROOT
rpool   936G   365G   571G        -         -     6%    39%  1.00x    ONLINE  -

root@ubuntu:~# cryptsetup open /dev/zvol/rpool/keystore rpool-keystore
Enter passphrase for /dev/zvol/rpool/keystore: 
root@ubuntu:~# mkdir /mnt-keystore
root@ubuntu:~# mount /dev/mapper/rpool-keystore /mnt-keystore
root@ubuntu:~# ls /mnt-keystore
lost+found  system.key

root@ubuntu:~# cat /mnt-keystore/system.key | zfs load-key -L prompt rpool
root@ubuntu:~# umount /mnt-keystore 
root@ubuntu:~# cryptsetup close rpool-keystore

root@ubuntu:~# zfs list
NAME                                               USED  AVAIL  REFER  MOUNTPOINT
rpool                                              365G   542G   192K  /
rpool/ROOT                                         106G   542G   192K  none
rpool/ROOT/ubuntu_ni6nkv                           106G   542G  11.5G  /mnt
rpool/ROOT/ubuntu_ni6nkv/srv                       352K   542G   192K  /mnt/srv
rpool/ROOT/ubuntu_ni6nkv/usr                      7.96M   542G   192K  /mnt/usr
rpool/ROOT/ubuntu_ni6nkv/usr/local                7.77M   542G  6.02M  /mnt/usr/local
rpool/ROOT/ubuntu_ni6nkv/var                      64.4G   542G   192K  /mnt/var
rpool/ROOT/ubuntu_ni6nkv/var/games                 272K   542G   192K  /mnt/var/games
rpool/ROOT/ubuntu_ni6nkv/var/lib                  60.0G   542G  24.2G  /mnt/var/lib
rpool/ROOT/ubuntu_ni6nkv/var/lib/AccountsService  1.07M   542G   212K  /mnt/var/lib/AccountsService
rpool/ROOT/ubuntu_ni6nkv/var/lib/NetworkManager   7.59M   542G   580K  /mnt/var/lib/NetworkManager
rpool/ROOT/ubuntu_ni6nkv/var/lib/apt               388M   542G   103M  /mnt/var/lib/apt
rpool/ROOT/ubuntu_ni6nkv/var/lib/dpkg             1.14G   542G   169M  /mnt/var/lib/dpkg
rpool/ROOT/ubuntu_ni6nkv/var/log                   261M   542G  92.7M  /mnt/var/log
rpool/ROOT/ubuntu_ni6nkv/var/mail                  272K   542G   192K  /mnt/var/mail
rpool/ROOT/ubuntu_ni6nkv/var/snap                 4.11G   542G  4.03G  /mnt/var/snap
rpool/ROOT/ubuntu_ni6nkv/var/spool                10.6M   542G   468K  /mnt/var/spool
rpool/ROOT/ubuntu_ni6nkv/var/www                  55.3M   542G  55.1M  /mnt/var/www
rpool/USERDATA                                     258G   542G   192K  none
rpool/USERDATA/home_39e1h7                         258G   542G   242G  /home
rpool/USERDATA/root_39e1h7                        58.8M   542G  28.4M  /root
rpool/keystore                                    39.8M   542G  16.5M  -
rpool/var                                          739M   542G   192K  /var
rpool/var/lib                                      739M   542G   192K  /var/lib
rpool/var/lib/docker                               738M   542G   729M  /var/lib/docker
root@ubuntu:~# zfs set mountpoint=/mnt rpool/ROOT/ubuntu_ni6nkv
root@ubuntu:~# zfs mount rpool/ROOT/ubuntu_ni6nkv

root@ubuntu:~# ls /mnt
bin  boot  cdrom  dev  etc  home  lib  lib32  lib64  media  mnt  opt  proc  root  run  sbin  snap  srv  sys  tmp  usr  var

root@ubuntu:~# zpool import -N -R /mnt bpool
root@ubuntu:~# zfs mount bpool/BOOT/ubuntu_ni6nkv
root@ubuntu:~# ls /mnt/boot/
System.map-6.11.0-21-generic  config-6.11.0-21-generic  efi         initrd.img-6.11.0-21-generic  initrd.img.old      memtest86+x64.bin  vmlinuz-6.11.0-21-generic  vmlinuz.old
System.map-6.11.0-24-generic  config-6.11.0-24-generic  grub        initrd.img-6.11.0-24-generic  memtest86+ia32.bin  memtest86+x64.efi  vmlinuz-6.11.0-24-generic
System.map-6.14.0-15-generic  config-6.14.0-15-generic  initrd.img  initrd.img-6.14.0-15-generic  memtest86+ia32.efi  vmlinuz            vmlinuz-6.14.0-15-generic

root@ubuntu:~# mount /dev/nvme0n1p1 /mnt/boot/efi/
root@ubuntu:~# ls /mnt/boot/efi/
EFI

root@ubuntu:~# for i in proc dev sys dev/pts; do mount -v --bind /$i /mnt/$i; done
mount: /proc bound on /mnt/proc.
mount: /dev bound on /mnt/dev.
mount: /sys bound on /mnt/sys.
mount: /dev/pts bound on /mnt/dev/pts.

root@ubuntu:~# zfs set mountpoint=/ rpool/ROOT/ubuntu_ni6nkv

Broadcast message from systemd-journald@ubuntu (Sat 2025-04-19 15:35:17 UTC):

systemd[1]: Caught , from our own process.

root@ubuntu:~# zfs list
NAME                                               USED  AVAIL  REFER  MOUNTPOINT
bpool                                              838M   953M    96K  /mnt/boot
bpool/BOOT                                         833M   953M    96K  none
bpool/BOOT/ubuntu_ni6nkv                           833M   953M   295M  /mnt/boot
rpool                                              365G   542G   192K  /
rpool/ROOT                                         106G   542G   192K  none
rpool/ROOT/ubuntu_ni6nkv                           106G   542G  11.5G  /
rpool/ROOT/ubuntu_ni6nkv/srv                       352K   542G   192K  /srv
rpool/ROOT/ubuntu_ni6nkv/usr                      7.96M   542G   192K  /usr
rpool/ROOT/ubuntu_ni6nkv/usr/local                7.77M   542G  6.02M  /usr/local
rpool/ROOT/ubuntu_ni6nkv/var                      64.4G   542G   192K  /var
rpool/ROOT/ubuntu_ni6nkv/var/games                 272K   542G   192K  /var/games
rpool/ROOT/ubuntu_ni6nkv/var/lib                  60.0G   542G  24.2G  /var/lib
rpool/ROOT/ubuntu_ni6nkv/var/lib/AccountsService  1.07M   542G   212K  /var/lib/AccountsService
rpool/ROOT/ubuntu_ni6nkv/var/lib/NetworkManager   7.59M   542G   580K  /var/lib/NetworkManager
rpool/ROOT/ubuntu_ni6nkv/var/lib/apt               388M   542G   103M  /var/lib/apt
rpool/ROOT/ubuntu_ni6nkv/var/lib/dpkg             1.14G   542G   169M  /var/lib/dpkg
rpool/ROOT/ubuntu_ni6nkv/var/log                   261M   542G  92.7M  /var/log
rpool/ROOT/ubuntu_ni6nkv/var/mail                  272K   542G   192K  /var/mail
rpool/ROOT/ubuntu_ni6nkv/var/snap                 4.11G   542G  4.03G  /var/snap
rpool/ROOT/ubuntu_ni6nkv/var/spool                10.6M   542G   468K  /var/spool
rpool/ROOT/ubuntu_ni6nkv/var/www                  55.3M   542G  55.1M  /var/www
rpool/USERDATA                                     258G   542G   192K  none
rpool/USERDATA/home_39e1h7                         258G   542G   242G  /home
rpool/USERDATA/root_39e1h7                        58.8M   542G  28.4M  /root
rpool/keystore                                    39.8M   542G  16.5M  -
rpool/var                                          739M   542G   192K  /var
rpool/var/lib                                      739M   542G   192K  /var/lib
rpool/var/lib/docker                               738M   542G   729M  /var/lib/docker
root@ubuntu:~# zfs set mountpoint=/boot bpool

root@ubuntu:~# zfs list
NAME                                               USED  AVAIL  REFER  MOUNTPOINT
bpool                                              838M   953M    96K  /mnt/boot
bpool/BOOT                                         833M   953M    96K  none
bpool/BOOT/ubuntu_ni6nkv                           833M   953M   295M  /mnt/boot
rpool                                              365G   542G   192K  /
rpool/ROOT                                         106G   542G   192K  none
rpool/ROOT/ubuntu_ni6nkv                           106G   542G  11.5G  /
rpool/ROOT/ubuntu_ni6nkv/srv                       352K   542G   192K  /srv
rpool/ROOT/ubuntu_ni6nkv/usr                      7.96M   542G   192K  /usr
rpool/ROOT/ubuntu_ni6nkv/usr/local                7.77M   542G  6.02M  /usr/local
rpool/ROOT/ubuntu_ni6nkv/var                      64.4G   542G   192K  /var
rpool/ROOT/ubuntu_ni6nkv/var/games                 272K   542G   192K  /var/games
rpool/ROOT/ubuntu_ni6nkv/var/lib                  60.0G   542G  24.2G  /var/lib
rpool/ROOT/ubuntu_ni6nkv/var/lib/AccountsService  1.07M   542G   212K  /var/lib/AccountsService
rpool/ROOT/ubuntu_ni6nkv/var/lib/NetworkManager   7.59M   542G   580K  /var/lib/NetworkManager
rpool/ROOT/ubuntu_ni6nkv/var/lib/apt               388M   542G   103M  /var/lib/apt
rpool/ROOT/ubuntu_ni6nkv/var/lib/dpkg             1.14G   542G   169M  /var/lib/dpkg
rpool/ROOT/ubuntu_ni6nkv/var/log                   261M   542G  92.7M  /var/log
rpool/ROOT/ubuntu_ni6nkv/var/mail                  272K   542G   192K  /var/mail
rpool/ROOT/ubuntu_ni6nkv/var/snap                 4.11G   542G  4.03G  /var/snap
rpool/ROOT/ubuntu_ni6nkv/var/spool                10.6M   542G   468K  /var/spool
rpool/ROOT/ubuntu_ni6nkv/var/www                  55.3M   542G  55.1M  /var/www
rpool/USERDATA                                     258G   542G   192K  none
rpool/USERDATA/home_39e1h7                         258G   542G   242G  /home
rpool/USERDATA/root_39e1h7                        58.8M   542G  28.4M  /root
rpool/keystore                                    39.8M   542G  16.5M  -
rpool/var                                          739M   542G   192K  /var
rpool/var/lib                                      739M   542G   192K  /var/lib
rpool/var/lib/docker                               738M   542G   729M  /var/lib/docker      
    
  

No fim deu certo e consegui fazer upgrade pro plucky. Mas o problema ainda existe. Não sei se será um problema quando chegar a época de upgrade do 24.04.

Espero que não.

O dogão da maldade com a salsicha harakiri

Details
Written by: Helio Loureiro
Category: Suécia
Published: August 14, 2025
Hits: 738
  • salsicha
  • hotdog

Esse é um dos vídeos mais hilários que vi aqui na Suécia. Pra encurtar a história, aqui tinha um carrinho de cachorros quentes que servia uma salsicha super apimentada. Era chamado de harakiri, do termo japonês pra suicídio, e tornou-se um trend aqui por um tempo.

O vídeo funciona com legendas em inglês. Então é possível assistir e gargalhar gostoso com a reportagem. Era mais apimentado que spray de pimenta que a polícia usa.

E eu já tentei essa iguaria?

Não.

E não vou nunca mais nem tentar porque o dono do carrinho parou de servir. Não que isso mudasse minha opinião sobre não tentar. Mas ele achou que estava chamando muita a atenção e tirando sua privacidade. Vendeu tudo e foi vender hambúrgueres em Malmö.

Update: eu vi que o vocêtubo não está funcionando corretamente nos telefones. Então aqui embaixo está o link pra vídeo.

Acessos de robôs nos logs web

Details
Written by: Helio Loureiro
Category: Perl
Published: August 14, 2025
Hits: 919

Esses dias eu vi um post no Mastodon sobre bloquear robôs que acessam o servidor web.

Perguntei pro autor do post como foi que ele conseguiu isso, mas fui solenemente ignorado. Coisas da Internet.

Então resolvi dar uma olhada nos logs do servidor, esse que hospeda esse mesmo site. E fiz um programa em perl pra isso. Pra matar as saudades. E tirar a ferrugem.

E esse foi o programa:

  
#! /usr/bin/env perl
use IO::Zlib;

my $LOGDIR = "/var/log/apache2";

opendir(DIR, $LOGDIR) or die "Impossible to read from directory: $!\n";

%ip_addrs;
%bot_agent;

@gzip_files;

foreach my $filename (readdir DIR) {
        next if $filename !~ /access/;
        # skip gz right now
        if ($filename =~ /\.gz/) {
                push(@gzip_files, ($LOGDIR."/".$filename));
                next;
        }

        print($LOGDIR."/".$filename."\n");
        open(FD, $LOGDIR."/".$filename) or die "Impossible to read file: $!\n";
        foreach my $line () {
                next if $line !~ /bot/;
                parse_log_line($line);
        }
}


print("result:\n");


for my $filename (@gzip_files) {
        print("$filename\n");
        my $fh = new IO::Zlib;
        $fh->open($filename, "rb") or die "impossible to read gzip file: $!\n";
        while ( my $line = <$fh>) {
                next if $line !~ /bot/;
                parse_log_line($line);
        }
}

foreach $bot (sort {$bot_agent{$b}<=>$bot_agent{$a} } keys %bot_agent) {
    print("$bot => $bot_agent{$bot}\n");
}

sub parse_log_line() {
        my $line = $_[0];
        our %ip_addrs;
        our %bot_agent;

        @params = split(/ /, $line);
        my $ip = $params[0];
        $ip_addrs{$ip}++;
        $line =~ s/.*]//;
        $line =~ s/\"$//;
        $line =~s/.*\"//;
        chomp($line);
        if ($line =~ m/bot/) {
                $bot_agent{$line}++;
        }
}    
  

E o resultado em formato de tabela:

User-Agent do robô Quantidade de acessos
Mozilla/5.0 (compatible; MJ12bot/v1.4.8; http://mj12bot.com/) 30130
Mozilla/5.0 (compatible; SemrushBot/7~bl; +http://www.semrush.com/bot.html) 22203
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; This email address is being protected from spambots. You need JavaScript enabled to view it.) 16981
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) Chrome/116.0.1938.76 Safari/537.36 15979
Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/) 12112
Mozilla/5.0 (Linux; Android 7.0;) AppleWebKit/537.36 (KHTML, like Gecko) Mobile Safari/537.36 (compatible; PetalBot;+https://webmaster.petalsearch.com/site/petalbot) 10744
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Amazonbot/0.1; +https://developer.amazon.com/support/amazonbot) Chrome/119.0.6045.214 Safari/537.36 9704
Mozilla/5.0 (compatible; DotBot/1.2; +https://opensiteexplorer.org/dotbot; This email address is being protected from spambots. You need JavaScript enabled to view it.) 7434
Mozilla/5.0 (compatible; AwarioBot/1.0; +https://awario.com/bots.html) 5042
Mozilla/5.0 (compatible; DataForSeoBot/1.0; +https://dataforseo.com/dataforseo-bot) 3104
Linguee Bot (http://www.linguee.com/bot; This email address is being protected from spambots. You need JavaScript enabled to view it.) 1957
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15 (Applebot/0.1; +http://www.apple.com/go/applebot) 1450
Mozilla/5.0 (compatible; archive.org_bot +http://archive.org/details/archive.org_bot) Zeno/5741de8 warc/v0.8.85 751
Mozilla/5.0 (compatible; SemrushBot-BA; +http://www.semrush.com/bot.html) 651
Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots) 567
Mozilla/5.0 (compatible; MJ12bot/v2.0.2; http://mj12bot.com/) 538
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.183 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 531
Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 411
Blogtrottr/2.1 (+https://blogtrottr.com/robot) 340
Googlebot-Image/1.0 315
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) Chrome/100.0.4896.127 Safari/537.36 300
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.2; +https://openai.com/gptbot) 261
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36; compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot 240
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.7151.119 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 207
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm) Chrome/136.0.0.0 Safari/537.36 191
ZoominfoBot (zoominfobot at zoominfo dot com) 164
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot 150
Mozilla/5.0 (compatible; SeznamBot/4.0; +https://o-seznam.cz/napoveda/vyhledavani/en/seznambot-crawler/) 124
Mozilla/5.0 (compatible; MojeekBot/0.11; +https://www.mojeek.com/bot.html) 107
Mozilla/5.0 (compatible; archive.org_bot +http://archive.org/details/archive.org_bot) Zeno/a7797cb warc/v0.8.78 89
DuckDuckBot/1.1; (+http://duckduckgo.com/duckduckbot.html) 82
Mozilla/5.0 (compatible;PetalBot;+https://webmaster.petalsearch.com/site/petalbot) 80
Mozilla/5.0 (compatible; YandexImages/3.0; +http://yandex.com/bots) 70
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot) 62
Mozilla/5.0 (compatible; YaK/1.0; http://linkfluence.com/; This email address is being protected from spambots. You need JavaScript enabled to view it.) 61
Mozilla/5.0 (compatible; coccocbot-image/1.0; +http://help.coccoc.com/searchengine) 56
Mozilla/5.0 (compatible; wpbot/1.3; +https://forms.gle/ajBaxygz9jSR8p8G9) 41
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/137.0.7151.119 Safari/537.36 37
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.168 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 29
Twitterbot/1.0 29
AdsBot-Google (+http://www.google.com/adsbot.html) 22
Mozilla/5.0 (compatible; intelx.io_bot +https://intelx.io) 21
BufferLinkPreviewBot/1.0 (+https://scraper.buffer.com/about/bots/link-preview-bot) 19
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.84 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 14
Mozilla/5.0 (compatible; Thinkbot/0.5.8; +In_the_test_phase,_if_the_Thinkbot_brings_you_trouble,_please_block_its_IP_address._Thank_you.) 13
Mozilla/5.0 (compatible; MJ12bot/v2.0.4; http://mj12bot.com/) 13
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.7204.183 Mobile Safari/537.36 (compatible; AdsBot-Google-Mobile; +http://www.google.com/mobile/adsbot.html) 9
Mozilla/4.0 (compatible; fluid/0.0; +http://www.leak.info/bot.html) 8
Mozilla/5.0 (Windows NT 10.0; Win64; x64; trendictionbot0.5.0; trendiction search; http://www.trendiction.de/bot; please let us know of any problems; web at trendiction.com) Gecko/20100101 Firefox/125.0 7
Googlebot/2.1 (+http://www.google.com/bot.html) 5
Mozilla/5.0 (compatible; SurdotlyBot/1.0; +http://sur.ly/bot.html) 5
Mozilla/5.0 (compatible; Website-info.net-Robot; https://website-info.net/robot) 4
Mozilla/5.0 (compatible; coccocbot-web/1.0; +http://help.coccoc.com/searchengine) 4
Pandalytics/2.0 (https://domainsbot.com/pandalytics/) 4
Mozilla/5.0 (compatible; IbouBot/1.0; This email address is being protected from spambots. You need JavaScript enabled to view it.; +https://ibou.io/iboubot.html) 4
DomainStatsBot/1.0 (https://domainstats.com/pages/our-bot) 4
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 3
Mozilla/5.0 (compatible; SeekportBot; +https://bot.seekport.com) 3
serpstatbot/2.1 (advanced backlink tracking bot; https://serpstatbot.com/; This email address is being protected from spambots. You need JavaScript enabled to view it.) 3
Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_1) AppleWebKit/601.2.4 (KHTML, like Gecko) Version/9.0.1 Safari/601.2.4 facebookexternalhit/1.1 Facebot Twitterbot/1.0 3
yacybot (-global; amd64 Linux 5.15.161; java 11.0.26-internal; America/en) http://yacy.net/bot.html 2
Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Googlebot/2.1; +http://www.google.com/bot.html) Chrome/120.0.6099.199 Safari/537.36 2
Mozilla/5.0 (compatible; Qwantbot/1.0_4396629; +https://help.qwant.com/bot/) 2
DomCopBot (https://www.domcop.com/bot) 2
Mozilla/5.0 (compatible; YandexFavicons/1.0; +http://yandex.com/bots) 2
yacybot (/global; amd64 Linux 5.15.0-140-generic; java 11.0.27; America/en) http://yacy.net/bot.html 2
Synapse (bot; +https://github.com/matrix-org/synapse) 2
Mozilla/5.0 (compatible; archive.org_bot; Wayback Machine Live Record; +http://archive.org/details/archive.org_bot) 1
Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html) 1
Mozilla/5.0 (compatible; Qwantbot/1.0; +https://help.qwant.com/bot/) 1
Facebot 1
Mozilla/5.0 (compatible; GetHPinfo.com-Bot/0.1; +http://www.gethpinfo.com/bot/ 1
Slack-ImgProxy (+https://api.slack.com/robots) 1
Googlebot-Video/1.0 1
yacybot (/global; amd64 Linux 6.12.38+deb13-amd64; java 21.0.8; Europe/fr) http://yacy.net/bot.html 1
Mozilla/5.0 (iPhone; CPU iPhone OS 6_0 like Mac OS X) AppleWebKit/536.26 (KHTML, like Gecko) Version/6.0 Mobile/10A5376e Safari/8536.25 (compatible; SMTBot/1.0; +http://www.similartech.com/smtbot) 1
Mastodon/4.5.0-alpha.1+chuckya (http.rb/5.3.1; +https://lab.wheelsbot.dev/) 1

O resultado é texto. Eu só formatei pra ficar mais fácil de visualizar aqui (usando "sd" pra isso). E está hardcoded pra buscar os logs do apache2 em sistemas debian alike.

Dos resultados, confesso que fiquei surpreso. Realmente bastante tráfego vindo de robôs. E vários que eu nunca ouvi falar.

Sobre bloquear ou não, eu por enquanto não mexi em nada e os robôs continuam acessando tudo. Mesmo porque eu não fiz nada qualitativo, pra saber se estão recebendo 200 (ok) ou alguma outra coisa como 404 (not found - não encontrad).

Mas caso eu decida pelo bloqueio, achei um projeto bem interessante no GitHub que já faz a curadoria de robôs "bons" e "ruins".

https://github.com/mitchellkrogza/apache-ultimate-bad-bot-blocker

Atualizando mapas de DNS no estilo do DynDNS

Details
Written by: Helio Loureiro
Category: Python
Published: August 11, 2025
Hits: 724
  • python
  • dns

Eu tenho rodado um tipo de sistema que faz DynDNS pra minhas máquinas que estão atrás de um serviço de DHCP como meu desktop que fica aqui em minha mesa, em casa.

O princípio é que essa máquina acessa uma certa URL a cada 5 minutos. Então a cada 5 minutos eu olho pro log do servidor web que roda nessa porta e pego os dados. Pego o IPv4 e o IPv6. Na verdade pego o que vier e vejo se é IPv4 ou IPv6. Depois olho no mapa de DNS se o valor mudou. Se mudou, altero o serial do map e mando um restart no serviço.

A lógica é simples. Mas eu fiz isso uns 15 anos atrás. Pra python2.Alguma-Coisa. Portei pra python3 no melhor estilo #XGH. O resultado? Problemas aqui e ali.

Vamos dar uma olhada no script anterior.

  
#! /usr/bin/python3 -u
# -*- encoding: utf-8 -*-

"""
DNS updater.  It checks if hostname and IP are updated
on maps.  If not, update accordingly and reload bind.
"""
# grep "\/\?update_dyndns=" /var/log/apache2/dyndns-access.log | sed "s/ HTTP.*//" | awk '{print $1, $NF}'

import re
import time
import os
import subprocess
import sys

LOG = "/var/log/apache2/dyndns-access.log"
DNSCONF = "/etc/bind/master/dyndns.truta.org"
DNSDATE = "/etc/bind/master/db.truta.org"
DNSFILES = [ DNSDATE, "/etc/bind/master/db.linux-br.org" ]

TRANSLATE = {
    "goosfraba" : "helio",
    "raspberrypi-masoso" : "rpi0",
    "raspberrypi1" : "rpi1",
    "raspberrypi2" : "rpi2",
    "raspberrypi3" : "rpi3"
    }

def debug(*msg):
    if "DEBUG" in os.environ:
        print("DEBUG:", *msg)

def get_latest_ips():
    """
    Dictionary in format:
        Domain/node : [ ipv4, ipv6]
    """
    debug("get_latest_ips()")
    NODE_IP = {}
    with open(LOG) as logfile:
        for line in logfile.readlines():
            if not re.search("\/\?update_dyndns=", line):
                continue
            line = re.sub(" HTTP.*", "", line)
            param = line.split()
            ip = param[0]
            nodename = re.sub("\/\?update_dyndns=", "", param[-1])

            if not nodename in NODE_IP:
                NODE_IP[nodename] = [ None, None ]
            # ipv4?
            if re.search("([0-9].*)\.([0-9].*)\.([0-9].*)\.([0-9].*)", ip):
                NODE_IP[nodename][0] = ip
                debug(f"{nodename} (ipv4): {ip}")
            else:
                NODE_IP[nodename][1] = ip
                debug(f"{nodename} (ipv6): {ip}")
    return NODE_IP

def apply_translate(DIC):
    debug("apply_translate()")
    # DIC = {  translatedName : [ipv4, ipv6] }
    debug("apply_translate(): DIC:\n", DIC)
    tempDIC = {}
    for nodename in DIC:
        debug(f"apply_translate(): searching {nodename}")
        if not nodename in TRANSLATE:
            debug(f"{nodename} not in TRANSLATE table")
            continue
        dnsName = TRANSLATE[nodename]
        if dnsName is None:
            debug(f"{nodename} translates to None")
            continue
        nodeValue = DIC[nodename]
        debug(f"apply_translate(): adding {dnsName} as {nodeValue}")
        tempDIC[dnsName] = nodeValue
    debug("apply_translate(): returning tempDIC:\n", tempDIC)
    return tempDIC

def check_for_update(DIC):
    debug("check_for_update()")
    isupdated = False
    buf = ""
    debug("check_for_update(): current DNS entries")
    with open(DNSCONF) as fh:
        for line in fh.readlines():
            if line.startswith(";") or line.startswith("#"):
                buf += line
                continue
            line = line.rstrip()
            param = line.split()
            if len(param) != 3:
                buf += f"{line}\n"
                continue
            subname, subtype, subip = param
            print(f"DNS current entry: {subname}, Type: {subtype}, IP: {subip}")
            if not subname in DIC:
                debug(f"check_for_update(): {subname} is not into the DIC")
                buf += f"{line}\n"
                continue
            ipv4, ipv6 = DIC[subname]
            print(f"Found {subname} for update")
            # ipv4
            if subtype == "A":
                print("Checking IPv4")
                if ipv4 != subip and len(ipv4) > 0:
                    print(f" * Updating {subname} from {subip} to {ipv4}")
                    isupdated = True
                    buf += f"{subname}\t\t\t{subtype}\t{ipv4}\n"
                    continue
            # ipv6
            if subtype == "AAAA":
                print("Checking IPv6")
                if ipv6 != subip and len(ipv6) > 0:
                    print(f" * Updating {subname} from {subip} to {ipv6}")
                    isupdated = True
                    buf += f"{subname}\t\t\t{subtype}\t{ipv6}\n"
                    continue
            buf += f"{line}\n"
        if isupdated:
            print(f"Updating file {DNSCONF}")
            debug("check_for_update(): buf:", buf)
            with open(DNSCONF, 'w') as fh:
                fh.write(buf)
        return isupdated

def get_dns_map_serial(dns_file: str) -> str:
    with open(dns_file) as fh:
        for line in fh.readlines():
            if not re.search(" ; serial", line):
               continue
            timestamp = line.split()[0] 
            return timestamp
    raise Exception(f"Failed to read serial from dns file: {dns_file}") 

def get_serial_date_update(timestamp: str) -> list:
    if len(timestamp) != 10 :
        raise Exception(f"Wrong timestamp size (not 10): {timestamp}") 
    dateformat = timestamp[:8]
    serial = timestamp[8:]
    return (serial)

def update_serial_in_file(old_serial: str, new_serial: str, filename: str) -> None:
    with open(filename) as fh:
        buf = fh.read()
    buf = re.sub(old_serial, new_serial, buf)
    with open(filename, 'w') as fw:
        fw.write(buf)

def update_timestamp(dryrun=False):
    debug("update_timestamp()")
    buf = ""
    today = time.strftime("%Y%m%d", time.localtime())

    timestamp = get_dns_map_serial(DNSDATE)
    (last_update, last_serial) = get_serial_date_update(timestamp)
    print("Timestamp:", timestamp)
    print("Last update:", last_update)
    print("Last serial:", last_serial)
    original_timestamp = ""
    if int(today) > int(last_update):
       timestamp = f"{today}00"
    else:
      serial = int(last_serial) + 1
      if serial < 100:
         timestamp = "%s%02d" % (today, serial)
      else:
         timestamp = "%08d00" % (int(today) + 1)
      print("New timestamp:", timestamp)
    print("Updating file", DNSDATE)
    debug(f"update_timestamp(): timestamp={timestamp}")
    if dryrun is False:
        update_serial_in_file(original_timestamp, timestamp, DNSDATE)
    else:
        print("-= dry-run mode =-")
        print(f"Here file {DNSDATE} would be updated.")
        print("Content:\n", buf)

def bind_restart(dryrun=False):
    #cmd = "systemctl restart named.service"
    ## reload is enough
    cmd = "systemctl reload named.service"
    if dryrun is False:
        subprocess.call(cmd.split())
    else:
        print("-= dry-run mode =-")
        print(f"Here command would be called: {cmd}")

def main():
    now = time.ctime(time.time())
    print(f"Starting: {now}")
    DNS = get_latest_ips()
    DNS = apply_translate(DNS)
    debug(DNS)
    status = check_for_update(DNS)
    if status:
        if len(sys.argv) > 1 and sys.argv[-1] == "--help":
            print(f"Use: {sys.argv[0]}  [--help|--dry-run]")
            sys.exit(0)
        if len(sys.argv) > 1 and sys.argv[-1] == "--dry-run":
            doDryRun = True
        else:
            doDryRun = False
        update_timestamp(dryrun=doDryRun)
        bind_restart(dryrun=doDryRun)

if __name__ == '__main__':
    main()    
  

O código está gigante e uma zona. E estava dando crash. Vamos então olhar por partes.

  
LOG = "/var/log/apache2/dyndns-access.log"
DNSCONF = "/etc/bind/master/dyndns.truta.org"
DNSDATE = ""
DNSFILES = [ DNSDATE, "/etc/bind/master/db.linux-br.org" ]    
  

LOG é onde eu tenho de ler pra buscar o padrão de acesso. DNSCONF é onde ficam as definições de "IP TIPO NOME" como "1.2.3.4 A helio" pro caso de IPv4. DNSDATE é onde ficavam as atualizações pro domínio truta.org. Eu percebi que dava pra fazer a mesma coisa com outros domínios, então depois eu inseri junto o DNSFILES pra ter também o linux-br.org. No final esses aquivos só precisam ser abertos pra alterar o serial.

O mapa de dns, pra entender o que é, é esse aqui pro truta.org:

  
❯ cat /etc/bind/master/db.truta.org
$TTL 180        ; # three minute
@               IN SOA  ns1.truta.org. helio.loureiro.eng.br. (
                                2025080800 ; serial
                                43200      ; refresh (12 hours)
                                3600       ; retry (1 hour)
                                604800     ; expire (1 week)
                                86400      ; minimum (1 day)
                                )
@                       NS      ns1.truta.org.
@                       NS      ns2.afraid.org.
@                       NS      ns1.first-ns.de.
@                       NS      robotns2.second-ns.de.
@                       NS      robotns3.second-ns.com.
@                       A       95.216.213.181
@                       MX      5 mail.truta.org.
@                       AAAA    2a01:4f9:c012:f3c4::1
@                       TXT     "v=spf1 a mx ip4:95.216.213.181 ip6:2a01:4f9:c012:f3c4::1 include:_spf.google.com -all"
mail._domainkey         IN      TXT     "v=DKIM1; k=rsa; t=y; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCpp/HlB1QAHjp6jHGCj9kfcZSjY3ipbGuU/enVjEeptlNgw2qTxRRJ6puPiW5DWCOaPEg2nC//wRazFgMuLZ3C89vF2TT57upngmNuCcLtbwNxo6G1JFo5GD92UbQgstYC+cjfrRlw56XfwzWVtojR4ZCB8PCQCguwhwunwRhJEwIDAQAB" ; ----- DKIM key mail for truta.org
; As e AAAAs

; CNAMES
www                     AAAA    2a01:4f9:c012:f3c4::1
www                     A       95.216.213.181
ns1                     A       95.216.213.181
                        AAAA    2a01:4f9:c012:f3c4::1
mail                    A       95.216.213.181
                        AAAA    2a01:4f9:c012:f3c4::1

; DYNAMIC TRUTAS
$INCLUDE "/etc/bind/master/dyndns.truta.org";
  

A linha que precisa ser alterada é a que tem 2025080800 ; serial. A parte que faz a mágica de ler os novos endereços IPs é $INCLUDE "/etc/bind/master/dyndns.truta.org";. E se não está familiarizado com mapas de dns, o que tem depois do ";" é ignorado. Então o bind lê somente o 2025080800. O ; serial é um comentário pra humanos entenderem o que é aquilo.

O serial é um número inteiro. Você pode usar qualquer número. Só precisa incrementar quando faz alguma alteração no mapa pra poder notificar que houve mudança pro seus secundários. A boa prática é usar YYYYMMDDXX onde:

  • YYYY: ano com 4 dígitos
  • MM: mês com 2 dígitos
  • DD: dia com 2 dígitos
  • XX: um número inteiro com 2 dígitos

Então se o mapa é atualizado no mesmo dia, você incrementa o XX. Claro que isso te limita à 100 alterações por dia, de 0 a 99. Mas não é algo como ficar atualizando tanto o mapa. A menos que você seja um provedor de cloud. Daí as coisas funcionam de jeito beeeeem diferente. Mas vamos ficar no feijão com arroz.

Vamos voltar ao script. Como está escrito com funções, fica mais fácil de olhar pra cada uma independentemente. Eu vou colocar fora de ordem pra ficar mais claro o que faz o quê. Ou o que deveria fazer o quê.

  
def debug(*msg):
    if "DEBUG" in os.environ:
        print("DEBUG:", *msg)    
  

Isso é claramente uma gambiarra. XGH em sua melhor forma. Só pra eu ter um "debug" pra olhar dentro do programa.

  
def main():
    now = time.ctime(time.time())
    print(f"Starting: {now}")
    DNS = get_latest_ips()
    DNS = apply_translate(DNS)
    debug(DNS)
    status = check_for_update(DNS)
    if status:
        if len(sys.argv) > 1 and sys.argv[-1] == "--help":
            print(f"Use: {sys.argv[0]}  [--help|--dry-run]")
            sys.exit(0)
        if len(sys.argv) > 1 and sys.argv[-1] == "--dry-run":
            doDryRun = True
        else:
            doDryRun = False
        update_timestamp(dryrun=doDryRun)
        bind_restart(dryrun=doDryRun)

if __name__ == '__main__':
    main()        
  

A função main é o que chama tudo. Algumas coisas de tempo pra saber quando começa. E efetivamente a primeira chamada aqui: DNS = get_latest_ips(). Soa razoável olhar pra esse get_latest_ips() e imaginar que volta os últimos ips. Mas ip de quem? Com certeza tem de voltar algo como um dicionário pra você saber qual hostname tem qual ip. E esse DNS? Tinha de ser em maiúsculo? Definitivamente não. Variáveis em maiúsculo a gente deixa pra constantes como LOG. Mas seguimos...

Agora temos um DNS = apply_translate(DNS). Esse é um filtro que usa o TRANSLATE lá em cima:

  
TRANSLATE = {
    "goosfraba" : "helio",
    "raspberrypi-masoso" : "rpi0",
    "raspberrypi1" : "rpi1",
    "raspberrypi2" : "rpi2",
    "raspberrypi3" : "rpi3"
    }    
  

E isso é usado porque meu desktop, por exemplo, tem hostname "goosfraba". Mas eu quero que ele seja no fqdn como "helio.truta.org". Então eu uso essa "tradução" de hostname recebido pro que eu quero no dns.

status = check_for_update(DNS) eu provavelmente comparo com o que está em DNSCONF. E se houve alteração, salvo em status. Definitivamente um boolean.

Em seguinda essa parte aqui:

  
    if status:
        if len(sys.argv) > 1 and sys.argv[-1] == "--help":
            print(f"Use: {sys.argv[0]}  [--help|--dry-run]")
            sys.exit(0)
        if len(sys.argv) > 1 and sys.argv[-1] == "--dry-run":
            doDryRun = True
        else:
            doDryRun = False    
  

Esse é um outro XGH. Tudo pra ver se existe o parâmetro "--help". Ou se é passado "--dry-run". "dry-run" é como é chamado um "test pra ver se vai" sem realmente fazer nada. Eu provavelmente fiz isso porque a primeira implementação era em shell script. Mas mesmo em shell script isso seria XGH.

update_timestamp(dryrun=doDryRun) faz aquele update de serial nos mapas de dns se algo mudou. A menos que o parâmetro de "dry-run" esteja lá.

E finalmente bind_restart(dryrun=doDryRun) reinicia o serviço via systemd ser não for um "dry-run".

Olhamos a lógica do que deveria fazer. Agora vamos ver o que foi feito. Vamos começar com o get_latest_ips( ).

  
def get_latest_ips():
    """
    Dictionary in format:
        Domain/node : [ ipv4, ipv6]
    """
    debug("get_latest_ips()")
    NODE_IP = {}
    with open(LOG) as logfile:
        for line in logfile.readlines():
            if not re.search("\/\?update_dyndns=", line):
                continue
            line = re.sub(" HTTP.*", "", line)
            param = line.split()
            ip = param[0]
            nodename = re.sub("\/\?update_dyndns=", "", param[-1])

            if not nodename in NODE_IP:
                NODE_IP[nodename] = [ None, None ]
            # ipv4?
            if re.search("([0-9].*)\.([0-9].*)\.([0-9].*)\.([0-9].*)", ip):
                NODE_IP[nodename][0] = ip
                debug(f"{nodename} (ipv4): {ip}")
            else:
                NODE_IP[nodename][1] = ip
                debug(f"{nodename} (ipv6): {ip}")
    return NODE_IP    
  

Aparentemente a estrutura NODE_IP = {} vai ser um dicionário do tipo hostname: [IPv4][IPv6].

  
    with open(LOG) as logfile:
        for line in logfile.readlines():
            if not re.search("\/\?update_dyndns=", line):
                continue    
  

Aqui é ler o aquivo LOG. Se a linha não tiver o padrão "/?update_dynds=", vai pra próxima linha.

  
            line = re.sub(" HTTP.*", "", line)
            param = line.split()
            ip = param[0]
            nodename = re.sub("\/\?update_dyndns=", "", param[-1])    
  

A alinha recebida é algo assim:

  83.233.219.150 - - [11/Aug/2025:08:54:57 +0000] "GET /?update_dyndns=goosfraba HTTP/1.1" 200 87144 "-" "curl/8.15.0"

Então o regex em line = re.sub(" HTTP.*", "", line) remove tudo depois de " HTTP", including essa parte junto. A linha então fica algo assim:

  83.233.219.150 - - [11/Aug/2025:08:54:57 +0000] "GET /?update_dyndns=goosfraba

A linha param = line.split() cria a variável param (parâmetros) com a linha separada por espaços simples. ip = param[0] pega o primeiro parâmetro. No exemplo seria "83.233.219.150". nodename = re.sub("\/\?update_dyndns=", "", param[-1]) pega o último parâmetro, que no exemplo seria "/?update_dyndns=goosfraba" e remove a parte "/?update_dyndns=". Vai sobrar o hostname, "goosfraba".

  
            if not nodename in NODE_IP:
                NODE_IP[nodename] = [ None, None ]    
  

Aqui olha se o dicionário NODE_IP tem a chave que é o nodename. Eu comentei acima hostname, mas no código chamei de nodename. Tenha em mente que eram a mesma coisa. E se a chave não existe, eu crio com uma lista vazia.

  
            if re.search("([0-9].*)\.([0-9].*)\.([0-9].*)\.([0-9].*)", ip):
                NODE_IP[nodename][0] = ip
                debug(f"{nodename} (ipv4): {ip}")
            else:
                NODE_IP[nodename][1] = ip
                debug(f"{nodename} (ipv6): {ip}")    
  

Finalmente eu comparo o ip se bate com o padrão ([0-9].*)\.([0-9].*)\.([0-9].*)\.([0-9].*) que é basicamente qualquer número de 0 à 9 seguido de ponto. Então se eu recebesse um IP inválido, como 999.9999999.999999999999.99999999, validaria. Mas pro script XGH, foi suficiente. Daí eu sei se é um IPv4. Se não for, é IPv6.

Como vou lendo o arquivo LOG de cima pra baixo, esses valores de hostname e ip é atualizado várias vezes. Com os mesmos valores. E como o servidor web atualiza também de cima pra baixo, a última linha deve ser a mais atual. Não muito eficiente, mas dá certo. XGH raiz.

O apply_translate( ) eu não vou comentar. Já expliquei acima o que ele faz, que é traduzir hostname pra entrada no dns. Não adiciona muita coisa. Então vamos pro próximo.

O próximo é um XGH raiz. Função grande. É a check_for_update( ). Vamos por partes.

  
def check_for_update(DIC):
    debug("check_for_update()")
    isupdated = False
    buf = ""
    debug("check_for_update(): current DNS entries")
    with open(DNSCONF) as fh:
        for line in fh.readlines():
            if line.startswith(";") or line.startswith("#"):
                buf += line
                continue
            line = line.rstrip()
            param = line.split()
            if len(param) != 3:
                buf += f"{line}\n"
                continue    
  

Aqui é lido o arquivo DNSCONF, que é onde está o mapa "ip tipo nome" como "1.2.3.4 A helio". Essa primeira parte descarta as linhas que começam ou com ";" ou com "#". Na verdade copia a linha como está em buf. A parte de baixo prepara a linha pra ser lida se tiver o padrão de ter ao menos 3 parâmetros quando separada por espaços. Se não for, copie pro buffer e vá pra próxima linha.

  
            subname, subtype, subip = param
            print(f"DNS current entry: {subname}, Type: {subtype}, IP: {subip}")
            if not subname in DIC:
                debug(f"check_for_update(): {subname} is not into the DIC")
                buf += f"{line}\n"
                continue    
  

Aqui já pega os 3 parâmetros que serão subname, subtype, subip. O subname é o hostname. Ou nodename. Consistência não é meu forte. Ou não era. E se esse hostname não estiver no dicionário, salva a linha como ela era no buffer e vai pra próxima linha.

  
            ipv4, ipv6 = DIC[subname]
            print(f"Found {subname} for update")
            # ipv4
            if subtype == "A":
                print("Checking IPv4")
                if ipv4 != subip and len(ipv4) > 0:
                    print(f" * Updating {subname} from {subip} to {ipv4}")
                    isupdated = True
                    buf += f"{subname}\t\t\t{subtype}\t{ipv4}\n"
                    continue
            # ipv6
            if subtype == "AAAA":
                print("Checking IPv6")
                if ipv6 != subip and len(ipv6) > 0:
                    print(f" * Updating {subname} from {subip} to {ipv6}")
                    isupdated = True
                    buf += f"{subname}\t\t\t{subtype}\t{ipv6}\n"
                    continue
            buf += f"{line}\n"    
  

Aqui os endereços IPv4 e IPv6 são recuperados do dicionário em ipv4, ipv6 = DIC[subname]. Se o subtipo for "A", eu olho se o endereço IPv4 mudou. Se for "AAAA", olho o IPv6. Se alterar, eu altero isupdated pra verdadeiro.

  
        if isupdated:
            print(f"Updating file {DNSCONF}")
            debug("check_for_update(): buf:", buf)
            with open(DNSCONF, 'w') as fh:
                fh.write(buf)
        return isupdated    
  

E finalmente, se o código foi alterado, escrevo em cima do arquivo antigo o conteúdo novo.

Até aqui tudo bem. O código está meio rebuscado e com alguns pontos de XGH mas parece funcional. Vamos então olhar a próxima parte, que é atualizar os mapas caso tenha tido alguma mudança. Olhando pra update_timestamp( ) vamos ver que esse depende de outras funções, que ficam pra cima no código. E aqui já entra um pequeno mal estar. Eu gosto dos conselhos do Uncle Bob sobre clean code. E prefiro ler um código de cima pra baixo. Usou uma função ou método? Coloque abaixo. Na ordem em que foi chamado. Mas com python com funções isso não funciona. Você tem de colocar acima as funções que serão chamadas abaixo. Então vamos lá.

  
  def update_timestamp(dryrun=False):
    debug("update_timestamp()")
    buf = ""
    today = time.strftime("%Y%m%d", time.localtime())

    timestamp = get_dns_map_serial(DNSDATE)
    

Bem básico. Pega o dia de hoje no formato "YYYMMDD". Em seguida olha qual o serial do arquivo com get_dns_map_serial( ). Ao contrário do que fiz até agora, eu vou parar de analizar essa função e seguir o código que ela chamou.

  
def get_dns_map_serial(dns_file: str) -> str:
    with open(dns_file) as fh:
        for line in fh.readlines():
            if not re.search(" ; serial", line):
               continue
            timestamp = line.split()[0] 
            return timestamp
    raise Exception(f"Failed to read serial from dns file: {dns_file}")     
  

Esse trecho de código é claramente mais recente. Usando f-strings e mais type hints pra saber o que seria ali. E variáveis em minúsculas! E bem simples: abre o arquivo passado (um mapa de dns) e busca pela linha com "; serial". Quebra a linha em espaços e retorna o primeiro parâmetro encontrado. Não tem verificação se realmente voltou uma string, mas parece ser isso. E se não achar nada, lança uma exceção e para a execução toda. Um pouco demais, mas pelo menos não retorna algo errado e deixa destruir o mapa de dns.

Vamos então voltar a olhar a função chamadora, update_timestamp( ).

  
    (last_update, last_serial) = get_serial_date_update(timestamp)    
  

Novamente um código mais enxuto. Ao invés de manter no corpo da função, chamar outra função auto-explicativa. Então vamos olhar a get_serial_date_update( ).

  
def get_serial_date_update(timestamp: str) -> list:
    if len(timestamp) != 10 :
        raise Exception(f"Wrong timestamp size (not 10): {timestamp}") 
    dateformat = timestamp[:8]
    serial = timestamp[8:]
    return (serial)    
  

Aqui ele verifica se o serial tem 10 caractéres, ou 10 dígitos com if len(timestamp) != 10. Ou gera uma exceção e encerra a execução. Pega os primeiro 8 caractéres e coloca em dateformat. Em seguida pega os caractéres restantes e coloca em serial. E retorna o serial.

Perceberam os problemas?

Já começa que a função chama-se get_serial_date_update( ). Qual o update? E deveria retornar uma lista. Até volta, mas com um valor só, o serial.

A função que chamava esperava o quê? Esperava isso aqui: (last_update, last_serial) = get_serial_date_update(timestamp). Então recebe last_update mas não last_serial. Achamos um 🪲.

Vamos então continuar em update_timestamp( ).

  
    print("Timestamp:", timestamp)
    print("Last update:", last_update)
    print("Last serial:", last_serial)
    original_timestamp = ""
    if int(today) > int(last_update):
       timestamp = f"{today}00"
    else:
      serial = int(last_serial) + 1
      if serial < 100:
         timestamp = "%s%02d" % (today, serial)
      else:
         timestamp = "%08d00" % (int(today) + 1)
      print("New timestamp:", timestamp)    
  

Aqui a data today é transformada em int (integer, inteiro) e comparada com last_update, que também é transformado em int. Se for maior, mais atual, então inicializa com "00". Se não for mais atual, então incrementa o número serial em 1. E olha se for maior que 100, incializa o dia seguinte e começa com 00 de novo.

E finalmente:

  
    if dryrun is False:
        update_serial_in_file(original_timestamp, timestamp, DNSDATE)    
  

Se não está rodando em dry-run, rodar esse update_serial_in_file( ). Pelo nome é possível deduzer que seja trocar o serial antigo do arquivo pelo novo. Mas vamos olhar o código.

  
def update_serial_in_file(old_serial: str, new_serial: str, filename: str) -> None:
    with open(filename) as fh:
        buf = fh.read()
    buf = re.sub(old_serial, new_serial, buf)
    with open(filename, 'w') as fw:
        fw.write(buf)    
  

E sim, é basicamente isso mesmo. Ler o arquivo todo, trocar a string antiga pela nova, e salvar.

E com isso chegamos na parte final do script:

  
           bind_restart(dryrun=doDryRun)
  

É bem descritivo e faz exatamente o que diz: reinicia o serviço de dns via systemd.

  
def bind_restart(dryrun=False):
    #cmd = "systemctl restart named.service"
    ## reload is enough
    cmd = "systemctl reload named.service"
    if dryrun is False:
        subprocess.call(cmd.split())
    else:
        print("-= dry-run mode =-")
        print(f"Here command would be called: {cmd}")    
  

Se teve paciência de ler até aqui, espero que tenha gostado e aprendido que esse código estava bem ruim. E o que seria um código bom? Com certeza um que seja legível de cima pra baixo. Até certo ponto porque python não é tão flexível assim. Sem delongas, vou mostrar o código novo. Depois vou explicando.

  
#! /usr/bin/python3 -u
# -*- encoding: utf-8 -*-

"""
DNS updater.  It checks if hostname and IP are updated
on maps.  If not, update accordingly and reload bind.
"""
# grep "\/\?node=" /var/log/apache2/dyndns-access.log | sed "s/ HTTP.*//" | awk '{print $1, $NF}'

import re
import time
import os
import subprocess
import sys
import argparse
import logging

logger = logging.getLogger(__file__)
consoleOutputHandler = logging.StreamHandler()
logger.addHandler(consoleOutputHandler)
logger.setLevel(logging.INFO)

TRANSLATE = {
    "goosfraba" : "helio",
    "raspberrypi-masoso" : "rpi0",
    "raspberrypi1" : "rpi1",
    "raspberrypi2" : "rpi2",
    "raspberrypi3" : "rpi3"
    }


class DnsIPUpdate:
    '''
    A class that reads httpd logs for patterns in order to find a host IP,
    update dns file accordingly if needed and restart dns service if updated.

    '''
    def __init__(self, args: argparse.Namespace):
        self._logfile: str = args.logfile
        self._bindfiles: list = args.bindfiles.split(",")
        self._IPv4: dict = {}
        self._IPv6: dict = {}
        self._dryrun: bool = args.dryrun
        self._dyndnsfile: str = args.dyndnsfile
        self._fakesystemd: bool = args.fakesystemd
        # for the serial on the dns files
        self._year: str = time.strftime("%Y", time.localtime())
        self._month: str = time.strftime("%m", time.localtime())
        self._day: str = time.strftime("%d", time.localtime())

    def update(self):
        'run the update'
        matched_lines: list = self.getEntriesFromLogs()
        self._populateIPsData(matched_lines)
        logger.debug(f'IPv4s: {self._IPv4}')
        logger.debug(f'IPv6s: {self._IPv6}')
        self._applyTranslation()
        logger.debug(f'IPv4s: {self._IPv4}')
        logger.debug(f'IPv6s: {self._IPv6}')

        if not self._dryrun:
            resp: bool = self._updateDNSFiles()
            if resp is True:
                self._restartService()

    def getEntriesFromLogs(self) -> list:
        'return the matching lines from logging file'
        matches: list = []
        with open(self._logfile) as fd:
            for line in fd.readlines():
                if not re.search(r"\/\?update_dyndns=", line):
                    continue
                line = line.rstrip()
                logger.debug(f"found line in log: {line}")
                matches.append(line)
        return matches

    def _populateIPsData(self, matches):
        'fill the IPv4 and IPv6 structures with IPs and hostnames'
        for line in matches:
            ip_addr, _, _, timestamp, timezone, method, uri, http_version, status_code, size, _, user_agent = line.split()
            timestamp = timestamp[1:]
            timezone = timezone[:-1]
            method = method[1:]
            http_version = http_version[:-1]
            user_agent = user_agent[1:-1]

            logger.debug(f"ip={ip_addr} timestamp={timestamp} timezone={timezone} method={method} uri={uri} proto={http_version} status_code={status_code} size={size} agent={user_agent}")

            hostname = self._getHostName(uri)

            if self._isIPv4(ip_addr):
                self._IPv4[hostname] = ip_addr
            else:
                self._IPv6[hostname] = ip_addr

    def _isIPv4(self, ip: str) -> bool:
        'quickly finds out whether IPv4 or not - then IPv6'
        if re.search("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", ip):
            return True
        return False

    def _getHostName(self, uri: str) -> str:
        'filter uri to return only the hostname'
        hostname = re.sub(r",.*", "", uri)
        hostname = re.sub(r".*update_dyndns=", "", hostname)
        return hostname

    def _applyTranslation(self) -> None:
        'translate hostname according to table'
        for old_name, new_name in TRANSLATE.items():
            if old_name in self._IPv4:
                logger.debug(f"Translating IPv4: from={old_name} to={new_name}")
                self._IPv4[new_name] = self._IPv4[old_name]
                del self._IPv4[old_name]
            if old_name in self._IPv6:
                logger.debug(f"Translating IPv6: from={old_name} to={new_name}")
                self._IPv6[new_name] = self._IPv6[old_name]
                del self._IPv6[old_name]

    def _updateDNSFiles(self) -> bool:
        'check and update DNS files if needed'
        isUpdated = self._updateDynDNS()
        if isUpdated is True:
            self._updateSerialOnDNS()
        return isUpdated

    def _updateDynDNS(self) -> bool:
        'check each entry into dyndns file and returns true if updated'
        status = False  # by default we don't update
        output = []
        with open(self._dyndnsfile) as fd:
            buffer = fd.readlines()

        for line in buffer:
            line = line.rstrip()  # remove new line
            try:
                hostname, _, _, dns_type, ip_addr = line.split("\t")
            except ValueError:
                output.append(f"{line}\n")
                continue

            match dns_type:
                case "A":
                    # IPv4
                    if hostname in self._IPv4:
                        if self._IPv4[hostname] != ip_addr:
                            logger.info(f"updating IPv4 for {hostname}: old={ip_addr} new={self._IPv4[hostname]}")
                            line = f"{hostname}\t\t\tA\t{self._IPv4[hostname]}"
                            status = True
                case "AAAA":
                    # IPv6
                    if hostname in self._IPv6:
                        if self._IPv6[hostname] != ip_addr:
                            logger.info(f"updating IPv6 for {hostname}: old={ip_addr} new={self._IPv6[hostname]}")
                            line = f"{hostname}\t\t\tAAAA\t{self._IPv6[hostname]}"
                            status = True
                case _:
                    pass
            output.append(f"{line}\n")

        if status is True:
            with open(self._dyndnsfile, 'w') as fw:
                fw.write("".join(output))

        return status

    def _updateSerialOnDNS(self) -> None:
        for filename in self._bindfiles:
            buffer: list = []
            logger.debug(f"updating serial on file: {filename}")
            with open(filename) as fd:
                for line in fd.readlines():
                    if not re.search("; serial", line):
                        buffer.append(line)
                        continue
                    line = line.rstrip()
                    logger.debug(f"line with serial number: {line}")
                    serial = self._sanitizeSerial(line)
                    logger.debug(f"serial={serial}")

                    new_serial = self._increaseSerial(serial)
                    logger.debug(f"new_serial={new_serial}")
                    line = f"\t\t\t\t{new_serial} ; serial\n"
                    logger.info(f"updating: filename={filename} old_serial={serial} new_serial={new_serial}")
                    buffer.append(line)
            with open(filename, "w") as fw:
                fw.write("".join(buffer))

    def _sanitizeSerial(self, serial_nr: str) -> str:
        'to remove the \t and spaces'
        serial, _ = serial_nr.split(";")
        serial = re.sub(r"\t", "", serial)
        serial = re.sub(" ", "", serial)
        return serial

    def _increaseSerial(self, serial_nr: str):
        'to identify and increase serial'
        year = serial_nr[:4]
        month = serial_nr[4:6]
        day = serial_nr[6:8]
        counter = serial_nr[8:]

        today = f"{self._year}{self._month}{self._day}"
        serial_date = f"{year}{month}{day}"
        if today == serial_date:
            new_counter = self._increaseString(counter)
            return f"{today}{new_counter}"
        else:
            return f"{today}00"

    def _increaseString(self, str_number: str) -> str:
        'convert string to int, increase by one and return as string'
        int_number: int = int(str_number)
        int_number += 1
        return f"{int_number}"

    def _restartService(self) -> None:
        'restart systemd service'
        cmd = ["systemctl", "reload", "named.service"]
        logger.info("restarting named.service")
        if self._fakesystemd is True:
            print(f"here service would be restarted: {cmd}")
        else:
            subprocess.call(cmd)


def getTimestamp():
    return time.strftime("%Y%m%dT%H:%M:%S", time.localtime())


def main():
    logger.info(f"starting: {getTimestamp()}")

    parser = argparse.ArgumentParser(description='script to update DNS entries for dynamic clients found on httpd logs')
    parser.add_argument("--logfile", required=True, help="the httpd log to be checked")
    parser.add_argument("--loglevel", default="info", help="logging level for this script")
    parser.add_argument("--bindfiles", required=True, help="files separated by \",\" to be updated to update serial")
    parser.add_argument("--dyndnsfile", required=True, help="the dyndns map to update the IPs")
    parser.add_argument("--dryrun", default=False, type=bool, help='run as dry-run or not (default=false)')
    parser.add_argument("--fakesystemd", default=False, type=bool, help='run just a printout instead of systemd')

    args = parser.parse_args()

    if args.loglevel != "info":
        logger.setLevel(args.loglevel.upper())

    logger.debug(f"args: {args}")

    dns = DnsIPUpdate(args)
    dns.update()

    logger.info(f"finished: {getTimestamp()}")


if __name__ == '__main__':
    main()    
  

A parte de baixo continua mais ou menos igual: chama uma função main( ) que faz tudo. Mas agora a função main( ) usa argparse pra pegar os argumentos. Então as constantes com os nomes dos arquivos sumiram e viraram agora argumentos. Isso facilita escrever testes (o que não fiz ainda). O que o código faz? Faz isso aqui:

  
    dns = DnsIPUpdate(args)
    dns.update()    
  

Cria um objeto DnsIPUpdate( ) e chama o método update( ). Não mais funções mas métodos. Parece mais simples de entender. Espero.

Vamos então agora olhar a classe. Começando com sua inicialização.

  
    def __init__(self, args: argparse.Namespace):
        self._logfile: str = args.logfile
        self._bindfiles: list = args.bindfiles.split(",")
        self._IPv4: dict = {}
        self._IPv6: dict = {}
        self._dryrun: bool = args.dryrun
        self._dyndnsfile: str = args.dyndnsfile
        self._fakesystemd: bool = args.fakesystemd
        # for the serial on the dns files
        self._year: str = time.strftime("%Y", time.localtime())
        self._month: str = time.strftime("%m", time.localtime())
        self._day: str = time.strftime("%d", time.localtime())    
  

As variáveis (atributos da classe) vêm do argparse. Todas com o "_" no início pra indicar que são internas. Não que isso importe muito uma vez que não é uma classe feita pra ser usada como biblioteca. E agora IPv4 e IPv6 são estruturas separadas. Do tipo dicionário. E pegamos ano, mês e dia separados.

Vamos então olhar o método chamado, o update( ):

  
    def update(self):
        'run the update'
        matched_lines: list = self.getEntriesFromLogs()
        self._populateIPsData(matched_lines)
        logger.debug(f'IPv4s: {self._IPv4}')
        logger.debug(f'IPv6s: {self._IPv6}')
        self._applyTranslation()
        logger.debug(f'IPv4s: {self._IPv4}')
        logger.debug(f'IPv6s: {self._IPv6}')

        if not self._dryrun:
            resp: bool = self._updateDNSFiles()
            if resp is True:
                self._restartService()    
  

Primeiramente que o método update( ) aparece logo abaixo da inicialização da classe. Então você lê o código de cima pra baixo, o que é muito desejado. E o código chama outro método em matched_lines: list = self.getEntriesFromLogs( ), que vai retornar uma lista. Vamost então olhar o que faz getEntriesFromLogs( ).

  
   def getEntriesFromLogs(self) -> list:
        'return the matching lines from logging file'
        matches: list = []
        with open(self._logfile) as fd:
            for line in fd.readlines():
                if not re.search(r"\/\?update_dyndns=", line):
                    continue
                line = line.rstrip()
                logger.debug(f"found line in log: {line}")
                matches.append(line)
        return matches    
  

Ele então abre o arquivo _logfile e guarda as linhas com o padrão "/?update_dyndns=". E em formate de lista. Depois retorna esses valores. Não melhorou muito em termos de ler o arquivo todo e fazer o parsing de tudo. Ficou só mais legível.

Voltando ao update( ) temos logo em seguida: self._populateIPsData(matched_lines). Então vamos olhar o código de _populateIPsData( ).

  
    def _populateIPsData(self, matches):
        'fill the IPv4 and IPv6 structures with IPs and hostnames'
        for line in matches:
            ip_addr, _, _, timestamp, timezone, method, uri, http_version, status_code, size, _, user_agent = line.split()
            timestamp = timestamp[1:]
            timezone = timezone[:-1]
            method = method[1:]
            http_version = http_version[:-1]
            user_agent = user_agent[1:-1]

            logger.debug(f"ip={ip_addr} timestamp={timestamp} timezone={timezone} method={method} uri={uri} proto={http_version} status_code={status_code} size={size} agent={user_agent}")

            hostname = self._getHostName(uri)

            if self._isIPv4(ip_addr):
                self._IPv4[hostname] = ip_addr
            else:
                self._IPv6[hostname] = ip_addr    
  

Agora a linha é quebrada em espaços mas o dados são utilizados. O que é "_" significa que seja descartado. Então olhando a linha de exemplo novamente:

  83.233.219.150 - - [11/Aug/2025:08:54:57 +0000] "GET /?update_dyndns=goosfraba HTTP/1.1" 200 200 "-" "curl/8.15.0"

Temos:

  • ip_addr=83.233.219.150
  • _=-
  • _=-
  • timestamp=[11/Aug/2025:08:54:57
  • timezone=+0000]
  • method="GET
  • uri=/?update_dyndns=goosfraba
  • http_version=HTTP/1.1"
  • status_code=200
  • size=200
  • _="-"
  • user_agent="curl/8.15.0"

O que vem a seguir é somente pra limpar esses parâmetros. Servem pra alguma coisa? Não. Só pra ficar bonitinho no debug. O que realmente importa são os valores de ip_addr e uri.

Em seguida é chamado hostname = self._getHostName(uri). Vamos olhar o código do _getHostName( ).

  
    def _getHostName(self, uri: str) -> str:
        'filter uri to return only the hostname'
        hostname = re.sub(r",.*", "", uri)
        hostname = re.sub(r".*update_dyndns=", "", hostname)
        return hostname    
  

É somente o mesmo sanitizador usado anteriormente. Vai remover o "/?update_dyndns=" e deixar somente o hostname.

Voltando ao _populateIPsData( ), temos a chamada if self._isIPv4(ip_addr): pra verificar se é um endereço IPv4 ou não.

  
   def _isIPv4(self, ip: str) -> bool:
        'quickly finds out whether IPv4 or not - then IPv6'
        if re.search("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$", ip):
            return True
        return False    
  

O código agora olha se é um número válido dentro do range de IPv4.

Então após essa verificação, ou o dicionário _IPv4 ou o _IPv6 é populado. E segue o formato "hostname: IP". Mas cada um em seu dicionário agora.

Voltando ao update( ) temos:

  
        logger.debug(f'IPv4s: {self._IPv4}')
        logger.debug(f'IPv6s: {self._IPv6}')
        self._applyTranslation()
        logger.debug(f'IPv4s: {self._IPv4}')
        logger.debug(f'IPv6s: {self._IPv6}')    
  

Que é a mesmo tradução feita anteriormente.

  
   def _applyTranslation(self) -> None:
        'translate hostname according to table'
        for old_name, new_name in TRANSLATE.items():
            if old_name in self._IPv4:
                logger.debug(f"Translating IPv4: from={old_name} to={new_name}")
                self._IPv4[new_name] = self._IPv4[old_name]
                del self._IPv4[old_name]
            if old_name in self._IPv6:
                logger.debug(f"Translating IPv6: from={old_name} to={new_name}")
                self._IPv6[new_name] = self._IPv6[old_name]
                del self._IPv6[old_name]    
  

Seguindo em frente no update( ) temos o seguinte:

  
        if not self._dryrun:
            resp: bool = self._updateDNSFiles()    
  

Vamos olhar então _updateDNSFiles( ) que faz o update já visto anteriormente.

  
   def _updateDNSFiles(self) -> bool:
        'check and update DNS files if needed'
        isUpdated = self._updateDynDNS()
        if isUpdated is True:
            self._updateSerialOnDNS()
        return isUpdated
  

Ele chama _updateDynDNS( ). Vamos então ao código.

  
    def _updateDynDNS(self) -> bool:
        'check each entry into dyndns file and returns true if updated'
        status = False  # by default we don't update
        output = []
        with open(self._dyndnsfile) as fd:
            buffer = fd.readlines()

        for line in buffer:
            line = line.rstrip()  # remove new line
            try:
                hostname, _, _, dns_type, ip_addr = line.split("\t")
            except ValueError:
                output.append(f"{line}\n")
                continue    
  

Então ele abre o arquivo e lê linha por linha. Mas dessa vez tenta popular a estrutura: hostname, _, _, dns_type, ip_addr = line.split("\t"). Se der errado, joga a linha no que será escrito depois. E segue pra próxima linha. Continuando.

  
            match dns_type:
                case "A":
                    # IPv4
                    if hostname in self._IPv4:
                        if self._IPv4[hostname] != ip_addr:
                            logger.info(f"updating IPv4 for {hostname}: old={ip_addr} new={self._IPv4[hostname]}")
                            line = f"{hostname}\t\t\tA\t{self._IPv4[hostname]}"
                            status = True
                case "AAAA":
                    # IPv6
                    if hostname in self._IPv6:
                        if self._IPv6[hostname] != ip_addr:
                            logger.info(f"updating IPv6 for {hostname}: old={ip_addr} new={self._IPv6[hostname]}")
                            line = f"{hostname}\t\t\tAAAA\t{self._IPv6[hostname]}"
                            status = True
                case _:
                    pass    
  

Usa match ao invés de um if. Poderia usar if? Sim. Mas achei que o match deixou o código mais organizado. Em seguida verifica se o hostname existe no dicionário de IP. Se existir, compara se o IP está diferente ou não. Se estiver, atualiza a flag status pra verdadeiro pra avisar que houveram mudanças.

  
          output.append(f"{line}\n")

        if status is True:
            with open(self._dyndnsfile, 'w') as fw:
                fw.write("".join(output))

        return status    
  

A parte final é salvar o arquivo caso tenha havido alguma alteração.

Voltando para _updateDNSFiles( ), temos:

  
      if isUpdated is True:
            self._updateSerialOnDNS()
        return isUpdated    
  

Vamos então olhar _updateSerialOnDNS( ):

  
   def _updateSerialOnDNS(self) -> None:
        for filename in self._bindfiles:
            buffer: list = []
            logger.debug(f"updating serial on file: {filename}")
            with open(filename) as fd:
                for line in fd.readlines():
                    if not re.search("; serial", line):
                        buffer.append(line)
                        continue
                    line = line.rstrip()
                    logger.debug(f"line with serial number: {line}")
                    serial = self._sanitizeSerial(line)
                    logger.debug(f"serial={serial}")

                    new_serial = self._increaseSerial(serial)
                    logger.debug(f"new_serial={new_serial}")
                    line = f"\t\t\t\t{new_serial} ; serial\n"
                    logger.info(f"updating: filename={filename} old_serial={serial} new_serial={new_serial}")
                    buffer.append(line)
            with open(filename, "w") as fw:
                fw.write("".join(buffer))    
  

Aqui já virou feijão com arroz. O que temos de olhar é _sanitizeSerial( ), depois _increaseSerial( ).

  
   def _sanitizeSerial(self, serial_nr: str) -> str:
        'to remove the \t and spaces'
        serial, _ = serial_nr.split(";")
        serial = re.sub(r"\t", "", serial)
        serial = re.sub(" ", "", serial)
        return serial    
  

Sem surpresas, só um regex pra voltar o número serial.

  
   def _increaseSerial(self, serial_nr: str):
        'to identify and increase serial'
        year = serial_nr[:4]
        month = serial_nr[4:6]
        day = serial_nr[6:8]
        counter = serial_nr[8:]

        today = f"{self._year}{self._month}{self._day}"
        serial_date = f"{year}{month}{day}"
        if today == serial_date:
            new_counter = self._increaseString(counter)
            return f"{today}{new_counter}"
        else:
            return f"{today}00"    
  

Aqui o código mudou. Ao invés de comparar com números inteiros, faz uma comparação de string direto. E chama _increaseString( ) pra aumentar o valor da string.

  
   def _increaseString(self, str_number: str) -> str:
        'convert string to int, increase by one and return as string'
        int_number: int = int(str_number)
        int_number += 1
        return f"{int_number}"    
  

Aqui sim a string é convertida pra inteiro. Esse é incrementado por 1. Mas retorna como string.

Chegamos então na parte final do update( ) que chama o método _restartService().

  
    def _restartService(self) -> None:
        'restart systemd service'
        cmd = ["systemctl", "reload", "named.service"]
        logger.info("restarting named.service")
        if self._fakesystemd is True:
            print(f"here service would be restarted: {cmd}")
        else:
            subprocess.call(cmd)    
  

Sem muita novidade, é o código pra reiniciar o serviço de dns via systemd.

E aqui acabamos com o código novo. O que achou? Eu achei melhor de manter. E funcionando sem bugs.

O próximo passo será escrever testes unitários pra ele ☺️.

Um vídeo excelente sobre o que é o fediverso

Details
Written by: Helio Loureiro
Category: Fediverso
Published: June 20, 2025
Hits: 866
  • mastodon

Roubartilhando um vídeo muito bom que a Fabs postou no Mastodon. Explica bastante o fediverso e a importância de estar ali.

Do original:

Post by @This email address is being protected from spambots. You need JavaScript enabled to view it.
View on Mastodon

Só uma pequena nota: estou salvando o vídeo aqui porque as coisas costuma sumir na Internet. Então aqui ficar preservado o vídeo.

25 anos de loureiro.eng.br

Details
Written by: Helio Loureiro
Category: Blog
Published: June 19, 2025
Hits: 801
  • aniversário

Foram inicialmente 10 anos, descritos em 10 anos de Loureiro.Eng.BR.

Depois foram 20 anos, 20 anos de Loureiro.Eng.BR !!!

E chegamos aos 25 anos do site. Pra minha imensa surpresa e prazer pessoal.

Eu esperava chegar nessa marca? Jamais! E continuar escrendo com frequência? Bem, isso eu imaginei sim no início. Aliás achei que era bem mais fácil criar conteúdo do que realmente é.

De notas pessoais a mensagens de blog. Tentei colocar de tudo por aqui. E manter a chama viva. E a chama sobreviveu esse tempo todo. E não parece que vai apagar tão cedo.

Nos vemos daqui 5 anos.

E feliz aniversário pro site.

DNS4EU, um sistema de DNS da Europa

Details
Written by: Helio Loureiro
Category: Blog
Published: June 09, 2025
Hits: 855
  • dns
  • europa

A Europa tem tentado manter sistemas independentes dos EUA. Não é algo de agora, mas sempre foi um dos pensamentos vigentes por aqui. Quem não lembra do projeto Galileu, pra subistutir o GPS dos americanos caso esse fosse desligado?

E isso tem sido mais acirrado ultimamente com os temas de soberania de dados. Sem falar no Trump. Esse sozinho é um caso à parte.

E uma das iniciativas da Europa é esses sistema de DNS independente de empresas americanas. Eu já adotei aqui em casa.

https://www.joindns4.eu/for-public

Seu browser é seguro?

Details
Written by: Helio Loureiro
Category: Blog
Published: May 29, 2025
Hits: 916

A EFF, Eletronic Frontier Foundation, lançou uma iniciativa bem legal. Você pode acessar e testar se seu browser mantém seus dados seguros no sentido de privacidade.

Eu testei no meus Firefox, tanto dos computadores (laptops e desktop) tanto quanto no meu telefone. Todos passaram maravilhosamente seguros quanto a minha privacidade.

E você? Já testou seus browser?

https://coveryourtracks.eff.org
  • Mais uma tentativa com python3.13 e python3.13t
  • Revisitando o artigo de shell lento com python3.13
  • O ano do Linux no desktop está acontecendo!
  • Novo canal da Bárbara Tostes
  • Gerando senhas com openssl

Page 2 of 37

  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

Estatísticas

  • Users 2
  • Articles 482
  • Articles View Hits 3489872

Imagem aleatória