GMGall’s blog

Programação, Linux e o que mais der na telha

Criando suas próprias ações no fail2ban – parte 3

Criando suas próprias ações

Estrutura de uma ação

Cada ação é um arquivo no diretório action.d. Esses arquivos seguem a seguinte estrutura:

[Definition]

# Option:  actionstart
# Notes.:  comando executado ao iniciar o Fail2Ban.
# Values:  CMD
#
actionstart =


# Option:  actionstop
# Notes.:  comando executado ao encerrar o Fail2Ban
# Values:  CMD
#
actionstop =


# Option:  actioncheck
# Notes.:  comando executado antes de cada comando actionban
# Values:  CMD
#
actioncheck =


# Option:  actionban
# Notes.:  comando executado ao banir um IP. Observe que o comando
#          é executado com as permissões do usuário executando o Fail2Ban.
# Tags:    <ip>  IP address
#          <failures>  number of failures
#          <time>  unix timestamp of the ban time
# Values:  CMD
#
actionban = ipfw add deny tcp from <ip> to <localhost> <port>


# Option:  actionunban
# Notes.:  comando executado ao "desbanir" um IP. Observe que o comando
#          é executado com as permissões do usuário executando o Fail2Ban.
# Tags:    <ip>  IP address
#          <failures>  number of failures
#          <time>  unix timestamp of the ban time
# Values:  CMD
#
actionunban = ipfw delete `ipfw list | grep -i <ip> | awk '{print $1;}'`

[Init]

# Option:  port
# Notes.:  specifies port to monitor
# Values:  [ NUM | STRING ]
#
port = ssh

# Option:  localhost
# Notes.:  the local IP address of the network interface
# Values:  IP
#
localhost = 127.0.0.1

O arquivo que usei de exemplo acima já vem com o pacote e configura uma ação que usa o ipfw para bloquear os IPs. Traduzi os comentários da seção [Definition] para explicar o que cada entrada define.

A seção [Init] define “variáveis” que podem ser usadas ao longo do arquivo. Nesse exemplo, port e localhost, mas que variáveis definir é por conta do usuário. Lembrando que <ip> vira o IP/hostname casado no grupo <host> dos filtros.

Definindo uma ação

Existem ações predefinidas que bloqueiam por iptables, ipfw, shorewall, TCP wrappers, que avisam por e-mail a cada bloqueio… Mas não existe nenhuma que avise via Gtalk. Vamos criar uma ação que faz isso.

Usarei o programa sendxmpp para definir uma ação que enviará uma mensagem para mim no Gtalk a cada evento. Como configurar o sendxmpp para o Gtalk pode ser visto aqui. Conheci o sendxmpp num post do blog do João Eriberto Mota Filho (@eribertomota).

Vamos à listagem do arquivo action.d/gtalk.local:

[Definition]

actionstart = printf %%b "Hi,\n
              The jail <name> has been started successfully.\n
              Regards,\n
              Fail2Ban"|sendxmpp -t -u <from> -o gmail.com 

actionstop = printf %%b "Hi,\n
             The jail <name> has been stopped.\n
             Regards,\n
             Fail2Ban"|sendxmpp -t -u <from> -o gmail.com 

actioncheck =

actionban = printf %%b "Hi,\n
            The IP <ip> has just been banned by Fail2Ban after
            <failures> attempts against <name>.\n
            Regards,\n
            Fail2Ban"|sendxmpp -t -u <from> -o gmail.com <to>

actionunban =

[Init]

name = default

from =

to =

Essa ação enviará uma mensagem para o usuário definido em to tendo como remetente o usuário definido em from ao iniciar, parar e ao bloquear um IP.

Ativando sua ação

As ações são definidas por jail ou globalmente na seção [DEFAULT] de jail.local. Ações definidas nas jails tem prioridade sobre as definidas globalmente.

Observe o seguinte trecho de jail.local:

#
# ACTIONS
#

# Default banning action (e.g. iptables, iptables-new,
# iptables-multiport, shorewall, etc) It is used to define
# action_* variables. Can be overriden globally or per
# section within jail.local file
banaction = iptables-multiport

# email action. Since 0.8.1 upstream fail2ban uses sendmail
# MTA for the mailing. Change mta configuration parameter to mail
# if you want to revert to conventional 'mail'.
mta = sendmail

# Default protocol
protocol = tcp

#
# Action shortcuts. To be used to define action parameter

# The simplest action to take: ban only
action_ = %(banaction)s[name=%(__name__)s, port="%(port)s", protocol="%(protocol)s]

# ban & send an e-mail with whois report to the destemail.
action_mw = %(banaction)s[name=%(__name__)s, port="%(port)s", protocol="%(protocol)s]
%(mta)s-whois[name=%(__name__)s, dest="%(destemail)s", protocol="%(protocol)s]

# ban & send an e-mail with whois report and relevant log lines
# to the destemail.
action_mwl = %(banaction)s[name=%(__name__)s, port="%(port)s", protocol="%(protocol)s]
%(mta)s-whois-lines[name=%(__name__)s, dest="%(destemail)s", logpath=%(logpath)s]

# Choose default action. To change, just override value of 'action' with the
# interpolation to the chosen action shortcut (e.g. action_mw, action_mwl, etc) in jail.local
# globally (section [DEFAULT]) or per specific section
action = %(action_)s

A variável action define a ação globalmente. As outras variáveis definidas antes (action_mvl, action_mw e action_) são atalhos úteis. Leia os comentários com atenção para entender como essas variáveis interagem.

Repare que mais de uma ação pode ser setada por linha e que cada ação pode receber parâmetros entre colchetes. Esses parâmetros definem os valores das variáveis declaradas na seção [Init]. Os atalhos action_mvl, action_mw e action_ são úteis por já ativarem ações e passarem parâmetros funcionais para tarefas rotineiras como banir e enviar um e-mail com informações úteis.

Para definir nossa ação gtalk globalmente, basta fazer

action = %(action_)s
        gtalk[name=%(__name__)s, from=gmgall, to=gmgall]

e recarregar as configurações do serviço:

# /etc/init.d/fail2ban reload

Funciona!

Screenshot do pidgin mostrando a mensagem enviada pela ação que acabamos de criar

Anúncios

Criando seus próprios filtros no fail2ban – parte 2

Criando seus próprios filtros

Se não existe um filtro pronto para o log que você deseja monitorar em filter.d, será necessário criar seu próprio filtro. Mostrarei como fazer isso através do exemplo que descrevo abaixo:

Cenário do exemplo

Mantenho um wiki moinmoin e desejo bloquear o acesso à ele pelos hosts que tentarem login por mais de 3 vezes sem sucesso. Vamos fazer um filtro para fazer esse bloqueio. O log do wiki é escrito em /var/log/moinmoin.log. Segue trecho desse log:

2011-07-12 15:45:40,447 MoinMoin.Page WARNING The page "MissingPage" could not be found. Check your underlay directory setting.
2011-07-12 15:45:44,002 MoinMoin.auth WARNING moin: performing login action | request from 192.168.0.10
2011-07-12 15:45:44,003 MoinMoin.auth WARNING moin: could not authenticate user u'GuilhermeGall' (not valid) | request from 192.168.0.10
2011-07-12 15:45:44,030 MoinMoin.Page WARNING The page "MissingPage" could not be found. Check your underlay directory setting.
2011-07-12 15:45:47,705 MoinMoin.auth WARNING moin: performing login action | request from 192.168.0.10
2011-07-12 15:45:47,706 MoinMoin.auth WARNING moin: could not authenticate user u'GuilhermeGall' (not valid) | request from 192.168.0.10
2011-07-12 15:45:47,732 MoinMoin.Page WARNING The page "MissingPage" could not be found. Check your underlay directory setting.
2011-07-12 15:55:59,473 MoinMoin.Page WARNING The page "MissingPage" could not be found. Check your underlay directory setting.
2011-07-12 16:08:51,543 MoinMoin.Page WARNING The page "MissingPage" could not be found. Check your underlay directory setting.
2011-07-13 09:09:01,908 MoinMoin.auth WARNING moin: performing login action | request from 192.168.0.7

Não é difícil perceber que as linhas com could not authenticate user u'GuilhermeGall' (not valid) representam as tentativas de login malsucedidas. Se desejamos bloquear os hosts de origem dessas tentativas temos que fazer a regex do filtro casar essas linhas e usar o IP que aparece nelas para executar nossa ação (por default, bloquear via iptables).

Antes de criar nosso filtro, vamos entender a estrutura de um filtro e como desenvolver nossas próprias regexes.

Estrutura de um filtro

Um filtro é simplesmente um arquivo com uma entrada failregex, que define as regexes que casam as linhas que representam as tentativas de login malsucedidas, e uma entrada ignoreregex, que define regexes que casam com linhas que devem ser ignoradas. Outras entradas podem existir, como before que faz um “import” de outro arquivo, mas failregex e ignoreregex são as essenciais e usadas na maioria dos casos.

Se for definir mais de uma regex para failregex ou ignoreregex, coloque uma por linha. Exemplo do arquivo filter.d/apache-auth.conf que já vem no pacote fail2ban:

[Definition]

# Option:  failregex
# Notes.:  regex to match the password failure messages in the logfile. The
#          host must be matched by a group named "host". The tag "<HOST>" can
#          be used for standard IP/hostname matching and is only an alias for
#          (?:::f{4,6}:)?(?P<host>[\w\-.^_]+)
# Values:  TEXT
#
failregex = [[]client <HOST>[]] user .* authentication failure
            [[]client <HOST>[]] user .* not found
            [[]client <HOST>[]] user .* password mismatch

# Option:  ignoreregex
# Notes.:  regex to ignore. If this regex matches, the line is ignored.
# Values:  TEXT
#
ignoreregex =

Conforme pode ser lido nos comentários do arquivo acima, a tag <HOST> deve aparecer dentro da regex na posição onde aparece o IP/hostname do host ofensor. Repare que mais de uma regex foi definida para failregex – uma em cada linha – e que ignoreregex pode ser vazio.

Desenvolvendo suas próprias regexes

Para escrever suas próprias regexes para o fail2ban é preciso ter em mente o seguinte:

  • Em toda linha de uma failregex, a parte que casa com o IP/hostname deve estar envolta pela estrutura (?P<host> ... ). Essa estrutura é uma extensão específica do Python que atribui o nome <host> ao que foi casado pelo grupo. A tag <host> é como você informa ao fail2ban qual host estava tentando logar.
  • Como conveniência, é possível usar <HOST> nas suas regexes, conforme citei no tópico anterior. <HOST> é um alias para (?:::f{4,6}:)?(?P<host>\S+) que casa um IP/hostname dentro de um grupo chamado <host>. Vide item anterior.
  • Nas ações, a tag <ip> será substituída pelo IP do host casado pela tag <host>, por isso sempre deve existir um grupo nomeado <host>.
  • Para que uma linha de um log case com sua failregex, ela deve casar em duas partes: o início da linha tem que casar com um padrão de timestamp e o restante da linha deve casar com a regex definida em failregex. Se sua failregex possui a âncora ^, então a âncora refere-se ao início do restante da linha, após o timestamp.
  • Por último, mas não menos importante, o comando fail2ban-regex permite testar suas regexes antes de criar o filtro. Na realidade, como escrever suas próprias regexes pode envolver alguma – muita! – tentativa e erro no começo, eu diria que esse é o item mais importante. 🙂 Ele pode ser usado de duas maneiras:
fail2ban-regex /path/para/arquivo.log '^regex a ser testada$'

ou

fail2ban-regex 'linha exemplo de log' '^regex a ser testada$'

Definindo o filtro

A regex que casa com as linhas que representam as tentativas de login malsucedidas é:

MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL) moin: could not authenticate user .* \(not valid\) \| request from <HOST>$

Não é do escopo desse artigo ensinar expressões regulares. Tem muito material bom sobre isso por aí, mas resumindo a expressão acima:

  • MoinMoin\.auth casa MoinMoin.auth
  • (DEBUG|INFO|WARNING|ERROR|CRITICAL) casa DEBUG, INFO, WARNING, ERROR ou CRITICAL. Eu poderia ter casado apenas um dos níveis de severidade, mas ainda estou decidindo em qual nível reportarei as mensagens referentes à tentativas de login.
  • moin: could not authenticate user casa moin: could not authenticate user
  • .* casa qualquer caractere em qualquer quantidade
  • \(not valid\) \| request from casa (not valid) | request from
  • <HOST> casa o IP/hostname. É a tal tag indicativa de onde está o IP/hostname que é substituída por (?:::f{4,6}:)?(?P<host>\S+)
  • $ casa o fim da linha

Testando uma linha de exemplo do log com o fail2ban-regex:

$ fail2ban-regex '2011-07-18 14:24:42,687 MoinMoin.auth WARNING moin: could not authenticate user u'UserName' (not valid) | request from 192.168.0.27' 'MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL) moin: could not authenticate user .* \(not valid\) \| request from <HOST>$'

Running tests
=============

Use regex line : MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL)...
Use single line: 2011-07-18 14:24:42,687 MoinMoin.auth WARNING moin...

Results
=======

Failregex
|- Regular expressions:
|  [1] MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL) moin: could not authenticate user .* \(not valid\) \| request from <HOST>$
|
`- Number of matches:
   [1] 1 match(es)

Ignoreregex
|- Regular expressions:
|
`- Number of matches:

Summary
=======

Addresses found:
[1]
    192.168.0.27 (Mon Jul 18 14:24:42 2011)

Date template hits:
0 hit(s): MONTH Day Hour:Minute:Second
0 hit(s): WEEKDAY MONTH Day Hour:Minute:Second Year
0 hit(s): WEEKDAY MONTH Day Hour:Minute:Second
0 hit(s): Year/Month/Day Hour:Minute:Second
0 hit(s): Day/Month/Year Hour:Minute:Second
0 hit(s): Day/Month/Year Hour:Minute:Second
0 hit(s): Day/MONTH/Year:Hour:Minute:Second
0 hit(s): Month/Day/Year:Hour:Minute:Second
2 hit(s): Year-Month-Day Hour:Minute:Second
0 hit(s): Day-MONTH-Year Hour:Minute:Second[.Millisecond]
0 hit(s): Day-Month-Year Hour:Minute:Second
0 hit(s): TAI64N
0 hit(s): Epoch
0 hit(s): ISO 8601
0 hit(s): Hour:Minute:Second
0 hit(s): 

Success, the total number of match is 1

However, look at the above section 'Running tests' which could contain important
information.

Perceba que o comando mostrou a regex e o número de casamentos, além do IP encontrado abaixo de “Addresses found”, indicando que a regex está correta. O número de casamentos presumivelmente seria maior que 1, se o comando fosse executado contra um arquivo de log ao invés de apenas contra uma linha de exemplo.

Vamos supor você tenha cometido um erro, como por exemplo não especificar um grupo chamado host na sua regex:

$ fail2ban-regex '2011-07-18 14:24:42,687 MoinMoin.auth WARNING moin: could not authenticate user u'UserName' (not valid) | request from 192.168.0.27' 'MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL) moin: could not authenticate user .* \(not valid\) \| request from$'

Running tests
=============

Use regex line : MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL)...
Use single line: 2011-07-18 14:24:42,687 MoinMoin.auth WARNING moin...

No 'host' group in 'MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL) moin: could not authenticate user .* \(not valid\) \| request from$'
Cannot remove regular expression. Index 0 is not valid

Results
=======

Failregex
|- Regular expressions:
|  [1] MoinMoin\.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL) moin: could not authenticate user .* \(not valid\) \| request from$
|
`- Number of matches:
   [1] 0 match(es)

Ignoreregex
|- Regular expressions:
|
`- Number of matches:

Summary
=======

Sorry, no match

Look at the above section 'Running tests' which could contain important
information.

Perceba que o fail2ban-regex informa o erro No 'host' group in...

Como já temos a regex funcional, crie um arquivo com o nome do filtro em filter.d:

# cd /etc/fail2ban/
# cat filter.d/moinmoin.conf
[Definition]

# Option:  failregex
# Notes.:  regex to match the password failures messages in the logfile. The
#          host must be matched by a group named "host". The tag "<HOST>" can
#          be used for standard IP/hostname matching and is only an alias for
#          (?:::f{4,6}:)?(?P<host>[\w\-.^_]+)
# Values:  TEXT
#
failregex = MoinMoin.auth (DEBUG|INFO|WARNING|ERROR|CRITICAL) moin: could not authenticate user .* \(not valid\) \| request from <HOST>$

# Option:  ignoreregex
# Notes.:  regex to ignore. If this regex matches, the line is ignored.
# Values:  TEXT
#
ignoreregex =

Crie uma jail que usa o filtro recém-criado em jail.local:

[moinmoin]

enabled = true
port = 80
filter = moinmoin
logpath = /var/log/moinmoin.log
maxretry = 3

A variável filter define o nome do filtro, que é o nome do arquivo criado em filter.d sem a extensão .conf.

Reinicie o fail2ban para ativar a nova jail:

# /etc/init.d/fail2ban restart

É interessante monitorar o log do próprio fail2ban, que por padrão fica em /var/log/fail2ban.log, para verificar se suas alterações foram aplicadas com sucesso:

# tail -f /var/log/fail2ban.log
2011-08-01 09:52:04,994 fail2ban.filter : INFO   Set findtime = 600
2011-08-01 09:52:04,994 fail2ban.actions: INFO   Set banTime = 600
2011-08-01 09:52:04,999 fail2ban.jail   : INFO   Creating new jail 'ssh'
2011-08-01 09:52:04,999 fail2ban.jail   : INFO   Jail 'ssh' uses poller
2011-08-01 09:52:05,000 fail2ban.filter : INFO   Added logfile = /var/log/auth.log
2011-08-01 09:52:05,001 fail2ban.filter : INFO   Set maxRetry = 3
2011-08-01 09:52:05,002 fail2ban.filter : INFO   Set findtime = 600
2011-08-01 09:52:05,002 fail2ban.actions: INFO   Set banTime = 600
2011-08-01 09:52:05,035 fail2ban.jail   : INFO   Jail 'moinmoin' started
2011-08-01 09:52:05,039 fail2ban.jail   : INFO   Jail 'ssh' started

As duas últimas linhas nos mostra as jails iniciadas. A ssh, que já vem configurada, e a moinmoin, que acabamos de configurar.

Introdução ao fail2ban – parte 1

Introdução

O fail2ban é um software que monitora os logs do sistema e em caso de X (sendo X configurável) tentativas de autenticação sem sucesso em diversos serviços toma uma atitude, que pode ser colocar o host ofensor em /etc/hosts.deny, “dropar” seus pacotes via iptables ou qualquer outra ação definida pelo usuário.

Instalação do fail2ban

Em máquinas Debian, a melhor maneira de instalar o fail2ban é via apt-get:

# apt-get update
# apt-get install fail2ban

As configurações default bloqueiam via iptables por 10 minutos os hosts que tentarem sem sucesso login via ssh 6 vezes. O fail2ban cria uma chain com nome no padrão fail2ban-serviço:

# iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
fail2ban-ssh tcp -- anywhere anywhere multiport dports ssh

Chain FORWARD (policy ACCEPT)
target prot opt source destination

Chain OUTPUT (policy ACCEPT)
target prot opt source destination

Chain fail2ban-ssh (1 references)
target prot opt source destination
RETURN all -- anywhere anywhere

Lembrando que todo esse comportamento é configurável. O bloqueio pode ser feito via TCP-wrappers (/etc/hosts.{allow,deny}), e muitos outros serviços são suportados.

Entendendo os arquivos de configuração

Alguns termos usados pelo fail2ban:

  • filter: um filtro define uma regex que casa um padrão correspondente a uma tentativa de login mal sucedido nos arquivos de log;
  • action: uma ação define os comandos que são executados nos mais diversos eventos, como bloquear um host (ex: bloquear via TCP-wrappers ou iptables), iniciar o fail2ban (ex: criar as chains no firewall) e parar o fail2ban (ex: remover as chains criadas ao iniciar);
  •  jail: uma jail é uma combinação de um filtro com uma ou várias actions. O fail2ban pode lidar com diversas jails ao mesmo tempo.

Uma jail é como dar a seguinte ordem ao fail2ban: “bloqueie via iptables por 10 minutos os hosts que aparecerem 3 vezes em /var/log/auth.log com falha de autenticação”. Nesse exemplo, bloquear via iptables é uma ação e a regex que casa a falha de autenticação é o filtro.

Os arquivos de configuração ficam em /etc/fail2ban.

# cd /etc/fail2ban
# ls -l
total 17
drwxr-xr-x 2 root root 1024 Jun 28 14:30 action.d
-rw-r--r-- 1 root root 859 Feb 27 2008 fail2ban.conf
drwxr-xr-x 2 root root 1024 Jun 28 14:30 filter.d
-rw-r--r-- 1 root root 6683 Jun 28 2010 jail.conf

Os diretórios action.d e filter.d mantêm as configurações de ações e filtros, respectivamente. Os que vêm com o pacote já devem atender à maior parte das necessidades. O arquivo fail2ban.conf contém configurações gerais do daemon fail2ban-server, como path do arquivo de log do fail2ban, path do arquivo socket usado para o cliente de linha de comando se comunicar com o daemon etc.

O arquivo jail.conf é o mais importante, já que configura as jails. No tópico abaixo será explicado como modificar uma jail.

Mudando as configurações default

Na maior parte do tempo, os filtros e ações que vêm com o pacote atendem às necessidades, bastando usá-los nas suas jails. A única jail que vem ativada por padrão é a que bloqueia os hosts que tentarem logar mais de 6 vezes via SSH. Como exemplo, será mostrado como alterar o número de tentativas antes do bloqueio de 6 para 3.

Primeiramente crie uma cópia do arquivo jail.conf chamada jail.local e faça as suas modificações nesse arquivo:

# cp jail.conf jail.local
# vim jail.local

O trecho abaixo configura uma jail chamada ssh

[ssh]

enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 6

Mude para:

[ssh]

enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3

Faça o fail2ban reler os arquivos de configuração:

# /etc/init.d/fail2ban reload
Reloading authentication failure monitor: fail2ban.

Não faça as alterações diretamente em jail.conf. Apesar de funcionar, o arquivo pode ser sobrescrito por atualizações no pacote fail2ban. O fail2ban aplica as regras primeiro do jail.conf depois do jail.local.

Daemon em Python

Já usei mais de uma vez o código abaixo para criar pequenos daemons para Linux. Achei aqui e como ele foi liberado em domínio público, use como melhor convir.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys, os, time, atexit
from signal import SIGTERM 

class Daemon:
    """
    A generic daemon class.
    
    Usage: subclass the Daemon class and override the run() method
    """
    def __init__(self, pidfile, stdin='/dev/null', stdout='/dev/null', stderr='/dev/null'):
        self.stdin = stdin
        self.stdout = stdout
        self.stderr = stderr
        self.pidfile = pidfile
    
    def daemonize(self):
        """
        do the UNIX double-fork magic, see Stevens' "Advanced 
        Programming in the UNIX Environment" for details (ISBN 0201563177)
        http://www.erlenstar.demon.co.uk/unix/faq_2.html#SEC16
        """
        try: 
            pid = os.fork() 
            if pid > 0:
                # exit first parent
                sys.exit(0) 
        except OSError, e: 
            sys.stderr.write("fork #1 failed: %d (%s)\n" % (e.errno, e.strerror))
            sys.exit(1)
    
        # decouple from parent environment
        os.chdir("/") 
        os.setsid() 
        os.umask(0) 
    
        # do second fork
        try: 
            pid = os.fork() 
            if pid > 0:
                # exit from second parent
                sys.exit(0) 
        except OSError, e: 
            sys.stderr.write("fork #2 failed: %d (%s)\n" % (e.errno, e.strerror))
            sys.exit(1) 
    
        # redirect standard file descriptors
        sys.stdout.flush()
        sys.stderr.flush()
        si = file(self.stdin, 'r')
        so = file(self.stdout, 'a+')
        se = file(self.stderr, 'a+', 0)
        os.dup2(si.fileno(), sys.stdin.fileno())
        os.dup2(so.fileno(), sys.stdout.fileno())
        os.dup2(se.fileno(), sys.stderr.fileno())
    
        # write pidfile
        atexit.register(self.delpid)
        pid = str(os.getpid())
        file(self.pidfile,'w+').write("%s\n" % pid)
    
    def delpid(self):
        os.remove(self.pidfile)

    def start(self):
        """
        Start the daemon
        """
        # Check for a pidfile to see if the daemon already runs
        try:
            pf = file(self.pidfile,'r')
            pid = int(pf.read().strip())
            pf.close()
        except IOError:
            pid = None
    
        if pid:
            message = "pidfile %s already exist. Daemon already running?\n"
            sys.stderr.write(message % self.pidfile)
            sys.exit(1)
        
        # Start the daemon
        self.daemonize()
        self.run()

    def stop(self):
        """
        Stop the daemon
        """
        # Get the pid from the pidfile
        try:
            pf = file(self.pidfile,'r')
            pid = int(pf.read().strip())
            pf.close()
        except IOError:
            pid = None
    
        if not pid:
            message = "pidfile %s does not exist. Daemon not running?\n"
            sys.stderr.write(message % self.pidfile)
            return # not an error in a restart

        # Try killing the daemon process    
        try:
            while 1:
                os.kill(pid, SIGTERM)
                time.sleep(0.1)
        except OSError, err:
            err = str(err)
            if err.find("No such process") > 0:
                if os.path.exists(self.pidfile):
                    os.remove(self.pidfile)
            else:
                print str(err)
                sys.exit(1)

    def restart(self):
        """
        Restart the daemon
        """
        self.stop()
        self.start()

    def run(self):
        """
        You should override this method when you subclass Daemon. It will be called after the process has been
        daemonized by start() or restart().
        """

Resolvi adicionar a funcionalidade de verificar o status do processo também, daí adicionei o seguinte método à classe acima:

def status(self):
    try:
        pf = file(self.pidfile, 'r')
        pid = int(pf.read().strip())
        pf.close()
    except IOError:
        pid = None

    try:
        procfile = file("/proc/%d/status" % pid, 'r')
        procfile.close()
    except IOError:
        sys.stdout.write("there is not a process with the PID specified in %s\n" % self.pidfile)
        sys.exit(0)
    except TypeError:
        sys.stdout.write("pidfile %s does not exist\n" % self.pidfile)
        sys.exit(0)

    sys.stdout.write("the process with the PID %d is running\n" % pid)

Não ficou muito bonito, mas funciona. Lembrando o que já está comentado no código original: para usar a classe acima para “daemonizar” seu programa, crie uma classe filha de Daemon e sobrescreva o método run(). Ele será chamado após o processo de “daemonização” por start() e restart().

Sequência look and say em Python

Tenho brincado ultimamente com os desafios do Python Challenge. São bem interessantes para quem quer aprender Python na prática. Estou resolvendo o nível 11 e já precisei processar imagens, descompactar dados comprimidos com zip e bz2, serializar objetos, acessar recursos via URL, usar expressões regulares e algumas tarefas que não exigiam necessariamente um módulo.

O último nível que resolvi tinha como resposta o comprimento de um elemento específico de uma sequência de inteiros conhecida como look and say (olhe e descreva). Achei diversas implementações da geração da sequência pela web, nenhuma delas me pareceu pythonica ou legível o suficiente. Resolvi juntar algumas das idéias que vi nessas implementações com os conhecimentos que obtive recentemente com a leitura de um material sobre programação funcional em Python e criei uma função que retorna uma lista com os elementos da sequência. A minha solução não é a mais eficiente possível (essa página contém uma implementação alegadamente otimizada para velocidade), mas acho que é elegante e mostra alguns aspectos interessantes da linguagem. Segue o código:

def look_and_say(first, elements):
    from itertools import groupby
    seq = [str(first)]

    def say(number):
        ret = []
        for k,g in groupby(number):
            ret.append( str(len(list(g))) + k )
        return ''.join(ret)

    for i in xrange(elements):
        seq.append(say(seq[-1]))
    return seq

O argumento first de look_and_say recebe o primeiro elemento da lista e elements quantos elementos depois do primeiro devem ser gerados. Exemplo de uso:

>>> look_and_say(1,10)
['1', '11', '21', '1211', '111221', '312211', '13112221', '1113213211', '31131211131221', '13211311123113112211', '11131221133112132113212221']
>>> look_and_say(55,4)
['55', '25', '1215', '11121115', '31123115']

Dentre os aspectos que gostaria de destacar estão a possibilidade de importar partes de módulos e definir funções dentro de funções, o uso de listas para concatenar strings com eficiência e o uso de itertools.groupby que faz um agrupamento semelhante ao do comando uniq do Unix, juntando elementos iguais consecutivos em um iterator.

Para quem quiser uma abordagem matemática da sequência, o Wolfram Mathworld tem uma página sobre ela.

Metaclasses em Python

Introdução

Li dois textos interessantes no Kodumaro recentemente: um sobre propriedades e acessores e outro sobre o design pattern singleton. Ambos citavam as metaclasses, um conceito novo para mim e, pelo que andei conversando, novo para muitos de meus colegas de faculdade e trabalho. O seguinte texto é resultado de minha tentativa de explicar o que são metaclasses de uma forma simples de ser assimilada por pessoas que começaram a estudar Python há pouco tempo como eu e portanto não pode ser considerado como um guia definitivo e sem erros sobre o assunto. Qualquer sugestão ou comentário é bem vindo.

Revisando Orientação a Objetos e definindo metaclasses

Em uma linguagem de programação orientada a objetos, você pode definir classes que combinam dados (atributos) e métodos (comportamentos) que atuam sobre esses dados. Pode-se afirmar que ao definir uma classe se está definindo um domínio.

As classes geralmente atuam como modelos para a criação de instâncias de classe, que apesar de compartilharem uma mesma “forma”, possuem dados diferentes. Uma instância real e outra peso de uma mesma classe Moeda podem possuir um atributo cotacao, mas cada instância terá seu próprio valor para esse atributo.

Em Python, tudo é objeto e tudo tem um tipo. Não existe diferença real entre uma classe e um tipo, especialmente depois da unificação de classes e tipos iniciada no Python 2.2. Ao definir uma classe, portanto, o programador está criando um novo tipo de dados. Como tudo é objeto, as classes também o são e possuem uma classe, um tipo. A classe de uma classe é chamada de metaclasse. O tipo default de uma classe é type.

  >>> class Moeda(object):
  ...    pass
  ...
  >>> type(Moeda)
  <type 'type'>
  >>> Moeda
  <class '__main__.Moeda'>

Ao chamar type() com apenas um argumento, obtém-se o tipo do objeto. Observe que o tipo da classe Moeda é type. Afirmar que o tipo de uma classe é type é o mesmo que afirmar que a sua metaclasse é type. O exemplo acima comprovou que as classes realmente são um objeto com um tipo.

Os conceitos apresentados até aqui são um tanto abstratos para quem aprendeu orientação a objetos em linguagens como Java e C# e para os novatos em Python. No tópico seguinte, apresento a implementação de uma classe e de uma metaclasse com dicionários, como se Python não tivesse suporte sintático para OO. Apesar de ninguém querer programar assim na prática o exemplo é extremamente útil para entender como os objetos funcionam em Python.

Implementando classes com dicionários

O código abaixo foi originalmente postado no blog de Pedro Werneck em um post que explicava o porquê do self explícito. Apesar de explicar metaclasses não ser o objetivo principal do texto, achei bastante útil para entender como o suporte a orientação a objetos foi implementado em Python. Isso acaba levando ao entendimento de vários conceitos interessantes (entre eles metaclasses) da linguagem e a uma apreciação ainda maior de sua simplicidade e consistência.

No post original, o código evoluiu aos poucos até chegar ao ponto em que está aqui. Para um entendimento pleno, leia o post original.

A primeira parte define uma função newClass que recebe um nome de classe e um dicionário com seus atributos e retorna um dicionário que faz o papel das “classes” desse programa.

  # precisaremos usar isso logo adiante...
  from functools import partial
  
  ### Agora já podemos pensar nela como a classe 'Class'
  def newClass(nome, atributos):
      cls = {'nome':nome} # cria o dicionário para a classe somente com seu nome
      for k, v in atributos.items():
          # aqui se um método for o newNome, ele tem de receber a mesma
          # alteração e passar a receber 'cls' como primeiro argumento
          if k == 'new'+nome:
              v = partial(v, cls)
          # e atribuímos tudo normalmente
          cls[k] = v
      return cls

Em seguida, é definido uma “classe” Pessoa com os atributos nome, nascimento e um método idade.

  ### Classe Pessoa
  
  # construtor, corresponde ao __new__ de Python
  def newPessoa(cls, nome, nascimento): # agora a classe mesmo vem aqui
      inst = {} # a nova instância
      inst['classe'] = cls # a instância tem de saber de que classe é, e
                           # agora pode saber dinamicamente
  
      # Agora a instância vai criar os métodos embrulhando as chamadas
      # para as funções originais em uma nova, já se incluindo nela
      for k, v in cls.items():
          # Se for função...
          if callable(v):
              # é, então vamos embrulhar...
              metodo = partial(v, inst)
              inst[k] = metodo
      inst['init'+cls['nome']](nome, nascimento)
      return inst
  
  # inicializador, corresponde ao __init__ de Python
  def initPessoa(inst, nome, nascimento):
      inst['nome'] = nome
      inst['nascimento'] = nascimento
      # assim como fazemos normalmente no __init__, aqui não precisamos
      # nos preocupar
  
  def idade(inst, hoje):
      hd, hm, ha = hoje
      nd, nm, na = inst['nascimento']
      x = ha - na
      return x

Com as funções que se tornarão os métodos da classe Pessoa definidos, falta a última parte da definição da classe que é criar o objeto que representa a classe. Esse objeto é retornado pela função newClass definida no início do código.

  # e agora initPessoa() tem de entrar aqui também
  Pessoa = newClass('Pessoa', {'newPessoa':newPessoa,
                               'initPessoa':initPessoa,
                               'idade':idade})
  ### Fim da definição de classe

A seguir, cria-se duas instâncias de Pessoa para testar a implementação.

  hank = Pessoa['newPessoa']('Hank Moody', (8, 11, 1967))
  print hank['idade']((6, 11, 2008))
  
  fante = Pessoa['newPessoa']('John Fante', (8, 4, 1909))
  print fante['idade']((6, 11, 2008))

Nesse exemplo, a função newClass faz o papel de uma metaclasse, pois é ela que é chamada para criar a classe. É interessante notar que não existe diferença real entre o funcionamento de newClass e newPessoa. Ambas são funções que retornam instâncias, as instâncias retornadas por newClass são “classes” e as instâncias retornadas por newPessoa são objetos do tipo Pessoa.

O autor termina o texto usando as funções de inicialização e cálculo de idade com a metaclasse padrão de Python, type. Ela recebe uma string que será o nome da nova classe, uma tupla de classes base e um dicionário representando o namespace da classe. Esse dicionário contém o que você normalmente define como o corpo de uma instrução class. A única coisa que tem que mudar é o acesso aos atributos, que antes estavam sendo feitos como chaves de dicionários e agora devem usar a sintaxe de atributos normais.

  #-*- coding: utf-8 -*-
  def initPessoa(inst, nome, nascimento):
          inst.nome = nome
          inst.nascimento = nascimento
  
  def idade(inst, hoje):
          hd, hm, ha = hoje
          nd, nm, na = inst.nascimento
          x = ha - na
          return x
  
  Pessoa = type('Pessoa', (), {'__init__':initPessoa,
                                'idade':idade})
  print "Representação de Pessoa como string: ",Pessoa
  print "Tipo de Pessoa (sua metaclasse): ",type(Pessoa)
  print
  print "Imprimindo a idade de uma instância de Pessoa para teste: "
  hank = Pessoa('Hank Moody', (8, 11, 1967))
  print hank.idade((6, 11, 2008))

Resumindo:


  • Em Python tudo é objeto, inclusive classes e instâncias. Não existe diferença real entre elas.
  • Criar uma classe é criar um novo tipo.
  • A classe de uma classe é chamada de metaclasse. As instâncias de metaclasses são classes.
  • A metaclasse default é type.
  • Não existe diferença real entre métodos e funções. Os métodos são apenas funções com namespaces específicos (suas classes).

Criando e definindo metaclasses

Para definir sua própria metaclasse, basta criar uma classe que herda da metaclasse type. O método inicializador recebe os mesmos argumentos que o método inicializador de type: uma string que será o nome da classe, uma tupla de classes bases e um dicionário representando o namespace da classe.

Segue um exemplo ligeiramente modificado de um artigo do IBM developerWorks. Primeiramente, vamos definir uma nova metaclasse:

  >>> class ChattyType(type):
  ...    def __new__(cls, name, bases, dct):
  ...       print "Alocando memória para classe", name
  ...       dct['usa_metaclasse']=True
  ...       return type.__new__(cls, name, bases, dct)
  ...    def __init__(cls, name, bases, dct):
  ...       print "Inicializando (configurando) classe", name
  ...       super(ChattyType, cls).__init__(name, bases, dct)
  ...

Essa metaclasse sobrescreve o método __new__ que efetivamente é o construtor e retorna uma nova instância de classe (nesse exemplo específico, a instância retornada será uma classe por estarmos escrevendo uma metaclasse) e o método __init__ que é o inicializador da instância e não retorna nada. É tentador chamar o __init__ de construtor, mas o trabalho de criação de instâncias é do __new__.

O código acima criou um novo tipo que imprime uma mensagem quando está sendo criado e outra quando está sendo inicializado. Definiu-se também que todas as instâncias do novo tipo terão um atributo usa_metaclasse com o valor inicial True. Essa mudança foi feita adicionando uma chave ao dicionário que representa o namespace da classe (dct).

Com a metaclasse definida, já é possível criar classes a partir dela chamando-a como chama-se type:

  >>> X=ChattyType('X', (object,), {})
  Alocando memória para classe X
  Inicializando (configurando) classe X

Instanciar classes manualmente como feito acima, não é um procedimento muito comum. Segue como Python determina a metaclasse de uma determinada classe:


  1. Se o dicionário que representa o namespace da classe contiver uma chave __metaclass__, o seu valor é usado.
  2. Senão, se existe pelo menos uma classe base, sua metaclasse é usada.
  3. Senão, se existe uma variável global __metaclass__, seu valor é usado.
  4. Senão, types.ClassType é usado.

A criação da classe X feita no exemplo acima comumente seria feita assim:

  >>> class X(object):
  ...    __metaclass__=ChattyType
  ...
  Alocando memória para classe X
  Inicializando (configurando) classe X

A instanciação de ChattyType, feita explicitamente no primeiro exemplo, agora é feita pelo interpretador Python no final da instrução class. Note que a instrução class é simplesmente açúcar sintático para a instanciação de classes através de suas metaclasses. Os programadores que não fazem uso de metaclasses acabam não percebendo isso, já que simplesmente criam suas classes usando class e Python simplesmente as instancia chamando type.

Continuando o exemplo, podemos confirmar que o tipo da classe é a metaclasse:

  >>> type(X)
  <class '__main__.ChattyType'>
  >>> y=X()
  >>> type(y)
  <class '__main__.X'>
  >>> type(X) == type(type(y))
  True

Para concluir, segue algumas dicas na hora de usar metaclasses:


  • A modificação do dicionário que representa o namespace da classe deve ser feita em __new__, como no exemplo. Se na definição de ChattyType, dct[‘usa_metaclasse’]=True fosse executado em __init__, a alteração não teria efeito. Considere o uso de setattr nesses casos.
  • Sempre herde de type.
  • __new__ é o construtor e deve retornar uma nova instância.
  • Erros em metaclasses costumam aparecer durante imports e definições de classes que usem metaclasses diferentes de type.

Alguns exemplos

Metaclasses são usadas na maior parte do tempo para alterar o comportamento de criação e inicialização de classes. Na maioria dos casos, os usuários finais são outros programadores. Frameworks e ferramentas de mapeamento objeto-relacional são exemplos de softwares que usam metaclasses.

Alguns usos para as metaclasses:

O importante é que a classe só é realmente criada na chamada final a type na metaclasse. Você é livre para modificar o dicionário de atributos de acordo com as suas necessidades antes disso, portanto existem muito mais possibilidades de uso para as metaclasses do que as apresentadas aqui.

Here strings, redirecionamentos e o builtin time

Estou fazendo um comparativo entre gzip e bzip2 em seus diferentes modos de compressão (-1 a -9) e para automatizar o processo fiz um script bash que usa alguns recursos bastante interessantes, mas às vezes negligenciados, por isso vou falar sobre eles aqui.

O script basicamente compacta um determinado arquivo com o bzip2 e o gzip, cada um deles usando todos os modos de compressão, mede o tempo que o compactador ficou na CPU e escreve os resultados em um arquivo de saída. O comando para compactar o arquivo é montado dinamicamente usando dois loops aninhados. Um deles, o mais externo, itera sobre a lista de modos de compressão (-1 a -9) e o outro, o mais interno, itera sobre os compactadores. No final, a linha que realmente faz a compactação e mede o tempo gasto é

TIMEC=$(bc <<< $({ time eval "$CMDC"; } 2>&1 ))

Onde TIMEC recebe o tempo de CPU gasto pelo comando de compactação e $CMDC contém o comando de compactação. Um valor que essa variável pode assumir durante a execução é gzip -c -2 arquivo>arquivo.gz, por exemplo.

Feita essa introdução, o primeiro recurso interessante que eu gostaria de apresentar é o here strings. O funcionamento dele é simples. Dado um comando como:

bc <<< '1+1'

a string 1+1 será usada para alimentar a entrada padrão de bc. Isso substitui a forma tradicional

echo '1+1' | bc

que faz a mesma coisa, mas força um fork para isso, sendo mais ineficiente.

Na linha do script que citei no início do post, o bc vai receber o tempo gasto em modo usuário e o tempo gasto em modo kernel separados por um sinal de adição, fazendo portanto TIMEC receber a soma desses valores. Aqui chegamos no segundo recurso que gostaria de citar nesse post. O builtin time do bash (não confundir com o comando externo time), pode ter sua saída formatada através do conteúdo da variável TIMEFORMAT. Para fazer a saída do time ficar no formato de uma soma, simplesmente atribuí o valor %U+%S à TIMEFORMAT. O %U representa o tempo gasto em modo de usuário e o %S o tempo gasto em modo kernel.

Exemplo:

$ time df
Sist. Arq.           1K-blocos      Usad Dispon.   Uso% Montado em
/dev/hdc3             37491624  32393072   3194048  92% /
tmpfs                   255180         0    255180   0% /dev/shm

real    0m0.061s
user    0m0.020s
sys     0m0.016s
$ TIMEFORMAT=%U+%S # Formatando a saída de time
$ time df
Sist. Arq.           1K-blocos      Usad Dispon.   Uso% Montado em
/dev/hdc3             37491624  32393072   3194048  92% /
tmpfs                   255180         0    255180   0% /dev/shm
0.020+0.008

Com isso, só fica faltando uma última coisa para mostrar: como redirecionar a saída do builtin time. A primeira coisa importante a ter em mente, é que os tempos medidos em si são jogados na stderr, enquanto que a stdout é usada para a saída do comando cujo tempo de execução é medido. Dito isso uma primeira tentativa de redirecionar a saída do time para um arquivo por exemplo, seria fazer simplesmente fazer algo como time comando 2> arquivo, mas isso não funciona. O que é redirecionado para arquivo nesse caso é a saída de erros de comando, não a do time, que continua imprimindo na tela. Exemplo:

$ time ls naoexiste 2>saida_erros

real    0m0.041s
user    0m0.036s
sys     0m0.004s
$ cat saida_erros
ls: impossível acessar naoexiste: Arquivo ou diretório não encontrado

A saída para isso é executar o time dentro de um bloco (em uma subshell também funciona, mas é ineficiente) e redirecionar a saída de erros desse bloco. Exemplo:

$ { time ls naoexiste 2>/dev/null; } 2>saida_erros
$ cat saida_erros

real    0m0.056s
user    0m0.028s
sys     0m0.004s

Perceba que dentro do bloco eu redirecionei a saída de erros de ls naoexiste para /dev/null, para que ela não se misturasse com a saída do time.

Com isso já temos toda a teoria para entender a linha que citei no início do post. Vamos desmembrá-la e revisar o que apresentei nesse post.

Revisando

Na linha

TIMEC=$(bc <<< $({ time eval "$CMDC"; } 2>&1 ))

TIMEC vai receber a soma, feita pela calculadora bc, dos tempos calculados pelo builtin time do bash. A saída desse comando foi formatada de acordo com a variável TIMEFORMAT para formar uma string com uma soma do tempo gasto em modo kernel com o tempo gasto em modo de usuário (%U+%S).

Dentro da subshell cujo resultado alimenta a entrada padrão do bc o time precisou ser executado dentro de um bloco, para ser possível capturar o seu resultado, que vai para stderr. A stdout é usada para a saída do comando cujo tempo de execução é medido pelo time. A stderr do bloco inteiro foi conectado a stdout, sendo assim devidamente retornada pela subshell e usada para alimentar a entrada padrão da bc via here strings.

É isso. Quaisquer comentários são bem vindos.

Referências

BashFaq: How can I redirect the output of ‘time’ to a variable or file?
Advanced Bash-Scripting Guide: I/O Redirection
Papo de Botequim: Tira Gosto
Manpage do Bash
Redirecting output of the bash keyword time