import assert from 'node:assert'; import BoxError from './boxerror.js'; import constants from './constants.js'; import cron from './cron.js'; import fs from 'node:fs'; import ipaddr from './ipaddr.js'; import path from 'node:path'; import paths from './paths.js'; import safe from '@cloudron/safetydance'; import settings from './settings.js'; import shellModule from './shell.js'; import noopProvider from './network/noop.js'; import fixedProvider from './network/fixed.js'; import networkInterfaceProvider from './network/network-interface.js'; import genericProvider from './network/generic.js'; const shell = shellModule('network'); const SET_BLOCKLIST_CMD = path.join(import.meta.dirname, 'scripts/setblocklist.sh'); let gDefaultIface = null; // cache const NETWORK_PROVIDERS = { 'noop': noopProvider, 'fixed': fixedProvider, 'network-interface': networkInterfaceProvider }; function api(provider) { assert.strictEqual(typeof provider, 'string'); return NETWORK_PROVIDERS[provider] || genericProvider; } function hasIPv6() { const IPV6_PROC_FILE = '/proc/net/if_inet6'; // on contabo, /proc/net/if_inet6 is an empty file. so just exists is not enough return fs.existsSync(IPV6_PROC_FILE) && fs.readFileSync(IPV6_PROC_FILE, 'utf8').trim().length !== 0; } async function testIPv4Config(config) { assert.strictEqual(typeof config, 'object'); return await api(config.provider).testIPv4Config(config); } async function testIPv6Config(config) { assert.strictEqual(typeof config, 'object'); return await api(config.provider).testIPv6Config(config); } async function getBlocklist() { const value = await settings.getBlob(settings.FIREWALL_BLOCKLIST_KEY); return value ? value.toString('utf8') : ''; } async function applyBlocklist() { const blocklist = await getBlocklist(); if (!safe.fs.writeFileSync(paths.FIREWALL_BLOCKLIST_FILE, blocklist + '\n', 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message); const [error] = await safe(shell.sudo([ SET_BLOCKLIST_CMD ], {})); if (error) throw new BoxError(BoxError.IPTABLES_ERROR, `Error setting blocklist: ${error.message}`); } async function setBlocklist(blocklist, auditSource) { assert.strictEqual(typeof blocklist, 'string'); assert.strictEqual(typeof auditSource, 'object'); let count = 0; for (const line of blocklist.split('\n')) { if (!line || line.startsWith('#')) continue; const rangeOrIP = line.trim(); if (!ipaddr.isValid(rangeOrIP) && !ipaddr.isValidCIDR(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `'${rangeOrIP}' is not a valid IP or range`); if (rangeOrIP.indexOf('/') === -1) { if (ipaddr.isEqual(rangeOrIP, auditSource.ip)) throw new BoxError(BoxError.BAD_FIELD, `IP '${rangeOrIP}' is the client IP. Cannot block yourself`); } else { if (ipaddr.includes(rangeOrIP, auditSource.ip)) throw new BoxError(BoxError.BAD_FIELD, `range '${rangeOrIP}' includes client IP. Cannot block yourself`); } // this won't work in cases where it's a bigger subnet if (rangeOrIP.startsWith('172.18.') || rangeOrIP.toLowerCase().startsWith('fd00:c107:d509:')) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} includes internal docker network. This cannot be blocked`); ++count; } if (count >= 262144) throw new BoxError(BoxError.CONFLICT, 'Blocklist is too large. Max 262144 entries are allowed'); // see the cloudron-firewall.sh if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode'); // store in blob since the value field is TEXT and has 16kb size limit await settings.setBlob(settings.FIREWALL_BLOCKLIST_KEY, Buffer.from(blocklist)); await applyBlocklist(); } async function getDynamicDns() { const enabled = await settings.get(settings.DYNAMIC_DNS_KEY); return enabled ? !!enabled : false; // db holds string values only } async function setDynamicDns(enabled) { assert.strictEqual(typeof enabled, 'boolean'); await settings.set(settings.DYNAMIC_DNS_KEY, enabled ? 'enabled' : ''); // db holds string values only await cron.handleDynamicDnsChanged(enabled); } async function getIPv4Config() { const value = await settings.getJson(settings.IPV4_CONFIG_KEY); return value || { provider: 'generic' }; } async function setIPv4Config(ipv4Config) { assert.strictEqual(typeof ipv4Config, 'object'); if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode'); const error = await testIPv4Config(ipv4Config); if (error) throw error; await settings.setJson(settings.IPV4_CONFIG_KEY, ipv4Config); } async function getIPv6Config() { const value = await settings.getJson(settings.IPV6_CONFIG_KEY); return value || { provider: 'noop' }; } async function setIPv6Config(ipv6Config) { assert.strictEqual(typeof ipv6Config, 'object'); if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode'); const error = await testIPv6Config(ipv6Config); if (error) throw error; await settings.setJson(settings.IPV6_CONFIG_KEY, ipv6Config); } async function getIPv4() { const config = await getIPv4Config(); return await api(config.provider).getIPv4(config); } async function getIPv6() { const config = await getIPv6Config(); return await api(config.provider).getIPv6(config); } async function detectIP() { const [error4, ipv4] = await safe(genericProvider.getIPv4({})); const [error6, ipv6] = await safe(genericProvider.getIPv6({})); return { ipv4: error4 ? null : ipv4, ipv6: error6 ? null : ipv6 }; } async function getDefaultInterface() { if (gDefaultIface) return gDefaultIface; const contents4 = await fs.promises.readFile('/proc/net/route', { encoding: 'utf8' }); const lines4 = contents4.trim().split('\n').slice(1); // skip header for (const line of lines4) { const cols = line.trim().split(/\s+/); // Iface, dest, gw, flags, refcount, use, metric, mask, mtu, window, irtt if (cols[1] === '00000000') { // && cols[7] === '00000000' gDefaultIface = cols[0]; return gDefaultIface; } } const contents6 = await fs.promises.readFile('/proc/net/ipv6_route', { encoding: 'utf8' }); const lines6 = contents6.trim().split('\n'); // no header! for (const line of lines6) { const cols = line.trim().split(/\s+/); // dest, dest_prefix_len, src, src_prefix_len, next_hop, metric, refcount, use, flags, iface if (cols[0] === '00000000000000000000000000000000' && cols[1] === '00') { gDefaultIface = cols.at(-1); return gDefaultIface; } } throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not detect default interface'); } export default { testIPv4Config, testIPv6Config, applyBlocklist, getBlocklist, setBlocklist, getDynamicDns, setDynamicDns, getIPv4Config, setIPv4Config, getIPv6Config, setIPv6Config, getIPv4, hasIPv6, getIPv6, detectIP, getDefaultInterface };