network: rewrite qubes-firewall daemon
This rewrite is mainly to adopt new interface for Qubes 4.x. Main changes: - change language from bash to python, introduce qubesagent python package - support both nftables (preferred) and iptables - new interface (https://qubes-os.org/doc/vm-interface/) - IPv6 support - unit tests included - nftables version support running along with other firewall loaded Fixes QubesOS/qubes-issues#1815 QubesOS/qubes-issues#718
This commit is contained in:
parent
b50cba3f2c
commit
ee0a292b21
6
Makefile
6
Makefile
@ -146,6 +146,11 @@ install-common:
|
|||||||
$(MAKE) -C autostart-dropins install
|
$(MAKE) -C autostart-dropins install
|
||||||
install -m 0644 -D misc/fstab $(DESTDIR)/etc/fstab
|
install -m 0644 -D misc/fstab $(DESTDIR)/etc/fstab
|
||||||
|
|
||||||
|
# force /usr/bin before /bin to have /usr/bin/python instead of /bin/python
|
||||||
|
PATH="/usr/bin:$(PATH)" python setup.py install -O1 --root $(DESTDIR)
|
||||||
|
mkdir -p $(DESTDIR)$(SBINDIR)
|
||||||
|
mv $(DESTDIR)/usr/bin/qubes-firewall $(DESTDIR)$(SBINDIR)/qubes-firewall
|
||||||
|
|
||||||
install -D -m 0440 misc/qubes.sudoers $(DESTDIR)/etc/sudoers.d/qubes
|
install -D -m 0440 misc/qubes.sudoers $(DESTDIR)/etc/sudoers.d/qubes
|
||||||
install -D -m 0440 misc/sudoers.d_qt_x11_no_mitshm $(DESTDIR)/etc/sudoers.d/qt_x11_no_mitshm
|
install -D -m 0440 misc/sudoers.d_qt_x11_no_mitshm $(DESTDIR)/etc/sudoers.d/qt_x11_no_mitshm
|
||||||
install -D -m 0644 misc/20_tcp_timestamps.conf $(DESTDIR)/etc/sysctl.d/20_tcp_timestamps.conf
|
install -D -m 0644 misc/20_tcp_timestamps.conf $(DESTDIR)/etc/sysctl.d/20_tcp_timestamps.conf
|
||||||
@ -200,7 +205,6 @@ install-common:
|
|||||||
|
|
||||||
|
|
||||||
install -d $(DESTDIR)/$(SBINDIR)
|
install -d $(DESTDIR)/$(SBINDIR)
|
||||||
install network/qubes-firewall $(DESTDIR)/$(SBINDIR)/
|
|
||||||
install network/qubes-netwatcher $(DESTDIR)/$(SBINDIR)/
|
install network/qubes-netwatcher $(DESTDIR)/$(SBINDIR)/
|
||||||
|
|
||||||
install -d $(DESTDIR)$(BINDIR)
|
install -d $(DESTDIR)$(BINDIR)
|
||||||
|
1
debian/control
vendored
1
debian/control
vendored
@ -38,6 +38,7 @@ Depends:
|
|||||||
net-tools,
|
net-tools,
|
||||||
psmisc,
|
psmisc,
|
||||||
python2.7,
|
python2.7,
|
||||||
|
python-daemon,
|
||||||
python-gi,
|
python-gi,
|
||||||
python-xdg,
|
python-xdg,
|
||||||
python-dbus,
|
python-dbus,
|
||||||
|
@ -3,6 +3,8 @@
|
|||||||
:INPUT DROP [1:72]
|
:INPUT DROP [1:72]
|
||||||
:FORWARD DROP [0:0]
|
:FORWARD DROP [0:0]
|
||||||
:OUTPUT ACCEPT [0:0]
|
:OUTPUT ACCEPT [0:0]
|
||||||
|
:QBS-FORWARD - [0:0]
|
||||||
-A INPUT -i lo -j ACCEPT
|
-A INPUT -i lo -j ACCEPT
|
||||||
|
-A FORWARD -j QBS-FORWARD
|
||||||
COMMIT
|
COMMIT
|
||||||
# Completed on Tue Sep 25 16:00:20 2012
|
# Completed on Tue Sep 25 16:00:20 2012
|
||||||
|
@ -17,6 +17,7 @@ COMMIT
|
|||||||
:INPUT ACCEPT [168:11399]
|
:INPUT ACCEPT [168:11399]
|
||||||
:FORWARD ACCEPT [0:0]
|
:FORWARD ACCEPT [0:0]
|
||||||
:OUTPUT ACCEPT [128:12536]
|
:OUTPUT ACCEPT [128:12536]
|
||||||
|
:QBS-FORWARD - [0:0]
|
||||||
-A INPUT -i vif+ -p udp -m udp --dport 68 -j DROP
|
-A INPUT -i vif+ -p udp -m udp --dport 68 -j DROP
|
||||||
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
-A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
-A INPUT -i vif+ -p icmp -j ACCEPT
|
-A INPUT -i vif+ -p icmp -j ACCEPT
|
||||||
@ -24,6 +25,7 @@ COMMIT
|
|||||||
-A INPUT -i vif+ -j REJECT --reject-with icmp-host-prohibited
|
-A INPUT -i vif+ -j REJECT --reject-with icmp-host-prohibited
|
||||||
-A INPUT -j DROP
|
-A INPUT -j DROP
|
||||||
-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
-A FORWARD -j QBS-FORWARD
|
||||||
-A FORWARD -i vif+ -o vif+ -j DROP
|
-A FORWARD -i vif+ -o vif+ -j DROP
|
||||||
-A FORWARD -i vif+ -j ACCEPT
|
-A FORWARD -i vif+ -j ACCEPT
|
||||||
-A FORWARD -j DROP
|
-A FORWARD -j DROP
|
||||||
|
@ -1,58 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
PIDFILE=/var/run/qubes/qubes-firewall.pid
|
|
||||||
XENSTORE_IPTABLES=/qubes-iptables
|
|
||||||
XENSTORE_IPTABLES_HEADER=/qubes-iptables-header
|
|
||||||
XENSTORE_ERROR=/qubes-iptables-error
|
|
||||||
OLD_RULES=""
|
|
||||||
# PIDfile handling
|
|
||||||
[ -e "$PIDFILE" ] && kill -s 0 $(cat "$PIDFILE") 2>/dev/null && exit 0
|
|
||||||
echo $$ >$PIDFILE
|
|
||||||
|
|
||||||
trap 'exit 0' TERM
|
|
||||||
|
|
||||||
FIRST_TIME=yes
|
|
||||||
|
|
||||||
while true; do
|
|
||||||
|
|
||||||
echo "1" > /proc/sys/net/ipv4/ip_forward
|
|
||||||
|
|
||||||
if [ "$FIRST_TIME" ]; then
|
|
||||||
FIRST_TIME=
|
|
||||||
TRIGGER=reload
|
|
||||||
else
|
|
||||||
# Wait for changes in qubesdb file
|
|
||||||
/usr/bin/qubesdb-watch $XENSTORE_IPTABLES
|
|
||||||
TRIGGER=$(/usr/bin/qubesdb-read $XENSTORE_IPTABLES)
|
|
||||||
fi
|
|
||||||
|
|
||||||
if ! [ "$TRIGGER" = "reload" ]; then continue ; fi
|
|
||||||
|
|
||||||
# Disable forwarding to prevent potential "leaks" that might
|
|
||||||
# be bypassing the firewall or some proxy service (e.g. tor)
|
|
||||||
# during the time when the rules are being (re)applied
|
|
||||||
echo "0" > /proc/sys/net/ipv4/ip_forward
|
|
||||||
|
|
||||||
RULES=$(qubesdb-read $XENSTORE_IPTABLES_HEADER)
|
|
||||||
IPTABLES_SAVE=$(iptables-save | sed '/^\*filter/,/^COMMIT/d')
|
|
||||||
OUT=$(printf '%s\n%s\n' "$RULES" "$IPTABLES_SAVE" | sed 's/\\n\|\\x0a/\n/g' | iptables-restore 2>&1 || true)
|
|
||||||
|
|
||||||
for i in $(qubesdb-list -f /qubes-iptables-domainrules) ; do
|
|
||||||
RULES=$(qubesdb-read "$i")
|
|
||||||
ERRS=$(printf '%s\n' "$RULES" | sed 's/\\n\|\\x0a/\n/g' | /sbin/iptables-restore -n 2>&1 || true)
|
|
||||||
if [ -n "$ERRS" ]; then
|
|
||||||
echo "Failed applying rules for $i: $ERRS" >&2
|
|
||||||
OUT="$OUT$ERRS"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
qubesdb-write $XENSTORE_ERROR "$OUT"
|
|
||||||
if [ -n "$OUT" ]; then
|
|
||||||
DISPLAY=:0 /usr/bin/notify-send -t 3000 "Firewall loading error ($(hostname))" "$OUT" || :
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if user didn't define some custom rules to be applied as well...
|
|
||||||
[ -x /rw/config/qubes-firewall-user-script ] && /rw/config/qubes-firewall-user-script
|
|
||||||
# XXX: Backward compatibility
|
|
||||||
[ -x /rw/config/qubes_firewall_user_script ] && /rw/config/qubes_firewall_user_script
|
|
||||||
done
|
|
0
qubesagent/__init__.py
Normal file
0
qubesagent/__init__.py
Normal file
576
qubesagent/firewall.py
Executable file
576
qubesagent/firewall.py
Executable file
@ -0,0 +1,576 @@
|
|||||||
|
#!/usr/bin/python2 -O
|
||||||
|
# vim: fileencoding=utf-8
|
||||||
|
|
||||||
|
#
|
||||||
|
# The Qubes OS Project, https://www.qubes-os.org/
|
||||||
|
#
|
||||||
|
# Copyright (C) 2016
|
||||||
|
# Marek Marczykowski-Górecki <marmarek@invisiblethingslab.com>
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License along
|
||||||
|
# with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
#
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
from distutils import spawn
|
||||||
|
|
||||||
|
import daemon
|
||||||
|
|
||||||
|
import qubesdb
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import signal
|
||||||
|
|
||||||
|
|
||||||
|
class RuleParseError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RuleApplyError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallWorker(object):
|
||||||
|
def __init__(self):
|
||||||
|
self.terminate_requested = False
|
||||||
|
self.qdb = qubesdb.QubesDB()
|
||||||
|
self.log = logging.getLogger('qubes.firewall')
|
||||||
|
self.log.addHandler(logging.StreamHandler(sys.stderr))
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
'''Create appropriate chains/tables'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
'''Remove tables/chains - reverse work done by init'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def apply_rules(self, source_addr, rules):
|
||||||
|
'''Apply rules in given source address'''
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def read_rules(self, target):
|
||||||
|
'''Read rules from QubesDB and return them as a list of dicts'''
|
||||||
|
entries = self.qdb.multiread('/qubes-firewall/{}/'.format(target))
|
||||||
|
assert isinstance(entries, dict)
|
||||||
|
# drop full path
|
||||||
|
entries = dict(((k.split('/')[3], v) for k, v in entries.items()))
|
||||||
|
if 'policy' not in entries:
|
||||||
|
raise RuleParseError('No \'policy\' defined')
|
||||||
|
policy = entries.pop('policy')
|
||||||
|
rules = []
|
||||||
|
for ruleno, rule in sorted(entries.items()):
|
||||||
|
if len(ruleno) != 4 or not ruleno.isdigit():
|
||||||
|
raise RuleParseError(
|
||||||
|
'Unexpected non-rule found: {}={}'.format(ruleno, rule))
|
||||||
|
rule_dict = dict(elem.split('=') for elem in rule.split(' '))
|
||||||
|
if 'action' not in rule_dict:
|
||||||
|
raise RuleParseError('Rule \'{}\' lack action'.format(rule))
|
||||||
|
rules.append(rule_dict)
|
||||||
|
rules.append({'action': policy})
|
||||||
|
return rules
|
||||||
|
|
||||||
|
def list_targets(self):
|
||||||
|
return set(t.split('/')[2] for t in self.qdb.list('/qubes-firewall/'))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_ip6(addr):
|
||||||
|
return addr.count(':') > 0
|
||||||
|
|
||||||
|
def log_error(self, msg):
|
||||||
|
self.log.error(msg)
|
||||||
|
subprocess.call(
|
||||||
|
['notify-send', '-t', '3000', msg],
|
||||||
|
env=os.environ.copy().update({'DISPLAY': ':0'})
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle_addr(self, addr):
|
||||||
|
try:
|
||||||
|
rules = self.read_rules(addr)
|
||||||
|
self.apply_rules(addr, rules)
|
||||||
|
except RuleParseError as e:
|
||||||
|
self.log_error(
|
||||||
|
'Failed to parse rules for {} ({}), blocking traffic'.format(
|
||||||
|
addr, str(e)
|
||||||
|
))
|
||||||
|
self.apply_rules(addr, [{'action': 'drop'}])
|
||||||
|
except RuleApplyError as e:
|
||||||
|
self.log_error(
|
||||||
|
'Failed to apply rules for {} ({}), blocking traffic'.format(
|
||||||
|
addr, str(e))
|
||||||
|
)
|
||||||
|
# retry with fallback rules
|
||||||
|
try:
|
||||||
|
self.apply_rules(addr, [{'action': 'drop'}])
|
||||||
|
except RuleApplyError:
|
||||||
|
self.log_error(
|
||||||
|
'Failed to block traffic for {}'.format(addr))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dns_addresses(family=None):
|
||||||
|
with open('/etc/resolv.conf') as resolv:
|
||||||
|
for line in resolv.readlines():
|
||||||
|
line = line.strip()
|
||||||
|
if line.startswith('nameserver'):
|
||||||
|
if line.count('.') == 3 and (family or 4) == 4:
|
||||||
|
yield line.split(' ')[1]
|
||||||
|
elif line.count(':') and (family or 6) == 6:
|
||||||
|
yield line.split(' ')[1]
|
||||||
|
|
||||||
|
def main(self):
|
||||||
|
self.terminate_requested = False
|
||||||
|
self.init()
|
||||||
|
# initial load
|
||||||
|
for source_addr in self.list_targets():
|
||||||
|
self.handle_addr(source_addr)
|
||||||
|
self.qdb.watch('/qubes-firewall/')
|
||||||
|
try:
|
||||||
|
for watch_path in iter(self.qdb.read_watch, None):
|
||||||
|
# ignore writing rules itself - wait for final write at
|
||||||
|
# source_addr level empty write (/qubes-firewall/SOURCE_ADDR)
|
||||||
|
if watch_path.count('/') > 2:
|
||||||
|
continue
|
||||||
|
source_addr = watch_path.split('/')[2]
|
||||||
|
self.handle_addr(source_addr)
|
||||||
|
except OSError: # EINTR
|
||||||
|
# signal received, don't continue the loop
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.cleanup()
|
||||||
|
|
||||||
|
def terminate(self):
|
||||||
|
self.terminate_requested = True
|
||||||
|
|
||||||
|
|
||||||
|
class IptablesWorker(FirewallWorker):
|
||||||
|
supported_rule_opts = ['action', 'proto', 'dst4', 'dst6', 'dsthost',
|
||||||
|
'dstports', 'specialtarget', 'icmptype']
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super(IptablesWorker, self).__init__()
|
||||||
|
self.chains = {
|
||||||
|
4: set(),
|
||||||
|
6: set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def chain_for_addr(addr):
|
||||||
|
'''Generate iptables chain name for given source address address'''
|
||||||
|
return 'qbs-' + addr.replace('.', '-').replace(':', '-')
|
||||||
|
|
||||||
|
def run_ipt(self, family, args, **kwargs):
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
if family == 6:
|
||||||
|
subprocess.check_call(['ip6tables'] + args, **kwargs)
|
||||||
|
else:
|
||||||
|
subprocess.check_call(['iptables'] + args, **kwargs)
|
||||||
|
|
||||||
|
def run_ipt_restore(self, family, args):
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
if family == 6:
|
||||||
|
return subprocess.Popen(['ip6tables-restore'] + args,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
else:
|
||||||
|
return subprocess.Popen(['iptables-restore'] + args,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
|
||||||
|
def create_chain(self, addr, chain, family):
|
||||||
|
'''
|
||||||
|
Create iptables chain and hook traffic coming from `addr` to it.
|
||||||
|
|
||||||
|
:param addr: source IP from which traffic should be handled by the
|
||||||
|
chain
|
||||||
|
:param chain: name of the chain to create
|
||||||
|
:param family: address family (4 or 6)
|
||||||
|
:return: None
|
||||||
|
'''
|
||||||
|
|
||||||
|
self.run_ipt(family, ['-N', chain])
|
||||||
|
self.run_ipt(family,
|
||||||
|
['-A', 'QBS-FORWARD', '-s', addr, '-j', chain])
|
||||||
|
self.chains[family].add(chain)
|
||||||
|
|
||||||
|
def prepare_rules(self, chain, rules, family):
|
||||||
|
'''
|
||||||
|
Helper function to translate rules list into input for iptables-restore
|
||||||
|
|
||||||
|
:param chain: name of the chain to put rules into
|
||||||
|
:param rules: list of rules
|
||||||
|
:param family: address family (4 or 6)
|
||||||
|
:return: input for iptables-restore
|
||||||
|
:rtype: str
|
||||||
|
'''
|
||||||
|
|
||||||
|
iptables = "*filter\n"
|
||||||
|
|
||||||
|
fullmask = '/128' if family == 6 else '/32'
|
||||||
|
|
||||||
|
dns = list(addr + fullmask for addr in self.dns_addresses(family))
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
unsupported_opts = set(rule.keys()).difference(
|
||||||
|
set(self.supported_rule_opts))
|
||||||
|
if unsupported_opts:
|
||||||
|
raise RuleParseError(
|
||||||
|
'Unsupported rule option(s): {!s}'.format(unsupported_opts))
|
||||||
|
if 'dst4' in rule and family == 6:
|
||||||
|
raise RuleParseError('IPv4 rule found for IPv6 address')
|
||||||
|
if 'dst6' in rule and family == 4:
|
||||||
|
raise RuleParseError('dst6 rule found for IPv4 address')
|
||||||
|
|
||||||
|
if 'proto' in rule:
|
||||||
|
protos = [rule['proto']]
|
||||||
|
else:
|
||||||
|
protos = None
|
||||||
|
|
||||||
|
if 'dst4' in rule:
|
||||||
|
dsthosts = [rule['dst4']]
|
||||||
|
elif 'dst6' in rule:
|
||||||
|
dsthosts = [rule['dst6']]
|
||||||
|
elif 'dsthost' in rule:
|
||||||
|
addrinfo = socket.getaddrinfo(rule['dsthost'], None,
|
||||||
|
(socket.AF_INET6 if family == 6 else socket.AF_INET))
|
||||||
|
dsthosts = set(item[4][0] + fullmask for item in addrinfo)
|
||||||
|
else:
|
||||||
|
dsthosts = None
|
||||||
|
|
||||||
|
if 'dstports' in rule:
|
||||||
|
dstports = rule['dstports'].replace('-', ':')
|
||||||
|
else:
|
||||||
|
dstports = None
|
||||||
|
|
||||||
|
if rule.get('specialtarget', None) == 'dns':
|
||||||
|
if dstports not in ('53:53', None):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
dstports = '53:53'
|
||||||
|
if not dns:
|
||||||
|
continue
|
||||||
|
if protos is not None:
|
||||||
|
protos = {'tcp', 'udp'}.intersection(protos)
|
||||||
|
else:
|
||||||
|
protos = {'tcp', 'udp'}
|
||||||
|
|
||||||
|
if dsthosts is not None:
|
||||||
|
dsthosts = set(dns).intersection(dsthosts)
|
||||||
|
else:
|
||||||
|
dsthosts = dns
|
||||||
|
|
||||||
|
if 'icmptype' in rule:
|
||||||
|
icmptype = rule['icmptype']
|
||||||
|
else:
|
||||||
|
icmptype = None
|
||||||
|
|
||||||
|
# make them iterable
|
||||||
|
if protos is None:
|
||||||
|
protos = [None]
|
||||||
|
if dsthosts is None:
|
||||||
|
dsthosts = [None]
|
||||||
|
|
||||||
|
# sorting here is only to ease writing tests
|
||||||
|
for proto in sorted(protos):
|
||||||
|
for dsthost in sorted(dsthosts):
|
||||||
|
ipt_rule = '-A {}'.format(chain)
|
||||||
|
if dsthost is not None:
|
||||||
|
ipt_rule += ' -d {}'.format(dsthost)
|
||||||
|
if proto is not None:
|
||||||
|
ipt_rule += ' -p {}'.format(proto)
|
||||||
|
if dstports is not None:
|
||||||
|
ipt_rule += ' --dport {}'.format(dstports)
|
||||||
|
if icmptype is not None:
|
||||||
|
ipt_rule += ' --icmp-type {}'.format(icmptype)
|
||||||
|
ipt_rule += ' -j {}\n'.format(
|
||||||
|
str(rule['action']).upper())
|
||||||
|
iptables += ipt_rule
|
||||||
|
|
||||||
|
iptables += 'COMMIT\n'
|
||||||
|
return iptables
|
||||||
|
|
||||||
|
def apply_rules_family(self, source, rules, family):
|
||||||
|
'''
|
||||||
|
Apply rules for given source address.
|
||||||
|
Handle only rules for given address family (IPv4 or IPv6).
|
||||||
|
|
||||||
|
:param source: source address
|
||||||
|
:param rules: rules list
|
||||||
|
:param family: address family, either 4 or 6
|
||||||
|
:return: None
|
||||||
|
'''
|
||||||
|
|
||||||
|
chain = self.chain_for_addr(source)
|
||||||
|
if chain not in self.chains[family]:
|
||||||
|
self.create_chain(source, chain, family)
|
||||||
|
|
||||||
|
iptables = self.prepare_rules(chain, rules, family)
|
||||||
|
try:
|
||||||
|
self.run_ipt(family, ['-F', chain])
|
||||||
|
p = self.run_ipt_restore(family, ['-n'])
|
||||||
|
(output, _) = p.communicate(iptables)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise RuleApplyError(
|
||||||
|
'iptables-restore failed: {}'.format(output))
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
raise RuleApplyError('\'iptables -F {}\' failed: {}'.format(
|
||||||
|
chain, e.output))
|
||||||
|
|
||||||
|
def apply_rules(self, source, rules):
|
||||||
|
if self.is_ip6(source):
|
||||||
|
self.apply_rules_family(source, rules, 6)
|
||||||
|
else:
|
||||||
|
self.apply_rules_family(source, rules, 4)
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
# make sure 'QBS_FORWARD' chain exists - should be created before
|
||||||
|
# starting qubes-firewall
|
||||||
|
try:
|
||||||
|
self.run_ipt(4, ['-nL', 'QBS-FORWARD'])
|
||||||
|
self.run_ipt(6, ['-nL', 'QBS-FORWARD'])
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
self.log_error('\'QBS-FORWARD\' chain not found, create it first')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
for family in (4, 6):
|
||||||
|
self.run_ipt(family, ['-F', 'QBS-FORWARD'])
|
||||||
|
for chain in self.chains[family]:
|
||||||
|
self.run_ipt(family, ['-F', chain])
|
||||||
|
self.run_ipt(family, ['-X', chain])
|
||||||
|
|
||||||
|
|
||||||
|
class NftablesWorker(FirewallWorker):
|
||||||
|
supported_rule_opts = ['action', 'proto', 'dst4', 'dst6', 'dsthost',
|
||||||
|
'dstports', 'specialtarget', 'icmptype']
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super(NftablesWorker, self).__init__()
|
||||||
|
self.chains = {
|
||||||
|
4: set(),
|
||||||
|
6: set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def chain_for_addr(addr):
|
||||||
|
'''Generate iptables chain name for given source address address'''
|
||||||
|
return 'qbs-' + addr.replace('.', '-').replace(':', '-')
|
||||||
|
|
||||||
|
def run_nft(self, nft_input):
|
||||||
|
# pylint: disable=no-self-use
|
||||||
|
p = subprocess.Popen(['nft', '-f', '/dev/stdin'],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
stdout, _ = p.communicate(nft_input)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise RuleApplyError('nft failed: {}'.format(stdout))
|
||||||
|
|
||||||
|
def create_chain(self, addr, chain, family):
|
||||||
|
'''
|
||||||
|
Create iptables chain and hook traffic coming from `addr` to it.
|
||||||
|
|
||||||
|
:param addr: source IP from which traffic should be handled by the
|
||||||
|
chain
|
||||||
|
:param chain: name of the chain to create
|
||||||
|
:param family: address family (4 or 6)
|
||||||
|
:return: None
|
||||||
|
'''
|
||||||
|
nft_input = (
|
||||||
|
'table {family} {table} {{\n'
|
||||||
|
' chain {chain} {{\n'
|
||||||
|
' }}\n'
|
||||||
|
' chain forward {{\n'
|
||||||
|
' {family} saddr {ip} jump {chain}\n'
|
||||||
|
' }}\n'
|
||||||
|
'}}\n'.format(
|
||||||
|
family=("ip6" if family == 6 else "ip"),
|
||||||
|
table='qubes-firewall',
|
||||||
|
chain=chain,
|
||||||
|
ip=addr,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.run_nft(nft_input)
|
||||||
|
self.chains[family].add(chain)
|
||||||
|
|
||||||
|
def prepare_rules(self, chain, rules, family):
|
||||||
|
'''
|
||||||
|
Helper function to translate rules list into input for iptables-restore
|
||||||
|
|
||||||
|
:param chain: name of the chain to put rules into
|
||||||
|
:param rules: list of rules
|
||||||
|
:param family: address family (4 or 6)
|
||||||
|
:return: input for iptables-restore
|
||||||
|
:rtype: str
|
||||||
|
'''
|
||||||
|
|
||||||
|
assert family in (4, 6)
|
||||||
|
nft_rules = []
|
||||||
|
ip_match = 'ip6' if family == 6 else 'ip'
|
||||||
|
|
||||||
|
fullmask = '/128' if family == 6 else '/32'
|
||||||
|
|
||||||
|
dns = list(addr + fullmask for addr in self.dns_addresses(family))
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
unsupported_opts = set(rule.keys()).difference(
|
||||||
|
set(self.supported_rule_opts))
|
||||||
|
if unsupported_opts:
|
||||||
|
raise RuleParseError(
|
||||||
|
'Unsupported rule option(s): {!s}'.format(unsupported_opts))
|
||||||
|
if 'dst4' in rule and family == 6:
|
||||||
|
raise RuleParseError('IPv4 rule found for IPv6 address')
|
||||||
|
if 'dst6' in rule and family == 4:
|
||||||
|
raise RuleParseError('dst6 rule found for IPv4 address')
|
||||||
|
|
||||||
|
nft_rule = ""
|
||||||
|
|
||||||
|
if 'proto' in rule:
|
||||||
|
if family == 4:
|
||||||
|
nft_rule += ' ip protocol {}'.format(rule['proto'])
|
||||||
|
elif family == 6:
|
||||||
|
proto = 'icmpv6' if rule['proto'] == 'icmp' \
|
||||||
|
else rule['proto']
|
||||||
|
nft_rule += ' ip6 nexthdr {}'.format(proto)
|
||||||
|
|
||||||
|
|
||||||
|
if 'dst4' in rule:
|
||||||
|
nft_rule += ' ip daddr {}'.format(rule['dst4'])
|
||||||
|
elif 'dst6' in rule:
|
||||||
|
nft_rule += ' ip6 daddr {}'.format(rule['dst6'])
|
||||||
|
elif 'dsthost' in rule:
|
||||||
|
addrinfo = socket.getaddrinfo(rule['dsthost'], None,
|
||||||
|
(socket.AF_INET6 if family == 6 else socket.AF_INET))
|
||||||
|
nft_rule += ' {} daddr {{ {} }}'.format(ip_match,
|
||||||
|
', '.join(set(item[4][0] + fullmask for item in addrinfo)))
|
||||||
|
|
||||||
|
if 'dstports' in rule:
|
||||||
|
dstports = rule['dstports']
|
||||||
|
if len(set(dstports.split('-'))) == 1:
|
||||||
|
dstports = dstports.split('-')[0]
|
||||||
|
else:
|
||||||
|
dstports = None
|
||||||
|
|
||||||
|
if rule.get('specialtarget', None) == 'dns':
|
||||||
|
if dstports not in ('53', None):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
dstports = '53'
|
||||||
|
if not dns:
|
||||||
|
continue
|
||||||
|
nft_rule += ' {} daddr {{ {} }}'.format(ip_match, ', '.join(
|
||||||
|
dns))
|
||||||
|
|
||||||
|
if 'icmptype' in rule:
|
||||||
|
if family == 4:
|
||||||
|
nft_rule += ' icmp type {}'.format(rule['icmptype'])
|
||||||
|
elif family == 6:
|
||||||
|
nft_rule += ' icmpv6 type {}'.format(rule['icmptype'])
|
||||||
|
|
||||||
|
# now duplicate rules for tcp/udp if needed
|
||||||
|
# it isn't possible to specify "tcp dport xx || udp dport xx" in
|
||||||
|
# one rule
|
||||||
|
if dstports is not None:
|
||||||
|
if 'proto' not in rule:
|
||||||
|
nft_rules.append(
|
||||||
|
nft_rule + ' tcp dport {} {}'.format(
|
||||||
|
dstports, rule['action']))
|
||||||
|
nft_rules.append(
|
||||||
|
nft_rule + ' udp dport {} {}'.format(
|
||||||
|
dstports, rule['action']))
|
||||||
|
else:
|
||||||
|
nft_rules.append(
|
||||||
|
nft_rule + ' {} dport {} {}'.format(
|
||||||
|
rule['proto'], dstports, rule['action']))
|
||||||
|
else:
|
||||||
|
nft_rules.append(nft_rule + ' ' + rule['action'])
|
||||||
|
|
||||||
|
return (
|
||||||
|
'flush chain {family} {table} {chain}\n'
|
||||||
|
'table {family} {table} {{\n'
|
||||||
|
' chain {chain} {{\n'
|
||||||
|
' {rules}\n'
|
||||||
|
' }}\n'
|
||||||
|
'}}\n'.format(
|
||||||
|
family=('ip6' if family == 6 else 'ip'),
|
||||||
|
table='qubes-firewall',
|
||||||
|
chain=chain,
|
||||||
|
rules='\n '.join(nft_rules)
|
||||||
|
))
|
||||||
|
|
||||||
|
def apply_rules_family(self, source, rules, family):
|
||||||
|
'''
|
||||||
|
Apply rules for given source address.
|
||||||
|
Handle only rules for given address family (IPv4 or IPv6).
|
||||||
|
|
||||||
|
:param source: source address
|
||||||
|
:param rules: rules list
|
||||||
|
:param family: address family, either 4 or 6
|
||||||
|
:return: None
|
||||||
|
'''
|
||||||
|
|
||||||
|
chain = self.chain_for_addr(source)
|
||||||
|
if chain not in self.chains[family]:
|
||||||
|
self.create_chain(source, chain, family)
|
||||||
|
|
||||||
|
self.run_nft(self.prepare_rules(chain, rules, family))
|
||||||
|
|
||||||
|
def apply_rules(self, source, rules):
|
||||||
|
if self.is_ip6(source):
|
||||||
|
self.apply_rules_family(source, rules, 6)
|
||||||
|
else:
|
||||||
|
self.apply_rules_family(source, rules, 4)
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
# make sure 'QBS_FORWARD' chain exists - should be created before
|
||||||
|
# starting qubes-firewall
|
||||||
|
nft_init = (
|
||||||
|
'table {family} qubes-firewall {{\n'
|
||||||
|
' chain forward {{\n'
|
||||||
|
' type filter hook forward priority 0;\n'
|
||||||
|
' }}\n'
|
||||||
|
'}}\n'
|
||||||
|
)
|
||||||
|
nft_init = ''.join(
|
||||||
|
nft_init.format(family=family) for family in ('ip', 'ip6'))
|
||||||
|
self.run_nft(nft_init)
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
nft_cleanup = (
|
||||||
|
'delete table ip qubes-firewall\n'
|
||||||
|
'delete table ip6 qubes-firewall\n'
|
||||||
|
)
|
||||||
|
self.run_nft(nft_cleanup)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if spawn.find_executable('nft'):
|
||||||
|
worker = NftablesWorker()
|
||||||
|
else:
|
||||||
|
worker = IptablesWorker()
|
||||||
|
context = daemon.DaemonContext()
|
||||||
|
context.stderr = sys.stderr
|
||||||
|
context.detach_process = False
|
||||||
|
context.files_preserve = [worker.qdb.watch_fd()]
|
||||||
|
context.signal_map = {
|
||||||
|
signal.SIGTERM: lambda _signal, _stack: worker.terminate(),
|
||||||
|
}
|
||||||
|
with context:
|
||||||
|
worker.main()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
507
qubesagent/test_firewall.py
Normal file
507
qubesagent/test_firewall.py
Normal file
@ -0,0 +1,507 @@
|
|||||||
|
import logging
|
||||||
|
from unittest import TestCase
|
||||||
|
|
||||||
|
import qubesagent.firewall
|
||||||
|
|
||||||
|
|
||||||
|
class DummyIptablesRestore(object):
|
||||||
|
# pylint: disable=too-few-public-methods
|
||||||
|
def __init__(self, worker_mock, family):
|
||||||
|
self._worker_mock = worker_mock
|
||||||
|
self._family = family
|
||||||
|
self.returncode = 0
|
||||||
|
|
||||||
|
def communicate(self, stdin=None):
|
||||||
|
self._worker_mock.loaded_iptables[self._family] = stdin
|
||||||
|
return ("", None)
|
||||||
|
|
||||||
|
class DummyQubesDB(object):
|
||||||
|
def __init__(self, worker_mock):
|
||||||
|
self._worker_mock = worker_mock
|
||||||
|
self.entries = {}
|
||||||
|
self.pending_watches = []
|
||||||
|
|
||||||
|
def read(self, key):
|
||||||
|
try:
|
||||||
|
return self.entries[key]
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def multiread(self, prefix):
|
||||||
|
result = {}
|
||||||
|
for key, value in self.entries.items():
|
||||||
|
if key.startswith(prefix):
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
def list(self, prefix):
|
||||||
|
result = []
|
||||||
|
for key in self.entries.keys():
|
||||||
|
if key.startswith(prefix):
|
||||||
|
result.append(key)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def watch(self, path):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def read_watch(self):
|
||||||
|
try:
|
||||||
|
return self.pending_watches.pop(0)
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FirewallWorker(qubesagent.firewall.FirewallWorker):
|
||||||
|
def __init__(self):
|
||||||
|
# pylint: disable=super-init-not-called
|
||||||
|
# don't call super on purpose - avoid connecting to QubesDB
|
||||||
|
# super(FirewallWorker, self).__init__()
|
||||||
|
self.qdb = DummyQubesDB(self)
|
||||||
|
self.log = logging.getLogger('qubes.tests')
|
||||||
|
|
||||||
|
self.init_called = False
|
||||||
|
self.cleanup_called = False
|
||||||
|
self.rules = {}
|
||||||
|
|
||||||
|
def apply_rules(self, source_addr, rules):
|
||||||
|
self.rules[source_addr] = rules
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self.init_called = True
|
||||||
|
|
||||||
|
def init(self):
|
||||||
|
self.cleanup_called = True
|
||||||
|
|
||||||
|
|
||||||
|
class IptablesWorker(qubesagent.firewall.IptablesWorker):
|
||||||
|
'''Override methods actually modifying system state to only log what
|
||||||
|
would be done'''
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# pylint: disable=super-init-not-called
|
||||||
|
# don't call super on purpose - avoid connecting to QubesDB
|
||||||
|
# super(IptablesWorker, self).__init__()
|
||||||
|
# copied __init__:
|
||||||
|
self.qdb = DummyQubesDB(self)
|
||||||
|
self.log = logging.getLogger('qubes.tests')
|
||||||
|
self.chains = {
|
||||||
|
4: set(),
|
||||||
|
6: set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: instead of really running `iptables`, log what would be called
|
||||||
|
self.called_commands = {
|
||||||
|
4: [],
|
||||||
|
6: [],
|
||||||
|
}
|
||||||
|
#: rules that would be loaded with `iptables-restore`
|
||||||
|
self.loaded_iptables = {
|
||||||
|
4: None,
|
||||||
|
6: None,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_ipt(self, family, args, **kwargs):
|
||||||
|
self.called_commands[family].append(args)
|
||||||
|
|
||||||
|
def run_ipt_restore(self, family, args):
|
||||||
|
return DummyIptablesRestore(self, family)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dns_addresses(family=None):
|
||||||
|
if family == 4:
|
||||||
|
return ['1.1.1.1', '2.2.2.2']
|
||||||
|
else:
|
||||||
|
return ['2001::1', '2001::2']
|
||||||
|
|
||||||
|
|
||||||
|
class NftablesWorker(qubesagent.firewall.NftablesWorker):
|
||||||
|
'''Override methods actually modifying system state to only log what
|
||||||
|
would be done'''
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# pylint: disable=super-init-not-called
|
||||||
|
# don't call super on purpose - avoid connecting to QubesDB
|
||||||
|
# super(IptablesWorker, self).__init__()
|
||||||
|
# copied __init__:
|
||||||
|
self.qdb = DummyQubesDB(self)
|
||||||
|
self.log = logging.getLogger('qubes.tests')
|
||||||
|
self.chains = {
|
||||||
|
4: set(),
|
||||||
|
6: set(),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: instead of really running `nft`, log what would be loaded
|
||||||
|
#: rules that would be loaded with `nft`
|
||||||
|
self.loaded_rules = []
|
||||||
|
|
||||||
|
def run_nft(self, nft_input):
|
||||||
|
self.loaded_rules.append(nft_input)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def dns_addresses(family=None):
|
||||||
|
if family == 4:
|
||||||
|
return ['1.1.1.1', '2.2.2.2']
|
||||||
|
else:
|
||||||
|
return ['2001::1', '2001::2']
|
||||||
|
|
||||||
|
|
||||||
|
class TestIptablesWorker(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TestIptablesWorker, self).setUp()
|
||||||
|
self.obj = IptablesWorker()
|
||||||
|
|
||||||
|
def test_000_chain_for_addr(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.obj.chain_for_addr('10.137.0.1'), 'qbs-10-137-0-1')
|
||||||
|
self.assertEqual(
|
||||||
|
self.obj.chain_for_addr('fd09:24ef:4179:0000::3'),
|
||||||
|
'qbs-fd09-24ef-4179-0000--3')
|
||||||
|
|
||||||
|
def test_001_create_chain(self):
|
||||||
|
testdata = [
|
||||||
|
(4, '10.137.0.1', 'qbs-10-137-0-1'),
|
||||||
|
(6, 'fd09:24ef:4179:0000::3', 'qbs-fd09-24ef-4179-0000--3')
|
||||||
|
]
|
||||||
|
for family, addr, chain in testdata:
|
||||||
|
self.obj.create_chain(addr, chain, family)
|
||||||
|
self.assertEqual(self.obj.called_commands[family],
|
||||||
|
[['-N', chain],
|
||||||
|
['-A', 'QBS-FORWARD', '-s', addr, '-j', chain]])
|
||||||
|
|
||||||
|
def test_002_prepare_rules4(self):
|
||||||
|
rules = [
|
||||||
|
{'action': 'accept', 'proto': 'tcp',
|
||||||
|
'dstports': '80-80', 'dst4': '1.2.3.0/24'},
|
||||||
|
{'action': 'accept', 'proto': 'udp',
|
||||||
|
'dstports': '443-1024', 'dsthost': 'yum.qubes-os.org'},
|
||||||
|
{'action': 'accept', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'udp', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'icmp'},
|
||||||
|
{'action': 'drop'},
|
||||||
|
]
|
||||||
|
expected_iptables = (
|
||||||
|
"*filter\n"
|
||||||
|
"-A chain -d 1.2.3.0/24 -p tcp --dport 80:80 -j ACCEPT\n"
|
||||||
|
"-A chain -d 82.94.215.165/32 -p udp --dport 443:1024 -j ACCEPT\n"
|
||||||
|
"-A chain -d 1.1.1.1/32 -p tcp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 2.2.2.2/32 -p tcp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 1.1.1.1/32 -p udp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 2.2.2.2/32 -p udp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 1.1.1.1/32 -p udp --dport 53:53 -j DROP\n"
|
||||||
|
"-A chain -d 2.2.2.2/32 -p udp --dport 53:53 -j DROP\n"
|
||||||
|
"-A chain -p icmp -j DROP\n"
|
||||||
|
"-A chain -j DROP\n"
|
||||||
|
"COMMIT\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(self.obj.prepare_rules('chain', rules, 4),
|
||||||
|
expected_iptables)
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.prepare_rules('chain', [{'unknown': 'xxx'}], 4)
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.prepare_rules('chain', [{'dst6': 'a::b'}], 4)
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.prepare_rules('chain', [{'dst4': '3.3.3.3'}], 6)
|
||||||
|
|
||||||
|
def test_003_prepare_rules6(self):
|
||||||
|
rules = [
|
||||||
|
{'action': 'accept', 'proto': 'tcp',
|
||||||
|
'dstports': '80-80', 'dst6': 'a::b/128'},
|
||||||
|
{'action': 'accept', 'proto': 'tcp',
|
||||||
|
'dsthost': 'ripe.net'},
|
||||||
|
{'action': 'accept', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'udp', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'icmp'},
|
||||||
|
{'action': 'drop'},
|
||||||
|
]
|
||||||
|
expected_iptables = (
|
||||||
|
"*filter\n"
|
||||||
|
"-A chain -d a::b/128 -p tcp --dport 80:80 -j ACCEPT\n"
|
||||||
|
"-A chain -d 2001:67c:2e8:22::c100:68b/128 -p tcp -j ACCEPT\n"
|
||||||
|
"-A chain -d 2001::1/128 -p tcp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 2001::2/128 -p tcp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 2001::1/128 -p udp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 2001::2/128 -p udp --dport 53:53 -j ACCEPT\n"
|
||||||
|
"-A chain -d 2001::1/128 -p udp --dport 53:53 -j DROP\n"
|
||||||
|
"-A chain -d 2001::2/128 -p udp --dport 53:53 -j DROP\n"
|
||||||
|
"-A chain -p icmp -j DROP\n"
|
||||||
|
"-A chain -j DROP\n"
|
||||||
|
"COMMIT\n"
|
||||||
|
)
|
||||||
|
self.assertEqual(self.obj.prepare_rules('chain', rules, 6),
|
||||||
|
expected_iptables)
|
||||||
|
|
||||||
|
def test_004_apply_rules4(self):
|
||||||
|
rules = [{'action': 'accept'}]
|
||||||
|
chain = 'qbs-10-137-0-1'
|
||||||
|
self.obj.apply_rules('10.137.0.1', rules)
|
||||||
|
self.assertEqual(self.obj.called_commands[4],
|
||||||
|
[
|
||||||
|
['-N', chain],
|
||||||
|
['-A', 'QBS-FORWARD', '-s', '10.137.0.1', '-j', chain],
|
||||||
|
['-F', chain]])
|
||||||
|
self.assertEqual(self.obj.loaded_iptables[4],
|
||||||
|
self.obj.prepare_rules(chain, rules, 4))
|
||||||
|
self.assertEqual(self.obj.called_commands[6], [])
|
||||||
|
self.assertIsNone(self.obj.loaded_iptables[6])
|
||||||
|
|
||||||
|
def test_005_apply_rules6(self):
|
||||||
|
rules = [{'action': 'accept'}]
|
||||||
|
chain = 'qbs-2000--a'
|
||||||
|
self.obj.apply_rules('2000::a', rules)
|
||||||
|
self.assertEqual(self.obj.called_commands[6],
|
||||||
|
[
|
||||||
|
['-N', chain],
|
||||||
|
['-A', 'QBS-FORWARD', '-s', '2000::a', '-j', chain],
|
||||||
|
['-F', chain]])
|
||||||
|
self.assertEqual(self.obj.loaded_iptables[6],
|
||||||
|
self.obj.prepare_rules(chain, rules, 6))
|
||||||
|
self.assertEqual(self.obj.called_commands[4], [])
|
||||||
|
self.assertIsNone(self.obj.loaded_iptables[4])
|
||||||
|
|
||||||
|
def test_006_init(self):
|
||||||
|
self.obj.init()
|
||||||
|
self.assertEqual(self.obj.called_commands[4],
|
||||||
|
[['-nL', 'QBS-FORWARD']])
|
||||||
|
self.assertEqual(self.obj.called_commands[6],
|
||||||
|
[['-nL', 'QBS-FORWARD']])
|
||||||
|
|
||||||
|
def test_007_cleanup(self):
|
||||||
|
self.obj.init()
|
||||||
|
self.obj.create_chain('1.2.3.4', 'chain-ip4-1', 4)
|
||||||
|
self.obj.create_chain('1.2.3.6', 'chain-ip4-2', 4)
|
||||||
|
self.obj.create_chain('2000::1', 'chain-ip6-1', 6)
|
||||||
|
self.obj.create_chain('2000::2', 'chain-ip6-2', 6)
|
||||||
|
# forget about commands called earlier
|
||||||
|
self.obj.called_commands[4] = []
|
||||||
|
self.obj.called_commands[6] = []
|
||||||
|
self.obj.cleanup()
|
||||||
|
self.assertEqual(self.obj.called_commands[4],
|
||||||
|
[['-F', 'QBS-FORWARD'],
|
||||||
|
['-F', 'chain-ip4-1'],
|
||||||
|
['-X', 'chain-ip4-1'],
|
||||||
|
['-F', 'chain-ip4-2'],
|
||||||
|
['-X', 'chain-ip4-2']])
|
||||||
|
self.assertEqual(self.obj.called_commands[6],
|
||||||
|
[['-F', 'QBS-FORWARD'],
|
||||||
|
['-F', 'chain-ip6-2'],
|
||||||
|
['-X', 'chain-ip6-2'],
|
||||||
|
['-F', 'chain-ip6-1'],
|
||||||
|
['-X', 'chain-ip6-1']])
|
||||||
|
|
||||||
|
|
||||||
|
class TestNftablesWorker(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super(TestNftablesWorker, self).setUp()
|
||||||
|
self.obj = NftablesWorker()
|
||||||
|
|
||||||
|
def test_000_chain_for_addr(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.obj.chain_for_addr('10.137.0.1'), 'qbs-10-137-0-1')
|
||||||
|
self.assertEqual(
|
||||||
|
self.obj.chain_for_addr('fd09:24ef:4179:0000::3'),
|
||||||
|
'qbs-fd09-24ef-4179-0000--3')
|
||||||
|
|
||||||
|
def expected_create_chain(self, family, addr, chain):
|
||||||
|
return (
|
||||||
|
'table {family} qubes-firewall {{\n'
|
||||||
|
' chain {chain} {{\n'
|
||||||
|
' }}\n'
|
||||||
|
' chain forward {{\n'
|
||||||
|
' {family} saddr {addr} jump {chain}\n'
|
||||||
|
' }}\n'
|
||||||
|
'}}\n'.format(family=family, addr=addr, chain=chain))
|
||||||
|
|
||||||
|
def test_001_create_chain(self):
|
||||||
|
testdata = [
|
||||||
|
(4, '10.137.0.1', 'qbs-10-137-0-1'),
|
||||||
|
(6, 'fd09:24ef:4179:0000::3', 'qbs-fd09-24ef-4179-0000--3')
|
||||||
|
]
|
||||||
|
for family, addr, chain in testdata:
|
||||||
|
self.obj.create_chain(addr, chain, family)
|
||||||
|
self.assertEqual(self.obj.loaded_rules,
|
||||||
|
[self.expected_create_chain('ip', '10.137.0.1', 'qbs-10-137-0-1'),
|
||||||
|
self.expected_create_chain(
|
||||||
|
'ip6', 'fd09:24ef:4179:0000::3', 'qbs-fd09-24ef-4179-0000--3'),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_002_prepare_rules4(self):
|
||||||
|
rules = [
|
||||||
|
{'action': 'accept', 'proto': 'tcp',
|
||||||
|
'dstports': '80-80', 'dst4': '1.2.3.0/24'},
|
||||||
|
{'action': 'accept', 'proto': 'udp',
|
||||||
|
'dstports': '443-1024', 'dsthost': 'yum.qubes-os.org'},
|
||||||
|
{'action': 'accept', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'udp', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'icmp'},
|
||||||
|
{'action': 'drop'},
|
||||||
|
]
|
||||||
|
expected_nft = (
|
||||||
|
'flush chain ip qubes-firewall chain\n'
|
||||||
|
'table ip qubes-firewall {\n'
|
||||||
|
' chain chain {\n'
|
||||||
|
' ip protocol tcp ip daddr 1.2.3.0/24 tcp dport 80 accept\n'
|
||||||
|
' ip protocol udp ip daddr { 82.94.215.165/32 } '
|
||||||
|
'udp dport 443-1024 accept\n'
|
||||||
|
' ip daddr { 1.1.1.1/32, 2.2.2.2/32 } tcp dport 53 accept\n'
|
||||||
|
' ip daddr { 1.1.1.1/32, 2.2.2.2/32 } udp dport 53 accept\n'
|
||||||
|
' ip protocol udp ip daddr { 1.1.1.1/32, 2.2.2.2/32 } udp dport '
|
||||||
|
'53 drop\n'
|
||||||
|
' ip protocol icmp drop\n'
|
||||||
|
' drop\n'
|
||||||
|
' }\n'
|
||||||
|
'}\n'
|
||||||
|
)
|
||||||
|
self.assertEqual(self.obj.prepare_rules('chain', rules, 4),
|
||||||
|
expected_nft)
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.prepare_rules('chain', [{'unknown': 'xxx'}], 4)
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.prepare_rules('chain', [{'dst6': 'a::b'}], 4)
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.prepare_rules('chain', [{'dst4': '3.3.3.3'}], 6)
|
||||||
|
|
||||||
|
def test_003_prepare_rules6(self):
|
||||||
|
rules = [
|
||||||
|
{'action': 'accept', 'proto': 'tcp',
|
||||||
|
'dstports': '80-80', 'dst6': 'a::b/128'},
|
||||||
|
{'action': 'accept', 'proto': 'tcp',
|
||||||
|
'dsthost': 'ripe.net'},
|
||||||
|
{'action': 'accept', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'udp', 'specialtarget': 'dns'},
|
||||||
|
{'action': 'drop', 'proto': 'icmp', 'icmptype': '128'},
|
||||||
|
{'action': 'drop'},
|
||||||
|
]
|
||||||
|
expected_nft = (
|
||||||
|
'flush chain ip6 qubes-firewall chain\n'
|
||||||
|
'table ip6 qubes-firewall {\n'
|
||||||
|
' chain chain {\n'
|
||||||
|
' ip6 nexthdr tcp ip6 daddr a::b/128 tcp dport 80 accept\n'
|
||||||
|
' ip6 nexthdr tcp ip6 daddr { 2001:67c:2e8:22::c100:68b/128 } '
|
||||||
|
'accept\n'
|
||||||
|
' ip6 daddr { 2001::1/128, 2001::2/128 } tcp dport 53 accept\n'
|
||||||
|
' ip6 daddr { 2001::1/128, 2001::2/128 } udp dport 53 accept\n'
|
||||||
|
' ip6 nexthdr udp ip6 daddr { 2001::1/128, 2001::2/128 } '
|
||||||
|
'udp dport 53 drop\n'
|
||||||
|
' ip6 nexthdr icmpv6 icmpv6 type 128 drop\n'
|
||||||
|
' drop\n'
|
||||||
|
' }\n'
|
||||||
|
'}\n'
|
||||||
|
)
|
||||||
|
self.assertEqual(self.obj.prepare_rules('chain', rules, 6),
|
||||||
|
expected_nft)
|
||||||
|
|
||||||
|
def test_004_apply_rules4(self):
|
||||||
|
rules = [{'action': 'accept'}]
|
||||||
|
chain = 'qbs-10-137-0-1'
|
||||||
|
self.obj.apply_rules('10.137.0.1', rules)
|
||||||
|
self.assertEqual(self.obj.loaded_rules,
|
||||||
|
[self.expected_create_chain('ip', '10.137.0.1', chain),
|
||||||
|
self.obj.prepare_rules(chain, rules, 4),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_005_apply_rules6(self):
|
||||||
|
rules = [{'action': 'accept'}]
|
||||||
|
chain = 'qbs-2000--a'
|
||||||
|
self.obj.apply_rules('2000::a', rules)
|
||||||
|
self.assertEqual(self.obj.loaded_rules,
|
||||||
|
[self.expected_create_chain('ip6', '2000::a', chain),
|
||||||
|
self.obj.prepare_rules(chain, rules, 6),
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_006_init(self):
|
||||||
|
self.obj.init()
|
||||||
|
self.assertEqual(self.obj.loaded_rules,
|
||||||
|
[
|
||||||
|
'table ip qubes-firewall {\n'
|
||||||
|
' chain forward {\n'
|
||||||
|
' type filter hook forward priority 0;\n'
|
||||||
|
' }\n'
|
||||||
|
'}\n'
|
||||||
|
'table ip6 qubes-firewall {\n'
|
||||||
|
' chain forward {\n'
|
||||||
|
' type filter hook forward priority 0;\n'
|
||||||
|
' }\n'
|
||||||
|
'}\n'
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_007_cleanup(self):
|
||||||
|
self.obj.init()
|
||||||
|
self.obj.create_chain('1.2.3.4', 'chain-ip4-1', 4)
|
||||||
|
self.obj.create_chain('1.2.3.6', 'chain-ip4-2', 4)
|
||||||
|
self.obj.create_chain('2000::1', 'chain-ip6-1', 6)
|
||||||
|
self.obj.create_chain('2000::2', 'chain-ip6-2', 6)
|
||||||
|
# forget about commands called earlier
|
||||||
|
self.obj.loaded_rules = []
|
||||||
|
self.obj.cleanup()
|
||||||
|
self.assertEqual(self.obj.loaded_rules,
|
||||||
|
['delete table ip qubes-firewall\n'
|
||||||
|
'delete table ip6 qubes-firewall\n',
|
||||||
|
])
|
||||||
|
|
||||||
|
class TestFirewallWorker(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.obj = FirewallWorker()
|
||||||
|
rules = {
|
||||||
|
'10.137.0.1': {
|
||||||
|
'policy': 'accept',
|
||||||
|
'0000': 'proto=tcp dstports=80-80 action=drop',
|
||||||
|
'0001': 'proto=udp specialtarget=dns action=accept',
|
||||||
|
'0002': 'proto=udp action=drop',
|
||||||
|
},
|
||||||
|
'10.137.0.2': {'policy': 'accept'},
|
||||||
|
# no policy
|
||||||
|
'10.137.0.3': {'0000': 'proto=tcp action=accept'},
|
||||||
|
# no action
|
||||||
|
'10.137.0.4': {
|
||||||
|
'policy': 'drop',
|
||||||
|
'0000': 'proto=tcp'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for addr, entries in rules.items():
|
||||||
|
for key, value in entries.items():
|
||||||
|
self.obj.qdb.entries[
|
||||||
|
'/qubes-firewall/{}/{}'.format(addr, key)] = value
|
||||||
|
|
||||||
|
def test_read_rules(self):
|
||||||
|
expected_rules1 = [
|
||||||
|
{'proto': 'tcp', 'dstports': '80-80', 'action': 'drop'},
|
||||||
|
{'proto': 'udp', 'specialtarget': 'dns', 'action': 'accept'},
|
||||||
|
{'proto': 'udp', 'action': 'drop'},
|
||||||
|
{'action': 'accept'},
|
||||||
|
]
|
||||||
|
expected_rules2 = [
|
||||||
|
{'action': 'accept'},
|
||||||
|
]
|
||||||
|
self.assertEqual(self.obj.read_rules('10.137.0.1'), expected_rules1)
|
||||||
|
self.assertEqual(self.obj.read_rules('10.137.0.2'), expected_rules2)
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.read_rules('10.137.0.3')
|
||||||
|
with self.assertRaises(qubesagent.firewall.RuleParseError):
|
||||||
|
self.obj.read_rules('10.137.0.4')
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_targets(self):
|
||||||
|
self.assertEqual(self.obj.list_targets(), set(['10.137.0.{}'.format(x)
|
||||||
|
for x in range(1, 5)]))
|
||||||
|
|
||||||
|
def test_is_ip6(self):
|
||||||
|
self.assertTrue(self.obj.is_ip6('2000::abcd'))
|
||||||
|
self.assertTrue(self.obj.is_ip6('2000:1:2:3:4:5:6:abcd'))
|
||||||
|
self.assertFalse(self.obj.is_ip6('10.137.0.1'))
|
||||||
|
|
||||||
|
def test_handle_addr(self):
|
||||||
|
self.obj.handle_addr('10.137.0.2')
|
||||||
|
self.assertEqual(self.obj.rules['10.137.0.2'], [{'action': 'accept'}])
|
||||||
|
# fallback to block all
|
||||||
|
self.obj.handle_addr('10.137.0.3')
|
||||||
|
self.assertEqual(self.obj.rules['10.137.0.3'], [{'action': 'drop'}])
|
||||||
|
self.obj.handle_addr('10.137.0.4')
|
||||||
|
self.assertEqual(self.obj.rules['10.137.0.4'], [{'action': 'drop'}])
|
||||||
|
|
||||||
|
|
||||||
|
def test_main(self):
|
||||||
|
self.obj.main()
|
||||||
|
self.assertTrue(self.obj.init_called)
|
||||||
|
self.assertTrue(self.obj.cleanup_called)
|
||||||
|
self.assertEqual(set(self.obj.rules.keys()), self.obj.list_targets())
|
||||||
|
# rules content were already tested
|
@ -54,6 +54,8 @@ Requires: pygobject3-base
|
|||||||
Requires: dbus-python
|
Requires: dbus-python
|
||||||
# for qubes-session-autostart, xdg-icon
|
# for qubes-session-autostart, xdg-icon
|
||||||
Requires: pyxdg
|
Requires: pyxdg
|
||||||
|
Requires: python-daemon
|
||||||
|
Requires: nftables
|
||||||
%if %{fedora} >= 20
|
%if %{fedora} >= 20
|
||||||
# gpk-update-viewer required by qubes-manager
|
# gpk-update-viewer required by qubes-manager
|
||||||
Requires: gnome-packagekit-updater
|
Requires: gnome-packagekit-updater
|
||||||
@ -437,6 +439,13 @@ rm -f %{name}-%{version}
|
|||||||
/usr/share/nautilus-python/extensions/qvm_move_nautilus.py*
|
/usr/share/nautilus-python/extensions/qvm_move_nautilus.py*
|
||||||
/usr/share/nautilus-python/extensions/qvm_dvm_nautilus.py*
|
/usr/share/nautilus-python/extensions/qvm_dvm_nautilus.py*
|
||||||
|
|
||||||
|
%dir %{python_sitelib}/qubesagent-*-py2.7.egg-info
|
||||||
|
%{python_sitelib}/qubesagent-*-py2.7.egg-info/*
|
||||||
|
%dir %{python_sitelib}/qubesagent
|
||||||
|
%{python_sitelib}/qubesagent/__init__.py*
|
||||||
|
%{python_sitelib}/qubesagent/firewall.py*
|
||||||
|
%{python_sitelib}/qubesagent/test_firewall.py*
|
||||||
|
|
||||||
/usr/share/qubes/mime-override/globs
|
/usr/share/qubes/mime-override/globs
|
||||||
/usr/share/qubes/qubes-master-key.asc
|
/usr/share/qubes/qubes-master-key.asc
|
||||||
%dir /home_volatile
|
%dir /home_volatile
|
||||||
|
22
setup.py
Normal file
22
setup.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# vim: fileencoding=utf-8
|
||||||
|
|
||||||
|
import setuptools
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
setuptools.setup(
|
||||||
|
name='qubesagent',
|
||||||
|
version=open('version').read().strip(),
|
||||||
|
author='Invisible Things Lab',
|
||||||
|
author_email='marmarek@invisiblethingslab.com',
|
||||||
|
description='Qubes core-agent-linux package',
|
||||||
|
license='GPL2+',
|
||||||
|
url='https://www.qubes-os.org/',
|
||||||
|
|
||||||
|
packages=('qubesagent',),
|
||||||
|
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'qubes-firewall = qubesagent.firewall:main'
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
Loading…
Reference in New Issue
Block a user