import os import re import pwd import grp import hmac import shutil import hashlib import warnings 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 from stored credentials _secret_src = _creds['hash'] or _creds['plain'] or 'insecure-dev-key' app.secret_key = hashlib.sha256(_secret_src.encode()).hexdigest() if not _is_configured(): 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)