from flask import Blueprint, session, redirect, get_flashed_messages
from markupsafe import Markup
import json, re, subprocess, os, sys, html as html_mod
import sanitize
from datetime import datetime, timezone
from config_utils import core_hash
bp = Blueprint('view_page', __name__)
DATA_DIR = '/data'
CONFIGS_DIR = '/configs'
LEVEL_RANK = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}
# -- Access level --------------------------------------------------------------
def _client_level():
return LEVEL_RANK.get(session.get('access_level', 'nothing'), 0)
def _passes(req, level):
if not req:
return False
for suffix, check in (('+', lambda n, l: l >= n),
('-', lambda n, l: l <= n),
('=', lambda n, l: l == n)):
if req.endswith(suffix):
role = req[:-1].replace('client_is_', '', 1)
needed = LEVEL_RANK.get(role)
if needed is None:
print(f'[view_page] WARNING: unknown role "{role}" in client_requirement "{req}"', file=sys.stderr)
return False
return check(needed, level)
print(f'[view_page] WARNING: client_requirement "{req}" has no valid suffix (+, -, =)', file=sys.stderr)
return False
# -- File loaders --------------------------------------------------------------
def _load_json(path):
try:
with open(path) as f:
return json.load(f)
except Exception as ex:
print(f'[view_page] ERROR loading {path}: {ex}', file=sys.stderr)
return {}
def _load_core(): return _load_json(f'{CONFIGS_DIR}/core.json')
def _load_ddns(): return _load_json(f'{CONFIGS_DIR}/ddns.json')
def _load_accounts(): return _load_json(f'{DATA_DIR}/authorized_accounts.json')
def _load_css():
try:
with open(f'{DATA_DIR}/page_styles.css') as f:
return f.read()
except Exception:
return ''
# -- Shell helper --------------------------------------------------------------
def _run(cmd):
try:
r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=5)
return r.stdout.strip()
except Exception:
return ''
# -- Live data loaders ---------------------------------------------------------
def _live_dhcp_leases():
rows = []
leases_file = '/var/lib/misc/dnsmasq.leases'
try:
with open(leases_file) as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 4:
rows.append({
'hostname': parts[3] if parts[3] != '*' else '-',
'ip_address': parts[2],
'mac_address': parts[1],
'vlan_name': _vlan_name_for_ip(parts[2]),
'expires': _fmt_timestamp(int(parts[0])),
})
except Exception:
pass
return rows
def _vlan_name_for_ip(ip):
import ipaddress
for vlan in _load_core().get('vlans', []):
subnet = vlan.get('dhcp', {}).get('subnet', '')
if not subnet:
continue
try:
if ipaddress.ip_address(ip) in ipaddress.ip_network(subnet + '/24', strict=False):
return vlan.get('name', '-')
except Exception:
pass
return '-'
def _fmt_timestamp(ts):
try:
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
except Exception:
return '-'
def _live_vpn_sessions():
rows = []
out = _run('wg show all dump 2>/dev/null')
for line in out.splitlines():
parts = line.split('\t')
if len(parts) == 9:
interface, _pubkey, _psk, endpoint, allowed_ips, last_hs, rx, tx, _ka = parts
rows.append({
'peer_name': _pubkey[:16] + '...',
'interface': interface,
'tunnel_ip': allowed_ips.split(',')[0].split('/')[0] if allowed_ips else '-',
'endpoint': endpoint if endpoint != '(none)' else '-',
'last_handshake': _fmt_timestamp(int(last_hs)) if last_hs.isdigit() and last_hs != '0' else 'Never',
'rx_bytes': _fmt_bytes(int(rx)) if rx.isdigit() else '-',
'tx_bytes': _fmt_bytes(int(tx)) if tx.isdigit() else '-',
})
return rows
def _fmt_bytes(n):
for unit in ('B', 'KB', 'MB', 'GB'):
if n < 1024:
return f'{n:.1f} {unit}'
n /= 1024
return f'{n:.1f} TB'
# -- Config data loaders -------------------------------------------------------
def _config_datasource(name):
core = _load_core()
vlans = core.get('vlans', [])
if name == 'banned_ips':
return core.get('banned_ips', [])
if name == 'host_overrides':
return core.get('host_overrides', [])
if name == 'blocklists':
rows = []
for bl in core.get('blocklists', []):
row = dict(bl)
bl_path = f'{CONFIGS_DIR}/blocklists/{bl.get("save_as", "")}'
try:
with open(bl_path) as f:
row['domain_count'] = str(sum(1 for _ in f))
row['last_updated'] = _fmt_timestamp(int(os.path.getmtime(bl_path)))
except Exception:
row['domain_count'] = '-'
row['last_updated'] = '-'
rows.append(row)
return rows
if name == 'vlans':
rows = []
for v in vlans:
row = {k: v.get(k) for k in ('vlan_id', 'name', 'interface', 'dhcp', 'radius_default', 'mdns_reflection')}
row['subnet'] = v.get('dhcp', {}).get('subnet', '')
row['use_blocklists'] = json.dumps(v.get('use_blocklists', []))
rows.append(row)
return rows
if name == 'inter_vlan_exceptions':
return core.get('inter_vlan_exceptions', [])
if name == 'port_forwarding':
return core.get('port_forwarding', [])
if name == 'dhcp_reservations':
rows = []
for vlan in vlans:
for res in vlan.get('reservations', []):
row = dict(res)
row['vlan_name'] = vlan.get('name', '-')
rows.append(row)
return rows
if name == 'ddns_providers':
ddns = _load_ddns()
rows = []
for p in ddns.get('providers', []):
row = dict(p)
ptype = p.get('provider', '').lower()
if ptype == 'noip':
row['credentials'] = f"U: {p.get('username', '-')}"
elif ptype in ('cloudflare', 'duckdns'):
row['credentials'] = '(token set)' if p.get('api_token') else '(not set)'
else:
row['credentials'] = '-'
row['hostnames'] = json.dumps(p.get('hostnames', p.get('subdomains', [])))
rows.append(row)
return rows
if name == 'accounts':
rows = []
for acct in _load_accounts().get('accounts', []):
row = dict(acct)
row['account_status'] = 'active' if acct.get('hashed_password') else 'pending'
rows.append(row)
return rows
return []
# -- Live stat helpers ---------------------------------------------------------
def _get_dnsmasq_stats():
stats = {'queries': '-', 'hits': '-', 'hit_rate': '-',
'forwarded': '-', 'auth': '-', 'tcp_peak': '-'}
out = _run('journalctl -u dnsmasq -n 200 --no-pager 2>/dev/null')
for line in reversed(out.splitlines()):
if 'queries forwarded' in line:
m = re.search(r'queries forwarded (\d+)', line)
if m: stats['forwarded'] = m.group(1)
m = re.search(r'queries answered locally (\d+)', line)
if m: stats['hits'] = m.group(1)
fwd = int(stats['forwarded']) if stats['forwarded'] != '-' else 0
hit = int(stats['hits']) if stats['hits'] != '-' else 0
total = fwd + hit
stats['queries'] = str(total) if total else '-'
if total > 0:
stats['hit_rate'] = f'{hit / total * 100:.0f}%'
break
if 'auth answered' in line:
m = re.search(r'auth answered (\d+)', line)
if m and stats['auth'] == '-':
stats['auth'] = m.group(1)
if 'max TCP connections' in line:
m = re.search(r'max TCP connections (\d+)', line)
if m and stats['tcp_peak'] == '-':
stats['tcp_peak'] = m.group(1)
return stats
def _count_blocked_today():
out = _run("journalctl -u dnsmasq --since today --no-pager 2>/dev/null | grep -c 'is NXDOMAIN'")
return out or '0'
def _count_blocked_domains():
bl_dir = f'{CONFIGS_DIR}/blocklists'
try:
total = sum(
int(_run(f'wc -l < "{bl_dir}/{f}"') or 0)
for f in os.listdir(bl_dir) if f.endswith('.conf')
)
return str(total)
except Exception:
return '-'
def _bl_last_update():
bl_dir = f'{CONFIGS_DIR}/blocklists'
try:
mtime = max(
os.path.getmtime(f'{bl_dir}/{f}')
for f in os.listdir(bl_dir) if f.endswith('.conf')
)
return _fmt_timestamp(int(mtime))
except Exception:
return '-'
def _ddns_log_tail(n=50):
log_path = f'{CONFIGS_DIR}/ddns.log'
try:
with open(log_path) as f:
lines = f.readlines()
return ''.join(lines[-n:]).strip() or '(log is empty)'
except FileNotFoundError:
return '(log file not found)'
except Exception:
return '(error reading log)'
def _fmt_seconds(secs):
secs = int(secs)
if secs < 60:
return f'{secs}s'
m, s = divmod(secs, 60)
if m < 60:
return f'{m}m {s}s' if s else f'{m}m'
h, m = divmod(m, 60)
return f'{h}h {m}m' if m else f'{h}h'
def _parse_interval_to_seconds(s):
m = re.match(r'^(\d+)([mhd])$', str(s).strip())
if not m:
return None
val, unit = int(m.group(1)), m.group(2)
return val * {'m': 60, 'h': 3600, 'd': 86400}[unit]
def _parse_time_remaining(text):
for line in text.splitlines():
if 'Trigger:' in line:
total, found = 0, False
for amt, unit in re.findall(r'(\d+)\s*(day|h|min|s)\b', line):
total += int(amt) * {'day': 86400, 'h': 3600, 'min': 60, 's': 1}[unit]
found = True
if found:
return total
return None
def _read_cached_ip():
try:
best_ip, best_mtime = '', 0
for fname in os.listdir(CONFIGS_DIR):
if fname.startswith('.ddns-last-ip-'):
path = f'{CONFIGS_DIR}/{fname}'
mtime = os.path.getmtime(path)
if mtime > best_mtime:
ip = open(path).read().strip()
if ip:
best_ip, best_mtime = ip, mtime
return best_ip
except Exception:
return ''
def _public_ip_info(ddns_cfg):
"""Return (ip_str, domains_sub, next_interval_str) for stat cards."""
script = f'{CONFIGS_DIR}/ddns.py'
enabled_p = [p for p in ddns_cfg.get('providers', []) if p.get('enabled', True)]
all_hosts = []
for p in enabled_p:
all_hosts.extend(p.get('hostnames', p.get('subdomains', [])))
domains_sub = ', '.join(all_hosts)
interval_secs = _parse_interval_to_seconds(ddns_cfg.get('general', {}).get('timer_interval', ''))
next_interval = '-'
# Path 1: timer healthy and within interval -> use cached IP
if interval_secs and enabled_p:
status = _run(f'python3 {script} --status 2>/dev/null')
if status:
is_enabled = '; enabled' in status
is_active = 'active (waiting)' in status or 'active (running)' in status
remaining = _parse_time_remaining(status)
if remaining is not None:
next_interval = _fmt_seconds(remaining)
if is_enabled and is_active and remaining is not None and remaining < interval_secs:
ip = _read_cached_ip()
if ip:
return ip, domains_sub, next_interval
# Path 2: live fetch
ip = _run(f'python3 {script} --getip 2>/dev/null')
if ip and re.match(r'^\d{1,3}(\.\d{1,3}){3}$', ip):
return ip, domains_sub, next_interval
# Path 3: offline
return 'DDNS Offline', domains_sub, next_interval
def _vpn_info():
for vlan in _load_core().get('vlans', []):
if 'vpn_information' in vlan:
return vlan['vpn_information']
return {}
# -- Token collection ----------------------------------------------------------
def collect_tokens():
tokens = {}
core = _load_core()
gen = core.get('general', {})
dns = core.get('upstream_dns', {})
vlans = core.get('vlans', [])
tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-'))
tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-'))
tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false'
tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false'
tokens['GENERAL_DAILY_EXECUTE_TIME'] = str(gen.get('daily_execute_time_24hr_local', '-'))
servers = dns.get('upstream_servers', [])
tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'
tokens['DNS_CACHE_SIZE'] = str(dns.get('cache_size', '-'))
tokens['DNS_UPSTREAM_SERVERS_JSON'] = json.dumps(servers)
tokens['OVERVIEW_UPSTREAM_SERVERS'] = ', '.join(servers) or '-'
non_vpn_vlans = [v for v in vlans if 'dhcp' in v]
vlan_names = [v.get('name', '') for v in vlans]
tokens['OVERVIEW_VLAN_NAMES'] = ', '.join(vlan_names) or '-'
tokens['STAT_VLAN_COUNT'] = str(len(non_vpn_vlans))
tokens['STAT_LEASE_COUNT'] = str(len(_live_dhcp_leases()))
filter_opts = '' + ''.join(
f'' for n in vlan_names
)
tokens['VLAN_FILTER_OPTIONS'] = filter_opts
tokens['VLAN_NAMES_AS_OPTIONS'] = json.dumps([{'value': n, 'label': n} for n in vlan_names])
tokens['STAT_BANNED_IP_COUNT'] = str(sum(1 for b in core.get('banned_ips', []) if b.get('enabled', True)))
tokens['STAT_BLOCKLIST_COUNT'] = str(sum(1 for b in core.get('blocklists', []) if b.get('enabled', True)))
ddns = _load_ddns()
tokens['DDNS_TIMER_INTERVAL'] = ddns.get('general', {}).get('timer_interval', '-')
enabled_p = [p for p in ddns.get('providers', []) if p.get('enabled', True)]
tokens['STAT_DDNS_PROVIDER_COUNT'] = str(len(enabled_p))
tokens['DDNS_PROVIDER_OPTIONS'] = json.dumps([
{'value': 'noip', 'label': 'No-IP'},
{'value': 'cloudflare', 'label': 'Cloudflare'},
{'value': 'duckdns', 'label': 'DuckDNS'},
])
vpn = _vpn_info()
overrides = vpn.get('explicit_overrides', {})
tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', ''))
tokens['VPN_GATEWAY'] = str(vpn.get('gateway', ''))
tokens['VPN_DOMAIN'] = str(vpn.get('domain', ''))
tokens['VPN_DNS_SERVER'] = str(overrides.get('dns_server', ''))
tokens['VPN_MTU'] = str(overrides.get('mtu', ''))
ip_str, sub_str, next_interval = _public_ip_info(ddns)
tokens['STAT_PUBLIC_IP'] = ip_str
tokens['STAT_DDNS_HOSTNAME'] = sub_str
tokens['STAT_DDNS_NEXT_INTERVAL'] = next_interval
tokens['DDNS_LOG_TAIL'] = _ddns_log_tail()
tokens['STAT_UPTIME'] = _run('uptime -p') or '-'
tokens['STAT_NFTABLES_STATUS'] = 'Active' if _run('nft list tables 2>/dev/null').strip() else 'Inactive'
dns_stats = _get_dnsmasq_stats()
tokens['DNS_STAT_QUERIES'] = dns_stats['queries']
tokens['DNS_STAT_HITS'] = dns_stats['hits']
tokens['DNS_STAT_HIT_RATE'] = dns_stats['hit_rate']
tokens['DNS_STAT_FORWARDED'] = dns_stats['forwarded']
tokens['DNS_STAT_AUTH'] = dns_stats['auth']
tokens['DNS_STAT_TCP_PEAK'] = dns_stats['tcp_peak']
tokens['STAT_BLOCKED_TODAY'] = _count_blocked_today()
tokens['STAT_BLOCKED_DOMAINS'] = _count_blocked_domains()
tokens['STAT_BL_LAST_UPDATE'] = _bl_last_update()
tokens['PREF_EMAIL'] = session.get('email_address', '')
tokens['PREF_TIMEZONE'] = session.get('timezone', '')
blank = [{'value': '', 'label': '-- Select timezone --'}]
tokens['TIMEZONE_OPTIONS'] = json.dumps(
blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]
)
return tokens
# -- HTML helpers --------------------------------------------------------------
def e(text):
return html_mod.escape(str(text))
def apply_tokens(text, tokens):
"""Substitute %TOKEN% placeholders. Values are NOT auto-escaped - callers
that use results in HTML attribute or text context should call e() around
the expanded value (or around individual fields) as appropriate."""
return re.sub(r'%([A-Z_]+)%', lambda m: str(tokens.get(m.group(1), m.group(0))), text)
def _expand_fields(obj, tokens):
"""Recursively apply token substitution to a field-definition object.
String values that resolve to a JSON array or object are parsed back into
Python structures so they serialize correctly into data-fields JSON."""
if isinstance(obj, list):
return [_expand_fields(item, tokens) for item in obj]
if isinstance(obj, dict):
out = {}
for k, v in obj.items():
if isinstance(v, str):
s = apply_tokens(v, tokens)
if s != v and s[:1] in ('[', '{'):
try:
out[k] = json.loads(s)
continue
except Exception:
pass
out[k] = s
else:
out[k] = _expand_fields(v, tokens)
return out
return obj
# -- Content item renderers ----------------------------------------------------
def render_items(items, tokens, inherited_req=None):
level = _client_level()
parts = []
for item in items:
req = item.get('client_requirement', inherited_req)
if not _passes(req, level):
continue
parts.append(_render_item(item, tokens, req))
return ''.join(parts)
def _render_item(item, tokens, inherited_req=None):
t = item.get('type', '')
req = item.get('client_requirement', inherited_req)
if t == 'h1':
return f'
{e(apply_tokens(item.get("text", ""), tokens))}
'
if t == 'p':
text = e(apply_tokens(item.get('text', ''), tokens))
link = item.get('link')
if link:
href = e(apply_tokens(link.get('action', '#'), tokens))
ltext = e(apply_tokens(link.get('text', ''), tokens))
return f'
'
if t == 'spacer':
return ''
if t in ('button_primary', 'button_secondary', 'button_danger', 'button_ghost'):
cls_map = {
'button_primary': 'btn-primary',
'button_secondary': 'btn-secondary',
'button_danger': 'btn-danger',
'button_ghost': 'btn-ghost',
}
cls = cls_map[t]
extra = item.get('class', '')
if extra:
cls = f'{cls} {extra}'
text = e(apply_tokens(item.get('text', ''), tokens))
action = e(apply_tokens(item.get('action', '#'), tokens))
if item.get('method', '').lower() == 'post':
return (f'')
return f'{text}'
if t == 'button_cancel':
text = e(apply_tokens(item.get('text', 'Cancel'), tokens))
return f''
if t == 'page_header':
return f'
'
if t in ('section', 'auth_wrapper'):
tag = 'div'
cls = 'auth-wrapper' if t == 'auth_wrapper' else 'section'
return f'<{tag} class="{cls}">{render_items(item.get("items", []), tokens, req)}{tag}>'
if t == 'auth_card':
return f'
'
if t == 'stat_card':
label = e(apply_tokens(item.get('label', ''), tokens))
value = e(apply_tokens(item.get('value', ''), tokens))
sub = e(apply_tokens(item.get('sub', ''), tokens))
variant = item.get('variant', '')
cls = f'stat-card{(" stat-card-" + variant) if variant else ""}'
return (f'
'
f'
{label}
'
f'
{value}
'
f'
{sub}
'
f'
')
if t == 'card':
label = item.get('label', '')
id_attr = f' id="{e(item["id"])}"' if item.get('id') else ''
style = ' style="display:none"' if item.get('hidden') else ''
header = f'
{e(label)}
' if label else ''
body = render_items(item.get('items', []), tokens, req)
return f'
{header}
{body}
'
if t == 'info_bar':
variant = item.get('variant', 'info')
text = e(apply_tokens(item.get('text', ''), tokens))
return f'
{text}
'
if t == 'pre_block':
text = e(apply_tokens(item.get('text', ''), tokens))
return f'
{text}
'
if t == 'credential_fields':
psel = e(item.get('provider_select', 'provider'))
return (
f'
'
f'
'
f'
'
f'
'
f'
'
f'
'
f'
'
f'
'
f'
'
f'
'
f'
'
f'
'
)
if t == 'grid':
rows_html = ''
for row in item.get('rows', []):
cells = ''.join(_render_item(c, tokens, req) for c in row.get('cells', []))
rows_html += f'
{cells}
'
return f'
{rows_html}
'
if t == 'grid_label':
return f'
{e(apply_tokens(item.get("text", ""), tokens))}
'
if t == 'grid_value':
return f'
{e(apply_tokens(item.get("text", ""), tokens))}
'
if t == 'form':
action = e(apply_tokens(item.get('action', ''), tokens))
method = e(item.get('method', 'post'))
inner = render_items(item.get('items', []), tokens, req)
hash_field = f''
return f''
if t == 'field':
return _render_field(item, tokens)
if t == 'editable_list':
return _render_editable_list(item, tokens)
if t == 'select':
name = e(item.get('name', ''))
options = apply_tokens(item.get('options', ''), tokens)
return f''
if t == 'button_row':
inner = render_items(item.get('items', []), tokens, req)
return f'
{inner}
'
if t == 'table':
return _render_table(item, tokens, req)
return ''
def _render_field(item, tokens):
label = e(item.get('label', ''))
name = e(item.get('name', ''))
input_type = item.get('input_type', 'text')
value = apply_tokens(item.get('value', ''), tokens)
placeholder = e(apply_tokens(item.get('placeholder', ''), tokens))
hint = e(apply_tokens(item.get('hint', ''), tokens))
hint_html = f'
{hint}
' if hint else ''
if input_type == 'hidden':
return f''
if input_type == 'checkbox':
checked = 'checked' if value.lower() in ('true', '1', 'yes') else ''
return (f'
'
f'{hint_html}
')
if input_type == 'checkbox_group':
try:
opts = json.loads(apply_tokens(item.get('options', '[]'), tokens))
selected = json.loads(value) if value else []
except Exception:
opts, selected = [], []
boxes = ''.join(
f''
for o in opts
)
return (f'
'
f'
{boxes}
{hint_html}
')
if input_type == 'select':
options = item.get('options', [])
if isinstance(options, str):
try:
options = json.loads(apply_tokens(options, tokens))
except Exception:
options = []
current = apply_tokens(item.get('value', ''), tokens)
opts_html = ''.join(
f''
for o in options
)
return (f'
'
f''
f'{hint_html}
')
if input_type == 'number':
min_attr = f' min="{item["min"]}"' if 'min' in item else ''
max_attr = f' max="{item["max"]}"' if 'max' in item else ''
return (f'