Development

This commit is contained in:
Matthew Grotke 2026-06-13 09:25:57 -04:00
parent 44261e5b5c
commit 8a8e947fcf
9 changed files with 289 additions and 33 deletions

View file

@ -9,7 +9,7 @@ APP_DIR = _os.path.dirname(_os.path.abspath(__file__))
CONFIGS_DIR = _settings.routlin_location() CONFIGS_DIR = _settings.routlin_location()
DATA_DIR = '/data' DATA_DIR = '/data'
WWW_DIR = '/www' WWW_DIR = '/www'
ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-accounts' ACCOUNTS_DB = f'{DATA_DIR}/.dashboard-singleaccount' if _settings.is_single_user() else f'{DATA_DIR}/.dashboard-accounts'
CONFIG_FILE = f'{CONFIGS_DIR}/config.json' CONFIG_FILE = f'{CONFIGS_DIR}/config.json'
DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue'
DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done'
@ -81,6 +81,23 @@ def init_accounts_db():
pass pass
con.close() con.close()
def init_single_user_session_db():
con = _sqlite3.connect(ACCOUNTS_DB, timeout=5)
con.execute('PRAGMA journal_mode=WAL')
con.executescript('''
CREATE TABLE IF NOT EXISTS sessions (
session_id TEXT PRIMARY KEY,
logged_in INTEGER NOT NULL DEFAULT 0,
timezone TEXT NOT NULL DEFAULT '',
preferences_json TEXT NOT NULL DEFAULT '{}',
flashes_json TEXT,
session_started_ts INTEGER NOT NULL,
last_seen_ts INTEGER NOT NULL
);
''')
con.commit()
con.close()
_LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'} _LEVEL_INT_TO_STR = {0: 'nothing', 1: 'viewer', 2: 'administrator', 3: 'manager'}
_LEVEL_STR_TO_INT = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3} _LEVEL_STR_TO_INT = {'nothing': 0, 'viewer': 1, 'administrator': 2, 'manager': 3}

View file

@ -32,8 +32,13 @@ from api_apply_health import bp as api_apply_health_bp
from session_interface import SqliteSessionInterface from session_interface import SqliteSessionInterface
app = Flask(__name__) app = Flask(__name__)
app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB) if settings.is_single_user():
config_utils.init_accounts_db() from session_interface import SingleUserSessionInterface
config_utils.init_single_user_session_db()
app.session_interface = SingleUserSessionInterface(config_utils.ACCOUNTS_DB)
else:
app.session_interface = SqliteSessionInterface(config_utils.ACCOUNTS_DB)
config_utils.init_accounts_db()
# Static www/ serving ================================================= # Static www/ serving =================================================
@ -83,6 +88,9 @@ def serve_view(page_name):
if not factory.passes(view_req, level): if not factory.passes(view_req, level):
return redirect('/overview' if level > 0 else '/accountlogin') return redirect('/overview' if level > 0 else '/accountlogin')
if settings.is_single_user() and page_name in ('accountmanage', 'accountcreate', 'accountverifyemail'):
return redirect('/overview' if level > 0 else '/accountlogin')
cfg = config_utils.load_config() cfg = config_utils.load_config()
if level >= factory.LEVEL_RANK['administrator']: if level >= factory.LEVEL_RANK['administrator']:
@ -138,10 +146,11 @@ app.register_blueprint(physicalinterfaces_bp)
app.register_blueprint(portforwarding_bp) app.register_blueprint(portforwarding_bp)
app.register_blueprint(portwrangling_bp) app.register_blueprint(portwrangling_bp)
app.register_blueprint(preferences_bp) app.register_blueprint(preferences_bp)
app.register_blueprint(accountverifyemail_bp) if not settings.is_single_user():
app.register_blueprint(accountverifyemail_bp)
app.register_blueprint(accountcreate_bp)
app.register_blueprint(accountmanage_bp)
app.register_blueprint(vpn_bp) app.register_blueprint(vpn_bp)
app.register_blueprint(accountcreate_bp)
app.register_blueprint(accountmanage_bp)
app.register_blueprint(accountlogout_bp) app.register_blueprint(accountlogout_bp)
app.register_blueprint(mdns_bp) app.register_blueprint(mdns_bp)
app.register_blueprint(radius_bp) app.register_blueprint(radius_bp)
@ -151,6 +160,8 @@ app.register_blueprint(api_apply_health_bp)
def _seed_initial_account(): def _seed_initial_account():
if settings.is_single_user():
return
import uuid as _uuid, time as _t import uuid as _uuid, time as _t
email = settings.get_initial_manager_email() email = settings.get_initial_manager_email()
if not email: if not email:

View file

@ -24,6 +24,21 @@ def form_login():
flash('Email address and password are required.', 'error') flash('Email address and password are required.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
if settings.is_single_user():
stored_hash = settings.get_initial_manager_password_hash()
if email != settings.get_initial_manager_email() or not stored_hash:
flash('Invalid email address or password.', 'error')
return redirect(f'/{_PAGE}')
if not bcrypt.checkpw(password.encode('utf-8'), stored_hash.encode('utf-8')):
flash('Invalid email address or password.', 'error')
return redirect(f'/{_PAGE}')
session.clear()
session['logged_in'] = True
session['timezone'] = settings.get_host_timezone()
session['apply_changes_immediately'] = False
session.permanent = True
return redirect('/overview')
account = config_utils.get_account_by_email(email) account = config_utils.get_account_by_email(email)
if account is None: if account is None:

View file

@ -4,6 +4,7 @@ import bcrypt
import auth import auth
import config_utils import config_utils
import sanitize import sanitize
import settings
_PAGE = Path(__file__).parent.name _PAGE = Path(__file__).parent.name
@ -25,9 +26,62 @@ def accountdetails_save():
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
@bp.route('/action/preferences/email_change_direct', methods=['POST'])
@auth.require_level('viewer')
def email_change_direct():
if session.get('email_address', '').lower() != settings.get_initial_manager_email():
flash('Not authorised.', 'error')
return redirect(f'/{_PAGE}')
new_email = sanitize.email(request.form.get('new_email', '').strip())
if not new_email:
flash('A valid email address is required.', 'error')
return redirect(f'/{_PAGE}')
current_email = session.get('email_address', '').lower()
if new_email == current_email:
flash('That is already your current email address.', 'error')
return redirect(f'/{_PAGE}')
if not settings.is_single_user() and config_utils.get_account_by_email(new_email):
flash('That email address is already in use.', 'error')
return redirect(f'/{_PAGE}')
if not settings.is_single_user():
try:
con = config_utils.open_accounts_db()
con.execute(
'UPDATE accounts SET email=?, requested_email=NULL WHERE account_id=?',
(new_email, session.get('account_id', ''))
)
con.commit()
con.close()
except Exception as exc:
flash(f'Could not update account: {exc}', 'error')
return redirect(f'/{_PAGE}')
try:
import json as _json
cfg_path = Path(settings._APP_CONFIG_PATH)
cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
cfg['initial_manager_email'] = new_email
cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n')
settings._app_config_cache = None
except Exception as exc:
flash(f'Could not update app_config.json: {exc}', 'error')
return redirect(f'/{_PAGE}')
session['email_address'] = new_email
flash('Email address updated.', 'success')
return redirect(f'/{_PAGE}')
@bp.route('/action/preferences/email_change_request', methods=['POST']) @bp.route('/action/preferences/email_change_request', methods=['POST'])
@auth.require_level('viewer') @auth.require_level('viewer')
def email_change_request(): def email_change_request():
if settings.is_single_user():
flash('Not available in single-user mode.', 'error')
return redirect(f'/{_PAGE}')
new_email = sanitize.email(request.form.get('new_email', '').strip()) new_email = sanitize.email(request.form.get('new_email', '').strip())
if not new_email: if not new_email:
flash('A valid email address is required.', 'error') flash('A valid email address is required.', 'error')
@ -77,6 +131,26 @@ def changepassword_save():
flash('New password must be at least 8 characters.', 'error') flash('New password must be at least 8 characters.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
if settings.is_single_user():
stored_hash = settings.get_initial_manager_password_hash()
if not stored_hash or not bcrypt.checkpw(current_password.encode('utf-8'), stored_hash.encode('utf-8')):
flash('Current password is incorrect.', 'error')
return redirect(f'/{_PAGE}')
try:
import json as _json
cfg_path = Path(settings._APP_CONFIG_PATH)
cfg = _json.loads(cfg_path.read_text()) if cfg_path.exists() else {}
cfg['initial_manager_password'] = hashed
cfg_path.write_text(_json.dumps(cfg, indent=2) + '\n')
settings._app_config_cache = None
except Exception as exc:
flash(f'Could not update password: {exc}', 'error')
return redirect(f'/{_PAGE}')
flash('Password changed successfully.', 'success')
return redirect(f'/{_PAGE}')
account = config_utils.get_account_by_id(session.get('account_id', '')) account = config_utils.get_account_by_id(session.get('account_id', ''))
if account is None: if account is None:
flash('Account not found. Please log in again.', 'error') flash('Account not found. Please log in again.', 'error')
@ -86,8 +160,6 @@ def changepassword_save():
flash('Current password is incorrect.', 'error') flash('Current password is incorrect.', 'error')
return redirect(f'/{_PAGE}') return redirect(f'/{_PAGE}')
hashed = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
try: try:
con = config_utils.open_accounts_db() con = config_utils.open_accounts_db()
con.execute( con.execute(

View file

@ -54,7 +54,7 @@
}, },
{ {
"type": "form", "type": "form",
"action": "/action/preferences/email_change_request", "action": "%EMAIL_CHANGE_ACTION%",
"method": "post", "method": "post",
"items": [ "items": [
{ {
@ -70,7 +70,7 @@
"items": [ "items": [
{ {
"type": "button_primary", "type": "button_primary",
"text": "Submit Request" "text": "%EMAIL_CHANGE_BTN_TEXT%"
} }
] ]
} }

View file

@ -3,6 +3,7 @@ from flask import session
import sanitize import sanitize
import config_utils import config_utils
import factory import factory
import settings
def collect_tokens(cfg): def collect_tokens(cfg):
@ -12,8 +13,19 @@ def collect_tokens(cfg):
tokens['PREF_TIMEZONE'] = session.get('timezone', '') tokens['PREF_TIMEZONE'] = session.get('timezone', '')
tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES]) tokens['TIMEZONE_OPTIONS'] = json.dumps(blank + [{'value': tz, 'label': tz} for tz in sanitize.VALID_TIMEZONES])
account = config_utils.get_account_by_id(session.get('account_id', '')) is_initial_manager = session.get('email_address', '').lower() == settings.get_initial_manager_email()
requested = (account or {}).get('requested_email', '') if is_initial_manager:
tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_direct'
tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Change Email'
else:
tokens['EMAIL_CHANGE_ACTION'] = '/action/preferences/email_change_request'
tokens['EMAIL_CHANGE_BTN_TEXT'] = 'Submit Request'
if not settings.is_single_user():
account = config_utils.get_account_by_id(session.get('account_id', ''))
requested = (account or {}).get('requested_email', '')
else:
requested = ''
if requested: if requested:
tokens['PENDING_EMAIL_BAR'] = ( tokens['PENDING_EMAIL_BAR'] = (
f'<div class="info-bar info-bar-inline info-bar-warning">' f'<div class="info-bar info-bar-inline info-bar-warning">'

View file

@ -134,3 +134,97 @@ class SqliteSessionInterface(SessionInterface):
secure=self.get_cookie_secure(app), secure=self.get_cookie_secure(app),
samesite=self.get_cookie_samesite(app), samesite=self.get_cookie_samesite(app),
) )
class SingleUserSessionInterface(SessionInterface):
def __init__(self, db_path):
self.db_path = db_path
def _connect(self):
con = sqlite3.connect(self.db_path, timeout=5)
con.execute('PRAGMA journal_mode=WAL')
con.row_factory = sqlite3.Row
return con
def open_session(self, app, request):
name = app.config.get('SESSION_COOKIE_NAME', 'session')
sid = request.cookies.get(name)
if not sid:
return SqliteSession(sid=str(uuid.uuid4()), new=True)
try:
con = self._connect()
row = con.execute('SELECT * FROM sessions WHERE session_id=?', (sid,)).fetchone()
con.close()
if row:
prefs = json.loads(row['preferences_json'] or '{}')
flashes = json.loads(row['flashes_json'] or '[]')
data = {'_flashes': flashes}
if row['logged_in']:
tz = str(row['timezone'] or '') or _settings.get_host_timezone()
data.update({
'email_address': _settings.get_initial_manager_email(),
'access_level': 'manager',
'timezone': tz,
'apply_changes_immediately': bool(prefs.get('apply_changes_immediately', False)),
'_permanent': True,
'logged_in': True,
})
return SqliteSession(data, sid=sid, new=False)
except Exception:
pass
return SqliteSession(sid=sid, new=False)
def save_session(self, app, session, response):
name = app.config.get('SESSION_COOKIE_NAME', 'session')
domain = self.get_cookie_domain(app)
path = self.get_cookie_path(app)
if not session and session.modified and not session.new:
try:
con = self._connect()
con.execute('DELETE FROM sessions WHERE session_id=?', (session.sid,))
con.commit()
con.close()
except Exception:
pass
response.delete_cookie(name, domain=domain, path=path)
return
prefs = json.dumps({
'apply_changes_immediately': bool(session.get('apply_changes_immediately', False)),
})
flashes_json = json.dumps(session.get('_flashes', []))
now = int(time.time())
logged_in = 1 if session.get('logged_in') else 0
try:
con = self._connect()
con.execute(
'''INSERT INTO sessions
(session_id, logged_in, timezone, preferences_json,
flashes_json, session_started_ts, last_seen_ts)
VALUES (?,?,?,?,?,?,?)
ON CONFLICT(session_id) DO UPDATE SET
logged_in=excluded.logged_in,
timezone=excluded.timezone,
preferences_json=excluded.preferences_json,
flashes_json=excluded.flashes_json,
last_seen_ts=excluded.last_seen_ts''',
(session.sid, logged_in,
session.get('timezone', ''),
prefs, flashes_json, now, now)
)
con.commit()
con.close()
except Exception:
pass
response.set_cookie(
name, session.sid,
expires=self.get_expiration_time(app, session),
httponly=self.get_cookie_httponly(app),
domain=domain,
path=path,
secure=self.get_cookie_secure(app),
samesite=self.get_cookie_samesite(app),
)

View file

@ -78,6 +78,14 @@ def get_initial_manager_email():
return str(cfg.get('initial_manager_email') or os.environ.get('INITIAL_MANAGER_EMAIL', '')).strip().lower() return str(cfg.get('initial_manager_email') or os.environ.get('INITIAL_MANAGER_EMAIL', '')).strip().lower()
def is_single_user():
return 'initial_manager_password' in _load_app_config()
def get_initial_manager_password_hash():
return _load_app_config().get('initial_manager_password', '')
def get_credentials_key(): def get_credentials_key():
"""Return a Fernet-compatible key derived from the credentials_key in app_config.json """Return a Fernet-compatible key derived from the credentials_key in app_config.json
(or CREDENTIALS_KEY env var as fallback), or None if not set. SHA-256 hashes the raw (or CREDENTIALS_KEY env var as fallback), or None if not set. SHA-256 hashes the raw

View file

@ -327,11 +327,6 @@ def setup_docker_compose(reuse_config=False):
print(" Dashboard container started.") print(" Dashboard container started.")
return return
print()
print(" SMTP is used to send email verification codes for new accounts.")
print(" (Gmail users: use an App Password, not your account password.)")
print()
manager_email = prompt_str("Initial manager account email") manager_email = prompt_str("Initial manager account email")
while not manager_email or "@" not in manager_email: while not manager_email or "@" not in manager_email:
print(" Please enter a valid email address.") print(" Please enter a valid email address.")
@ -345,23 +340,55 @@ def setup_docker_compose(reuse_config=False):
credentials_key = _sec.token_urlsafe(48) credentials_key = _sec.token_urlsafe(48)
print(f" Generated key: {credentials_key}") print(f" Generated key: {credentials_key}")
smtp_host = prompt_str("SMTP host", default="smtp.gmail.com") print()
smtp_port = prompt_str("SMTP port", default="587") multi_user = prompt_yn(
smtp_user = prompt_str("SMTP username (email)") f"Enable multi-user access? (requires SMTP to send account verification emails)",
smtp_password = prompt_str("SMTP password", secret=True) default="n"
smtp_from = prompt_str("SMTP From address", default=smtp_user) )
app_config = { if multi_user:
"initial_manager_email": manager_email, print()
"credentials_key": credentials_key, print(" (Gmail users: use an App Password, not your account password.)")
"smtp": { print()
"host": smtp_host, smtp_host = prompt_str("SMTP host", default="smtp.gmail.com")
"port": int(smtp_port), smtp_port = prompt_str("SMTP port", default="587")
"user": smtp_user, smtp_user = prompt_str("SMTP username (email)")
"password": smtp_password, smtp_password = prompt_str("SMTP password", secret=True)
"from": smtp_from, smtp_from = prompt_str("SMTP From address", default=smtp_user)
}, app_config = {
} "initial_manager_email": manager_email,
"credentials_key": credentials_key,
"smtp": {
"host": smtp_host,
"port": int(smtp_port),
"user": smtp_user,
"password": smtp_password,
"from": smtp_from,
},
}
else:
print()
print(f" Single-user mode: only {manager_email} can log in.")
print(f" Password must be at least 8 characters.")
print()
while True:
import getpass as _gp
pw = _gp.getpass(f" Password for {manager_email}: ")
pw2 = _gp.getpass(f" Confirm password: ")
if pw != pw2:
print(" Passwords do not match. Try again.")
continue
if len(pw) < 8:
print(" Password must be at least 8 characters.")
continue
break
import bcrypt as _bcrypt
pw_hash = _bcrypt.hashpw(pw.encode('utf-8'), _bcrypt.gensalt()).decode('utf-8')
app_config = {
"initial_manager_email": manager_email,
"credentials_key": credentials_key,
"initial_manager_password": pw_hash,
}
APP_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) APP_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
APP_CONFIG_FILE.write_text(json.dumps(app_config, indent=2) + "\n") APP_CONFIG_FILE.write_text(json.dumps(app_config, indent=2) + "\n")