diff --git a/CHANGES b/CHANGES index c31b2712c..9bc2bdba6 100644 --- a/CHANGES +++ b/CHANGES @@ -2081,3 +2081,5 @@ * mail: add API to set banner * Fix bug where systemd 237 ignores --nice value in systemd-run * postgresql: enable uuid-ossp extension +* firewall: add blocklist + diff --git a/baseimage/initializeBaseUbuntuImage.sh b/baseimage/initializeBaseUbuntuImage.sh index a7d733559..9173db597 100755 --- a/baseimage/initializeBaseUbuntuImage.sh +++ b/baseimage/initializeBaseUbuntuImage.sh @@ -39,6 +39,7 @@ apt-get -y install \ debconf-utils \ dmsetup \ $gpg_package \ + ipset \ iptables \ libpython2.7 \ linux-generic \ diff --git a/scripts/installer.sh b/scripts/installer.sh index d0725c08b..7b574a276 100755 --- a/scripts/installer.sh +++ b/scripts/installer.sh @@ -66,6 +66,11 @@ if [[ "${nginx_version}" != *"1.18."* ]]; then rm /tmp/nginx.deb fi +if ! which ipset; then + echo "==> installer: installing ipset" + apt install -y ipset +fi + echo "==> installer: updating node" if [[ "$(node --version)" != "v10.18.1" ]]; then mkdir -p /usr/local/node-10.18.1 diff --git a/setup/start/cloudron-firewall.sh b/setup/start/cloudron-firewall.sh index 9e729b635..a91bacdcd 100755 --- a/setup/start/cloudron-firewall.sh +++ b/setup/start/cloudron-firewall.sh @@ -12,6 +12,20 @@ iptables -t filter -I CLOUDRON -m state --state RELATED,ESTABLISHED -j ACCEPT # ssh is allowed alternately on port 202 iptables -A CLOUDRON -p tcp -m tcp -m multiport --dports 22,25,80,202,443 -j ACCEPT +# user firewall +user_firewall_json="/home/yellowtent/boxdata/firewall-config.json" +if allowed_tcp_ports=$(node -e "console.log(JSON.parse(fs.readFileSync('${user_firewall_json}', 'utf8')).allowed_tcp_ports.join(','))" 2>/dev/null); then + [[ -n "${allowed_tcp_ports}" ]] && iptables -A CLOUDRON -p tcp -m tcp -m multiport --dports "${allowed_tcp_ports}" -j ACCEPT +fi + +ipset create cloudron_blocklist hash:net || true +/home/yellowtent/box/src/scripts/setblocklist.sh + +iptables -t filter -A CLOUDRON -m set --match-set cloudron_blocklist src -j DROP +if ! iptables -t filter -C FORWARD -m set --match-set cloudron_blocklist src -j DROP; then + iptables -t filter -I FORWARD -m set --match-set cloudron_blocklist src -j DROP +fi + # turn and stun service iptables -t filter -A CLOUDRON -p tcp -m multiport --dports 3478,5349 -j ACCEPT iptables -t filter -A CLOUDRON -p udp -m multiport --dports 3478,5349 -j ACCEPT diff --git a/setup/start/sudoers b/setup/start/sudoers index 94e70e13f..1141108db 100644 --- a/setup/start/sudoers +++ b/setup/start/sudoers @@ -59,3 +59,6 @@ yellowtent ALL=(root) NOPASSWD:SETENV: /home/yellowtent/box/src/scripts/starttas Defaults!/home/yellowtent/box/src/scripts/stoptask.sh env_keep="HOME BOX_ENV" yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/stoptask.sh +Defaults!/home/yellowtent/box/src/scripts/setblocklist.sh env_keep="HOME BOX_ENV" +yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/setblocklist.sh + diff --git a/src/boxerror.js b/src/boxerror.js index f300f5194..dccf67db4 100644 --- a/src/boxerror.js +++ b/src/boxerror.js @@ -50,6 +50,7 @@ BoxError.FS_ERROR = 'FileSystem Error'; BoxError.INACTIVE = 'Inactive'; BoxError.INTERNAL_ERROR = 'Internal Error'; BoxError.INVALID_CREDENTIALS = 'Invalid Credentials'; +BoxError.IPTABLES_ERROR = 'IPTables Error'; BoxError.LICENSE_ERROR = 'License Error'; BoxError.LOGROTATE_ERROR = 'Logrotate Error'; BoxError.MAIL_ERROR = 'Mail Error'; @@ -92,6 +93,7 @@ BoxError.toHttpError = function (error) { case BoxError.MAIL_ERROR: case BoxError.DOCKER_ERROR: case BoxError.ADDONS_ERROR: + case BoxError.IPTABLES_ERROR: return new HttpError(424, error); case BoxError.DATABASE_ERROR: case BoxError.INTERNAL_ERROR: diff --git a/src/network.js b/src/network.js new file mode 100644 index 000000000..bd44e1cc2 --- /dev/null +++ b/src/network.js @@ -0,0 +1,46 @@ +'use strict'; + +exports = module.exports = { + getBlocklist, + setBlocklist +}; + +const assert = require('assert'), + BoxError = require('./boxerror.js'), + path = require('path'), + paths = require('./paths.js'), + safe = require('safetydance'), + shell = require('./shell.js'), + validator = require('validator'); + +const SET_BLOCKLIST_CMD = path.join(__dirname, 'scripts/setblocklist.sh'); + +function getBlocklist(callback) { + assert.strictEqual(typeof callback, 'function'); + + const data = safe.fs.readFileSync(paths.FIREWALL_CONFIG_FILE, 'utf8'); + const config = safe.JSON.parse(data); + const blocklist = config && config.blocklist ? config.blocklist : []; + + callback(null, blocklist); +} + +function setBlocklist(blocklist, callback) { + assert(Array.isArray(blocklist)); + assert.strictEqual(typeof callback, 'function'); + + if (!blocklist.every(x => validator.isIP(x) || validator.isIPRange(x))) return callback(new BoxError(BoxError.BAD_FIELD, 'blocklist must contain IP or IP range')); + + const data = safe.fs.readFileSync(paths.FIREWALL_CONFIG_FILE, 'utf8'); + const config = safe.JSON.parse(data) || {}; + + config.blocklist = blocklist; + + if (!safe.fs.writeFileSync(paths.FIREWALL_CONFIG_FILE, JSON.stringify(config, null, 4), 'utf8')) return callback(new BoxError(BoxError.FS_ERROR, safe.error.message)); + + shell.sudo('setBlocklist', [ SET_BLOCKLIST_CMD ], {}, function (error) { + if (error) return callback(new BoxError(BoxError.IPTABLES_ERROR, `Error setting blocklist: ${error.message}`)); + + callback(); + }); +} diff --git a/src/paths.js b/src/paths.js index 5d99d0612..bfa16a964 100644 --- a/src/paths.js +++ b/src/paths.js @@ -46,6 +46,7 @@ exports = module.exports = { CLOUDRON_AVATAR_FILE: path.join(baseDir(), 'boxdata/avatar.png'), UPDATE_CHECKER_FILE: path.join(baseDir(), 'boxdata/updatechecker.json'), ADDON_TURN_SECRET_FILE: path.join(baseDir(), 'boxdata/addon-turn-secret'), + FIREWALL_CONFIG_FILE: path.join(baseDir(), 'boxdata/firewall-config.json'), LOG_DIR: path.join(baseDir(), 'platformdata/logs'), TASKS_LOG_DIR: path.join(baseDir(), 'platformdata/logs/tasks'), diff --git a/src/routes/index.js b/src/routes/index.js index 65cd6cde7..4724133f1 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -15,6 +15,7 @@ exports = module.exports = { groups: require('./groups.js'), mail: require('./mail.js'), mailserver: require('./mailserver.js'), + network: require('./network.js'), notifications: require('./notifications.js'), profile: require('./profile.js'), provision: require('./provision.js'), diff --git a/src/routes/network.js b/src/routes/network.js new file mode 100644 index 000000000..70dedce7e --- /dev/null +++ b/src/routes/network.js @@ -0,0 +1,33 @@ +'use strict'; + +exports = module.exports = { + getBlocklist, + setBlocklist +}; + +var assert = require('assert'), + BoxError = require('../boxerror.js'), + HttpError = require('connect-lastmile').HttpError, + HttpSuccess = require('connect-lastmile').HttpSuccess, + network = require('../network.js'); + +function getBlocklist(req, res, next) { + network.getBlocklist(function (error, blocklist) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { blocklist })); + }); +} + +function setBlocklist(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (!Array.isArray(req.body.blocklist)) return next(new HttpError(400, 'blocklist is required')); + if (!req.body.blocklist.every(x => typeof x === 'string')) return next(new HttpError(400, 'blocklist must be array of strings')); + + network.setBlocklist(req.body.blocklist, function (error) { + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); + }); +} diff --git a/src/scripts/setblocklist.sh b/src/scripts/setblocklist.sh new file mode 100755 index 000000000..08c2e2297 --- /dev/null +++ b/src/scripts/setblocklist.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +set -eu -o pipefail + +if [[ ${EUID} -ne 0 ]]; then + echo "This script should be run as root." > /dev/stderr + exit 1 +fi + +if [[ $# == 1 && "$1" == "--check" ]]; then + echo "OK" + exit 0 +fi + +ipset flush cloudron_blocklist + +user_firewall_json="/home/yellowtent/boxdata/firewall-config.json" +if blocklist=$(node -e "console.log(JSON.parse(fs.readFileSync('${user_firewall_json}', 'utf8')).blocklist.join(' '))" 2>/dev/null); then + + for ip in ${blocklist}; do + ipset add cloudron_blocklist "${ip}" + done +fi diff --git a/src/server.js b/src/server.js index 412bfa612..ce99fe8c0 100644 --- a/src/server.js +++ b/src/server.js @@ -241,6 +241,10 @@ function initializeExpressSync() { return req.params.setting === 'cloudron_avatar' ? multipart(req, res, next) : next(); }, routes.branding.set); + // network routes + router.get ('/api/v1/network/blocklist', token, authorizeAdmin, routes.network.getBlocklist); + router.post('/api/v1/network/blocklist', json, token, authorizeAdmin, routes.network.setBlocklist); + // settings routes (these are for the settings tab - avatar & name have public routes for normal users. see above) router.get ('/api/v1/settings/:setting', token, authorizeAdmin, routes.settings.get); router.post('/api/v1/settings/backup_config', json, token, authorizeOwner, routes.settings.setBackupConfig);