firewall.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. #!/usr/bin/python2 -O
  2. # vim: fileencoding=utf-8
  3. # pylint: disable=too-few-public-methods
  4. #
  5. # The Qubes OS Project, https://www.qubes-os.org/
  6. #
  7. # Copyright (C) 2016
  8. # Marek Marczykowski-Górecki <marmarek@invisiblethingslab.com>
  9. #
  10. # This program is free software; you can redistribute it and/or modify
  11. # it under the terms of the GNU General Public License as published by
  12. # the Free Software Foundation; either version 2 of the License, or
  13. # (at your option) any later version.
  14. #
  15. # This program is distributed in the hope that it will be useful,
  16. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  17. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  18. # GNU General Public License for more details.
  19. #
  20. # You should have received a copy of the GNU General Public License along
  21. # with this program; if not, write to the Free Software Foundation, Inc.,
  22. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  23. #
  24. import datetime
  25. import subprocess
  26. import itertools
  27. import lxml.etree
  28. import os
  29. import socket
  30. import qubes
  31. import qubes.vm.qubesvm
  32. class RuleOption(object):
  33. def __init__(self, value):
  34. self._value = str(value)
  35. @property
  36. def rule(self):
  37. raise NotImplementedError
  38. def __str__(self):
  39. return self._value
  40. def __eq__(self, other):
  41. return str(self) == other
  42. # noinspection PyAbstractClass
  43. class RuleChoice(RuleOption):
  44. # pylint: disable=abstract-method
  45. def __init__(self, value):
  46. super(RuleChoice, self).__init__(value)
  47. self.allowed_values = \
  48. [v for k, v in self.__class__.__dict__.items()
  49. if not k.startswith('__') and isinstance(v, basestring) and
  50. not v.startswith('__')]
  51. if value not in self.allowed_values:
  52. raise ValueError(value)
  53. class Action(RuleChoice):
  54. accept = 'accept'
  55. drop = 'drop'
  56. @property
  57. def rule(self):
  58. return 'action=' + str(self)
  59. class Proto(RuleChoice):
  60. tcp = 'tcp'
  61. udp = 'udp'
  62. icmp = 'icmp'
  63. @property
  64. def rule(self):
  65. return 'proto=' + str(self)
  66. class DstHost(RuleOption):
  67. '''Represent host/network address: either IPv4, IPv6, or DNS name'''
  68. def __init__(self, value, prefixlen=None):
  69. # TODO: in python >= 3.3 ipaddress module could be used
  70. if value.count('/') > 1:
  71. raise ValueError('Too many /: ' + value)
  72. elif not value.count('/'):
  73. # add prefix length to bare IP addresses
  74. try:
  75. socket.inet_pton(socket.AF_INET6, value)
  76. self.prefixlen = prefixlen or 128
  77. if self.prefixlen < 0 or self.prefixlen > 128:
  78. raise ValueError(
  79. 'netmask for IPv6 must be between 0 and 128')
  80. value += '/' + str(self.prefixlen)
  81. self.type = 'dst6'
  82. except socket.error:
  83. try:
  84. socket.inet_pton(socket.AF_INET, value)
  85. if value.count('.') != 3:
  86. raise ValueError(
  87. 'Invalid number of dots in IPv4 address')
  88. self.prefixlen = prefixlen or 32
  89. if self.prefixlen < 0 or self.prefixlen > 32:
  90. raise ValueError(
  91. 'netmask for IPv4 must be between 0 and 32')
  92. value += '/' + str(self.prefixlen)
  93. self.type = 'dst4'
  94. except socket.error:
  95. self.type = 'dsthost'
  96. self.prefixlen = 0
  97. else:
  98. host, prefixlen = value.split('/', 1)
  99. prefixlen = int(prefixlen)
  100. if prefixlen < 0:
  101. raise ValueError('netmask must be non-negative')
  102. self.prefixlen = prefixlen
  103. try:
  104. socket.inet_pton(socket.AF_INET6, host)
  105. if prefixlen > 128:
  106. raise ValueError('netmask for IPv6 must be <= 128')
  107. self.type = 'dst6'
  108. except socket.error:
  109. try:
  110. socket.inet_pton(socket.AF_INET, host)
  111. if prefixlen > 32:
  112. raise ValueError('netmask for IPv4 must be <= 32')
  113. self.type = 'dst4'
  114. if host.count('.') != 3:
  115. raise ValueError(
  116. 'Invalid number of dots in IPv4 address')
  117. except socket.error:
  118. raise ValueError('Invalid IP address: ' + host)
  119. super(DstHost, self).__init__(value)
  120. @property
  121. def rule(self):
  122. return self.type + '=' + str(self)
  123. class DstPorts(RuleOption):
  124. def __init__(self, value):
  125. if isinstance(value, int):
  126. value = str(value)
  127. if value.count('-') == 1:
  128. self.range = [int(x) for x in value.split('-', 1)]
  129. elif not value.count('-'):
  130. self.range = [int(value), int(value)]
  131. else:
  132. raise ValueError(value)
  133. if any(port < 0 or port > 65536 for port in self.range):
  134. raise ValueError('Ports out of range')
  135. if self.range[0] > self.range[1]:
  136. raise ValueError('Invalid port range')
  137. super(DstPorts, self).__init__(
  138. str(self.range[0]) if self.range[0] == self.range[1]
  139. else '-'.join(map(str, self.range)))
  140. @property
  141. def rule(self):
  142. return 'dstports=' + '{!s}-{!s}'.format(*self.range)
  143. class IcmpType(RuleOption):
  144. def __init__(self, value):
  145. super(IcmpType, self).__init__(value)
  146. value = int(value)
  147. if value < 0 or value > 255:
  148. raise ValueError('ICMP type out of range')
  149. @property
  150. def rule(self):
  151. return 'icmptype=' + str(self)
  152. class SpecialTarget(RuleChoice):
  153. dns = 'dns'
  154. @property
  155. def rule(self):
  156. return 'specialtarget=' + str(self)
  157. class Expire(RuleOption):
  158. def __init__(self, value):
  159. super(Expire, self).__init__(value)
  160. self.datetime = datetime.datetime.utcfromtimestamp(int(value))
  161. @property
  162. def rule(self):
  163. return None
  164. @property
  165. def expired(self):
  166. return self.datetime < datetime.datetime.utcnow()
  167. class Comment(RuleOption):
  168. @property
  169. def rule(self):
  170. return None
  171. class Rule(qubes.PropertyHolder):
  172. def __init__(self, xml, **kwargs):
  173. super(Rule, self).__init__(xml, **kwargs)
  174. self.load_properties()
  175. self.events_enabled = True
  176. # validate dependencies
  177. if self.dstports:
  178. self.on_set_dstports('property-set:dstports', 'dstports',
  179. self.dstports, None)
  180. if self.icmptype:
  181. self.on_set_icmptype('property-set:icmptype', 'icmptype',
  182. self.icmptype, None)
  183. self.property_require('action', False, True)
  184. action = qubes.property('action',
  185. type=Action,
  186. order=0,
  187. doc='rule action')
  188. proto = qubes.property('proto',
  189. type=Proto,
  190. default=None,
  191. order=1,
  192. doc='protocol to match')
  193. dsthost = qubes.property('dsthost',
  194. type=DstHost,
  195. default=None,
  196. order=1,
  197. doc='destination host/network')
  198. dstports = qubes.property('dstports',
  199. type=DstPorts,
  200. default=None,
  201. order=2,
  202. doc='Destination port(s) (for \'tcp\' and \'udp\' protocol only)')
  203. icmptype = qubes.property('icmptype',
  204. type=IcmpType,
  205. default=None,
  206. order=2,
  207. doc='ICMP packet type (for \'icmp\' protocol only)')
  208. specialtarget = qubes.property('specialtarget',
  209. type=SpecialTarget,
  210. default=None,
  211. order=1,
  212. doc='Special target, for now only \'dns\' supported')
  213. expire = qubes.property('expire',
  214. type=Expire,
  215. default=None,
  216. doc='Timestamp (UNIX epoch) on which this rule expire')
  217. comment = qubes.property('comment',
  218. type=Comment,
  219. default=None,
  220. doc='User comment')
  221. # noinspection PyUnusedLocal
  222. @qubes.events.handler('property-pre-set:dstports')
  223. def on_set_dstports(self, _event, _prop, _new_value, _old_value=None):
  224. if self.proto not in ('tcp', 'udp'):
  225. raise ValueError(
  226. 'dstports valid only for \'tcp\' and \'udp\' protocols')
  227. # noinspection PyUnusedLocal
  228. @qubes.events.handler('property-pre-set:icmptype')
  229. def on_set_icmptype(self, _event, _prop, _new_value, _old_value=None):
  230. if self.proto not in ('icmp',):
  231. raise ValueError('icmptype valid only for \'icmp\' protocol')
  232. # noinspection PyUnusedLocal
  233. @qubes.events.handler('property-set:proto')
  234. def on_set_proto(self, _event, _prop, new_value, _old_value=None):
  235. if new_value not in ('tcp', 'udp'):
  236. self.dstports = qubes.property.DEFAULT
  237. if new_value not in ('icmp',):
  238. self.icmptype = qubes.property.DEFAULT
  239. @qubes.events.handler('property-del:proto')
  240. def on_del_proto(self, _event, _prop, _old_value):
  241. self.dstports = qubes.property.DEFAULT
  242. self.icmptype = qubes.property.DEFAULT
  243. @property
  244. def rule(self):
  245. if self.expire and self.expire.expired:
  246. return None
  247. values = []
  248. for prop in self.property_list():
  249. value = getattr(self, prop.__name__)
  250. if value is None:
  251. continue
  252. if value.rule is None:
  253. continue
  254. values.append(value.rule)
  255. return ' '.join(values)
  256. @classmethod
  257. def from_xml_v1(cls, node, action):
  258. netmask = node.get('netmask')
  259. if netmask is None:
  260. netmask = 32
  261. else:
  262. netmask = int(netmask)
  263. address = node.get('address')
  264. if address:
  265. dsthost = DstHost(address, netmask)
  266. else:
  267. dsthost = None
  268. proto = node.get('proto')
  269. port = node.get('port')
  270. toport = node.get('toport')
  271. if port and toport:
  272. dstports = port + '-' + toport
  273. elif port:
  274. dstports = port
  275. else:
  276. dstports = None
  277. # backward compatibility: protocol defaults to TCP if port is specified
  278. if dstports and not proto:
  279. proto = 'tcp'
  280. if proto == 'any':
  281. proto = None
  282. expire = node.get('expire')
  283. kwargs = {
  284. 'action': action,
  285. }
  286. if dsthost:
  287. kwargs['dsthost'] = dsthost
  288. if dstports:
  289. kwargs['dstports'] = dstports
  290. if proto:
  291. kwargs['proto'] = proto
  292. if expire:
  293. kwargs['expire'] = expire
  294. return cls(None, **kwargs)
  295. def __eq__(self, other):
  296. return self.rule == other.rule
  297. class Firewall(object):
  298. def __init__(self, vm, load=True):
  299. assert hasattr(vm, 'firewall_conf')
  300. self.vm = vm
  301. #: firewall rules
  302. self.rules = []
  303. #: default action
  304. self.policy = None
  305. if load:
  306. self.load()
  307. def load_defaults(self):
  308. self.rules = []
  309. self.policy = Action('accept')
  310. def load(self):
  311. firewall_conf = os.path.join(self.vm.dir_path, self.vm.firewall_conf)
  312. if os.path.exists(firewall_conf):
  313. self.rules = []
  314. tree = lxml.etree.parse(firewall_conf)
  315. root = tree.getroot()
  316. version = root.get('version', '1')
  317. if version == '1':
  318. self.load_v1(root)
  319. elif version == '2':
  320. self.load_v2(root)
  321. else:
  322. raise qubes.exc.QubesVMError(self.vm,
  323. 'Unsupported firewall.xml version: {}'.format(version))
  324. else:
  325. self.load_defaults()
  326. def load_v1(self, xml_root):
  327. policy_v1 = xml_root.get('policy')
  328. assert policy_v1 in ('allow', 'deny')
  329. if policy_v1 == 'allow':
  330. self.policy = Action('accept')
  331. else:
  332. self.policy = Action('drop')
  333. def _translate_action(key):
  334. if xml_root.get(key, policy_v1) == 'allow':
  335. return Action.accept
  336. else:
  337. return Action.drop
  338. self.rules.append(Rule(None,
  339. action=_translate_action('dns'),
  340. specialtarget=SpecialTarget('dns')))
  341. self.rules.append(Rule(None,
  342. action=_translate_action('icmp'),
  343. proto=Proto.icmp))
  344. if self.policy == Action.accept:
  345. rule_action = Action.drop
  346. else:
  347. rule_action = Action.accept
  348. for element in xml_root:
  349. rule = Rule.from_xml_v1(element, rule_action)
  350. self.rules.append(rule)
  351. def load_v2(self, xml_root):
  352. self.policy = Action(xml_root.findtext('policy'))
  353. xml_rules = xml_root.find('rules')
  354. for xml_rule in xml_rules:
  355. rule = Rule(xml_rule)
  356. self.rules.append(rule)
  357. def save(self):
  358. firewall_conf = os.path.join(self.vm.dir_path, self.vm.firewall_conf)
  359. expiring_rules_present = False
  360. xml_root = lxml.etree.Element('firewall', version=str(2))
  361. xml_policy = lxml.etree.Element('policy')
  362. xml_policy.text = str(self.policy)
  363. xml_root.append(xml_policy)
  364. xml_rules = lxml.etree.Element('rules')
  365. for rule in self.rules:
  366. if rule.expire:
  367. if rule.expire and rule.expire.expired:
  368. continue
  369. else:
  370. expiring_rules_present = True
  371. xml_rule = lxml.etree.Element('rule')
  372. xml_rule.append(rule.xml_properties())
  373. xml_rules.append(xml_rule)
  374. xml_root.append(xml_rules)
  375. xml_tree = lxml.etree.ElementTree(xml_root)
  376. try:
  377. old_umask = os.umask(0o002)
  378. with open(firewall_conf, 'w') as firewall_xml:
  379. xml_tree.write(firewall_xml, encoding="UTF-8",
  380. pretty_print=True)
  381. os.umask(old_umask)
  382. except EnvironmentError as err:
  383. self.vm.log.error("save error: {}".format(err))
  384. raise qubes.exc.QubesException('save error: {}'.format(err))
  385. self.vm.fire_event('firewall-changed')
  386. if expiring_rules_present and not self.vm.app.vmm.offline_mode:
  387. subprocess.call(["sudo", "systemctl", "start",
  388. "qubes-reload-firewall@%s.timer" % self.vm.name])
  389. def qdb_entries(self, addr_family=None):
  390. entries = {
  391. 'policy': str(self.policy)
  392. }
  393. exclude_dsttype = None
  394. if addr_family is not None:
  395. exclude_dsttype = 'dst4' if addr_family == 6 else 'dst6'
  396. for ruleno, rule in zip(itertools.count(), self.rules):
  397. # exclude rules for another address family
  398. if rule.dsthost and rule.dsthost.type == exclude_dsttype:
  399. continue
  400. entries['{:04}'.format(ruleno)] = rule.rule
  401. return entries