245 lines
7.6 KiB
Python
Executable File

#!/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())