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 flask import Flask
|
||||||
from page_dashboard import bp as dashboard_bp
|
from view_page import bp as view_page_bp
|
||||||
from page_signup import bp as signup_bp
|
from action_apply_general import bp as action_apply_general_bp
|
||||||
from page_signin import bp as signin_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 = Flask(__name__)
|
||||||
app.register_blueprint(dashboard_bp)
|
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24))
|
||||||
app.register_blueprint(signup_bp)
|
app.register_blueprint(view_page_bp)
|
||||||
app.register_blueprint(signin_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__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=25327)
|
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
|
|
@ -333,6 +333,7 @@ sudo python3 ddns.py --disable # Stop updates and remove systemd ti
|
||||||
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
|
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
|
||||||
python3 ddns.py --force # Force update regardless of cached IP
|
python3 ddns.py --force # Force update regardless of cached IP
|
||||||
python3 ddns.py --status # Timer/service status
|
python3 ddns.py --status # Timer/service status
|
||||||
|
python3 ddns.py --getip # Print current public IP and exit
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,7 @@
|
||||||
"name": "trusted",
|
"name": "trusted",
|
||||||
"interface": "enp6s0",
|
"interface": "enp6s0",
|
||||||
"radius_default": false,
|
"radius_default": false,
|
||||||
|
"mdns_reflection": false,
|
||||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||||
"server_identities": [
|
"server_identities": [
|
||||||
{ "description": "Router/Gateway", "ip": "192.168.1.1" },
|
{ "description": "Router/Gateway", "ip": "192.168.1.1" },
|
||||||
|
|
@ -134,6 +135,7 @@
|
||||||
"name": "iot",
|
"name": "iot",
|
||||||
"interface": "enp6s0.10",
|
"interface": "enp6s0.10",
|
||||||
"radius_default": false,
|
"radius_default": false,
|
||||||
|
"mdns_reflection": true,
|
||||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||||
"server_identities": [
|
"server_identities": [
|
||||||
{ "description": "Router/Gateway", "ip": "192.168.10.1" }
|
{ "description": "Router/Gateway", "ip": "192.168.10.1" }
|
||||||
|
|
@ -168,6 +170,7 @@
|
||||||
"name": "guest",
|
"name": "guest",
|
||||||
"interface": "enp6s0.20",
|
"interface": "enp6s0.20",
|
||||||
"radius_default": true,
|
"radius_default": true,
|
||||||
|
"mdns_reflection": true,
|
||||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||||
"server_identities": [
|
"server_identities": [
|
||||||
{ "description": "Router/Gateway", "ip": "192.168.20.1" }
|
{ "description": "Router/Gateway", "ip": "192.168.20.1" }
|
||||||
|
|
@ -196,6 +199,7 @@
|
||||||
"name": "kids",
|
"name": "kids",
|
||||||
"interface": "enp6s0.30",
|
"interface": "enp6s0.30",
|
||||||
"radius_default": false,
|
"radius_default": false,
|
||||||
|
"mdns_reflection": true,
|
||||||
"use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"],
|
"use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"],
|
||||||
"server_identities": [
|
"server_identities": [
|
||||||
{ "description": "Router/Gateway", "ip": "192.168.30.1" }
|
{ "description": "Router/Gateway", "ip": "192.168.30.1" }
|
||||||
|
|
@ -226,6 +230,7 @@
|
||||||
"name": "vpn",
|
"name": "vpn",
|
||||||
"interface": "wg0",
|
"interface": "wg0",
|
||||||
"radius_default": false,
|
"radius_default": false,
|
||||||
|
"mdns_reflection": false,
|
||||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||||
"vpn_information": {
|
"vpn_information": {
|
||||||
"listen_port": 51820,
|
"listen_port": 51820,
|
||||||
|
|
@ -240,11 +245,6 @@
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
],
|
]
|
||||||
|
|
||||||
"mdns_reflection": {
|
|
||||||
"enabled": true,
|
|
||||||
"reflect_vlans": ["iot", "guest", "kids"]
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
210
router/core.py
210
router/core.py
|
|
@ -100,6 +100,7 @@ import urllib.error
|
||||||
import argparse
|
import argparse
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from validation import VALID_PROTOCOLS, VALID_BLOCKLIST_FORMATS
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
CONFIG_FILE = SCRIPT_DIR / "core.json"
|
CONFIG_FILE = SCRIPT_DIR / "core.json"
|
||||||
|
|
@ -119,14 +120,14 @@ NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
|
||||||
|
|
||||||
log = None
|
log = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Logging
|
# Logging
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def chown_to_script_dir_owner(path):
|
def chown_to_script_dir_owner(path):
|
||||||
"""Chown a file to the owner of the script directory.
|
"""Chown a file to the owner of the script directory.
|
||||||
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
|
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
|
||||||
or as a normal user — the script directory owner is always the right target.
|
or as a normal user - the script directory owner is always the right target.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
stat = SCRIPT_DIR.stat()
|
stat = SCRIPT_DIR.stat()
|
||||||
|
|
@ -159,9 +160,9 @@ def setup_logging(max_kb, errors_only):
|
||||||
)
|
)
|
||||||
log = logging.getLogger("dns-dhcp")
|
log = logging.getLogger("dns-dhcp")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def service_warning(action, svc, stderr):
|
def service_warning(action, svc, stderr):
|
||||||
"""Print a service start/restart warning, adding --install hint if unit not found."""
|
"""Print a service start/restart warning, adding --install hint if unit not found."""
|
||||||
|
|
@ -172,7 +173,7 @@ def service_warning(action, svc, stderr):
|
||||||
|
|
||||||
|
|
||||||
def die(msg):
|
def die(msg):
|
||||||
print(f"ERROR: {msg}")
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def check_root():
|
def check_root():
|
||||||
|
|
@ -279,9 +280,9 @@ def expand_protocols(rule):
|
||||||
return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")]
|
return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")]
|
||||||
return [(proto, rule, "")]
|
return [(proto, rule, "")]
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Load
|
# Load
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
if not CONFIG_FILE.exists():
|
if not CONFIG_FILE.exists():
|
||||||
|
|
@ -292,9 +293,9 @@ def load_config():
|
||||||
die("No vlans defined in core.json.")
|
die("No vlans defined in core.json.")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Validate
|
# Validate
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def validate_config(data):
|
def validate_config(data):
|
||||||
errors = []
|
errors = []
|
||||||
|
|
@ -330,8 +331,8 @@ def validate_config(data):
|
||||||
for field in ("name", "description", "save_as", "url", "format"):
|
for field in ("name", "description", "save_as", "url", "format"):
|
||||||
if not bl.get(field):
|
if not bl.get(field):
|
||||||
errors.append(f"{label}: missing or empty field '{field}'.")
|
errors.append(f"{label}: missing or empty field '{field}'.")
|
||||||
if bl.get("format") and bl["format"] not in ("dnsmasq", "hosts"):
|
if bl.get("format") and bl["format"] not in VALID_BLOCKLIST_FORMATS:
|
||||||
errors.append(f"{label}: format must be 'dnsmasq' or 'hosts'.")
|
errors.append(f"{label}: format must be one of: {', '.join(sorted(VALID_BLOCKLIST_FORMATS))}.")
|
||||||
if name:
|
if name:
|
||||||
if name in blocklists_by_name:
|
if name in blocklists_by_name:
|
||||||
errors.append(f"{label}: duplicate blocklist name '{name}'.")
|
errors.append(f"{label}: duplicate blocklist name '{name}'.")
|
||||||
|
|
@ -365,6 +366,9 @@ def validate_config(data):
|
||||||
else:
|
else:
|
||||||
seen_interfaces[iface] = name
|
seen_interfaces[iface] = name
|
||||||
|
|
||||||
|
if vlan.get("mdns_reflection") is True and is_wg(vlan):
|
||||||
|
errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.")
|
||||||
|
|
||||||
if is_wg(vlan):
|
if is_wg(vlan):
|
||||||
vpi = vlan.get("vpn_information")
|
vpi = vlan.get("vpn_information")
|
||||||
if not isinstance(vpi, dict):
|
if not isinstance(vpi, dict):
|
||||||
|
|
@ -538,7 +542,7 @@ def validate_config(data):
|
||||||
errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.")
|
errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.")
|
||||||
|
|
||||||
# -- NAT / firewall validation ---------------------------------------------
|
# -- NAT / firewall validation ---------------------------------------------
|
||||||
valid_protos = {"tcp", "udp", "both"}
|
valid_protos = VALID_PROTOCOLS
|
||||||
known_interfaces = set(seen_interfaces.keys())
|
known_interfaces = set(seen_interfaces.keys())
|
||||||
|
|
||||||
def nat_check_port(label, port):
|
def nat_check_port(label, port):
|
||||||
|
|
@ -621,25 +625,11 @@ def validate_config(data):
|
||||||
if r.get("dst_port") is not None:
|
if r.get("dst_port") is not None:
|
||||||
nat_check_port(f"{label} dst_port", r.get("dst_port"))
|
nat_check_port(f"{label} dst_port", r.get("dst_port"))
|
||||||
|
|
||||||
# -- mdns_reflection validation --------------------------------------------
|
# -- radius_default uniqueness check ---------------------------------------
|
||||||
mdns = data.get("mdns_reflection", {})
|
defaults = [v["name"] for v in data["vlans"] if v.get("radius_default") is True]
|
||||||
if mdns.get("enabled") is True:
|
if len(defaults) > 1:
|
||||||
known_vlan_names = {v["name"] for v in data["vlans"]}
|
errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). "
|
||||||
reflect_vlans = mdns.get("reflect_vlans", [])
|
f"Only one VLAN may be the RADIUS default.")
|
||||||
for vname in reflect_vlans:
|
|
||||||
if vname not in known_vlan_names:
|
|
||||||
errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is not a known VLAN name.")
|
|
||||||
else:
|
|
||||||
vlan = next(v for v in data["vlans"] if v["name"] == vname)
|
|
||||||
if is_wg(vlan):
|
|
||||||
errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is a WireGuard VLAN "
|
|
||||||
f"and cannot participate in mDNS reflection.")
|
|
||||||
if not reflect_vlans:
|
|
||||||
errors.append("mdns_reflection.reflect_vlans is empty. "
|
|
||||||
"Add at least two VLAN names or set enabled: false.")
|
|
||||||
elif len(reflect_vlans) < 2:
|
|
||||||
errors.append("mdns_reflection.reflect_vlans must contain at least two VLANs — "
|
|
||||||
"reflecting mDNS on a single VLAN has no effect.")
|
|
||||||
|
|
||||||
# -- banned_ips validation -------------------------------------------------
|
# -- banned_ips validation -------------------------------------------------
|
||||||
for idx, entry in enumerate(data.get("banned_ips", [])):
|
for idx, entry in enumerate(data.get("banned_ips", [])):
|
||||||
|
|
@ -654,14 +644,14 @@ def validate_config(data):
|
||||||
errors.append(f"{lbl}: {e}")
|
errors.append(f"{lbl}: {e}")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
print("Validation failed:")
|
print("Validation failed:", file=sys.stderr)
|
||||||
for e in errors:
|
for e in errors:
|
||||||
print(f" - {e}")
|
print(f" - {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Build systemd-networkd files
|
# Build systemd-networkd files
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def build_netdev(vlan):
|
def build_netdev(vlan):
|
||||||
return "\n".join([
|
return "\n".join([
|
||||||
|
|
@ -787,9 +777,9 @@ def apply_networkd(data, dry_run=False, only_if_changed=False):
|
||||||
print("systemd-networkd: no changes. Good.")
|
print("systemd-networkd: no changes. Good.")
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Blocklist management
|
# Blocklist management
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def combo_hash(names):
|
def combo_hash(names):
|
||||||
"""Return a stable 8-char hex hash for a list/set of blocklist names."""
|
"""Return a stable 8-char hex hash for a list/set of blocklist names."""
|
||||||
|
|
@ -934,9 +924,9 @@ def update_blocklists(data):
|
||||||
any_failed = any(content is None for content, _ in downloaded.values())
|
any_failed = any(content is None for content, _ in downloaded.values())
|
||||||
return not any_failed
|
return not any_failed
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Build per-VLAN dnsmasq config
|
# Build per-VLAN dnsmasq config
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def _wan_has_ipv6(iface):
|
def _wan_has_ipv6(iface):
|
||||||
"""Return True if the WAN interface has a non-link-local IPv6 address."""
|
"""Return True if the WAN interface has a non-link-local IPv6 address."""
|
||||||
|
|
@ -1087,9 +1077,9 @@ def build_vlan_dnsmasq_conf(vlan, data):
|
||||||
|
|
||||||
return "\n".join(L)
|
return "\n".join(L)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Build per-VLAN systemd service unit
|
# Build per-VLAN systemd service unit
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def build_vlan_service(vlan):
|
def build_vlan_service(vlan):
|
||||||
name = vlan["name"]
|
name = vlan["name"]
|
||||||
|
|
@ -1133,9 +1123,9 @@ def build_vlan_service(vlan):
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# System dnsmasq / resolv.conf
|
# System dnsmasq / resolv.conf
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def ensure_resolv_conf(data):
|
def ensure_resolv_conf(data):
|
||||||
"""Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1)."""
|
"""Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1)."""
|
||||||
|
|
@ -1297,9 +1287,9 @@ def restore_ntp():
|
||||||
else:
|
else:
|
||||||
print("systemd-timesyncd is not available on this system.")
|
print("systemd-timesyncd is not available on this system.")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Apply dnsmasq instances
|
# Apply dnsmasq instances
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def wg_interface_up(iface):
|
def wg_interface_up(iface):
|
||||||
"""Return True if the WireGuard interface exists and is up."""
|
"""Return True if the WireGuard interface exists and is up."""
|
||||||
|
|
@ -1452,9 +1442,9 @@ def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
|
||||||
else:
|
else:
|
||||||
print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)")
|
print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Timer management
|
# Timer management
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def parse_time_to_calendar(time_str):
|
def parse_time_to_calendar(time_str):
|
||||||
parts = time_str.strip().split(":")
|
parts = time_str.strip().split(":")
|
||||||
|
|
@ -1519,9 +1509,9 @@ def remove_timer():
|
||||||
print(f"Not found, skipping: {f}")
|
print(f"Not found, skipping: {f}")
|
||||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# banned_ips expansion
|
# banned_ips expansion
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def _expand_banned_ipv4(ip_str):
|
def _expand_banned_ipv4(ip_str):
|
||||||
"""Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements."""
|
"""Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements."""
|
||||||
|
|
@ -1531,7 +1521,7 @@ def _expand_banned_ipv4(ip_str):
|
||||||
|
|
||||||
parts = ip_str.split('.')
|
parts = ip_str.split('.')
|
||||||
if len(parts) != 4:
|
if len(parts) != 4:
|
||||||
raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} — expected 4 octets")
|
raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} - expected 4 octets")
|
||||||
|
|
||||||
def parse_octet(s, pos):
|
def parse_octet(s, pos):
|
||||||
if s == '*':
|
if s == '*':
|
||||||
|
|
@ -1587,7 +1577,7 @@ def _expand_banned_ipv4(ip_str):
|
||||||
_enum_cidr(idx + 1, chosen + [v])
|
_enum_cidr(idx + 1, chosen + [v])
|
||||||
_enum_cidr(0, [])
|
_enum_cidr(0, [])
|
||||||
else:
|
else:
|
||||||
# No trailing wildcards — enumerate outer 3 octets, express last as range
|
# No trailing wildcards - enumerate outer 3 octets, express last as range
|
||||||
outer_ranges = ranges[:3]
|
outer_ranges = ranges[:3]
|
||||||
lo4, hi4 = ranges[3]
|
lo4, hi4 = ranges[3]
|
||||||
|
|
||||||
|
|
@ -1682,9 +1672,9 @@ def banned_ip_sets(data):
|
||||||
return v4, v6
|
return v4, v6
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# nftables config generation
|
# nftables config generation
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def build_nft_config(data, dry_run=False):
|
def build_nft_config(data, dry_run=False):
|
||||||
wan = data["general"]["wan_interface"]
|
wan = data["general"]["wan_interface"]
|
||||||
|
|
@ -1946,9 +1936,9 @@ def build_nft_config(data, dry_run=False):
|
||||||
|
|
||||||
return "\n".join(L)
|
return "\n".join(L)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# nftables apply / disable / status
|
# nftables apply / disable / status
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def table_exists(family, name):
|
def table_exists(family, name):
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
|
|
@ -1977,8 +1967,8 @@ def apply_nft_config(config_text):
|
||||||
capture_output=True, text=True
|
capture_output=True, text=True
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
print("ERROR: nft rejected the ruleset:")
|
print("ERROR: nft rejected the ruleset:", file=sys.stderr)
|
||||||
print(result.stderr)
|
print(result.stderr, file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def apply_nftables(data, dry_run=False):
|
def apply_nftables(data, dry_run=False):
|
||||||
|
|
@ -2075,9 +2065,9 @@ def show_rules():
|
||||||
else:
|
else:
|
||||||
print(result.stdout)
|
print(result.stdout)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# nftables boot service
|
# nftables boot service
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def install_nat_service():
|
def install_nat_service():
|
||||||
script_path = Path(__file__).resolve()
|
script_path = Path(__file__).resolve()
|
||||||
|
|
@ -2121,13 +2111,13 @@ def remove_nat_service():
|
||||||
else:
|
else:
|
||||||
print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service")
|
print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Status
|
# Status
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# RADIUS
|
# RADIUS
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
|
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
|
||||||
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
|
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
|
||||||
|
|
@ -2275,25 +2265,19 @@ def apply_radius(data):
|
||||||
service_warning("start", "freeradius", result.stderr)
|
service_warning("start", "freeradius", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Avahi mDNS Reflector
|
# Avahi mDNS Reflector
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
|
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
|
||||||
|
|
||||||
def avahi_enabled(data):
|
def avahi_enabled(data):
|
||||||
"""Return True if mdns_reflection is enabled with at least two VLANs configured."""
|
"""Return True if at least one non-WireGuard VLAN has mdns_reflection enabled."""
|
||||||
mdns = data.get("mdns_reflection", {})
|
return any(v.get("mdns_reflection") is True for v in data.get("vlans", []) if not is_wg(v))
|
||||||
return mdns.get("enabled") is True
|
|
||||||
|
|
||||||
def avahi_interfaces(data):
|
def avahi_interfaces(data):
|
||||||
"""Return list of interface names for mDNS reflection based on reflect_vlans."""
|
"""Return list of interface names for VLANs with mdns_reflection enabled."""
|
||||||
reflect = data.get("mdns_reflection", {}).get("reflect_vlans", [])
|
return [v["interface"] for v in data.get("vlans", []) if v.get("mdns_reflection") is True and not is_wg(v)]
|
||||||
ifaces = []
|
|
||||||
for vlan in data["vlans"]:
|
|
||||||
if vlan["name"] in reflect and not is_wg(vlan):
|
|
||||||
ifaces.append(vlan["interface"])
|
|
||||||
return ifaces
|
|
||||||
|
|
||||||
def build_avahi_conf(data):
|
def build_avahi_conf(data):
|
||||||
"""Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection.
|
"""Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection.
|
||||||
|
|
@ -2317,7 +2301,7 @@ def build_avahi_conf(data):
|
||||||
replacement = f"{directive}={value}"
|
replacement = f"{directive}={value}"
|
||||||
if pattern.search(text):
|
if pattern.search(text):
|
||||||
return pattern.sub(replacement, text)
|
return pattern.sub(replacement, text)
|
||||||
# Not present at all — this shouldn't happen with a standard avahi install
|
# Not present at all - this shouldn't happen with a standard avahi install
|
||||||
# but append it to the relevant section if needed
|
# but append it to the relevant section if needed
|
||||||
return text + f"\n{replacement}\n"
|
return text + f"\n{replacement}\n"
|
||||||
|
|
||||||
|
|
@ -2403,8 +2387,8 @@ def show_status(data):
|
||||||
r_enabled = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True)
|
r_enabled = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True)
|
||||||
active = r_active.stdout.strip()
|
active = r_active.stdout.strip()
|
||||||
enabled = r_enabled.stdout.strip()
|
enabled = r_enabled.stdout.strip()
|
||||||
active_sym = "✓" if active == "active" else "✗"
|
active_sym = "+" if active == "active" else "x"
|
||||||
enabled_sym = "✓" if enabled == "enabled" else "✗"
|
enabled_sym = "+" if enabled == "enabled" else "x"
|
||||||
active_ok = "(OK) " if active == expected_active else "(BAD)"
|
active_ok = "(OK) " if active == expected_active else "(BAD)"
|
||||||
enabled_ok = "(OK) " if enabled == "enabled" else "(BAD)"
|
enabled_ok = "(OK) " if enabled == "enabled" else "(BAD)"
|
||||||
return active_sym, active, active_ok, enabled_sym, enabled, enabled_ok
|
return active_sym, active, active_ok, enabled_sym, enabled, enabled_ok
|
||||||
|
|
@ -2416,7 +2400,7 @@ def show_status(data):
|
||||||
else:
|
else:
|
||||||
units.append((vlan_service_name(vlan), None, "active"))
|
units.append((vlan_service_name(vlan), None, "active"))
|
||||||
units.append((f"{TIMER_NAME}.timer", None, "active"))
|
units.append((f"{TIMER_NAME}.timer", None, "active"))
|
||||||
units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot — exits after running
|
units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot - exits after running
|
||||||
units.append(("freeradius", None, "active"))
|
units.append(("freeradius", None, "active"))
|
||||||
units.append(("avahi-daemon", None, "active"))
|
units.append(("avahi-daemon", None, "active"))
|
||||||
|
|
||||||
|
|
@ -2456,9 +2440,9 @@ def show_configs(data):
|
||||||
else:
|
else:
|
||||||
print(f"No config found at {cf} (not yet applied).")
|
print(f"No config found at {cf} (not yet applied).")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Leases
|
# Leases
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def reset_leases(data, vlan_name=None):
|
def reset_leases(data, vlan_name=None):
|
||||||
"""Stop dnsmasq instances, delete lease files, restart instances.
|
"""Stop dnsmasq instances, delete lease files, restart instances.
|
||||||
|
|
@ -2572,9 +2556,9 @@ def show_leases(data):
|
||||||
if not any_leases:
|
if not any_leases:
|
||||||
print("No active leases found.")
|
print("No active leases found.")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Metrics
|
# Metrics
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def collect_metrics(data):
|
def collect_metrics(data):
|
||||||
"""
|
"""
|
||||||
|
|
@ -2755,9 +2739,9 @@ def show_metrics(data):
|
||||||
print(f" NXDOMAIN : {s['nxdomain']:,}")
|
print(f" NXDOMAIN : {s['nxdomain']:,}")
|
||||||
print(f" Latency : {s['avg_latency_ms']}ms (last recorded)")
|
print(f" Latency : {s['avg_latency_ms']}ms (last recorded)")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Stop / disable
|
# Stop / disable
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def stop_instances(data):
|
def stop_instances(data):
|
||||||
"""Remove timer and stop all per-VLAN instances (config files preserved)."""
|
"""Remove timer and stop all per-VLAN instances (config files preserved)."""
|
||||||
|
|
@ -2867,19 +2851,19 @@ def _suggest_static_ip(physical_vlan):
|
||||||
chosen = max(non_gateway, key=lambda ip: ip.packed[-1])
|
chosen = max(non_gateway, key=lambda ip: ip.packed[-1])
|
||||||
return f"{chosen}/{prefix}"
|
return f"{chosen}/{prefix}"
|
||||||
|
|
||||||
# All identities end in .1 — pick a random unused host in the subnet
|
# All identities end in .1 - pick a random unused host in the subnet
|
||||||
hosts = list(network.hosts())
|
hosts = list(network.hosts())
|
||||||
candidates = [h for h in hosts if h not in known_ips and h.packed[-1] != 1]
|
candidates = [h for h in hosts if h not in known_ips and h.packed[-1] != 1]
|
||||||
if candidates:
|
if candidates:
|
||||||
chosen = random.choice(candidates)
|
chosen = random.choice(candidates)
|
||||||
return f"{chosen}/{prefix}"
|
return f"{chosen}/{prefix}"
|
||||||
|
|
||||||
# Degenerate fallback — extremely small subnet
|
# Degenerate fallback - extremely small subnet
|
||||||
return f"{list(network.hosts())[0]}/{prefix}"
|
return f"{list(network.hosts())[0]}/{prefix}"
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Dry-run helpers
|
# Dry-run helpers
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def _svc_state(unit):
|
def _svc_state(unit):
|
||||||
"""Return 'active', 'inactive', or 'unknown' for a systemd unit."""
|
"""Return 'active', 'inactive', or 'unknown' for a systemd unit."""
|
||||||
|
|
@ -2900,12 +2884,12 @@ def _dry_run_conflicting_services(data):
|
||||||
if state == "active":
|
if state == "active":
|
||||||
print(f" Would stop and disable: {label} (currently: active)")
|
print(f" Would stop and disable: {label} (currently: active)")
|
||||||
else:
|
else:
|
||||||
print(f" {label}: not active — no action needed")
|
print(f" {label}: not active - no action needed")
|
||||||
|
|
||||||
chrony_ok = subprocess.run(["systemctl", "cat", "chrony"],
|
chrony_ok = subprocess.run(["systemctl", "cat", "chrony"],
|
||||||
capture_output=True, text=True).returncode == 0
|
capture_output=True, text=True).returncode == 0
|
||||||
if not chrony_ok:
|
if not chrony_ok:
|
||||||
print(" chrony: not installed — dependency check would have prompted to install it")
|
print(" chrony: not installed - dependency check would have prompted to install it")
|
||||||
else:
|
else:
|
||||||
chrony_conf = Path("/etc/chrony/chrony.conf")
|
chrony_conf = Path("/etc/chrony/chrony.conf")
|
||||||
if chrony_conf.exists():
|
if chrony_conf.exists():
|
||||||
|
|
@ -2922,7 +2906,7 @@ def _dry_run_conflicting_services(data):
|
||||||
if missing:
|
if missing:
|
||||||
print(f" Would add chrony allow directives for: {', '.join(missing)}")
|
print(f" Would add chrony allow directives for: {', '.join(missing)}")
|
||||||
else:
|
else:
|
||||||
print(" chrony.conf already has required allow directives — no change needed")
|
print(" chrony.conf already has required allow directives - no change needed")
|
||||||
print(f" Would enable and restart: chrony")
|
print(f" Would enable and restart: chrony")
|
||||||
|
|
||||||
if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode == 0:
|
if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode == 0:
|
||||||
|
|
@ -2930,20 +2914,20 @@ def _dry_run_conflicting_services(data):
|
||||||
if "Status: active" in status.stdout:
|
if "Status: active" in status.stdout:
|
||||||
print(" Would disable: ufw (currently: active)")
|
print(" Would disable: ufw (currently: active)")
|
||||||
else:
|
else:
|
||||||
print(" ufw: not active — no rule action needed")
|
print(" ufw: not active - no rule action needed")
|
||||||
if _svc_enabled("ufw"):
|
if _svc_enabled("ufw"):
|
||||||
print(" Would disable: ufw.service (currently: enabled at boot)")
|
print(" Would disable: ufw.service (currently: enabled at boot)")
|
||||||
else:
|
else:
|
||||||
print(" ufw.service: not enabled at boot — no action needed")
|
print(" ufw.service: not enabled at boot - no action needed")
|
||||||
else:
|
else:
|
||||||
print(" ufw: not installed — no action needed")
|
print(" ufw: not installed - no action needed")
|
||||||
|
|
||||||
r = subprocess.run(["systemctl", "is-enabled", "dnsmasq"],
|
r = subprocess.run(["systemctl", "is-enabled", "dnsmasq"],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
if r.stdout.strip() in ("enabled", "enabled-runtime"):
|
if r.stdout.strip() in ("enabled", "enabled-runtime"):
|
||||||
print(f" Would stop and disable: system dnsmasq.service (currently: enabled)")
|
print(f" Would stop and disable: system dnsmasq.service (currently: enabled)")
|
||||||
else:
|
else:
|
||||||
print(" system dnsmasq.service: not enabled — no action needed")
|
print(" system dnsmasq.service: not enabled - no action needed")
|
||||||
|
|
||||||
physical = next((v for v in data["vlans"] if is_physical(v)), None)
|
physical = next((v for v in data["vlans"] if is_physical(v)), None)
|
||||||
if physical:
|
if physical:
|
||||||
|
|
@ -2956,7 +2940,7 @@ def _dry_run_conflicting_services(data):
|
||||||
if wanted not in current:
|
if wanted not in current:
|
||||||
print(f" Would update /etc/resolv.conf: nameserver {gw}")
|
print(f" Would update /etc/resolv.conf: nameserver {gw}")
|
||||||
else:
|
else:
|
||||||
print(f" /etc/resolv.conf already points to {gw} — no change needed")
|
print(f" /etc/resolv.conf already points to {gw} - no change needed")
|
||||||
|
|
||||||
def _dry_run_blocklists(data):
|
def _dry_run_blocklists(data):
|
||||||
print("-- Blocklists (dry-run) ----------------------------------------------")
|
print("-- Blocklists (dry-run) ----------------------------------------------")
|
||||||
|
|
@ -2982,7 +2966,7 @@ def _dry_run_timer(data):
|
||||||
for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]:
|
for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]:
|
||||||
action = "update" if path.exists() else "create and enable"
|
action = "update" if path.exists() else "create and enable"
|
||||||
print(f" Would {action}: {path}")
|
print(f" Would {action}: {path}")
|
||||||
print(f" Schedule: daily at {execute_time} local time (Persistent=true — catches up if missed)")
|
print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)")
|
||||||
|
|
||||||
def _dry_run_boot_service():
|
def _dry_run_boot_service():
|
||||||
print("-- Boot service (dry-run) --------------------------------------------")
|
print("-- Boot service (dry-run) --------------------------------------------")
|
||||||
|
|
@ -3016,11 +3000,11 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
print(f" Would flush nftables table: {table}")
|
print(f" Would flush nftables table: {table}")
|
||||||
else:
|
else:
|
||||||
print(f" nftables table {table}: not present — no action needed")
|
print(f" nftables table {table}: not present - no action needed")
|
||||||
if NAT_SERVICE_FILE.exists():
|
if NAT_SERVICE_FILE.exists():
|
||||||
print(f" Would stop, disable, and remove: {NAT_SERVICE_NAME}.service")
|
print(f" Would stop, disable, and remove: {NAT_SERVICE_NAME}.service")
|
||||||
else:
|
else:
|
||||||
print(f" {NAT_SERVICE_NAME}.service: not installed — no action needed")
|
print(f" {NAT_SERVICE_NAME}.service: not installed - no action needed")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
print("-- Restoring NTP client (dry-run) ------------------------------------")
|
print("-- Restoring NTP client (dry-run) ------------------------------------")
|
||||||
|
|
@ -3028,7 +3012,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
|
||||||
if state == "active":
|
if state == "active":
|
||||||
print(f" Would stop and disable: chrony (currently: active)")
|
print(f" Would stop and disable: chrony (currently: active)")
|
||||||
else:
|
else:
|
||||||
print(f" chrony: not active — no action needed")
|
print(f" chrony: not active - no action needed")
|
||||||
r = subprocess.run(["systemctl", "cat", "systemd-timesyncd"],
|
r = subprocess.run(["systemctl", "cat", "systemd-timesyncd"],
|
||||||
capture_output=True, text=True)
|
capture_output=True, text=True)
|
||||||
if r.returncode == 0:
|
if r.returncode == 0:
|
||||||
|
|
@ -3063,9 +3047,9 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
|
||||||
print(f" nameserver {static_nameserver}")
|
print(f" nameserver {static_nameserver}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Disable wizard
|
# Disable wizard
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cmd_disable(data, dry_run=False):
|
def cmd_disable(data, dry_run=False):
|
||||||
"""Interactive wizard to revert the machine from router to plain network client."""
|
"""Interactive wizard to revert the machine from router to plain network client."""
|
||||||
|
|
@ -3085,7 +3069,7 @@ def cmd_disable(data, dry_run=False):
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Step 1 — Confirmation
|
# Step 1 - Confirmation
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
while True:
|
while True:
|
||||||
print(" [1] Proceed with reversion")
|
print(" [1] Proceed with reversion")
|
||||||
|
|
@ -3100,7 +3084,7 @@ def cmd_disable(data, dry_run=False):
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Step 2 — IP configuration
|
# Step 2 - IP configuration
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
physical = next((v for v in data["vlans"] if is_physical(v)), None)
|
physical = next((v for v in data["vlans"] if is_physical(v)), None)
|
||||||
if physical is None:
|
if physical is None:
|
||||||
|
|
@ -3110,7 +3094,7 @@ def cmd_disable(data, dry_run=False):
|
||||||
|
|
||||||
print(" How should this machine obtain its IP address after reversion?")
|
print(" How should this machine obtain its IP address after reversion?")
|
||||||
print()
|
print()
|
||||||
print(" [1] Obtain IP via DHCP (recommended — let the new router assign one)")
|
print(" [1] Obtain IP via DHCP (recommended - let the new router assign one)")
|
||||||
print(" [2] Use a static IP")
|
print(" [2] Use a static IP")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
|
@ -3156,7 +3140,7 @@ def cmd_disable(data, dry_run=False):
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Step 3 — DNS resolver
|
# Step 3 - DNS resolver
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
# If resolv.conf is already a plain file with no router gateway IPs, leave it alone.
|
# If resolv.conf is already a plain file with no router gateway IPs, leave it alone.
|
||||||
|
|
@ -3187,7 +3171,7 @@ def cmd_disable(data, dry_run=False):
|
||||||
print()
|
print()
|
||||||
|
|
||||||
if resolved_available:
|
if resolved_available:
|
||||||
print(" [1] Re-enable systemd-resolved (recommended — adapts to any network)")
|
print(" [1] Re-enable systemd-resolved (recommended - adapts to any network)")
|
||||||
print(" [2] Enter a static nameserver IP")
|
print(" [2] Enter a static nameserver IP")
|
||||||
while True:
|
while True:
|
||||||
choice = input(" Choice [1/2]: ").strip()
|
choice = input(" Choice [1/2]: ").strip()
|
||||||
|
|
@ -3219,7 +3203,7 @@ def cmd_disable(data, dry_run=False):
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Step 4 — Execute (or dry-run summary)
|
# Step 4 - Execute (or dry-run summary)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
if dry_run:
|
if dry_run:
|
||||||
_dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver)
|
_dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver)
|
||||||
|
|
@ -3260,9 +3244,9 @@ def cmd_disable(data, dry_run=False):
|
||||||
else:
|
else:
|
||||||
print(f" Interface {iface} will use static IP: {static_cidr}")
|
print(f" Interface {iface} will use static IP: {static_cidr}")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
|
|
||||||
def cmd_install(data):
|
def cmd_install(data):
|
||||||
|
|
@ -3455,7 +3439,7 @@ def main():
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if args.dry_run and not any([args.apply, args.disable]):
|
if args.dry_run and not any([args.apply, args.disable]):
|
||||||
print("ERROR: --dry-run must be combined with --apply or --disable.")
|
print("ERROR: --dry-run must be combined with --apply or --disable.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
data = load_config()
|
data = load_config()
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@
|
||||||
"username": "your-username",
|
"username": "your-username",
|
||||||
"password": "your-password",
|
"password": "your-password",
|
||||||
"hostnames": [
|
"hostnames": [
|
||||||
"grotke.ddns.net"
|
"yoursubdomain.ddns.net",
|
||||||
|
"yourothersubdomain.ddns.net"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -36,16 +37,19 @@
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"api_token": "your-cloudflare-api-token",
|
"api_token": "your-cloudflare-api-token",
|
||||||
"hostnames": [
|
"hostnames": [
|
||||||
"yourdomain.com"
|
"yourdomain.com",
|
||||||
|
"yoursubdomain.yourdomain.com",
|
||||||
|
"yourothersubdomain.yourdomain.com"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"description": "DuckDNS Account",
|
"description": "DuckDNS Account",
|
||||||
"provider": "duckdns",
|
"provider": "duckdns",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "your-duckdns-token",
|
"api_token": "your-duckdns-api-token",
|
||||||
"subdomains": [
|
"hostnames": [
|
||||||
"yoursubdomain"
|
"yoursubdomain.duckdns.org",
|
||||||
|
"yourothersubdomain.duckdns.org"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
117
router/ddns.py
117
router/ddns.py
|
|
@ -18,9 +18,10 @@ Log is cleared when it exceeds general.log_max_kb from config.
|
||||||
Usage:
|
Usage:
|
||||||
sudo python3 ddns.py --start Run update and install systemd timer
|
sudo python3 ddns.py --start Run update and install systemd timer
|
||||||
sudo python3 ddns.py --disable Stop updates and remove systemd timer
|
sudo python3 ddns.py --disable Stop updates and remove systemd timer
|
||||||
sudo python3 ddns.py --apply Run update once (used by timer)
|
python3 ddns.py --apply Run update once (used by timer)
|
||||||
sudo python3 ddns.py --force Force update regardless of cached IP
|
python3 ddns.py --force Force update regardless of cached IP
|
||||||
sudo python3 ddns.py --status Show timer/service status
|
python3 ddns.py --status Show timer/service status
|
||||||
|
python3 ddns.py --getip Print current public IP and exit
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -44,13 +45,13 @@ TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer")
|
||||||
# log is assigned in setup_logging() after config is loaded
|
# log is assigned in setup_logging() after config is loaded
|
||||||
log = None
|
log = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Load config
|
# Load config
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def load_config():
|
def load_config():
|
||||||
if not CONFIG_FILE.exists():
|
if not CONFIG_FILE.exists():
|
||||||
print(f"ERROR: Config file not found: {CONFIG_FILE}")
|
print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
with open(CONFIG_FILE) as f:
|
with open(CONFIG_FILE) as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
@ -59,47 +60,47 @@ def load_config():
|
||||||
required_general = {"log_max_kb", "log_errors_only", "ip_check_services"}
|
required_general = {"log_max_kb", "log_errors_only", "ip_check_services"}
|
||||||
missing = required_general - set(data.get("general", {}).keys())
|
missing = required_general - set(data.get("general", {}).keys())
|
||||||
if missing:
|
if missing:
|
||||||
print(f"ERROR: Missing keys in general block: {missing}")
|
print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
if not data["general"]["ip_check_services"]:
|
if not data["general"]["ip_check_services"]:
|
||||||
print("ERROR: ip_check_services list is empty.")
|
print("ERROR: ip_check_services list is empty.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# Validate providers block
|
# Validate providers block
|
||||||
if not data.get("providers"):
|
if not data.get("providers"):
|
||||||
print("ERROR: No providers defined in config.")
|
print("ERROR: No providers defined in config.", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
for p in data["providers"]:
|
for p in data["providers"]:
|
||||||
base_required = {"description", "provider", "enabled"}
|
base_required = {"description", "provider", "enabled"}
|
||||||
missing = base_required - set(p.keys())
|
missing = base_required - set(p.keys())
|
||||||
if missing:
|
if missing:
|
||||||
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}")
|
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
ptype = p.get("provider", "").lower()
|
ptype = p.get("provider", "").lower()
|
||||||
if ptype == "noip":
|
if ptype == "noip":
|
||||||
extra = {"username", "password", "hostnames"}
|
extra = {"username", "password", "hostnames"}
|
||||||
elif ptype == "duckdns":
|
elif ptype == "duckdns":
|
||||||
extra = {"token", "subdomains"}
|
extra = {"api_token", "hostnames"}
|
||||||
elif ptype == "cloudflare":
|
elif ptype == "cloudflare":
|
||||||
extra = {"api_token", "hostnames"}
|
extra = {"api_token", "hostnames"}
|
||||||
else:
|
else:
|
||||||
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'")
|
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
missing = extra - set(p.keys())
|
missing = extra - set(p.keys())
|
||||||
if missing:
|
if missing:
|
||||||
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}")
|
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def chown_to_script_dir_owner(path):
|
def chown_to_script_dir_owner(path):
|
||||||
"""Chown a file to the owner of the script directory.
|
"""Chown a file to the owner of the script directory.
|
||||||
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
|
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
|
||||||
or as a normal user — the script directory owner is always the right target.
|
or as a normal user - the script directory owner is always the right target.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
stat = SCRIPT_DIR.stat()
|
stat = SCRIPT_DIR.stat()
|
||||||
|
|
@ -107,9 +108,9 @@ def chown_to_script_dir_owner(path):
|
||||||
except OSError:
|
except OSError:
|
||||||
pass # non-fatal
|
pass # non-fatal
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Logging
|
# Logging
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def setup_logging(max_kb, errors_only):
|
def setup_logging(max_kb, errors_only):
|
||||||
"""Clear log if oversized, then initialise logger. Must be called before log is used."""
|
"""Clear log if oversized, then initialise logger. Must be called before log is used."""
|
||||||
|
|
@ -138,9 +139,9 @@ def setup_logging(max_kb, errors_only):
|
||||||
)
|
)
|
||||||
log = logging.getLogger("ddns")
|
log = logging.getLogger("ddns")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Per-provider IP cache
|
# Per-provider IP cache
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cache_file_for(description):
|
def cache_file_for(description):
|
||||||
"""Return the cache file path for a given provider description."""
|
"""Return the cache file path for a given provider description."""
|
||||||
|
|
@ -158,9 +159,9 @@ def save_cached_ip(description, ip):
|
||||||
f.write_text(ip)
|
f.write_text(ip)
|
||||||
chown_to_script_dir_owner(f)
|
chown_to_script_dir_owner(f)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Service rotation
|
# Service rotation
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def get_next_service_index(total):
|
def get_next_service_index(total):
|
||||||
"""Read last used index, increment, wrap around, return next index."""
|
"""Read last used index, increment, wrap around, return next index."""
|
||||||
|
|
@ -177,8 +178,9 @@ def save_service_index(index):
|
||||||
CACHE_SERVICE_FILE.write_text(str(index))
|
CACHE_SERVICE_FILE.write_text(str(index))
|
||||||
chown_to_script_dir_owner(CACHE_SERVICE_FILE)
|
chown_to_script_dir_owner(CACHE_SERVICE_FILE)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Public IP detection
|
# Public IP detection
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
def extract_ip(body):
|
def extract_ip(body):
|
||||||
"""
|
"""
|
||||||
|
|
@ -225,7 +227,7 @@ def _get_ip_via_cf_dns(spec):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def get_public_ip(services):
|
def get_public_ip(services):
|
||||||
"""
|
"""
|
||||||
|
|
@ -258,9 +260,9 @@ def get_public_ip(services):
|
||||||
log.error("Could not determine public IP from any configured service.")
|
log.error("Could not determine public IP from any configured service.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# No-IP update
|
# No-IP update
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def update_noip(provider, ip):
|
def update_noip(provider, ip):
|
||||||
"""
|
"""
|
||||||
|
|
@ -325,9 +327,9 @@ def interpret_noip_response(response, hostnames, ip):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# DuckDNS update
|
# DuckDNS update
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def update_duckdns(provider, ip):
|
def update_duckdns(provider, ip):
|
||||||
"""
|
"""
|
||||||
|
|
@ -338,8 +340,8 @@ def update_duckdns(provider, ip):
|
||||||
as a comma-separated list.
|
as a comma-separated list.
|
||||||
Returns True on success, False on failure.
|
Returns True on success, False on failure.
|
||||||
"""
|
"""
|
||||||
token = provider["token"]
|
token = provider["api_token"]
|
||||||
subdomains = ",".join(provider["subdomains"])
|
subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"])
|
||||||
description = provider["description"]
|
description = provider["description"]
|
||||||
|
|
||||||
url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}"
|
url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}"
|
||||||
|
|
@ -358,9 +360,9 @@ def update_duckdns(provider, ip):
|
||||||
log.error(f"Network error contacting DuckDNS: {e}")
|
log.error(f"Network error contacting DuckDNS: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Cloudflare DNS update
|
# Cloudflare DNS update
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def _cf_api_get(url, headers):
|
def _cf_api_get(url, headers):
|
||||||
req = urllib.request.Request(url, headers=headers)
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
|
@ -429,9 +431,9 @@ def update_cloudflare(provider, ip):
|
||||||
success = False
|
success = False
|
||||||
return success
|
return success
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Process a single provider block
|
# Process a single provider block
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def process_provider(provider, current_ip, force=False):
|
def process_provider(provider, current_ip, force=False):
|
||||||
description = provider["description"]
|
description = provider["description"]
|
||||||
|
|
@ -471,9 +473,9 @@ def process_provider(provider, current_ip, force=False):
|
||||||
save_cached_ip(description, current_ip)
|
save_cached_ip(description, current_ip)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Timer management
|
# Timer management
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def parse_interval(interval_str):
|
def parse_interval(interval_str):
|
||||||
"""
|
"""
|
||||||
|
|
@ -557,16 +559,22 @@ def remove_timer():
|
||||||
else:
|
else:
|
||||||
print("No timer found, nothing to remove.")
|
print("No timer found, nothing to remove.")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def run_update(cfg, force=False):
|
def run_update(cfg, force=False, getip_only=False):
|
||||||
"""Perform a single DDNS update pass. Called by both timer and --start.
|
"""Perform a single DDNS update pass. Called by both timer and --start.
|
||||||
If force=True, bypasses the cached IP check and always updates."""
|
If force=True, bypasses the cached IP check and always updates.
|
||||||
|
If getip_only=True, prints the detected public IP and returns without updating providers."""
|
||||||
general = cfg["general"]
|
general = cfg["general"]
|
||||||
current_ip = get_public_ip(general["ip_check_services"])
|
current_ip = get_public_ip(general["ip_check_services"])
|
||||||
enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
|
|
||||||
|
if getip_only:
|
||||||
|
print(current_ip)
|
||||||
|
return
|
||||||
|
|
||||||
|
enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
|
||||||
|
|
||||||
if not enabled:
|
if not enabled:
|
||||||
log.error("No enabled providers found in config.")
|
log.error("No enabled providers found in config.")
|
||||||
|
|
@ -594,20 +602,22 @@ def main():
|
||||||
"examples:\n"
|
"examples:\n"
|
||||||
" sudo python3 ddns.py --start Run update and install systemd timer\n"
|
" sudo python3 ddns.py --start Run update and install systemd timer\n"
|
||||||
" sudo python3 ddns.py --disable Stop updates and remove systemd timer\n"
|
" sudo python3 ddns.py --disable Stop updates and remove systemd timer\n"
|
||||||
" sudo python3 ddns.py --apply Run update once (used by timer)\n"
|
" python3 ddns.py --apply Run update once (used by timer)\n"
|
||||||
" sudo python3 ddns.py --force Force update regardless of cached IP\n"
|
" python3 ddns.py --force Force update regardless of cached IP\n"
|
||||||
" sudo python3 ddns.py --status Show timer/service status\n"
|
" python3 ddns.py --status Show timer/service status\n"
|
||||||
|
" python3 ddns.py --getip Print current public IP and exit\n"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
parser.add_argument("--start", action="store_true", help="Run update and install systemd timer")
|
parser.add_argument("--start", action="store_true", help="Run update and install systemd timer")
|
||||||
parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer")
|
parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer")
|
||||||
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
|
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
|
||||||
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
|
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
|
||||||
parser.add_argument("--status", action="store_true", help="Show timer/service status")
|
parser.add_argument("--status", action="store_true", help="Show timer/service status")
|
||||||
|
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if not any([args.start, args.disable, args.apply, args.force, args.status]):
|
if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]):
|
||||||
parser.print_help()
|
parser.print_help()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -615,6 +625,15 @@ def main():
|
||||||
show_status()
|
show_status()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if args.getip:
|
||||||
|
global log
|
||||||
|
log = logging.getLogger("ddns_quiet")
|
||||||
|
log.addHandler(logging.NullHandler())
|
||||||
|
log.propagate = False
|
||||||
|
cfg = load_config()
|
||||||
|
run_update(cfg, getip_only=True)
|
||||||
|
return
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
general = cfg["general"]
|
general = cfg["general"]
|
||||||
setup_logging(general["log_max_kb"], general["log_errors_only"])
|
setup_logging(general["log_max_kb"], general["log_errors_only"])
|
||||||
|
|
|
||||||
164
router/validation.py
Normal file
164
router/validation.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
"""
|
||||||
|
validation.py -- Shared structural validators for core.json fields.
|
||||||
|
|
||||||
|
Lives alongside core.py in ~/router/ and is volume-mounted into the
|
||||||
|
router-dash container at /configs/validation.py. Importable by both
|
||||||
|
core.py (router host) and the Flask app (via validate.py which adds
|
||||||
|
/configs to sys.path).
|
||||||
|
|
||||||
|
Convention: each function accepts a raw string and returns the
|
||||||
|
normalised valid value, or '' if the input is invalid.
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
|
||||||
|
VALID_PROTOCOLS = {'tcp', 'udp', 'both'}
|
||||||
|
VALID_BLOCKLIST_FORMATS = {'dnsmasq', 'hosts'}
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# IP / CIDR
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def ip(value):
|
||||||
|
"""Return value if it is a valid IPv4 or IPv6 address, else ''."""
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
v = str(value).strip()
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(v)
|
||||||
|
return v
|
||||||
|
except ValueError:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def ip_or_cidr(value):
|
||||||
|
"""Return value if it is a valid IPv4/IPv6 address or CIDR network, else ''."""
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
v = str(value).strip()
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(v)
|
||||||
|
return v
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
ipaddress.ip_network(v, strict=False)
|
||||||
|
return v
|
||||||
|
except ValueError:
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Port
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def port(value):
|
||||||
|
"""Return port as string if valid 1-65535, else ''."""
|
||||||
|
try:
|
||||||
|
p = int(re.sub(r'[^0-9]', '', str(value)))
|
||||||
|
if 1 <= p <= 65535:
|
||||||
|
return str(p)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================
|
||||||
|
# Banned-IP pattern
|
||||||
|
# ===================================================================
|
||||||
|
|
||||||
|
def banned_ip(value):
|
||||||
|
"""
|
||||||
|
Return value if it is a valid banned_ip pattern, else ''.
|
||||||
|
|
||||||
|
Accepted formats (mirrors core.py expand_banned_ip):
|
||||||
|
IPv4:
|
||||||
|
Single address 192.0.2.1
|
||||||
|
CIDR 192.0.2.0/24
|
||||||
|
Wildcard octet 192.0.2.*
|
||||||
|
Octet range 192.0.2.10-20
|
||||||
|
(combinations that expand to <=1024 entries are accepted)
|
||||||
|
IPv6:
|
||||||
|
Single address 2001:db8::1
|
||||||
|
CIDR 2001:db8::/32
|
||||||
|
Trailing wildcard 2001:db8:c17:*
|
||||||
|
"""
|
||||||
|
if not value:
|
||||||
|
return ''
|
||||||
|
v = str(value).strip()
|
||||||
|
try:
|
||||||
|
_check_banned_ip(v)
|
||||||
|
return v
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def _check_banned_ip(ip_str):
|
||||||
|
if ':' in ip_str:
|
||||||
|
_check_banned_ipv6(ip_str)
|
||||||
|
else:
|
||||||
|
_check_banned_ipv4(ip_str)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_banned_ipv4(ip_str):
|
||||||
|
if '/' in ip_str:
|
||||||
|
ipaddress.IPv4Network(ip_str, strict=False)
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = ip_str.split('.')
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise ValueError(f"Expected 4 octets: {ip_str!r}")
|
||||||
|
|
||||||
|
def parse_octet(s):
|
||||||
|
if s == '*':
|
||||||
|
return (0, 255)
|
||||||
|
if '-' in s:
|
||||||
|
a, b = s.split('-', 1)
|
||||||
|
lo, hi = int(a), int(b)
|
||||||
|
if not (0 <= lo <= hi <= 255):
|
||||||
|
raise ValueError(f"Invalid octet range {s!r}")
|
||||||
|
return (lo, hi)
|
||||||
|
v = int(s)
|
||||||
|
if not 0 <= v <= 255:
|
||||||
|
raise ValueError(f"Octet {v} out of 0-255")
|
||||||
|
return (v, v)
|
||||||
|
|
||||||
|
ranges = [parse_octet(p) for p in parts]
|
||||||
|
|
||||||
|
trailing = 0
|
||||||
|
for lo, hi in reversed(ranges):
|
||||||
|
if lo == 0 and hi == 255:
|
||||||
|
trailing += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
total = 1
|
||||||
|
for lo, hi in ranges[:4 - trailing]:
|
||||||
|
total *= (hi - lo + 1)
|
||||||
|
if total > 1024:
|
||||||
|
raise ValueError(f"Pattern expands to {total} entries (limit 1024); use CIDR")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_banned_ipv6(ip_str):
|
||||||
|
if '/' in ip_str:
|
||||||
|
ipaddress.IPv6Network(ip_str, strict=False)
|
||||||
|
return
|
||||||
|
if '*' not in ip_str:
|
||||||
|
ipaddress.IPv6Address(ip_str)
|
||||||
|
return
|
||||||
|
if not ip_str.endswith(':*'):
|
||||||
|
raise ValueError(f"Unsupported IPv6 wildcard: {ip_str!r}; use 'prefix:*' or CIDR")
|
||||||
|
prefix_part = ip_str[:-2]
|
||||||
|
if '::' in prefix_part:
|
||||||
|
left, right = prefix_part.split('::', 1)
|
||||||
|
lg = [g for g in left.split(':') if g] if left else []
|
||||||
|
rg = [g for g in right.split(':') if g] if right else []
|
||||||
|
zeros = 8 - len(lg) - len(rg) - 1
|
||||||
|
if zeros < 0:
|
||||||
|
raise ValueError(f"Too many groups in {ip_str!r}")
|
||||||
|
groups = lg + ['0000'] * zeros + rg
|
||||||
|
else:
|
||||||
|
groups = [g for g in prefix_part.split(':') if g]
|
||||||
|
if not (1 <= len(groups) <= 7):
|
||||||
|
raise ValueError(f"IPv6 wildcard must have 1-7 prefix groups: {ip_str!r}")
|
||||||
|
|
@ -41,6 +41,7 @@ import sys
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from validation import ip as validate_ip
|
||||||
|
|
||||||
SCRIPT_DIR = Path(__file__).parent
|
SCRIPT_DIR = Path(__file__).parent
|
||||||
DHCP_CONFIG_FILE = SCRIPT_DIR / "core.json"
|
DHCP_CONFIG_FILE = SCRIPT_DIR / "core.json"
|
||||||
|
|
@ -48,12 +49,12 @@ DDNS_CONFIG_FILE = SCRIPT_DIR / "ddns.json"
|
||||||
WG_DIR = Path("/etc/wireguard")
|
WG_DIR = Path("/etc/wireguard")
|
||||||
KEEPALIVE = 25
|
KEEPALIVE = 25
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Helpers
|
# Helpers
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def die(msg):
|
def die(msg):
|
||||||
print(f"ERROR: {msg}")
|
print(f"ERROR: {msg}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
def check_root():
|
def check_root():
|
||||||
|
|
@ -63,7 +64,7 @@ def check_root():
|
||||||
def chown_to_script_dir_owner(path):
|
def chown_to_script_dir_owner(path):
|
||||||
"""Chown a file to the owner of the script directory.
|
"""Chown a file to the owner of the script directory.
|
||||||
Keeps SCRIPT_DIR files user-owned even when running as root.
|
Keeps SCRIPT_DIR files user-owned even when running as root.
|
||||||
/etc/wireguard files are intentionally excluded — they stay root-owned.
|
/etc/wireguard files are intentionally excluded - they stay root-owned.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
stat = SCRIPT_DIR.stat()
|
stat = SCRIPT_DIR.stat()
|
||||||
|
|
@ -124,9 +125,9 @@ def _fmt_bytes(n):
|
||||||
else:
|
else:
|
||||||
return f"{n / 1024**3:.2f} GB"
|
return f"{n / 1024**3:.2f} GB"
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Load core.json / dotfiles
|
# Load core.json / dotfiles
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def load_dhcp():
|
def load_dhcp():
|
||||||
if not DHCP_CONFIG_FILE.exists():
|
if not DHCP_CONFIG_FILE.exists():
|
||||||
|
|
@ -169,9 +170,9 @@ def save_peers(iface, peers):
|
||||||
path.chmod(0o600)
|
path.chmod(0o600)
|
||||||
chown_to_script_dir_owner(path)
|
chown_to_script_dir_owner(path)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# IP allocation
|
# IP allocation
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def next_available_ip(vlan, peers):
|
def next_available_ip(vlan, peers):
|
||||||
"""
|
"""
|
||||||
|
|
@ -197,9 +198,9 @@ def next_available_ip(vlan, peers):
|
||||||
|
|
||||||
die(f"No available IPs in VPN subnet {network} (all .2-.254 allocated).")
|
die(f"No available IPs in VPN subnet {network} (all .2-.254 allocated).")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Key management
|
# Key management
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def generate_server_key(iface):
|
def generate_server_key(iface):
|
||||||
"""Generate server private key and store at WG_DIR/<iface>.key (600)."""
|
"""Generate server private key and store at WG_DIR/<iface>.key (600)."""
|
||||||
|
|
@ -228,9 +229,9 @@ def generate_peer_keypair():
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
return private, public
|
return private, public
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Endpoint resolution
|
# Endpoint resolution
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def resolve_endpoint(listen_port):
|
def resolve_endpoint(listen_port):
|
||||||
"""
|
"""
|
||||||
|
|
@ -294,9 +295,9 @@ def resolve_endpoint(listen_port):
|
||||||
entry = f"{entry}:{listen_port}"
|
entry = f"{entry}:{listen_port}"
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Split-tunnel route computation
|
# Split-tunnel route computation
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def split_tunnel_routes(dhcp_data):
|
def split_tunnel_routes(dhcp_data):
|
||||||
"""
|
"""
|
||||||
|
|
@ -316,9 +317,9 @@ def split_tunnel_routes(dhcp_data):
|
||||||
routes.append(str(net))
|
routes.append(str(net))
|
||||||
return routes
|
return routes
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Client config
|
# Client config
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def build_client_conf(peer, private_key, server_public_key, endpoint,
|
def build_client_conf(peer, private_key, server_public_key, endpoint,
|
||||||
allowed_ips, dns, domain, mtu):
|
allowed_ips, dns, domain, mtu):
|
||||||
|
|
@ -348,9 +349,9 @@ def write_client_conf(peer, private_key, server_public_key, endpoint,
|
||||||
chown_to_script_dir_owner(conf_path)
|
chown_to_script_dir_owner(conf_path)
|
||||||
return conf_path
|
return conf_path
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# WireGuard server conf
|
# WireGuard server conf
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def build_wg_conf(vlan, peers, server_private_key):
|
def build_wg_conf(vlan, peers, server_private_key):
|
||||||
iface = vlan["interface"]
|
iface = vlan["interface"]
|
||||||
|
|
@ -381,9 +382,9 @@ def build_wg_conf(vlan, peers, server_private_key):
|
||||||
]
|
]
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Live peer sync
|
# Live peer sync
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def sync_peers_live(iface, peers):
|
def sync_peers_live(iface, peers):
|
||||||
"""
|
"""
|
||||||
|
|
@ -418,9 +419,9 @@ def sync_peers_live(iface, peers):
|
||||||
run(["wg", "set", iface, "peer", key, "remove"])
|
run(["wg", "set", iface, "peer", key, "remove"])
|
||||||
print(f" Removed peer: {key[:16]}...")
|
print(f" Removed peer: {key[:16]}...")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Interface selection
|
# Interface selection
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def validate_wg_vlans(wg_vlans):
|
def validate_wg_vlans(wg_vlans):
|
||||||
"""Die with a clear message if any wg VLAN is missing a valid vpn_information block."""
|
"""Die with a clear message if any wg VLAN is missing a valid vpn_information block."""
|
||||||
|
|
@ -432,8 +433,11 @@ def validate_wg_vlans(wg_vlans):
|
||||||
f"Add: \"vpn_information\": {{\"listen_port\": 51820, \"gateway\": \"...\"}}")
|
f"Add: \"vpn_information\": {{\"listen_port\": 51820, \"gateway\": \"...\"}}")
|
||||||
if not isinstance(info.get("listen_port"), int):
|
if not isinstance(info.get("listen_port"), int):
|
||||||
die(f"Interface '{iface}' vpn_information is missing a valid listen_port in core.json.")
|
die(f"Interface '{iface}' vpn_information is missing a valid listen_port in core.json.")
|
||||||
if not info.get("gateway"):
|
gw = info.get("gateway", "")
|
||||||
|
if not gw:
|
||||||
die(f"Interface '{iface}' vpn_information is missing gateway in core.json.")
|
die(f"Interface '{iface}' vpn_information is missing gateway in core.json.")
|
||||||
|
elif not validate_ip(gw):
|
||||||
|
die(f"Interface '{iface}' vpn_information.gateway '{gw}' is not a valid IP address.")
|
||||||
|
|
||||||
def pick_wg_interface(wg_vlans):
|
def pick_wg_interface(wg_vlans):
|
||||||
"""
|
"""
|
||||||
|
|
@ -459,9 +463,9 @@ def pick_wg_interface(wg_vlans):
|
||||||
pass
|
pass
|
||||||
print(" Invalid selection.")
|
print(" Invalid selection.")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# --add-peer
|
# --add-peer
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cmd_add_peer(dhcp_data):
|
def cmd_add_peer(dhcp_data):
|
||||||
check_root()
|
check_root()
|
||||||
|
|
@ -569,9 +573,9 @@ def cmd_add_peer(dhcp_data):
|
||||||
print(" sudo python3 vpn.py --apply")
|
print(" sudo python3 vpn.py --apply")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# --list-peers
|
# --list-peers
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cmd_list_peers(dhcp_data):
|
def cmd_list_peers(dhcp_data):
|
||||||
check_root()
|
check_root()
|
||||||
|
|
@ -718,9 +722,9 @@ def cmd_list_peers(dhcp_data):
|
||||||
print(" sudo python3 vpn.py --apply")
|
print(" sudo python3 vpn.py --apply")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# --apply
|
# --apply
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cmd_apply(dhcp_data):
|
def cmd_apply(dhcp_data):
|
||||||
check_root()
|
check_root()
|
||||||
|
|
@ -808,9 +812,9 @@ def cmd_apply(dhcp_data):
|
||||||
else:
|
else:
|
||||||
print(f"WARNING: {core_py} not found -- run core.py --apply manually to load VPN firewall rules.")
|
print(f"WARNING: {core_py} not found -- run core.py --apply manually to load VPN firewall rules.")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# --disable
|
# --disable
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cmd_disable(dhcp_data):
|
def cmd_disable(dhcp_data):
|
||||||
check_root()
|
check_root()
|
||||||
|
|
@ -825,9 +829,9 @@ def cmd_disable(dhcp_data):
|
||||||
else:
|
else:
|
||||||
print(f"WireGuard service {svc} stopped and disabled.")
|
print(f"WireGuard service {svc} stopped and disabled.")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# --status
|
# --status
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cmd_status(dhcp_data):
|
def cmd_status(dhcp_data):
|
||||||
check_root()
|
check_root()
|
||||||
|
|
@ -844,8 +848,8 @@ def cmd_status(dhcp_data):
|
||||||
r_enabled = run(["systemctl", "is-enabled", svc], check=False)
|
r_enabled = run(["systemctl", "is-enabled", svc], check=False)
|
||||||
active = r_active.stdout.strip()
|
active = r_active.stdout.strip()
|
||||||
enabled = r_enabled.stdout.strip()
|
enabled = r_enabled.stdout.strip()
|
||||||
active_sym = "✓" if active == "active" else "✗"
|
active_sym = "+" if active == "active" else "x"
|
||||||
enabled_sym = "✓" if enabled == "enabled" else "✗"
|
enabled_sym = "+" if enabled == "enabled" else "x"
|
||||||
print(f" {svc:<45} {active_sym} {active:<10} {enabled_sym} {enabled}")
|
print(f" {svc:<45} {active_sym} {active:<10} {enabled_sym} {enabled}")
|
||||||
|
|
||||||
if active == "active":
|
if active == "active":
|
||||||
|
|
@ -869,9 +873,9 @@ def cmd_status(dhcp_data):
|
||||||
enabled_peers = [p for p in peers if p.get("enabled") is True]
|
enabled_peers = [p for p in peers if p.get("enabled") is True]
|
||||||
print(f" peers: {len(enabled_peers)} configured, {info.get('peers', 0)} connected")
|
print(f" peers: {len(enabled_peers)} configured, {info.get('peers', 0)} connected")
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# --logs
|
# --logs
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def cmd_logs(dhcp_data):
|
def cmd_logs(dhcp_data):
|
||||||
check_root()
|
check_root()
|
||||||
|
|
@ -940,9 +944,9 @@ def cmd_logs(dhcp_data):
|
||||||
|
|
||||||
print()
|
print()
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
# Main
|
# Main
|
||||||
# ------------------------------------------------------------------------------
|
# ===================================================================
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue