Phase 3: full share CRUD + session login page
Backend: - SMB config parser/writer (_parse_smb_conf, _write_smb_conf) for safe whole-file rewrites instead of append-only - NFS exports parser/writer (_parse_exports, _write_exports) with support for disabled (commented-out) exports - DELETE/PATCH/toggle endpoints for SMB and NFS shares - parse_smb() now returns enabled field (reads available = no) - get_nfs_shares() uses the new parser and returns enabled field - Replace HTTP Basic Auth with Flask session auth (secret key derived from MICRONAS_PASS so sessions survive restarts) - /login GET/POST, /logout routes Frontend: - Login page (templates/login.html) with same dark theme - Sign out button in header - Edit modal pre-filled with current share values - Delete buttons wired up (with confirm dialog) - Enable/Disable toggle per share (green/grey dot status indicator) - Disabled shares shown at reduced opacity Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
1118681fd2
commit
5d9a9e62ec
4 files changed
+487
-69
No files matched your search
@@ -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/<name>', 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/<name>', 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/<name>/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:
|
||||
|
||||
Reference in new issue
Block a user