Mapeo de puertos en el "router" a través de Python y UPnP

Las redes domésticas salen a Internet a través de un NAT. Esto es necesario en un mundo IPv4 pero mucho me temo que seguirá siendo la (triste) norma en una red IPv6. Es una lástima porque es una limitación importante para desplegar servicios Internet en nuestra casa, algo perfectamente posible con equipos potentes pero de bajo consumo y con la creciente disponibilidad de líneas Internet con una capacidad de envío sustancial.

Tema de otro artículo :-).

Una de las consecuencias de salir a través del NAT de nuestro router doméstico es que no podemos prestar servicios en Internet sin configurar manualmente el router. Es el famoso mapeo de puertos. Pero modificar la configuración del router no es trivial, podemos no tener la clave de acceso siquiera y, desde luego, no es una opción para un producto "empaquetable", que es lo que tengo en mente en este caso concreto.

Existe otra opción en el entorno doméstico, el llamado Universal Plug and Play. UPnP es un estándar muy complejo y barroco, casi espeluznante. No quiero mencionar siquiera sus problemas de seguridad, que me pongo malo y hoy necesito un respiro.

Es lo que hay. Vivimos en un mundo que... en fin...

En cualquier caso en este momento Universal Plug and Play nos viene muy bien, especialmente el perfil Internet Gateway Device Protocol. En pocas palabras basta con localizar el router de nuestra red doméstica y pedirle amablemente que abra un puerto en la IP externa y reenvíe a una IP y un puerto de la red interna las conexiones entrantes que vayan llegando.

Pegarse con UPnP directamente hace que quieras arrancarte los ojos. Afortunadamente hay quien se lo ha currado y tenemos librerías ya creadas que nos hacen la vida más fácil.

Estoy usando el programa que sigue para desarrollar un producto "empaquetado" concreto. Tendrás que adaptarlo a tus necesidades lo que, muy posiblemente, te permita eliminar muchas líneas y simplificar el código. Mis necesidades son un tanto especiales.

Necesitarás instalar dos librerías Python, ambas disponibles a través de PYPI, así que puedes hacer algo del estilo:

$ sudo pip install miniupnpc
$ sudo pip install netifaces

El código en sí está pensado para ser lanzado en el arranque del equipo, pero la "demonización" del mismo es responsabilidad de otro proceso que no muestro aquí.

Vayamos al grano:

 #!/usr/bin/python

 # (c) 2015 jcea@jcea.es - http://www.jcea.es/
 # Código liberado como Dominio Público.
 # Haz con él lo que quieras.

 import time, os
 import logging
 import logging.handlers

 import netifaces

 # http://miniupnp.tuxfamily.org/
 # https://pypi.python.org/pypi/miniupnpc
 import miniupnpc

 def get_TOR_port() :
   with open('/etc/tor/torrc', 'r') as f :
     for line in f :
       line = line.split()
       if len(line) and (line[0].lower() == 'orport') :
           return int(line[1])

 def do_map(TOR_port, logger) :
   # https://github.com/miniupnp/miniupnp/blob/master/miniupnpc/testupnpigd.py
   u = miniupnpc.UPnP()
   u.discoverdelay = 1000  # 1000ms
   u.discover()
   u.selectigd()

   done = False
   legacy_addr = u.lanaddr
   while True :
     current_addr = netifaces.ifaddresses('eth0')[netifaces.AF_INET][0]['addr']
     if legacy_addr != current_addr :
       logger.warning('Hemos cambiado de direccion IP: %s -> %s' %(legacy_addr, current_addr))
       if u.deleteportmapping(TOR_port, 'TCP') :
         logger.info('Eliminamos el mapeo antiguo OK')
       else :
         logger.info('No conseguimos eliminar el mapeo antiguo!!!!')
       break
     if u.addportmapping(TOR_port, 'TCP', current_addr, TOR_port,
       'TOR - '+time.ctime(), '') :
       if not done :
         done = True
         logger.info('Mapeo completado con exito')
     else :
       logger.warning('No conseguimos hacer el mapeo')
       break

     time.sleep(60)

 def keep_map_alive(TOR_port, logger) :
   while True :
     try :
       logger.info('Intentamos mapear el puerto %d en el router' %TOR_port)
       do_map(TOR_port, logger=logger)
     except Exception :
       logger.warning('Ha ocurrido una excepcion!')
     time.sleep(60)

 def listMaps() :
   u = miniupnpc.UPnP()
   u.discoverdelay = 1000  # 1000ms
   u.discover()
   u.selectigd()
   vv = []
   for i in xrange(999999) :
     v=u.getgenericportmapping(i)
     if v is None :
       return vv
     vv.append(v)

 if __name__ == '__main__' :
   logger = logging.getLogger('JCEA-upnp')
   handler = logging.handlers.SysLogHandler(address = '/dev/log')
   handler.setFormatter(logging.Formatter('JCEA-upnp[%d]: ' %os.getpid() + '%(message)s'))
   logger.addHandler(handler)
   handler = logging.StreamHandler()
   handler.setFormatter(logging.Formatter('%(asctime)s: %(message)s'))
   logger.addHandler(handler)
   logger.setLevel(logging.INFO)
   logger.info('Lanzamiento de JCEA Universal Plug & Play')

   TOR_port = get_TOR_port()
   keep_map_alive(TOR_port, logger=logger)

La línea 1 llama directamente a Python 2.7. No usamos Python 3, que debería ser el camino a seguir en desarrollos nuevos, porque no está instalado en el dispositivo objetivo del proyecto. Quiero reducir los requisitos al mínimo y Python 2.7 ya viene instalado por defecto. Asimismo indico el path directamente en vez de emplear el típico #!/usr/bin/env python2 porque en el arranque del equipo las variables de entorno están a medio definir.

Las líneas 17-22 buscan el puerto que debemos hacer accesible en el router. Da una pista clara de la naturaleza del proyecto en el que estoy trabajando :-).

Las líneas 26-29 inicializan la librería UPnP y localizan un router con capacidad IGD que nos dará el servicio que nos interesa. En un entorno doméstico típico solo habrá un router, pero tu caso puede ser diferente.

Las líneas 33-49 crean el mapeo que queremos. Lo refrescamos una vez por minuto, aunque el router que estoy probando mantiene el mapeo de puertos durante 72 horas (un problema de seguridad en sí mismo). Lo hago así porque el router se podría reiniciar en cualquier momento, algo bastante habitual en mi entorno. Una complicación extra es que nos preocupamos de que el dispositivo cambie de IP durante el funcionamiento del sistema. Si es así, borramos el mapeo previo y solicitamos uno nuevo.

Si hay cualquier tipo de problema, las líneas 53-60 se encargan de reintentar la operación las veces que haga falta.

Por último, las líneas 76-83 se encargan de inicializar el sistema de logging para enviar los logs tanto al syslog como al propio terminal [1].

[1] En este proyecto la salida al terminal se manda directamente a /dev/null, salvo cuando se está haciendo debugging.

El código es mejorable. Podría "demonizarse" él mismo, sin depender de un proceso externo y debería eliminar el mapeo cuando el programa muere por cualquier causa. Puede mandarte un email o un SMS si no consigue hacer lo que quiere hacer. Además el programa está muy pegado a idiosincrasias de Linux, como la interfaz eth0 o el /dev/log. Tómalo como un punto de partida y hazme llegar tus mejoras.