diff --git a/app.py b/app.py index 68b2fcd..9933025 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,12 @@ import os import re import shutil +import hashlib import subprocess import functools from pathlib import Path -from flask import Flask, request, jsonify, render_template, Response +from flask import Flask, request, jsonify, render_template, Response, session, redirect, url_for app = Flask(__name__) @@ -16,6 +17,11 @@ ALLOWED_PATH_BASE = Path('/srv').resolve() _AUTH_USER = os.environ.get('MICRONAS_USER', 'admin') _AUTH_PASS = os.environ.get('MICRONAS_PASS', '') +# Derive secret key from the password so sessions survive restarts +# but are automatically invalidated if the password changes. +app.secret_key = os.environ.get('MICRONAS_SECRET') or \ + hashlib.sha256((_AUTH_PASS or 'insecure-dev-key').encode()).hexdigest() + if not _AUTH_PASS: import warnings warnings.warn( @@ -30,18 +36,15 @@ def require_auth(f): def decorated(*args, **kwargs): if not _AUTH_PASS: return f(*args, **kwargs) - auth = request.authorization - if not auth or auth.username != _AUTH_USER or auth.password != _AUTH_PASS: - return Response( - 'Unauthorized', 401, - {'WWW-Authenticate': 'Basic realm="micronas"'}, - ) + if not session.get('logged_in'): + if request.path.startswith('/api/'): + return Response('Unauthorized', 401) + return redirect(url_for('login')) return f(*args, **kwargs) return decorated def validate_path(path_str): - """Resolve path and ensure it falls under /srv.""" try: p = Path(path_str).resolve() except Exception: @@ -52,11 +55,49 @@ def validate_path(path_str): return str(p) +def _validate_users(valid_users): + """Validate and return a cleaned comma-separated user list, or raise ValueError.""" + users = [] + for u in re.split(r'[\s,]+', valid_users): + if not u: + continue + if not VALID_NAME.match(u): + raise ValueError(f'invalid username: {u}') + users.append(u) + return ', '.join(users) + + # ===== SMB ===== _SMB_SKIP = {'global', 'homes', 'printers', 'print$', 'ipc$'} +def _parse_smb_conf(): + """Parse /etc/samba/smb.conf into sections. First entry (name=None) is the preamble.""" + sections = [] + current = {'name': None, 'lines': []} + try: + with open('/etc/samba/smb.conf', 'r') as f: + for line in f: + m = re.match(r'^\s*\[(.+)\]\s*$', line) + if m: + sections.append(current) + current = {'name': m.group(1).strip(), 'lines': [line]} + else: + current['lines'].append(line) + except FileNotFoundError: + return [] + sections.append(current) + return sections + + +def _write_smb_conf(sections): + with open('/etc/samba/smb.conf', 'w') as f: + for s in sections: + f.writelines(s['lines']) + subprocess.run(['smbcontrol', 'all', 'reload-config'], capture_output=True) + + def parse_smb(): result = subprocess.run(['testparm', '-s'], capture_output=True, text=True) shares = [] @@ -68,7 +109,7 @@ def parse_smb(): if m: if current is not None and current['name'].lower() not in _SMB_SKIP: shares.append(current) - current = {'name': m.group(1), 'path': '', 'valid_users': ''} + current = {'name': m.group(1), 'path': '', 'valid_users': '', 'enabled': True} elif current is not None and '=' in line: key, _, val = line.partition('=') key = key.strip().lower().replace(' ', '_') @@ -77,6 +118,8 @@ def parse_smb(): current['path'] = val elif key == 'valid_users': current['valid_users'] = val + elif key == 'available': + current['enabled'] = val.lower() not in ('no', 'false', '0') if current is not None and current['name'].lower() not in _SMB_SKIP: shares.append(current) @@ -99,22 +142,52 @@ def add_smb_share(name, path, valid_users): # ===== NFS ===== -def get_nfs_shares(): - shares = [] +def _parse_exports(): + """Parse /etc/exports preserving blanks, comments, and disabled (commented-out) exports.""" + entries = [] try: with open('/etc/exports', 'r') as f: - for line in f: - line = line.strip() - if not line or line.startswith('#'): + for raw in f: + stripped = raw.strip() + if not stripped: + entries.append({'kind': 'blank', 'raw': raw}) continue - parts = line.split() - shares.append({ - 'path': parts[0], - 'clients': ' '.join(parts[1:]) if len(parts) > 1 else '*', - }) + if stripped.startswith('#'): + inner = stripped[1:].strip() + if inner.startswith('/'): + parts = inner.split(None, 1) + entries.append({'kind': 'export', 'enabled': False, + 'path': parts[0], 'rest': parts[1] if len(parts) > 1 else '', + 'raw': raw}) + else: + entries.append({'kind': 'comment', 'raw': raw}) + continue + parts = stripped.split(None, 1) + entries.append({'kind': 'export', 'enabled': True, + 'path': parts[0], 'rest': parts[1] if len(parts) > 1 else '', + 'raw': raw}) except FileNotFoundError: pass - return shares + return entries + + +def _write_exports(entries): + with open('/etc/exports', 'w') as f: + for e in entries: + if e['kind'] == 'export': + prefix = '' if e['enabled'] else '#' + rest = f'\t{e["rest"]}' if e['rest'] else '' + f.write(f'{prefix}{e["path"]}{rest}\n') + else: + f.write(e['raw']) + subprocess.run(['exportfs', '-ra'], check=True) + + +def get_nfs_shares(): + return [ + {'path': e['path'], 'clients': e['rest'], 'enabled': e['enabled']} + for e in _parse_exports() if e['kind'] == 'export' + ] def add_nfs_share(path, clients, ro=False): @@ -125,7 +198,30 @@ def add_nfs_share(path, clients, ro=False): subprocess.run(['exportfs', '-ra'], check=True) -# ===== ROUTES ===== +# ===== AUTH ROUTES ===== + +@app.route('/login', methods=['GET', 'POST']) +def login(): + if session.get('logged_in'): + return redirect(url_for('index')) + error = None + if request.method == 'POST': + username = request.form.get('username', '') + password = request.form.get('password', '') + if not _AUTH_PASS or (username == _AUTH_USER and password == _AUTH_PASS): + session['logged_in'] = True + return redirect(url_for('index')) + error = 'Invalid username or password.' + return render_template('login.html', error=error) + + +@app.route('/logout') +def logout(): + session.clear() + return redirect(url_for('login')) + + +# ===== MAIN ROUTES ===== @app.route('/') @require_auth @@ -139,6 +235,8 @@ def get_all_shares(): return jsonify({'smb': parse_smb(), 'nfs': get_nfs_shares()}) +# ===== SMB CRUD ===== + @app.route('/api/share/smb', methods=['POST']) @require_auth def create_smb(): @@ -149,22 +247,85 @@ def create_smb(): if not VALID_NAME.match(name): return jsonify({'error': 'invalid share name (letters, digits, _ -)'}), 400 - users = [] - for u in re.split(r'[\s,]+', valid_users): - if not u: - continue - if not VALID_NAME.match(u): - return jsonify({'error': f'invalid username: {u}'}), 400 - users.append(u) try: + clean_users = _validate_users(valid_users) safe_path = validate_path(path) except ValueError as e: return jsonify({'error': str(e)}), 400 - add_smb_share(name, safe_path, ', '.join(users)) + add_smb_share(name, safe_path, clean_users) return jsonify({'type': 'smb', 'status': 'created'}) +@app.route('/api/share/smb/', methods=['DELETE']) +@require_auth +def delete_smb(name): + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid share name'}), 400 + sections = _parse_smb_conf() + filtered = [s for s in sections if s['name'] != name] + if len(filtered) == len(sections): + return jsonify({'error': f'share {name!r} not found'}), 404 + _write_smb_conf(filtered) + return jsonify({'status': 'deleted'}) + + +@app.route('/api/share/smb/', methods=['PATCH']) +@require_auth +def edit_smb(name): + data = request.json or {} + new_name = data.get('name', name).strip() + path = data.get('path', '') + valid_users = data.get('validUsers', '') + + if not VALID_NAME.match(new_name): + return jsonify({'error': 'invalid share name'}), 400 + try: + clean_users = _validate_users(valid_users) + safe_path = validate_path(path) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + sections = _parse_smb_conf() + for s in sections: + if s['name'] == name: + s['name'] = new_name + s['lines'] = [ + f'[{new_name}]\n', + f' path = {safe_path}\n', + f' valid users = {clean_users}\n', + ' writable = yes\n', + ' browseable = yes\n', + ] + break + else: + return jsonify({'error': f'share {name!r} not found'}), 404 + _write_smb_conf(sections) + return jsonify({'status': 'updated'}) + + +@app.route('/api/share/smb//toggle', methods=['POST']) +@require_auth +def toggle_smb(name): + if not VALID_NAME.match(name): + return jsonify({'error': 'invalid share name'}), 400 + enabled = (request.json or {}).get('enabled', True) + + sections = _parse_smb_conf() + for s in sections: + if s['name'] == name: + s['lines'] = [l for l in s['lines'] if not re.match(r'\s*available\s*=', l, re.I)] + if not enabled: + s['lines'].append(' available = no\n') + break + else: + return jsonify({'error': f'share {name!r} not found'}), 404 + _write_smb_conf(sections) + return jsonify({'status': 'enabled' if enabled else 'disabled'}) + + +# ===== NFS CRUD ===== + @app.route('/api/share/nfs', methods=['POST']) @require_auth def create_nfs(): @@ -184,6 +345,78 @@ def create_nfs(): return jsonify({'type': 'nfs', 'status': 'created'}) +@app.route('/api/share/nfs', methods=['DELETE']) +@require_auth +def delete_nfs(): + path = (request.json or {}).get('path', '') + try: + safe_path = validate_path(path) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + entries = _parse_exports() + original = sum(1 for e in entries if e['kind'] == 'export') + entries = [e for e in entries if not (e['kind'] == 'export' and e['path'] == safe_path)] + if sum(1 for e in entries if e['kind'] == 'export') == original: + return jsonify({'error': f'export {safe_path!r} not found'}), 404 + _write_exports(entries) + return jsonify({'status': 'deleted'}) + + +@app.route('/api/share/nfs', methods=['PATCH']) +@require_auth +def edit_nfs(): + data = request.json or {} + path = data.get('path', '') + clients = data.get('clients', '*') + ro = data.get('readOnly', False) + + try: + safe_path = validate_path(path) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + if not VALID_NFS_CLIENT.match(clients): + return jsonify({'error': 'invalid client spec'}), 400 + + options = 'ro' if ro else 'rw' + new_rest = f'{clients}({options},sync,no_subtree_check)' + + entries = _parse_exports() + for e in entries: + if e['kind'] == 'export' and e['path'] == safe_path: + e['rest'] = new_rest + break + else: + return jsonify({'error': f'export {safe_path!r} not found'}), 404 + _write_exports(entries) + return jsonify({'status': 'updated'}) + + +@app.route('/api/share/nfs/toggle', methods=['POST']) +@require_auth +def toggle_nfs(): + data = request.json or {} + path = data.get('path', '') + enabled = data.get('enabled', True) + + try: + safe_path = validate_path(path) + except ValueError as e: + return jsonify({'error': str(e)}), 400 + + entries = _parse_exports() + for e in entries: + if e['kind'] == 'export' and e['path'] == safe_path: + e['enabled'] = enabled + break + else: + return jsonify({'error': f'export {safe_path!r} not found'}), 404 + _write_exports(entries) + return jsonify({'status': 'enabled' if enabled else 'disabled'}) + + +# ===== QUOTA ===== + @app.route('/api/quota', methods=['POST']) @require_auth def set_quota(): @@ -210,13 +443,9 @@ def set_quota(): except ValueError as e: return jsonify({'error': str(e)}), 400 - # setquota takes kilobytes; soft = requested, hard = +1 GB buffer soft_kb = str(quota_gb * 1024 * 1024) hard_kb = str((quota_gb + 1) * 1024 * 1024) - subprocess.run( - ['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount], - check=True, - ) + subprocess.run(['setquota', '-u', user, soft_kb, hard_kb, '0', '0', mount], check=True) return jsonify({'status': 'ok'}) @@ -238,7 +467,7 @@ def _list_users(): parts = line.strip().split(':') if len(parts) < 4: continue - name, _, uid = parts[0], parts[1], parts[2] + name, uid = parts[0], parts[2] uid = int(uid) if uid < 1000 or uid == 65534: continue @@ -353,18 +582,14 @@ def _shares_with_usage(): result = [] for s in parse_smb(): result.append({ - 'type': 'smb', - 'name': s['name'], - 'path': s['path'], - 'access': s['valid_users'], + 'type': 'smb', 'name': s['name'], 'path': s['path'], + 'access': s['valid_users'], 'enabled': s['enabled'], 'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0, }) for s in get_nfs_shares(): result.append({ - 'type': 'nfs', - 'name': None, - 'path': s['path'], - 'access': s['clients'], + 'type': 'nfs', 'name': None, 'path': s['path'], + 'access': s['clients'], 'enabled': s['enabled'], 'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0, }) return result @@ -388,9 +613,7 @@ def _quota_usage(): if len(parts) < 5 or not re.match(r'^[+-]{2}$', parts[1]): continue try: - used_kb = int(parts[2]) - soft_kb = int(parts[3]) - hard_kb = int(parts[4]) + used_kb, soft_kb, hard_kb = int(parts[2]), int(parts[3]), int(parts[4]) except (ValueError, IndexError): continue if soft_kb == 0 and hard_kb == 0 and used_kb == 0: diff --git a/static/js/shares.js b/static/js/shares.js index 3a7e335..8bd1f76 100644 --- a/static/js/shares.js +++ b/static/js/shares.js @@ -5,39 +5,53 @@ async function loadShares() { const sb = document.getElementById('smbTableBody'); if (!shares.smb?.length) { - sb.innerHTML = `No SMB shares.`; + sb.innerHTML = `No SMB shares.`; } else { - sb.innerHTML = shares.smb.map(s => ` - + sb.innerHTML = shares.smb.map(s => { + const enabled = s.enabled !== false; + const statusDot = ``; + return ` ${esc(s.name)} ${esc(s.path)} ${esc(s.valid_users || '—')} + ${statusDot} - + onclick="openEditModal('smb',${JSON.stringify(s)})">Edit + + + - `).join(''); + `; + }).join(''); } const nb = document.getElementById('nfsTableBody'); if (!shares.nfs?.length) { - nb.innerHTML = `No NFS exports.`; + nb.innerHTML = `No NFS exports.`; } else { - nb.innerHTML = shares.nfs.map(s => ` - + nb.innerHTML = shares.nfs.map(s => { + const enabled = s.enabled !== false; + const statusDot = ``; + return ` ${esc(s.path)} ${esc(s.clients)} + ${statusDot} - + onclick="openEditModal('nfs',${JSON.stringify(s)})">Edit + + + - `).join(''); + `; + }).join(''); } } @@ -58,8 +72,7 @@ async function addSMBShare() { document.getElementById('smbName').value = ''; document.getElementById('smbPath').value = ''; document.getElementById('smbUsers').value = ''; - loadShares(); - loadDashboard(); + loadShares(); loadDashboard(); } async function addNFSShare() { @@ -79,6 +92,127 @@ async function addNFSShare() { document.getElementById('nfsPath').value = ''; document.getElementById('nfsClients').value = ''; document.getElementById('nfsReadOnly').checked = false; - loadShares(); - loadDashboard(); + loadShares(); loadDashboard(); +} + +// ===== EDIT ===== + +let _editCtx = null; + +function openEditModal(type, share) { + _editCtx = { type, original: share }; + document.getElementById('editModalTitle').textContent = + type === 'smb' ? `Edit SMB Share — ${esc(share.name)}` : `Edit NFS Export — ${esc(share.path)}`; + + let body = ''; + if (type === 'smb') { + body = ` +
+ + +
+
+ + +
+
+ + +
`; + } else { + const isRo = /\(ro[,)]/.test(share.clients); + const cleanClients = share.clients.replace(/\s*\(.*?\)/g, '').trim(); + body = ` +
+ + +
+
+ + +
+
+ + +
`; + } + + document.getElementById('editModalBody').innerHTML = body; + new bootstrap.Modal(document.getElementById('editModal')).show(); +} + +async function submitEdit() { + const ctx = _editCtx; + if (!ctx) return; + + let res; + if (ctx.type === 'smb') { + const name = document.getElementById('editName').value.trim(); + const path = document.getElementById('editPath').value.trim(); + const validUsers = document.getElementById('editUsers').value.trim(); + res = await apiFetch(`/api/share/smb/${encodeURIComponent(ctx.original.name)}`, { + method: 'PATCH', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({name, path, validUsers}), + }); + } else { + const clients = document.getElementById('editClients').value.trim(); + const readOnly = document.getElementById('editReadOnly').checked; + res = await apiFetch('/api/share/nfs', { + method: 'PATCH', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({path: ctx.original.path, clients, readOnly}), + }); + } + + if (!res) return; + const data = await res.json(); + bootstrap.Modal.getInstance(document.getElementById('editModal')).hide(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast('Share updated.'); + loadShares(); loadDashboard(); +} + +// ===== DELETE ===== + +async function deleteShare(type, identifier) { + const label = type === 'smb' ? `SMB share "${identifier}"` : `NFS export "${identifier}"`; + if (!confirm(`Delete ${label}? This cannot be undone.`)) return; + + let res; + if (type === 'smb') { + res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}`, { method: 'DELETE' }); + } else { + res = await apiFetch('/api/share/nfs', { + method: 'DELETE', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({path: identifier}), + }); + } + + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast(`${label} deleted.`); + loadShares(); loadDashboard(); +} + +// ===== TOGGLE ===== + +async function toggleShare(type, identifier, enable) { + let res; + if (type === 'smb') { + res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}/toggle`, { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({enabled: enable}), + }); + } else { + res = await apiFetch('/api/share/nfs/toggle', { + method: 'POST', headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({path: identifier, enabled: enable}), + }); + } + + if (!res) return; + const data = await res.json(); + if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger'); + showToast(`Share ${enable ? 'enabled' : 'disabled'}.`); + loadShares(); loadDashboard(); } diff --git a/templates/index.html b/templates/index.html index 503483b..df1b649 100644 --- a/templates/index.html +++ b/templates/index.html @@ -48,10 +48,30 @@ + + +
MicroNAS - +
+ + Sign out +
@@ -147,7 +167,7 @@
Shares
- + @@ -172,7 +192,7 @@
Exports
NamePathValid UsersActions
NamePathValid UsersActions
Loading…
- + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..77d35cb --- /dev/null +++ b/templates/login.html @@ -0,0 +1,41 @@ + + + + + MicroNAS — Login + + + + + + + +
PathClientsActions
PathClientsActions
Loading…