Figure out on which distribution we are running.
[oddments/ddns.git] / src / ddns / system.py
1 #!/usr/bin/python
2 ###############################################################################
3 #                                                                             #
4 # ddns - A dynamic DNS client for IPFire                                      #
5 # Copyright (C) 2012 IPFire development team                                  #
6 #                                                                             #
7 # This program is free software: you can redistribute it and/or modify        #
8 # it under the terms of the GNU General Public License as published by        #
9 # the Free Software Foundation, either version 3 of the License, or           #
10 # (at your option) any later version.                                         #
11 #                                                                             #
12 # This program is distributed in the hope that it will be useful,             #
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of              #
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the               #
15 # GNU General Public License for more details.                                #
16 #                                                                             #
17 # You should have received a copy of the GNU General Public License           #
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.       #
19 #                                                                             #
20 ###############################################################################
21
22 import base64
23 import re
24 import socket
25 import urllib
26 import urllib2
27
28 from __version__ import CLIENT_VERSION
29 from .errors import *
30 from i18n import _
31
32 # Initialize the logger.
33 import logging
34 logger = logging.getLogger("ddns.system")
35 logger.propagate = 1
36
37 class DDNSSystem(object):
38         """
39                 The DDNSSystem class adds a layer of abstraction
40                 between the ddns software and the system.
41         """
42
43         # The default useragent.
44         USER_AGENT = "IPFireDDNSUpdater/%s" % CLIENT_VERSION
45
46         def __init__(self, core):
47                 # Connection to the core of the program.
48                 self.core = core
49
50                 # Find out on which distribution we are running.
51                 self.distro = self._get_distro_identifier()
52                 logger.debug(_("Running on distribution: %s") % self.distro)
53
54         @property
55         def proxy(self):
56                 proxy = self.core.settings.get("proxy")
57
58                 # Strip http:// at the beginning.
59                 if proxy and proxy.startswith("http://"):
60                         proxy = proxy[7:]
61
62                 return proxy
63
64         def _guess_external_ip_address(self, url, timeout=10):
65                 """
66                         Sends a request to an external web server
67                         to determine the current default IP address.
68                 """
69                 try:
70                         response = self.send_request(url, timeout=timeout)
71
72                 # If the server could not be reached, we will return nothing.
73                 except DDNSNetworkError:
74                         return
75
76                 if not response.code == 200:
77                         return
78
79                 match = re.search(r"^Your IP address is: (.*)$", response.read())
80                 if match is None:
81                         return
82
83                 return match.group(1)
84
85         def guess_external_ipv6_address(self):
86                 """
87                         Sends a request to the internet to determine
88                         the public IPv6 address.
89                 """
90                 return self._guess_external_ip_address("http://checkip6.dns.lightningwirelabs.com")
91
92         def guess_external_ipv4_address(self):
93                 """
94                         Sends a request to the internet to determine
95                         the public IPv4 address.
96                 """
97                 return self._guess_external_ip_address("http://checkip4.dns.lightningwirelabs.com")
98
99         def send_request(self, url, method="GET", data=None, username=None, password=None, timeout=30):
100                 assert method in ("GET", "POST")
101
102                 # Add all arguments in the data dict to the URL and escape them properly.
103                 if method == "GET" and data:
104                         query_args = self._format_query_args(data)
105                         data = None
106
107                         if "?" in url:
108                                 url = "%s&%s" % (url, query_args)
109                         else:
110                                 url = "%s?%s" % (url, query_args)
111
112                 logger.debug("Sending request (%s): %s" % (method, url))
113                 if data:
114                         logger.debug("  data: %s" % data)
115
116                 req = urllib2.Request(url, data=data)
117
118                 if username and password:
119                         basic_auth_header = self._make_basic_auth_header(username, password)
120                         req.add_header("Authorization", "Basic %s" % basic_auth_header)
121
122                 # Set the user agent.
123                 req.add_header("User-Agent", self.USER_AGENT)
124
125                 # All requests should not be cached anywhere.
126                 req.add_header("Pragma", "no-cache")
127
128                 # Set the upstream proxy if needed.
129                 if self.proxy:
130                         logger.debug("Using proxy: %s" % self.proxy)
131
132                         # Configure the proxy for this request.
133                         req.set_proxy(self.proxy, "http")
134
135                 assert req.get_method() == method
136
137                 logger.debug(_("Request header:"))
138                 for k, v in req.headers.items():
139                         logger.debug("  %s: %s" % (k, v))
140
141                 try:
142                         resp = urllib2.urlopen(req, timeout=timeout)
143
144                         # Log response header.
145                         logger.debug(_("Response header:"))
146                         for k, v in resp.info().items():
147                                 logger.debug("  %s: %s" % (k, v))
148
149                         # Return the entire response object.
150                         return resp
151
152                 except urllib2.HTTPError, e:
153                         # 503 - Service Unavailable
154                         if e.code == 503:
155                                 raise DDNSServiceUnavailableError
156
157                         # Raise all other unhandled exceptions.
158                         raise
159
160                 except urllib2.URLError, e:
161                         if e.reason:
162                                 # Network Unreachable (e.g. no IPv6 access)
163                                 if e.reason.errno == 101:
164                                         raise DDNSNetworkUnreachableError
165                                 elif e.reason.errno == 111:
166                                         raise DDNSConnectionRefusedError
167
168                         # Raise all other unhandled exceptions.
169                         raise
170
171                 except socket.timeout, e:
172                         logger.debug(_("Connection timeout"))
173
174                         raise DDNSConnectionTimeoutError
175
176         def _format_query_args(self, data):
177                 args = []
178
179                 for k, v in data.items():
180                         arg = "%s=%s" % (k, urllib.quote(v))
181                         args.append(arg)
182
183                 return "&".join(args)
184
185         def _make_basic_auth_header(self, username, password):
186                 authstring = "%s:%s" % (username, password)
187
188                 # Encode authorization data in base64.
189                 authstring = base64.encodestring(authstring)
190
191                 # Remove any newline characters.
192                 authstring = authstring.replace("\n", "")
193
194                 return authstring
195
196         def get_address(self, proto):
197                 assert proto in ("ipv6", "ipv4")
198
199                 # Check if the external IP address should be guessed from
200                 # a remote server.
201                 guess_ip = self.core.settings.get("guess_external_ip", "true")
202
203                 # If the external IP address should be used, we just do
204                 # that.
205                 if guess_ip in ("true", "yes", "1"):
206                         if proto == "ipv6":
207                                 return self.guess_external_ipv6_address()
208
209                         elif proto == "ipv4":
210                                 return self.guess_external_ipv4_address()
211
212                 # XXX TODO
213                 assert False
214
215         def resolve(self, hostname, proto=None):
216                 addresses = []
217
218                 if proto is None:
219                         family = 0
220                 elif proto == "ipv6":
221                         family = socket.AF_INET6
222                 elif proto == "ipv4":
223                         family = socket.AF_INET
224                 else:
225                         raise ValueError("Protocol not supported: %s" % proto)
226
227                 # Resolve the host address.
228                 try:
229                         response = socket.getaddrinfo(hostname, None, family)
230                 except socket.gaierror, e:
231                         # Name or service not known
232                         if e.errno == -2:
233                                 return []
234
235                         # No record for requested family available (e.g. no AAAA)
236                         elif e.errno == -5:
237                                 return []
238
239                         raise
240
241                 # Handle responses.
242                 for family, socktype, proto, canonname, sockaddr in response:
243                         # IPv6
244                         if family == socket.AF_INET6:
245                                 address, port, flow_info, scope_id = sockaddr
246
247                                 # Only use the global scope.
248                                 if not scope_id == 0:
249                                         continue
250
251                         # IPv4
252                         elif family == socket.AF_INET:
253                                 address, port = sockaddr
254
255                         # Ignore everything else...
256                         else:
257                                 continue
258
259                         # Add to repsonse list if not already in there.
260                         if not address in addresses:
261                                 addresses.append(address)
262
263                 return addresses
264
265         def _get_distro_identifier(self):
266                 """
267                         Returns a unique identifier for the distribution
268                         we are running on.
269                 """
270                 os_release = self.__parse_os_release()
271                 if os_release:
272                         return os_release
273
274                 system_release = self.__parse_system_release()
275                 if system_release:
276                         return system_release
277
278                 # If nothing else could be found, we return
279                 # just "unknown".
280                 return "unknown"
281
282         def __parse_os_release(self):
283                 """
284                         Tries to parse /etc/os-release and
285                         returns a unique distribution identifier
286                         if the file exists.
287                 """
288                 try:
289                         f = open("/etc/os-release", "r")
290                 except IOError, e:
291                         # File not found
292                         if e.errno == 2:
293                                 return
294
295                         raise
296
297                 os_release = {}
298                 with f:
299                         for line in f.readlines():
300                                 m = re.match(r"^([A-Z\_]+)=(.*)$", line)
301                                 if m is None:
302                                         continue
303
304                                 os_release[m.group(1)] = m.group(2)
305
306                 try:
307                         return "%(ID)s-%(VERSION_ID)s" % os_release
308                 except KeyError:
309                         return
310
311         def __parse_system_release(self):
312                 """
313                         Tries to parse /etc/system-release and
314                         returns a unique distribution identifier
315                         if the file exists.
316                 """
317                 try:
318                         f = open("/etc/system-release", "r")
319                 except IOError, e:
320                         # File not found
321                         if e.errno == 2:
322                                 return
323
324                         raise
325
326                 with f:
327                         # Read first line
328                         line = f.readline()
329
330                         # Check for IPFire systems
331                         m = re.match(r"^IPFire (\d).(\d+)", line)
332                         if m:
333                                 return "ipfire-%s" % m.group(1)