Development
This commit is contained in:
parent
113328c566
commit
01a636e842
16 changed files with 388 additions and 502 deletions
|
|
@ -14,7 +14,7 @@ DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
|
||||||
DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
|
DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run'
|
||||||
DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
|
DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock'
|
||||||
DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending'
|
DASHBOARD_PENDING = f'{CONFIGS_DIR}/.dashboard-pending'
|
||||||
SNAPSHOTS_DIR = f'{CONFIGS_DIR}/.snapshots'
|
DASHBOARD_DB = f'{CONFIGS_DIR}/.dashboard-snapshots'
|
||||||
HEALTH_FILE = f'{CONFIGS_DIR}/.health'
|
HEALTH_FILE = f'{CONFIGS_DIR}/.health'
|
||||||
PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin')
|
PRODUCT_NAME = os.environ.get('PRODUCT_NAME', 'routlin')
|
||||||
DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue'
|
DASHB_TIMER_NAME = f'{PRODUCT_NAME}-dashboard-queue'
|
||||||
|
|
@ -339,153 +339,225 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'):
|
||||||
|
|
||||||
# Snapshot system ===================================================
|
# Snapshot system ===================================================
|
||||||
|
|
||||||
def _pending_uuid_set():
|
import re as _re
|
||||||
return {item[0] for item in _read_dashboard_pending()}
|
import sqlite3 as _sqlite3
|
||||||
|
|
||||||
|
|
||||||
def _find_snapshot_dependencies(path, key):
|
def _db():
|
||||||
"""Return UUIDs of still-pending snapshots that modified the same path+key."""
|
conn = _sqlite3.connect(DASHBOARD_DB)
|
||||||
try:
|
conn.row_factory = _sqlite3.Row
|
||||||
pending = _pending_uuid_set()
|
conn.execute('PRAGMA journal_mode=WAL')
|
||||||
deps = []
|
conn.executescript('''
|
||||||
for fname in sorted(os.listdir(SNAPSHOTS_DIR)):
|
CREATE TABLE IF NOT EXISTS groups (
|
||||||
if not fname.endswith('.json'):
|
uuid TEXT PRIMARY KEY,
|
||||||
continue
|
ts INTEGER NOT NULL,
|
||||||
try:
|
cmd TEXT,
|
||||||
with open(os.path.join(SNAPSHOTS_DIR, fname)) as f:
|
user TEXT,
|
||||||
snap = json.load(f)
|
parent_path TEXT NOT NULL,
|
||||||
if (snap.get('path') == path
|
item_key TEXT,
|
||||||
and snap.get('key') == str(key)
|
item_value TEXT,
|
||||||
and snap.get('uuid') in pending):
|
reverts_group TEXT
|
||||||
deps.append(snap['uuid'])
|
);
|
||||||
except Exception:
|
CREATE TABLE IF NOT EXISTS changes (
|
||||||
pass
|
group_id TEXT NOT NULL REFERENCES groups(uuid),
|
||||||
return deps
|
field TEXT NOT NULL,
|
||||||
except Exception:
|
before TEXT,
|
||||||
return []
|
after TEXT,
|
||||||
|
value_type TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (group_id, field)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_changes_group ON changes(group_id);
|
||||||
|
''')
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
def _items_match(item, ref):
|
def _py_value_type(val):
|
||||||
"""Return True if item and ref refer to the same entity by a common identifier field."""
|
if val is None: return 'null'
|
||||||
if not isinstance(item, dict) or not isinstance(ref, dict):
|
if isinstance(val, bool): return 'bool'
|
||||||
return item == ref
|
if isinstance(val, int): return 'int'
|
||||||
for field in ('ip', 'name', 'mac_address', 'host', 'id', 'address'):
|
if isinstance(val, float): return 'float'
|
||||||
if field in ref and field in item:
|
if isinstance(val, (dict, list)): return 'json'
|
||||||
return item[field] == ref[field]
|
return 'str'
|
||||||
return item == ref
|
|
||||||
|
|
||||||
|
|
||||||
def revert_snapshot_to_config(entry_uuid):
|
def _serialize_value(val):
|
||||||
"""Apply the inverse of a snapshot to config.json and queue a new pending change.
|
if val is None:
|
||||||
|
return None
|
||||||
|
if isinstance(val, bool):
|
||||||
|
return 'true' if val else 'false'
|
||||||
|
if isinstance(val, (dict, list)):
|
||||||
|
return json.dumps(val, separators=(',', ':'))
|
||||||
|
return str(val)
|
||||||
|
|
||||||
Returns (flash_message, success_bool).
|
|
||||||
|
def _deserialize_value(text, value_type):
|
||||||
|
if text is None:
|
||||||
|
return None
|
||||||
|
if value_type == 'int': return int(text)
|
||||||
|
if value_type == 'float': return float(text)
|
||||||
|
if value_type == 'bool': return text == 'true'
|
||||||
|
if value_type in ('json', 'null'): return json.loads(text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def diff_fields(before_dict, after_dict):
|
||||||
|
"""Return list of (field, before_text, after_text, value_type) for changed fields."""
|
||||||
|
bd = before_dict or {}
|
||||||
|
ad = after_dict or {}
|
||||||
|
result = []
|
||||||
|
for key in sorted(set(bd) | set(ad)):
|
||||||
|
bval = bd.get(key)
|
||||||
|
aval = ad.get(key)
|
||||||
|
if bval == aval:
|
||||||
|
continue
|
||||||
|
ref = aval if aval is not None else bval
|
||||||
|
result.append((
|
||||||
|
key,
|
||||||
|
_serialize_value(bval),
|
||||||
|
_serialize_value(aval),
|
||||||
|
_py_value_type(ref),
|
||||||
|
))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_PATH_SEG = _re.compile(r'([^\.\[]+)(?:\[([^\]=]+)=([^\]]+)\])?')
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_path(path):
|
||||||
|
"""Parse 'vlans[name=trusted].field' into [(field, sel_key, sel_val), ...]."""
|
||||||
|
return [(m.group(1), m.group(2), m.group(3)) for m in _PATH_SEG.finditer(path)]
|
||||||
|
|
||||||
|
|
||||||
|
def _nav_get(cfg, path):
|
||||||
|
"""Navigate config to the value at path."""
|
||||||
|
for field, sel_key, sel_val in _parse_path(path):
|
||||||
|
cfg = cfg[field]
|
||||||
|
if sel_key:
|
||||||
|
cfg = next(x for x in cfg if str(x.get(sel_key, '')) == str(sel_val))
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _nav_parent(cfg, path):
|
||||||
|
"""Return (parent_obj, final_key) for setting/deleting the last path segment."""
|
||||||
|
segs = _parse_path(path)
|
||||||
|
for field, sel_key, sel_val in segs[:-1]:
|
||||||
|
cfg = cfg[field]
|
||||||
|
if sel_key:
|
||||||
|
cfg = next(x for x in cfg if str(x.get(sel_key, '')) == str(sel_val))
|
||||||
|
return cfg, segs[-1][0]
|
||||||
|
|
||||||
|
|
||||||
|
def record_group(cfg, parent_path, item_key, item_value, changes, cmd,
|
||||||
|
reverts_group=None, queue=True):
|
||||||
|
"""Insert a group + changes into sqlite, save config, and queue the command.
|
||||||
|
|
||||||
|
Returns a flash message string.
|
||||||
"""
|
"""
|
||||||
snap = load_snapshot_for_uuid(entry_uuid)
|
group_uuid = str(uuid.uuid4())
|
||||||
if not snap:
|
|
||||||
return f'Snapshot not found for {entry_uuid[:8]}.', False
|
|
||||||
|
|
||||||
path = snap['path']
|
|
||||||
key = snap['key']
|
|
||||||
before = snap['before'] # original state to restore
|
|
||||||
after = snap['after'] # applied state to undo
|
|
||||||
operation = snap['operation']
|
|
||||||
|
|
||||||
if operation == 'revert':
|
|
||||||
return 'This change is already a revert; cannot revert again.', False
|
|
||||||
|
|
||||||
core = load_config()
|
|
||||||
|
|
||||||
if key == 'global':
|
|
||||||
if before is None:
|
|
||||||
core.pop(path, None)
|
|
||||||
else:
|
|
||||||
core[path] = before
|
|
||||||
else:
|
|
||||||
items = core.setdefault(path, [])
|
|
||||||
if operation == 'add':
|
|
||||||
core[path] = [x for x in items if not _items_match(x, after)]
|
|
||||||
elif operation == 'delete':
|
|
||||||
if before:
|
|
||||||
core[path].append(before)
|
|
||||||
else:
|
|
||||||
if before and after:
|
|
||||||
for i, item in enumerate(items):
|
|
||||||
if _items_match(item, after):
|
|
||||||
items[i] = before
|
|
||||||
break
|
|
||||||
|
|
||||||
msg = save_config_with_snapshot(
|
|
||||||
core, path=path, key=key, operation='revert',
|
|
||||||
before=after, after=before,
|
|
||||||
description=f"Reverted: {snap.get('description', '')}",
|
|
||||||
cmd=snap.get('cmd', 'core apply'),
|
|
||||||
reverts=entry_uuid,
|
|
||||||
)
|
|
||||||
return msg or 'Reverted.', True
|
|
||||||
|
|
||||||
|
|
||||||
def load_snapshot_for_uuid(entry_uuid):
|
|
||||||
"""Return the snapshot dict for the given UUID, or None if not found."""
|
|
||||||
try:
|
|
||||||
for fname in os.listdir(SNAPSHOTS_DIR):
|
|
||||||
if fname.endswith(f'-{entry_uuid}.json'):
|
|
||||||
with open(os.path.join(SNAPSHOTS_DIR, fname)) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_config_with_snapshot(new_config, path, key, operation, before, after,
|
|
||||||
description='', cmd='core apply', queue=True, reverts=None):
|
|
||||||
"""
|
|
||||||
Write a .snapshots/{ts}-{uuid}.json file, save new_config to disk, and
|
|
||||||
optionally create a pending queue entry. Returns a flash message string.
|
|
||||||
|
|
||||||
queue=False: skips queueing and records the change directly in
|
|
||||||
.dashboard-done so it appears in Change History without a pending step.
|
|
||||||
"""
|
|
||||||
entry_uuid = str(uuid.uuid4())
|
|
||||||
entry_ts = int(datetime.now().timestamp())
|
entry_ts = int(datetime.now().timestamp())
|
||||||
current_user = session.get('email_address', 'unknown')
|
current_user = session.get('email_address', 'unknown')
|
||||||
|
|
||||||
depends_on = _find_snapshot_dependencies(path, key)
|
conn = _db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO groups '
|
||||||
|
'(uuid,ts,cmd,user,parent_path,item_key,item_value,reverts_group) '
|
||||||
|
'VALUES (?,?,?,?,?,?,?,?)',
|
||||||
|
(group_uuid, entry_ts, cmd, current_user,
|
||||||
|
parent_path, item_key, item_value, reverts_group)
|
||||||
|
)
|
||||||
|
for field, before, after, value_type in changes:
|
||||||
|
conn.execute(
|
||||||
|
'INSERT INTO changes (group_id,field,before,after,value_type) '
|
||||||
|
'VALUES (?,?,?,?,?)',
|
||||||
|
(group_uuid, field, before, after, value_type)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
os.makedirs(SNAPSHOTS_DIR, exist_ok=True)
|
save_config(cfg)
|
||||||
snapshot = {
|
|
||||||
'uuid': entry_uuid,
|
|
||||||
'ts': entry_ts,
|
|
||||||
'cmd': cmd,
|
|
||||||
'user': current_user,
|
|
||||||
'operation': operation,
|
|
||||||
'description': description,
|
|
||||||
'path': path,
|
|
||||||
'key': str(key),
|
|
||||||
'before': before,
|
|
||||||
'after': after,
|
|
||||||
'depends_on': depends_on,
|
|
||||||
'reverts': reverts,
|
|
||||||
}
|
|
||||||
with open(os.path.join(SNAPSHOTS_DIR, f'{entry_ts}-{entry_uuid}.json'), 'w') as f:
|
|
||||||
json.dump(snapshot, f, indent=2)
|
|
||||||
|
|
||||||
save_config(new_config)
|
|
||||||
|
|
||||||
if not queue:
|
if not queue:
|
||||||
with open(DASHBOARD_DONE, 'a') as f:
|
with open(DASHBOARD_DONE, 'a') as f:
|
||||||
f.write(f'{entry_uuid} {entry_ts}\n')
|
f.write(f'{group_uuid} {entry_ts}\n')
|
||||||
return 'Saved.'
|
return 'Saved.'
|
||||||
|
|
||||||
if _apply_changes_immediately():
|
if _apply_changes_immediately():
|
||||||
with open(DASHBOARD_QUEUE, 'a') as f:
|
with open(DASHBOARD_QUEUE, 'a') as f:
|
||||||
f.write(f'{entry_uuid} {entry_ts} [{cmd}] ({current_user})\n')
|
f.write(f'{group_uuid} {entry_ts} [{cmd}] ({current_user})\n')
|
||||||
_trim_if_needed()
|
_trim_if_needed()
|
||||||
else:
|
else:
|
||||||
_queue_pending_presigned(cmd, entry_uuid, entry_ts)
|
_queue_pending_presigned(cmd, group_uuid, entry_ts)
|
||||||
|
|
||||||
return _build_timing_msg(entry_ts)
|
return _build_timing_msg(entry_ts)
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_groups():
|
||||||
|
"""Return list of (group_dict, [change_dicts]) sorted newest first."""
|
||||||
|
conn = _db()
|
||||||
|
try:
|
||||||
|
gs = conn.execute('SELECT * FROM groups ORDER BY ts DESC').fetchall()
|
||||||
|
result = []
|
||||||
|
for g in gs:
|
||||||
|
cs = conn.execute(
|
||||||
|
'SELECT * FROM changes WHERE group_id=? ORDER BY field', (g['uuid'],)
|
||||||
|
).fetchall()
|
||||||
|
result.append((dict(g), [dict(c) for c in cs]))
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def revert_group(group_uuid):
|
||||||
|
"""Revert a change group. Returns (flash_message, success_bool)."""
|
||||||
|
conn = _db()
|
||||||
|
try:
|
||||||
|
g = conn.execute('SELECT * FROM groups WHERE uuid=?', (group_uuid,)).fetchone()
|
||||||
|
if not g:
|
||||||
|
return f'Snapshot not found for {group_uuid[:8]}.', False
|
||||||
|
g = dict(g)
|
||||||
|
changes = [dict(c) for c in conn.execute(
|
||||||
|
'SELECT * FROM changes WHERE group_id=?', (group_uuid,)
|
||||||
|
).fetchall()]
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if g['reverts_group']:
|
||||||
|
return 'Cannot revert a revert.', False
|
||||||
|
|
||||||
|
cfg = load_config()
|
||||||
|
parent_path = g['parent_path']
|
||||||
|
item_key = g['item_key']
|
||||||
|
item_value = g['item_value']
|
||||||
|
|
||||||
|
all_before_null = all(c['before'] is None for c in changes)
|
||||||
|
all_after_null = all(c['after'] is None for c in changes)
|
||||||
|
|
||||||
|
if all_before_null:
|
||||||
|
parent_obj, lst_key = _nav_parent(cfg, parent_path)
|
||||||
|
parent_obj[lst_key] = [
|
||||||
|
x for x in parent_obj[lst_key]
|
||||||
|
if str(x.get(item_key, '')) != str(item_value)
|
||||||
|
]
|
||||||
|
elif all_after_null:
|
||||||
|
item = {c['field']: _deserialize_value(c['before'], c['value_type']) for c in changes}
|
||||||
|
_nav_get(cfg, parent_path).append(item)
|
||||||
|
else:
|
||||||
|
item_path = f'{parent_path}[{item_key}={item_value}]' if item_key else parent_path
|
||||||
|
for c in changes:
|
||||||
|
parent_obj, field = _nav_parent(cfg, f'{item_path}.{c["field"]}')
|
||||||
|
if c['before'] is None:
|
||||||
|
parent_obj.pop(field, None)
|
||||||
|
else:
|
||||||
|
parent_obj[field] = _deserialize_value(c['before'], c['value_type'])
|
||||||
|
|
||||||
|
inv = [(c['field'], c['after'], c['before'], c['value_type']) for c in changes]
|
||||||
|
msg = record_group(cfg, parent_path, item_key, item_value, inv,
|
||||||
|
g['cmd'], reverts_group=group_uuid)
|
||||||
|
return msg, True
|
||||||
|
|
||||||
|
|
||||||
# Misc ==============================================================
|
# Misc ==============================================================
|
||||||
|
|
||||||
def run_apply():
|
def run_apply():
|
||||||
|
|
|
||||||
|
|
@ -92,40 +92,41 @@ def passes(req, level):
|
||||||
|
|
||||||
# Snapshot helpers ====================================================
|
# Snapshot helpers ====================================================
|
||||||
|
|
||||||
def snap_text(val):
|
def build_snap_val(changes):
|
||||||
"""Return the plain-text representation of a snapshot before/after value."""
|
"""Return a brief summary of changed field names for the history table cell."""
|
||||||
if val is None:
|
if not changes:
|
||||||
return ''
|
return ''
|
||||||
if isinstance(val, dict) and len(val) == 1:
|
fields = [c['field'] for c in changes]
|
||||||
k, v = next(iter(val.items()))
|
if len(fields) <= 2:
|
||||||
return f'{k}: {v}'
|
return e(', '.join(fields))
|
||||||
if isinstance(val, (dict, list)):
|
return e(f'{fields[0]}, {fields[1]} (+{len(fields) - 2} more)')
|
||||||
return json.dumps(val, separators=(',', ':'))
|
|
||||||
return str(val)
|
|
||||||
|
|
||||||
|
|
||||||
def build_snap_val(val):
|
def snap_expand_row(changes, colspan):
|
||||||
"""Return truncated escaped HTML for a snapshot before/after table cell."""
|
"""Return a hidden <tr> with a per-field change table."""
|
||||||
text = snap_text(val)
|
if not changes:
|
||||||
if not text:
|
|
||||||
return ''
|
return ''
|
||||||
trunc = (text[:23] + '…') if len(text) > 24 else text
|
rows = ''
|
||||||
return e(trunc)
|
for c in changes:
|
||||||
|
bval = c['before'] if c['before'] is not None else ''
|
||||||
|
aval = c['after'] if c['after'] is not None else ''
|
||||||
def snap_expand_row(before_val, after_val, colspan):
|
rows += (
|
||||||
"""Return a hidden <tr> that expands with full before/after content."""
|
'<tr>'
|
||||||
def box(label, val):
|
f'<td class="snap-expand-field">{e(c["field"])}</td>'
|
||||||
text = snap_text(val) if val is not None else ''
|
f'<td class="snap-expand-val">{e(bval) if bval else "<em>(none)</em>"}</td>'
|
||||||
if isinstance(val, (dict, list)):
|
f'<td class="snap-expand-val">{e(aval) if aval else "<em>(none)</em>"}</td>'
|
||||||
text = json.dumps(val, indent=2)
|
'</tr>'
|
||||||
body = e(text) if text else '<em class="snap-expand-none">(none)</em>'
|
|
||||||
return (
|
|
||||||
'<div class="snap-expand-col">'
|
|
||||||
f'<span class="snap-expand-label">{label}</span>'
|
|
||||||
f'<pre class="snap-expand-pre">{body}</pre></div>'
|
|
||||||
)
|
)
|
||||||
inner = f'<div class="snap-expand-cols">{box("Before", before_val)}{box("After", after_val)}</div>'
|
inner = (
|
||||||
|
'<table class="snap-expand-table">'
|
||||||
|
'<thead><tr>'
|
||||||
|
'<th class="snap-expand-th">Field</th>'
|
||||||
|
'<th class="snap-expand-th">Before</th>'
|
||||||
|
'<th class="snap-expand-th">After</th>'
|
||||||
|
'</tr></thead>'
|
||||||
|
f'<tbody>{rows}</tbody>'
|
||||||
|
'</table>'
|
||||||
|
)
|
||||||
return f'<tr hidden><td colspan="{colspan}" class="snap-expand-cell">{inner}</td></tr>'
|
return f'<tr hidden><td colspan="{colspan}" class="snap-expand-cell">{inner}</td></tr>'
|
||||||
|
|
||||||
# Form helpers ========================================================
|
# Form helpers ========================================================
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import os
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import Blueprint, request, redirect, flash, session
|
from flask import Blueprint, request, redirect, flash, session
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import (flush_pending_to_queue, get_dashboard_pending,
|
from config_utils import (flush_pending_to_queue, get_dashboard_pending,
|
||||||
revert_snapshot_to_config, queued_msg,
|
revert_group, queued_msg,
|
||||||
SNAPSHOTS_DIR, DASHBOARD_PENDING)
|
DASHBOARD_PENDING, _db)
|
||||||
|
|
||||||
_PAGE = Path(__file__).parent.name
|
_PAGE = Path(__file__).parent.name
|
||||||
|
|
||||||
|
|
@ -40,7 +39,7 @@ def history_revert():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
succeeded, failed = 0, 0
|
succeeded, failed = 0, 0
|
||||||
for uuid in selected_uuids:
|
for uuid in selected_uuids:
|
||||||
msg, ok = revert_snapshot_to_config(uuid)
|
msg, ok = revert_group(uuid)
|
||||||
if ok:
|
if ok:
|
||||||
succeeded += 1
|
succeeded += 1
|
||||||
else:
|
else:
|
||||||
|
|
@ -60,14 +59,15 @@ def history_clear():
|
||||||
flash('No items selected.', 'info')
|
flash('No items selected.', 'info')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
count = 0
|
count = 0
|
||||||
for fname in os.listdir(SNAPSHOTS_DIR):
|
conn = _db()
|
||||||
if not fname.endswith('.json'):
|
try:
|
||||||
continue
|
for uid in selected_uuids:
|
||||||
if any(fname.endswith(f'-{uuid}.json') for uuid in selected_uuids):
|
conn.execute('DELETE FROM changes WHERE group_id=?', (uid,))
|
||||||
fpath = os.path.join(SNAPSHOTS_DIR, fname)
|
result = conn.execute('DELETE FROM groups WHERE uuid=?', (uid,))
|
||||||
if os.path.isfile(fpath):
|
count += result.rowcount
|
||||||
os.remove(fpath)
|
conn.commit()
|
||||||
count += 1
|
finally:
|
||||||
|
conn.close()
|
||||||
plural = 's' if count != 1 else ''
|
plural = 's' if count != 1 else ''
|
||||||
flash(f'{count} history record{plural} cleared.', 'success')
|
flash(f'{count} history record{plural} cleared.', 'success')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -56,12 +56,8 @@ def addip_add():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg,
|
flash(record_group(cfg, 'banned_ips', 'ip', ip, changes, 'core apply'), 'success')
|
||||||
path='banned_ips', key=ip, operation='add',
|
|
||||||
before=None, after=entry,
|
|
||||||
description=f'Added banned IP: {ip}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -82,6 +78,7 @@ def table_toggle():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
old_enabled = items[idx].get('enabled', True)
|
old_enabled = items[idx].get('enabled', True)
|
||||||
|
before = copy.deepcopy(items[idx])
|
||||||
items[idx]['enabled'] = not old_enabled
|
items[idx]['enabled'] = not old_enabled
|
||||||
errors = validate.validate_config(cfg)
|
errors = validate.validate_config(cfg)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
@ -89,13 +86,9 @@ def table_toggle():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
ip = items[idx]['ip']
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, items[idx])
|
||||||
cfg,
|
flash(record_group(cfg, 'banned_ips', 'ip', ip, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -130,12 +123,8 @@ def table_edit():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, items[idx])
|
||||||
cfg,
|
flash(record_group(cfg, 'banned_ips', 'ip', ip, changes, 'core apply'), 'success')
|
||||||
path='banned_ips', key=ip, operation='edit',
|
|
||||||
before=before, after=copy.deepcopy(items[idx]),
|
|
||||||
description=f'Edited banned IP: {ip}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -162,10 +151,6 @@ def table_delete():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(removed, None)
|
||||||
cfg,
|
flash(record_group(cfg, 'banned_ips', 'ip', removed['ip'], changes, 'core apply'), 'success')
|
||||||
path='banned_ips', key=removed['ip'], operation='delete',
|
|
||||||
before=removed, after=None,
|
|
||||||
description=f'Deleted banned IP: {removed["ip"]}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
import os
|
import os
|
||||||
from flask import Blueprint, request, redirect, flash, send_file, abort
|
from flask import Blueprint, request, redirect, flash, send_file, abort
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, verify_config_hash, save_config_with_snapshot, CONFIGS_DIR
|
from config_utils import load_config, verify_config_hash, record_group, diff_fields, CONFIGS_DIR
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -49,13 +49,8 @@ def addaccount_add():
|
||||||
|
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
cfg.setdefault('ddns', {}).setdefault('providers', []).append(entry)
|
cfg.setdefault('ddns', {}).setdefault('providers', []).append(entry)
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg, path='ddns', key=description, operation='add',
|
flash(record_group(cfg, 'ddns.providers', 'description', description, changes, 'ddns update', queue=False), 'success')
|
||||||
before=None, after=copy.deepcopy(entry),
|
|
||||||
description=f'Added DDNS provider: {description}',
|
|
||||||
cmd='ddns update',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,13 +96,8 @@ def accounts_edit():
|
||||||
entry['api_token'] = request.form.get('api_token', '').strip()
|
entry['api_token'] = request.form.get('api_token', '').strip()
|
||||||
|
|
||||||
providers[row_index] = entry
|
providers[row_index] = entry
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, entry)
|
||||||
cfg, path='ddns', key=description, operation='edit',
|
flash(record_group(cfg, 'ddns.providers', 'description', description, changes, 'ddns update', queue=False), 'success')
|
||||||
before=before, after=copy.deepcopy(entry),
|
|
||||||
description=f'Edited DDNS provider: {description}',
|
|
||||||
cmd='ddns update',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -133,13 +123,8 @@ def accounts_delete():
|
||||||
before = copy.deepcopy(providers[row_index])
|
before = copy.deepcopy(providers[row_index])
|
||||||
description = before.get('description', str(row_index))
|
description = before.get('description', str(row_index))
|
||||||
del providers[row_index]
|
del providers[row_index]
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, None)
|
||||||
cfg, path='ddns', key=description, operation='delete',
|
flash(record_group(cfg, 'ddns.providers', 'description', description, changes, 'ddns update', queue=False), 'success')
|
||||||
before=before, after=None,
|
|
||||||
description=f'Deleted DDNS provider: {description}',
|
|
||||||
cmd='ddns update',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -163,12 +148,8 @@ def ipcheckinterval_save():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
|
before = copy.deepcopy(cfg.get('ddns', {}).get('general', {}))
|
||||||
cfg.setdefault('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval
|
cfg.setdefault('ddns', {}).setdefault('general', {})['timer_interval'] = timer_interval
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['ddns']['general'])
|
||||||
cfg, path='ddns', key='general', operation='edit',
|
flash(record_group(cfg, 'ddns.general', None, None, changes, 'core apply'), 'success')
|
||||||
before=before, after=copy.deepcopy(cfg['ddns']['general']),
|
|
||||||
description='Updated DDNS check interval',
|
|
||||||
cmd='core apply',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -191,13 +172,8 @@ def ipcheckservices_save():
|
||||||
services = [{'type': 'http', 'url': u} for u in http_services]
|
services = [{'type': 'http', 'url': u} for u in http_services]
|
||||||
services += [{'type': 'dig', 'url': u} for u in dig_services]
|
services += [{'type': 'dig', 'url': u} for u in dig_services]
|
||||||
cfg.setdefault('ddns', {})['ip_check_services'] = services
|
cfg.setdefault('ddns', {})['ip_check_services'] = services
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields({'ip_check_services': before}, {'ip_check_services': services})
|
||||||
cfg, path='ddns', key='ip_check_services', operation='edit',
|
flash(record_group(cfg, 'ddns', None, None, changes, 'ddns update', queue=False), 'success')
|
||||||
before=before, after=copy.deepcopy(services),
|
|
||||||
description='Updated DDNS IP check services',
|
|
||||||
cmd='ddns update',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -220,13 +196,8 @@ def logging_save():
|
||||||
'log_max_kb': log_max_kb,
|
'log_max_kb': log_max_kb,
|
||||||
'log_errors_only': log_errors_only,
|
'log_errors_only': log_errors_only,
|
||||||
})
|
})
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['ddns']['general'])
|
||||||
cfg, path='ddns', key='general', operation='edit',
|
flash(record_group(cfg, 'ddns.general', None, None, changes, 'ddns update', queue=False), 'success')
|
||||||
before=before, after=copy.deepcopy(cfg['ddns']['general']),
|
|
||||||
description='Updated DDNS logging settings',
|
|
||||||
cmd='ddns update',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import ipaddress
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -114,12 +114,8 @@ def addreservation_add():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg,
|
flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', mac, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -142,6 +138,7 @@ def reservations_toggle():
|
||||||
|
|
||||||
res = vlans[vi]['reservations'][ri]
|
res = vlans[vi]['reservations'][ri]
|
||||||
old_enabled = res.get('enabled', True)
|
old_enabled = res.get('enabled', True)
|
||||||
|
before = copy.deepcopy(res)
|
||||||
res['enabled'] = not old_enabled
|
res['enabled'] = not old_enabled
|
||||||
errors = validate.validate_config(cfg)
|
errors = validate.validate_config(cfg)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
@ -150,13 +147,8 @@ def reservations_toggle():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
vlan_name = vlans[vi]['name']
|
vlan_name = vlans[vi]['name']
|
||||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
changes = diff_fields(before, res)
|
||||||
flash(save_config_with_snapshot(
|
flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', res['mac'], changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -211,12 +203,8 @@ def reservations_edit():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
vlan_name = vlans[vi]['name']
|
vlan_name = vlans[vi]['name']
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, res)
|
||||||
cfg,
|
flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', mac, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -245,10 +233,6 @@ def reservations_delete():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(removed, None)
|
||||||
cfg,
|
flash(record_group(cfg, f'vlans[name={vlan_name}].reservations', 'mac', removed['mac'], changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
import re
|
import re
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash, queued_msg
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -78,12 +78,8 @@ def blocklists_delete():
|
||||||
for msg in errors:
|
for msg in errors:
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, None)
|
||||||
cfg, path='dns_blocking', key=name, operation='delete',
|
flash(record_group(cfg, 'dns_blocking.blocklists', 'name', name, changes, 'core apply', queue=False), 'success')
|
||||||
before=before, after=None,
|
|
||||||
description=f'Deleted blocklist: {name}',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -120,12 +116,8 @@ def blocklists_edit():
|
||||||
for msg in errors:
|
for msg in errors:
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, items[idx])
|
||||||
cfg, path='dns_blocking', key=fields['name'], operation='edit',
|
flash(record_group(cfg, 'dns_blocking.blocklists', 'name', fields['name'], changes, 'core apply', queue=False), 'success')
|
||||||
before=before, after=copy.deepcopy(items[idx]),
|
|
||||||
description=f'Edited blocklist: {fields["name"]}',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -159,12 +151,8 @@ def addblocklist_add():
|
||||||
for msg in errors:
|
for msg in errors:
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg, path='dns_blocking', key=fields['name'], operation='add',
|
flash(record_group(cfg, 'dns_blocking.blocklists', 'name', fields['name'], changes, 'core apply', queue=False), 'success')
|
||||||
before=None, after=copy.deepcopy(entry),
|
|
||||||
description=f'Added blocklist: {fields["name"]}',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -184,12 +172,8 @@ def blocklistrefresh_save():
|
||||||
cfg = load_config()
|
cfg = load_config()
|
||||||
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
|
before = copy.deepcopy(cfg.get('dns_blocking', {}).get('general', {}))
|
||||||
cfg.setdefault('dns_blocking', {}).setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time
|
cfg.setdefault('dns_blocking', {}).setdefault('general', {})['daily_execute_time_24hr_local'] = daily_execute_time
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['dns_blocking']['general'])
|
||||||
cfg, path='dns_blocking', key='general', operation='edit',
|
flash(record_group(cfg, 'dns_blocking.general', None, None, changes, 'core apply'), 'success')
|
||||||
before=before, after=copy.deepcopy(cfg['dns_blocking']['general']),
|
|
||||||
description='Updated daily blocklist refresh time',
|
|
||||||
cmd='core apply',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -226,10 +210,6 @@ def logging_save():
|
||||||
for msg in errors:
|
for msg in errors:
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['dns_blocking']['general'])
|
||||||
cfg, path='dns_blocking', key='general', operation='edit',
|
flash(record_group(cfg, 'dns_blocking.general', None, None, changes, 'core apply', queue=False), 'success')
|
||||||
before=before, after=copy.deepcopy(cfg['dns_blocking']['general']),
|
|
||||||
description='Updated DNS blocking log settings',
|
|
||||||
queue=False,
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from pathlib import Path
|
||||||
import copy
|
import copy
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -50,12 +50,8 @@ def upstreamdns_save():
|
||||||
for msg in errors:
|
for msg in errors:
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['upstream_dns'])
|
||||||
cfg, path='upstream_dns', key='global', operation='edit',
|
flash(record_group(cfg, 'upstream_dns', None, None, changes, 'core apply'), 'success')
|
||||||
before=before, after=copy.deepcopy(cfg['upstream_dns']),
|
|
||||||
description='Updated upstream DNS servers',
|
|
||||||
cmd='core apply',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -84,10 +80,6 @@ def dnsforwarding_save():
|
||||||
for msg in errors:
|
for msg in errors:
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['upstream_dns'])
|
||||||
cfg, path='upstream_dns', key='global', operation='edit',
|
flash(record_group(cfg, 'upstream_dns', None, None, changes, 'core apply'), 'success')
|
||||||
before=before, after=copy.deepcopy(cfg['upstream_dns']),
|
|
||||||
description='Updated DNS cache size',
|
|
||||||
cmd='core apply',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import ipaddress
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -74,12 +74,8 @@ def addoverride_add():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg,
|
flash(record_group(cfg, 'host_overrides', 'host', host, changes, 'core apply'), 'success')
|
||||||
path='host_overrides', key=host, operation='add',
|
|
||||||
before=None, after=entry,
|
|
||||||
description=f'Added host override: {host} → {ip}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -100,6 +96,7 @@ def table_toggle():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
old_enabled = items[idx].get('enabled', True)
|
old_enabled = items[idx].get('enabled', True)
|
||||||
|
before = copy.deepcopy(items[idx])
|
||||||
items[idx]['enabled'] = not old_enabled
|
items[idx]['enabled'] = not old_enabled
|
||||||
errors = validate.validate_config(cfg)
|
errors = validate.validate_config(cfg)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
@ -107,13 +104,9 @@ def table_toggle():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
host = items[idx]['host']
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, items[idx])
|
||||||
cfg,
|
flash(record_group(cfg, 'host_overrides', 'host', host, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,12 +147,8 @@ def table_edit():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, items[idx])
|
||||||
cfg,
|
flash(record_group(cfg, 'host_overrides', 'host', host, changes, 'core apply'), 'success')
|
||||||
path='host_overrides', key=host, operation='edit',
|
|
||||||
before=before, after=copy.deepcopy(items[idx]),
|
|
||||||
description=f'Edited host override: {host} → {ip}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -186,10 +175,6 @@ def table_delete():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(removed, None)
|
||||||
cfg,
|
flash(record_group(cfg, 'host_overrides', 'host', removed['host'], changes, 'core apply'), 'success')
|
||||||
path='host_overrides', key=removed['host'], operation='delete',
|
|
||||||
before=removed, after=None,
|
|
||||||
description=f'Deleted host override: {removed["host"]}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -73,10 +73,6 @@ def _parse_entry():
|
||||||
}, None
|
}, 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/intervlan/addexception_add', methods=['POST'])
|
@bp.route('/action/intervlan/addexception_add', methods=['POST'])
|
||||||
@require_level('administrator')
|
@require_level('administrator')
|
||||||
|
|
@ -95,13 +91,9 @@ def addexception_add():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = _entry_key(entry)
|
src = entry.get('src_ip_or_subnet', '')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg,
|
flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success')
|
||||||
path='inter_vlan_exceptions', key=key, operation='add',
|
|
||||||
before=None, after=entry,
|
|
||||||
description=f'Added inter-VLAN rule: {key}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -122,6 +114,7 @@ def table_toggle():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
old_enabled = items[idx].get('enabled', True)
|
old_enabled = items[idx].get('enabled', True)
|
||||||
|
before = copy.deepcopy(items[idx])
|
||||||
items[idx]['enabled'] = not old_enabled
|
items[idx]['enabled'] = not old_enabled
|
||||||
errors = validate.validate_config(cfg)
|
errors = validate.validate_config(cfg)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
@ -129,14 +122,9 @@ def table_toggle():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = _entry_key(items[idx])
|
src = items[idx].get('src_ip_or_subnet', '')
|
||||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
changes = diff_fields(before, items[idx])
|
||||||
flash(save_config_with_snapshot(
|
flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -169,13 +157,9 @@ def table_edit():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = _entry_key(entry)
|
src = items[idx].get('src_ip_or_subnet', '')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, items[idx])
|
||||||
cfg,
|
flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -202,11 +186,7 @@ def table_delete():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = _entry_key(removed)
|
src = removed.get('src_ip_or_subnet', '')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(removed, None)
|
||||||
cfg,
|
flash(record_group(cfg, 'inter_vlan_exceptions', 'src_ip_or_subnet', src, changes, 'core apply'), 'success')
|
||||||
path='inter_vlan_exceptions', key=key, operation='delete',
|
|
||||||
before=removed, after=None,
|
|
||||||
description=f'Deleted inter-VLAN rule: {key}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -38,10 +38,6 @@ def settings_apply():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['mdns_reflection'])
|
||||||
cfg,
|
flash(record_group(cfg, 'mdns_reflection', None, None, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import json
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -13,9 +13,6 @@ _PAGE = Path(__file__).parent.name
|
||||||
|
|
||||||
bp = Blueprint(_PAGE, __name__)
|
bp = Blueprint(_PAGE, __name__)
|
||||||
|
|
||||||
_VLAN_FIELDS = ['name', 'vlan_id', 'is_vpn', 'subnet', 'subnet_mask', 'dnsmasq_log_queries',
|
|
||||||
'radius_default', 'mdns_reflection', 'use_blocklists']
|
|
||||||
|
|
||||||
|
|
||||||
def _row_index():
|
def _row_index():
|
||||||
try:
|
try:
|
||||||
|
|
@ -205,12 +202,8 @@ def addvlan_add():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg,
|
flash(record_group(cfg, 'vlans', 'name', name, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -404,7 +397,7 @@ def vlans_edit():
|
||||||
flash('No changes were made.', 'info')
|
flash('No changes were made.', 'info')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
before = {k: existing.get(k) for k in _VLAN_FIELDS}
|
before = copy.deepcopy(existing)
|
||||||
existing.update({
|
existing.update({
|
||||||
'name': name,
|
'name': name,
|
||||||
'vlan_id': vlan_id,
|
'vlan_id': vlan_id,
|
||||||
|
|
@ -438,12 +431,8 @@ def vlans_edit():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, existing)
|
||||||
cfg,
|
flash(record_group(cfg, 'vlans', 'name', name, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -470,11 +459,6 @@ def vlans_delete():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(removed, None)
|
||||||
cfg,
|
flash(record_group(cfg, 'vlans', 'name', removed['name'], changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import os
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, queued_msg, queue_command
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash, queued_msg, queue_command
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -66,12 +66,8 @@ def physicalinterface_save():
|
||||||
for msg in errors:
|
for msg in errors:
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, cfg['network_interfaces'])
|
||||||
cfg, path='network_interfaces', key='global', operation='edit',
|
flash(record_group(cfg, 'network_interfaces', None, None, changes, 'core apply'), 'success')
|
||||||
before=before, after=copy.deepcopy(cfg['network_interfaces']),
|
|
||||||
description='Updated network interfaces',
|
|
||||||
cmd='core apply',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import copy
|
||||||
|
|
||||||
from flask import Blueprint, request, redirect, flash
|
from flask import Blueprint, request, redirect, flash
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -91,13 +91,9 @@ def addrule_add():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = f'{entry["protocol"]}:{entry["dest_port"]}'
|
dest_port = entry.get('dest_port', '')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg,
|
flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,6 +114,7 @@ def rules_toggle():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
old_enabled = items[idx].get('enabled', True)
|
old_enabled = items[idx].get('enabled', True)
|
||||||
|
before = copy.deepcopy(items[idx])
|
||||||
items[idx]['enabled'] = not old_enabled
|
items[idx]['enabled'] = not old_enabled
|
||||||
errors = validate.validate_config(cfg)
|
errors = validate.validate_config(cfg)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
@ -125,14 +122,9 @@ def rules_toggle():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = f'{items[idx]["protocol"]}:{items[idx]["dest_port"]}'
|
dest_port = items[idx].get('dest_port', '')
|
||||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
changes = diff_fields(before, items[idx])
|
||||||
flash(save_config_with_snapshot(
|
flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -165,13 +157,9 @@ def rules_edit():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = f'{entry["protocol"]}:{entry["dest_port"]}'
|
dest_port = items[idx].get('dest_port', '')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, items[idx])
|
||||||
cfg,
|
flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -198,11 +186,7 @@ def rules_delete():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
key = f'{removed["protocol"]}:{removed["dest_port"]}'
|
dest_port = removed.get('dest_port', '')
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(removed, None)
|
||||||
cfg,
|
flash(record_group(cfg, 'port_forwarding', 'dest_port', dest_port, changes, 'core apply'), 'success')
|
||||||
path='port_forwarding', key=key, operation='delete',
|
|
||||||
before=removed, after=None,
|
|
||||||
description=f'Deleted port forward: {key}',
|
|
||||||
), 'success')
|
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import re
|
||||||
|
|
||||||
from flask import Blueprint, make_response, redirect, flash, request
|
from flask import Blueprint, make_response, redirect, flash, request
|
||||||
from auth import require_level
|
from auth import require_level
|
||||||
from config_utils import load_config, save_config_with_snapshot, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
|
from config_utils import load_config, record_group, diff_fields, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
|
|
||||||
|
|
@ -200,12 +200,8 @@ def wireguard_apply():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
vlan_name = vpn_vlan['name']
|
vlan_name = vpn_vlan['name']
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before_info, info)
|
||||||
cfg,
|
flash(record_group(cfg, f'vlans[name={vlan_name}].vpn_information', None, None, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -271,13 +267,8 @@ def addpeer_add():
|
||||||
flash(msg, 'error')
|
flash(msg, 'error')
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
save_config_with_snapshot(
|
changes = diff_fields(None, entry)
|
||||||
cfg,
|
record_group(cfg, f'vlans[name={peer_vlan_nm}].peers', 'name', peer_name, changes, 'core apply')
|
||||||
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)
|
return _conf_response(vpn_vlan, peer_name, peer_ip, private_key)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -319,12 +310,8 @@ def peers_edit():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
vlan_name = vlan['name']
|
vlan_name = vlan['name']
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(before, {'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled})
|
||||||
cfg,
|
flash(record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', peer_name, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -346,6 +333,7 @@ def peers_toggle():
|
||||||
|
|
||||||
peers = vlan.get('peers', [])
|
peers = vlan.get('peers', [])
|
||||||
old_enabled = peers[peer_idx].get('enabled', True)
|
old_enabled = peers[peer_idx].get('enabled', True)
|
||||||
|
before = copy.deepcopy(peers[peer_idx])
|
||||||
peers[peer_idx]['enabled'] = not old_enabled
|
peers[peer_idx]['enabled'] = not old_enabled
|
||||||
errors = validate.validate_config(cfg)
|
errors = validate.validate_config(cfg)
|
||||||
if errors:
|
if errors:
|
||||||
|
|
@ -355,13 +343,8 @@ def peers_toggle():
|
||||||
|
|
||||||
peer_name = peers[peer_idx]['name']
|
peer_name = peers[peer_idx]['name']
|
||||||
vlan_name = vlan['name']
|
vlan_name = vlan['name']
|
||||||
action = 'Enabled' if not old_enabled else 'Disabled'
|
changes = diff_fields(before, peers[peer_idx])
|
||||||
flash(save_config_with_snapshot(
|
flash(record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', peer_name, changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -390,13 +373,8 @@ def peers_delete():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
vlan_name = vlan['name']
|
vlan_name = vlan['name']
|
||||||
flash(save_config_with_snapshot(
|
changes = diff_fields(removed, None)
|
||||||
cfg,
|
flash(record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', removed['name'], changes, 'core apply'), 'success')
|
||||||
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(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -427,11 +405,6 @@ def peers_regenerate():
|
||||||
return redirect(f'/{_PAGE}')
|
return redirect(f'/{_PAGE}')
|
||||||
|
|
||||||
vlan_name = vlan['name']
|
vlan_name = vlan['name']
|
||||||
save_config_with_snapshot(
|
changes = diff_fields({'public_key': old_pub_key}, {'public_key': public_key})
|
||||||
cfg,
|
record_group(cfg, f'vlans[name={vlan_name}].peers', 'name', peer['name'], changes, 'core apply')
|
||||||
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)
|
return _conf_response(vlan, peer['name'], peer['ip'], private_key)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import json, re, subprocess, os, sys
|
||||||
import sanitize
|
import sanitize
|
||||||
import validation as validate
|
import validation as validate
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from config_utils import config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_snapshot_for_uuid, load_all_snapshots, get_done_timestamps, queue_command, _find_cmd_in_queues, _entry_ts_from_queue, _apply_changes_immediately, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR, WWW_DIR, ACCOUNTS_FILE, APP_DIR
|
from config_utils import config_hash, get_pending_entries, get_dashboard_pending, get_dashboard_done, load_all_groups, revert_group, get_done_timestamps, queue_command, _find_cmd_in_queues, _entry_ts_from_queue, _apply_changes_immediately, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime, WEB_APP_DISPLAY_NAME, CONFIGS_DIR, DATA_DIR, WWW_DIR, ACCOUNTS_FILE, APP_DIR
|
||||||
import factory
|
import factory
|
||||||
from factory import LEVEL_RANK, e, client_level, passes, build_items, build_snap_val, snap_expand_row
|
from factory import LEVEL_RANK, e, client_level, passes, build_items, build_snap_val, snap_expand_row
|
||||||
PAGES_DIR = os.path.join(APP_DIR, 'pages')
|
PAGES_DIR = os.path.join(APP_DIR, 'pages')
|
||||||
|
|
@ -634,19 +634,18 @@ def collect_tokens():
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
all_snaps = load_all_snapshots()
|
all_groups = load_all_groups() # [(group_dict, [change_dicts])]
|
||||||
_snap_uuid_set = {s.get('uuid') for s in all_snaps}
|
_group_uuid_set = {g['uuid'] for g, _ in all_groups}
|
||||||
pending_items = get_dashboard_pending()
|
pending_items = get_dashboard_pending()
|
||||||
if pending_items:
|
if pending_items:
|
||||||
# Group by command; each group = one row in the Pending Actions table.
|
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
groups = defaultdict(list)
|
_pgroups = defaultdict(list)
|
||||||
for _uuid, _ts, cmd, user in pending_items:
|
for _uuid, _ts, cmd, user in pending_items:
|
||||||
groups[cmd].append((_uuid, user))
|
_pgroups[cmd].append((_uuid, user))
|
||||||
rows = ''
|
rows = ''
|
||||||
for cmd, entries in groups.items():
|
for cmd, entries in _pgroups.items():
|
||||||
users = ', '.join(sorted({u for _, u in entries if u and u != 'unknown'}))
|
users = ', '.join(sorted({u for _, u in entries if u and u != 'unknown'}))
|
||||||
snap_uuids = [_uuid for _uuid, _ in entries if _uuid in _snap_uuid_set]
|
snap_uuids = [_uuid for _uuid, _ in entries if _uuid in _group_uuid_set]
|
||||||
if snap_uuids:
|
if snap_uuids:
|
||||||
req_tags = ''.join(
|
req_tags = ''.join(
|
||||||
f'<span class="tag" data-tooltip="{_uuid}" data-uuid="{_uuid}">'
|
f'<span class="tag" data-tooltip="{_uuid}" data-uuid="{_uuid}">'
|
||||||
|
|
@ -690,27 +689,33 @@ def collect_tokens():
|
||||||
if pending_items else ''
|
if pending_items else ''
|
||||||
)
|
)
|
||||||
done_ts_map = get_done_timestamps()
|
done_ts_map = get_done_timestamps()
|
||||||
if all_snaps:
|
if all_groups:
|
||||||
# UUIDs that cannot be reverted: revert entries themselves, and entries
|
|
||||||
# that have already been reverted (referenced in another snap's 'reverts').
|
|
||||||
_no_revert = set()
|
_no_revert = set()
|
||||||
for _s in all_snaps:
|
for g, _ in all_groups:
|
||||||
if _s.get('operation') == 'revert':
|
if g['reverts_group']:
|
||||||
_no_revert.add(_s.get('uuid', ''))
|
_no_revert.add(g['uuid'])
|
||||||
if _s.get('reverts'):
|
_no_revert.add(g['reverts_group'])
|
||||||
_no_revert.add(_s['reverts'])
|
|
||||||
hist_rows = ''
|
hist_rows = ''
|
||||||
_hist_onclick = (
|
_hist_onclick = (
|
||||||
'onclick="if(event.target.type!==\'checkbox\')'
|
'onclick="if(event.target.type!==\'checkbox\')'
|
||||||
'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"'
|
'this.nextElementSibling.hidden=!this.nextElementSibling.hidden"'
|
||||||
)
|
)
|
||||||
for snap in all_snaps:
|
for g, changes in all_groups:
|
||||||
_uuid = snap.get('uuid', '')
|
_uuid = g['uuid']
|
||||||
applied_ts = done_ts_map.get(_uuid)
|
applied_ts = done_ts_map.get(_uuid)
|
||||||
dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') if applied_ts else '-'
|
dt_str = datetime.fromtimestamp(applied_ts).strftime('%Y-%m-%d %H:%M') if applied_ts else '-'
|
||||||
snap_desc = e(snap.get('description', ''))
|
all_before_null = all(c['before'] is None for c in changes)
|
||||||
before_val = snap.get('before')
|
all_after_null = all(c['after'] is None for c in changes)
|
||||||
after_val = snap.get('after')
|
if g['reverts_group']:
|
||||||
|
verb = 'Reverted'
|
||||||
|
elif all_before_null:
|
||||||
|
verb = 'Added'
|
||||||
|
elif all_after_null:
|
||||||
|
verb = 'Deleted'
|
||||||
|
else:
|
||||||
|
verb = 'Edited'
|
||||||
|
item = g.get('item_value') or ''
|
||||||
|
summary = f'{verb} {g["parent_path"]}: {item}' if item else f'{verb} {g["parent_path"]}'
|
||||||
snap_tag = (
|
snap_tag = (
|
||||||
f'<div class="tag-list"><span class="tag" data-tooltip="{e(_uuid)}" data-uuid="{e(_uuid)}">'
|
f'<div class="tag-list"><span class="tag" data-tooltip="{e(_uuid)}" data-uuid="{e(_uuid)}">'
|
||||||
f'<span class="tl-full">{e(_uuid[:8])}</span>'
|
f'<span class="tl-full">{e(_uuid[:8])}</span>'
|
||||||
|
|
@ -718,19 +723,18 @@ def collect_tokens():
|
||||||
f'<span class="tl-min">{e(_uuid[:8])}</span>'
|
f'<span class="tl-min">{e(_uuid[:8])}</span>'
|
||||||
'</span></div>'
|
'</span></div>'
|
||||||
)
|
)
|
||||||
snap_user = e(snap.get('user', ''))
|
snap_user = e(g.get('user', ''))
|
||||||
_cb_attrs = 'disabled title="Cannot revert"' if _uuid in _no_revert else ''
|
_cb_attrs = 'disabled title="Cannot revert"' if _uuid in _no_revert else ''
|
||||||
hist_rows += (
|
hist_rows += (
|
||||||
f'<tr class="row-expandable" data-uuid="{e(_uuid)}" {_hist_onclick}>'
|
f'<tr class="row-expandable" data-uuid="{e(_uuid)}" {_hist_onclick}>'
|
||||||
f'<td class="table-cell"><input type="checkbox" name="selected_uuids" value="{e(_uuid)}" {_cb_attrs}/></td>'
|
f'<td class="table-cell"><input type="checkbox" name="selected_uuids" value="{e(_uuid)}" {_cb_attrs}/></td>'
|
||||||
f'<td class="table-cell">{e(dt_str)}</td>'
|
f'<td class="table-cell">{e(dt_str)}</td>'
|
||||||
f'<td class="table-cell">{snap_desc}</td>'
|
f'<td class="table-cell">{e(summary)}</td>'
|
||||||
f'<td class="table-cell">{build_snap_val(before_val)}</td>'
|
f'<td class="table-cell">{build_snap_val(changes)}</td>'
|
||||||
f'<td class="table-cell">{build_snap_val(after_val)}</td>'
|
|
||||||
f'<td class="table-cell">{snap_tag}</td>'
|
f'<td class="table-cell">{snap_tag}</td>'
|
||||||
f'<td class="table-cell">{snap_user}</td>'
|
f'<td class="table-cell">{snap_user}</td>'
|
||||||
'</tr>'
|
'</tr>'
|
||||||
f'{snap_expand_row(before_val, after_val, 7)}'
|
f'{snap_expand_row(changes, 6)}'
|
||||||
)
|
)
|
||||||
select_all = (
|
select_all = (
|
||||||
'<input type="checkbox" '
|
'<input type="checkbox" '
|
||||||
|
|
@ -741,10 +745,9 @@ def collect_tokens():
|
||||||
'<thead><tr>'
|
'<thead><tr>'
|
||||||
f'<th class="table-header">{select_all}</th>'
|
f'<th class="table-header">{select_all}</th>'
|
||||||
'<th class="table-header">Applied</th>'
|
'<th class="table-header">Applied</th>'
|
||||||
'<th class="table-header">Description</th>'
|
'<th class="table-header">Change</th>'
|
||||||
'<th class="table-header">Before</th>'
|
'<th class="table-header">Fields</th>'
|
||||||
'<th class="table-header">After</th>'
|
'<th class="table-header">Group</th>'
|
||||||
'<th class="table-header">Snapshot</th>'
|
|
||||||
'<th class="table-header">User</th>'
|
'<th class="table-header">User</th>'
|
||||||
'</tr></thead>'
|
'</tr></thead>'
|
||||||
f'<tbody>{hist_rows}</tbody>'
|
f'<tbody>{hist_rows}</tbody>'
|
||||||
|
|
@ -754,7 +757,7 @@ def collect_tokens():
|
||||||
history_html = '<p class="text-muted">No change history.</p>'
|
history_html = '<p class="text-muted">No change history.</p>'
|
||||||
|
|
||||||
tokens['CHANGE_HISTORY_HTML'] = history_html
|
tokens['CHANGE_HISTORY_HTML'] = history_html
|
||||||
tokens['NO_HISTORY'] = 'true' if not all_snaps else ''
|
tokens['NO_HISTORY'] = 'true' if not all_groups else ''
|
||||||
|
|
||||||
servers = dns.get('upstream_servers', [])
|
servers = dns.get('upstream_servers', [])
|
||||||
tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'
|
tokens['DNS_STRICT_ORDER'] = 'true' if dns.get('strict_order') else 'false'
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue