#!/usr/bin/env python ''' This code is intended to replace the very fragile firewall generation code that currently runs on dom0, by a lightweight daemon that applies the rules on the AppVM (with static IP), responding to rule changes made by the administrator on-the-fly. This daemon is injected into the VM as soon as qrexec capability becomes available on the recently-started VM. The daemon: 1. Reads the QubesDB key /qubes-fortress-iptables-rules. 2. Atomically applies the rules therein saved therein. The rules in /qubes-fortress-iptables-rules are generated by the dom0 code in 007FortressQubesProxyVM, which in turn are based on the firewall rules that the administrator has configured. These rules are generated and applied at the same time as the rules generated and applied on the ProxyVM attached to the AppVM, ensuring that the rules in the VM are kept in sync with the rules in the ProxyVM at all times. FIXME: The previous paragraph is still a work in progress. ''' import collections import logging import os import shutil import subprocess import sys NAME = "qubes-appvm-firewall" UNITDIRS = ["/usr/lib/systemd/system", "/lib/systemd/system"] DEPDIR = "/run/fortress" KEY = '/qubes-fortress-iptables-rules' CHAIN = 'FORTRESS-INPUT' class ReadError(Exception): pass def watch(key): subprocess.check_call(['qubesdb-watch', key]) def read(key): try: return subprocess.check_output(['qubesdb-read', '-r', key]) except subprocess.CalledProcessError as e: logging.error("error reading key %s: %s", key, e) raise ReadError() class Table(object): header = None chains = None rules = None footer = None original_chains = None original_rules = None def __init__(self, text): lines = text.splitlines(True) self.header = '' self.chains = collections.OrderedDict() self.rules = [] self.footer = '' mode = "header" for line in lines: if mode == "header": if line.startswith(":"): self.chains.update([line[1:].split(" ", 1)]) mode = "chains" else: self.header += line elif mode == "chains": if line.startswith("-"): self.rules.append(line) mode = "rules" else: self.chains.update([line[1:].split(" ", 1)]) elif mode == "rules": if line.startswith("COMMIT"): self.footer += line mode = "footer" else: self.rules.append(line) else: # mode == "footer": self.footer += line self.original_chains = collections.OrderedDict(self.chains.items()) self.original_rules = list(self.rules) def __str__(self): return self.render() def render(self, old=False): if old: chains = self.original_chains rules = self.original_rules else: chains = self.chains rules = self.rules return ( self.header + "".join(":%s %s" % x for x in chains.items()) + "".join(rules) + self.footer ) def dirty(self): return self.render() != self.render(True) def ensure_chain_present(self, name): if name not in self.chains: logging.info("Adding chain %s", name) self.chains[name] = '- [0:0]\n' def clear_chain(self, name): for n, rule in reversed(list(enumerate(self.rules))): if rule.startswith("-A %s " % name): self.rules.pop(n) def add_rule(self, rule, after_rule): original_after_rule = after_rule if not rule.endswith("\n"): rule += "\n" if not after_rule.endswith("\n"): after_rule += "\n" inserted = False if rule in self.rules: return for n, exrule in enumerate(self.rules): if exrule == after_rule: logging.info("Inserting rule %s", rule.strip()) self.rules.insert(n + 1, rule) inserted = True break if not inserted: logging.error("Could not insert rule %s", rule.strip()) raise KeyError(original_after_rule) def replace_rules(self, chain, ruletext): for rule in ruletext.splitlines(): if not rule.strip(): continue if not rule.startswith("-A %s " % chain): raise ValueError( "rule %s is not for chain %s" % ( rule.strip(), chain, ) ) self.ensure_chain_present(chain) self.clear_chain(chain) for rule in ruletext.splitlines(): if rule.startswith("-A %s " % chain): self.rules.append(rule + "\n") def commit(self): if not self.dirty(): return text = self.render() cmd = ['iptables-restore'] p = subprocess.Popen( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) out, _ = p.communicate(text) w = p.wait() if w != 0: logging.error("Rule changes commit failed with status %s: %s", w, out) raise subprocess.CalledProcessError(w, cmd, out) self.original_chains = collections.OrderedDict(self.chains.items()) self.original_rules = list(self.rules) logging.info("Rule changes committed") @classmethod def filter_from_iptables(klass): r = subprocess.check_output(['iptables-save', '-t', 'filter']) t = klass(r) return t def deploy(): deppath = os.path.join(DEPDIR, NAME) if not os.path.isdir(DEPDIR): os.makedirs(DEPDIR) shutil.copyfile(__file__, deppath) os.chmod(deppath, 0755) service = '''[Unit] Description=Qubes AppVM firewall updater After=qubes-iptables.service qubes-firewall.service Before=qubes-network.service network.target [Service] Type=simple ExecStart=%s main ''' % deppath for unitdir in UNITDIRS: if os.path.isdir(unitdir): break unitpath = os.path.join(unitdir, NAME + ".service") if not os.path.isfile(unitpath) or open(unitpath, "rb").read() != service: open(unitpath, "wb").write(service) subprocess.check_call(['systemctl', '--system', 'daemon-reload']) subprocess.check_call(['systemctl', 'restart', os.path.basename(unitpath)]) def main(): logging.basicConfig(level=logging.INFO) t = Table.filter_from_iptables() t.ensure_chain_present(CHAIN) t.add_rule('-A INPUT -j %s' % CHAIN, '-A INPUT -i lo -j ACCEPT') try: newrules = read(KEY) t.replace_rules(CHAIN, newrules) except ReadError: # Key may not exist at this time. logging.warning("Qubes DB key %s does not yet exist", KEY) t.commit() logging.info("Startup complete") while True: watch(KEY) try: newrules = read(KEY) except ReadError: # Key may have been deleted. logging.warning("Qubes DB key %s could not be read", KEY) continue logging.info("Rule changes detected") try: t.replace_rules(CHAIN, newrules) t.commit() except Exception: logging.exception("Rule changes could not be committed") try: cmd = sys.argv[1] except IndexError: cmd = 'main' cmd = locals()[cmd] sys.exit(cmd())