From f011594b048c26677e378c36a960556b2a0e22d7 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Mon, 8 Jun 2026 01:08:24 -0400 Subject: [PATCH] Development --- .../app/pages/captiveportal/action.py | 19 ++++++--- .../app/pages/captiveportal/content.json | 27 +++++++++++- .../app/pages/captiveportal/view.py | 22 +++++----- .../app/pages/clientcredentials/action.py | 14 ++++--- .../app/pages/clientcredentials/content.json | 42 +++++++++++++++---- .../app/pages/clientcredentials/view.py | 13 ++++-- .../routlin-dash/app/pages/radius/action.py | 29 ++++++++----- .../app/pages/radius/content.json | 28 ++++++++++++- docker/routlin-dash/app/pages/radius/view.py | 11 +++++ routlin/mod_radius.py | 4 +- 10 files changed, 163 insertions(+), 46 deletions(-) diff --git a/docker/routlin-dash/app/pages/captiveportal/action.py b/docker/routlin-dash/app/pages/captiveportal/action.py index 8d6009c..63eb450 100644 --- a/docker/routlin-dash/app/pages/captiveportal/action.py +++ b/docker/routlin-dash/app/pages/captiveportal/action.py @@ -62,13 +62,22 @@ def portal_save(): except (ValueError, TypeError): duration = 0 + try: + exp_n = int(request.form.get('default_expiration_value', '0').strip() or '0') + exp_unit = request.form.get('default_expiration_unit', 'hours') + mult = {'hours': 3600, 'days': 86400}.get(exp_unit, 3600) + expiration = exp_n * mult if exp_n > 0 else 0 + except (ValueError, TypeError): + expiration = 0 + after = { **existing, - 'portal_splash_title': splash_title, - 'portal_splash_text': splash_text, - 'portal_terms': terms, - 'require_username_password': require_upw, - 'default_session_seconds': duration, + 'portal_splash_title': splash_title, + 'portal_splash_text': splash_text, + 'portal_terms': terms, + 'require_username_password': require_upw, + 'default_session_seconds': duration, + 'default_expiration_seconds': expiration, } vlan['captive_portal'] = after diff --git a/docker/routlin-dash/app/pages/captiveportal/content.json b/docker/routlin-dash/app/pages/captiveportal/content.json index 38c9b98..dc23f39 100644 --- a/docker/routlin-dash/app/pages/captiveportal/content.json +++ b/docker/routlin-dash/app/pages/captiveportal/content.json @@ -177,7 +177,7 @@ "input_type": "number", "min": 0, "value": "0", - "hint": "How long portal access lasts after authentication. 0 = no expiration." + "hint": "How long portal access lasts after authentication. 0 = no session limit." }, { "type": "field", @@ -191,6 +191,31 @@ } ] }, + { + "type": "field_row", + "cols": 2, + "items": [ + { + "type": "field", + "label": "Default Expiration Duration", + "name": "default_expiration_value", + "input_type": "number", + "min": 0, + "value": "0", + "hint": "How long after creation an account is valid before it permanently expires. 0 = never expires." + }, + { + "type": "field", + "label": "Unit", + "name": "default_expiration_unit", + "input_type": "select", + "options": [ + {"value": "hours", "label": "Hours"}, + {"value": "days", "label": "Days"} + ] + } + ] + }, { "type": "button_row", "items": [ diff --git a/docker/routlin-dash/app/pages/captiveportal/view.py b/docker/routlin-dash/app/pages/captiveportal/view.py index bf7c408..adda45a 100644 --- a/docker/routlin-dash/app/pages/captiveportal/view.py +++ b/docker/routlin-dash/app/pages/captiveportal/view.py @@ -38,18 +38,20 @@ def collect_tokens(cfg): text = cp.get('portal_splash_text', vlan.get('portal_splash_text', '')) terms = cp.get('portal_terms', vlan.get('portal_terms', [])) require_upw = cp.get('require_username_password', vlan.get('require_username_password', False)) - duration = cp.get('default_session_seconds', vlan.get('default_session_seconds', 0)) + duration = cp.get('default_session_seconds', vlan.get('default_session_seconds', 0)) + expiration = cp.get('default_expiration_seconds', vlan.get('default_expiration_seconds', 0)) n = len(terms) display_rows.append({ - 'vlan_name': vlan['name'], - 'portal_splash_title': title, - 'portal_splash_text': text, - 'portal_terms': terms, - 'portal_terms_display': f'{n} term{"s" if n != 1 else ""}' if n else '--', - 'require_upw': require_upw, - 'require_username_password': require_upw, - 'default_session_seconds': duration, - 'session_display': _format_session(duration), + 'vlan_name': vlan['name'], + 'portal_splash_title': title, + 'portal_splash_text': text, + 'portal_terms': terms, + 'portal_terms_display': f'{n} term{"s" if n != 1 else ""}' if n else '--', + 'require_upw': require_upw, + 'require_username_password': require_upw, + 'default_session_seconds': duration, + 'default_expiration_seconds': expiration, + 'session_display': _format_session(duration), }) content = factory.load_json(f'{factory.PAGES_DIR}/captiveportal/content.json') diff --git a/docker/routlin-dash/app/pages/clientcredentials/action.py b/docker/routlin-dash/app/pages/clientcredentials/action.py index d31bcd6..684201a 100644 --- a/docker/routlin-dash/app/pages/clientcredentials/action.py +++ b/docker/routlin-dash/app/pages/clientcredentials/action.py @@ -187,8 +187,12 @@ def addedit(): enabled = 'enabled' in request.form session_seconds = _parse_session_seconds( - request.form.get('session_seconds_value', ''), - request.form.get('session_seconds_unit', 'hours'), + request.form.get('session_duration_value', ''), + request.form.get('session_duration_unit', 'hours'), + ) + expires_seconds = _parse_session_seconds( + request.form.get('expiration_duration_value', ''), + request.form.get('expiration_duration_unit', 'hours'), ) if not username: @@ -223,10 +227,10 @@ def addedit(): conn.execute( """UPDATE credentials SET username=?, password=?, description=?, user_type=?, digest_type=?, - vlan=?, enabled=?, date_set=?, session_seconds=? + vlan=?, enabled=?, date_set=?, session_seconds=?, expires_seconds=? WHERE id=?""", (username, stored_password, description, user_type, stored_digest_type, - vlan, int(enabled), date_set, session_seconds, existing['id']), + vlan, int(enabled), date_set, session_seconds, expires_seconds, existing['id']), ) conn.commit() except sqlite3.IntegrityError: @@ -255,7 +259,7 @@ def addedit(): (username, password, description, user_type, digest_type, vlan, enabled, date_set, session_seconds, expires_seconds) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (username, hashed, description, user_type, digest_type, - vlan, int(enabled), int(time.time()), session_seconds, 0), + vlan, int(enabled), int(time.time()), session_seconds, expires_seconds), ) conn.commit() except sqlite3.IntegrityError: diff --git a/docker/routlin-dash/app/pages/clientcredentials/content.json b/docker/routlin-dash/app/pages/clientcredentials/content.json index 5866dfb..ad961b5 100644 --- a/docker/routlin-dash/app/pages/clientcredentials/content.json +++ b/docker/routlin-dash/app/pages/clientcredentials/content.json @@ -76,7 +76,7 @@ { "type": "card", "id": "add-form", - "label": "Add Credential", + "label": "Add User Account", "client_requirement": "client_is_administrator+", "items": [ { @@ -91,13 +91,14 @@ }, { "type": "field", - "label": "Credential Active", + "label": "Account Active", "name": "enabled", - "input_type": "checkbox" + "input_type": "checkbox", + "value": "true" }, { "type": "raw_html", - "html": "" + "html": "" }, { "type": "field", @@ -170,17 +171,42 @@ "items": [ { "type": "field", - "label": "Valid For", - "name": "session_seconds_value", + "label": "Session Duration", + "name": "session_duration_value", "input_type": "number", "min": 0, "value": "0", - "hint": "How long this credential is valid after creation. 0 = no expiration." + "hint": "0 = no session limit" }, { "type": "field", "label": "Unit", - "name": "session_seconds_unit", + "name": "session_duration_unit", + "input_type": "select", + "options": [ + {"value": "hours", "label": "Hours"}, + {"value": "days", "label": "Days"} + ] + } + ] + }, + { + "type": "field_row", + "cols": 2, + "items": [ + { + "type": "field", + "label": "Expiration Duration", + "name": "expiration_duration_value", + "input_type": "number", + "min": 0, + "value": "0", + "hint": "How long after creation an account is valid before it permanently expires. 0 = never expires." + }, + { + "type": "field", + "label": "Unit", + "name": "expiration_duration_unit", "input_type": "select", "options": [ {"value": "hours", "label": "Hours"}, diff --git a/docker/routlin-dash/app/pages/clientcredentials/view.py b/docker/routlin-dash/app/pages/clientcredentials/view.py index 08b86bb..4fb7b09 100644 --- a/docker/routlin-dash/app/pages/clientcredentials/view.py +++ b/docker/routlin-dash/app/pages/clientcredentials/view.py @@ -71,6 +71,9 @@ def collect_tokens(cfg): tokens['RADIUS_DEFAULT_SESSION_SECONDS_JS'] = str( cfg.get('radius', {}).get('options', {}).get('default_session_seconds', 0) or 0 ) + tokens['RADIUS_DEFAULT_EXPIRATION_SECONDS_JS'] = str( + cfg.get('radius', {}).get('options', {}).get('default_expiration_seconds', 0) or 0 + ) vlans = [v for v in cfg.get('vlans', []) if not v.get('is_vpn')] tokens['VLAN_OPTIONS'] = json.dumps( @@ -84,10 +87,12 @@ def collect_tokens(cfg): { 'value': v['name'], 'label': f"{v['name']} (VLAN {v['vlan_id']})", - 'require_upw': v.get('captive_portal', {}).get('require_username_password', - v.get('require_username_password', False)), - 'default_session_seconds': v.get('captive_portal', {}).get('default_session_seconds', - v.get('default_session_seconds', 0)), + 'require_upw': v.get('captive_portal', {}).get('require_username_password', + v.get('require_username_password', False)), + 'default_session_seconds': v.get('captive_portal', {}).get('default_session_seconds', + v.get('default_session_seconds', 0)), + 'default_expiration_seconds': v.get('captive_portal', {}).get('default_expiration_seconds', + v.get('default_expiration_seconds', 0)), } for v in captive_vlans ] diff --git a/docker/routlin-dash/app/pages/radius/action.py b/docker/routlin-dash/app/pages/radius/action.py index ecb5581..45caa5c 100644 --- a/docker/routlin-dash/app/pages/radius/action.py +++ b/docker/routlin-dash/app/pages/radius/action.py @@ -101,20 +101,29 @@ def auth_mode_save(): after['default_session_seconds'] = dur_n * mult if dur_n > 0 else 0 except (ValueError, TypeError): after['default_session_seconds'] = 0 + try: + exp_n = int(request.form.get('default_expiration_value', '0').strip() or '0') + exp_unit = request.form.get('default_expiration_unit', 'hours') + mult = {'hours': 3600, 'days': 86400}.get(exp_unit, 3600) + after['default_expiration_seconds'] = exp_n * mult if exp_n > 0 else 0 + except (ValueError, TypeError): + after['default_expiration_seconds'] = 0 elif auth_mode == 'eap_certificate': after['include_length'] = include_length after['mab_first'] = mab_first - after.pop('eap_protocol', None) - after.pop('tunneled_reply', None) - after.pop('inner_protocol', None) - after.pop('default_session_seconds', None) + after.pop('eap_protocol', None) + after.pop('tunneled_reply', None) + after.pop('inner_protocol', None) + after.pop('default_session_seconds', None) + after.pop('default_expiration_seconds', None) else: # mab - after.pop('eap_protocol', None) - after.pop('tunneled_reply', None) - after.pop('inner_protocol', None) - after.pop('include_length', None) - after.pop('mab_first', None) - after.pop('default_session_seconds', None) + after.pop('eap_protocol', None) + after.pop('tunneled_reply', None) + after.pop('inner_protocol', None) + after.pop('include_length', None) + after.pop('mab_first', None) + after.pop('default_session_seconds', None) + after.pop('default_expiration_seconds', None) cfg.setdefault('radius', {})['options'] = after changes = config_utils.diff_fields(before, after) diff --git a/docker/routlin-dash/app/pages/radius/content.json b/docker/routlin-dash/app/pages/radius/content.json index 9009500..3979286 100644 --- a/docker/routlin-dash/app/pages/radius/content.json +++ b/docker/routlin-dash/app/pages/radius/content.json @@ -225,7 +225,7 @@ "input_type": "number", "min": 0, "value": "%RADIUS_DEFAULT_SESSION_VALUE%", - "hint": "How long a client session lasts before reauthentication is required. 0 = no expiration." + "hint": "How long a client session lasts before reauthentication is required. 0 = no session limit." }, { "type": "field", @@ -240,6 +240,32 @@ } ] }, + { + "type": "field_row", + "cols": 2, + "items": [ + { + "type": "field", + "label": "Default Expiration Duration", + "name": "default_expiration_value", + "input_type": "number", + "min": 0, + "value": "%RADIUS_DEFAULT_EXPIRATION_VALUE%", + "hint": "How long after creation an account is valid before it permanently expires. 0 = never expires." + }, + { + "type": "field", + "label": "Unit", + "name": "default_expiration_unit", + "input_type": "select", + "value": "%RADIUS_DEFAULT_EXPIRATION_UNIT%", + "options": [ + {"value": "hours", "label": "Hours"}, + {"value": "days", "label": "Days"} + ] + } + ] + }, { "type": "raw_html", "html": "" diff --git a/docker/routlin-dash/app/pages/radius/view.py b/docker/routlin-dash/app/pages/radius/view.py index e3b05b7..beabd75 100644 --- a/docker/routlin-dash/app/pages/radius/view.py +++ b/docker/routlin-dash/app/pages/radius/view.py @@ -138,6 +138,17 @@ def collect_tokens(cfg): tokens['RADIUS_DEFAULT_SESSION_VALUE'] = '0' tokens['RADIUS_DEFAULT_SESSION_UNIT'] = 'hours' + exps = fr_opts.get('default_expiration_seconds', 0) or 0 + if exps >= 86400 and exps % 86400 == 0: + tokens['RADIUS_DEFAULT_EXPIRATION_VALUE'] = str(exps // 86400) + tokens['RADIUS_DEFAULT_EXPIRATION_UNIT'] = 'days' + elif exps > 0: + tokens['RADIUS_DEFAULT_EXPIRATION_VALUE'] = str(max(1, round(exps / 3600))) + tokens['RADIUS_DEFAULT_EXPIRATION_UNIT'] = 'hours' + else: + tokens['RADIUS_DEFAULT_EXPIRATION_VALUE'] = '0' + tokens['RADIUS_DEFAULT_EXPIRATION_UNIT'] = 'hours' + vlans = cfg.get('vlans', []) default_vlan = next((v['name'] for v in vlans if v.get('radius_default') is True), '') vlan_options = [{'value': '', 'label': 'None (reject unknown devices)'}] diff --git a/routlin/mod_radius.py b/routlin/mod_radius.py index 66c61fa..0bc440c 100644 --- a/routlin/mod_radius.py +++ b/routlin/mod_radius.py @@ -52,7 +52,7 @@ def _load_supplicant_credentials(): conn = sqlite3.connect(str(CREDENTIALS_DB_FILE)) conn.row_factory = sqlite3.Row rows = conn.execute( - "SELECT username, password, digest_type, vlan, session_seconds, expires_seconds FROM credentials" + "SELECT username, password, digest_type, vlan, session_seconds, expires_seconds, date_set FROM credentials" " WHERE user_type=? AND enabled=1", (USER_TYPE_SUPPLICANT,) ).fetchall() @@ -145,7 +145,7 @@ def _supplicant_reply_attrs(cred, default_session_seconds, vlan_id, vlan): attrs.append(f" Session-Timeout = {session}") expires = cred.get('expires_seconds') or 0 if expires: - dt = datetime.datetime.fromtimestamp(expires) + dt = datetime.datetime.fromtimestamp((cred.get('date_set') or 0) + expires) attrs.append(f" Expiration := \"{dt.strftime('%b %d %Y %H:%M:%S')}\"") return attrs