firewall.py 16 KB

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