From f2aca2e3eafd37cc25be880e4d400b5a7db7d3ba Mon Sep 17 00:00:00 2001 From: Honey Date: Fri, 19 Jun 2026 21:02:46 +0200 Subject: [PATCH] Initial commit: MicroNAS share manager (Phase 1) --- .gitignore | 7 ++ CLAUDE.md | 43 ++++++++ PLANNING.md | 96 ++++++++++++++++++ README.md | 46 +++++++++ app.py | 223 ++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 17 ++++ fileshare-web.service | 16 +++ index.html | 223 ++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 671 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 PLANNING.md create mode 100644 README.md create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 fileshare-web.service create mode 100644 index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6d3983e --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +venv/ +.venv/ +*.env +fileshare-web.env +.DS_Store diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..41e80d1 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ +# CLAUDE.md + +Project context for Claude Code. See `PLANNING.md` for the full roadmap. + +## What this is +A lightweight web GUI to manage file shares on a single home server (Proxmox +LXC/VM) — a minimal NAS control panel. It manages **SMB shares**, **NFS +exports**, and **quotas** by editing host config files and reloading the +daemons. Home/LAN use only. + +## Files +- `app.py` — Flask backend (API + share/export/quota logic). +- `index.html` — Bootstrap single-page UI. +- `docker-compose.yml` — optional dev/test sandbox only. +- `PLANNING.md` — roadmap and feature list. + +## How it runs (target) +Directly on the LXC/VM as a `systemd` service, Flask behind `gunicorn`. +**Not** in Docker for production — the app manipulates host services +(`smbd`, `nfsd`, `/etc/exports`, `exportfs`, `setquota`), so it must run on the +host. Docker stays only as a test sandbox. + +## Conventions & constraints +- **Keep it simple.** No database, no auth framework, dependency-light. Config + files (`/etc/samba/smb.conf`, `/etc/exports`) are the source of truth. +- **Security basics are non-negotiable even for home use:** + - Never use `subprocess.run(..., shell=True)` with user input. Use argument + lists. + - Validate all input: names `^[A-Za-z0-9_-]+$`; paths must resolve under + `/srv`. + - All routes require auth (single-user HTTP Basic Auth from an env var). +- **Config edits must be safe:** parse into blocks and rewrite whole files for + edit/delete — do not blindly append. +- LAN-only. Do not add internet-facing features. + +## Out of scope +Backups (handled by Proxmox Backup Server), multi-user roles, databases, +internet exposure, multi-host management. + +## Working agreement +- Work in the phases defined in `PLANNING.md`; pause after each phase for the + user to test. +- Explain changes briefly. Don't add heavy dependencies without asking. diff --git a/PLANNING.md b/PLANNING.md new file mode 100644 index 0000000..dbeb302 --- /dev/null +++ b/PLANNING.md @@ -0,0 +1,96 @@ +# Fileshare Web — Planning + +A lightweight web GUI to manage file shares on a single home server — like a +minimal NAS control panel for a Proxmox LXC/VM. Manages **SMB shares**, +**NFS exports**, and **quotas** by editing the host config files and reloading +the daemons. + +**Design principle: keep it small.** Single box, home/LAN use only, no database, +no auth framework, dependency-light. If a feature needs heavy machinery, it's +out of scope. + +--- + +## Deployment decision + +Run **directly on the LXC/VM as a `systemd` service** (Flask behind `gunicorn`). +**Not** Docker. + +Reason: this app's whole job is to manipulate *host* services (`smbd`, `nfsd`, +`/etc/exports`, `exportfs`, `setquota`). Containerizing it means bind-mounting +host config, running privileged, and fighting the kernel NFS server — all the +downsides of running on the host with none of the isolation benefit. Docker is +kept only as an optional dev/test sandbox. + +**Network:** LAN-only. Never expose to the internet. Reach it via local network +or Tailscale. Auth is a safety net, not an internet-facing defense. + +**Backups:** Out of scope. Handled at the Proxmox layer (Proxmox Backup Server +on a second client). This app does not implement backup. + +--- + +## Current state (baseline) + +- `app.py` — Flask API: list/create SMB & NFS shares, set quotas. +- `index.html` — Bootstrap UI with SMB / NFS / Quotas tabs. +- `docker-compose.yml` — test sandbox. + +### Known issues to fix +- `parse_smb()` is called (`app.py:13`) but never defined → `/api/shares` 500s. +- `subprocess.run(cmd, shell=True)` with f-string input (`app.py:82`) → command + injection as root. +- `add_smb_share` writes raw user input into `smb.conf` → config injection via + newlines. +- No authentication on any route. +- Config is append-only (`open(..., 'a')`) → no working delete/edit; UI Delete + buttons are wired to nothing. +- No input validation on names or paths (path traversal, share of `/`). + +--- + +## Roadmap + +### Phase 1 — Correctness & safety (must-have) +- [ ] Implement `parse_smb()` so `/api/shares` works. +- [ ] Remove all `shell=True`; use argument-list `subprocess` calls. +- [ ] Validate input: + - share/user names: `^[A-Za-z0-9_-]+$` + - paths must resolve under `/srv` + - reject anything else with a clear error. +- [ ] Simple login: HTTP Basic Auth, single user + password from env var, + protecting all routes. +- [ ] `systemd` unit + `gunicorn`, plus README run instructions. + +### Phase 2 — Dashboard & health (the main goal) +A landing page that shows the state of the server at a glance: +- [ ] **Daemon status** — is `smbd` running? is `nfsd` running? (green/red). +- [ ] **Disk usage** — free / used / total for `/srv` (and per-disk if simple). +- [ ] **All shares in one view** — every SMB share and NFS export, their paths, + and who can access them. +- [ ] **Per-share data used** — size on disk of each share's path (`du`). +- [ ] **Quota usage** — for each user with a quota: used vs limit, with a + progress bar (e.g. "12 GB / 50 GB"). +- [ ] Auto-refresh or a manual refresh button. + +### Phase 3 — Full management (complete the CRUD) +- [ ] Working **delete** for SMB & NFS (parse config into blocks, rewrite the + whole file — not append-only). +- [ ] **Edit** existing shares. +- [ ] Enable/disable a share without deleting it. + +### Phase 4 — Nice-to-have +- [ ] **WebDAV** support. Preferred approach: don't build it in Flask — have the + app drop an Apache/nginx WebDAV config snippet, same pattern as SMB/NFS, + so it reuses the existing architecture. +- [ ] Simple in-browser file browser for `/srv`. +- [ ] Toast notifications instead of `alert()`. + +--- + +## Out of scope (intentionally) +- Backups (Proxmox Backup Server handles this). +- Multi-user accounts / roles (single admin login is enough). +- Database (config files are the source of truth). +- Internet exposure / TLS termination (LAN + Tailscale only). +- Clustering / multi-host management. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9a0494 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Fileshare Web + +Lightweight web GUI to manage SMB shares, NFS exports, and quotas on a single home server (Proxmox LXC/VM). Runs directly on the host as a systemd service. + +## Requirements + +``` +apt install samba nfs-kernel-server quota python3 python3-venv +``` + +## Install + +```bash +# Copy files +cp -r . /opt/fileshare-web + +# Create virtualenv and install deps +python3 -m venv /opt/fileshare-web/venv +/opt/fileshare-web/venv/bin/pip install flask gunicorn + +# Create environment file with your credentials +cat > /etc/fileshare-web.env < 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) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..711637d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3' +services: + share-manager: + image: python:3.11-slim + volumes: + - ./app.py:/app/app.py + - /etc/samba:/etc/samba + - /etc/exports:/etc/exports + - /srv:/srv + ports: + - "5000:5000" + working_dir: /app + command: > + sh -c "pip install flask && + python app.py" + cap_add: + - SYS_ADMIN \ No newline at end of file diff --git a/fileshare-web.service b/fileshare-web.service new file mode 100644 index 0000000..bd2f3e2 --- /dev/null +++ b/fileshare-web.service @@ -0,0 +1,16 @@ +[Unit] +Description=Fileshare Web GUI +After=network.target smbd.service nfs-server.service +Wants=smbd.service nfs-server.service + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/fileshare-web +EnvironmentFile=/etc/fileshare-web.env +ExecStart=/opt/fileshare-web/venv/bin/gunicorn -w 1 -b 127.0.0.1:5000 app:app +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/index.html b/index.html new file mode 100644 index 0000000..b74974d --- /dev/null +++ b/index.html @@ -0,0 +1,223 @@ + + + + SMB + NFS Manager + + + + +
+

📁 Share Manager (SMB + NFS)

+ + + + + +
+

SMB Shares

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + +
NamePathValid UsersActions
+
+
+ + +
+

NFS Exports

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + +
PathClientsOptionsActions
+
+
+ + +
+

Set Quotas

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ +
+ + + + + + \ No newline at end of file