import os import re import subprocess import functools from pathlib import Path from flask import Flask, request, jsonify, send_file, Response 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() _AUTH_USER = os.environ.get('MICRONAS_USER', 'admin') _AUTH_PASS = os.environ.get('MICRONAS_PASS', '') if not _AUTH_PASS: import warnings warnings.warn( 'MICRONAS_PASS is not set — all routes are unprotected! ' 'Set it via the environment or /etc/micronas.env.', stacklevel=1, ) def require_auth(f): @functools.wraps(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"'}, ) 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: 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) # ===== SMB ===== _SMB_SKIP = {'global', 'homes', 'printers', 'print$', 'ipc$'} 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': ''} 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 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 ===== def get_nfs_shares(): shares = [] try: with open('/etc/exports', 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue parts = line.split() shares.append({ 'path': parts[0], 'clients': ' '.join(parts[1:]) if len(parts) > 1 else '*', }) except FileNotFoundError: pass 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) # ===== ROUTES ===== @app.route('/') @require_auth def index(): return send_file(os.path.join(os.path.dirname(__file__), 'index.html')) @app.route('/api/shares', methods=['GET']) @require_auth def get_all_shares(): return jsonify({'smb': parse_smb(), 'nfs': get_nfs_shares()}) @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 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: safe_path = validate_path(path) except ValueError as e: return jsonify({'error': str(e)}), 400 add_smb_share(name, safe_path, ', '.join(users)) return jsonify({'type': 'smb', 'status': 'created'}) @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/quota', methods=['POST']) @require_auth def set_quota(): data = request.json or {} user = data.get('user', '') quota_type = data.get('type', 'smb') raw_quota = data.get('quota', 0) path = data.get('path', '/srv/samba') if not VALID_NAME.match(user): return jsonify({'error': 'invalid username'}), 400 try: quota_gb = int(raw_quota) if quota_gb <= 0: raise ValueError() except (ValueError, TypeError): return jsonify({'error': 'quota must be a positive integer (GB)'}), 400 if quota_type == 'smb': mount = '/srv/samba' else: try: mount = validate_path(path) 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, ) return jsonify({'status': 'ok'}) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)