firewall.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533
  1. # -*- encoding: utf8 -*-
  2. # pylint: disable=too-few-public-methods
  3. #
  4. # The Qubes OS Project, http://www.qubes-os.org
  5. #
  6. # Copyright (C) 2017 Marek Marczykowski-Górecki
  7. # <marmarek@invisiblethingslab.com>
  8. #
  9. # This program is free software; you can redistribute it and/or modify
  10. # it under the terms of the GNU Lesser General Public License as published by
  11. # the Free Software Foundation; either version 2.1 of the License, or
  12. # (at your option) any later version.
  13. #
  14. # This program is distributed in the hope that it will be useful,
  15. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. # GNU Lesser General Public License for more details.
  18. #
  19. # You should have received a copy of the GNU Lesser General Public License along
  20. # with this program; if not, see <http://www.gnu.org/licenses/>.
  21. '''Firewall configuration interface'''
  22. import datetime
  23. import socket
  24. import string
  25. class RuleOption(object):
  26. '''Base class for a single rule element'''
  27. def __init__(self, value):
  28. self._value = str(value)
  29. @property
  30. def rule(self):
  31. '''API representation of this rule element'''
  32. raise NotImplementedError
  33. @property
  34. def pretty_value(self):
  35. '''Human readable representation'''
  36. return str(self)
  37. def __str__(self):
  38. return self._value
  39. def __eq__(self, other):
  40. return str(self) == other
  41. # noinspection PyAbstractClass
  42. class RuleChoice(RuleOption):
  43. '''Base class for multiple-choices rule elements'''
  44. # pylint: disable=abstract-method
  45. def __init__(self, value):
  46. super().__init__(value)
  47. self.allowed_values = \
  48. [v for k, v in self.__class__.__dict__.items()
  49. if not k.startswith('__') and isinstance(v, str) and
  50. not v.startswith('__')]
  51. if value not in self.allowed_values:
  52. raise ValueError(value)
  53. class Action(RuleChoice):
  54. '''Rule action'''
  55. accept = 'accept'
  56. drop = 'drop'
  57. forward = 'forward'
  58. @property
  59. def rule(self):
  60. '''API representation of this rule element'''
  61. return 'action=' + str(self)
  62. class ForwardType(RuleChoice):
  63. external = 'external'
  64. internal = 'internal'
  65. @property
  66. def rule(self):
  67. return 'forwardtype=' + str(self)
  68. class Proto(RuleChoice):
  69. '''Protocol name'''
  70. tcp = 'tcp'
  71. udp = 'udp'
  72. icmp = 'icmp'
  73. @property
  74. def rule(self):
  75. '''API representation of this rule element'''
  76. return 'proto=' + str(self)
  77. class DstHost(RuleOption):
  78. '''Represent host/network address: either IPv4, IPv6, or DNS name'''
  79. def __init__(self, value, prefixlen=None):
  80. # TODO: in python >= 3.3 ipaddress module could be used
  81. if value.count('/') > 1:
  82. raise ValueError('Too many /: ' + value)
  83. if not value.count('/'):
  84. # add prefix length to bare IP addresses
  85. try:
  86. socket.inet_pton(socket.AF_INET6, value)
  87. if prefixlen is not None:
  88. self.prefixlen = prefixlen
  89. else:
  90. self.prefixlen = 128
  91. if self.prefixlen < 0 or self.prefixlen > 128:
  92. raise ValueError(
  93. 'netmask for IPv6 must be between 0 and 128')
  94. value += '/' + str(self.prefixlen)
  95. self.type = 'dst6'
  96. except socket.error:
  97. try:
  98. socket.inet_pton(socket.AF_INET, value)
  99. if value.count('.') != 3:
  100. raise ValueError(
  101. 'Invalid number of dots in IPv4 address')
  102. if prefixlen is not None:
  103. self.prefixlen = prefixlen
  104. else:
  105. self.prefixlen = 32
  106. if self.prefixlen < 0 or self.prefixlen > 32:
  107. raise ValueError(
  108. 'netmask for IPv4 must be between 0 and 32')
  109. value += '/' + str(self.prefixlen)
  110. self.type = 'dst4'
  111. except socket.error:
  112. self.type = 'dsthost'
  113. self.prefixlen = 0
  114. safe_set = string.ascii_lowercase + string.digits + '-._'
  115. if not all(c in safe_set for c in value):
  116. raise ValueError('Invalid hostname')
  117. else:
  118. host, prefixlen = value.split('/', 1)
  119. prefixlen = int(prefixlen)
  120. if prefixlen < 0:
  121. raise ValueError('netmask must be non-negative')
  122. self.prefixlen = prefixlen
  123. try:
  124. socket.inet_pton(socket.AF_INET6, host)
  125. if prefixlen > 128:
  126. raise ValueError('netmask for IPv6 must be <= 128')
  127. self.type = 'dst6'
  128. except socket.error:
  129. try:
  130. socket.inet_pton(socket.AF_INET, host)
  131. if prefixlen > 32:
  132. raise ValueError('netmask for IPv4 must be <= 32')
  133. self.type = 'dst4'
  134. if host.count('.') != 3:
  135. raise ValueError(
  136. 'Invalid number of dots in IPv4 address')
  137. except socket.error:
  138. raise ValueError('Invalid IP address: ' + host)
  139. super().__init__(value)
  140. @property
  141. def rule(self):
  142. '''API representation of this rule element'''
  143. if self.prefixlen == 0 and self.type != 'dsthost':
  144. # 0.0.0.0/0 or ::/0, doesn't limit to any particular host,
  145. # so skip it
  146. return None
  147. return self.type + '=' + str(self)
  148. class DstPorts(RuleOption):
  149. '''Destination port(s), for TCP/UDP only'''
  150. def __init__(self, value):
  151. if isinstance(value, int):
  152. value = str(value)
  153. if value.count('-') == 1:
  154. self.range = [int(x) for x in value.split('-', 1)]
  155. elif not value.count('-'):
  156. self.range = [int(value), int(value)]
  157. else:
  158. raise ValueError(value)
  159. if any(port < 0 or port > 65536 for port in self.range):
  160. raise ValueError('Ports out of range')
  161. if self.range[0] > self.range[1]:
  162. raise ValueError('Invalid port range')
  163. super().__init__(
  164. str(self.range[0]) if self.range[0] == self.range[1]
  165. else '{!s}-{!s}'.format(*self.range))
  166. @property
  167. def rule(self):
  168. '''API representation of this rule element'''
  169. return 'dstports=' + '{!s}-{!s}'.format(*self.range)
  170. class SrcPorts(RuleOption):
  171. '''Source port(s), for TCP/UDP forwarding only'''
  172. def __init__(self, value):
  173. if isinstance(value, int):
  174. value = str(value)
  175. if value.count('-') == 1:
  176. self.range = [int(x) for x in value.split('-', 1)]
  177. elif not value.count('-'):
  178. self.range = [int(value), int(value)]
  179. else:
  180. raise ValueError(value)
  181. if any(port < 0 or port > 65536 for port in self.range):
  182. raise ValueError('Ports out of range')
  183. if self.range[0] > self.range[1]:
  184. raise ValueError('Invalid port range')
  185. super().__init__(
  186. str(self.range[0]) if self.range[0] == self.range[1]
  187. else '{!s}-{!s}'.format(*self.range))
  188. @property
  189. def rule(self):
  190. '''API representation of this rule element'''
  191. return 'srcports=' + '{!s}-{!s}'.format(*self.range)
  192. class IcmpType(RuleOption):
  193. '''ICMP packet type'''
  194. def __init__(self, value):
  195. super().__init__(value)
  196. value = int(value)
  197. if value < 0 or value > 255:
  198. raise ValueError('ICMP type out of range')
  199. @property
  200. def rule(self):
  201. '''API representation of this rule element'''
  202. return 'icmptype=' + str(self)
  203. class SpecialTarget(RuleChoice):
  204. '''Special destination'''
  205. dns = 'dns'
  206. @property
  207. def rule(self):
  208. '''API representation of this rule element'''
  209. return 'specialtarget=' + str(self)
  210. class Expire(RuleOption):
  211. '''Rule expire time'''
  212. def __init__(self, value):
  213. super().__init__(value)
  214. self.datetime = datetime.datetime.utcfromtimestamp(int(value))
  215. @property
  216. def rule(self):
  217. '''API representation of this rule element'''
  218. return 'expire=' + str(self)
  219. @property
  220. def expired(self):
  221. '''Has this rule expired already?'''
  222. return self.datetime < datetime.datetime.utcnow()
  223. @property
  224. def pretty_value(self):
  225. '''Human readable representation'''
  226. now = datetime.datetime.utcnow()
  227. duration = (self.datetime - now).total_seconds()
  228. return "{:+.0f}s".format(duration)
  229. class Comment(RuleOption):
  230. '''User comment'''
  231. @property
  232. def rule(self):
  233. '''API representation of this rule element'''
  234. return 'comment=' + str(self)
  235. class Rule(object):
  236. '''A single firewall rule'''
  237. def __init__(self, rule, **kwargs):
  238. '''Single firewall rule
  239. :param xml: XML element describing rule, or None
  240. :param kwargs: rule elements
  241. '''
  242. self._action = None
  243. self._forwardtype = None
  244. self._proto = None
  245. self._dsthost = None
  246. self._srcports = None
  247. self._dstports = None
  248. self._icmptype = None
  249. self._specialtarget = None
  250. self._expire = None
  251. self._comment = None
  252. rule_dict = {}
  253. if rule is not None:
  254. rule_opts, _, comment = rule.partition('comment=')
  255. rule_dict = dict(rule_opt.split('=', 1) for rule_opt in
  256. rule_opts.split(' ') if rule_opt)
  257. if comment:
  258. rule_dict['comment'] = comment
  259. rule_dict.update(kwargs)
  260. rule_elements = ('action', 'forwardtype', 'proto', 'dsthost', 'dst4',
  261. 'dst6', 'specialtarget', 'srcports', 'dstports', 'icmptype',
  262. 'expire', 'comment')
  263. for rule_opt in rule_elements:
  264. value = rule_dict.pop(rule_opt, None)
  265. if value is None:
  266. continue
  267. if rule_opt in ('dst4', 'dst6'):
  268. rule_opt = 'dsthost'
  269. setattr(self, rule_opt, value)
  270. if rule_dict:
  271. raise ValueError('Unknown rule elements: {!r}'.format(
  272. rule_dict))
  273. if self.action is None:
  274. raise ValueError('missing action=')
  275. @property
  276. def action(self):
  277. '''rule action'''
  278. return self._action
  279. @action.setter
  280. def action(self, value):
  281. if not isinstance(value, Action):
  282. value = Action(value)
  283. self._action = value
  284. @property
  285. def forwardtype(self):
  286. '''type of forwarding (internal or external)'''
  287. return self._forwardtype
  288. @forwardtype.setter
  289. def forwardtype(self, value):
  290. if not isinstance(value, ForwardType):
  291. value = ForwardType(value)
  292. self._forwardtype = value
  293. @property
  294. def proto(self):
  295. '''protocol to match'''
  296. return self._proto
  297. @proto.setter
  298. def proto(self, value):
  299. if value is not None and not isinstance(value, Proto):
  300. value = Proto(value)
  301. if value not in ('tcp', 'udp'):
  302. self.dstports = None
  303. if value not in ('icmp',):
  304. self.icmptype = None
  305. self._proto = value
  306. @property
  307. def dsthost(self):
  308. '''destination host/network'''
  309. return self._dsthost
  310. @dsthost.setter
  311. def dsthost(self, value):
  312. if value is not None and not isinstance(value, DstHost):
  313. value = DstHost(value)
  314. self._dsthost = value
  315. @property
  316. def srcports(self):
  317. ''''Source port(s) (for forwarding only)'''
  318. return self._srcports
  319. @srcports.setter
  320. def srcports(self, value):
  321. if value is not None:
  322. if self.proto not in ('tcp', 'udp'):
  323. raise ValueError(
  324. 'srcports valid only for \'tcp\' and \'udp\' protocols')
  325. if not isinstance(value, DstPorts):
  326. value = SrcPorts(value)
  327. self._srcports = value
  328. @property
  329. def dstports(self):
  330. ''''Destination port(s) (for \'tcp\' and \'udp\' protocol only)'''
  331. return self._dstports
  332. @dstports.setter
  333. def dstports(self, value):
  334. if value is not None:
  335. if self.proto not in ('tcp', 'udp'):
  336. raise ValueError(
  337. 'dstports valid only for \'tcp\' and \'udp\' protocols')
  338. if not isinstance(value, DstPorts):
  339. value = DstPorts(value)
  340. self._dstports = value
  341. @property
  342. def icmptype(self):
  343. '''ICMP packet type (for \'icmp\' protocol only)'''
  344. return self._icmptype
  345. @icmptype.setter
  346. def icmptype(self, value):
  347. if value is not None:
  348. if self.proto not in ('icmp',):
  349. raise ValueError('icmptype valid only for \'icmp\' protocol')
  350. if not isinstance(value, IcmpType):
  351. value = IcmpType(value)
  352. self._icmptype = value
  353. @property
  354. def specialtarget(self):
  355. '''Special target, for now only \'dns\' supported'''
  356. return self._specialtarget
  357. @specialtarget.setter
  358. def specialtarget(self, value):
  359. if not isinstance(value, SpecialTarget):
  360. value = SpecialTarget(value)
  361. self._specialtarget = value
  362. @property
  363. def expire(self):
  364. '''Timestamp (UNIX epoch) on which this rule expire'''
  365. return self._expire
  366. @expire.setter
  367. def expire(self, value):
  368. if not isinstance(value, Expire):
  369. value = Expire(value)
  370. self._expire = value
  371. @property
  372. def comment(self):
  373. '''User comment'''
  374. return self._comment
  375. @comment.setter
  376. def comment(self, value):
  377. if not isinstance(value, Comment):
  378. value = Comment(value)
  379. self._comment = value
  380. @property
  381. def rule(self):
  382. '''API representation of this rule'''
  383. values = []
  384. # comment must be the last one
  385. for prop in ('action', 'forwardtype', 'proto', 'dsthost', 'srcports',
  386. 'dstports', 'icmptype', 'specialtarget', 'expire', 'comment'):
  387. value = getattr(self, prop)
  388. if value is None:
  389. continue
  390. if value.rule is None:
  391. continue
  392. values.append(value.rule)
  393. return ' '.join(values)
  394. def __eq__(self, other):
  395. if isinstance(other, Rule):
  396. return self.rule == other.rule
  397. if isinstance(other, str):
  398. return self.rule == str
  399. return NotImplemented
  400. def __repr__(self):
  401. return 'Rule(\'{}\')'.format(self.rule)
  402. class Firewall(object):
  403. '''Firewal manager for a VM'''
  404. def __init__(self, vm):
  405. self.vm = vm
  406. self._rules = []
  407. self._policy = None
  408. self._loaded = False
  409. def load_rules(self):
  410. '''Force (re-)loading firewall rules'''
  411. rules_str = self.vm.qubesd_call(None, 'admin.vm.firewall.Get')
  412. rules = []
  413. for rule_str in rules_str.decode().splitlines():
  414. rules.append(Rule(rule_str))
  415. self._rules = rules
  416. self._loaded = True
  417. @property
  418. def rules(self):
  419. '''Firewall rules
  420. You can either copy them, edit and then assign new rules list to this
  421. property, or edit in-place and call :py:meth:`save_rules`.
  422. Once rules are loaded, they are cached. To reload rules,
  423. call :py:meth:`load_rules`.
  424. '''
  425. if not self._loaded:
  426. self.load_rules()
  427. return self._rules
  428. @rules.setter
  429. def rules(self, value):
  430. self.save_rules(value)
  431. self._rules = value
  432. def save_rules(self, rules=None):
  433. '''Save firewall rules. Needs to be called after in-place editing
  434. :py:attr:`rules`.
  435. '''
  436. if rules is None:
  437. rules = self._rules
  438. self.vm.qubesd_call(None, 'admin.vm.firewall.Set',
  439. payload=(''.join('{}\n'.format(rule.rule)
  440. for rule in rules)).encode('ascii'))
  441. @property
  442. def policy(self):
  443. '''Default action to take if no rule matches'''
  444. return Action('drop')
  445. def reload(self):
  446. '''Force reload the same firewall rules.
  447. Can be used for example to force again names resolution.
  448. '''
  449. self.vm.qubesd_call(None, 'admin.vm.firewall.Reload')