Flask app progress
This commit is contained in:
parent
c4fe022d42
commit
b0994069ad
38 changed files with 6631 additions and 220 deletions
65
docker/router-dash/app/action_add_account.py
Normal file
65
docker/router-dash/app/action_add_account.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, re
|
||||
from datetime import datetime, timezone
|
||||
from auth import require_level
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_add_account', __name__)
|
||||
|
||||
DATA_DIR = '/data'
|
||||
ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
|
||||
|
||||
VALID_LEVELS = {'viewer', 'administrator', 'manager'}
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(ACCOUNTS_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {'accounts': []}
|
||||
|
||||
def _save_accounts(data):
|
||||
with open(ACCOUNTS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
@bp.route('/action/add_account', methods=['POST'])
|
||||
@require_level('manager')
|
||||
def add_account():
|
||||
email = sanitize.email(request.form.get('email_address', ''))
|
||||
access_level = request.form.get('access_level', '').strip()
|
||||
|
||||
if not email:
|
||||
flash('Email address is required.', 'error')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
|
||||
flash('Email address does not appear to be valid.', 'error')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
|
||||
if access_level not in VALID_LEVELS:
|
||||
flash('Invalid access level.', 'error')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
|
||||
data = _load_accounts()
|
||||
accounts = data.get('accounts', [])
|
||||
|
||||
if any(a.get('email_address', '').lower() == email for a in accounts):
|
||||
flash('An account with that email address already exists.', 'error')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
|
||||
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
accounts.append({
|
||||
'email_address': email,
|
||||
'access_level': access_level,
|
||||
'account_created_utc': now,
|
||||
'account_created_by': session.get('email_address', ''),
|
||||
'hashed_password': '',
|
||||
'timezone': '',
|
||||
})
|
||||
data['accounts'] = accounts
|
||||
_save_accounts(data)
|
||||
|
||||
flash(f'Authorization added for {email}. User must complete account setup via the Create Account page.', 'success')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
137
docker/router-dash/app/action_apply_banned_ips.py
Normal file
137
docker/router-dash/app/action_apply_banned_ips.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
bp = Blueprint('action_apply_banned_ips', __name__)
|
||||
|
||||
VIEW = '/view/view_banned_ips'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _parse_ip():
|
||||
"""Return validated IP string, or None after flashing an error."""
|
||||
raw = request.form.get('ip', '').strip()
|
||||
if not raw:
|
||||
flash('The configuration has not been saved because an IP address, CIDR, or wildcard pattern is required.', 'error')
|
||||
return None
|
||||
ip = validate.banned_ip(raw)
|
||||
if not ip:
|
||||
flash(f'The configuration has not been saved because "{raw}" is not a valid IP address, CIDR, or wildcard pattern.', 'error')
|
||||
return None
|
||||
return ip
|
||||
|
||||
|
||||
@bp.route('/action/add_banned_ip', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_banned_ip():
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
ip = _parse_ip()
|
||||
if ip is None:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
core.setdefault('banned_ips', []).append({
|
||||
'description': description,
|
||||
'ip': ip,
|
||||
'enabled': True,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_banned_ip', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_banned_ip():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('banned_ips', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/edit_banned_ip', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_banned_ip():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
ip = _parse_ip()
|
||||
if ip is None:
|
||||
return redirect(VIEW)
|
||||
enabled = request.form.get('enabled') == 'on'
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('banned_ips', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_banned_ip', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_banned_ip():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('banned_ips', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
172
docker/router-dash/app/action_apply_blocklists.py
Normal file
172
docker/router-dash/app/action_apply_blocklists.py
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, run_update_blocklists, apply_msg
|
||||
import re
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
bp = Blueprint('action_apply_blocklists', __name__)
|
||||
|
||||
VIEW = '/view/view_blocklists'
|
||||
|
||||
_VALID_FORMATS_STR = ', '.join(sorted(validate.VALID_BLOCKLIST_FORMATS))
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _save_as_from_name(name):
|
||||
slug = re.sub(r'[^a-z0-9_-]', '_', name.lower()).strip('_')
|
||||
return f'{slug}.conf'
|
||||
|
||||
|
||||
def _parse_fields():
|
||||
"""Parse and validate add/edit form fields. Returns (fields_dict, None) or (None, already_flashed)."""
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
fmt = request.form.get('format', '').strip()
|
||||
url = sanitize.url(request.form.get('url', ''))
|
||||
|
||||
if not name:
|
||||
flash('The configuration has not been saved because a name is required.', 'error')
|
||||
return None, True
|
||||
if not url:
|
||||
flash('The configuration has not been saved because a URL is required.', 'error')
|
||||
return None, True
|
||||
if fmt not in validate.VALID_BLOCKLIST_FORMATS:
|
||||
flash(f'The configuration has not been saved because "{fmt}" is not a valid format. '
|
||||
f'Accepted formats: {_VALID_FORMATS_STR}.', 'error')
|
||||
return None, True
|
||||
|
||||
return {'name': name, 'description': description, 'format': fmt, 'url': url}, None
|
||||
|
||||
|
||||
@bp.route('/action/add_blocklist', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_blocklist():
|
||||
fields, err = _parse_fields()
|
||||
if err:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
blocklists = core.setdefault('blocklists', [])
|
||||
|
||||
if any(b.get('name', '').lower() == fields['name'].lower() for b in blocklists):
|
||||
flash('The configuration has not been saved because a blocklist with that name already exists.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
blocklists.append({
|
||||
'name': fields['name'],
|
||||
'description': fields['description'],
|
||||
'format': fields['format'],
|
||||
'url': fields['url'],
|
||||
'save_as': _save_as_from_name(fields['name']),
|
||||
'enabled': True,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_blocklist', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_blocklist():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('blocklists', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/edit_blocklist', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_blocklist():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
fields, err = _parse_fields()
|
||||
if err:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('blocklists', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx].update({
|
||||
'name': fields['name'],
|
||||
'description': fields['description'],
|
||||
'format': fields['format'],
|
||||
'url': fields['url'],
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_blocklist', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_blocklist():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('blocklists', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/update_blocklists', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def update_blocklists():
|
||||
run_update_blocklists()
|
||||
flash('Blocklist refresh triggered.', 'success')
|
||||
return redirect(VIEW)
|
||||
139
docker/router-dash/app/action_apply_ddns_providers.py
Normal file
139
docker/router-dash/app/action_apply_ddns_providers.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
import json
|
||||
|
||||
bp = Blueprint('action_apply_ddns_providers', __name__)
|
||||
|
||||
DDNS_FILE = '/configs/ddns.json'
|
||||
|
||||
|
||||
@bp.route('/action/add_ddns_provider', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_ddns_provider():
|
||||
provider_type = request.form.get('provider', '').strip().lower()
|
||||
description = request.form.get('description', '').strip()
|
||||
hostnames_raw = request.form.get('hostnames', '')
|
||||
hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()]
|
||||
|
||||
if not description:
|
||||
flash('Description is required.', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
if not hostnames:
|
||||
flash('At least one hostname is required.', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
if provider_type not in ('noip', 'cloudflare', 'duckdns'):
|
||||
flash('Unknown provider type.', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
try:
|
||||
with open(DDNS_FILE) as f:
|
||||
data = json.load(f)
|
||||
except Exception as ex:
|
||||
flash(f'Could not read config: {ex}', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
entry = {
|
||||
'description': description,
|
||||
'provider': provider_type,
|
||||
'enabled': True,
|
||||
'hostnames': hostnames,
|
||||
}
|
||||
if provider_type == 'noip':
|
||||
entry['username'] = request.form.get('username', '').strip()
|
||||
entry['password'] = request.form.get('password', '').strip()
|
||||
else:
|
||||
entry['api_token'] = request.form.get('api_token', '').strip()
|
||||
|
||||
data.setdefault('providers', []).append(entry)
|
||||
|
||||
try:
|
||||
with open(DDNS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
flash(f'DDNS provider "{description}" added.', 'success')
|
||||
except Exception as ex:
|
||||
flash(f'Could not save config: {ex}', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
|
||||
@bp.route('/action/edit_ddns_provider', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_ddns_provider():
|
||||
try:
|
||||
row_index = int(request.form.get('row_index', -1))
|
||||
except (TypeError, ValueError):
|
||||
flash('Invalid row index.', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
provider_type = request.form.get('provider', '').strip().lower()
|
||||
description = request.form.get('description', '').strip()
|
||||
hostnames_raw = request.form.get('hostnames', '')
|
||||
enabled = request.form.get('enabled') == 'on'
|
||||
hostnames = [h.strip() for h in hostnames_raw.splitlines() if h.strip()]
|
||||
|
||||
try:
|
||||
with open(DDNS_FILE) as f:
|
||||
data = json.load(f)
|
||||
except Exception as ex:
|
||||
flash(f'Could not read config: {ex}', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
providers = data.get('providers', [])
|
||||
if row_index < 0 or row_index >= len(providers):
|
||||
flash('Invalid provider index.', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
entry = {
|
||||
'description': description,
|
||||
'provider': provider_type,
|
||||
'enabled': enabled,
|
||||
'hostnames': hostnames,
|
||||
}
|
||||
if provider_type == 'noip':
|
||||
entry['username'] = request.form.get('username', '').strip()
|
||||
entry['password'] = request.form.get('password', '').strip()
|
||||
else:
|
||||
entry['api_token'] = request.form.get('api_token', '').strip()
|
||||
|
||||
providers[row_index] = entry
|
||||
data['providers'] = providers
|
||||
|
||||
try:
|
||||
with open(DDNS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
flash('DDNS provider updated.', 'success')
|
||||
except Exception as ex:
|
||||
flash(f'Could not save config: {ex}', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
|
||||
@bp.route('/action/delete_ddns_provider', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_ddns_provider():
|
||||
try:
|
||||
row_index = int(request.form.get('row_index', -1))
|
||||
except (TypeError, ValueError):
|
||||
flash('Invalid row index.', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
try:
|
||||
with open(DDNS_FILE) as f:
|
||||
data = json.load(f)
|
||||
except Exception as ex:
|
||||
flash(f'Could not read config: {ex}', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
providers = data.get('providers', [])
|
||||
if row_index < 0 or row_index >= len(providers):
|
||||
flash('Invalid provider index.', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
|
||||
del providers[row_index]
|
||||
data['providers'] = providers
|
||||
|
||||
try:
|
||||
with open(DDNS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
flash('DDNS provider deleted.', 'success')
|
||||
except Exception as ex:
|
||||
flash(f'Could not save config: {ex}', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
187
docker/router-dash/app/action_apply_dhcp_reservations.py
Normal file
187
docker/router-dash/app/action_apply_dhcp_reservations.py
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
bp = Blueprint('action_apply_dhcp_reservations', __name__)
|
||||
|
||||
VIEW = '/view/view_dhcp'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _flat_index_to_vlan_res(vlans, flat_idx):
|
||||
pos = 0
|
||||
for vi, vlan in enumerate(vlans):
|
||||
for ri in range(len(vlan.get('reservations', []))):
|
||||
if pos == flat_idx:
|
||||
return vi, ri
|
||||
pos += 1
|
||||
return None, None
|
||||
|
||||
|
||||
def _parse_ip():
|
||||
"""Return validated IP string, or None after flashing an error."""
|
||||
raw = request.form.get('ip', '').strip()
|
||||
if not raw:
|
||||
flash('The configuration has not been saved because an IP address is required.', 'error')
|
||||
return None
|
||||
ip = validate.ip(raw)
|
||||
if not ip:
|
||||
flash(f'The configuration has not been saved because "{raw}" is not a valid IP address.', 'error')
|
||||
return None
|
||||
return ip
|
||||
|
||||
|
||||
@bp.route('/action/add_dhcp_reservation', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_dhcp_reservation():
|
||||
vlan_name = sanitize.name(request.form.get('vlan_name', ''))
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
hostname = sanitize.hostname(request.form.get('hostname', ''))
|
||||
mac = sanitize.mac(request.form.get('mac', ''))
|
||||
ip = _parse_ip()
|
||||
radius_client = 'radius_client' in request.form
|
||||
|
||||
if ip is None:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not vlan_name:
|
||||
flash('The configuration has not been saved because a VLAN is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not mac:
|
||||
flash('The configuration has not been saved because a MAC address is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
vlan = next((v for v in vlans if v.get('name') == vlan_name), None)
|
||||
if vlan is None:
|
||||
flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlan.setdefault('reservations', []).append({
|
||||
'description': description,
|
||||
'hostname': hostname,
|
||||
'mac': mac,
|
||||
'ip': ip,
|
||||
'radius_client': radius_client,
|
||||
'enabled': True,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_dhcp_reservation', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_dhcp_reservation():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
vi, ri = _flat_index_to_vlan_res(vlans, idx)
|
||||
if vi is None:
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
res = vlans[vi]['reservations'][ri]
|
||||
res['enabled'] = not res.get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/edit_dhcp_reservation', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_dhcp_reservation():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
hostname = sanitize.hostname(request.form.get('hostname', ''))
|
||||
mac = sanitize.mac(request.form.get('mac', ''))
|
||||
ip = _parse_ip()
|
||||
radius_client = 'radius_client' in request.form
|
||||
|
||||
if ip is None:
|
||||
return redirect(VIEW)
|
||||
if not mac:
|
||||
flash('The configuration has not been saved because a MAC address is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
vi, ri = _flat_index_to_vlan_res(vlans, idx)
|
||||
if vi is None:
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
res = vlans[vi]['reservations'][ri]
|
||||
enabled = res.get('enabled', True)
|
||||
res.update({
|
||||
'description': description,
|
||||
'hostname': hostname,
|
||||
'mac': mac,
|
||||
'ip': ip,
|
||||
'radius_client': radius_client,
|
||||
'enabled': enabled,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_dhcp_reservation', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_dhcp_reservation():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
vi, ri = _flat_index_to_vlan_res(vlans, idx)
|
||||
if vi is None:
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = vlans[vi]['reservations'].pop(ri)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
46
docker/router-dash/app/action_apply_general.py
Normal file
46
docker/router-dash/app/action_apply_general.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_general', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/action/apply_general', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_general():
|
||||
wan_interface = sanitize.interface_name(request.form.get('wan_interface', ''))
|
||||
log_max_kb_raw = request.form.get('log_max_kb', '').strip()
|
||||
log_errors_only = 'log_errors_only' in request.form
|
||||
dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
|
||||
daily_execute_time = sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', ''))
|
||||
|
||||
if not wan_interface:
|
||||
flash('WAN Interface is required.', 'error')
|
||||
return redirect('/view/view_general')
|
||||
|
||||
try:
|
||||
log_max_kb = int(log_max_kb_raw)
|
||||
if log_max_kb < 64:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash('Max Log Size must be a number >= 64.', 'error')
|
||||
return redirect('/view/view_general')
|
||||
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect('/view/view_general')
|
||||
|
||||
core = load_core()
|
||||
core.setdefault('general', {}).update({
|
||||
'wan_interface': wan_interface,
|
||||
'log_max_kb': log_max_kb,
|
||||
'log_errors_only': log_errors_only,
|
||||
'dnsmasq_log_queries': dnsmasq_log_queries,
|
||||
'daily_execute_time_24hr_local': daily_execute_time,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect('/view/view_general')
|
||||
129
docker/router-dash/app/action_apply_host_overrides.py
Normal file
129
docker/router-dash/app/action_apply_host_overrides.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_host_overrides', __name__)
|
||||
|
||||
VIEW = '/view/view_host_overrides'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@bp.route('/action/add_host_override', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_host_override():
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
host = sanitize.hostname(request.form.get('host', ''))
|
||||
ip = sanitize.ip(request.form.get('ip', ''))
|
||||
|
||||
if not host or not ip:
|
||||
flash('Hostname and IP address are required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
core.setdefault('host_overrides', []).append({
|
||||
'description': description,
|
||||
'host': host,
|
||||
'ip': ip,
|
||||
'enabled': True,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_host_override', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_host_override():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('host_overrides', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/edit_host_override', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_host_override():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
host = sanitize.hostname(request.form.get('host', ''))
|
||||
ip = sanitize.ip(request.form.get('ip', ''))
|
||||
|
||||
if not host or not ip:
|
||||
flash('Hostname and IP address are required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('host_overrides', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx].update({'description': description, 'host': host, 'ip': ip})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_host_override', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_host_override():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('host_overrides', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
167
docker/router-dash/app/action_apply_inter_vlan.py
Normal file
167
docker/router-dash/app/action_apply_inter_vlan.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
bp = Blueprint('action_apply_inter_vlan', __name__)
|
||||
|
||||
VIEW = '/view/view_inter_vlan'
|
||||
|
||||
_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _parse_entry():
|
||||
"""Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed)."""
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
protocol = request.form.get('protocol', '').strip()
|
||||
src_raw = request.form.get('src_ip_or_subnet', '').strip()
|
||||
dst_raw = request.form.get('dst_ip_or_subnet', '').strip()
|
||||
dst_port_raw = request.form.get('dst_port', '').strip()
|
||||
|
||||
if protocol not in validate.VALID_PROTOCOLS:
|
||||
flash(f'The configuration has not been saved because "{protocol}" is not a valid protocol. '
|
||||
f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
|
||||
return None, True
|
||||
|
||||
if not src_raw:
|
||||
flash('The configuration has not been saved because a source IP or subnet is required.', 'error')
|
||||
return None, True
|
||||
src = validate.ip_or_cidr(src_raw)
|
||||
if not src:
|
||||
flash(f'The configuration has not been saved because "{src_raw}" is not a valid IP address or subnet.', 'error')
|
||||
return None, True
|
||||
|
||||
if not dst_raw:
|
||||
flash('The configuration has not been saved because a destination IP or subnet is required.', 'error')
|
||||
return None, True
|
||||
dst = validate.ip_or_cidr(dst_raw)
|
||||
if not dst:
|
||||
flash(f'The configuration has not been saved because "{dst_raw}" is not a valid IP address or subnet.', 'error')
|
||||
return None, True
|
||||
|
||||
dst_port = ''
|
||||
if dst_port_raw:
|
||||
dst_port = validate.port(dst_port_raw)
|
||||
if not dst_port:
|
||||
flash(f'The configuration has not been saved because "{dst_port_raw}" is not a valid port number (1-65535).', 'error')
|
||||
return None, True
|
||||
|
||||
return {
|
||||
'description': description,
|
||||
'protocol': protocol,
|
||||
'src_ip_or_subnet': src,
|
||||
'dst_ip_or_subnet': dst,
|
||||
'dst_port': dst_port,
|
||||
'enabled': True,
|
||||
}, None
|
||||
|
||||
|
||||
@bp.route('/action/add_inter_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_inter_vlan():
|
||||
entry, err = _parse_entry()
|
||||
if err:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
core.setdefault('inter_vlan_exceptions', []).append(entry)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_inter_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_inter_vlan():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('inter_vlan_exceptions', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/edit_inter_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_inter_vlan():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
entry, err = _parse_entry()
|
||||
if err:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('inter_vlan_exceptions', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
enabled = items[idx].get('enabled', True)
|
||||
items[idx] = entry
|
||||
items[idx]['enabled'] = enabled
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_inter_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_inter_vlan():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('inter_vlan_exceptions', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
28
docker/router-dash/app/action_apply_mdns.py
Normal file
28
docker/router-dash/app/action_apply_mdns.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_mdns', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/action/apply_mdns', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_mdns():
|
||||
mdns_enabled = 'mdns_enabled' in request.form
|
||||
mdns_reflect_vlans = [sanitize.name(v) for v in request.form.getlist('mdns_reflect_vlans') if v.strip()]
|
||||
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect('/view/view_mdns')
|
||||
|
||||
core = load_core()
|
||||
core.setdefault('mdns_reflection', {}).update({
|
||||
'enabled': mdns_enabled,
|
||||
'reflect_vlans': mdns_reflect_vlans,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect('/view/view_mdns')
|
||||
168
docker/router-dash/app/action_apply_port_forwarding.py
Normal file
168
docker/router-dash/app/action_apply_port_forwarding.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
bp = Blueprint('action_apply_port_forwarding', __name__)
|
||||
|
||||
VIEW = '/view/view_port_forwarding'
|
||||
|
||||
_VALID_PROTOS_STR = ', '.join(sorted(validate.VALID_PROTOCOLS))
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _parse_entry():
|
||||
"""Parse and validate form fields. Returns (entry_dict, None) or (None, already_flashed)."""
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
protocol = request.form.get('protocol', '').strip()
|
||||
dest_port_raw = request.form.get('dest_port', '').strip()
|
||||
nat_ip_raw = request.form.get('nat_ip', '').strip()
|
||||
nat_port_raw = request.form.get('nat_port', '').strip()
|
||||
|
||||
if protocol not in validate.VALID_PROTOCOLS:
|
||||
flash(f'The configuration has not been saved because "{protocol}" is not a valid protocol. '
|
||||
f'Accepted values: {_VALID_PROTOS_STR}.', 'error')
|
||||
return None, True
|
||||
|
||||
if not dest_port_raw:
|
||||
flash('The configuration has not been saved because the external port is required.', 'error')
|
||||
return None, True
|
||||
dest_port = validate.port(dest_port_raw)
|
||||
if not dest_port:
|
||||
flash(f'The configuration has not been saved because "{dest_port_raw}" is not a valid port number (1-65535).', 'error')
|
||||
return None, True
|
||||
|
||||
if not nat_ip_raw:
|
||||
flash('The configuration has not been saved because the NAT IP address is required.', 'error')
|
||||
return None, True
|
||||
nat_ip = validate.ip(nat_ip_raw)
|
||||
if not nat_ip:
|
||||
flash(f'The configuration has not been saved because "{nat_ip_raw}" is not a valid IP address.', 'error')
|
||||
return None, True
|
||||
|
||||
if not nat_port_raw:
|
||||
flash('The configuration has not been saved because the NAT port is required.', 'error')
|
||||
return None, True
|
||||
nat_port = validate.port(nat_port_raw)
|
||||
if not nat_port:
|
||||
flash(f'The configuration has not been saved because "{nat_port_raw}" is not a valid port number (1-65535).', 'error')
|
||||
return None, True
|
||||
|
||||
return {
|
||||
'description': description,
|
||||
'protocol': protocol,
|
||||
'dest_port': dest_port,
|
||||
'nat_ip': nat_ip,
|
||||
'nat_port': nat_port,
|
||||
'enabled': True,
|
||||
}, None
|
||||
|
||||
|
||||
@bp.route('/action/add_port_forward', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_port_forward():
|
||||
entry, err = _parse_entry()
|
||||
if err:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
core.setdefault('port_forwarding', []).append(entry)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_port_forward', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_port_forward():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('port_forwarding', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items[idx]['enabled'] = not items[idx].get('enabled', True)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/edit_port_forward', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_port_forward():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
entry, err = _parse_entry()
|
||||
if err:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('port_forwarding', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
enabled = items[idx].get('enabled', True)
|
||||
items[idx] = entry
|
||||
items[idx]['enabled'] = enabled
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_port_forward', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_port_forward():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
items = core.get('port_forwarding', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
39
docker/router-dash/app/action_apply_upstream_dns.py
Normal file
39
docker/router-dash/app/action_apply_upstream_dns.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_upstream_dns', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/action/apply_upstream_dns', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_upstream_dns():
|
||||
strict_order = 'strict_order' in request.form
|
||||
cache_size_raw = request.form.get('cache_size', '').strip()
|
||||
upstream_servers = [sanitize.ip(s) for s in request.form.getlist('upstream_servers') if s.strip()]
|
||||
upstream_servers = [s for s in upstream_servers if s]
|
||||
|
||||
try:
|
||||
cache_size = int(cache_size_raw)
|
||||
if cache_size < 0:
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash('Cache Size must be a non-negative integer.', 'error')
|
||||
return redirect('/view/view_upstream_dns')
|
||||
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect('/view/view_upstream_dns')
|
||||
|
||||
core = load_core()
|
||||
core.setdefault('upstream_dns', {}).update({
|
||||
'strict_order': strict_order,
|
||||
'cache_size': cache_size,
|
||||
'upstream_servers': upstream_servers,
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect('/view/view_upstream_dns')
|
||||
129
docker/router-dash/app/action_apply_vlans.py
Normal file
129
docker/router-dash/app/action_apply_vlans.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_apply_vlans', __name__)
|
||||
|
||||
VIEW = '/view/view_vlans'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@bp.route('/action/add_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_vlan():
|
||||
vlan_id_raw = request.form.get('vlan_id', '').strip()
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
interface = sanitize.interface_name(request.form.get('interface', ''))
|
||||
subnet = sanitize.ip_or_cidr(request.form.get('subnet', ''))
|
||||
radius_default = 'radius_default' in request.form
|
||||
mdns_reflection = 'mdns_reflection' in request.form
|
||||
|
||||
if not vlan_id_raw or not name or not interface:
|
||||
flash('VLAN ID, name, and interface are required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
try:
|
||||
vlan_id = int(vlan_id_raw)
|
||||
if not (1 <= vlan_id <= 4094):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash('VLAN ID must be between 1 and 4094.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
vlans = core.setdefault('vlans', [])
|
||||
|
||||
if any(v.get('vlan_id') == vlan_id for v in vlans):
|
||||
flash(f'VLAN {vlan_id} already exists.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlans.append({
|
||||
'vlan_id': vlan_id,
|
||||
'name': name,
|
||||
'interface': interface,
|
||||
'dhcp': {'subnet': subnet},
|
||||
'use_blocklists': [],
|
||||
'radius_default': radius_default,
|
||||
'mdns_reflection': mdns_reflection,
|
||||
'reservations': [],
|
||||
})
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/edit_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_vlan():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
interface = sanitize.interface_name(request.form.get('interface', ''))
|
||||
subnet = sanitize.ip_or_cidr(request.form.get('subnet', ''))
|
||||
radius_default = 'radius_default' in request.form
|
||||
mdns_reflection = 'mdns_reflection' in request.form
|
||||
|
||||
if not name or not interface:
|
||||
flash('Name and interface are required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
if idx < 0 or idx >= len(vlans):
|
||||
flash('VLAN not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlans[idx].update({'name': name, 'interface': interface, 'radius_default': radius_default, 'mdns_reflection': mdns_reflection})
|
||||
vlans[idx].setdefault('dhcp', {})['subnet'] = subnet
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_vlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_vlan():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
core = load_core()
|
||||
vlans = core.get('vlans', [])
|
||||
if idx < 0 or idx >= len(vlans):
|
||||
flash('VLAN not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = vlans.pop(idx)
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(), 'success')
|
||||
return redirect(VIEW)
|
||||
93
docker/router-dash/app/action_apply_vpn.py
Normal file
93
docker/router-dash/app/action_apply_vpn.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_core, save_core, verify_core_hash, apply_msg, _APPLY_CMD_VPN
|
||||
import sanitize
|
||||
import validate
|
||||
|
||||
bp = Blueprint('action_apply_vpn', __name__)
|
||||
|
||||
_VIEW = '/view/view_vpn'
|
||||
_MTU_MIN = 576
|
||||
_MTU_MAX = 9000
|
||||
|
||||
|
||||
@bp.route('/action/apply_vpn', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_vpn():
|
||||
listen_port_raw = request.form.get('vpn_listen_port', '').strip()
|
||||
gateway_raw = request.form.get('vpn_gateway', '').strip()
|
||||
domain = sanitize.hostname(request.form.get('vpn_domain', ''))
|
||||
dns_raw = request.form.get('vpn_dns_server', '').strip()
|
||||
mtu_raw = request.form.get('vpn_mtu', '').strip()
|
||||
|
||||
# -- Listen port -----------------------------------------------------------
|
||||
if not listen_port_raw:
|
||||
flash('The configuration has not been saved because the listen port is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
try:
|
||||
listen_port = int(listen_port_raw)
|
||||
if not (1 <= listen_port <= 65535):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash(f'The configuration has not been saved because "{listen_port_raw}" is not a valid port number (1-65535).', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
# -- Gateway (required) ----------------------------------------------------
|
||||
if not gateway_raw:
|
||||
flash('The configuration has not been saved because a gateway IP address is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
gateway = validate.ip(gateway_raw)
|
||||
if not gateway:
|
||||
flash(f'The configuration has not been saved because "{gateway_raw}" is not a valid IP address.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
# -- DNS server (optional) -------------------------------------------------
|
||||
dns_server = ''
|
||||
if dns_raw:
|
||||
dns_server = validate.ip(dns_raw)
|
||||
if not dns_server:
|
||||
flash(f'The configuration has not been saved because "{dns_raw}" is not a valid IP address for DNS server.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
# -- MTU (optional) --------------------------------------------------------
|
||||
mtu = None
|
||||
if mtu_raw:
|
||||
try:
|
||||
mtu = int(mtu_raw)
|
||||
if not (_MTU_MIN <= mtu <= _MTU_MAX):
|
||||
raise ValueError
|
||||
except (ValueError, TypeError):
|
||||
flash(f'The configuration has not been saved because "{mtu_raw}" is not a valid MTU '
|
||||
f'(must be a number between {_MTU_MIN} and {_MTU_MAX}).', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
# -- Hash check and save ---------------------------------------------------
|
||||
if not verify_core_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
core = load_core()
|
||||
vpn_vlan = next((v for v in core.get('vlans', []) if 'vpn_information' in v), None)
|
||||
if vpn_vlan is None:
|
||||
flash('The configuration has not been saved because no VPN VLAN was found in the configuration.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
info = vpn_vlan.setdefault('vpn_information', {})
|
||||
info['listen_port'] = listen_port
|
||||
info['gateway'] = gateway
|
||||
info['domain'] = domain
|
||||
|
||||
overrides = info.setdefault('explicit_overrides', {})
|
||||
if dns_server:
|
||||
overrides['dns_server'] = dns_server
|
||||
else:
|
||||
overrides.pop('dns_server', None)
|
||||
if mtu is not None:
|
||||
overrides['mtu'] = mtu
|
||||
else:
|
||||
overrides.pop('mtu', None)
|
||||
|
||||
save_core(core)
|
||||
|
||||
flash(apply_msg(_APPLY_CMD_VPN), 'success')
|
||||
return redirect(_VIEW)
|
||||
64
docker/router-dash/app/action_change_password.py
Normal file
64
docker/router-dash/app/action_change_password.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, bcrypt
|
||||
from auth import require_level
|
||||
|
||||
bp = Blueprint('action_change_password', __name__)
|
||||
|
||||
DATA_DIR = '/data'
|
||||
ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(ACCOUNTS_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {'accounts': []}
|
||||
|
||||
def _save_accounts(data):
|
||||
with open(ACCOUNTS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
@bp.route('/action/change_password', methods=['POST'])
|
||||
@require_level('viewer')
|
||||
def change_password():
|
||||
current_password = request.form.get('current_password', '')
|
||||
new_password = request.form.get('new_password', '')
|
||||
confirm_password = request.form.get('confirm_password', '')
|
||||
|
||||
if not current_password or not new_password or not confirm_password:
|
||||
flash('All fields are required.', 'error')
|
||||
return redirect('/view/view_preferences')
|
||||
|
||||
if new_password != confirm_password:
|
||||
flash('New passwords do not match.', 'error')
|
||||
return redirect('/view/view_preferences')
|
||||
|
||||
if len(new_password) < 8:
|
||||
flash('New password must be at least 8 characters.', 'error')
|
||||
return redirect('/view/view_preferences')
|
||||
|
||||
email = session.get('email_address', '').lower()
|
||||
data = _load_accounts()
|
||||
accounts = data.get('accounts', [])
|
||||
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
|
||||
|
||||
if account is None:
|
||||
flash('Account not found. Please log in again.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
|
||||
stored_hash = account.get('hashed_password', '').encode('utf-8')
|
||||
if not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash):
|
||||
flash('Current password is incorrect.', 'error')
|
||||
return redirect('/view/view_preferences')
|
||||
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(new_password.encode('utf-8'), salt)
|
||||
|
||||
account['hashed_password'] = hashed.decode('utf-8')
|
||||
account['salt'] = salt.decode('utf-8')
|
||||
_save_accounts(data)
|
||||
|
||||
flash('Password changed successfully.', 'success')
|
||||
return redirect('/view/view_preferences')
|
||||
17
docker/router-dash/app/action_clear_ddns_log.py
Normal file
17
docker/router-dash/app/action_clear_ddns_log.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from flask import Blueprint, redirect, flash
|
||||
from auth import require_level
|
||||
|
||||
bp = Blueprint('action_clear_ddns_log', __name__)
|
||||
|
||||
LOG_FILE = '/configs/ddns.log'
|
||||
|
||||
|
||||
@bp.route('/action/clear_ddns_log', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def clear_ddns_log():
|
||||
try:
|
||||
open(LOG_FILE, 'w').close()
|
||||
flash('DDNS log cleared.', 'success')
|
||||
except Exception as ex:
|
||||
flash(f'Could not clear log: {ex}', 'error')
|
||||
return redirect('/view/view_ddns')
|
||||
106
docker/router-dash/app/action_create_account.py
Normal file
106
docker/router-dash/app/action_create_account.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, os, bcrypt, secrets, smtplib
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from email.message import EmailMessage
|
||||
from auth import require_level
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_create_account', __name__)
|
||||
|
||||
DATA_DIR = '/data'
|
||||
ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
|
||||
CODE_TTL_MIN = 15
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(ACCOUNTS_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {'accounts': []}
|
||||
|
||||
|
||||
def _send_verification_email(to_address, code):
|
||||
host = os.environ.get('SMTP_HOST', '')
|
||||
port = int(os.environ.get('SMTP_PORT', 587))
|
||||
user = os.environ.get('SMTP_USER', '')
|
||||
password = os.environ.get('SMTP_PASSWORD', '')
|
||||
from_addr = os.environ.get('SMTP_FROM', user)
|
||||
|
||||
if not host:
|
||||
raise RuntimeError('SMTP_HOST is not configured.')
|
||||
|
||||
msg = EmailMessage()
|
||||
msg['Subject'] = 'Router Dashboard - Email Verification'
|
||||
msg['From'] = from_addr
|
||||
msg['To'] = to_address
|
||||
msg.set_content(
|
||||
f'Your verification code is: {code}\n\n'
|
||||
f'This code expires in {CODE_TTL_MIN} minutes.\n\n'
|
||||
f'If you did not request this, you can ignore this email.'
|
||||
)
|
||||
|
||||
with smtplib.SMTP(host, port) as smtp:
|
||||
smtp.ehlo()
|
||||
if port != 465:
|
||||
smtp.starttls()
|
||||
if user and password:
|
||||
smtp.login(user, password)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
@bp.route('/action/create_account', methods=['POST'])
|
||||
@require_level('nothing')
|
||||
def create_account():
|
||||
# Abort if already logged in
|
||||
if session.get('access_level', 'nothing') != 'nothing':
|
||||
return redirect('/view/view_overview')
|
||||
|
||||
email = sanitize.email(request.form.get('email', ''))
|
||||
password = request.form.get('password', '')
|
||||
password_confirm = request.form.get('password_confirm', '')
|
||||
tz = sanitize.timezone(request.form.get('timezone', '').strip())
|
||||
|
||||
if not email or not password or not password_confirm or not tz:
|
||||
flash('All fields are required.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
if password != password_confirm:
|
||||
flash('Passwords do not match.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
if len(password) < 8:
|
||||
flash('Password must be at least 8 characters.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
accounts = _load_accounts().get('accounts', [])
|
||||
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
|
||||
|
||||
if account is None:
|
||||
flash('Email address not recognised. Contact your manager.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
if account.get('hashed_password'):
|
||||
flash('This account is already set up. Please log in instead.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||
code = f'{secrets.randbelow(1000000):06d}'
|
||||
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
|
||||
|
||||
try:
|
||||
_send_verification_email(account['email_address'], code)
|
||||
except Exception as exc:
|
||||
flash(f'Could not send verification email: {exc}', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
session['pending_create_account'] = {
|
||||
'email': account['email_address'],
|
||||
'hashed_password': hashed.decode('utf-8'),
|
||||
'timezone': tz,
|
||||
'code': code,
|
||||
'expires': expires,
|
||||
}
|
||||
|
||||
return redirect('/view/view_verify_email')
|
||||
51
docker/router-dash/app/action_delete_account.py
Normal file
51
docker/router-dash/app/action_delete_account.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json
|
||||
from auth import require_level
|
||||
|
||||
bp = Blueprint('action_delete_account', __name__)
|
||||
|
||||
DATA_DIR = '/data'
|
||||
ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(ACCOUNTS_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {'accounts': []}
|
||||
|
||||
def _save_accounts(data):
|
||||
with open(ACCOUNTS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
@bp.route('/action/delete_account', methods=['POST'])
|
||||
@require_level('manager')
|
||||
def delete_account():
|
||||
try:
|
||||
row_index = int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
|
||||
data = _load_accounts()
|
||||
accounts = data.get('accounts', [])
|
||||
|
||||
if row_index < 0 or row_index >= len(accounts):
|
||||
flash('Account not found.', 'error')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
|
||||
target = accounts[row_index]
|
||||
|
||||
if target.get('email_address', '').lower() == session.get('email_address', '').lower():
|
||||
flash('You cannot remove your own account.', 'error')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
|
||||
removed_email = target.get('email_address', '')
|
||||
accounts.pop(row_index)
|
||||
data['accounts'] = accounts
|
||||
_save_accounts(data)
|
||||
|
||||
flash(f'Account for {removed_email} has been removed.', 'success')
|
||||
return redirect('/view/view_manage_accounts')
|
||||
55
docker/router-dash/app/action_log_in.py
Normal file
55
docker/router-dash/app/action_log_in.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, bcrypt
|
||||
from auth import require_level
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_log_in', __name__)
|
||||
|
||||
DATA_DIR = '/data'
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(f'{DATA_DIR}/authorized_accounts.json') as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {'accounts': []}
|
||||
|
||||
|
||||
@bp.route('/action/log_in', methods=['POST'])
|
||||
@require_level('nothing')
|
||||
def log_in():
|
||||
# Abort if already logged in
|
||||
if session.get('access_level', 'nothing') != 'nothing':
|
||||
return redirect('/view/view_overview')
|
||||
|
||||
email = sanitize.email(request.form.get('email', ''))
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not email or not password:
|
||||
flash('Email address and password are required.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
|
||||
accounts = _load_accounts().get('accounts', [])
|
||||
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
|
||||
|
||||
if account is None:
|
||||
flash('Email address not recognised.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
|
||||
if not account.get('hashed_password'):
|
||||
flash('Account setup is not complete. Please use Create Account to set your password first.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
|
||||
stored_hash = account['hashed_password'].encode('utf-8')
|
||||
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash):
|
||||
flash('Invalid email address or password.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
|
||||
session.clear()
|
||||
session['email_address'] = account['email_address']
|
||||
session['access_level'] = account.get('access_level', 'viewer')
|
||||
session['timezone'] = account.get('timezone', '')
|
||||
session.permanent = True
|
||||
|
||||
return redirect('/view/view_overview')
|
||||
11
docker/router-dash/app/action_log_out.py
Normal file
11
docker/router-dash/app/action_log_out.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from flask import Blueprint, session, redirect
|
||||
from auth import require_level
|
||||
|
||||
bp = Blueprint('action_log_out', __name__)
|
||||
|
||||
|
||||
@bp.route('/action/log_out', methods=['POST'])
|
||||
@require_level('viewer')
|
||||
def log_out():
|
||||
session.clear()
|
||||
return redirect('/view/view_overview')
|
||||
48
docker/router-dash/app/action_save_preferences.py
Normal file
48
docker/router-dash/app/action_save_preferences.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json
|
||||
from auth import require_level
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('action_save_preferences', __name__)
|
||||
|
||||
DATA_DIR = '/data'
|
||||
ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(ACCOUNTS_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {'accounts': []}
|
||||
|
||||
def _save_accounts(data):
|
||||
with open(ACCOUNTS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
@bp.route('/action/save_preferences', methods=['POST'])
|
||||
@require_level('viewer')
|
||||
def save_preferences():
|
||||
tz = sanitize.timezone(request.form.get('timezone', '').strip())
|
||||
|
||||
if not tz:
|
||||
flash('Timezone is required.', 'error')
|
||||
return redirect('/view/view_preferences')
|
||||
|
||||
email = session.get('email_address', '').lower()
|
||||
data = _load_accounts()
|
||||
accounts = data.get('accounts', [])
|
||||
account = next((a for a in accounts if a.get('email_address', '').lower() == email), None)
|
||||
|
||||
if account is None:
|
||||
flash('Account not found. Please log in again.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
|
||||
account['timezone'] = tz
|
||||
_save_accounts(data)
|
||||
|
||||
session['timezone'] = tz
|
||||
|
||||
flash('Preferences saved.', 'success')
|
||||
return redirect('/view/view_preferences')
|
||||
113
docker/router-dash/app/action_verify_email.py
Normal file
113
docker/router-dash/app/action_verify_email.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, os, secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from auth import require_level
|
||||
|
||||
bp = Blueprint('action_verify_email', __name__)
|
||||
|
||||
DATA_DIR = '/data'
|
||||
ACCOUNTS_FILE = f'{DATA_DIR}/authorized_accounts.json'
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(ACCOUNTS_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {'accounts': []}
|
||||
|
||||
def _save_accounts(data):
|
||||
with open(ACCOUNTS_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
@bp.route('/action/verify_email', methods=['POST'])
|
||||
@require_level('nothing')
|
||||
def verify_email():
|
||||
# Abort if already logged in
|
||||
if session.get('access_level', 'nothing') != 'nothing':
|
||||
return redirect('/view/view_overview')
|
||||
|
||||
pending = session.get('pending_create_account')
|
||||
|
||||
if not pending:
|
||||
flash('No pending account creation found. Please start over.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
expires = datetime.fromisoformat(pending['expires'])
|
||||
if datetime.now(tz=timezone.utc) > expires:
|
||||
session.pop('pending_create_account', None)
|
||||
flash('Verification code has expired. Please start over.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
submitted = request.form.get('code', '').strip()
|
||||
if submitted != pending['code']:
|
||||
flash('Incorrect verification code.', 'error')
|
||||
return redirect('/view/view_verify_email')
|
||||
|
||||
data = _load_accounts()
|
||||
accounts = data.get('accounts', [])
|
||||
account = next(
|
||||
(a for a in accounts if a.get('email_address', '').lower() == pending['email'].lower()),
|
||||
None
|
||||
)
|
||||
|
||||
if account is None:
|
||||
session.pop('pending_create_account', None)
|
||||
flash('Account no longer exists. Contact your manager.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
if account.get('hashed_password'):
|
||||
session.pop('pending_create_account', None)
|
||||
flash('This account is already set up. Please log in.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
|
||||
now = datetime.now(tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
account['hashed_password'] = pending['hashed_password']
|
||||
account['timezone'] = pending['timezone']
|
||||
if not account.get('account_created_utc'):
|
||||
account['account_created_utc'] = now
|
||||
if not account.get('account_created_by'):
|
||||
account['account_created_by'] = 'self'
|
||||
|
||||
_save_accounts(data)
|
||||
session.pop('pending_create_account', None)
|
||||
|
||||
session['email_address'] = account['email_address']
|
||||
session['access_level'] = account.get('access_level', 'viewer')
|
||||
session['timezone'] = pending['timezone']
|
||||
session.permanent = True
|
||||
|
||||
return redirect('/view/view_overview')
|
||||
|
||||
|
||||
@bp.route('/action/resend_verification')
|
||||
@require_level('nothing')
|
||||
def resend_verification():
|
||||
# Abort if already logged in
|
||||
if session.get('access_level', 'nothing') != 'nothing':
|
||||
return redirect('/view/view_overview')
|
||||
|
||||
from action_create_account import _send_verification_email, CODE_TTL_MIN
|
||||
|
||||
pending = session.get('pending_create_account')
|
||||
|
||||
if not pending:
|
||||
flash('No pending account creation found. Please start over.', 'error')
|
||||
return redirect('/view/view_create_account')
|
||||
|
||||
code = f'{secrets.randbelow(1000000):06d}'
|
||||
expires = (datetime.now(tz=timezone.utc) + timedelta(minutes=CODE_TTL_MIN)).isoformat()
|
||||
|
||||
try:
|
||||
_send_verification_email(pending['email'], code)
|
||||
except Exception as exc:
|
||||
flash(f'Could not resend verification email: {exc}', 'error')
|
||||
return redirect('/view/view_verify_email')
|
||||
|
||||
pending['code'] = code
|
||||
pending['expires'] = expires
|
||||
session['pending_create_account'] = pending
|
||||
|
||||
flash('A new verification code has been sent.', 'success')
|
||||
return redirect('/view/view_verify_email')
|
||||
21
docker/router-dash/app/auth.py
Normal file
21
docker/router-dash/app/auth.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from flask import session, redirect, flash
|
||||
from functools import wraps
|
||||
|
||||
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
|
||||
|
||||
|
||||
def require_level(minimum):
|
||||
"""Decorator that enforces a minimum access level on an action route."""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapper(*args, **kwargs):
|
||||
current = session.get('access_level', 'nothing')
|
||||
if LEVEL_RANK.get(current, 0) < LEVEL_RANK.get(minimum, 0):
|
||||
if current == 'nothing':
|
||||
flash('Please log in to continue.', 'error')
|
||||
return redirect('/view/view_log_in')
|
||||
flash('You do not have permission to perform this action.', 'error')
|
||||
return redirect('/view/view_overview')
|
||||
return f(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
74
docker/router-dash/app/config_utils.py
Normal file
74
docker/router-dash/app/config_utils.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import json, subprocess, hashlib
|
||||
from markupsafe import Markup
|
||||
|
||||
_APPLY_CMD = 'sudo python3 ~/router/core.py --apply'
|
||||
_APPLY_CMD_VPN = 'sudo python3 ~/router/vpn.py --apply'
|
||||
|
||||
|
||||
def apply_msg(cmd=None):
|
||||
"""Return a Markup flash message for the apply reminder."""
|
||||
command = cmd if cmd is not None else _APPLY_CMD
|
||||
return Markup(
|
||||
f'Configuration updated. To apply changes, run: '
|
||||
f'<code><strong>{command}</strong></code>'
|
||||
)
|
||||
|
||||
CONFIGS_DIR = '/configs'
|
||||
CORE_FILE = f'{CONFIGS_DIR}/core.json'
|
||||
|
||||
|
||||
def load_core():
|
||||
try:
|
||||
with open(CORE_FILE) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_core(data):
|
||||
with open(CORE_FILE, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
|
||||
def core_hash():
|
||||
try:
|
||||
with open(CORE_FILE, 'rb') as f:
|
||||
return hashlib.md5(f.read()).hexdigest()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def verify_core_hash(submitted):
|
||||
if not submitted:
|
||||
return True
|
||||
return submitted == core_hash()
|
||||
|
||||
|
||||
def run_apply():
|
||||
try:
|
||||
subprocess.run(
|
||||
['python3', f'{CONFIGS_DIR}/core.py', '--apply'],
|
||||
capture_output=True, timeout=30
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_apply_vpn():
|
||||
try:
|
||||
subprocess.run(
|
||||
['python3', f'{CONFIGS_DIR}/vpn.py', '--apply'],
|
||||
capture_output=True, timeout=30
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def run_update_blocklists():
|
||||
try:
|
||||
subprocess.run(
|
||||
['python3', f'{CONFIGS_DIR}/core.py', '--update-blocklists'],
|
||||
capture_output=True, timeout=120
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -1,12 +1,84 @@
|
|||
import os, json, sys
|
||||
from flask import Flask
|
||||
from page_dashboard import bp as dashboard_bp
|
||||
from page_signup import bp as signup_bp
|
||||
from page_signin import bp as signin_bp
|
||||
from view_page import bp as view_page_bp
|
||||
from action_apply_general import bp as action_apply_general_bp
|
||||
from action_apply_upstream_dns import bp as action_apply_upstream_dns_bp
|
||||
from action_apply_mdns import bp as action_apply_mdns_bp
|
||||
from action_apply_vpn import bp as action_apply_vpn_bp
|
||||
from action_apply_banned_ips import bp as action_apply_banned_ips_bp
|
||||
from action_apply_host_overrides import bp as action_apply_host_overrides_bp
|
||||
from action_apply_blocklists import bp as action_apply_blocklists_bp
|
||||
from action_apply_vlans import bp as action_apply_vlans_bp
|
||||
from action_apply_inter_vlan import bp as action_apply_inter_vlan_bp
|
||||
from action_apply_port_forwarding import bp as action_apply_port_forwarding_bp
|
||||
from action_apply_dhcp_reservations import bp as action_apply_dhcp_reservations_bp
|
||||
from action_create_account import bp as action_create_account_bp
|
||||
from action_log_in import bp as action_log_in_bp
|
||||
from action_log_out import bp as action_log_out_bp
|
||||
from action_verify_email import bp as action_verify_email_bp
|
||||
from action_add_account import bp as action_add_account_bp
|
||||
from action_delete_account import bp as action_delete_account_bp
|
||||
from action_save_preferences import bp as action_save_preferences_bp
|
||||
from action_change_password import bp as action_change_password_bp
|
||||
from action_clear_ddns_log import bp as action_clear_ddns_log_bp
|
||||
from action_apply_ddns_providers import bp as action_apply_ddns_providers_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(dashboard_bp)
|
||||
app.register_blueprint(signup_bp)
|
||||
app.register_blueprint(signin_bp)
|
||||
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||
app.register_blueprint(view_page_bp)
|
||||
app.register_blueprint(action_apply_general_bp)
|
||||
app.register_blueprint(action_apply_upstream_dns_bp)
|
||||
app.register_blueprint(action_apply_mdns_bp)
|
||||
app.register_blueprint(action_apply_vpn_bp)
|
||||
app.register_blueprint(action_apply_banned_ips_bp)
|
||||
app.register_blueprint(action_apply_host_overrides_bp)
|
||||
app.register_blueprint(action_apply_blocklists_bp)
|
||||
app.register_blueprint(action_apply_vlans_bp)
|
||||
app.register_blueprint(action_apply_inter_vlan_bp)
|
||||
app.register_blueprint(action_apply_port_forwarding_bp)
|
||||
app.register_blueprint(action_apply_dhcp_reservations_bp)
|
||||
app.register_blueprint(action_create_account_bp)
|
||||
app.register_blueprint(action_log_in_bp)
|
||||
app.register_blueprint(action_log_out_bp)
|
||||
app.register_blueprint(action_verify_email_bp)
|
||||
app.register_blueprint(action_add_account_bp)
|
||||
app.register_blueprint(action_delete_account_bp)
|
||||
app.register_blueprint(action_save_preferences_bp)
|
||||
app.register_blueprint(action_change_password_bp)
|
||||
app.register_blueprint(action_clear_ddns_log_bp)
|
||||
app.register_blueprint(action_apply_ddns_providers_bp)
|
||||
|
||||
def _seed_initial_account():
|
||||
email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower()
|
||||
if not email:
|
||||
try:
|
||||
with open(accounts_file) as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
data = {'accounts': []}
|
||||
if not data.get('accounts'):
|
||||
print('[main] WARNING: No accounts exist and INITIAL_MANAGER_EMAIL is not set. '
|
||||
'Set it in docker-compose.yml to seed the initial manager account.', file=sys.stderr)
|
||||
return
|
||||
accounts_file = '/data/authorized_accounts.json'
|
||||
try:
|
||||
with open(accounts_file) as f:
|
||||
data = json.load(f)
|
||||
except Exception:
|
||||
data = {'accounts': []}
|
||||
if data.get('accounts'):
|
||||
return
|
||||
data['accounts'] = [{
|
||||
'email_address': email,
|
||||
'access_level': 'manager',
|
||||
'hashed_password': '',
|
||||
'timezone': '',
|
||||
}]
|
||||
with open(accounts_file, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
print(f'[main] Seeded initial manager account: {email}', file=sys.stderr)
|
||||
|
||||
_seed_initial_account()
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=25327)
|
||||
|
|
|
|||
156
docker/router-dash/app/sanitize.py
Normal file
156
docker/router-dash/app/sanitize.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import re
|
||||
|
||||
# Curated IANA timezone list for the dropdown. Validation accepts any entry from this set.
|
||||
VALID_TIMEZONES = [
|
||||
'UTC',
|
||||
# Americas
|
||||
'America/New_York',
|
||||
'America/Detroit',
|
||||
'America/Indiana/Indianapolis',
|
||||
'America/Chicago',
|
||||
'America/Denver',
|
||||
'America/Phoenix',
|
||||
'America/Los_Angeles',
|
||||
'America/Anchorage',
|
||||
'America/Adak',
|
||||
'Pacific/Honolulu',
|
||||
'America/Toronto',
|
||||
'America/Vancouver',
|
||||
'America/Winnipeg',
|
||||
'America/Halifax',
|
||||
'America/St_Johns',
|
||||
'America/Mexico_City',
|
||||
'America/Bogota',
|
||||
'America/Lima',
|
||||
'America/Santiago',
|
||||
'America/Caracas',
|
||||
'America/Sao_Paulo',
|
||||
'America/Argentina/Buenos_Aires',
|
||||
'America/Montevideo',
|
||||
# Europe
|
||||
'Europe/London',
|
||||
'Europe/Dublin',
|
||||
'Europe/Lisbon',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Brussels',
|
||||
'Europe/Madrid',
|
||||
'Europe/Rome',
|
||||
'Europe/Zurich',
|
||||
'Europe/Vienna',
|
||||
'Europe/Stockholm',
|
||||
'Europe/Oslo',
|
||||
'Europe/Copenhagen',
|
||||
'Europe/Helsinki',
|
||||
'Europe/Warsaw',
|
||||
'Europe/Prague',
|
||||
'Europe/Budapest',
|
||||
'Europe/Bucharest',
|
||||
'Europe/Athens',
|
||||
'Europe/Istanbul',
|
||||
'Europe/Moscow',
|
||||
'Europe/Kyiv',
|
||||
# Africa
|
||||
'Africa/Casablanca',
|
||||
'Africa/Lagos',
|
||||
'Africa/Cairo',
|
||||
'Africa/Nairobi',
|
||||
'Africa/Johannesburg',
|
||||
# Asia
|
||||
'Asia/Dubai',
|
||||
'Asia/Tbilisi',
|
||||
'Asia/Tehran',
|
||||
'Asia/Karachi',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Colombo',
|
||||
'Asia/Dhaka',
|
||||
'Asia/Yangon',
|
||||
'Asia/Bangkok',
|
||||
'Asia/Ho_Chi_Minh',
|
||||
'Asia/Singapore',
|
||||
'Asia/Kuala_Lumpur',
|
||||
'Asia/Jakarta',
|
||||
'Asia/Shanghai',
|
||||
'Asia/Hong_Kong',
|
||||
'Asia/Taipei',
|
||||
'Asia/Manila',
|
||||
'Asia/Seoul',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Yakutsk',
|
||||
'Asia/Vladivostok',
|
||||
# Australia / Pacific
|
||||
'Australia/Perth',
|
||||
'Australia/Darwin',
|
||||
'Australia/Adelaide',
|
||||
'Australia/Brisbane',
|
||||
'Australia/Sydney',
|
||||
'Australia/Melbourne',
|
||||
'Australia/Hobart',
|
||||
'Pacific/Auckland',
|
||||
'Pacific/Fiji',
|
||||
'Pacific/Guam',
|
||||
'Pacific/Honolulu',
|
||||
]
|
||||
|
||||
_TIMEZONE_SET = set(VALID_TIMEZONES)
|
||||
|
||||
|
||||
def _strip(value, pattern, max_len):
|
||||
return re.sub(pattern, '', str(value).strip())[:max_len]
|
||||
|
||||
|
||||
def text(value, max_len=200):
|
||||
"""General description: letters, digits, spaces, basic punctuation. No quotes/braces/brackets/slashes."""
|
||||
return _strip(value, r'''["'{}\[\]\\/<>;`^~]''', max_len)
|
||||
|
||||
def name(value, max_len=64):
|
||||
"""Label/name: letters, digits, spaces, hyphens, underscores, dots."""
|
||||
return _strip(value, r'[^A-Za-z0-9 \-_.]', max_len)
|
||||
|
||||
def hostname(value, max_len=253):
|
||||
"""Hostname or domain: letters, digits, hyphens, dots. Lowercased."""
|
||||
return _strip(value.lower(), r'[^a-z0-9\-.]', max_len)
|
||||
|
||||
def ip(value, max_len=45):
|
||||
"""IPv4 or IPv6 address: digits, dots, colons, hex letters."""
|
||||
return _strip(value, r'[^0-9a-fA-F.:]', max_len)
|
||||
|
||||
def ip_or_cidr(value, max_len=49):
|
||||
"""IP address or CIDR subnet: adds forward slash."""
|
||||
return _strip(value, r'[^0-9a-fA-F.:/]', max_len)
|
||||
|
||||
def mac(value, max_len=17):
|
||||
"""MAC address: hex digits and colons."""
|
||||
return _strip(value.upper(), r'[^0-9A-F:]', max_len)
|
||||
|
||||
def url(value, max_len=500):
|
||||
"""URL: printable ASCII except quotes, braces, brackets, backslash, spaces."""
|
||||
return _strip(value, r'''["'{}\[\]\\ ]''', max_len)
|
||||
|
||||
def interface_name(value, max_len=32):
|
||||
"""Network interface name: letters, digits, hyphens, underscores, dots."""
|
||||
return _strip(value, r'[^A-Za-z0-9\-_.]', max_len)
|
||||
|
||||
def port(value):
|
||||
"""Port number string, validated 1-65535. Returns '' if invalid."""
|
||||
digits = re.sub(r'[^0-9]', '', str(value))
|
||||
try:
|
||||
n = int(digits)
|
||||
if 1 <= n <= 65535:
|
||||
return str(n)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return ''
|
||||
|
||||
def time_24h(value, max_len=5):
|
||||
"""24-hour time HH:MM: digits and colon only."""
|
||||
return _strip(value, r'[^0-9:]', max_len)
|
||||
|
||||
def email(value, max_len=254):
|
||||
"""Email address: letters, digits, @, dot, hyphen, underscore, plus. Lowercased."""
|
||||
return _strip(value.lower(), r'[^a-z0-9@.\-_+]', max_len)
|
||||
|
||||
def timezone(value):
|
||||
"""Timezone string: must be in VALID_TIMEZONES list. Returns '' if not found."""
|
||||
return value if value in _TIMEZONE_SET else ''
|
||||
25
docker/router-dash/app/validate.py
Normal file
25
docker/router-dash/app/validate.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
"""
|
||||
validate.py -- Flask app re-export of the shared validation module.
|
||||
|
||||
~/router/validation.py is volume-mounted to /app/validation.py by
|
||||
docker-compose, making it directly importable. Action scripts import
|
||||
this module (validate.*) so they are insulated from the shared file's
|
||||
location and name.
|
||||
"""
|
||||
from validation import (
|
||||
VALID_PROTOCOLS,
|
||||
VALID_BLOCKLIST_FORMATS,
|
||||
ip,
|
||||
ip_or_cidr,
|
||||
port,
|
||||
banned_ip,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'VALID_PROTOCOLS',
|
||||
'VALID_BLOCKLIST_FORMATS',
|
||||
'ip',
|
||||
'ip_or_cidr',
|
||||
'port',
|
||||
'banned_ip',
|
||||
]
|
||||
1210
docker/router-dash/app/view_page.py
Normal file
1210
docker/router-dash/app/view_page.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1 +1 @@
|
|||
{}
|
||||
{"accounts": []}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
{}
|
||||
53
docker/router-dash/data/navbar_content.json
Normal file
53
docker/router-dash/data/navbar_content.json
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"items": [
|
||||
{
|
||||
"type": "nav_item",
|
||||
"label": "Overview",
|
||||
"map_to": "view_overview",
|
||||
"client_requirement": "client_is_nothing+"
|
||||
},
|
||||
{
|
||||
"type": "nav_menu",
|
||||
"label": "%MENU_LABEL%",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{ "type": "nav_item", "label": "General", "map_to": "view_general", "client_requirement": "client_is_administrator+" },
|
||||
{ "type": "nav_item", "label": "VLANs", "map_to": "view_vlans", "client_requirement": "client_is_administrator+" },
|
||||
{ "type": "nav_item", "label": "Inter-VLAN Exceptions","map_to": "view_inter_vlan", "client_requirement": "client_is_administrator+" },
|
||||
{ "type": "nav_item", "label": "Upstream DNS", "map_to": "view_upstream_dns", "client_requirement": "client_is_administrator+" },
|
||||
{ "type": "nav_item", "label": "DNS Blocklists", "map_to": "view_blocklists", "client_requirement": "client_is_administrator+" },
|
||||
{ "type": "nav_item", "label": "Port Forwarding", "map_to": "view_port_forwarding","client_requirement": "client_is_administrator+" },
|
||||
{ "type": "nav_item", "label": "DHCP", "map_to": "view_dhcp" },
|
||||
{ "type": "nav_item", "label": "Host Overrides", "map_to": "view_host_overrides", "client_requirement": "client_is_administrator+" },
|
||||
{ "type": "nav_item", "label": "DDNS", "map_to": "view_ddns" },
|
||||
{ "type": "nav_item", "label": "VPN", "map_to": "view_vpn" },
|
||||
{ "type": "nav_item", "label": "Banned IPs", "map_to": "view_banned_ips", "client_requirement": "client_is_administrator+" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "nav_menu",
|
||||
"label": "Profile",
|
||||
"align": "right",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{ "type": "nav_item", "label": "Preferences", "map_to": "view_preferences" },
|
||||
{ "type": "nav_item", "label": "Manage Accounts", "map_to": "view_manage_accounts", "client_requirement": "client_is_manager+" },
|
||||
{ "type": "nav_action", "label": "Log Out", "action": "log_out" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "nav_item",
|
||||
"label": "Log In",
|
||||
"map_to": "view_log_in",
|
||||
"align": "right",
|
||||
"client_requirement": "client_is_nothing="
|
||||
},
|
||||
{
|
||||
"type": "nav_item",
|
||||
"label": "Create Account",
|
||||
"map_to": "view_create_account",
|
||||
"align": "right",
|
||||
"client_requirement": "client_is_nothing="
|
||||
}
|
||||
]
|
||||
}
|
||||
2661
docker/router-dash/data/page_content.json
Normal file
2661
docker/router-dash/data/page_content.json
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue