Development
This commit is contained in:
parent
d8d1d46fd2
commit
eed1d295dc
69 changed files with 3355 additions and 3230 deletions
0
docker/routlin-dash/app/pages/__init__.py
Normal file
0
docker/routlin-dash/app/pages/__init__.py
Normal file
0
docker/routlin-dash/app/pages/accountadd/__init__.py
Normal file
0
docker/routlin-dash/app/pages/accountadd/__init__.py
Normal file
64
docker/routlin-dash/app/pages/accountadd/action.py
Normal file
64
docker/routlin-dash/app/pages/accountadd/action.py
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, re
|
||||
from datetime import datetime, timezone
|
||||
from auth import require_level
|
||||
from config_utils import ACCOUNTS_FILE
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('accountadd', __name__)
|
||||
|
||||
|
||||
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_manageaccounts')
|
||||
|
||||
if not re.match(r'^[^@\s]+@[^@\s]+\.[^@\s]+$', email):
|
||||
flash('Email address does not appear to be valid.', 'error')
|
||||
return redirect('/view/view_manageaccounts')
|
||||
|
||||
if access_level not in VALID_LEVELS:
|
||||
flash('Invalid access level.', 'error')
|
||||
return redirect('/view/view_manageaccounts')
|
||||
|
||||
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_manageaccounts')
|
||||
|
||||
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_manageaccounts')
|
||||
0
docker/routlin-dash/app/pages/accountcreate/__init__.py
Normal file
0
docker/routlin-dash/app/pages/accountcreate/__init__.py
Normal file
105
docker/routlin-dash/app/pages/accountcreate/action.py
Normal file
105
docker/routlin-dash/app/pages/accountcreate/action.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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
|
||||
from config_utils import WEB_APP_DISPLAY_NAME, ACCOUNTS_FILE
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('accountcreate', __name__)
|
||||
|
||||
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'] = f'{WEB_APP_DISPLAY_NAME} - 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_createaccount')
|
||||
|
||||
if password != password_confirm:
|
||||
flash('Passwords do not match.', 'error')
|
||||
return redirect('/view/view_createaccount')
|
||||
|
||||
if len(password) < 8:
|
||||
flash('Password must be at least 8 characters.', 'error')
|
||||
return redirect('/view/view_createaccount')
|
||||
|
||||
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_createaccount')
|
||||
|
||||
if account.get('hashed_password'):
|
||||
flash('This account is already set up. Please log in instead.', 'error')
|
||||
return redirect('/view/view_createaccount')
|
||||
|
||||
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_createaccount')
|
||||
|
||||
session['pending_create_account'] = {
|
||||
'email': account['email_address'],
|
||||
'hashed_password': hashed.decode('utf-8'),
|
||||
'timezone': tz,
|
||||
'code': code,
|
||||
'expires': expires,
|
||||
}
|
||||
|
||||
return redirect('/view/view_verifyemail')
|
||||
103
docker/routlin-dash/app/pages/accountcreate/content.json
Normal file
103
docker/routlin-dash/app/pages/accountcreate/content.json
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
{
|
||||
"id": "view_createaccount",
|
||||
"client_requirement": "client_is_nothing=",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_wrapper",
|
||||
"client_requirement": "client_is_nothing=",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_card",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Complete Your Account"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "If your email has been pre-registered by a manager, setup your account below."
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/create_account",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Email Address",
|
||||
"name": "email",
|
||||
"input_type": "text",
|
||||
"placeholder": "you@example.com",
|
||||
"hint": "Must match your pre-registered email address."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "New Password",
|
||||
"name": "password",
|
||||
"input_type": "password",
|
||||
"placeholder": "Choose a strong password"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Confirm Password",
|
||||
"name": "password_confirm",
|
||||
"input_type": "password",
|
||||
"placeholder": "Repeat your password"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Timezone",
|
||||
"name": "timezone",
|
||||
"input_type": "select",
|
||||
"value": "",
|
||||
"options": "%TIMEZONE_OPTIONS%",
|
||||
"hint": "Used to display timestamps in your local time."
|
||||
},
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/create_account",
|
||||
"method": "post",
|
||||
"text": "Create Account",
|
||||
"class": "btn-full"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Already have an account?",
|
||||
"link": {
|
||||
"action": "/view/view_login",
|
||||
"text": "Log In"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Already logged in."
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Your account is already active."
|
||||
},
|
||||
{
|
||||
"type": "spacer"
|
||||
},
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/view/overview",
|
||||
"text": "Go to Overview"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/accountdelete/__init__.py
Normal file
0
docker/routlin-dash/app/pages/accountdelete/__init__.py
Normal file
50
docker/routlin-dash/app/pages/accountdelete/action.py
Normal file
50
docker/routlin-dash/app/pages/accountdelete/action.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json
|
||||
from auth import require_level
|
||||
from config_utils import ACCOUNTS_FILE
|
||||
|
||||
bp = Blueprint('accountdelete', __name__)
|
||||
|
||||
|
||||
|
||||
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_manageaccounts')
|
||||
|
||||
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_manageaccounts')
|
||||
|
||||
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_manageaccounts')
|
||||
|
||||
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_manageaccounts')
|
||||
0
docker/routlin-dash/app/pages/accountlogin/__init__.py
Normal file
0
docker/routlin-dash/app/pages/accountlogin/__init__.py
Normal file
56
docker/routlin-dash/app/pages/accountlogin/action.py
Normal file
56
docker/routlin-dash/app/pages/accountlogin/action.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, bcrypt
|
||||
from auth import require_level
|
||||
from config_utils import ACCOUNTS_FILE
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('accountlogin', __name__)
|
||||
|
||||
|
||||
|
||||
def _load_accounts():
|
||||
try:
|
||||
with open(ACCOUNTS_FILE) 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_login')
|
||||
|
||||
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_login')
|
||||
|
||||
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_login')
|
||||
|
||||
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_login')
|
||||
|
||||
session.clear()
|
||||
session['email_address'] = account['email_address']
|
||||
session['access_level'] = account.get('access_level', 'viewer')
|
||||
session['timezone'] = account.get('timezone', '')
|
||||
session['apply_changes_immediately'] = False
|
||||
session.permanent = True
|
||||
|
||||
return redirect('/view/view_overview')
|
||||
86
docker/routlin-dash/app/pages/accountlogin/content.json
Normal file
86
docker/routlin-dash/app/pages/accountlogin/content.json
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
"id": "view_login",
|
||||
"client_requirement": "client_is_nothing=",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_wrapper",
|
||||
"client_requirement": "client_is_nothing=",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_card",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Log In"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Enter your credentials to access the dashboard."
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/log_in",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Email Address",
|
||||
"name": "email",
|
||||
"input_type": "text",
|
||||
"placeholder": "you@example.com"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Password",
|
||||
"name": "password",
|
||||
"input_type": "password",
|
||||
"placeholder": "Password"
|
||||
},
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/log_in",
|
||||
"method": "post",
|
||||
"text": "Log In",
|
||||
"class": "btn-full"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Need to complete your account?",
|
||||
"link": {
|
||||
"action": "/view/view_createaccount",
|
||||
"text": "Create Account"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Already logged in."
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "You are already authenticated."
|
||||
},
|
||||
{
|
||||
"type": "spacer"
|
||||
},
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/view/overview",
|
||||
"text": "Go to Overview"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/accountlogout/__init__.py
Normal file
0
docker/routlin-dash/app/pages/accountlogout/__init__.py
Normal file
11
docker/routlin-dash/app/pages/accountlogout/action.py
Normal file
11
docker/routlin-dash/app/pages/accountlogout/action.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from flask import Blueprint, session, redirect
|
||||
from auth import require_level
|
||||
|
||||
bp = Blueprint('accountlogout', __name__)
|
||||
|
||||
|
||||
@bp.route('/action/log_out', methods=['POST'])
|
||||
@require_level('viewer')
|
||||
def log_out():
|
||||
session.clear()
|
||||
return redirect('/view/view_overview')
|
||||
0
docker/routlin-dash/app/pages/accountmanage/__init__.py
Normal file
0
docker/routlin-dash/app/pages/accountmanage/__init__.py
Normal file
90
docker/routlin-dash/app/pages/accountmanage/content.json
Normal file
90
docker/routlin-dash/app/pages/accountmanage/content.json
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
"id": "view_manageaccounts",
|
||||
"client_requirement": "client_is_manager+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Manage Accounts"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:accounts",
|
||||
"empty_message": "No accounts configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Email Address",
|
||||
"field": "email_address"
|
||||
},
|
||||
{
|
||||
"label": "Access Level",
|
||||
"field": "access_level"
|
||||
},
|
||||
{
|
||||
"label": "Added By",
|
||||
"field": "account_created_by"
|
||||
},
|
||||
{
|
||||
"label": "Added",
|
||||
"field": "account_created_utc"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "account_status",
|
||||
"render": "badge_active_inactive"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"action": "/action/delete_account",
|
||||
"method": "post",
|
||||
"text": "Remove",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Authorize New Account",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/add_account",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Email Address",
|
||||
"name": "email_address",
|
||||
"input_type": "text",
|
||||
"placeholder": "user@example.com",
|
||||
"hint": "The user will verify ownership of this address during account setup."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Access Level",
|
||||
"name": "access_level",
|
||||
"input_type": "select",
|
||||
"options": "%ACCOUNT_LEVEL_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/add_account",
|
||||
"method": "post",
|
||||
"text": "Authorize"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
112
docker/routlin-dash/app/pages/accountverifyemail/action.py
Normal file
112
docker/routlin-dash/app/pages/accountverifyemail/action.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, os, secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from auth import require_level
|
||||
from config_utils import ACCOUNTS_FILE
|
||||
|
||||
bp = Blueprint('accountverifyemail', __name__)
|
||||
|
||||
|
||||
|
||||
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_createaccount')
|
||||
|
||||
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_createaccount')
|
||||
|
||||
submitted = request.form.get('code', '').strip()
|
||||
if submitted != pending['code']:
|
||||
flash('Incorrect verification code.', 'error')
|
||||
return redirect('/view/view_verifyemail')
|
||||
|
||||
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_createaccount')
|
||||
|
||||
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_login')
|
||||
|
||||
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 pages.accountcreate.action 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_createaccount')
|
||||
|
||||
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_verifyemail')
|
||||
|
||||
pending['code'] = code
|
||||
pending['expires'] = expires
|
||||
session['pending_create_account'] = pending
|
||||
|
||||
flash('A new verification code has been sent.', 'success')
|
||||
return redirect('/view/view_verifyemail')
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
{
|
||||
"id": "view_verifyemail",
|
||||
"client_requirement": "client_is_nothing=",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_wrapper",
|
||||
"client_requirement": "client_is_nothing=",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_card",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Verify Your Email"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "A 6-digit code was sent to your email address. Enter it below to complete your account."
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/verify_email",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Verification Code",
|
||||
"name": "code",
|
||||
"input_type": "text",
|
||||
"placeholder": "000000",
|
||||
"hint": "The code expires in 15 minutes."
|
||||
},
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/verify_email",
|
||||
"method": "post",
|
||||
"text": "Verify",
|
||||
"class": "btn-full"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Didn't receive it?",
|
||||
"link": {
|
||||
"action": "/action/resend_verification",
|
||||
"text": "Resend code"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Wrong email?",
|
||||
"link": {
|
||||
"action": "/view/view_createaccount",
|
||||
"text": "Start over"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Already logged in."
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Your account is already active."
|
||||
},
|
||||
{
|
||||
"type": "spacer"
|
||||
},
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/view/view_overview",
|
||||
"text": "Go to Overview"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/actions/__init__.py
Normal file
0
docker/routlin-dash/app/pages/actions/__init__.py
Normal file
50
docker/routlin-dash/app/pages/actions/action.py
Normal file
50
docker/routlin-dash/app/pages/actions/action.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from flask import Blueprint, request, redirect, flash, session
|
||||
from auth import require_level
|
||||
from config_utils import (flush_pending_to_queue, get_dashboard_pending,
|
||||
revert_snapshot_to_config, queued_msg)
|
||||
|
||||
bp = Blueprint('actions', __name__)
|
||||
|
||||
_VIEW = '/view/view_actions'
|
||||
|
||||
|
||||
@bp.route('/action/actions_cardpending_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def actions_cardpending_save():
|
||||
session['apply_changes_immediately'] = 'apply_changes_immediately' in request.form
|
||||
flash('Preference saved.', 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/actions_cardpending_applynow', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def actions_cardpending_applynow():
|
||||
pending = get_dashboard_pending()
|
||||
if not pending:
|
||||
flash('No pending changes to apply.', 'info')
|
||||
return redirect(_VIEW)
|
||||
flush_pending_to_queue()
|
||||
if any(cmd != 'fix problems' for _, _, cmd, _ in pending):
|
||||
flash('Changes queued.', 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/actions_cardhistory_revertselected', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def actions_cardhistory_revertselected():
|
||||
selected_uuids = request.form.getlist('selected_uuids')
|
||||
if not selected_uuids:
|
||||
flash('No items selected.', 'info')
|
||||
return redirect(_VIEW)
|
||||
succeeded, failed = 0, 0
|
||||
for uuid in selected_uuids:
|
||||
msg, ok = revert_snapshot_to_config(uuid)
|
||||
if ok:
|
||||
succeeded += 1
|
||||
else:
|
||||
flash(msg, 'error')
|
||||
failed += 1
|
||||
if succeeded:
|
||||
plural = 's' if succeeded != 1 else ''
|
||||
flash(f'{succeeded} change{plural} reverted.', 'success')
|
||||
return redirect(_VIEW)
|
||||
112
docker/routlin-dash/app/pages/actions/content.json
Normal file
112
docker/routlin-dash/app/pages/actions/content.json
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
{
|
||||
"id": "view_actions",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Actions"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Apply or stage pending configuration changes."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Pending Actions",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/actions_cardpending_applynow",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "raw_html",
|
||||
"html": "%PENDING_ACTIONS_HTML%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"text": "Apply Now",
|
||||
"disabled": "%NO_PENDING%"
|
||||
},
|
||||
{
|
||||
"type": "raw_html",
|
||||
"html": "%APPLY_WARNING%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/actions_cardpending_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Apply Changes Immediately",
|
||||
"name": "apply_changes_immediately",
|
||||
"input_type": "checkbox",
|
||||
"value": "%GENERAL_APPLY_ON_SAVE%",
|
||||
"hint": "When enabled, saved changes are queued immediately. When disabled, changes accumulate in Pending Actions until you click Apply Now."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/actions_cardpending_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Change History",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/actions_cardhistory_revertselected",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "raw_html",
|
||||
"html": "%CHANGE_HISTORY_HTML%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_danger",
|
||||
"text": "Revert Selected",
|
||||
"disabled": "%NO_HISTORY%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/bannedips/__init__.py
Normal file
0
docker/routlin-dash/app/pages/bannedips/__init__.py
Normal file
171
docker/routlin-dash/app/pages/bannedips/action.py
Normal file
171
docker/routlin-dash/app/pages/bannedips/action.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import copy
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('bannedips', __name__)
|
||||
|
||||
VIEW = '/view/view_bannedips'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_config_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():
|
||||
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)
|
||||
|
||||
cfg = load_config()
|
||||
entry = {'description': description, 'ip': ip, 'enabled': True}
|
||||
cfg.setdefault('banned_ips', []).append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='banned_ips', key=ip, operation='add',
|
||||
before=None, after=entry,
|
||||
description=f'Added banned IP: {ip}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('banned_ips', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
old_enabled = items[idx].get('enabled', True)
|
||||
items[idx]['enabled'] = not old_enabled
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='banned_ips', key=items[idx]['ip'], operation='toggle',
|
||||
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
|
||||
description=f'{action} banned IP: {items[idx]["ip"]}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('banned_ips', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(items[idx])
|
||||
items[idx].update({'description': description, 'ip': ip, 'enabled': enabled})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='banned_ips', key=ip, operation='edit',
|
||||
before=before, after=copy.deepcopy(items[idx]),
|
||||
description=f'Edited banned IP: {ip}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('banned_ips', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='banned_ips', key=removed['ip'], operation='delete',
|
||||
before=removed, after=None,
|
||||
description=f'Deleted banned IP: {removed["ip"]}',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
121
docker/routlin-dash/app/pages/bannedips/content.json
Normal file
121
docker/routlin-dash/app/pages/bannedips/content.json
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
{
|
||||
"id": "view_bannedips",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Banned IPs"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "IPs and ranges blocked in both directions at the nftables firewall."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "info_bar",
|
||||
"variant": "info",
|
||||
"text": "Supports single IPs, CIDR (94.130.0.0/16), wildcards (94.130.*.*), and ranges (94.130.52.1-20). IPv4 and IPv6 are both supported."
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:banned_ips",
|
||||
"empty_message": "No IP bans configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Description",
|
||||
"field": "description"
|
||||
},
|
||||
{
|
||||
"label": "IP / Range",
|
||||
"field": "ip",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "enabled",
|
||||
"render": "badge_enabled_disabled"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/edit_banned_ip",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "description",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "ip",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/delete_banned_ip",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "add-form",
|
||||
"label": "Add Banned IP",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/add_banned_ip",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Description",
|
||||
"name": "description",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. Bad actor",
|
||||
"hint": "Optional label for this entry."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "IP / Range",
|
||||
"name": "ip",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. 1.2.3.4 or 1.2.3.0/24"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/add_banned_ip",
|
||||
"method": "post",
|
||||
"text": "Add Banned IP"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/ddns/__init__.py
Normal file
0
docker/routlin-dash/app/pages/ddns/__init__.py
Normal file
247
docker/routlin-dash/app/pages/ddns/action.py
Normal file
247
docker/routlin-dash/app/pages/ddns/action.py
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import copy
|
||||
import os
|
||||
from flask import Blueprint, request, redirect, flash, send_file, abort
|
||||
from auth import require_level
|
||||
from config_utils import load_config, verify_config_hash, save_config_with_snapshot, CONFIGS_DIR
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('ddns', __name__)
|
||||
|
||||
VIEW = '/view/view_ddns'
|
||||
LOG_FILE = f'{CONFIGS_DIR}/ddns.log'
|
||||
|
||||
|
||||
@bp.route('/action/ddns_cardaddaccount_add', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def ddns_cardaddaccount_add():
|
||||
provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
|
||||
description = sanitize.description(request.form.get('description', ''))
|
||||
hostnames = sanitize.domainlist(request.form.get('hostnames', '').splitlines())
|
||||
|
||||
if not description:
|
||||
flash('Description is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not hostnames:
|
||||
flash('At least one hostname is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not provider_type:
|
||||
flash('Unknown provider type.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
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()
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault('ddns', {}).setdefault('providers', []).append(entry)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='ddns', key=description, operation='add',
|
||||
before=None, after=copy.deepcopy(entry),
|
||||
description=f'Added DDNS provider: {description}',
|
||||
cmd='ddns update',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/ddns_tableaccounts_rowedit', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def ddns_tableaccounts_rowedit():
|
||||
try:
|
||||
row_index = int(request.form.get('row_index', -1))
|
||||
except (TypeError, ValueError):
|
||||
flash('Invalid row index.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
provider_type = sanitize.filtervalue(request.form.get('provider', ''), validate.VALID_DDNS_PROVIDERS)
|
||||
description = sanitize.description(request.form.get('description', ''))
|
||||
hostnames = [h.strip() for h in request.form.get('hostnames', '').splitlines() if h.strip()]
|
||||
enabled = request.form.get('enabled') == 'on'
|
||||
|
||||
if not provider_type:
|
||||
flash('Unknown provider type.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
|
||||
if row_index < 0 or row_index >= len(providers):
|
||||
flash('Invalid provider index.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(providers[row_index])
|
||||
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
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='ddns', key=description, operation='edit',
|
||||
before=before, after=copy.deepcopy(entry),
|
||||
description=f'Edited DDNS provider: {description}',
|
||||
cmd='ddns update',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/ddns_tableaccounts_rowdelete', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def ddns_tableaccounts_rowdelete():
|
||||
try:
|
||||
row_index = int(request.form.get('row_index', -1))
|
||||
except (TypeError, ValueError):
|
||||
flash('Invalid row index.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
providers = cfg.setdefault('ddns', {}).setdefault('providers', [])
|
||||
if row_index < 0 or row_index >= len(providers):
|
||||
flash('Invalid provider index.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(providers[row_index])
|
||||
description = before.get('description', str(row_index))
|
||||
del providers[row_index]
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='ddns', key=description, operation='delete',
|
||||
before=before, after=None,
|
||||
description=f'Deleted DDNS provider: {description}',
|
||||
cmd='ddns update',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/ddns_cardipcheckinterval_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def ddns_cardipcheckinterval_save():
|
||||
raw = request.form.get('timer_interval', '').strip()
|
||||
try:
|
||||
mins = int(raw)
|
||||
if mins < 1:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
flash('Interval must be a whole number of minutes >= 1.', 'error')
|
||||
return redirect(VIEW)
|
||||
timer_interval = f'{mins}m'
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
|
||||
cfg.setdefault('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='ddns', key='general', operation='edit',
|
||||
before=before, after=copy.deepcopy(cfg['ddns']['general']),
|
||||
description='Updated DDNS check interval',
|
||||
cmd='core apply',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/ddns_cardipcheckservices_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def ddns_cardipcheckservices_save():
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
http_services = [u.strip() for u in request.form.getlist('http_services') if u.strip()]
|
||||
dig_services = [u.strip() for u in request.form.getlist('dig_services') if u.strip()]
|
||||
|
||||
if not http_services and not dig_services:
|
||||
flash('At least one IP check service is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('ddns', {}).get('ip_check_services', []))
|
||||
services = [{'type': 'http', 'url': u} for u in http_services]
|
||||
services += [{'type': 'dig', 'url': u} for u in dig_services]
|
||||
cfg.setdefault('ddns', {})['ip_check_services'] = services
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='ddns', key='ip_check_services', operation='edit',
|
||||
before=before, after=copy.deepcopy(services),
|
||||
description='Updated DDNS IP check services',
|
||||
cmd='ddns update',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/ddns_cardlogging_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def ddns_cardlogging_save():
|
||||
log_max_kb = validate.int_range(request.form.get('log_max_kb', '').strip(), 64, None)
|
||||
if log_max_kb is None:
|
||||
flash('Max Log Size must be a number >= 64.', 'error')
|
||||
return redirect(VIEW)
|
||||
log_errors_only = 'log_errors_only' in request.form
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
|
||||
cfg.setdefault('ddns', {}).setdefault('general', {}).update({
|
||||
'log_max_kb': log_max_kb,
|
||||
'log_errors_only': log_errors_only,
|
||||
})
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='ddns', key='general', operation='edit',
|
||||
before=before, after=copy.deepcopy(cfg['ddns']['general']),
|
||||
description='Updated DDNS logging settings',
|
||||
cmd='ddns update',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/ddns_cardlogging_clear', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def ddns_cardlogging_clear():
|
||||
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)
|
||||
|
||||
|
||||
@bp.route('/action/ddns_cardlogging_download', methods=['GET'])
|
||||
@require_level('administrator')
|
||||
def ddns_cardlogging_download():
|
||||
if not os.path.isfile(LOG_FILE):
|
||||
abort(404)
|
||||
return send_file(LOG_FILE, as_attachment=True, download_name='ddns.log', mimetype='text/plain')
|
||||
295
docker/routlin-dash/app/pages/ddns/content.json
Normal file
295
docker/routlin-dash/app/pages/ddns/content.json
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
{
|
||||
"id": "view_ddns",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "DDNS"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Dynamic DNS provider status and last known IP update."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat_card_grid",
|
||||
"items": [
|
||||
{
|
||||
"type": "stat_card",
|
||||
"label": "Current Public IP",
|
||||
"value": "%STAT_PUBLIC_IP%",
|
||||
"sub": "%STAT_PUBLIC_IP_LAST_OBTAINED%"
|
||||
},
|
||||
{
|
||||
"type": "stat_card",
|
||||
"label": "IP Check Interval",
|
||||
"value": "%DDNS_TIMER_INTERVAL%",
|
||||
"sub": "%STAT_PUBLIC_IP_LAST_CHECKED%",
|
||||
"edit_action": "/action/ddns_cardipcheckinterval_save",
|
||||
"edit_field": "timer_interval",
|
||||
"edit_input_type": "number",
|
||||
"edit_min": "1",
|
||||
"edit_suffix": "minutes",
|
||||
"edit_value": "%DDNS_TIMER_INTERVAL_MINS%"
|
||||
},
|
||||
{
|
||||
"type": "stat_card",
|
||||
"label": "IP Check Services",
|
||||
"value": "%STAT_IP_CHECK_TOTAL%",
|
||||
"sub": "%STAT_IP_CHECK_SUB%",
|
||||
"reveal_card_id": "ip-check-services-edit"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "ip-check-services-edit",
|
||||
"label": "IP Check Services",
|
||||
"hidden": true,
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/ddns_cardipcheckservices_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "editable_list",
|
||||
"label": "HTTP APIs",
|
||||
"name": "http_services",
|
||||
"item_placeholder": "https://...",
|
||||
"add_label": "Add HTTP API",
|
||||
"items": "%IP_CHECK_HTTP_JSON%"
|
||||
},
|
||||
{
|
||||
"type": "editable_list",
|
||||
"label": "Dig APIs",
|
||||
"name": "dig_services",
|
||||
"item_placeholder": "e.g. @1.1.1.1 ch txt whoami.cloudflare",
|
||||
"add_label": "Add Dig API",
|
||||
"items": "%IP_CHECK_DIG_JSON%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/ddns_cardipcheckservices_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel",
|
||||
"class": "js-hide-card"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:ddns_providers",
|
||||
"empty_message": "No DDNS providers configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Description",
|
||||
"field": "description"
|
||||
},
|
||||
{
|
||||
"label": "Provider",
|
||||
"field": "provider"
|
||||
},
|
||||
{
|
||||
"label": "Hostname(s)",
|
||||
"field": "hostnames",
|
||||
"render": "tag_list"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "enabled",
|
||||
"render": "badge_enabled_disabled"
|
||||
},
|
||||
{
|
||||
"label": "Credentials",
|
||||
"field": "credentials",
|
||||
"render": "raw_html"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/ddns_tableaccounts_rowedit",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "description",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "provider",
|
||||
"input_type": "select",
|
||||
"options": "%DDNS_PROVIDER_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"col": "hostnames",
|
||||
"input_type": "textarea"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
"input_type": "checkbox"
|
||||
},
|
||||
{
|
||||
"col": "credentials",
|
||||
"input_type": "credentials"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/ddns_tableaccounts_rowdelete",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Add DDNS Account",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/ddns_cardaddaccount_add",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Description",
|
||||
"name": "description",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. My DuckDNS Account"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Provider",
|
||||
"name": "provider",
|
||||
"input_type": "select",
|
||||
"options": "%DDNS_PROVIDER_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Hostnames (one per line)",
|
||||
"name": "hostnames",
|
||||
"input_type": "textarea",
|
||||
"placeholder": "e.g. myhome.duckdns.org"
|
||||
},
|
||||
{
|
||||
"type": "credential_fields",
|
||||
"provider_select": "provider"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/ddns_cardaddaccount_add",
|
||||
"method": "post",
|
||||
"text": "Add Provider"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Logging",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "pre_block",
|
||||
"text": "%DDNS_LOG_TAIL%",
|
||||
"scroll_to_bottom": true
|
||||
},
|
||||
{
|
||||
"type": "raw_html",
|
||||
"html": "%DDNS_LOG_SUMMARY%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"justify": "space-between",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_ghost",
|
||||
"action": "/action/ddns_cardlogging_download",
|
||||
"text": "Download Log"
|
||||
},
|
||||
{
|
||||
"type": "button_danger",
|
||||
"action": "/action/ddns_cardlogging_clear",
|
||||
"method": "post",
|
||||
"text": "Clear Log"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/ddns_cardlogging_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Max Log Size (KB)",
|
||||
"name": "log_max_kb",
|
||||
"input_type": "number",
|
||||
"layout": "inline",
|
||||
"value": "%DDNS_GEN_LOG_MAX_KB%",
|
||||
"min": "64"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "",
|
||||
"name": "log_errors_only",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Only record errors to log",
|
||||
"value": "%DDNS_GEN_LOG_ERRORS_ONLY%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/ddns_cardlogging_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/dhcp/__init__.py
Normal file
0
docker/routlin-dash/app/pages/dhcp/__init__.py
Normal file
254
docker/routlin-dash/app/pages/dhcp/action.py
Normal file
254
docker/routlin-dash/app/pages/dhcp/action.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import copy
|
||||
import ipaddress
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('dhcp', __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_config_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():
|
||||
raw = request.form.get('ip', '').strip()
|
||||
if not raw:
|
||||
return 'dynamic'
|
||||
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
|
||||
|
||||
|
||||
def _check_ip_conflicts(ip, vlan):
|
||||
if ip == 'dynamic':
|
||||
return None
|
||||
dhcp = vlan.get('dhcp_information', {})
|
||||
pool_start = dhcp.get('dynamic_pool_start')
|
||||
pool_end = dhcp.get('dynamic_pool_end')
|
||||
if pool_start and pool_end:
|
||||
try:
|
||||
if (ipaddress.IPv4Address(pool_start) <= ipaddress.IPv4Address(ip)
|
||||
<= ipaddress.IPv4Address(pool_end)):
|
||||
return f'{ip} falls within the dynamic pool range ({pool_start}-{pool_end}).'
|
||||
except Exception:
|
||||
pass
|
||||
identity_ips = {s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')}
|
||||
if ip in identity_ips:
|
||||
return f'{ip} is already assigned as a server identity IP.'
|
||||
return None
|
||||
|
||||
|
||||
@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 = validate.domainname(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)
|
||||
|
||||
cfg = load_config()
|
||||
vlans = cfg.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)
|
||||
|
||||
conflict = _check_ip_conflicts(ip, vlan)
|
||||
if conflict:
|
||||
flash(f'The configuration has not been saved because {conflict}', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
entry = {
|
||||
'description': description,
|
||||
'hostname': hostname,
|
||||
'mac': mac,
|
||||
'ip': ip,
|
||||
'radius_client': radius_client,
|
||||
'enabled': True,
|
||||
}
|
||||
vlan.setdefault('reservations', []).append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.reservations', key=mac, operation='add',
|
||||
before=None, after=entry,
|
||||
description=f'Added DHCP reservation: {hostname or mac} ({ip or "dynamic"})',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
vlans = cfg.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]
|
||||
old_enabled = res.get('enabled', True)
|
||||
res['enabled'] = not old_enabled
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlan_name = vlans[vi]['name']
|
||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.reservations', key=res['mac'], operation='toggle',
|
||||
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
|
||||
description=f'{action} DHCP reservation: {res.get("hostname") or res["mac"]}',
|
||||
), '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 = validate.domainname(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)
|
||||
|
||||
cfg = load_config()
|
||||
vlans = cfg.get('vlans', [])
|
||||
vi, ri = _flat_index_to_vlan_res(vlans, idx)
|
||||
if vi is None:
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
conflict = _check_ip_conflicts(ip, vlans[vi])
|
||||
if conflict:
|
||||
flash(f'The configuration has not been saved because {conflict}', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
res = vlans[vi]['reservations'][ri]
|
||||
before = copy.deepcopy(res)
|
||||
res.update({
|
||||
'description': description,
|
||||
'hostname': hostname,
|
||||
'mac': mac,
|
||||
'ip': ip,
|
||||
'radius_client': radius_client,
|
||||
'enabled': 'enabled' in request.form,
|
||||
})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlan_name = vlans[vi]['name']
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.reservations', key=mac, operation='edit',
|
||||
before=before, after=copy.deepcopy(res),
|
||||
description=f'Edited DHCP reservation: {hostname or mac} ({ip or "dynamic"})',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
vlans = cfg.get('vlans', [])
|
||||
vi, ri = _flat_index_to_vlan_res(vlans, idx)
|
||||
if vi is None:
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
vlan_name = vlans[vi]['name']
|
||||
removed = vlans[vi]['reservations'].pop(ri)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.reservations', key=removed['mac'], operation='delete',
|
||||
before=removed, after=None,
|
||||
description=f'Deleted DHCP reservation: {removed.get("hostname") or removed["mac"]}',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
221
docker/routlin-dash/app/pages/dhcp/content.json
Normal file
221
docker/routlin-dash/app/pages/dhcp/content.json
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
{
|
||||
"id": "view_dhcp",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "DHCP"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Active leases, IP reservations, and VLAN authorizations."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "live:dhcp_leases",
|
||||
"empty_message": "No active DHCP leases found.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Hostname",
|
||||
"field": "hostname"
|
||||
},
|
||||
{
|
||||
"label": "IP Address",
|
||||
"field": "ip_address",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "MAC Address",
|
||||
"field": "mac_address",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "VLAN",
|
||||
"field": "vlan_name"
|
||||
},
|
||||
{
|
||||
"label": "Expires",
|
||||
"field": "expires"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:dhcp_reservations",
|
||||
"empty_message": "No DHCP reservations configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Description",
|
||||
"field": "description"
|
||||
},
|
||||
{
|
||||
"label": "Hostname",
|
||||
"field": "hostname",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "MAC",
|
||||
"field": "mac",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "IP",
|
||||
"field": "ip",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "VLAN",
|
||||
"field": "vlan_name"
|
||||
},
|
||||
{
|
||||
"label": "RADIUS",
|
||||
"field": "radius_client",
|
||||
"render": "badge_yes_no"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "enabled",
|
||||
"render": "badge_enabled_disabled"
|
||||
}
|
||||
],
|
||||
"toolbar": {
|
||||
"items": [
|
||||
{
|
||||
"type": "select",
|
||||
"name": "vlan_filter",
|
||||
"value": "all",
|
||||
"options": "%VLAN_FILTER_OPTIONS%",
|
||||
"filter_col": "vlan_name"
|
||||
}
|
||||
]
|
||||
},
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/edit_dhcp_reservation",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "description",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "hostname",
|
||||
"input_type": "text",
|
||||
"validate": "networkname"
|
||||
},
|
||||
{
|
||||
"col": "mac",
|
||||
"input_type": "text",
|
||||
"validate": "mac"
|
||||
},
|
||||
{
|
||||
"col": "ip",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "radius_client",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/delete_dhcp_reservation",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "add-form",
|
||||
"label": "Add Reservation/Authorization",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/add_dhcp_reservation",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "VLAN",
|
||||
"name": "vlan_name",
|
||||
"input_type": "select",
|
||||
"options": "%VLAN_NAMES_AS_OPTIONS%",
|
||||
"hint": "VLAN this reservation belongs to."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Description",
|
||||
"name": "description",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. NAS"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Hostname",
|
||||
"name": "hostname",
|
||||
"input_type": "text",
|
||||
"validate": "networkname",
|
||||
"placeholder": "e.g. nas"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "MAC Address",
|
||||
"name": "mac",
|
||||
"input_type": "text",
|
||||
"validate": "mac",
|
||||
"placeholder": "e.g. aa:bb:cc:dd:ee:ff"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "IP Address",
|
||||
"name": "ip",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. 192.168.10.50",
|
||||
"hint": "Leave blank to authorize device on this VLAN dynamically."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "RADIUS Client",
|
||||
"name": "radius_client",
|
||||
"input_type": "checkbox",
|
||||
"hint": "This device acts as a RADIUS authenticator, verifying credentials of other devices on the network."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/add_dhcp_reservation",
|
||||
"method": "post",
|
||||
"text": "Add"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/dnsblocking/__init__.py
Normal file
0
docker/routlin-dash/app/pages/dnsblocking/__init__.py
Normal file
234
docker/routlin-dash/app/pages/dnsblocking/action.py
Normal file
234
docker/routlin-dash/app/pages/dnsblocking/action.py
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import copy
|
||||
import re
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('dnsblocking', __name__)
|
||||
|
||||
VIEW = '/view/view_dnsblocking'
|
||||
|
||||
_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_config_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():
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
description = sanitize.description(request.form.get('description', ''))
|
||||
fmt = sanitize.filtervalue(request.form.get('format', ''), validate.VALID_BLOCKLIST_FORMATS)
|
||||
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 not fmt:
|
||||
flash(f'The configuration has not been saved because the format is invalid. '
|
||||
f'Accepted formats: {_VALID_FORMATS_STR}.', 'error')
|
||||
return None, True
|
||||
|
||||
return {'name': name, 'description': description, 'format': fmt, 'url': url}, None
|
||||
|
||||
|
||||
@bp.route('/action/dnsblocking_tableblocklists_rowdelete', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsblocking_tableblocklists_rowdelete():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('dns_blocking', {}).get('blocklists', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(items[idx])
|
||||
name = before.get('name', str(idx))
|
||||
items.pop(idx)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='dns_blocking', key=name, operation='delete',
|
||||
before=before, after=None,
|
||||
description=f'Deleted blocklist: {name}',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/dnsblocking_tableblocklists_rowedit', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsblocking_tableblocklists_rowedit():
|
||||
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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('dns_blocking', {}).get('blocklists', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(items[idx])
|
||||
items[idx].update({
|
||||
'name': fields['name'],
|
||||
'description': fields['description'],
|
||||
'format': fields['format'],
|
||||
'url': fields['url'],
|
||||
})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='dns_blocking', key=fields['name'], operation='edit',
|
||||
before=before, after=copy.deepcopy(items[idx]),
|
||||
description=f'Edited blocklist: {fields["name"]}',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/dnsblocking_cardaddblocklist_add', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsblocking_cardaddblocklist_add():
|
||||
fields, err = _parse_fields()
|
||||
if err:
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
blocklists = cfg.setdefault('dns_blocking', {}).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)
|
||||
|
||||
entry = {
|
||||
'name': fields['name'],
|
||||
'description': fields['description'],
|
||||
'format': fields['format'],
|
||||
'url': fields['url'],
|
||||
'save_as': _save_as_from_name(fields['name']),
|
||||
}
|
||||
blocklists.append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='dns_blocking', key=fields['name'], operation='add',
|
||||
before=None, after=copy.deepcopy(entry),
|
||||
description=f'Added blocklist: {fields["name"]}',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/dnsblocking_cardblocklistrefresh_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsblocking_cardblocklistrefresh_save():
|
||||
daily_execute_time = validate.time_24h(sanitize.time_24h(request.form.get('daily_execute_time_24hr_local', '')))
|
||||
|
||||
if not daily_execute_time:
|
||||
flash('Daily Refresh Time must be a valid 24-hour time (e.g. 02:30).', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
|
||||
cfg.setdefault('dns_blocking', {}).setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='dns_blocking', key='general', operation='edit',
|
||||
before=before, after=copy.deepcopy(cfg['dns_blocking']['general']),
|
||||
description='Updated daily blocklist refresh time',
|
||||
cmd='core apply',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/dnsblocking_cardblocklistrefresh_refreshnow', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsblocking_cardblocklistrefresh_refreshnow():
|
||||
flash(queued_msg('core update-blocklists', action_label='Blocklist refresh queued'), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/dnsblocking_cardlogging_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsblocking_cardlogging_save():
|
||||
log_max_kb_raw = request.form.get('log_max_kb', '').strip()
|
||||
log_errors_only = 'log_errors_only' in request.form
|
||||
|
||||
log_max_kb = validate.int_range(log_max_kb_raw, 64, None)
|
||||
if log_max_kb is None:
|
||||
flash('Max Log Size must be a number >= 64.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
|
||||
cfg.setdefault('dns_blocking', {}).setdefault('general', {}).update({
|
||||
'log_max_kb': log_max_kb,
|
||||
'log_errors_only': log_errors_only,
|
||||
})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='dns_blocking', key='general', operation='edit',
|
||||
before=before, after=copy.deepcopy(cfg['dns_blocking']['general']),
|
||||
description='Updated DNS blocking log settings',
|
||||
queue=False,
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
247
docker/routlin-dash/app/pages/dnsblocking/content.json
Normal file
247
docker/routlin-dash/app/pages/dnsblocking/content.json
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
{
|
||||
"id": "view_dnsblocking",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "DNS Blocking"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Domain level blocking via dnsmasq."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:blocklists",
|
||||
"empty_message": "No blocklists configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Name",
|
||||
"field": "name"
|
||||
},
|
||||
{
|
||||
"label": "Description",
|
||||
"field": "description"
|
||||
},
|
||||
{
|
||||
"label": "Format",
|
||||
"field": "format",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Source URL",
|
||||
"field": "url",
|
||||
"class": "col-mono"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/dnsblocking_tableblocklists_rowedit",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname"
|
||||
},
|
||||
{
|
||||
"col": "description",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "format",
|
||||
"input_type": "select",
|
||||
"options": "%BLOCKLIST_FORMAT_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"col": "url",
|
||||
"input_type": "text",
|
||||
"validate": "url"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/dnsblocking_tableblocklists_rowdelete",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "add-form",
|
||||
"label": "Add Blocklist",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/dnsblocking_cardaddblocklist_add",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Name",
|
||||
"name": "name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"placeholder": "e.g. steven-black"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Description",
|
||||
"name": "description",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. Steven Black (ads, malware, trackers)"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Format",
|
||||
"name": "format",
|
||||
"input_type": "select",
|
||||
"options": "%BLOCKLIST_FORMAT_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Source URL",
|
||||
"name": "url",
|
||||
"input_type": "text",
|
||||
"validate": "url",
|
||||
"placeholder": "https://..."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/dnsblocking_cardaddblocklist_add",
|
||||
"method": "post",
|
||||
"text": "Add Blocklist"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Blocklist Refresh",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "raw_html",
|
||||
"html": "%BLOCKLIST_STATS_HTML%"
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_secondary",
|
||||
"action": "/action/dnsblocking_cardblocklistrefresh_refreshnow",
|
||||
"method": "post",
|
||||
"text": "Refresh All Now"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/dnsblocking_cardblocklistrefresh_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Daily Refresh Time",
|
||||
"name": "daily_execute_time_24hr_local",
|
||||
"input_type": "text",
|
||||
"validate": "time_24h",
|
||||
"value": "%GENERAL_DAILY_EXECUTE_TIME%",
|
||||
"placeholder": "e.g. 02:30",
|
||||
"hint": "24-hour local time for the daily blocklist refresh."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/dnsblocking_cardblocklistrefresh_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Logging",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/dnsblocking_cardlogging_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Max Log Size (KB)",
|
||||
"name": "log_max_kb",
|
||||
"input_type": "number",
|
||||
"value": "%GENERAL_LOG_MAX_KB%",
|
||||
"min": 64,
|
||||
"hint": "Log is cleared and restarted when it exceeds this size."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Only record errors to log",
|
||||
"name": "log_errors_only",
|
||||
"input_type": "checkbox",
|
||||
"value": "%GENERAL_LOG_ERRORS_ONLY%",
|
||||
"hint": "Only write error-level messages to the log."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/dnsblocking_cardlogging_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/dnsserver/__init__.py
Normal file
0
docker/routlin-dash/app/pages/dnsserver/__init__.py
Normal file
93
docker/routlin-dash/app/pages/dnsserver/action.py
Normal file
93
docker/routlin-dash/app/pages/dnsserver/action.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import copy
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('dnsserver', __name__)
|
||||
|
||||
_VIEW = '/view/view_dnsserver'
|
||||
|
||||
|
||||
@bp.route('/action/dnsserver_cardupstreamdns_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsserver_cardupstreamdns_save():
|
||||
strict_order = 'strict_order' in request.form
|
||||
submitted = request.form.getlist('upstream_servers')
|
||||
|
||||
for s in submitted:
|
||||
if not s.strip():
|
||||
flash('Remove blank server entries before saving.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
upstream_servers = []
|
||||
for s in submitted:
|
||||
clean = sanitize.ip(s.strip())
|
||||
if not clean:
|
||||
flash(f"'{s.strip()}' is not a valid IP address.", 'error')
|
||||
return redirect(_VIEW)
|
||||
upstream_servers.append(clean)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('upstream_dns', {}))
|
||||
current = cfg.get('upstream_dns', {})
|
||||
if (strict_order == bool(current.get('strict_order', False)) and
|
||||
upstream_servers == current.get('upstream_servers', [])):
|
||||
flash('No changes detected.', 'info')
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg.setdefault('upstream_dns', {}).update({
|
||||
'strict_order': strict_order,
|
||||
'upstream_servers': upstream_servers,
|
||||
})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='upstream_dns', key='global', operation='edit',
|
||||
before=before, after=copy.deepcopy(cfg['upstream_dns']),
|
||||
description='Updated upstream DNS servers',
|
||||
cmd='core apply',
|
||||
), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/dnsserver_carddnsforwarding_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def dnsserver_carddnsforwarding_save():
|
||||
cache_size = validate.int_range(request.form.get('cache_size', '').strip(), 0, None)
|
||||
if cache_size is None:
|
||||
flash('Cache Size must be a non-negative integer.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('upstream_dns', {}))
|
||||
current = cfg.get('upstream_dns', {})
|
||||
if cache_size == int(current.get('cache_size', 0)):
|
||||
flash('No changes detected.', 'info')
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg.setdefault('upstream_dns', {})['cache_size'] = cache_size
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='upstream_dns', key='global', operation='edit',
|
||||
before=before, after=copy.deepcopy(cfg['upstream_dns']),
|
||||
description='Updated DNS cache size',
|
||||
cmd='core apply',
|
||||
), 'success')
|
||||
return redirect(_VIEW)
|
||||
104
docker/routlin-dash/app/pages/dnsserver/content.json
Normal file
104
docker/routlin-dash/app/pages/dnsserver/content.json
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
{
|
||||
"id": "view_dnsserver",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "DNS Server"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Upstream resolvers and forwarding DNS service settings."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Upstream DNS",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/dnsserver_cardupstreamdns_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "editable_list",
|
||||
"label": "DNS Providers",
|
||||
"name": "upstream_servers",
|
||||
"item_placeholder": "e.g. 1.1.1.1",
|
||||
"add_label": "Add Provider",
|
||||
"validate": "ip",
|
||||
"hint": "DNS resolvers queried for external hostnames. Supports IPv4 and IPv6.",
|
||||
"items": "%DNS_UPSTREAM_SERVERS_JSON%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Strict Order",
|
||||
"name": "strict_order",
|
||||
"input_type": "checkbox",
|
||||
"value": "%DNS_STRICT_ORDER%",
|
||||
"hint": "Query DNS providers in list order rather than in parallel."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/dnsserver_cardupstreamdns_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "DNS Forwarding",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/dnsserver_carddnsforwarding_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Cache Size",
|
||||
"name": "cache_size",
|
||||
"input_type": "number",
|
||||
"value": "%DNS_CACHE_SIZE%",
|
||||
"min": 0,
|
||||
"hint": "Max DNS responses to cache per instance. Set to 0 to disable caching."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/dnsserver_carddnsforwarding_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/hostoverrides/__init__.py
Normal file
0
docker/routlin-dash/app/pages/hostoverrides/__init__.py
Normal file
195
docker/routlin-dash/app/pages/hostoverrides/action.py
Normal file
195
docker/routlin-dash/app/pages/hostoverrides/action.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import copy
|
||||
import ipaddress
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('hostoverrides', __name__)
|
||||
|
||||
VIEW = '/view/view_hostoverrides'
|
||||
|
||||
|
||||
def _vlan_networks(cfg):
|
||||
nets = []
|
||||
for v in cfg.get('vlans', []):
|
||||
subnet = v.get('subnet', '')
|
||||
mask = v.get('subnet_mask', '')
|
||||
if subnet and mask:
|
||||
try:
|
||||
nets.append(ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False))
|
||||
except ValueError:
|
||||
pass
|
||||
return nets
|
||||
|
||||
|
||||
def _ip_in_vlan(ip_str, cfg):
|
||||
try:
|
||||
addr = ipaddress.IPv4Address(ip_str)
|
||||
except ValueError:
|
||||
return False
|
||||
nets = _vlan_networks(cfg)
|
||||
return not nets or any(addr in net for net in nets)
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_config_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 = validate.domainname(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)
|
||||
|
||||
cfg = load_config()
|
||||
if not _ip_in_vlan(ip, cfg):
|
||||
flash('IP address does not fall within any configured VLAN subnet.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
entry = {'description': description, 'host': host, 'ip': ip, 'enabled': True}
|
||||
cfg.setdefault('host_overrides', []).append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='host_overrides', key=host, operation='add',
|
||||
before=None, after=entry,
|
||||
description=f'Added host override: {host} → {ip}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('host_overrides', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
old_enabled = items[idx].get('enabled', True)
|
||||
items[idx]['enabled'] = not old_enabled
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='host_overrides', key=items[idx]['host'], operation='toggle',
|
||||
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
|
||||
description=f'{action} host override: {items[idx]["host"]}',
|
||||
), '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 = validate.domainname(request.form.get('host', ''))
|
||||
ip = sanitize.ip(request.form.get('ip', ''))
|
||||
enabled = request.form.get('enabled') == 'on'
|
||||
|
||||
if not host or not ip:
|
||||
flash('Hostname and IP address are required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
if not _ip_in_vlan(ip, cfg):
|
||||
flash('IP address does not fall within any configured VLAN subnet.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
items = cfg.get('host_overrides', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(items[idx])
|
||||
items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='host_overrides', key=host, operation='edit',
|
||||
before=before, after=copy.deepcopy(items[idx]),
|
||||
description=f'Edited host override: {host} → {ip}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('host_overrides', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='host_overrides', key=removed['host'], operation='delete',
|
||||
before=removed, after=None,
|
||||
description=f'Deleted host override: {removed["host"]}',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
135
docker/routlin-dash/app/pages/hostoverrides/content.json
Normal file
135
docker/routlin-dash/app/pages/hostoverrides/content.json
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
{
|
||||
"id": "view_hostoverrides",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Host Overrides"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Force a hostname to resolve to a specific internal IP."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:host_overrides",
|
||||
"empty_message": "No host overrides configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Description",
|
||||
"field": "description"
|
||||
},
|
||||
{
|
||||
"label": "Hostname",
|
||||
"field": "host",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Resolves To",
|
||||
"field": "ip",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "enabled",
|
||||
"render": "badge_enabled_disabled"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/edit_host_override",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "description",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "host",
|
||||
"input_type": "text",
|
||||
"validate": "domainname"
|
||||
},
|
||||
{
|
||||
"col": "ip",
|
||||
"input_type": "text",
|
||||
"validate": "ip"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/delete_host_override",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "add-form",
|
||||
"label": "Add Host Override",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/add_host_override",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Description",
|
||||
"name": "description",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. Local server"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Hostname",
|
||||
"name": "host",
|
||||
"input_type": "text",
|
||||
"validate": "domainname",
|
||||
"placeholder": "e.g. server.home.local"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Resolves To",
|
||||
"name": "ip",
|
||||
"input_type": "text",
|
||||
"validate": "ip",
|
||||
"placeholder": "e.g. 192.168.1.100"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/add_host_override",
|
||||
"method": "post",
|
||||
"text": "Add Host Override"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/intervlan/__init__.py
Normal file
0
docker/routlin-dash/app/pages/intervlan/__init__.py
Normal file
211
docker/routlin-dash/app/pages/intervlan/action.py
Normal file
211
docker/routlin-dash/app/pages/intervlan/action.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import copy
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('intervlan', __name__)
|
||||
|
||||
VIEW = '/view/view_intervlan'
|
||||
|
||||
_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_config_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():
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
protocol = sanitize.filtervalue(request.form.get('protocol', ''), validate.VALID_PROTOCOLS)
|
||||
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 not protocol:
|
||||
flash(f'The configuration has not been saved because the protocol is invalid. '
|
||||
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
|
||||
|
||||
|
||||
def _entry_key(entry):
|
||||
port = f':{entry["dst_port"]}' if entry.get('dst_port') else ''
|
||||
return f'{entry["protocol"]}:{entry["src_ip_or_subnet"]}→{entry["dst_ip_or_subnet"]}{port}'
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault('inter_vlan_exceptions', []).append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = _entry_key(entry)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='inter_vlan_exceptions', key=key, operation='add',
|
||||
before=None, after=entry,
|
||||
description=f'Added inter-VLAN rule: {key}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('inter_vlan_exceptions', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
old_enabled = items[idx].get('enabled', True)
|
||||
items[idx]['enabled'] = not old_enabled
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = _entry_key(items[idx])
|
||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='inter_vlan_exceptions', key=key, operation='toggle',
|
||||
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
|
||||
description=f'{action} inter-VLAN rule: {key}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('inter_vlan_exceptions', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(items[idx])
|
||||
items[idx] = entry
|
||||
items[idx]['enabled'] = request.form.get('enabled') == 'on'
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = _entry_key(entry)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='inter_vlan_exceptions', key=key, operation='edit',
|
||||
before=before, after=copy.deepcopy(items[idx]),
|
||||
description=f'Edited inter-VLAN rule: {key}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('inter_vlan_exceptions', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = _entry_key(removed)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='inter_vlan_exceptions', key=key, operation='delete',
|
||||
before=removed, after=None,
|
||||
description=f'Deleted inter-VLAN rule: {key}',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
167
docker/routlin-dash/app/pages/intervlan/content.json
Normal file
167
docker/routlin-dash/app/pages/intervlan/content.json
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
{
|
||||
"id": "view_intervlan",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Inter-VLAN Exceptions"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Firewall rules that permit specific traffic to cross VLAN boundaries."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:inter_vlan_exceptions",
|
||||
"empty_message": "No inter-VLAN exceptions configured. All cross-VLAN traffic is blocked by default.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Description",
|
||||
"field": "description"
|
||||
},
|
||||
{
|
||||
"label": "Protocol",
|
||||
"field": "protocol",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Source",
|
||||
"field": "src_ip_or_subnet",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Destination",
|
||||
"field": "dst_ip_or_subnet",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Dest Port",
|
||||
"field": "dst_port",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "enabled",
|
||||
"render": "badge_enabled_disabled"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/edit_inter_vlan",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "description",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "protocol",
|
||||
"input_type": "select",
|
||||
"options": "%PROTOCOL_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"col": "src_ip_or_subnet",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "dst_ip_or_subnet",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "dst_port",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/delete_inter_vlan",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "add-form",
|
||||
"label": "Add Exception",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/add_inter_vlan",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Description",
|
||||
"name": "description",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. Allow Chromecast"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Protocol",
|
||||
"name": "protocol",
|
||||
"input_type": "select",
|
||||
"options": "%PROTOCOL_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Source",
|
||||
"name": "src_ip_or_subnet",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4cidr",
|
||||
"placeholder": "e.g. 192.168.20.0/24"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Destination",
|
||||
"name": "dst_ip_or_subnet",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"placeholder": "e.g. 192.168.10.100"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Dest Port",
|
||||
"name": "dst_port",
|
||||
"input_type": "text",
|
||||
"validate": "port",
|
||||
"placeholder": "e.g. 8009"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/add_inter_vlan",
|
||||
"method": "post",
|
||||
"text": "Add Exception"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/mdns/__init__.py
Normal file
0
docker/routlin-dash/app/pages/mdns/__init__.py
Normal file
44
docker/routlin-dash/app/pages/mdns/action.py
Normal file
44
docker/routlin-dash/app/pages/mdns/action.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import copy
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('mdns', __name__)
|
||||
|
||||
|
||||
@bp.route('/action/apply_mdns', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_mdns():
|
||||
mdns_enabled = 'mdns_enabled' in request.form
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect('/view/view_mdns')
|
||||
|
||||
cfg = load_config()
|
||||
mdns_reflect_vlans = sanitize.filterlist(
|
||||
request.form.getlist('mdns_reflect_vlans'),
|
||||
{v.get('name') for v in cfg.get('vlans', [])},
|
||||
)
|
||||
|
||||
before = copy.deepcopy(cfg.get('mdns_reflection', {}))
|
||||
cfg.setdefault('mdns_reflection', {}).update({
|
||||
'enabled': mdns_enabled,
|
||||
'reflect_vlans': mdns_reflect_vlans,
|
||||
})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect('/view/view_mdns')
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='mdns_reflection', key='global', operation='edit',
|
||||
before=before or None, after=copy.deepcopy(cfg['mdns_reflection']),
|
||||
description='Updated mDNS reflection settings',
|
||||
), 'success')
|
||||
return redirect('/view/view_mdns')
|
||||
0
docker/routlin-dash/app/pages/networklayout/__init__.py
Normal file
0
docker/routlin-dash/app/pages/networklayout/__init__.py
Normal file
360
docker/routlin-dash/app/pages/networklayout/action.py
Normal file
360
docker/routlin-dash/app/pages/networklayout/action.py
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import copy
|
||||
import ipaddress
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('networklayout', __name__)
|
||||
|
||||
VIEW = '/view/view_networklayout'
|
||||
|
||||
_VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries',
|
||||
'radius_default', 'mdns_reflection', 'use_blocklists']
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_config_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/networklayout_cardaddvlan_addvlan', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def networklayout_cardaddvlan_addvlan():
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
|
||||
is_vpn = 'is_vpn' in request.form
|
||||
subnet = sanitize.ip(request.form.get('subnet', ''))
|
||||
subnet_mask = sanitize.subnet_mask(request.form.get('subnet_mask', ''))
|
||||
radius_default = 'radius_default' in request.form
|
||||
mdns_reflection = 'mdns_reflection' in request.form
|
||||
dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
|
||||
use_blocklists = sanitize.filterlist(
|
||||
request.form.getlist('use_blocklists'),
|
||||
{b.get('name') for b in load_config().get('dns_blocking', {}).get('blocklists', [])},
|
||||
)
|
||||
|
||||
if not name:
|
||||
flash('Name is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if vlan_id is None:
|
||||
flash('VLAN ID must be an integer between 1 and 4094.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not subnet:
|
||||
flash('Subnet IP is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if subnet_mask is None:
|
||||
flash('Invalid subnet prefix (must be 1-30).', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vlans = cfg.setdefault('vlans', [])
|
||||
|
||||
if any(v.get('vlan_id') == vlan_id for v in vlans):
|
||||
flash(f'VLAN ID {vlan_id} is already in use.', 'error')
|
||||
return redirect(VIEW)
|
||||
if radius_default and any(v.get('radius_default') for v in vlans):
|
||||
flash('Only one VLAN can be the RADIUS default.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
entry = {
|
||||
'name': name,
|
||||
'vlan_id': vlan_id,
|
||||
'is_vpn': is_vpn,
|
||||
'subnet': subnet,
|
||||
'subnet_mask': subnet_mask,
|
||||
'dnsmasq_log_queries': dnsmasq_log_queries,
|
||||
'use_blocklists': use_blocklists,
|
||||
'radius_default': radius_default,
|
||||
'mdns_reflection': mdns_reflection,
|
||||
}
|
||||
if is_vpn:
|
||||
entry['peers'] = []
|
||||
else:
|
||||
entry['reservations'] = []
|
||||
vlans.append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='vlans', key=name, operation='add',
|
||||
before=None, after={k: entry[k] for k in _VLAN_FIELDS if k in entry},
|
||||
description=f'Added VLAN: {name} ({subnet}/{subnet_mask})',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/networklayout_tablevlans_edit', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def networklayout_tablevlans_edit():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
name = sanitize.name(request.form.get('name', ''))
|
||||
vlan_id = sanitize.vlan_id(request.form.get('vlan_id', ''))
|
||||
subnet = sanitize.ip(request.form.get('subnet', ''))
|
||||
radius_default = 'radius_default' in request.form
|
||||
mdns_reflection = 'mdns_reflection' in request.form
|
||||
dnsmasq_log_queries = 'dnsmasq_log_queries' in request.form
|
||||
use_blocklists = sanitize.filterlist(
|
||||
request.form.getlist('use_blocklists'),
|
||||
{b.get('name') for b in load_config().get('dns_blocking', {}).get('blocklists', [])},
|
||||
)
|
||||
identity_ips_raw = [line.strip() for line in request.form.get('server_identity_ips', '').splitlines() if line.strip()]
|
||||
identity_ips = []
|
||||
for raw_ip in identity_ips_raw:
|
||||
clean = sanitize.ip(raw_ip)
|
||||
if not clean:
|
||||
flash(f"'{raw_ip}' is not a valid IP address.", 'error')
|
||||
return redirect(VIEW)
|
||||
identity_ips.append(clean)
|
||||
identity_descs = request.form.get('server_identity_descriptions', '').splitlines()
|
||||
identity_hostnames = request.form.get('server_identity_hostnames', '').splitlines()
|
||||
|
||||
subnet_mask_raw = request.form.get('subnet_mask')
|
||||
if subnet_mask_raw is not None:
|
||||
subnet_mask = sanitize.subnet_mask(subnet_mask_raw)
|
||||
if subnet_mask is None:
|
||||
flash('Invalid subnet prefix (must be 1-30).', 'error')
|
||||
return redirect(VIEW)
|
||||
else:
|
||||
subnet_mask = None
|
||||
|
||||
if not name:
|
||||
flash('Name is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if vlan_id is None:
|
||||
flash('VLAN ID must be an integer between 1 and 4094.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not subnet:
|
||||
flash('Subnet IP is required.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vlans = cfg.get('vlans', [])
|
||||
if idx < 0 or idx >= len(vlans):
|
||||
flash('VLAN not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
existing = vlans[idx]
|
||||
is_vpn = existing.get('is_vpn', False)
|
||||
final_mask = subnet_mask if subnet_mask is not None else existing.get('subnet_mask', 24)
|
||||
|
||||
if identity_ips:
|
||||
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
|
||||
for _ip in identity_ips:
|
||||
if ipaddress.IPv4Address(_ip) not in _vlan_net:
|
||||
flash(f"Server identity IP '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
current_id = existing.get('vlan_id')
|
||||
if current_id == 1 and vlan_id != 1:
|
||||
flash('VLAN 1 is the physical interface and cannot change its ID.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if vlan_id != current_id and any(v.get('vlan_id') == vlan_id for i, v in enumerate(vlans) if i != idx):
|
||||
flash(f'VLAN ID {vlan_id} is already in use.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
if radius_default and any(i != idx and v.get('radius_default') for i, v in enumerate(vlans)):
|
||||
flash('Only one VLAN can be the RADIUS default.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
old_identities = existing.get('server_identities', [])
|
||||
new_identities = []
|
||||
for i, ip in enumerate(identity_ips):
|
||||
entry = dict(old_identities[i]) if i < len(old_identities) else {}
|
||||
entry['ip'] = ip
|
||||
desc = identity_descs[i].strip() if i < len(identity_descs) else ''
|
||||
if desc:
|
||||
entry['description'] = desc
|
||||
else:
|
||||
entry.pop('description', None)
|
||||
hostname_raw = identity_hostnames[i].strip() if i < len(identity_hostnames) else ''
|
||||
if hostname_raw:
|
||||
clean_hostname = sanitize.hostname(hostname_raw)
|
||||
if clean_hostname is None:
|
||||
flash(f"'{hostname_raw}' is not a valid hostname.", 'error')
|
||||
return redirect(VIEW)
|
||||
entry['hostname'] = clean_hostname
|
||||
else:
|
||||
entry.pop('hostname', None)
|
||||
new_identities.append(entry)
|
||||
|
||||
gateway_raw = sanitize.ip(request.form.get('gateway', ''))
|
||||
if gateway_raw and gateway_raw not in identity_ips:
|
||||
flash(f"Gateway '{gateway_raw}' must match one of the server identity IPs.", 'error')
|
||||
return redirect(VIEW)
|
||||
inferred_gw = (min(identity_ips, key=lambda ip: int(ip.split('.')[-1]))
|
||||
if identity_ips else '')
|
||||
new_stored_gw = gateway_raw if (gateway_raw and gateway_raw != inferred_gw) else ''
|
||||
existing_gw = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('gateway', '')
|
||||
|
||||
dns_override = 'dns_server_override' in request.form
|
||||
dns_ips = []
|
||||
for _line in request.form.get('dns_server', '').splitlines():
|
||||
_line = _line.strip()
|
||||
if not _line:
|
||||
continue
|
||||
_clean = sanitize.ip(_line)
|
||||
if not _clean:
|
||||
flash(f"'{_line}' is not a valid DNS server IP.", 'error')
|
||||
return redirect(VIEW)
|
||||
dns_ips.append(_clean)
|
||||
if dns_override and not dns_ips:
|
||||
flash('At least one DNS server IP is required when override is enabled.', 'error')
|
||||
return redirect(VIEW)
|
||||
if dns_override and dns_ips:
|
||||
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
|
||||
for _ip in dns_ips:
|
||||
if ipaddress.IPv4Address(_ip) not in _vlan_net:
|
||||
flash(f"DNS server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error')
|
||||
return redirect(VIEW)
|
||||
new_stored_dns = dns_ips if dns_override else []
|
||||
_existing_dns = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('dns_server', [])
|
||||
existing_dns = _existing_dns if isinstance(_existing_dns, list) else ([_existing_dns] if _existing_dns else [])
|
||||
|
||||
ntp_override = 'ntp_server_override' in request.form
|
||||
ntp_ips = []
|
||||
for _line in request.form.get('ntp_server', '').splitlines():
|
||||
_line = _line.strip()
|
||||
if not _line:
|
||||
continue
|
||||
_clean = sanitize.ip(_line)
|
||||
if not _clean:
|
||||
flash(f"'{_line}' is not a valid NTP server IP.", 'error')
|
||||
return redirect(VIEW)
|
||||
ntp_ips.append(_clean)
|
||||
if ntp_override and not ntp_ips:
|
||||
flash('At least one NTP server IP is required when override is enabled.', 'error')
|
||||
return redirect(VIEW)
|
||||
if ntp_override and ntp_ips:
|
||||
_vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False)
|
||||
for _ip in ntp_ips:
|
||||
if ipaddress.IPv4Address(_ip) not in _vlan_net:
|
||||
flash(f"NTP server '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error')
|
||||
return redirect(VIEW)
|
||||
new_stored_ntp = ntp_ips if ntp_override else []
|
||||
_existing_ntp = existing.get('dhcp_information', {}).get('explicit_overrides', {}).get('ntp_server', [])
|
||||
existing_ntp = _existing_ntp if isinstance(_existing_ntp, list) else ([_existing_ntp] if _existing_ntp else [])
|
||||
|
||||
_ids_unchanged = (
|
||||
len(new_identities) == len(old_identities) and
|
||||
all(
|
||||
n.get('ip') == o.get('ip') and
|
||||
n.get('description', '') == o.get('description', '') and
|
||||
n.get('hostname', '') == o.get('hostname', '')
|
||||
for n, o in zip(new_identities, old_identities)
|
||||
)
|
||||
)
|
||||
if (name == existing.get('name', '')
|
||||
and vlan_id == existing.get('vlan_id')
|
||||
and subnet == existing.get('subnet', '')
|
||||
and final_mask == existing.get('subnet_mask', 24)
|
||||
and dnsmasq_log_queries == bool(existing.get('dnsmasq_log_queries', False))
|
||||
and radius_default == bool(existing.get('radius_default', False))
|
||||
and mdns_reflection == bool(existing.get('mdns_reflection', False))
|
||||
and sorted(use_blocklists) == sorted(existing.get('use_blocklists', []))
|
||||
and _ids_unchanged
|
||||
and new_stored_gw == existing_gw
|
||||
and new_stored_dns == existing_dns
|
||||
and new_stored_ntp == existing_ntp):
|
||||
flash('No changes were made.', 'info')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = {k: existing.get(k) for k in _VLAN_FIELDS}
|
||||
existing.update({
|
||||
'name': name,
|
||||
'vlan_id': vlan_id,
|
||||
'is_vpn': is_vpn,
|
||||
'subnet': subnet,
|
||||
'subnet_mask': final_mask,
|
||||
'dnsmasq_log_queries': dnsmasq_log_queries,
|
||||
'radius_default': radius_default,
|
||||
'mdns_reflection': mdns_reflection,
|
||||
'use_blocklists': use_blocklists,
|
||||
'server_identities': new_identities,
|
||||
})
|
||||
dhcp_overrides = existing.setdefault('dhcp_information', {}).setdefault('explicit_overrides', {})
|
||||
if new_stored_gw:
|
||||
dhcp_overrides['gateway'] = new_stored_gw
|
||||
else:
|
||||
dhcp_overrides.pop('gateway', None)
|
||||
if new_stored_dns:
|
||||
dhcp_overrides['dns_server'] = new_stored_dns
|
||||
else:
|
||||
dhcp_overrides.pop('dns_server', None)
|
||||
if new_stored_ntp:
|
||||
dhcp_overrides['ntp_server'] = new_stored_ntp
|
||||
else:
|
||||
dhcp_overrides.pop('ntp_server', None)
|
||||
if not dhcp_overrides:
|
||||
existing.get('dhcp_information', {}).pop('explicit_overrides', None)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='vlans', key=name, operation='edit',
|
||||
before=before, after={k: existing.get(k) for k in _VLAN_FIELDS},
|
||||
description=f'Edited VLAN: {name}',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/networklayout_tablevlans_delete', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def networklayout_tablevlans_delete():
|
||||
idx = _row_index()
|
||||
if idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vlans = cfg.get('vlans', [])
|
||||
if idx < 0 or idx >= len(vlans):
|
||||
flash('VLAN not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = vlans.pop(idx)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='vlans', key=removed['name'], operation='delete',
|
||||
before={k: removed.get(k) for k in _VLAN_FIELDS},
|
||||
after=None,
|
||||
description=f'Deleted VLAN: {removed["name"]}',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
283
docker/routlin-dash/app/pages/networklayout/content.json
Normal file
283
docker/routlin-dash/app/pages/networklayout/content.json
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
{
|
||||
"id": "view_networklayout",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Network Layout"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Network segments managed by systemd-networkd, dnsmasq, nftables, and freeradius."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "info_bar",
|
||||
"variant": "info",
|
||||
"text": "For a basic flat network with no VLAN segmentation, only use VLAN 1 and delete the others."
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:vlans",
|
||||
"empty_message": "No VLANs configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "VLAN ID",
|
||||
"field": "vlan_id",
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Name",
|
||||
"field": "name",
|
||||
"class": "col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Interface",
|
||||
"field": "interface",
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Subnet",
|
||||
"field": "subnet",
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Mask",
|
||||
"field": "subnet_mask",
|
||||
"class": "col-mono col-narrow"
|
||||
},
|
||||
{
|
||||
"label": "Self Ident(s)",
|
||||
"field": "server_identity_ips",
|
||||
"render": "tag_list"
|
||||
},
|
||||
{
|
||||
"label": "Blocklists",
|
||||
"field": "use_blocklists",
|
||||
"class": "col-expand",
|
||||
"render": "tag_list"
|
||||
},
|
||||
{
|
||||
"label": "Default",
|
||||
"field": "radius_default",
|
||||
"class": "col-narrow",
|
||||
"render": "badge_yes_no",
|
||||
"render_options": {
|
||||
"title_true": "RADIUS Default",
|
||||
"title_false": "Not RADIUS Default"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "mDNS",
|
||||
"field": "mdns_reflection",
|
||||
"class": "col-narrow",
|
||||
"render": "badge_yes_no",
|
||||
"render_options": {
|
||||
"title_true": "mDNS Reflection Enabled",
|
||||
"title_false": "mDNS Reflection Disabled"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Record",
|
||||
"field": "dnsmasq_log_queries",
|
||||
"class": "col-narrow",
|
||||
"render": "badge_yes_no",
|
||||
"render_options": {
|
||||
"title_true": "DNS Queries Recorded",
|
||||
"title_false": "DNS Queries Not Recorded"
|
||||
}
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/networklayout_tablevlans_edit",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname"
|
||||
},
|
||||
{
|
||||
"col": "subnet",
|
||||
"input_type": "text",
|
||||
"validate": "subnet"
|
||||
},
|
||||
{
|
||||
"col": "subnet_mask",
|
||||
"input_type": "number",
|
||||
"min": 1,
|
||||
"max": 30
|
||||
},
|
||||
{
|
||||
"col": "server_identity_ips",
|
||||
"input_type": "textarea_pair",
|
||||
"col_label": "IP Address",
|
||||
"col_validate": "ip",
|
||||
"pair_col": "server_identity_descriptions",
|
||||
"pair_label": "Description (Opt)",
|
||||
"pair_wide": true,
|
||||
"pair_col2": "server_identity_hostnames",
|
||||
"pair_label2": "Hostname (Opt)",
|
||||
"pair_validate2": "networkname",
|
||||
"gateway_col": "server_identity_gateway",
|
||||
"dns_col": "server_identity_dns_server",
|
||||
"ntp_col": "server_identity_ntp_server"
|
||||
},
|
||||
{
|
||||
"col": "radius_default",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
},
|
||||
{
|
||||
"col": "mdns_reflection",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
},
|
||||
{
|
||||
"col": "dnsmasq_log_queries",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Record"
|
||||
},
|
||||
{
|
||||
"col": "use_blocklists",
|
||||
"input_type": "checkbox_multi",
|
||||
"options": "%BLOCKLIST_NAME_OPTIONS%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/networklayout_tablevlans_delete",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm",
|
||||
"disable_if": {
|
||||
"field": "vlan_id",
|
||||
"value": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "add-form",
|
||||
"label": "Add VLAN",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/networklayout_cardaddvlan_addvlan",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field_row",
|
||||
"cols": 4,
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "VLAN ID",
|
||||
"name": "vlan_id",
|
||||
"input_type": "number",
|
||||
"min": 1,
|
||||
"max": 4094,
|
||||
"validate": "vlan_id",
|
||||
"hint": "Unique integer 1-4094. Sets the 802.1Q tag and interface name."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "VLAN Name",
|
||||
"name": "name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"hint": "Lowercase letters, digits, hyphens. E.g. iot"
|
||||
},
|
||||
{
|
||||
"type": "subnet_row",
|
||||
"subnet_name": "subnet",
|
||||
"prefix_name": "subnet_mask",
|
||||
"subnet_placeholder": "e.g. 192.168.x.0",
|
||||
"prefix_value": "24"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "VLAN Type",
|
||||
"name": "is_vpn",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Is VPN",
|
||||
"hint": "Check if this VLAN uses a WireGuard interface (e.g. wg0, wg1, etc)."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "identity_builder",
|
||||
"label": "Router Identities on this VLAN:"
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Blocklists",
|
||||
"name": "use_blocklists",
|
||||
"input_type": "checkbox_group",
|
||||
"options": "%BLOCKLIST_NAME_OPTIONS%",
|
||||
"hint": "Note: Selected lists will be merged and de-duplicated prior to use."
|
||||
},
|
||||
{
|
||||
"type": "hr"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "RADIUS Default",
|
||||
"name": "radius_default",
|
||||
"input_type": "checkbox",
|
||||
"hint": "Wireless devices without a DHCP reservation will be placed into this VLAN. (Note: wired devices are not placed via RADIUS but rather by layer 3 switch policy.)"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "mDNS Reflection",
|
||||
"name": "mdns_reflection",
|
||||
"input_type": "checkbox",
|
||||
"hint": "Reflect mDNS traffic to/from this VLAN via avahi-daemon. Not supported on WireGuard interfaces."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Record DNS Queries",
|
||||
"name": "dnsmasq_log_queries",
|
||||
"input_type": "checkbox",
|
||||
"hint": "Log every DNS query. High volume - enable for debugging only."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/networklayout_cardaddvlan_addvlan",
|
||||
"method": "post",
|
||||
"text": "Add VLAN",
|
||||
"class": "add-vlan-btn",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/overview/__init__.py
Normal file
0
docker/routlin-dash/app/pages/overview/__init__.py
Normal file
289
docker/routlin-dash/app/pages/overview/content.json
Normal file
289
docker/routlin-dash/app/pages/overview/content.json
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
{
|
||||
"id": "view_overview",
|
||||
"client_requirement": "client_is_nothing+",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_wrapper",
|
||||
"client_requirement": "client_is_nothing=",
|
||||
"items": [
|
||||
{
|
||||
"type": "auth_card",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Routlin Dashboard"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Log in to monitor and manage your network."
|
||||
},
|
||||
{
|
||||
"type": "spacer"
|
||||
},
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/view/view_login",
|
||||
"text": "Log In"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Overview"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Current network status at a glance."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "stat_card_grid",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "stat_card",
|
||||
"label": "DHCP Leases",
|
||||
"value": "%STAT_LEASE_COUNT%",
|
||||
"sub": "active leases",
|
||||
"variant": "accent"
|
||||
},
|
||||
{
|
||||
"type": "stat_card",
|
||||
"label": "Queries Blocked",
|
||||
"value": "%STAT_BLOCKED_TODAY%",
|
||||
"sub": "since midnight",
|
||||
"variant": "warning"
|
||||
},
|
||||
{
|
||||
"type": "stat_card",
|
||||
"label": "Public IP",
|
||||
"value": "%STAT_PUBLIC_IP%",
|
||||
"sub": "%STAT_DDNS_HOSTNAME%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Network",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "grid",
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "WAN Interface"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%GENERAL_WAN_INTERFACE%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "VLANs"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%OVERVIEW_VLAN_NAMES%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Firewall"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%STAT_NFTABLES_STATUS%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "System Uptime"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%STAT_UPTIME%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "DNS Blocking",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "grid",
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Blocked Domains"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%STAT_BLOCKED_DOMAINS%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Active Blocklists"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%STAT_BLOCKLIST_COUNT% lists"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Last Refreshed"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%STAT_BL_LAST_UPDATE%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Active IP Bans"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%STAT_BANNED_IP_COUNT% rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "DNS Caching",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "grid",
|
||||
"rows": [
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Total Queries"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%DNS_STAT_QUERIES%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Cache Hits"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%DNS_STAT_HITS% (%DNS_STAT_HIT_RATE% hit rate)"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Forwarded"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%DNS_STAT_FORWARDED%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Cache Capacity"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%DNS_CACHE_SIZE% entries"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "Authoritative Answers"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%DNS_STAT_AUTH%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "TCP Connections Peak"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%DNS_STAT_TCP_PEAK%"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"type": "grid_label",
|
||||
"text": "DNS Providers"
|
||||
},
|
||||
{
|
||||
"type": "grid_value",
|
||||
"text": "%OVERVIEW_UPSTREAM_SERVERS%"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
127
docker/routlin-dash/app/pages/physicalinterfaces/action.py
Normal file
127
docker/routlin-dash/app/pages/physicalinterfaces/action.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import copy
|
||||
import os
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg, queue_command
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('physicalinterfaces', __name__)
|
||||
|
||||
_VIEW = '/view/view_physicalinterfaces'
|
||||
|
||||
_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth',
|
||||
'tun', 'tap', 'ppp', 'virbr',
|
||||
'podman', 'vnet', 'macvtap', 'fc-')
|
||||
|
||||
|
||||
def _get_system_interfaces():
|
||||
try:
|
||||
return {
|
||||
n for n in os.listdir('/sys/class/net')
|
||||
if not n.startswith(_EXCLUDE_PREFIXES)
|
||||
and os.path.exists(f'/sys/class/net/{n}/device')
|
||||
}
|
||||
except Exception:
|
||||
return set()
|
||||
|
||||
|
||||
def _valid_interface(name):
|
||||
return name in _get_system_interfaces()
|
||||
|
||||
|
||||
@bp.route('/action/physicalinterfaces_cardphysicalinterface_save', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def physicalinterfaces_cardphysicalinterface_save():
|
||||
wan = sanitize.interface_name(request.form.get('wan_interface', ''))
|
||||
lan = sanitize.interface_name(request.form.get('lan_interface', ''))
|
||||
|
||||
if not wan or not lan:
|
||||
flash('Both WAN and LAN interfaces are required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if wan == lan:
|
||||
flash('WAN and LAN interfaces must be different.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
available = _get_system_interfaces()
|
||||
for iface in (wan, lan):
|
||||
if available and iface not in available:
|
||||
flash(f"Interface '{iface}' does not exist on this system.", 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
before = copy.deepcopy(cfg.get('network_interfaces', {}))
|
||||
gen = cfg.setdefault('network_interfaces', {})
|
||||
gen['wan_interface'] = wan
|
||||
gen['lan_interface'] = lan
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
flash(save_config_with_snapshot(
|
||||
cfg, path='network_interfaces', key='global', operation='edit',
|
||||
before=before, after=copy.deepcopy(cfg['network_interfaces']),
|
||||
description='Updated network interfaces',
|
||||
cmd='core apply',
|
||||
), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/physicalinterfaces_cardinterfaceconfiguration_apply', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def physicalinterfaces_cardinterfaceconfiguration_apply():
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
iface = sanitize.interface_name(request.form.get('iface', ''))
|
||||
mtu = request.form.get('mtu', '').strip()
|
||||
mac = sanitize.mac(request.form.get('mac', ''))
|
||||
original_mtu = request.form.get('original_mtu', '').strip()
|
||||
original_mac = sanitize.mac(request.form.get('original_mac', ''))
|
||||
|
||||
if not iface:
|
||||
flash('No interface specified.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not _valid_interface(iface):
|
||||
flash(f"Interface '{iface}' does not exist on this system.", 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
mtu_int = None
|
||||
if mtu:
|
||||
mtu_int = validate.int_range(mtu, 68, 9000)
|
||||
if mtu_int is None:
|
||||
flash('MTU must be an integer between 68 and 9000.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
mac_raw = request.form.get('mac', '').strip()
|
||||
if mac_raw and not mac:
|
||||
flash('MAC address must be in the format aa:bb:cc:dd:ee:ff.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not mtu_int and not mac:
|
||||
flash('No changes specified.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
queued = False
|
||||
if mtu_int and str(mtu_int) != original_mtu:
|
||||
queue_command(f'mtu {iface} {mtu_int}')
|
||||
queued = True
|
||||
if mac and mac != original_mac:
|
||||
queue_command(f'mac {iface} {mac}')
|
||||
queued = True
|
||||
|
||||
if not queued:
|
||||
flash('No changes detected.', 'info')
|
||||
return redirect(_VIEW)
|
||||
|
||||
flash(queued_msg(action_label='Changes queued'), 'success')
|
||||
return redirect(_VIEW)
|
||||
166
docker/routlin-dash/app/pages/physicalinterfaces/content.json
Normal file
166
docker/routlin-dash/app/pages/physicalinterfaces/content.json
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
{
|
||||
"id": "view_physicalinterfaces",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Physical Interfaces"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "WAN/LAN interface assignments and per-interface settings."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Physical Interfaces",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/physicalinterfaces_cardphysicalinterface_save",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "WAN Interface",
|
||||
"name": "wan_interface",
|
||||
"input_type": "interface_picker",
|
||||
"value": "%GENERAL_WAN_INTERFACE%",
|
||||
"data": "%NETWORK_INTERFACE_DATA_JSON%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "LAN Interface",
|
||||
"name": "lan_interface",
|
||||
"input_type": "interface_picker",
|
||||
"value": "%GENERAL_LAN_INTERFACE%",
|
||||
"data": "%NETWORK_INTERFACE_DATA_JSON%"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/physicalinterfaces_cardphysicalinterface_save",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "iface-config-card",
|
||||
"label": "Interface Configuration",
|
||||
"hidden": true,
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "hidden",
|
||||
"name": "original_mtu",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "hidden",
|
||||
"name": "original_mac",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "field_row",
|
||||
"cols": 3,
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Interface",
|
||||
"name": "iface",
|
||||
"input_type": "text",
|
||||
"readonly": true,
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "MTU",
|
||||
"name": "mtu",
|
||||
"input_type": "select",
|
||||
"value": "",
|
||||
"options": [
|
||||
{
|
||||
"label": "576",
|
||||
"value": "576"
|
||||
},
|
||||
{
|
||||
"label": "1280",
|
||||
"value": "1280"
|
||||
},
|
||||
{
|
||||
"label": "1492",
|
||||
"value": "1492"
|
||||
},
|
||||
{
|
||||
"label": "1500",
|
||||
"value": "1500"
|
||||
},
|
||||
{
|
||||
"label": "4096",
|
||||
"value": "4096"
|
||||
},
|
||||
{
|
||||
"label": "9000",
|
||||
"value": "9000"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "MAC Address",
|
||||
"name": "mac",
|
||||
"input_type": "text",
|
||||
"validate": "mac",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/physicalinterfaces_cardinterfaceconfiguration_apply",
|
||||
"method": "post",
|
||||
"text": "Apply"
|
||||
},
|
||||
{
|
||||
"type": "button_secondary",
|
||||
"action": "#",
|
||||
"text": "Cancel",
|
||||
"class": "iface-config-cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "raw_html",
|
||||
"html": "<br /><br /><br /><br /><br />"
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/portforwarding/__init__.py
Normal file
0
docker/routlin-dash/app/pages/portforwarding/__init__.py
Normal file
207
docker/routlin-dash/app/pages/portforwarding/action.py
Normal file
207
docker/routlin-dash/app/pages/portforwarding/action.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import copy
|
||||
|
||||
from flask import Blueprint, request, redirect, flash
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('portforwarding', __name__)
|
||||
|
||||
VIEW = '/view/view_portforwarding'
|
||||
|
||||
_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_config_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():
|
||||
description = sanitize.text(request.form.get('description', ''))
|
||||
protocol = sanitize.filtervalue(request.form.get('protocol', ''), validate.VALID_PROTOCOLS)
|
||||
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 not protocol:
|
||||
flash(f'The configuration has not been saved because the protocol is invalid. '
|
||||
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)
|
||||
|
||||
cfg = load_config()
|
||||
cfg.setdefault('port_forwarding', []).append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = f'{entry["protocol"]}:{entry["dest_port"]}'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='port_forwarding', key=key, operation='add',
|
||||
before=None, after=entry,
|
||||
description=f'Added port forward: {key} → {entry["nat_ip"]}:{entry["nat_port"]}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('port_forwarding', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
old_enabled = items[idx].get('enabled', True)
|
||||
items[idx]['enabled'] = not old_enabled
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = f'{items[idx]["protocol"]}:{items[idx]["dest_port"]}'
|
||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='port_forwarding', key=key, operation='toggle',
|
||||
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
|
||||
description=f'{action} port forward: {key}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('port_forwarding', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
before = copy.deepcopy(items[idx])
|
||||
items[idx] = entry
|
||||
items[idx]['enabled'] = request.form.get('enabled') == 'on'
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = f'{entry["protocol"]}:{entry["dest_port"]}'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='port_forwarding', key=key, operation='edit',
|
||||
before=before, after=copy.deepcopy(items[idx]),
|
||||
description=f'Edited port forward: {key} → {entry["nat_ip"]}:{entry["nat_port"]}',
|
||||
), '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)
|
||||
|
||||
cfg = load_config()
|
||||
items = cfg.get('port_forwarding', [])
|
||||
if idx < 0 or idx >= len(items):
|
||||
flash('Entry not found.', 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
removed = items.pop(idx)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(VIEW)
|
||||
|
||||
key = f'{removed["protocol"]}:{removed["dest_port"]}'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path='port_forwarding', key=key, operation='delete',
|
||||
before=removed, after=None,
|
||||
description=f'Deleted port forward: {key}',
|
||||
), 'success')
|
||||
return redirect(VIEW)
|
||||
167
docker/routlin-dash/app/pages/portforwarding/content.json
Normal file
167
docker/routlin-dash/app/pages/portforwarding/content.json
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
{
|
||||
"id": "view_portforwarding",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Port Forwarding"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "DNAT rules that forward inbound WAN traffic to internal hosts."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"datasource": "config:port_forwarding",
|
||||
"empty_message": "No port forwarding rules configured.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Description",
|
||||
"field": "description"
|
||||
},
|
||||
{
|
||||
"label": "Protocol",
|
||||
"field": "protocol",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Ext Port",
|
||||
"field": "dest_port",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "NAT IP",
|
||||
"field": "nat_ip",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "NAT Port",
|
||||
"field": "nat_port",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Status",
|
||||
"field": "enabled",
|
||||
"render": "badge_enabled_disabled"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/edit_port_forward",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "description",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "protocol",
|
||||
"input_type": "select",
|
||||
"options": "%PROTOCOL_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"col": "dest_port",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "nat_ip",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "nat_port",
|
||||
"input_type": "text"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/delete_port_forward",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"id": "add-form",
|
||||
"label": "Add Rule",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/add_port_forward",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Description",
|
||||
"name": "description",
|
||||
"input_type": "text",
|
||||
"placeholder": "e.g. Minecraft server"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Protocol",
|
||||
"name": "protocol",
|
||||
"input_type": "select",
|
||||
"options": "%PROTOCOL_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Ext Port",
|
||||
"name": "dest_port",
|
||||
"input_type": "text",
|
||||
"validate": "port",
|
||||
"placeholder": "e.g. 25565"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "NAT IP",
|
||||
"name": "nat_ip",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"placeholder": "e.g. 192.168.1.50"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "NAT Port",
|
||||
"name": "nat_port",
|
||||
"input_type": "text",
|
||||
"validate": "port",
|
||||
"placeholder": "e.g. 25565"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/add_port_forward",
|
||||
"method": "post",
|
||||
"text": "Add Rule"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/preferences/__init__.py
Normal file
0
docker/routlin-dash/app/pages/preferences/__init__.py
Normal file
91
docker/routlin-dash/app/pages/preferences/action.py
Normal file
91
docker/routlin-dash/app/pages/preferences/action.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from flask import Blueprint, request, session, redirect, flash
|
||||
import json, bcrypt
|
||||
from auth import require_level
|
||||
from config_utils import ACCOUNTS_FILE
|
||||
import sanitize
|
||||
|
||||
bp = Blueprint('preferences', __name__)
|
||||
|
||||
|
||||
|
||||
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_login')
|
||||
|
||||
account['timezone'] = tz
|
||||
_save_accounts(data)
|
||||
|
||||
session['timezone'] = tz
|
||||
|
||||
flash('Preferences saved.', 'success')
|
||||
return redirect('/view/view_preferences')
|
||||
|
||||
|
||||
@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_login')
|
||||
|
||||
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')
|
||||
105
docker/routlin-dash/app/pages/preferences/content.json
Normal file
105
docker/routlin-dash/app/pages/preferences/content.json
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
{
|
||||
"id": "view_preferences",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "Preferences"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "Your personal account settings."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Account Details",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/save_preferences",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Email Address",
|
||||
"name": "email",
|
||||
"input_type": "text",
|
||||
"value": "%PREF_EMAIL%",
|
||||
"hint": "Contact your manager to change your email address."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Timezone",
|
||||
"name": "timezone",
|
||||
"input_type": "select",
|
||||
"value": "%PREF_TIMEZONE%",
|
||||
"options": "%TIMEZONE_OPTIONS%",
|
||||
"hint": "All timestamps will be displayed in this timezone."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/save_preferences",
|
||||
"method": "post",
|
||||
"text": "Save Preferences"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Change Password",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/change_password",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Current Password",
|
||||
"name": "current_password",
|
||||
"input_type": "password",
|
||||
"placeholder": "Current password"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "New Password",
|
||||
"name": "new_password",
|
||||
"input_type": "password",
|
||||
"placeholder": "New password"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Confirm Password",
|
||||
"name": "confirm_password",
|
||||
"input_type": "password",
|
||||
"placeholder": "Repeat new password"
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/change_password",
|
||||
"method": "post",
|
||||
"text": "Change Password"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
0
docker/routlin-dash/app/pages/vpn/__init__.py
Normal file
0
docker/routlin-dash/app/pages/vpn/__init__.py
Normal file
435
docker/routlin-dash/app/pages/vpn/action.py
Normal file
435
docker/routlin-dash/app/pages/vpn/action.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import base64
|
||||
import copy
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
from flask import Blueprint, make_response, redirect, flash, request
|
||||
from auth import require_level
|
||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
|
||||
import sanitize
|
||||
import validation as validate
|
||||
|
||||
bp = Blueprint('vpn', __name__)
|
||||
|
||||
_VIEW = '/view/view_vpn'
|
||||
_MTU_MIN = 576
|
||||
_MTU_MAX = 9000
|
||||
|
||||
|
||||
def _wg_vlan(cfg):
|
||||
return next((v for v in cfg.get('vlans', []) if v.get('is_vpn')), None)
|
||||
|
||||
|
||||
def _wg_vlan_by_name(cfg, name):
|
||||
return next((v for v in cfg.get('vlans', []) if v.get('is_vpn') and v.get('name') == name), None)
|
||||
|
||||
|
||||
def _find_peer_by_flat_idx(cfg, flat_idx):
|
||||
i = 0
|
||||
for vlan in cfg.get('vlans', []):
|
||||
if not vlan.get('is_vpn'):
|
||||
continue
|
||||
peers = vlan.get('peers', [])
|
||||
for j in range(len(peers)):
|
||||
if i == flat_idx:
|
||||
return vlan, j
|
||||
i += 1
|
||||
return None, None
|
||||
|
||||
|
||||
def _wg_iface(vlan, cfg):
|
||||
wg_vlans = [v for v in cfg.get('vlans', []) if v.get('is_vpn')]
|
||||
idx = next((i for i, v in enumerate(wg_vlans) if v is vlan), 0)
|
||||
return f'wg{idx}'
|
||||
|
||||
|
||||
def _row_index():
|
||||
try:
|
||||
return int(request.form.get('row_index', ''))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _hash_ok():
|
||||
if not verify_config_hash(request.form.get('config_hash', '')):
|
||||
flash('Configuration was modified by another session. Please refresh and try again.', 'error')
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _generate_wg_keypair():
|
||||
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding, PublicFormat, PrivateFormat, NoEncryption,
|
||||
)
|
||||
private = X25519PrivateKey.generate()
|
||||
priv_raw = private.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
|
||||
pub_raw = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||
return base64.b64encode(priv_raw).decode(), base64.b64encode(pub_raw).decode()
|
||||
|
||||
|
||||
def _server_pubkey(iface):
|
||||
try:
|
||||
with open(f'{CONFIGS_DIR}/.{iface}.pub') as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pubkey):
|
||||
info = vlan.get('vpn_information', {})
|
||||
overrides = info.get('explicit_overrides', {})
|
||||
subnet = vlan.get('subnet', '')
|
||||
mask = vlan.get('subnet_mask', '')
|
||||
network = ipaddress.IPv4Network(f'{subnet}/{mask}', strict=False)
|
||||
ident_ips = [s['ip'] for s in vlan.get('server_identities', []) if s.get('ip')]
|
||||
default = str(min((ipaddress.IPv4Address(ip) for ip in ident_ips),
|
||||
key=lambda x: x.packed[-1])) if ident_ips else str(next(network.hosts()))
|
||||
gateway = overrides.get('gateway') or default
|
||||
dns = overrides.get('dns_server') or gateway
|
||||
prefix = network.prefixlen
|
||||
mtu = overrides.get('mtu', '')
|
||||
endpoint = info.get('server_endpoint', '')
|
||||
listen_port = info.get('listen_port', 51820)
|
||||
|
||||
split_tunnel = next(
|
||||
(p.get('split_tunnel', False) for p in vlan.get('peers', []) if p.get('name') == peer_name),
|
||||
False,
|
||||
)
|
||||
allowed_ips = f'{subnet}/{prefix}' if split_tunnel else '0.0.0.0/0'
|
||||
|
||||
lines = [
|
||||
f'# Generated by {WEB_APP_DISPLAY_NAME}', '',
|
||||
'[Interface]',
|
||||
f'PrivateKey = {private_key}',
|
||||
f'Address = {peer_ip}/{prefix}',
|
||||
f'DNS = {dns}',
|
||||
]
|
||||
if mtu:
|
||||
lines.append(f'MTU = {mtu}')
|
||||
lines += ['', '[Peer]', f'PublicKey = {server_pubkey}']
|
||||
if endpoint:
|
||||
lines.append(f'Endpoint = {endpoint}:{listen_port}')
|
||||
lines += [f'AllowedIPs = {allowed_ips}', 'PersistentKeepalive = 25', '']
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _conf_response(vlan, peer_name, peer_ip, private_key):
|
||||
cfg = load_config()
|
||||
iface = _wg_iface(vlan, cfg)
|
||||
server_pub = _server_pubkey(iface)
|
||||
if not server_pub:
|
||||
flash('Peer saved. Run sudo python3 ~/routlin/core.py --apply to generate the server '
|
||||
'public key, then regenerate this peer to download the client config.', 'warning')
|
||||
return redirect(_VIEW)
|
||||
conf = _build_client_conf(vlan, peer_name, peer_ip, private_key, server_pub)
|
||||
safe = re.sub(r'[^A-Za-z0-9_\-]', '_', peer_name)
|
||||
resp = make_response(conf)
|
||||
resp.headers['Content-Type'] = 'text/plain; charset=utf-8'
|
||||
resp.headers['Content-Disposition'] = f'attachment; filename="vpn-client-{safe}.conf"'
|
||||
return resp
|
||||
|
||||
|
||||
@bp.route('/action/apply_vpn', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def apply_vpn():
|
||||
listen_port_raw = request.form.get('vpn_listen_port', '').strip()
|
||||
server_endpoint = validate.domainname(request.form.get('vpn_server_endpoint', ''))
|
||||
domain = validate.domainname(request.form.get('vpn_domain', ''))
|
||||
dns_raw = request.form.get('vpn_dns_server', '').strip()
|
||||
mtu_raw = request.form.get('vpn_mtu', '').strip()
|
||||
|
||||
if not listen_port_raw:
|
||||
flash('Listen port is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
listen_port = validate.int_range(listen_port_raw, 1, 65535)
|
||||
if listen_port is None:
|
||||
flash(f'"{listen_port_raw}" is not a valid port number (1-65535).', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
dns_server = ''
|
||||
if dns_raw:
|
||||
dns_server = validate.ip(dns_raw)
|
||||
if not dns_server:
|
||||
flash(f'"{dns_raw}" is not a valid IP address for DNS server.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
mtu = None
|
||||
if mtu_raw:
|
||||
mtu = validate.int_range(mtu_raw, _MTU_MIN, _MTU_MAX)
|
||||
if mtu is None:
|
||||
flash(f'"{mtu_raw}" is not a valid MTU (must be {_MTU_MIN}-{_MTU_MAX}).', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vpn_vlan = _wg_vlan(cfg)
|
||||
if vpn_vlan is None:
|
||||
flash('No WireGuard VLAN found in configuration.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
for v in cfg.get('vlans', []):
|
||||
if v.get('is_vpn') and v is not vpn_vlan and v.get('vpn_information', {}).get('listen_port') == listen_port:
|
||||
flash(f'Listen port {listen_port} is already used by another VPN VLAN.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
before_info = copy.deepcopy(vpn_vlan.get('vpn_information', {}))
|
||||
info = vpn_vlan.setdefault('vpn_information', {})
|
||||
info['listen_port'] = listen_port
|
||||
info['server_endpoint'] = server_endpoint
|
||||
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)
|
||||
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
vlan_name = vpn_vlan['name']
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.vpn_information', key=vlan_name, operation='edit',
|
||||
before=before_info or None, after=copy.deepcopy(info),
|
||||
description=f'Updated VPN configuration for {vlan_name}',
|
||||
), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/add_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def add_vpn_peer():
|
||||
peer_name = sanitize.name(request.form.get('peer_name', ''))
|
||||
peer_vlan_nm = request.form.get('peer_vlan', '').strip()
|
||||
peer_ip_raw = request.form.get('peer_ip', '').strip()
|
||||
split_tunnel = 'split_tunnel' in request.form
|
||||
enabled = 'enabled' in request.form
|
||||
|
||||
if not peer_name:
|
||||
flash('Peer name is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not peer_vlan_nm:
|
||||
flash('Assigned VLAN is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
peer_ip = validate.ip(peer_ip_raw)
|
||||
if not peer_ip:
|
||||
flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vpn_vlan = _wg_vlan_by_name(cfg, peer_vlan_nm)
|
||||
if vpn_vlan is None:
|
||||
flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
try:
|
||||
network = ipaddress.IPv4Network(f"{vpn_vlan['subnet']}/{vpn_vlan['subnet_mask']}", strict=False)
|
||||
if ipaddress.IPv4Address(peer_ip) not in network:
|
||||
flash(f'{peer_ip} is not within the subnet {vpn_vlan["subnet"]}/{vpn_vlan["subnet_mask"]} of {peer_vlan_nm}.', 'error')
|
||||
return redirect(_VIEW)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
peers = vpn_vlan.setdefault('peers', [])
|
||||
if any(p.get('name') == peer_name for p in peers):
|
||||
flash(f'A peer named "{peer_name}" already exists.', 'error')
|
||||
return redirect(_VIEW)
|
||||
for v in cfg.get('vlans', []):
|
||||
if not v.get('is_vpn'):
|
||||
continue
|
||||
if any(p.get('ip') == peer_ip for p in v.get('peers', [])):
|
||||
flash(f'IP address {peer_ip} is already assigned to another peer.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
private_key, public_key = _generate_wg_keypair()
|
||||
entry = {
|
||||
'name': peer_name,
|
||||
'ip': peer_ip,
|
||||
'public_key': public_key,
|
||||
'split_tunnel': split_tunnel,
|
||||
'enabled': enabled,
|
||||
}
|
||||
peers.append(entry)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{peer_vlan_nm}.peers', key=peer_name, operation='add',
|
||||
before=None, after={k: v for k, v in entry.items() if k != 'public_key'},
|
||||
description=f'Added VPN peer: {peer_name} ({peer_ip})',
|
||||
queue=True,
|
||||
)
|
||||
return _conf_response(vpn_vlan, peer_name, peer_ip, private_key)
|
||||
|
||||
|
||||
@bp.route('/action/edit_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def edit_vpn_peer():
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peer_name = sanitize.name(request.form.get('name', ''))
|
||||
split_tunnel = request.form.get('split_tunnel') in ('true', '1', 'on', 'yes')
|
||||
enabled = request.form.get('enabled') not in ('false', '0', '')
|
||||
|
||||
if not peer_name:
|
||||
flash('Peer name is required.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers = vlan.get('peers', [])
|
||||
if any(j != peer_idx and p.get('name') == peer_name for j, p in enumerate(peers)):
|
||||
flash(f'A peer named "{peer_name}" already exists.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
before = copy.deepcopy({k: peers[peer_idx].get(k) for k in ('name', 'split_tunnel', 'enabled')})
|
||||
peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
vlan_name = vlan['name']
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.peers', key=peer_name, operation='edit',
|
||||
before=before, after={'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled},
|
||||
description=f'Edited VPN peer: {peer_name}',
|
||||
), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/toggle_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def toggle_vpn_peer():
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers = vlan.get('peers', [])
|
||||
old_enabled = peers[peer_idx].get('enabled', True)
|
||||
peers[peer_idx]['enabled'] = not old_enabled
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peer_name = peers[peer_idx]['name']
|
||||
vlan_name = vlan['name']
|
||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.peers', key=peer_name, operation='toggle',
|
||||
before={'enabled': old_enabled}, after={'enabled': not old_enabled},
|
||||
description=f'{action} VPN peer: {peer_name}',
|
||||
), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/delete_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def delete_vpn_peer():
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
peers = vlan.get('peers', [])
|
||||
removed = peers.pop(peer_idx)
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
vlan_name = vlan['name']
|
||||
flash(save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.peers', key=removed['name'], operation='delete',
|
||||
before={k: removed.get(k) for k in ('name', 'ip', 'split_tunnel', 'enabled')},
|
||||
after=None,
|
||||
description=f'Deleted VPN peer: {removed["name"]}',
|
||||
), 'success')
|
||||
return redirect(_VIEW)
|
||||
|
||||
|
||||
@bp.route('/action/regenerate_vpn_peer', methods=['POST'])
|
||||
@require_level('administrator')
|
||||
def regenerate_vpn_peer():
|
||||
flat_idx = _row_index()
|
||||
if flat_idx is None:
|
||||
flash('Invalid request.', 'error')
|
||||
return redirect(_VIEW)
|
||||
if not _hash_ok():
|
||||
return redirect(_VIEW)
|
||||
|
||||
cfg = load_config()
|
||||
vlan, peer_idx = _find_peer_by_flat_idx(cfg, flat_idx)
|
||||
if vlan is None:
|
||||
flash('Peer not found.', 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
private_key, public_key = _generate_wg_keypair()
|
||||
peer = vlan['peers'][peer_idx]
|
||||
old_pub_key = peer.get('public_key', '')
|
||||
peer['public_key'] = public_key
|
||||
errors = validate.validate_config(cfg)
|
||||
if errors:
|
||||
for msg in errors:
|
||||
flash(msg, 'error')
|
||||
return redirect(_VIEW)
|
||||
|
||||
vlan_name = vlan['name']
|
||||
save_config_with_snapshot(
|
||||
cfg,
|
||||
path=f'vlans.{vlan_name}.peers', key=peer['name'], operation='regenerate',
|
||||
before={'public_key': old_pub_key}, after={'public_key': public_key},
|
||||
description=f'Regenerated keypair for VPN peer: {peer["name"]}',
|
||||
queue=True,
|
||||
)
|
||||
return _conf_response(vlan, peer['name'], peer['ip'], private_key)
|
||||
277
docker/routlin-dash/app/pages/vpn/content.json
Normal file
277
docker/routlin-dash/app/pages/vpn/content.json
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
{
|
||||
"id": "view_vpn",
|
||||
"client_requirement": "client_is_viewer+",
|
||||
"items": [
|
||||
{
|
||||
"type": "header_page_title",
|
||||
"items": [
|
||||
{
|
||||
"type": "h1",
|
||||
"text": "VPN"
|
||||
},
|
||||
{
|
||||
"type": "p",
|
||||
"text": "WireGuard peer management and server interface configuration."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"label": "Active Sessions",
|
||||
"datasource": "live:vpn_sessions",
|
||||
"empty_message": "No active VPN sessions.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Peer",
|
||||
"field": "peer_name"
|
||||
},
|
||||
{
|
||||
"label": "Tunnel IP",
|
||||
"field": "tunnel_ip",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Endpoint",
|
||||
"field": "endpoint",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Last Handshake",
|
||||
"field": "last_handshake"
|
||||
},
|
||||
{
|
||||
"label": "Received",
|
||||
"field": "rx_bytes",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Sent",
|
||||
"field": "tx_bytes",
|
||||
"class": "col-mono"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "table",
|
||||
"label": "Peers",
|
||||
"datasource": "config:vpn_peers",
|
||||
"empty_message": "No peers configured. Use Add Peer below.",
|
||||
"columns": [
|
||||
{
|
||||
"label": "Name",
|
||||
"field": "name"
|
||||
},
|
||||
{
|
||||
"label": "Assigned VLAN",
|
||||
"field": "vlan_display",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Assigned IP",
|
||||
"field": "ip",
|
||||
"class": "col-mono"
|
||||
},
|
||||
{
|
||||
"label": "Split Tunnel",
|
||||
"field": "split_tunnel"
|
||||
},
|
||||
{
|
||||
"label": "Enabled",
|
||||
"field": "enabled",
|
||||
"render": "badge_enabled_disabled"
|
||||
},
|
||||
{
|
||||
"label": "Public Key",
|
||||
"field": "pubkey_short",
|
||||
"class": "col-mono"
|
||||
}
|
||||
],
|
||||
"row_actions": [
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/edit_vpn_peer",
|
||||
"method": "inline_edit",
|
||||
"text": "Edit",
|
||||
"class": "btn-ghost btn-sm",
|
||||
"fields": [
|
||||
{
|
||||
"col": "name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname"
|
||||
},
|
||||
{
|
||||
"col": "split_tunnel",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
},
|
||||
{
|
||||
"col": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checkbox_label": "Enabled"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/regenerate_vpn_peer",
|
||||
"method": "post",
|
||||
"text": "Regen Conf",
|
||||
"class": "btn-ghost btn-sm"
|
||||
},
|
||||
{
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"action": "/action/delete_vpn_peer",
|
||||
"method": "post",
|
||||
"text": "Delete",
|
||||
"class": "btn-danger btn-sm"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "Add Peer",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/add_vpn_peer",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Name",
|
||||
"name": "peer_name",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"placeholder": "e.g. laptop",
|
||||
"hint": "Friendly name for this peer."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Assigned VLAN",
|
||||
"name": "peer_vlan",
|
||||
"input_type": "select",
|
||||
"options": "%VPN_VLAN_OPTIONS%"
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Assigned IP",
|
||||
"name": "peer_ip",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"placeholder": "e.g. 192.168.40.2",
|
||||
"hint": "Static IP assigned to this peer within the VPN subnet."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Split Tunnel",
|
||||
"name": "split_tunnel",
|
||||
"input_type": "checkbox",
|
||||
"hint": "Route only VPN subnet traffic through the tunnel. When unchecked all traffic is routed through the VPN."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Enabled",
|
||||
"name": "enabled",
|
||||
"input_type": "checkbox",
|
||||
"checked": true
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/add_vpn_peer",
|
||||
"method": "post",
|
||||
"text": "Add Peer & Download Conf"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "card",
|
||||
"label": "WireGuard Interface",
|
||||
"client_requirement": "client_is_administrator+",
|
||||
"items": [
|
||||
{
|
||||
"type": "form",
|
||||
"action": "/action/apply_vpn",
|
||||
"method": "post",
|
||||
"items": [
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Listen Port",
|
||||
"name": "vpn_listen_port",
|
||||
"input_type": "number",
|
||||
"value": "%VPN_LISTEN_PORT%",
|
||||
"min": 1024,
|
||||
"max": 65535,
|
||||
"hint": "UDP port WireGuard listens on. Must match your port forwarding rule."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Server Endpoint",
|
||||
"name": "vpn_server_endpoint",
|
||||
"input_type": "text",
|
||||
"validate": "endpoint",
|
||||
"value": "%VPN_SERVER_ENDPOINT%",
|
||||
"placeholder": "e.g. vpn.example.com",
|
||||
"hint": "Publicly reachable hostname or IP of this server, embedded in client config files."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "Domain",
|
||||
"name": "vpn_domain",
|
||||
"input_type": "text",
|
||||
"validate": "dashname",
|
||||
"value": "%VPN_DOMAIN%",
|
||||
"placeholder": "e.g. local",
|
||||
"hint": "DNS search domain pushed to VPN clients."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "DNS Override",
|
||||
"name": "vpn_dns_server",
|
||||
"input_type": "text",
|
||||
"validate": "ipv4",
|
||||
"value": "%VPN_DNS_SERVER%",
|
||||
"placeholder": "Leave blank to use gateway IP (%VPN_GATEWAY%)",
|
||||
"hint": "Explicit DNS server pushed to peers. Defaults to the gateway IP."
|
||||
},
|
||||
{
|
||||
"type": "field",
|
||||
"label": "MTU Override",
|
||||
"name": "vpn_mtu",
|
||||
"input_type": "number",
|
||||
"value": "%VPN_MTU%",
|
||||
"placeholder": "Leave blank for default",
|
||||
"hint": "Override tunnel MTU. Leave blank for the system default."
|
||||
},
|
||||
{
|
||||
"type": "button_row",
|
||||
"items": [
|
||||
{
|
||||
"type": "button_primary",
|
||||
"action": "/action/apply_vpn",
|
||||
"method": "post",
|
||||
"text": "Save"
|
||||
},
|
||||
{
|
||||
"type": "button_cancel",
|
||||
"text": "Cancel"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue