firewall.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452
  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. class RuleOption(object):
  25. '''Base class for a single rule element'''
  26. def __init__(self, value):
  27. self._value = str(value)
  28. @property
  29. def rule(self):
  30. '''API representation of this rule element'''
  31. raise NotImplementedError
  32. def __str__(self):
  33. return self._value
  34. def __eq__(self, other):
  35. return str(self) == other
  36. # noinspection PyAbstractClass
  37. class RuleChoice(RuleOption):
  38. '''Base class for multiple-choices rule elements'''
  39. # pylint: disable=abstract-method
  40. def __init__(self, value):
  41. super(RuleChoice, self).__init__(value)
  42. self.allowed_values = \
  43. [v for k, v in self.__class__.__dict__.items()
  44. if not k.startswith('__') and isinstance(v, str) and
  45. not v.startswith('__')]
  46. if value not in self.allowed_values:
  47. raise ValueError(value)
  48. class Action(RuleChoice):
  49. '''Rule action'''
  50. accept = 'accept'
  51. drop = 'drop'
  52. @property
  53. def rule(self):
  54. '''API representation of this rule element'''
  55. return 'action=' + str(self)
  56. class Proto(RuleChoice):
  57. '''Protocol name'''
  58. tcp = 'tcp'
  59. udp = 'udp'
  60. icmp = 'icmp'
  61. @property
  62. def rule(self):
  63. '''API representation of this rule element'''
  64. return 'proto=' + str(self)
  65. class DstHost(RuleOption):
  66. '''Represent host/network address: either IPv4, IPv6, or DNS name'''
  67. def __init__(self, value, prefixlen=None):
  68. # TODO: in python >= 3.3 ipaddress module could be used
  69. if value.count('/') > 1:
  70. raise ValueError('Too many /: ' + value)
  71. elif not value.count('/'):
  72. # add prefix length to bare IP addresses
  73. try:
  74. socket.inet_pton(socket.AF_INET6, value)
  75. if prefixlen is not None:
  76. self.prefixlen = prefixlen
  77. else:
  78. self.prefixlen = 128
  79. if self.prefixlen < 0 or self.prefixlen > 128:
  80. raise ValueError(
  81. 'netmask for IPv6 must be between 0 and 128')
  82. value += '/' + str(self.prefixlen)
  83. self.type = 'dst6'
  84. except socket.error:
  85. try:
  86. socket.inet_pton(socket.AF_INET, value)
  87. if value.count('.') != 3:
  88. raise ValueError(
  89. 'Invalid number of dots in IPv4 address')
  90. if prefixlen is not None:
  91. self.prefixlen = prefixlen
  92. else:
  93. self.prefixlen = 32
  94. if self.prefixlen < 0 or self.prefixlen > 32:
  95. raise ValueError(
  96. 'netmask for IPv4 must be between 0 and 32')
  97. value += '/' + str(self.prefixlen)
  98. self.type = 'dst4'
  99. except socket.error:
  100. self.type = 'dsthost'
  101. self.prefixlen = 0
  102. else:
  103. host, prefixlen = value.split('/', 1)
  104. prefixlen = int(prefixlen)
  105. if prefixlen < 0:
  106. raise ValueError('netmask must be non-negative')
  107. self.prefixlen = prefixlen
  108. try:
  109. socket.inet_pton(socket.AF_INET6, host)
  110. if prefixlen > 128:
  111. raise ValueError('netmask for IPv6 must be <= 128')
  112. self.type = 'dst6'
  113. except socket.error:
  114. try:
  115. socket.inet_pton(socket.AF_INET, host)
  116. if prefixlen > 32:
  117. raise ValueError('netmask for IPv4 must be <= 32')
  118. self.type = 'dst4'
  119. if host.count('.') != 3:
  120. raise ValueError(
  121. 'Invalid number of dots in IPv4 address')
  122. except socket.error:
  123. raise ValueError('Invalid IP address: ' + host)
  124. super(DstHost, self).__init__(value)
  125. @property
  126. def rule(self):
  127. '''API representation of this rule element'''
  128. if self.prefixlen == 0 and self.type != 'dsthost':
  129. # 0.0.0.0/0 or ::/0, doesn't limit to any particular host,
  130. # so skip it
  131. return None
  132. return self.type + '=' + str(self)
  133. class DstPorts(RuleOption):
  134. '''Destination port(s), for TCP/UDP only'''
  135. def __init__(self, value):
  136. if isinstance(value, int):
  137. value = str(value)
  138. if value.count('-') == 1:
  139. self.range = [int(x) for x in value.split('-', 1)]
  140. elif not value.count('-'):
  141. self.range = [int(value), int(value)]
  142. else:
  143. raise ValueError(value)
  144. if any(port < 0 or port > 65536 for port in self.range):
  145. raise ValueError('Ports out of range')
  146. if self.range[0] > self.range[1]:
  147. raise ValueError('Invalid port range')
  148. super(DstPorts, self).__init__(
  149. str(self.range[0]) if self.range[0] == self.range[1]
  150. else '{!s}-{!s}'.format(*self.range))
  151. @property
  152. def rule(self):
  153. '''API representation of this rule element'''
  154. return 'dstports=' + '{!s}-{!s}'.format(*self.range)
  155. class IcmpType(RuleOption):
  156. '''ICMP packet type'''
  157. def __init__(self, value):
  158. super(IcmpType, self).__init__(value)
  159. value = int(value)
  160. if value < 0 or value > 255:
  161. raise ValueError('ICMP type out of range')
  162. @property
  163. def rule(self):
  164. '''API representation of this rule element'''
  165. return 'icmptype=' + str(self)
  166. class SpecialTarget(RuleChoice):
  167. '''Special destination'''
  168. dns = 'dns'
  169. @property
  170. def rule(self):
  171. '''API representation of this rule element'''
  172. return 'specialtarget=' + str(self)
  173. class Expire(RuleOption):
  174. '''Rule expire time'''
  175. def __init__(self, value):
  176. super(Expire, self).__init__(value)
  177. self.datetime = datetime.datetime.utcfromtimestamp(int(value))
  178. @property
  179. def rule(self):
  180. '''API representation of this rule element'''
  181. return 'expire=' + str(self)
  182. @property
  183. def expired(self):
  184. '''Have this rule expired already?'''
  185. return self.datetime < datetime.datetime.utcnow()
  186. class Comment(RuleOption):
  187. '''User comment'''
  188. @property
  189. def rule(self):
  190. '''API representation of this rule element'''
  191. return 'comment=' + str(self)
  192. class Rule(object):
  193. '''A single firewall rule'''
  194. def __init__(self, rule, **kwargs):
  195. '''Single firewall rule
  196. :param xml: XML element describing rule, or None
  197. :param kwargs: rule elements
  198. '''
  199. self._action = None
  200. self._proto = None
  201. self._dsthost = None
  202. self._dstports = None
  203. self._icmptype = None
  204. self._specialtarget = None
  205. self._expire = None
  206. self._comment = None
  207. rule_dict = {}
  208. if rule is not None:
  209. rule_opts, _, comment = rule.partition('comment=')
  210. rule_dict = dict(rule_opt.split('=', 1) for rule_opt in
  211. rule_opts.split(' ') if rule_opt)
  212. if comment:
  213. rule_dict['comment'] = comment
  214. rule_dict.update(kwargs)
  215. rule_elements = ('action', 'proto', 'dsthost', 'dst4', 'dst6',
  216. 'specialtarget', 'dstports', 'icmptype', 'expire', 'comment')
  217. for rule_opt in rule_elements:
  218. value = rule_dict.pop(rule_opt, None)
  219. if value is None:
  220. continue
  221. if rule_opt in ('dst4', 'dst6'):
  222. rule_opt = 'dsthost'
  223. setattr(self, rule_opt, value)
  224. if rule_dict:
  225. raise ValueError('Unknown rule elements: {!r}'.format(
  226. rule_dict))
  227. if self.action is None:
  228. raise ValueError('missing action=')
  229. @property
  230. def action(self):
  231. '''rule action'''
  232. return self._action
  233. @action.setter
  234. def action(self, value):
  235. if not isinstance(value, Action):
  236. value = Action(value)
  237. self._action = value
  238. @property
  239. def proto(self):
  240. '''protocol to match'''
  241. return self._proto
  242. @proto.setter
  243. def proto(self, value):
  244. if value is not None and not isinstance(value, Proto):
  245. value = Proto(value)
  246. if value not in ('tcp', 'udp'):
  247. self.dstports = None
  248. if value not in ('icmp',):
  249. self.icmptype = None
  250. self._proto = value
  251. @property
  252. def dsthost(self):
  253. '''destination host/network'''
  254. return self._dsthost
  255. @dsthost.setter
  256. def dsthost(self, value):
  257. if value is not None and not isinstance(value, DstHost):
  258. value = DstHost(value)
  259. self._dsthost = value
  260. @property
  261. def dstports(self):
  262. ''''Destination port(s) (for \'tcp\' and \'udp\' protocol only)'''
  263. return self._dstports
  264. @dstports.setter
  265. def dstports(self, value):
  266. if value is not None:
  267. if self.proto not in ('tcp', 'udp'):
  268. raise ValueError(
  269. 'dstports valid only for \'tcp\' and \'udp\' protocols')
  270. if not isinstance(value, DstPorts):
  271. value = DstPorts(value)
  272. self._dstports = value
  273. @property
  274. def icmptype(self):
  275. '''ICMP packet type (for \'icmp\' protocol only)'''
  276. return self._icmptype
  277. @icmptype.setter
  278. def icmptype(self, value):
  279. if value is not None:
  280. if self.proto not in ('icmp',):
  281. raise ValueError('icmptype valid only for \'icmp\' protocol')
  282. if not isinstance(value, IcmpType):
  283. value = IcmpType(value)
  284. self._icmptype = value
  285. @property
  286. def specialtarget(self):
  287. '''Special target, for now only \'dns\' supported'''
  288. return self._specialtarget
  289. @specialtarget.setter
  290. def specialtarget(self, value):
  291. if not isinstance(value, SpecialTarget):
  292. value = SpecialTarget(value)
  293. self._specialtarget = value
  294. @property
  295. def expire(self):
  296. '''Timestamp (UNIX epoch) on which this rule expire'''
  297. return self._expire
  298. @expire.setter
  299. def expire(self, value):
  300. if not isinstance(value, Expire):
  301. value = Expire(value)
  302. self._expire = value
  303. @property
  304. def comment(self):
  305. '''User comment'''
  306. return self._comment
  307. @comment.setter
  308. def comment(self, value):
  309. if not isinstance(value, Comment):
  310. value = Comment(value)
  311. self._comment = value
  312. @property
  313. def rule(self):
  314. '''API representation of this rule'''
  315. values = []
  316. # comment must be the last one
  317. for prop in ('action', 'proto', 'dsthost', 'dstports', 'icmptype',
  318. 'specialtarget', 'expire', 'comment'):
  319. value = getattr(self, prop)
  320. if value is None:
  321. continue
  322. if value.rule is None:
  323. continue
  324. values.append(value.rule)
  325. return ' '.join(values)
  326. def __eq__(self, other):
  327. if isinstance(other, Rule):
  328. return self.rule == other.rule
  329. if isinstance(other, str):
  330. return self.rule == str
  331. return NotImplemented
  332. def __repr__(self):
  333. return 'Rule(\'{}\')'.format(self.rule)
  334. class Firewall(object):
  335. '''Firewal manager for a VM'''
  336. def __init__(self, vm):
  337. self.vm = vm
  338. self._rules = []
  339. self._policy = None
  340. self._loaded = False
  341. def load_rules(self):
  342. '''Force (re-)loading firewall rules'''
  343. rules_str = self.vm.qubesd_call(None, 'admin.vm.firewall.Get')
  344. rules = []
  345. for rule_str in rules_str.decode().splitlines():
  346. rules.append(Rule(rule_str))
  347. self._rules = rules
  348. self._loaded = True
  349. @property
  350. def rules(self):
  351. '''Firewall rules
  352. You can either copy them, edit and then assign new rules list to this
  353. property, or edit in-place and call :py:meth:`save_rules`.
  354. Once rules are loaded, they are cached. To reload rules,
  355. call :py:meth:`load_rules`.
  356. '''
  357. if not self._loaded:
  358. self.load_rules()
  359. return self._rules
  360. @rules.setter
  361. def rules(self, value):
  362. self.save_rules(value)
  363. self._rules = value
  364. def save_rules(self, rules=None):
  365. '''Save firewall rules. Needs to be called after in-place editing
  366. :py:attr:`rules`.
  367. '''
  368. if rules is None:
  369. rules = self._rules
  370. self.vm.qubesd_call(None, 'admin.vm.firewall.Set',
  371. payload=(''.join('{}\n'.format(rule.rule)
  372. for rule in rules)).encode('ascii'))
  373. @property
  374. def policy(self):
  375. '''Default action to take if no rule matches'''
  376. return Action('drop')
  377. def reload(self):
  378. '''Force reload the same firewall rules.
  379. Can be used for example to force again names resolution.
  380. '''
  381. self.vm.qubesd_call(None, 'admin.vm.firewall.Reload')