import os import re import pwd import grp import hmac import shutil import hashlib import subprocess import functools from pathlib import Path from flask import Flask, request, jsonify, render_template, Response, session, redirect, url_for app = Flask(__name__) VALID_NAME = re.compile(r'^[A-Za-z0-9_-]+$') VALID_NFS_CLIENT = re.compile(r'^[A-Za-z0-9.*:/\-]+$') ALLOWED_PATH_BASE = Path('/srv').resolve() _CONF_DIR = '/etc/micronas' _ENV_FILE = '/etc/micronas/micronas.env' # Mutable credential store — updated by setup/change without restart (single gunicorn worker). _creds = { 'user': os.environ.get('MICRONAS_USER', ''), 'hash': os.environ.get('MICRONAS_PASS_HASH', ''), # PBKDF2: "salt$hex" 'plain': os.environ.get('MICRONAS_PASS', ''), # legacy plaintext } def _is_configured(): return bool(_creds['user'] and (_creds['hash'] or _creds['plain'])) def _hash_password(password): salt = os.urandom(16).hex() h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 260_000).hex() return f'{salt}${h}' def _verify_password(password): if _creds['hash']: try: salt, h = _creds['hash'].split('$', 1) computed = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 260_000).hex() return hmac.compare_digest(computed, h) except Exception: return False if _creds['plain']: return hmac.compare_digest(password, _creds['plain']) return False def _save_credentials(user, password): h = _hash_password(password) _ensure_conf_dir() Path(_ENV_FILE).write_text(f'MICRONAS_USER={user}\nMICRONAS_PASS_HASH={h}\n') os.chmod(_ENV_FILE, 0o600) _creds['user'] = user _creds['hash'] = h _creds['plain'] = '' app.secret_key = hashlib.sha256(h.encode()).hexdigest() # Derive initial secret key _secret_src = _creds['hash'] or _creds['plain'] or 'insecure-dev-key' app.secret_key = os.environ.get('MICRONAS_SECRET') or \ hashlib.sha256(_secret_src.encode()).hexdigest() if not _is_configured(): import warnings warnings.warn('No credentials configured — first-time setup required at /setup', stacklevel=1) def require_auth(f): @functools.wraps(f) def decorated(*args, **kwargs): if not _is_configured(): if request.path.startswith('/api/'): return Response('Setup required', 403) return redirect(url_for('setup')) 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): try: p = Path(path_str).resolve() except Exception: raise ValueError('invalid path') allowed = str(ALLOWED_PATH_BASE) if not (str(p) == allowed or str(p).startswith(allowed + '/')): raise ValueError('path must be under /srv') return str(p) def _path_stat(path): """Return owner, group, and octal mode for a path. Empty strings on error.""" try: s = os.stat(path) return { 'owner': pwd.getpwuid(s.st_uid).pw_name, 'group': grp.getgrgid(s.st_gid).gr_name, 'mode': oct(s.st_mode)[-3:], } except Exception: return {'owner': '', 'group': '', 'mode': ''} _VALID_SMB_ENTRY = re.compile(r'^@?[A-Za-z0-9_-]+$') # allows @groupname for SMB def _validate_users(valid_users): """Validate and return a cleaned comma-separated user list (SMB: allows @group).""" users = [] for u in re.split(r'[\s,]+', valid_users): if not u: continue if not _VALID_SMB_ENTRY.match(u): raise ValueError(f'invalid username: {u!r}') users.append(u) return ', '.join(users) def _validate_webdav_users(valid_users): """Validate and return space-separated WebDAV usernames (no @groups).""" users = [] for u in re.split(r'[\s,]+', valid_users): if not u: continue if not VALID_NAME.match(u): raise ValueError(f'invalid WebDAV username: {u!r} (no groups)') 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 = [] current = None for line in result.stdout.splitlines(): line = line.rstrip() m = re.match(r'^\[(.+)\]$', line) 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': '', 'enabled': True, 'guest_ok': False, 'read_only': False, 'force_user': '', 'force_group': '', 'recycle_bin': False} elif current is not None and '=' in line: key, _, val = line.partition('=') key = key.strip().lower().replace(' ', '_') val = val.strip() if key == 'path': current['path'] = val elif key == 'valid_users': current['valid_users'] = val elif key == 'available': current['enabled'] = val.lower() not in ('no', 'false', '0') elif key == 'guest_ok': current['guest_ok'] = val.lower() in ('yes', 'true', '1') elif key in ('writable', 'writeable'): current['read_only'] = val.lower() in ('no', 'false', '0') elif key == 'read_only': current['read_only'] = val.lower() in ('yes', 'true', '1') elif key == 'force_user': current['force_user'] = val elif key == 'force_group': current['force_group'] = val elif key == 'vfs_objects' and 'recycle' in val: current['recycle_bin'] = True if current is not None and current['name'].lower() not in _SMB_SKIP: shares.append(current) return shares def add_smb_share(name, path, valid_users): config = ( f'\n[{name}]\n' f' path = {path}\n' f' valid users = {valid_users}\n' ' writable = yes\n' ' browseable = yes\n' ) with open('/etc/samba/smb.conf', 'a') as f: f.write(config) subprocess.run(['smbcontrol', 'all', 'reload-config'], check=True) # ===== NFS ===== _DISABLED_PREFIX = '## ' # marker MicroNAS uses for disabled exports def _parse_exports(): """Parse /etc/exports. Only lines starting with '## /' are treated as disabled exports — everything else that starts with '#' is a plain comment and is left untouched.""" entries = [] try: with open('/etc/exports', 'r') as f: for raw in f: stripped = raw.strip() if not stripped: entries.append({'kind': 'blank', 'raw': raw}) continue if stripped.startswith(_DISABLED_PREFIX + '/'): inner = stripped[len(_DISABLED_PREFIX):] parts = inner.split(None, 1) entries.append({'kind': 'export', 'enabled': False, 'path': parts[0], 'rest': parts[1] if len(parts) > 1 else ''}) continue if stripped.startswith('#'): 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 ''}) except FileNotFoundError: pass 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 _DISABLED_PREFIX 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(): shares = [] for e in _parse_exports(): if e['kind'] != 'export': continue rest = e['rest'] shares.append({ 'path': e['path'], 'clients': rest, 'enabled': e['enabled'], 'no_root_squash': 'no_root_squash' in rest, 'all_squash': 'all_squash' in rest, }) return shares def add_nfs_share(path, clients, ro=False): options = 'ro' if ro else 'rw' export_line = f'{path}\t{clients}({options},sync,no_subtree_check)\n' with open('/etc/exports', 'a') as f: f.write(export_line) subprocess.run(['exportfs', '-ra'], check=True) # ===== WEBDAV ===== WEBDAV_CONF = '/etc/apache2/conf-available/micronas-webdav.conf' WEBDAV_PASSWD = '/etc/micronas/webdav.passwd' _WEBDAV_MARK = 'MICRONAS' def _parse_webdav_conf(): shares = [] try: content = Path(WEBDAV_CONF).read_text() except FileNotFoundError: return shares pattern = re.compile( r'# BEGIN ' + _WEBDAV_MARK + r' (\S+)\n(.*?)# END ' + _WEBDAV_MARK + r' \1', re.DOTALL, ) for m in pattern.finditer(content): name = m.group(1) block = m.group(2) alias_m = re.search(r'^Alias\s+\S+\s+(\S+)', block, re.M) users_m = re.search(r'Require user (.+)$', block, re.M) shares.append({ 'name': name, 'path': alias_m.group(1).strip() if alias_m else '', 'users': users_m.group(1).strip() if users_m else '', }) return shares def _ensure_conf_dir(): """Create /etc/micronas with correct ownership so Apache can traverse it.""" os.makedirs(_CONF_DIR, mode=0o750, exist_ok=True) subprocess.run(['chown', 'root:www-data', _CONF_DIR], capture_output=True) def _write_webdav_conf(shares): _ensure_conf_dir() os.makedirs('/var/lib/apache2/webdav', exist_ok=True) subprocess.run(['chown', 'www-data:www-data', '/var/lib/apache2/webdav'], capture_output=True) lines = ['# Managed by MicroNAS — do not edit manually\n\n', 'DavLockDB /var/lib/apache2/webdav/DavLock\n\n'] for s in shares: require = f'Require user {s["users"]}' if s.get('users') else 'Require valid-user' lines += [ f'# BEGIN {_WEBDAV_MARK} {s["name"]}\n', f'Alias /dav/{s["name"]} {s["path"]}\n', f'\n', ' Options Indexes\n', ' AllowOverride None\n', ' Require all granted\n', '\n', f'\n', ' DAV On\n', ' AuthType Basic\n', ' AuthName "MicroNAS WebDAV"\n', f' AuthUserFile {WEBDAV_PASSWD}\n', f' {require}\n', '\n', f'# END {_WEBDAV_MARK} {s["name"]}\n\n', ] with open(WEBDAV_CONF, 'w') as f: f.writelines(lines) # Ensure passwd file exists and is readable by www-data (Apache) but not world-readable if not os.path.exists(WEBDAV_PASSWD): Path(WEBDAV_PASSWD).touch(mode=0o640) subprocess.run(['chown', 'root:www-data', WEBDAV_PASSWD], capture_output=True) subprocess.run(['a2enconf', 'micronas-webdav'], capture_output=True) subprocess.run(['systemctl', 'reload', 'apache2'], capture_output=True) def _set_webdav_password(name, password): """Add or update a user in the WebDAV htpasswd file. Silent if htpasswd not installed.""" flag = '-ci' if not os.path.exists(WEBDAV_PASSWD) else '-i' try: subprocess.run(['htpasswd', flag, WEBDAV_PASSWD, name], input=password, capture_output=True, text=True) except FileNotFoundError: pass # apache2-utils not installed — skip def _remove_webdav_user(name): if os.path.exists(WEBDAV_PASSWD): try: subprocess.run(['htpasswd', '-D', WEBDAV_PASSWD, name], capture_output=True) except FileNotFoundError: pass @app.route('/api/share/webdav', methods=['POST']) @require_auth def create_webdav(): data = request.json or {} name = data.get('name', '').strip() path = data.get('path', '') users = data.get('users', '') if not VALID_NAME.match(name): return jsonify({'error': 'invalid share name'}), 400 try: safe_path = validate_path(path) clean_users = _validate_webdav_users(users) if users else '' except ValueError as e: return jsonify({'error': str(e)}), 400 shares = _parse_webdav_conf() if any(s['name'] == name for s in shares): return jsonify({'error': f'share {name!r} already exists'}), 400 shares.append({'name': name, 'path': safe_path, 'users': clean_users}) _write_webdav_conf(shares) return jsonify({'status': 'created'}) @app.route('/api/share/webdav/', methods=['PATCH']) @require_auth def edit_webdav(name): data = request.json or {} new_name = data.get('name', name).strip() path = data.get('path', '') users = data.get('users', '') if not VALID_NAME.match(new_name): return jsonify({'error': 'invalid share name'}), 400 try: safe_path = validate_path(path) clean_users = _validate_webdav_users(users) if users else '' except ValueError as e: return jsonify({'error': str(e)}), 400 shares = _parse_webdav_conf() for s in shares: if s['name'] == name: s['name'] = new_name s['path'] = safe_path s['users'] = clean_users break else: return jsonify({'error': f'share {name!r} not found'}), 404 _write_webdav_conf(shares) return jsonify({'status': 'updated'}) @app.route('/api/share/webdav/', methods=['DELETE']) @require_auth def delete_webdav(name): if not VALID_NAME.match(name): return jsonify({'error': 'invalid share name'}), 400 shares = _parse_webdav_conf() filtered = [s for s in shares if s['name'] != name] if len(filtered) == len(shares): return jsonify({'error': f'share {name!r} not found'}), 404 _write_webdav_conf(filtered) return jsonify({'status': 'deleted'}) # ===== AUTH ROUTES ===== @app.route('/setup', methods=['GET', 'POST']) def setup(): if _is_configured(): return redirect(url_for('index')) error = None username = 'admin' if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '') confirm = request.form.get('confirm', '') if not username or not VALID_NAME.match(username): error = 'Invalid username (letters, digits, _ - only).' elif len(password) < 8: error = 'Password must be at least 8 characters.' elif password != confirm: error = 'Passwords do not match.' else: _save_credentials(username, password) return redirect(url_for('login')) return render_template('setup.html', error=error, username=username) @app.route('/login', methods=['GET', 'POST']) def login(): if not _is_configured(): return redirect(url_for('setup')) 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 username == _creds['user'] and _verify_password(password): 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 def index(): return render_template('index.html') @app.route('/api/shares', methods=['GET']) @require_auth def get_all_shares(): smb = parse_smb() for s in smb: s.update(_path_stat(s['path'])) nfs = get_nfs_shares() for s in nfs: s.update(_path_stat(s['path'])) webdav = _parse_webdav_conf() for s in webdav: s.update(_path_stat(s['path'])) return jsonify({'smb': smb, 'nfs': nfs, 'webdav': webdav}) # ===== SMB CRUD ===== @app.route('/api/share/smb', methods=['POST']) @require_auth def create_smb(): data = request.json or {} name = data.get('name', '') path = data.get('path', '') valid_users = data.get('validUsers', '') if not VALID_NAME.match(name): return jsonify({'error': 'invalid share name (letters, digits, _ -)'}), 400 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, 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', '') force_user = data.get('forceUser', '').strip() force_group = data.get('forceGroup', '').strip() guest_ok = data.get('guestOk', False) read_only = data.get('readOnly', False) recycle_bin = data.get('recycleBin', False) if not VALID_NAME.match(new_name): return jsonify({'error': 'invalid share name'}), 400 if force_user and not VALID_NAME.match(force_user): return jsonify({'error': 'invalid force user'}), 400 if force_group and not VALID_NAME.match(force_group): return jsonify({'error': 'invalid force group'}), 400 try: clean_users = _validate_users(valid_users) safe_path = validate_path(path) except ValueError as e: return jsonify({'error': str(e)}), 400 lines = [ f'[{new_name}]\n', f' path = {safe_path}\n', f' valid users = {clean_users}\n', f' {"read only" if read_only else "writable"} = yes\n', ' browseable = yes\n', ] if force_user: lines.append(f' force user = {force_user}\n') if force_group: lines.append(f' force group = {force_group}\n') if guest_ok: lines.append(' guest ok = yes\n') if recycle_bin: lines.append(' vfs objects = recycle\n') lines.append(' recycle:repository = .recycle\n') lines.append(' recycle:keeptree = yes\n') lines.append(' recycle:versions = yes\n') sections = _parse_smb_conf() for s in sections: if s['name'] == name: s['name'] = new_name s['lines'] = lines 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(): 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 add_nfs_share(safe_path, clients, ro) 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 no_root_squash = data.get('noRootSquash', False) all_squash = data.get('allSquash', False) options = ','.join(filter(None, [ 'ro' if ro else 'rw', 'sync', 'no_subtree_check', 'no_root_squash' if no_root_squash else None, 'all_squash' if all_squash else None, ])) new_rest = f'{clients}({options})' 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'}) # ===== PERMISSIONS ===== @app.route('/api/permissions', methods=['POST']) @require_auth def set_permissions(): data = request.json or {} path = data.get('path', '') owner = data.get('owner', '').strip() group = data.get('group', '').strip() mode = data.get('mode', '').strip() try: safe_path = validate_path(path) except ValueError as e: return jsonify({'error': str(e)}), 400 if owner and not VALID_NAME.match(owner): return jsonify({'error': 'invalid owner name'}), 400 if group and not VALID_NAME.match(group): return jsonify({'error': 'invalid group name'}), 400 if mode and not re.match(r'^[0-7]{3,4}$', mode): return jsonify({'error': 'mode must be 3-4 octal digits (e.g. 770)'}), 400 if owner or group: chown_arg = f'{owner}:{group}' if (owner and group) else (owner or f':{group}') r = subprocess.run(['chown', chown_arg, safe_path], capture_output=True, text=True) if r.returncode != 0: return jsonify({'error': r.stderr.strip() or 'chown failed'}), 500 if mode: r = subprocess.run(['chmod', mode, safe_path], capture_output=True, text=True) if r.returncode != 0: return jsonify({'error': r.stderr.strip() or 'chmod failed'}), 500 return jsonify({'status': 'ok', 'stat': _path_stat(safe_path)}) # ===== GROUPS ===== _GROUP_WHITELIST = {'root', 'staff', 'sambashare', 'www-data'} _USER_WHITELIST = {'www-data'} # system accounts useful as folder owners def _list_groups(): groups = [] with open('/etc/group', 'r') as f: for line in f: parts = line.strip().split(':') if len(parts) < 4: continue name, gid_str, members_str = parts[0], parts[2], parts[3] try: gid = int(gid_str) except ValueError: continue members = [m for m in members_str.split(',') if m] if gid >= 1000 or name in _GROUP_WHITELIST: groups.append({'name': name, 'gid': gid, 'members': members}) return sorted(groups, key=lambda g: g['name']) @app.route('/api/groups', methods=['GET']) @require_auth def list_groups(): return jsonify(_list_groups()) @app.route('/api/groups', methods=['POST']) @require_auth def create_group(): name = (request.json or {}).get('name', '') if not VALID_NAME.match(name): return jsonify({'error': 'invalid group name'}), 400 r = subprocess.run(['groupadd', name], capture_output=True, text=True) if r.returncode != 0: return jsonify({'error': r.stderr.strip() or 'groupadd failed'}), 400 return jsonify({'status': 'created'}) @app.route('/api/groups/', methods=['DELETE']) @require_auth def delete_group(name): if not VALID_NAME.match(name): return jsonify({'error': 'invalid group name'}), 400 r = subprocess.run(['groupdel', name], capture_output=True, text=True) if r.returncode != 0: return jsonify({'error': r.stderr.strip() or 'groupdel failed'}), 500 return jsonify({'status': 'deleted'}) @app.route('/api/groups//members', methods=['POST']) @require_auth def add_group_member(name): if not VALID_NAME.match(name): return jsonify({'error': 'invalid group name'}), 400 user = (request.json or {}).get('user', '') if not VALID_NAME.match(user): return jsonify({'error': 'invalid username'}), 400 r = subprocess.run(['gpasswd', '-a', user, name], capture_output=True, text=True) if r.returncode != 0: return jsonify({'error': r.stderr.strip() or 'gpasswd failed'}), 500 return jsonify({'status': 'added'}) @app.route('/api/groups//members/', methods=['DELETE']) @require_auth def remove_group_member(name, user): if not VALID_NAME.match(name) or not VALID_NAME.match(user): return jsonify({'error': 'invalid name'}), 400 r = subprocess.run(['gpasswd', '-d', user, name], capture_output=True, text=True) if r.returncode != 0: return jsonify({'error': r.stderr.strip() or 'gpasswd failed'}), 500 return jsonify({'status': 'removed'}) # ===== USERS ===== def _smb_users(): try: r = subprocess.run(['pdbedit', '-L'], capture_output=True, text=True) return {line.split(':')[0].strip() for line in r.stdout.splitlines() if ':' in line} except FileNotFoundError: return set() def _list_users(): smb = _smb_users() users = [] with open('/etc/passwd', 'r') as f: for line in f: parts = line.strip().split(':') if len(parts) < 4: continue name, uid = parts[0], parts[2] uid = int(uid) if uid < 1000 or uid == 65534: if name not in _USER_WHITELIST: continue users.append({'name': name, 'uid': uid, 'smb': name in smb}) return users def _set_smb_password(name, password): r = subprocess.run( ['smbpasswd', '-a', '-s', name], input=f'{password}\n{password}\n', capture_output=True, text=True, ) if r.returncode != 0: raise RuntimeError(r.stderr.strip() or 'smbpasswd failed') @app.route('/api/users', methods=['GET']) @require_auth def list_users(): return jsonify(_list_users()) @app.route('/api/users', methods=['POST']) @require_auth def create_user(): data = request.json or {} name = data.get('name', '') password = data.get('password', '') if not VALID_NAME.match(name): return jsonify({'error': 'invalid username (letters, digits, _ -)'}), 400 if len(password) < 8: return jsonify({'error': 'password must be at least 8 characters'}), 400 try: subprocess.run(['useradd', '-M', '-s', '/usr/sbin/nologin', name], check=True) except subprocess.CalledProcessError: return jsonify({'error': f'user {name!r} already exists or useradd failed'}), 400 try: _set_smb_password(name, password) except RuntimeError as e: return jsonify({'error': str(e)}), 500 _set_webdav_password(name, password) return jsonify({'status': 'created'}) @app.route('/api/users/', methods=['DELETE']) @require_auth def delete_user(name): if not VALID_NAME.match(name): return jsonify({'error': 'invalid username'}), 400 subprocess.run(['smbpasswd', '-x', name], capture_output=True) _remove_webdav_user(name) try: subprocess.run(['userdel', name], check=True) except subprocess.CalledProcessError as e: return jsonify({'error': f'userdel failed: {e}'}), 500 return jsonify({'status': 'deleted'}) @app.route('/api/admin/credentials', methods=['POST']) @require_auth def change_admin_credentials(): data = request.json or {} current_pw = data.get('currentPassword', '') new_user = data.get('username', '').strip() new_pw = data.get('password', '') if not _verify_password(current_pw): return jsonify({'error': 'Current password is incorrect.'}), 403 if new_user and not VALID_NAME.match(new_user): return jsonify({'error': 'Invalid username.'}), 400 if new_pw and len(new_pw) < 8: return jsonify({'error': 'Password must be at least 8 characters.'}), 400 user = new_user or _creds['user'] pw = new_pw or current_pw _save_credentials(user, pw) session.clear() return jsonify({'status': 'updated'}) @app.route('/api/users//password', methods=['POST']) @require_auth def change_password(name): if not VALID_NAME.match(name): return jsonify({'error': 'invalid username'}), 400 data = request.json or {} password = data.get('password', '') if len(password) < 8: return jsonify({'error': 'password must be at least 8 characters'}), 400 try: _set_smb_password(name, password) except RuntimeError as e: return jsonify({'error': str(e)}), 500 _set_webdav_password(name, password) return jsonify({'status': 'updated'}) # ===== DASHBOARD ===== def _daemon_status(): def is_active(service): r = subprocess.run(['systemctl', 'is-active', service], capture_output=True, text=True) return r.stdout.strip() == 'active' return { 'smbd': is_active('smbd'), 'nfsd': is_active('nfs-server'), 'apache2': is_active('apache2'), } def _server_ips(): """Return list of {iface, ip} for all non-loopback IPv4 addresses.""" try: r = subprocess.run(['ip', '-4', '-o', 'addr', 'show'], capture_output=True, text=True) result = [] for line in r.stdout.splitlines(): # Format: : inet ... m = re.match(r'\d+:\s+(\S+)\s+inet\s+([\d.]+)', line) if m and m.group(1) != 'lo': result.append({'iface': m.group(1), 'ip': m.group(2)}) return result except Exception: return [] def _active_connections(): """Parse smbstatus output to return list of active SMB sessions.""" try: r = subprocess.run(['smbstatus', '-b'], capture_output=True, text=True, timeout=5) sessions = [] in_section = False for line in r.stdout.splitlines(): if re.match(r'-{10,}', line): in_section = True continue if not in_section or not line.strip(): continue # "PID Username Group Machine Protocol Encryption Signing" # Skip the header row if line.strip().startswith('PID'): continue parts = line.split() if len(parts) >= 4: sessions.append({'user': parts[1], 'machine': parts[3]}) return sessions except Exception: return [] @app.route('/api/service//restart', methods=['POST']) @require_auth def restart_service(name): service_map = {'smbd': 'smbd', 'nfsd': 'nfs-server', 'apache2': 'apache2'} if name not in service_map: return jsonify({'error': 'unknown service'}), 400 r = subprocess.run(['systemctl', 'restart', service_map[name]], capture_output=True, text=True) if r.returncode != 0: return jsonify({'error': r.stderr.strip() or 'restart failed'}), 500 return jsonify({'status': 'restarted'}) def _disk_usage(): try: u = shutil.disk_usage('/srv') return { 'path': '/srv', 'total_gb': round(u.total / 1024**3, 1), 'used_gb': round(u.used / 1024**3, 1), 'free_gb': round(u.free / 1024**3, 1), 'percent': round(u.used / u.total * 100, 1) if u.total else 0, } except Exception as e: return {'error': str(e)} def _du_bytes(path): try: r = subprocess.run(['du', '-sb', path], capture_output=True, text=True, timeout=15) if r.returncode == 0: parts = r.stdout.split() return int(parts[0]) if parts else 0 except Exception: pass return 0 def _shares_with_usage(): result = [] for s in parse_smb(): result.append({ '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'], 'enabled': s['enabled'], 'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0, }) for s in _parse_webdav_conf(): result.append({ 'type': 'webdav', 'name': s['name'], 'path': s['path'], 'access': s['users'] or 'all', 'enabled': True, 'size_gb': round(_du_bytes(s['path']) / 1024**3, 2) if s['path'] else 0, }) return result @app.route('/api/dashboard', methods=['GET']) @require_auth def get_dashboard(): return jsonify({ 'status': _daemon_status(), 'disk': _disk_usage(), 'shares': _shares_with_usage(), 'ips': _server_ips(), 'connections': _active_connections(), }) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)