700 blocked www-data from entering the dir even though the passwd file had correct group permissions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1099 lines
36 KiB
Python
1099 lines
36 KiB
Python
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)
|
|
os.makedirs(_CONF_DIR, mode=0o750, exist_ok=True)
|
|
subprocess.run(['chown', 'root:www-data', _CONF_DIR], capture_output=True)
|
|
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 _write_webdav_conf(shares):
|
|
os.makedirs('/var/lib/apache2/webdav', exist_ok=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'<Directory {s["path"]}>\n',
|
|
' DAV On\n',
|
|
' AuthType Basic\n',
|
|
' AuthName "MicroNAS WebDAV"\n',
|
|
f' AuthUserFile {WEBDAV_PASSWD}\n',
|
|
f' {require}\n',
|
|
' Options Indexes\n',
|
|
' AllowOverride None\n',
|
|
'</Directory>\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/<name>', 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/<name>', 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/<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', '')
|
|
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/<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():
|
|
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'}
|
|
|
|
|
|
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/<name>', 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/<name>/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/<name>/members/<user>', 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:
|
|
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/<name>', 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/<name>', methods=['PATCH'])
|
|
@require_auth
|
|
def rename_user(name):
|
|
if not VALID_NAME.match(name):
|
|
return jsonify({'error': 'invalid username'}), 400
|
|
new_name = (request.json or {}).get('name', '').strip()
|
|
if not new_name or not VALID_NAME.match(new_name):
|
|
return jsonify({'error': 'invalid new username'}), 400
|
|
if new_name == name:
|
|
return jsonify({'status': 'no change'}), 200
|
|
|
|
r = subprocess.run(['usermod', '-l', new_name, name], capture_output=True, text=True)
|
|
if r.returncode != 0:
|
|
return jsonify({'error': r.stderr.strip() or 'usermod failed'}), 500
|
|
|
|
# Remove SMB and WebDAV entries — password must be reset after rename
|
|
subprocess.run(['smbpasswd', '-x', name], capture_output=True)
|
|
_remove_webdav_user(name)
|
|
return jsonify({'status': 'renamed',
|
|
'warning': 'SMB and WebDAV password must be reset for the renamed user.'})
|
|
|
|
|
|
@app.route('/api/users/<name>/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: <idx>: <iface> inet <ip/prefix> ...
|
|
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/<name>/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)
|