Compare commits

...

10 commits

Author SHA1 Message Date
Timotej Lazar 25ee4e8a44 Improve rule management page
Address rules by name instead of index. Still problematic if the rules
are changed while someone is managing them, but with names it’s
more likely to just not work instead of enabling or disabling the
wrong rule.

Also prevent bringing down the whole network with a single click.
2024-05-29 11:10:31 +02:00
Timotej Lazar 0e9d1ce6f0 Add some words to templates
Also some tags. Also remove some other words and some other tags.
2024-05-02 23:33:13 +02:00
Timotej Lazar 32af5a43c0 Oops, missed a six 2024-05-02 17:27:18 +02:00
Timotej Lazar d123db4e64 Consolidate NAT and VPN settings into IP sets
I have tried every possible permutation and I think this is the one.

NetBox-managed IP prefixes are pushed with ansible to firewall master.
The managed prefixes are added to custom IP sets defined in the app,
but only NAT addresses and VPN groups can be configured for them.

This way all NAT and VPN policy is (again) configured in the app. Also
both NetBox-managed and user-defined networks are treated the same.

Also improve^Wtweak config generation. Also templates.
2024-04-30 20:57:46 +02:00
Timotej Lazar cac7658566 Fix handling default settings
If a setting has ben set to empty string, dict.get will return it and
not default argument. This is wrong when default is something else.
2024-04-30 09:54:39 +02:00
Timotej Lazar f8d71b7b06 vpn: fix key name regex 2024-04-25 12:32:39 +02:00
Timotej Lazar 2ebc87f308 firewall: tweak instructions some more 2024-04-24 10:29:49 +02:00
Timotej Lazar 880c6b4140 friwall: tweak instructions
For no particularly good reason.
2024-04-23 12:38:32 +02:00
Timotej Lazar d33fec65a2 system: support LDAP queries with no user_group set
Though it might be better to allow multiple groups. On the other hand
the main filter is in the group→ipset settings file anyway; any VPN
user not in one of those groups will not get forwarded to anywhere.
2024-04-22 10:43:50 +02:00
Timotej Lazar ab2485c063 Unlicense 2024-03-27 11:28:21 +01:00
16 changed files with 276 additions and 207 deletions

1
LICENSE Symbolic link
View file

@ -0,0 +1 @@
UNLICENSE

24
UNLICENSE Normal file
View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View file

@ -54,9 +54,6 @@ def create_app(test_config=None):
from . import ipsets from . import ipsets
app.register_blueprint(ipsets.blueprint, url_prefix='/ipsets') app.register_blueprint(ipsets.blueprint, url_prefix='/ipsets')
from . import nat
app.register_blueprint(nat.blueprint, url_prefix='/nat')
from . import rules from . import rules
app.register_blueprint(rules.blueprint, url_prefix='/rules') app.register_blueprint(rules.blueprint, url_prefix='/rules')

View file

@ -15,18 +15,27 @@ def index():
return flask.Response('forbidden', status=403, mimetype='text/plain') return flask.Response('forbidden', status=403, mimetype='text/plain')
with db.locked(): with db.locked():
ipsets = db.read('ipsets')
networks = db.read('networks')
if flask.request.method == 'POST': if flask.request.method == 'POST':
form = flask.request.form # read network data from NetBox, merge in custom definitions and dump the lot
ipsets = {} ipsets = db.read('networks')
for name, ip, ip6 in zip(form.getlist('name'), form.getlist('ip'), form.getlist('ip6')): formdata = zip(*(flask.request.form.getlist(e) for e in ('name', 'ip', 'ip6', 'nat', 'vpn')))
if name and name not in networks: for name, ip, ip6, nat, vpn in formdata:
ipsets[name] = { # drop sets with empty names
'ip': ip.split(), if not name:
'ip6': ip6.split() continue
} # assign IPs for custom networks only
if name not in ipsets:
ipsets[name] = { 'ip': ip.split(), 'ip6': ip6.split() }
# assign NAT and VPN for all networks
ipsets[name] |= { 'nat': nat, 'vpn': vpn }
db.write('ipsets', ipsets) db.write('ipsets', ipsets)
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
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) return flask.render_template('ipsets/index.html', ipsets=ipsets)

View file

@ -1,26 +0,0 @@
import flask
import flask_login
from . import db
from . import system
blueprint = flask.Blueprint('nat', __name__)
@blueprint.route('/', methods=('GET', 'POST'))
@flask_login.login_required
def index():
if not flask_login.current_user.is_admin:
return flask.Response('forbidden', status=403, mimetype='text/plain')
with db.locked():
nat = { network: "" for network in db.read('networks') }
nat |= db.read('nat')
if flask.request.method == 'POST':
form = flask.request.form
for network, address in form.items():
if network in nat:
nat[network] = address
db.write('nat', nat)
system.run(system.save_config)
return flask.redirect(flask.url_for('nat.index'))
return flask.render_template('nat/index.html', nat=nat)

View file

@ -46,8 +46,6 @@ def edit(index):
with db.locked(): with db.locked():
ipsets = db.read('ipsets') ipsets = db.read('ipsets')
for network, data in db.read('networks').items():
ipsets[network] = {'ip': data.get('ip', []), 'ip6': data.get('ip6', [])}
return flask.render_template('rules/edit.html', index=index, rule=db.load('rules')[index], ipsets=ipsets) 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')
@ -55,24 +53,24 @@ def edit(index):
def can_toggle(user, rule): def can_toggle(user, rule):
return user.is_admin or not user.groups.isdisjoint(rule.get('managers', ())) return user.is_admin or not user.groups.isdisjoint(rule.get('managers', ()))
@blueprint.route('/manage') @blueprint.route('/manage', methods=('GET', 'POST'))
@flask_login.login_required @flask_login.login_required
def manage(): def manage():
rules = [rule|{'index': index} for index, rule in enumerate(db.load('rules')) with db.locked():
if can_toggle(flask_login.current_user, rule)] rules = db.read('rules')
return flask.render_template('rules/manage.html', rules=rules) allowed = set(rule['name'] for rule in rules if can_toggle(flask_login.current_user, rule))
if flask.request.method == 'POST':
@blueprint.route('/toggle/<int:index>/<enable>') # check that all posted rules are allowed for this user
@flask_login.login_required posted = set(flask.request.form.getlist('rule'))
def toggle(index, enable): if posted - allowed:
try:
with db.locked():
rules = db.read('rules')
if not can_toggle(flask_login.current_user, rules[index]):
return flask.Response('forbidden', status=403, mimetype='text/plain') return flask.Response('forbidden', status=403, mimetype='text/plain')
rules[index]['enabled'] = (enable == 'true')
# set status for posted rules
enabled = set(flask.request.form.getlist('enabled'))
for rule in rules:
if rule['name'] in posted:
rule['enabled'] = (rule['name'] in enabled)
db.write('rules', rules) db.write('rules', rules)
system.run(system.save_config) system.run(system.save_config)
return flask.redirect(flask.url_for('rules.manage')) return flask.redirect(flask.url_for('rules.manage'))
except IndexError as e: return flask.render_template('rules/manage.html', rules=[rule for rule in rules if rule['name'] in allowed])
return flask.Response(f'invalid rule: {index}', status=400, mimetype='text/plain')

View file

@ -19,6 +19,10 @@ import ldap3
from . import db from . import db
def init_app(app):
app.cli.add_command(generate)
app.cli.add_command(push)
def mail(rcpt, subject, body): def mail(rcpt, subject, body):
try: try:
msg = email.message.EmailMessage() msg = email.message.EmailMessage()
@ -31,10 +35,6 @@ def mail(rcpt, subject, body):
except Exception as e: except Exception as e:
syslog.syslog(f'error sending mail: {e}') syslog.syslog(f'error sending mail: {e}')
def init_app(app):
app.cli.add_command(generate)
app.cli.add_command(push)
def run(fun, args=()): def run(fun, args=()):
def task(): def task():
if os.fork() == 0: if os.fork() == 0:
@ -42,65 +42,52 @@ def run(fun, args=()):
fun(*args) fun(*args)
multiprocessing.Process(target=task).start() multiprocessing.Process(target=task).start()
def ipset_add(ipsets, name, ip=None, ip6=None): # Generate configuration files and create a config tarball.
ipsets[name].update(ip or ())
ipsets[f'{name}/6'].update(ip6 or ())
def save_config(): def save_config():
# Format strings for creating firewall config files.
nft_set = 'set {name} {{\n type ipv{family}_addr; flags interval; {elements}\n}}\n\n'
nft_map = 'map {name} {{\n type ipv4_addr : interval ipv4_addr; flags interval; {elements}\n}}\n\n'
nft_forward = '# {index}. {name}\n{text}\n\n'
wg_intf = '[Interface]\nListenPort = {port}\nPrivateKey = {key}\n\n'
wg_peer = '# {user}\n[Peer]\nPublicKey = {key}\nAllowedIPs = {ips}\n\n'
output = None output = None
try: try:
# Just load the 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')
groups = db.read('groups')
# For each user build a list of networks they have access to, based on # Build LDAP query for users and groups.
# group membership in AD. Only query groups associated with at least one filters = [
# network, and query each group only once. '(objectClass=user)', # only users
user_networks = collections.defaultdict(set) '(objectCategory=person)', # that are people
ldap = ldap3.Connection(ldap3.Server(settings.get('ldap_host'), use_ssl=True), '(!(userAccountControl:1.2.840.113556.1.4.803:=2))', # with enabled accounts
settings.get('ldap_user'), settings.get('ldap_pass'), auto_bind=True) ]
if group := settings.get('user_group'):
filters += [f'(memberOf:1.2.840.113556.1.4.1941:={group})'] # in given group, recursively
# Run query and store group membership data.
server = ldap3.Server(settings['ldap_host'], use_ssl=True)
ldap = ldap3.Connection(server, settings['ldap_user'], settings['ldap_pass'], auto_bind=True)
ldap.search(settings.get('ldap_base_dn', ''), ldap.search(settings.get('ldap_base_dn', ''),
'(&(objectClass=user)(objectCategory=person)' + # only people f'(&{"".join(filters)})', # conjuction (&(…)(…)(…)) of queries
'(!(userAccountControl:1.2.840.113556.1.4.803:=2))' + # with enabled accounts attributes=['userPrincipalName', 'memberOf'])
f'(memberOf:1.2.840.113556.1.4.1941:={settings.get("user_group", "")}))', # in given group, recursively user_groups = { e.userPrincipalName.value: set(e.memberOf) for e in ldap.entries }
attributes=['userPrincipalName', 'memberOf'])
for entry in ldap.entries:
for group in entry.memberOf:
if group in groups:
user_networks[entry.userPrincipalName.value].add(groups[group])
# Now read the settings again and lock the database while generating # Now read the settings again while keeping the database locked until
# config files, then increment version before unlocking. # config files are generated, and increment version before unlocking.
with db.locked(): with db.locked():
settings = db.read('settings') ipsets = db.read('ipsets')
version = settings['version'] = int(settings.get('version', 0)) + 1
# Populate IP sets.
ipsets = collections.defaultdict(set)
# Sets corresponding to VLANs in NetBox. Prefixes for these sets are configured on firewall nodes with ansible.
for name, network in db.read('networks').items():
ipset_add(ipsets, name)
# Sets defined by user in friwall app.
for name, network in db.read('ipsets').items():
ipset_add(ipsets, name, network.get('ip'), network.get('ip6'))
# Add registered VPN addresses for each network based on
# LDAP group membership.
wireguard = db.read('wireguard') wireguard = db.read('wireguard')
settings = db.read('settings')
version = settings['version'] = int(settings.get('version') or '0') + 1
# Update IP sets with VPN addresses based on AD group membership.
vpn_groups = set([e['vpn'] for e in ipsets.values() if e.get('vpn')])
group_networks = {
group: [name for name, data in ipsets.items() if data['vpn'] == group] for group in vpn_groups
}
for ip, key in wireguard.items(): for ip, key in wireguard.items():
ip4 = [f'{ip}/32'] for group in user_groups.get(key.get('user', ''), ()):
ip6 = [f'{key["ip6"]}'] if key.get('ip6') else None for network in group_networks.get(group, ()):
for network in user_networks.get(key.get('user', ''), ()): ipsets[network]['ip'].append(f'{ip}/32')
ipset_add(ipsets, network, ip4, ip6) if ip6 := key.get('ip6'):
ipsets[network]['ip6'].append(ip6)
# Create config files. # Create config files.
output = pathlib.Path.home() / 'config' / f'{version}' output = pathlib.Path.home() / 'config' / f'{version}'
@ -114,51 +101,70 @@ def save_config():
# Print nftables sets. # Print nftables sets.
with open(output / 'etc/nftables.d/sets.nft', 'w', encoding='utf-8') as f: with open(output / 'etc/nftables.d/sets.nft', 'w', encoding='utf-8') as f:
for name, ips in ipsets.items(): nft_set = 'set {name} {{\n type ipv4_addr; flags interval; {ips}\n}}\n'
f.write(nft_set.format( nft_set6 = 'set {name}/6 {{\n type ipv6_addr; flags interval; {ips}\n}}\n'
name=name, def make_set(ips):
family='6' if name.endswith('/6') else '4', # return "elements = { ip1, ip2, … }", prefixed with "# " if no ips
elements=f'{"" if ips else "# "}elements = {{ {", ".join(ips)} }}')) return f'{"" if ips else "# "}elements = {{ {", ".join(ips)} }}'
for name, data in ipsets.items():
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('\n')
# Print static NAT (1:1) rules. # Print static NAT (1:1) rules.
with open(output / 'etc/nftables.d/netmap.nft', 'w', encoding='utf-8') as f: with open(output / 'etc/nftables.d/netmap.nft', 'w', encoding='utf-8') as f:
netmap = db.read('netmap') # { private range: public range… } nft_map = 'map {name} {{\n type ipv4_addr : interval ipv4_addr; flags interval; elements = {{\n{ips}\n }}\n}}\n'
if netmap: def make_map(ips, reverse=False):
f.write(nft_map.format( # return "{ from1: to1, from2: to2, … }" with possibly reversed from and to
name='netmap-out', return ',\n'.join(f"{b if reverse else a}: {a if reverse else b}" for a, b in ips)
elements='elements = {' + ',\n'.join(f'{a}: {b}' for a, b in netmap.items()) + '}')) if netmap := db.read('netmap'): # { private range: public range… }
f.write(nft_map.format( f.write(nft_map.format(name='netmap-out', ips=make_map(netmap.items())))
name='netmap-in', f.write('\n')
elements='elements = {' + ',\n'.join(f'{b}: {a}' for a, b in netmap.items()) + '}')) f.write(nft_map.format(name='netmap-in', ips=make_map(netmap.items(), reverse=True)))
# 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:
nat = db.read('nat') # { network name: public range… } nft_nat = 'iif @inside oif @outside ip saddr @{name} snat to {nat}\n'
for network, address in nat.items(): for name, data in ipsets.items():
if address: if nat := data.get('nat'):
f.write(f'iif @inside oif @outside ip saddr @{network} snat to {address}\n') 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.
if vpn_networks := sorted(name for name, data in ipsets.items() if data.get('vpn')):
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'
f.write('# forward from the VPN interface to physical networks and back\n')
for name in vpn_networks:
f.write(nft_forward.format(name=name))
for name in vpn_networks:
f.write(nft_forward6.format(name=name))
f.write('\n')
# Custom forwarding rules.
nft_rule = '# {index}. {name}\n{text}\n\n'
for index, rule in enumerate(db.read('rules')): for index, rule in enumerate(db.read('rules')):
if rule.get('enabled') and rule.get('text'): if rule.get('enabled') and rule.get('text'):
f.write(nft_forward.format(index=index, name=rule.get('name', ''), text=rule['text'])) f.write(nft_rule.format(index=index, name=rule.get('name', ''), text=rule['text']))
# Print wireguard config. # Print wireguard config.
with open(output / 'etc/wireguard/wg.conf', 'w', encoding='utf-8') as f: with open(output / 'etc/wireguard/wg.conf', 'w', encoding='utf-8') as f:
f.write(wg_intf.format( # Server configuration.
port=settings.get('wg_port', 51820), wg_intf = '[Interface]\nListenPort = {port}\nPrivateKey = {key}\n\n'
key=settings.get('wg_key'))) f.write(wg_intf.format(port=settings.get('wg_port') or 51820, key=settings.get('wg_key')))
# Client configuration.
wg_peer = '# {user}\n[Peer]\nPublicKey = {key}\nAllowedIPs = {ips}\n\n'
for ip, data in wireguard.items(): for ip, data in wireguard.items():
f.write(wg_peer.format( f.write(wg_peer.format(
user=data.get('user'), user=data.get('user'),
key=data.get('key'), key=data.get('key'),
ips=', '.join(filter(None, [ip, data.get('ip6')])))) ips=', '.join(filter(None, [ip, data.get('ip6')]))))
# Make a config archive in a temporary place, so we don’t send incomplete tars. # Make a temporary config archive and move it to the final location,
# so we avoid sending incomplete tars.
tar_file = shutil.make_archive(f'{output}-tmp', 'gztar', root_dir=output, owner='root', group='root') tar_file = shutil.make_archive(f'{output}-tmp', 'gztar', root_dir=output, owner='root', group='root')
# Move config archive to the final destination.
os.rename(tar_file, f'{output}.tar.gz') os.rename(tar_file, f'{output}.tar.gz')
# If we get here, write settings with the new version. # If we get here, write settings with the new version.

View file

@ -27,11 +27,13 @@ h1 > a {
color: unset; color: unset;
text-decoration: none; text-decoration: none;
} }
input:read-only {
border-style: dotted;
}
pre { pre {
background-color: #eeeeee; background-color: #eeeeee;
border: 1px solid black; border: 1px solid #cccccc;
padding: 0.5em; padding: 0.5em;
margin: 0;
} }
th { th {
text-align: left; text-align: left;
@ -39,6 +41,9 @@ th {
th, td { th, td {
padding-right: 1em; padding-right: 1em;
} }
th {
border-bottom: 1px solid black;
}
ul.keys { ul.keys {
margin: 0 0.5em 0.5em; margin: 0 0.5em 0.5em;
padding-left: 1em; padding-left: 1em;
@ -48,6 +53,8 @@ ul.keys a {
} }
</style> </style>
{% block header %}{% endblock %}
<title>FRIwall</title> <title>FRIwall</title>
{% if current_user.is_authenticated %} {% if current_user.is_authenticated %}

View file

@ -14,18 +14,14 @@
<dl> <dl>
<dt><a href="{{ url_for('nodes') }}">Status</a> <dt><a href="{{ url_for('nodes') }}">Status</a>
<dd>status opek v požarnem zidu <dd>status opek v požarnem zidu
<dt><a href="{{ url_for('config.index') }}">Nastavitve</a> <dt><a href="{{ url_for('ipsets.index') }}">Omrežja</a>
<dd>nastavitve aplikacije FRIwall <dd>območja IP, naslovi NAT in skupine za VPN
<dt><a href="{{ url_for('ipsets.index') }}">Območja IP</a> <dt><a href="{{ url_for('rules.index') }}">Pravila</a>
<dd>definicije območij IP
<dt><a href="{{ url_for('rules.index') }}">Urejanje pravil</a>
<dd>pravila za posredovanje prometa <dd>pravila za posredovanje prometa
<dt><a href="{{ url_for('nat.index') }}">NAT</a>
<dd>javni naslovi za pisarniška omrežja
<dt><a href="{{ url_for('config.edit', name='netmap') }}">Netmap</a> <dt><a href="{{ url_for('config.edit', name='netmap') }}">Netmap</a>
<dd>statične 1:1 preslikave naslovov za strežniška omrežja <dd>statične 1:1 preslikave naslovov za strežniška omrežja
<dt><a href="{{ url_for('config.edit', name='groups') }}">Skupine</a> <dt><a href="{{ url_for('config.index') }}">Nastavitve</a>
<dd>preslikave uporabnikov LDAP v pisarniška omrežja <dd>nastavitve aplikacije FRIwall
</dl> </dl>
</section> </section>
{% endif %} {% endif %}

View file

@ -1,25 +1,41 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block header %}
<style>
td > input {
width: 100%;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<p> <p>
Urejate območja IP. Za vsako območje lahko dodate enega ali več obsegov IP in/ali IPv6, ločenih s presledki. Urejate območja IP za <a href="{{ url_for('rules.index') }}">posredovalna pravila</a> na požarnem zidu.
<p>
NAT se izvaja za notranja omrežja, kjer je nastavljen. Če nastavimo skupino AD, bodo omrežju dodane naprave, ki so jih v VPN povezali uporabniki v tej skupini. Vse naprave v posameznem omrežju so dostopne med sabo in za njih veljajo ista posredovalna pravila.
<p>
Imen in naslovnih prostorov fizičnih omrežij ne moremo spreminjati. Za svoja omrežja lahko definiramo vsa polja. Omrežje odstranimo tako, da mu pobrišemo ime.
<form id="request" method="POST"> <form id="request" method="POST">
<table> <table style="width: 100%;">
<thead> <thead>
<th>Ime<th>IP<th>IPv6 <th>Omrežje<th>IP<th>IPv6<th>NAT<th>VPN
<tbody> <tbody>
<tbody> {% for name, data in ipsets.items() %}
{% for name, addresses in ipsets.items() %}
<tr> <tr>
<td><input name="name" value="{{ name }}" /> <td style="max-width: 4em;"><input name="name" value="{{ name }}" {% if not data.custom %}readonly{% endif %} />
<td><input name="ip" value="{{ addresses.ip|join(' ') }}" /> <td style="max-width: 5em;"><input name="ip" value="{{ data.ip|join(' ') }}" {% if not data.custom %}readonly{% endif %} />
<td><input name="ip6" value="{{ addresses.ip6|join(' ') }}" /> <td style="max-width: 8em;"><input name="ip6" value="{{ data.ip6|join(' ') }}" {% if not data.custom %}readonly{% endif %} />
<td style="max-width: 5em;"><input name="nat" value="{{ data.nat }}" />
<td style=""><input name="vpn" value="{{ data.vpn }}" />
{% endfor %} {% endfor %}
<tr> <tr>
<td><input name="name" /> <td style="max-width: 4em;"><input name="name" placeholder="novo območje" />
<td><input name="ip" /> <td style="max-width: 5em;"><input name="ip" placeholder="10.0.0.0/8" />
<td><input name="ip6" /> <td style="max-width: 8em;"><input name="ip6" placeholder="fc00:1::/64 fc00:2::/64" />
<td style="max-width: 5em;"><input name="nat" />
<td><input name="vpn" placeholder="skupina v AD" />
</table> </table>
<p><button id="submit" type="submit">Shrani</button> <p><button id="submit" type="submit">Shrani</button>
</form> </form>

View file

@ -1,19 +0,0 @@
{% extends 'base.html' %}
{% block content %}
<p>
Urejate naslove NAT za pisarniška omrežja.
<form id="request" method="POST">
<table>
<tbody>
{% for office, address in nat.items() %}
<tr>
<td><label for="{{ office }}">{{ office }}</label>
<td><input id="{{ office }}" name="{{ office }}" value="{{ address }}" />
{% endfor %}
</table>
<p><button id="submit" type="submit">Shrani</button>
</form>
{% endblock %}

View file

@ -1,36 +1,70 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block header %}
<style>
hr {
border-style: dotted;
border-color: gray;
border-width: 1px 0 0;
}
tbody > tr:nth-child(odd) {
background-color: #eeeeee;
}
td {
vertical-align: top;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<p> <p>
Urejate pravilo #{{ index }}. V pravilih lahko uporabljate imena območij IP, prikazana spodaj. <a href="{{ url_for('rules.index') }}">Seznam pravil.</a> Urejate pravilo #{{ index }} (<a href="{{ url_for('rules.index') }}">seznam pravil</a>). Pravila so vključena v <a href="https://wiki.nftables.org">nftables</a> <em>filter chain</em> na požarnem zidu. Povzetek filtrov najdemo <a href="https://wiki.nftables.org/wiki-nftables/index.php/Quick_reference-nftables_in_10_minutes#Matches">v dokumentaciji</a>.
<form id="request" method="POST"> <form id="request" method="POST">
<p> <p>
<label for="name">Ime</label><br> <label for="name">Ime</label><br>
<input name="name" value="{{ rule.name }}" /> <input id="name" name="name" value="{{ rule.name }}" />
<p> <p>
Uporabniki, ki lahko o(ne)mogočijo pravilo<br> <label for="newmanager">Skupine, ki lahko o(ne)mogočijo pravilo</label>
<br>
{% for manager in rule.managers %} {% for manager in rule.managers %}
<input name="manager" type="text" style="width: 50%" value="{{ manager }}" /><br> <input name="manager" type="text" style="width: 50%" value="{{ manager }}" /><br>
{% endfor %} {% endfor %}
<input name="manager" type="text" style="width: 50%" value="" /> <input id="newmanager" name="manager" type="text" style="width: 50%" value="" />
<p> <p>
<label for="text">Pravila nftables</label> <label for="text">Pravila nftables</label>
<textarea id="text" name="text" style="width: 100%; height: 20em;">{{ rule.text }}</textarea> <textarea id="text" name="text" style="width: 100%; height: 8em;" placeholder="iif @inside ip saddr @from ip daddr @to accept iif @inside ip6 saddr @from/6 ip6 daddr @to/6 accept">
{{- rule.text }}
</textarea>
<p><button id="submit" type="submit">Shrani</button> <p><button id="submit" type="submit">Shrani</button>
</form> </form>
<table> <hr>
<p>
Promet z naslova <em>src</em> iz zunanjega omrežja na naslov <em>dst</em> na notranjem dovolimo z
<pre><code>iif @outside oif @inside ip saddr src ip daddr dst accept</code></pre>
<p>
Za naslova <em>src</em> in <em>dst</em> lahko uporabimo <a href="{{ url_for('ipsets.index') }}">definirana omrežja</a>, prikazana v spodnji tabeli. Za omrežje <code>net</code> uporabimo oznaki <code>@net</code> in <code>@net/6</code> za naslove IPv4 in IPv6. Da npr. preprečimo povezave iz omrežja <code>classroom</code> izven omrežja FRI, uporabimo pravili
<pre><code>iif @inside ip saddr @classroom ip daddr != @fri drop
iif @inside ip6 saddr @classroom/6 ip6 daddr != @fri/6 drop</code></pre>
<table style="width: 100%;">
<thead> <thead>
<th>Območje<th>IP<th>IPv6 <th>Omrežje
<th>IP
<th>IPv6
<th>VPN
<tbody> <tbody>
{% for network, addresses in ipsets.items() %} {% for name, data in ipsets.items() %}
<tr> <tr>
<td>{{ network }} <td>{{ name }}
<td>{{ addresses.ip|join('<br>')|safe }} <td>{{ data.ip|join('<br>')|safe }}
<td>{{ addresses.ip6|join('<br>')|safe }} <td>{{ data.ip6|join('<br>')|safe }}
<td>{{ data.vpn }}
{% endfor %} {% endfor %}
</table> </table>

View file

@ -2,10 +2,12 @@
{% block content %} {% block content %}
<p> <p>
Urejate prioritete pravil za požarni zid. Pravilo odstranite tako, da izbrišete pripadajočo številko. V zadnji vrstici lahko dodate novo pravilo. Urejate posredovalna pravila. Pravila razvrstimo s stolpcem <em>N</em>. Če vrednost pobrišemo, pravilo odstranimo.
<form id="request" method="POST"> <form id="request" method="POST">
<table> <table>
<thead>
<th>N<th>Pravilo
<tbody> <tbody>
{% for rule in rules %} {% for rule in rules %}
<tr> <tr>
@ -15,7 +17,7 @@ Urejate prioritete pravil za požarni zid. Pravilo odstranite tako, da izbrišet
{% endfor %} {% endfor %}
<tr> <tr>
<td><input name="index" type="number" min="0" size="4" /> <td><input name="index" type="number" min="0" size="4" />
<td><input name="name" type="text" size="40" /> <td><input name="name" type="text" size="40" placeholder="novo pravilo" />
</table> </table>
<p><button id="submit" type="submit">Shrani</button> <p><button id="submit" type="submit">Shrani</button>
</form> </form>

View file

@ -1,20 +1,33 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block header %}
<style>
pre {
margin: 0.5em 2em;
}
summary {
list-style: none;
}
</style>
{% endblock %}
{% block content %} {% block content %}
<p> <p>
Tu lahko vklopite in izklopite posamezna pravila za požarni zid. Tu lahko vklopite in izklopite posamezna pravila za požarni zid.
<form id="request" method="POST">
{% for rule in rules %} {% for rule in rules %}
<details> <details>
<summary> <summary>
{% if rule.enabled %} <input name="rule" type="hidden" value="{{ rule.name }}" />
<font color="green"></font> {{ rule.name }} <a href="{{ url_for('rules.toggle', index=rule.index, enable='false') }}">izklopi</a> <input name="enabled" type="checkbox" value="{{ rule.name }}" autocomplete="off"{% if rule.enabled %} checked{% endif %} />
{% else %} {{ rule.name }}
<font color="red"></font> {{ rule.name }} <a href="{{ url_for('rules.toggle', index=rule.index, enable='true') }}">vklopi</a>
{% endif %}
</summary> </summary>
<pre><code>{{ rule.text }}</code></pre> <pre><code>{{ rule.text }}</code></pre>
</details> </details>
{% endfor %} {% endfor %}
<p>
<button>Potrdi</button>
</form>
{% endblock %} {% endblock %}

View file

@ -2,24 +2,33 @@
{% block content %} {% block content %}
<p> <p>
Za oddeljano povezavo v omrežje FRI namestite <a href="https://www.wireguard.com/install/">WireGuard</a>, ustvarite ključ in sledite napotkom za posamezni sistem. Za VPN oziroma oddeljano povezavo v omrežje FRI uporabljamo <a href="https://wireguard.com">WireGuard</a>. Več informacij o uporabi in nastavitvah VPN najdete v <a href="https://doku.fri.uni-lj.si/vpn">dokumentaciji</a>.
<p>
Za priklop v omrežje spodaj ustvarite nov ključ in prenesite izpisano datoteko. Nato sledite napotkom za posamezni sistem.
<details> <details>
<summary>Windows / Mac</summary> <summary>Windows / Mac</summary>
<p> <p>
Zaženite WireGuard, kliknite <em>Import tunnel(s) from file</em> in izberite preneseno datoteko z nastavitvami. VPN nato (de)aktivirate s klikom na gumb <em>(De)activate</em>. Namestite in zaženite WireGuard za <a href="https://download.wireguard.com/windows-client/wireguard-installer.exe">Windows</a> ali <a href="https://itunes.apple.com/us/app/wireguard/id1451685025?ls=1&mt=12">Mac</a>. Kliknite <em>Import tunnel(s) from file</em> in izberite preneseno datoteko z nastavitvami <code>wg-fri.conf</code>. VPN nato aktivirate oziroma deaktivirate s klikom na gumb <em>(De)activate</em>.
</details> </details>
<details> <details>
<summary>Android / iOS</summary> <summary>Android / iOS</summary>
<p> <p>
Zaženite WireGuard, izberite <em>Scan from QR code</em> in skenirajte kodo, prikazano ob izdelavi novega ključa. Namestite WireGuard za <a href="https://play.google.com/store/apps/details?id=com.wireguard.android">Android</a> ali <a href="https://itunes.apple.com/us/app/wireguard/id1441195209?ls=1&mt=8">iOS</a>. Zaženite WireGuard, dodajte novo povezavo z <em>Import from file</em> in izberite preneseno datoteko z nastavitvami.
<p>
Ključ lahko ustvarite tudi na računalniku in ga v telefon dodate s <em>Scan from QR code</em> s kodo, prikazano ob novem ključu.
</details> </details>
<details> <details>
<summary>Linux / BSD</summary> <summary>Linux / BSD</summary>
<p> <p>
Nastavitve shranite (kot skrbnik) v <code>/etc/wireguard/wg-fri.conf</code>. VPN nato (de)aktivirate s <code>sudo wg-quick up wg-fri</code> oz. <code>sudo wg-quick down wg-fri</code>. Povezavo lahko uvozite tudi v <a href="https://www.xmodulo.com/wireguard-vpn-network-manager-gui.html">NetworkManager</a> ali podobno. Nastavitve shranite (kot skrbnik) v <code>/etc/wireguard/wg-fri.conf</code>. VPN nato (de)aktivirate s <code>sudo wg-quick up wg-fri</code> oz. <code>sudo wg-quick down wg-fri</code>.
<p>
Povezavo lahko uvozite tudi v NetworkManager z ukazom <code>nmcli connection import type wireguard file wg-fri.conf</code>.
</details> </details>
<section id="new-key"> <section id="new-key">
@ -27,19 +36,21 @@ Nastavitve shranite (kot skrbnik) v <code>/etc/wireguard/wg-fri.conf</code>. VPN
<form id="request"> <form id="request">
<p> <p>
Vnesite poljubno oznako in kliknite <em>Ustvari ključ</em>. Če vklopite prvo opcijo, bo vaš računalnik čez VPN usmeril ves mrežni promet, ne le tistega, ki je namenjen strežnikom na FRI. Če izklopite drugo opcijo, bodo nekatere storitve dostopne le prek naslova IP. Če ste v dvomih, pustite privzete nastavitve. Na vsaki napravi, ki jo želite povezati v omrežje FRI, ustvarite nov ključ. Privzete nastavitve usmerijo čez VPN le promet, namenjen strežnikom na FRI in UL.
<p> <p>
<label for="name">Ime naprave</label><br> <label for="name">Ime naprave</label><br>
<input id="name" name="name" pattern="[.-_A-Za-z0-9 ]*" maxlength="16" placeholder="A-Z a-z 0-9 . _ - " /> <input id="name" name="name" pattern="[-._A-Za-z0-9 ]*" maxlength="16" placeholder="A-Z a-z 0-9 . _ - " />
<button id="submit" type="submit">Ustvari ključ</button>
<p> <p>
<input type="checkbox" id="add_default" name="add_default" /> <input type="checkbox" id="add_default" name="add_default" />
<label for="add_default">Uporabi VPN za ves promet</label> <label for="add_default">Uporabi VPN za ves promet</label>
<br> <br>
<input type="checkbox" id="use_dns" name="use_dns" checked /> <input type="checkbox" id="use_dns" name="use_dns" checked />
<label for="use_dns">Uporabi imenske strežnike FRI</label> <label for="use_dns">Uporabi imenske strežnike FRI</label>
<p> <p>
<button id="submit" type="submit">Ustvari ključ</button> Če vklopite prvo opcijo, bo vaš računalnik čez VPN usmerjal ves promet. Če izklopite drugo opcijo, bodo nekateri strežniki dostopni le prek naslova IP. Če ste v dvomih, pustite privzete nastavitve.
</form> </form>
<section id="settings" style="display: none;"> <section id="settings" style="display: none;">
@ -47,7 +58,7 @@ Vnesite poljubno oznako in kliknite <em>Ustvari ključ</em>. Če vklopite prvo o
Nastavitve za povezavo so izpisane spodaj. Zasebni ključ varujte enako skrbno kot geslo, s katerim ste se prijavili; priporočena je raba šifriranega diska. Za nov ključ osvežite to stran. Nastavitve za povezavo so izpisane spodaj. Zasebni ključ varujte enako skrbno kot geslo, s katerim ste se prijavili; priporočena je raba šifriranega diska. Za nov ključ osvežite to stran.
<section style="display: flex; align-items: center;"> <section style="display: flex; align-items: center;">
<pre style="flex-grow: 3;"><a id="download" href="" style="float: right; padding: 0.5em;">Prenesi</a><code id="config"></code></pre> <pre style="flex-grow: 3; margin: 0;"><a id="download" href="" style="float: right; padding: 0.5em;">Prenesi</a><code id="config"></code></pre>
<div id="qr" style="flex-grow: 1; text-align: center;"></div> <div id="qr" style="flex-grow: 1; text-align: center;"></div>
</section> </section>
@ -57,9 +68,9 @@ V nastavitvah lahko dodate ali odstranite vnose <code>AllowedIPs</code>. Ti dolo
</section> </section>
<section class="keys" style="display: none;"> <section class="keys" style="display: none;">
<h1>Obstoječi ključi</h1> <h1>Ključi</h1>
<p> <p>
Za vsako napravo ustvarite nov ključ. Ključe, ki jih ne uporabljate, lahko tukaj odstranite. Trenutno so registrirani ključi: Če ključa ne uporabljamo, smo ga izgubili ali so nam ga ukradli, ga tukaj odstranimo. Trenutno so registrirani ključi:
<ul class="keys" style="list-style: none;"></ul> <ul class="keys" style="list-style: none;"></ul>
<p class="keys" id="active-key-warning" style="margin-top: 0;"> <p class="keys" id="active-key-warning" style="margin-top: 0;">
</section> </section>

View file

@ -50,7 +50,7 @@ def new():
server_pubkey = subprocess.run([f'wg pubkey'], input=settings.get('wg_key'), server_pubkey = subprocess.run([f'wg pubkey'], input=settings.get('wg_key'),
text=True, capture_output=True, shell=True).stdout.strip() text=True, capture_output=True, shell=True).stdout.strip()
host = ipaddress.ip_interface(settings.get('wg_net', '10.0.0.1/24')) host = ipaddress.ip_interface(settings.get('wg_net') or '10.0.0.1/24')
ip6 = None ip6 = None
with db.locked(): with db.locked():
# Find a free address for the new key. # Find a free address for the new key.
@ -63,7 +63,7 @@ def new():
else: else:
return flask.Response('no more available IP addresses', status=500, mimetype='text/plain') return flask.Response('no more available IP addresses', status=500, mimetype='text/plain')
now = datetime.datetime.utcnow() now = datetime.datetime.utcnow()
name = re.sub('[^\w ]', '', flask.request.json.get('name', '')) name = re.sub('[^-._A-Za-z0-9]', '', flask.request.json.get('name', ''))
keys[str(ip)] = { keys[str(ip)] = {
'key': pubkey, 'key': pubkey,
@ -80,7 +80,7 @@ def new():
# Template arguments. # Template arguments.
args = { args = {
'server': settings.get('wg_endpoint'), 'server': settings.get('wg_endpoint'),
'port': settings.get('wg_port', '51820'), 'port': settings.get('wg_port') or '51820',
'server_key': server_pubkey, 'server_key': server_pubkey,
'pubkey': pubkey, 'pubkey': pubkey,
'ip': str(ip), 'ip': str(ip),
@ -88,7 +88,7 @@ def new():
'timestamp': now, 'timestamp': now,
'name': name, 'name': name,
'dns': settings.get('wg_dns') if flask.request.json.get('use_dns', True) else False, 'dns': settings.get('wg_dns') if flask.request.json.get('use_dns', True) else False,
'allowed_nets': settings.get('wg_allowed_nets', []), 'allowed_nets': settings.get('wg_allowed_nets', ''),
'add_default': flask.request.json.get('add_default', False), 'add_default': flask.request.json.get('add_default', False),
} }
return flask.render_template('vpn/wg-fri.conf', **args) return flask.render_template('vpn/wg-fri.conf', **args)