Always combine IP set data with static network definitions from NetBox

Before we relied on the combined data being present in ipsets.json
when generating a new config, but ipsets.json is only updated through
the form at /ipsets. So submitting any other form after changing
NetBox definitions might crash when trying to find an entry from
networks.json in ipsets.json.

Now we introduce a helper functon to always read both files and
combine the prefixes fron networks.json with ipsets.json. This way it
is not necessary to save a new ipsets.json before other changes.

Also don’t crash when enumerating networks for each VPN group.
This commit is contained in:
Timotej Lazar 2024-08-14 11:25:07 +02:00
parent b6c191e2ce
commit 048195c45c
5 changed files with 41 additions and 26 deletions

View file

@ -8,9 +8,17 @@ The firewall consists of two servers (or “bricks”) in active–backup config
On each configuration change, a tarball of relevant files in `/etc` is generated and pushed via SSH to both nodes. This happens for instance each time an IP set or a forwarding rule is modified, or a VPN key is added or removed. Firewall nodes and the current configuration version for each are stored in `nodes.json`. On each configuration change, a tarball of relevant files in `/etc` is generated and pushed via SSH to both nodes. This happens for instance each time an IP set or a forwarding rule is modified, or a VPN key is added or removed. Firewall nodes and the current configuration version for each are stored in `nodes.json`.
## Database
Application data is stored in a number of JSON files in the home directory of the user the application runs as. The `db` module defines utility functions to ensure consistency when manipulating data:
- `lock(name)` and `unlock(name)` acquire or release the lock for a given file or all files with no argument;
- `read(name)` and `write(name, data)` retrieve or store a dictionary in the given file, which should be locked;
- `load(name)` and `save(name, data)` do the same but lock the file first.
## IP sets ## IP sets
Names and IP prefixes for physical networks are configured in NetBox and stored in `networks.json`, which is never modified by the application. Custom IP sets used for forwarding rules may be defined at `/ipsets`, as well as NAT addresse and VPN access for all networks. These settings are stored in `ipsets.json` and added to static definitions in `networks.json` when generating firewall configuration. Names and IP prefixes for physical networks are configured in NetBox and stored in `networks.json`, which is never modified by the application. Custom IP sets used for forwarding rules may be defined at `/ipsets`, as well as NAT addresse and VPN access for all networks. These settings are stored in `ipsets.json`. To read and combine data from both files, the `ipsets.read` utility function should be used. Combined data may then be modified and written back to `ipsets.json`, as with all other files.
## Rules ## Rules

View file

@ -8,6 +8,19 @@ from . import system
blueprint = flask.Blueprint('ipsets', __name__) blueprint = flask.Blueprint('ipsets', __name__)
# return combined data for all IP sets, stored in two files:
# - networks.json: IP prefixes for networks defined in NetBox
# - ipsets.json: custom IP prefixes defined with this app, and NAT and other settings for all sets
# database should be locked when calling this function
def read():
# first read in static data
all_ipsets = db.read('networks')
# then add the custom definitions, not overriding any IP prefixes from NetBox
for name, data in db.read('ipsets').items():
# set “custom” flag for this set if it is not present in networks.json
all_ipsets[name] = data | all_ipsets.get(name, {'custom': True})
return all_ipsets
@blueprint.route('/', methods=('GET', 'POST')) @blueprint.route('/', methods=('GET', 'POST'))
@flask_login.login_required @flask_login.login_required
def index(): def index():
@ -17,25 +30,19 @@ def index():
with db.locked(): with db.locked():
if flask.request.method == 'POST': if flask.request.method == 'POST':
# read network data from NetBox, merge in custom definitions and dump the lot # read network data from NetBox, merge in custom definitions and dump the lot
ipsets = db.read('networks') sets = db.read('networks')
formdata = zip(*(flask.request.form.getlist(e) for e in ('name', 'ip', 'ip6', 'nat', 'vpn'))) formdata = zip(*(flask.request.form.getlist(e) for e in ('name', 'ip', 'ip6', 'nat', 'vpn')))
for name, ip, ip6, nat, vpn in formdata: for name, ip, ip6, nat, vpn in formdata:
# drop sets with empty names # drop sets with empty names
if not name: if not name:
continue continue
# assign IPs for custom networks only # assign IPs for custom networks only
if name not in ipsets: if name not in sets:
ipsets[name] = { 'ip': ip.split(), 'ip6': ip6.split() } sets[name] = { 'ip': ip.split(), 'ip6': ip6.split() }
# assign NAT and VPN for all networks # assign NAT and VPN for all networks
ipsets[name] |= { 'nat': nat, 'vpn': vpn } sets[name] |= { 'nat': nat, 'vpn': vpn }
db.write('ipsets', ipsets) db.write('ipsets', sets)
system.run(system.save_config) system.run(system.save_config)
return flask.redirect(flask.url_for('ipsets.index')) return flask.redirect(flask.url_for('ipsets.index'))
# read network data from NetBox and merge in custom definitions return flask.render_template('ipsets/index.html', ipsets=read())
ipsets = db.read('networks')
for name, data in db.read('ipsets').items():
# keep static IPs if there are any, otherwise set custom flag for this set
ipsets[name] = data | ipsets.get(name, {'custom': True})
return flask.render_template('ipsets/index.html', ipsets=ipsets)

View file

@ -2,6 +2,7 @@ import flask
import flask_login import flask_login
from . import db from . import db
from . import ipsets
from . import system from . import system
blueprint = flask.Blueprint('rules', __name__) blueprint = flask.Blueprint('rules', __name__)
@ -45,8 +46,7 @@ def edit(index):
system.run(system.save_config) system.run(system.save_config)
with db.locked(): with db.locked():
ipsets = db.read('ipsets') return flask.render_template('rules/edit.html', index=index, rule=db.load('rules')[index], ipsets=ipsets.read())
return flask.render_template('rules/edit.html', index=index, rule=db.load('rules')[index], ipsets=ipsets)
except IndexError as e: except IndexError as e:
return flask.Response(f'invalid rule: {index}', status=400, mimetype='text/plain') return flask.Response(f'invalid rule: {index}', status=400, mimetype='text/plain')

View file

@ -18,6 +18,7 @@ import flask.cli
import ldap3 import ldap3
from . import db from . import db
from . import ipsets
def init_app(app): def init_app(app):
app.cli.add_command(generate) app.cli.add_command(generate)
@ -49,7 +50,6 @@ def save_config():
# Just load required settings here but keep the database unlocked # Just load required settings here but keep the database unlocked
# while we load group memberships from LDAP. # while we load group memberships from LDAP.
with db.locked(): with db.locked():
ipsets = db.read('ipsets')
settings = db.read('settings') settings = db.read('settings')
# Build LDAP query for users and groups. # Build LDAP query for users and groups.
@ -72,15 +72,15 @@ def save_config():
# Now read the settings again while keeping the database locked until # Now read the settings again while keeping the database locked until
# config files are generated, and increment version before unlocking. # config files are generated, and increment version before unlocking.
with db.locked(): with db.locked():
ipsets = db.read('ipsets') sets = ipsets.read()
wireguard = db.read('wireguard') wireguard = db.read('wireguard')
settings = db.read('settings') settings = db.read('settings')
version = settings['version'] = int(settings.get('version') or '0') + 1 version = settings['version'] = int(settings.get('version') or '0') + 1
# Find networks accessible to VPN users for each AD group. # Find networks accessible to VPN users for each AD group.
vpn_groups = {e['vpn'] for e in ipsets.values() if e.get('vpn')} vpn_groups = {e['vpn'] for e in sets.values() if e.get('vpn')}
group_networks = { group_networks = {
group: [name for name, data in ipsets.items() if data['vpn'] == group] for group in vpn_groups group: [name for name, data in sets.items() if data.get('vpn') == group] for group in vpn_groups
} }
# Add VPN addresses to IP sets. # Add VPN addresses to IP sets.
@ -92,9 +92,9 @@ def save_config():
for group in user_groups.get(key.get('user', ''), ()): for group in user_groups.get(key.get('user', ''), ()):
key_networks |= set(group_networks.get(group, ())) key_networks |= set(group_networks.get(group, ()))
for network in key_networks: for network in key_networks:
ipsets[network]['ip'].append(f'{ip}/32') sets[network]['ip'].append(f'{ip}/32')
if ip6 := key.get('ip6'): if ip6 := key.get('ip6'):
ipsets[network]['ip6'].append(ip6) sets[network]['ip6'].append(ip6)
# Create config files. # Create config files.
output = pathlib.Path.home() / 'config' / f'{version}' output = pathlib.Path.home() / 'config' / f'{version}'
@ -113,7 +113,7 @@ def save_config():
def make_set(ips): def make_set(ips):
# return "elements = { ip1, ip2, … }", prefixed with "# " if no ips # return "elements = { ip1, ip2, … }", prefixed with "# " if no ips
return f'{"" if ips else "# "}elements = {{ {", ".join(ips)} }}' return f'{"" if ips else "# "}elements = {{ {", ".join(ips)} }}'
for name, data in ipsets.items(): for name, data in sets.items():
f.write(nft_set.format(name=name, ips=make_set(data.get('ip', ())))) f.write(nft_set.format(name=name, ips=make_set(data.get('ip', ()))))
f.write(nft_set6.format(name=name, ips=make_set(data.get('ip6', ())))) f.write(nft_set6.format(name=name, ips=make_set(data.get('ip6', ()))))
f.write('\n') f.write('\n')
@ -132,14 +132,14 @@ def save_config():
# Print dynamic NAT rules. # Print dynamic NAT rules.
with open(output / 'etc/nftables.d/nat.nft', 'w', encoding='utf-8') as f: with open(output / 'etc/nftables.d/nat.nft', 'w', encoding='utf-8') as f:
nft_nat = 'iif @inside oif @outside ip saddr @{name} snat to {nat}\n' nft_nat = 'iif @inside oif @outside ip saddr @{name} snat to {nat}\n'
for name, data in ipsets.items(): for name, data in sets.items():
if nat := data.get('nat'): if nat := data.get('nat'):
f.write(nft_nat.format(name=name, nat=nat)) f.write(nft_nat.format(name=name, nat=nat))
# Print forwarding rules. # Print forwarding rules.
with open(output / 'etc/nftables.d/forward.nft', 'w', encoding='utf-8') as f: with open(output / 'etc/nftables.d/forward.nft', 'w', encoding='utf-8') as f:
# Forwarding rules for VPN users. # Forwarding rules for VPN users.
if vpn_networks := sorted(name for name, data in ipsets.items() if data.get('vpn')): if vpn_networks := sorted(name for name, data in sets.items() if data.get('vpn')):
nft_forward = 'iif @inside oif @inside ip saddr @{name} ip daddr @{name} accept\n' nft_forward = 'iif @inside oif @inside ip saddr @{name} ip daddr @{name} accept\n'
nft_forward6 = 'iif @inside oif @inside ip6 saddr @{name}/6 ip6 daddr @{name}/6 accept\n' nft_forward6 = 'iif @inside oif @inside ip6 saddr @{name}/6 ip6 daddr @{name}/6 accept\n'
f.write('# forward from the VPN interface to physical networks and back\n') f.write('# forward from the VPN interface to physical networks and back\n')

View file

@ -8,6 +8,7 @@ import flask
import flask_login import flask_login
from . import db from . import db
from . import ipsets
from . import system from . import system
blueprint = flask.Blueprint('vpn', __name__) blueprint = flask.Blueprint('vpn', __name__)
@ -25,8 +26,7 @@ def custom():
return flask.Response('forbidden', status=403, mimetype='text/plain') return flask.Response('forbidden', status=403, mimetype='text/plain')
with db.locked(): with db.locked():
keys = {ip: data for ip, data in db.read('wireguard').items() if data.get('networks') and not data.get('user')} keys = {ip: data for ip, data in db.read('wireguard').items() if data.get('networks') and not data.get('user')}
ipsets = db.read('networks') | db.read('ipsets') return flask.render_template('vpn/custom.html', keys=keys, ipsets=ipsets.read().keys())
return flask.render_template('vpn/custom.html', keys=keys, ipsets=ipsets.keys())
@blueprint.route('/list') @blueprint.route('/list')
@flask_login.login_required @flask_login.login_required