upnp: fix hangs with routers that don't have any free port
[re6stnet.git] / re6st / upnpigd.py
1 from functools import wraps
2 import logging, random, socket, time
3 import miniupnpc
4
5
6 class UPnPException(Exception):
7 pass
8
9
10 class Forwarder(object):
11 """
12 External port is chosen randomly between 32768 & 49151 included.
13 """
14
15 next_refresh = 0
16 _next_retry = -1
17 _lcg_n = 0
18
19 @classmethod
20 def _getExternalPort(cls):
21 # Since _refresh() does not test all ports in a row, we prefer to
22 # return random ports to maximize the chance to find a free port.
23 # A linear congruential generator should be random enough, without
24 # wasting memory/cpu by keeping a full 'shuffle'd list of integers.
25 n = cls._lcg_n
26 if not n:
27 cls._lcg_a = 1 + 4 * random.randrange(0, 2048)
28 cls._lcg_c = 1 + 2 * random.randrange(0, 4096)
29 n = cls._lcg_n = (n * cls._lcg_a + cls._lcg_c) % 8192
30 return 32768 + n
31
32 def __init__(self, description):
33 self._description = description
34 self._u = miniupnpc.UPnP()
35 self._u.discoverdelay = 200
36 self._rules = []
37
38 def __getattr__(self, name):
39 wrapped = getattr(self._u, name)
40 def wrapper(*args, **kw):
41 try:
42 return wrapped(*args, **kw)
43 except Exception, e:
44 raise UPnPException(str(e))
45 return wraps(wrapped)(wrapper)
46
47 def select(self, r, w, t):
48 t.append((self.next_refresh, self.refresh))
49
50 def checkExternalIp(self, ip=None):
51 if ip:
52 try:
53 socket.inet_aton(ip)
54 except socket.error:
55 ip = None
56 else:
57 ip = self.refresh()
58 # If port is None, we assume we're not NATed.
59 return socket.AF_INET, [(ip, str(port or local), proto)
60 for local, proto, port in self._rules] if ip else ()
61
62 def addRule(self, local_port, proto):
63 self._rules.append([local_port, proto, None])
64
65 def refresh(self):
66 if self._next_retry:
67 if time.time() < self._next_retry:
68 return
69 self._next_retry = 0
70 else:
71 try:
72 return self._refresh()
73 except UPnPException, e:
74 logging.debug("UPnP failure", exc_info=1)
75 self.clear()
76 try:
77 self.discover()
78 self.selectigd()
79 return self._refresh()
80 except UPnPException, e:
81 self.next_refresh = self._next_retry = time.time() + 60
82 logging.info(str(e))
83 self.clear()
84
85 def _refresh(self):
86 t = time.time()
87 force = self.next_refresh < t
88 if force:
89 self.next_refresh = t + 500
90 logging.debug('Refreshing port forwarding')
91 ip = self.externalipaddress()
92 lanaddr = self._u.lanaddr
93 # It's too expensive (CPU/network) to try a full range every minute
94 # when the router really has no free port. Or with slow routers, it
95 # can take more than 15 minutes. So let's use some saner limits:
96 t += 1
97 retry = 15
98 for r in self._rules:
99 local, proto, port = r
100 if port and not force:
101 continue
102 desc = '%s (%u/%s)' % (self._description, local, proto)
103 args = proto.upper(), lanaddr, local, desc, ''
104 while True:
105 if port is None:
106 if not retry or t < time.time():
107 raise UPnPException('No free port to redirect %s'
108 % desc)
109 retry -= 1
110 port = self._getExternalPort()
111 try:
112 self.addportmapping(port, *args)
113 break
114 except UPnPException, e:
115 if str(e) != 'ConflictInMappingEntry':
116 raise
117 port = None
118 if r[2] != port:
119 logging.debug('%s forwarded from %s:%u', desc, ip, port)
120 r[2] = port
121 return ip
122
123 def clear(self):
124 for r in self._rules:
125 port = r[2]
126 if port:
127 r[2] = None
128 try:
129 self.deleteportmapping(port, r[1].upper())
130 except UPnPException:
131 pass