yunohost/lib/yunohost/firewall.py

446 lines
14 KiB
Python

# -*- coding: utf-8 -*-
""" License
Copyright (C) 2013 YunoHost
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 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 Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program; if not, see http://www.gnu.org/licenses
"""
""" yunohost_firewall.py
Manage firewall rules
"""
import os
import sys
import yaml
import errno
try:
import miniupnpc
except ImportError:
sys.stderr.write('Error: Yunohost CLI Require miniupnpc lib\n')
sys.exit(1)
from moulinette.core import MoulinetteError
from moulinette.utils import process
from moulinette.utils.log import getActionLogger
from moulinette.utils.text import prependlines
firewall_file = '/etc/yunohost/firewall.yml'
upnp_cron_job = '/etc/cron.d/yunohost-firewall-upnp'
logger = getActionLogger('yunohost.firewall')
def firewall_allow(port, protocol='TCP', ipv4_only=False, ipv6_only=False,
no_upnp=False, no_reload=False):
"""
Allow connections on a port
Keyword arguments:
port -- Port or range of ports to open
protocol -- Protocol type to allow (default: TCP)
ipv4_only -- Only add a rule for IPv4 connections
ipv6_only -- Only add a rule for IPv6 connections
no_upnp -- Do not add forwarding of this port with UPnP
no_reload -- Do not reload firewall rules
"""
firewall = firewall_list(raw=True)
# Validate port
if not isinstance(port, int) and ':' not in port:
port = int(port)
# Validate protocols
protocols = ['TCP', 'UDP']
if protocol != 'Both' and protocol in protocols:
protocols = [protocol,]
# Validate IP versions
ipvs = ['ipv4', 'ipv6']
if ipv4_only and not ipv6_only:
ipvs = ['ipv4',]
elif ipv6_only and not ipv4_only:
ipvs = ['ipv6',]
for p in protocols:
# Iterate over IP versions to add port
for i in ipvs:
if port not in firewall[i][p]:
firewall[i][p].append(port)
else:
ipv = "IPv%s" % i[3]
msignals.display(m18n.n('port_already_opened', port, ipv),
'warning')
# Add port forwarding with UPnP
if not no_upnp and port not in firewall['uPnP'][p]:
firewall['uPnP'][p].append(port)
# Update and reload firewall
_update_firewall_file(firewall)
if not no_reload:
return firewall_reload()
def firewall_disallow(port, protocol='TCP', ipv4_only=False, ipv6_only=False,
upnp_only=False, no_reload=False):
"""
Disallow connections on a port
Keyword arguments:
port -- Port or range of ports to close
protocol -- Protocol type to disallow (default: TCP)
ipv4_only -- Only remove the rule for IPv4 connections
ipv6_only -- Only remove the rule for IPv6 connections
upnp_only -- Only remove forwarding of this port with UPnP
no_reload -- Do not reload firewall rules
"""
firewall = firewall_list(raw=True)
# Validate port
if ':' not in port:
port = int(port)
# Validate protocols
protocols = ['TCP', 'UDP']
if protocol != 'Both' and protocol in protocols:
protocols = [protocol,]
# Validate IP versions and UPnP
ipvs = ['ipv4', 'ipv6']
upnp = True
if ipv4_only and ipv6_only:
upnp = True # automatically disallow UPnP
elif ipv4_only:
ipvs = ['ipv4',]
upnp = upnp_only
elif ipv6_only:
ipvs = ['ipv6',]
upnp = upnp_only
elif upnp_only:
ipvs = []
for p in protocols:
# Iterate over IP versions to remove port
for i in ipvs:
if port in firewall[i][p]:
firewall[i][p].remove(port)
else:
ipv = "IPv%s" % i[3]
msignals.display(m18n.n('port_already_closed', port, ipv),
'warning')
# Remove port forwarding with UPnP
if upnp and port in firewall['uPnP'][p]:
firewall['uPnP'][p].remove(port)
# Update and reload firewall
_update_firewall_file(firewall)
if not no_reload:
return firewall_reload()
def firewall_list(raw=False, by_ip_version=False, list_forwarded=False):
"""
List all firewall rules
Keyword arguments:
raw -- Return the complete YAML dict
by_ip_version -- List rules by IP version
list_forwarded -- List forwarded ports with UPnP
"""
with open(firewall_file) as f:
firewall = yaml.load(f)
if raw:
return firewall
# Retrieve all ports for IPv4 and IPv6
ports = {}
for i in ['ipv4', 'ipv6']:
f = firewall[i]
# Combine TCP and UDP ports
ports[i] = sorted(set(f['TCP']) | set(f['UDP']))
if not by_ip_version:
# Combine IPv4 and IPv6 ports
ports = sorted(set(ports['ipv4']) | set(ports['ipv6']))
# Format returned dict
ret = { "opened_ports": ports }
if list_forwarded:
# Combine TCP and UDP forwarded ports
ret['forwarded_ports'] = sorted(
set(firewall['uPnP']['TCP']) | set(firewall['uPnP']['UDP']))
return ret
def firewall_reload():
"""
Reload all firewall rules
"""
from yunohost.hook import hook_callback
reloaded = False
errors = False
# Check if SSH port is allowed
ssh_port = _get_ssh_port()
if ssh_port not in firewall_list()['opened_ports']:
firewall_allow(ssh_port, no_reload=True)
# Retrieve firewall rules and UPnP status
firewall = firewall_list(raw=True)
upnp = firewall_upnp()['enabled']
# IPv4
try:
process.check_output("iptables -L")
except process.CalledProcessError as e:
logger.info('iptables seems to be not available, it outputs:\n%s',
prependlines(e.output.rstrip(), '> '))
msignals.display(m18n.n('iptables_unavailable'), 'info')
else:
rules = [
"iptables -F",
"iptables -X",
"iptables -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT",
]
# Iterate over ports and add rule
for protocol in ['TCP', 'UDP']:
for port in firewall['ipv4'][protocol]:
rules.append("iptables -A INPUT -p %s --dport %s -j ACCEPT" \
% (protocol, process.quote(str(port))))
rules += [
"iptables -A INPUT -i lo -j ACCEPT",
"iptables -A INPUT -p icmp -j ACCEPT",
"iptables -P INPUT DROP",
]
# Execute each rule
if process.check_commands(rules, callback=_on_rule_command_error):
errors = True
reloaded = True
# IPv6
try:
process.check_output("ip6tables -L")
except process.CalledProcessError as e:
logger.info('ip6tables seems to be not available, it outputs:\n%s',
prependlines(e.output.rstrip(), '> '))
msignals.display(m18n.n('ip6tables_unavailable'), 'info')
else:
rules = [
"ip6tables -F",
"ip6tables -X",
"ip6tables -A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT",
]
# Iterate over ports and add rule
for protocol in ['TCP', 'UDP']:
for port in firewall['ipv6'][protocol]:
rules.append("ip6tables -A INPUT -p %s --dport %s -j ACCEPT" \
% (protocol, process.quote(str(port))))
rules += [
"ip6tables -A INPUT -i lo -j ACCEPT",
"ip6tables -A INPUT -p icmpv6 -j ACCEPT",
"ip6tables -P INPUT DROP",
]
# Execute each rule
if process.check_commands(rules, callback=_on_rule_command_error):
errors = True
reloaded = True
if not reloaded:
raise MoulinetteError(errno.ESRCH, m18n.n('firewall_reload_failed'))
hook_callback('post_iptable_rules',
args=[upnp, os.path.exists("/proc/net/if_inet6")])
if upnp:
# Refresh port forwarding with UPnP
firewall_upnp(no_refresh=False)
# TODO: Use service_restart
os.system("service fail2ban restart")
if errors:
msignals.display(m18n.n('firewall_rules_cmd_failed'), 'warning')
else:
msignals.display(m18n.n('firewall_reloaded'), 'success')
return firewall_list()
def firewall_upnp(action='status', no_refresh=False):
"""
Manage port forwarding using UPnP
Note: 'reload' action is deprecated and will be removed in the near
future. You should use 'status' instead - which retrieve UPnP status
and automatically refresh port forwarding if 'no_refresh' is False.
Keyword argument:
action -- Action to perform
no_refresh -- Do not refresh port forwarding
"""
firewall = firewall_list(raw=True)
enabled = firewall['uPnP']['enabled']
# Compatibility with previous version
if action == 'reload':
logger.warning("'reload' action is deprecated and will be removed")
try:
# Remove old cron job
os.remove('/etc/cron.d/yunohost-firewall')
except: pass
action = 'status'
no_refresh = False
if action == 'status' and no_refresh:
# Only return current state
return { 'enabled': enabled }
elif action == 'enable' or (enabled and action == 'status'):
# Add cron job
with open(upnp_cron_job, 'w+') as f:
f.write('*/50 * * * * root '
'/usr/bin/yunohost firewall upnp status >>/dev/null\n')
enabled = True
elif action == 'disable' or (not enabled and action == 'status'):
try:
# Remove cron job
os.remove(upnp_cron_job)
except: pass
enabled = False
if action == 'status':
no_refresh = True
else:
raise MoulinetteError(errno.EINVAL, m18n.n('action_invalid', action))
# Refresh port mapping using UPnP
if not no_refresh:
upnpc = miniupnpc.UPnP()
upnpc.discoverdelay = 3000
# Discover UPnP device(s)
logger.debug('discovering UPnP devices...')
nb_dev = upnpc.discover()
logger.debug('found %d UPnP device(s)', int(nb_dev))
if nb_dev < 1:
msignals.display(m18n.n('upnp_dev_not_found'), 'error')
enabled = False
else:
try:
# Select UPnP device
upnpc.selectigd()
except:
logger.exception('unable to select UPnP device')
enabled = False
else:
# Iterate over ports
for protocol in ['TCP', 'UDP']:
for port in firewall['uPnP'][protocol]:
# Clean the mapping of this port
if upnpc.getspecificportmapping(port, protocol):
try:
upnpc.deleteportmapping(port, protocol)
except: pass
if not enabled:
continue
try:
# Add new port mapping
upnpc.addportmapping(port, protocol, upnpc.lanaddr,
port, 'yunohost firewall: port %d' % port, '')
except:
logger.exception('unable to add port %d using UPnP',
port)
enabled = False
if enabled != firewall['uPnP']['enabled']:
firewall['uPnP']['enabled'] = enabled
# Make a backup and update firewall file
os.system("cp {0} {0}.old".format(firewall_file))
with open(firewall_file, 'w') as f:
yaml.safe_dump(firewall, f, default_flow_style=False)
if not no_refresh:
# Display success message if needed
if action == 'enable' and enabled:
msignals.display(m18n.n('upnp_enabled'), 'success')
elif action == 'disable' and not enabled:
msignals.display(m18n.n('upnp_disabled'), 'success')
# Make sure to disable UPnP
elif action != 'disable' and not enabled:
firewall_upnp('disable', no_refresh=True)
if action == 'enable' and not enabled:
raise MoulinetteError(errno.ENXIO, m18n.n('upnp_port_open_failed'))
return { 'enabled': enabled }
def firewall_stop():
"""
Stop iptables and ip6tables
"""
if os.system("iptables -P INPUT ACCEPT") != 0:
raise MoulinetteError(errno.ESRCH, m18n.n('iptables_unavailable'))
os.system("iptables -F")
os.system("iptables -X")
if os.path.exists("/proc/net/if_inet6"):
os.system("ip6tables -P INPUT ACCEPT")
os.system("ip6tables -F")
os.system("ip6tables -X")
if os.path.exists(upnp_cron_job):
firewall_upnp('disable')
def _get_ssh_port(default=22):
"""Return the SSH port to use
Retrieve the SSH port from the sshd_config file or used the default
one if it's not defined.
"""
from moulinette.utils.text import searchf
try:
m = searchf(r'^Port[ \t]+([0-9]+)$',
'/etc/ssh/sshd_config', count=-1)
if m:
return int(m)
except:
pass
return default
def _update_firewall_file(rules):
"""Make a backup and write new rules to firewall file"""
os.system("cp {0} {0}.old".format(firewall_file))
with open(firewall_file, 'w') as f:
yaml.safe_dump(rules, f, default_flow_style=False)
def _on_rule_command_error(returncode, cmd, output):
"""Callback for rules commands error"""
# Log error and continue commands execution
logger.error('"%s" returned non-zero exit status %d:\n%s',
cmd, returncode, prependlines(output.rstrip(), '> '))
return True