Add WebDAV support via Apache mod_dav

Backend:
- WebDAV config managed at /etc/apache2/conf-available/micronas-webdav.conf
  with BEGIN/END markers per share; reload apache2 on every write
- htpasswd file at /etc/micronas-webdav.passwd synced automatically on
  create_user, change_password, delete_user
- Full CRUD: POST/PATCH/DELETE /api/share/webdav[/<name>]
- _validate_webdav_users(): space-separated, no @group (not supported by Apache)
- Fix: _validate_users() now allows @groupname for SMB (was incorrectly rejected)
- Dashboard: apache2 status + restart button; webdav shares in /api/dashboard

Frontend:
- WebDAV tab: create form + table with clickable access URLs
- Edit modal supports 'webdav' type (name, path, users, permissions section)
- badge-webdav CSS class (green tint) in app.css
- dashboard.js renders badge-<type> dynamically (smb/nfs/webdav)

Docs: README WebDAV mount guide (Linux davfs2, Windows, Mac), PLANNING phase 4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Sonnet 4.6 committed 2026-06-20 01:22:19 +02:00
1 parent 1be6a05cef
commit 7c9fe07a17
7 files changed
+338 -19

No files matched your search

+13 -3
View File
@@ -102,9 +102,19 @@ MicroNAS/
- [x] **Delete confirmation modal** — replaces browser confirm().
- [x] **Auto-refresh countdown** — 30 s timer with live seconds display.
### Phase 4 — WebDAV + extras
- [ ] **WebDAV** support. Preferred approach: drop an Apache/nginx config
snippet — don't build it in Flask.
### Phase 4 — WebDAV ✅
- [x] Apache `mod_dav` + `mod_dav_fs` managed by MicroNAS.
Config written to `/etc/apache2/conf-available/micronas-webdav.conf`
with BEGIN/END markers per share; `a2enconf` + `systemctl reload apache2`.
- [x] Shares served at `http://<server>/dav/<name>` (Apache on port 80).
- [x] Auth via htpasswd (`/etc/micronas-webdav.passwd`) — passwords synced
automatically when creating/changing user passwords in the Users tab.
- [x] Full CRUD: create, edit (name/path/users/permissions), delete.
- [x] Dashboard: apache2 status dot + restart button.
- [x] WebDAV badge in dashboard shares overview.
- [x] @groupname fix in SMB valid users (was incorrectly rejected by VALID_NAME).
### Phase 5 — Nice-to-have
- [ ] Simple in-browser file browser for `/srv`.
- [ ] Auto-create share directory on add (with correct owner/permissions).
+29 -1
View File
@@ -21,7 +21,14 @@ Run everything below **inside the LXC container** as root.
```bash
apt update
apt install -y git samba nfs-kernel-server python3 python3-venv
apt install -y git samba nfs-kernel-server apache2 apache2-utils python3 python3-venv
```
Enable Apache WebDAV modules:
```bash
a2enmod dav dav_fs auth_basic authn_file
systemctl restart apache2
```
### 2. Clone the repo
@@ -200,6 +207,27 @@ mount -t nfs <nas-ip>:/srv/data /mnt/nas
**Multiple networks (e.g. LAN + Tailscale):** enter both subnets space-separated in the Clients field when creating the export, e.g. `192.168.1.0/24 100.64.0.0/10`. Both get the same ro/rw setting.
### WebDAV
WebDAV shares are served by Apache at `http://<nas-ip>/dav/<sharename>` (port 80).
```bash
# Mount WebDAV on Linux (davfs2)
apt install davfs2
mount -t davfs http://<nas-ip>/dav/photos /mnt/webdav -o username=alice
# Permanent — /etc/fstab:
http://<nas-ip>/dav/photos /mnt/webdav davfs username=alice,_netdev 0 0
# Store password in /etc/davfs2/secrets:
http://<nas-ip>/dav/photos alice yourpassword
```
On **Windows**: open File Explorer → "Map network drive" → `\\<nas-ip>\DavWWWRoot\dav\photos` (WebDAV via WebClient service), or use `http://<nas-ip>/dav/photos` directly in the address bar.
On **Mac**: Finder → Go → Connect to Server → `http://<nas-ip>/dav/photos`.
User passwords are synced automatically — when you set a password in the Users tab it is also written to the WebDAV htpasswd file. Existing users created before WebDAV was set up need their password changed once to register.
### Useful commands
```bash
+177 -7
View File
@@ -70,16 +70,31 @@ def _path_stat(path):
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, or raise ValueError."""
"""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 username: {u}')
raise ValueError(f'invalid WebDAV username: {u!r} (no groups)')
users.append(u)
return ', '.join(users)
return ' '.join(users)
# ===== SMB =====
@@ -239,6 +254,146 @@ def add_nfs_share(path, clients, ro=False):
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 None\n',
' AllowOverride None\n',
'</Directory>\n',
f'# END {_WEBDAV_MARK} {s["name"]}\n\n',
]
with open(WEBDAV_CONF, 'w') as f:
f.writelines(lines)
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('/login', methods=['GET', 'POST'])
@@ -279,7 +434,10 @@ def get_all_shares():
nfs = get_nfs_shares()
for s in nfs:
s.update(_path_stat(s['path']))
return jsonify({'smb': smb, 'nfs': nfs})
webdav = _parse_webdav_conf()
for s in webdav:
s.update(_path_stat(s['path']))
return jsonify({'smb': smb, 'nfs': nfs, 'webdav': webdav})
# ===== SMB CRUD =====
@@ -667,7 +825,7 @@ def create_user():
_set_smb_password(name, password)
except RuntimeError as e:
return jsonify({'error': str(e)}), 500
_set_webdav_password(name, password)
return jsonify({'status': 'created'})
@@ -677,6 +835,7 @@ 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:
@@ -697,6 +856,7 @@ def change_password(name):
_set_smb_password(name, password)
except RuntimeError as e:
return jsonify({'error': str(e)}), 500
_set_webdav_password(name, password)
return jsonify({'status': 'updated'})
@@ -706,7 +866,11 @@ 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')}
return {
'smbd': is_active('smbd'),
'nfsd': is_active('nfs-server'),
'apache2': is_active('apache2'),
}
def _server_ips():
@@ -751,7 +915,7 @@ def _active_connections():
@app.route('/api/service/<name>/restart', methods=['POST'])
@require_auth
def restart_service(name):
service_map = {'smbd': 'smbd', 'nfsd': 'nfs-server'}
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)
@@ -799,6 +963,12 @@ def _shares_with_usage():
'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
+3 -2
View File
@@ -50,8 +50,9 @@ body {
.progress { background: var(--surf2); border-radius: 4px; }
/* Badges */
.badge-smb { background: #1f3a5f; color: #58a6ff; font-size: .72rem; }
.badge-nfs { background: #2d1f5f; color: #a78bfa; font-size: .72rem; }
.badge-smb { background: #1f3a5f; color: #58a6ff; font-size: .72rem; }
.badge-nfs { background: #2d1f5f; color: #a78bfa; font-size: .72rem; }
.badge-webdav { background: #1f3a2f; color: #3fb950; font-size: .72rem; }
/* Modal */
.modal-content { background: var(--surf); border: 1px solid var(--border); }
+4 -3
View File
@@ -13,8 +13,9 @@ async function loadDashboard() {
document.getElementById(id).style.display = 'flex';
document.getElementById(id).style.alignItems = 'center';
}
setDaemon('statusSmbd', 'smbd', d.status.smbd);
setDaemon('statusNfsd', 'nfsd', d.status.nfsd);
setDaemon('statusSmbd', 'smbd', d.status.smbd);
setDaemon('statusNfsd', 'nfsd', d.status.nfsd);
setDaemon('statusApache2', 'apache2', d.status.apache2);
// Disk bar
if (d.disk && !d.disk.error) {
@@ -43,7 +44,7 @@ async function loadDashboard() {
} else {
sb.innerHTML = d.shares.map(s => `
<tr>
<td><span class="badge rounded-pill ${s.type === 'smb' ? 'badge-smb' : 'badge-nfs'}">${s.type.toUpperCase()}</span></td>
<td><span class="badge rounded-pill badge-${s.type}">${s.type.toUpperCase()}</span></td>
<td>${esc(s.name ?? '—')}</td>
<td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.access || '—')}</td>
+71 -1
View File
@@ -30,6 +30,29 @@ async function loadShares() {
}).join('');
}
const wb = document.getElementById('webdavTableBody');
if (!shares.webdav?.length) {
wb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No WebDAV shares.</td></tr>`;
} else {
wb.innerHTML = shares.webdav.map(s => {
const shareJson = esc(JSON.stringify(s));
const nameJson = esc(JSON.stringify(s.name));
const url = `http://${window.location.hostname}/dav/${esc(s.name)}`;
return `<tr>
<td>${esc(s.name)}</td>
<td><code>${esc(s.path)}</code></td>
<td style="color:var(--muted); font-size:.87em">${esc(s.users || '— all —')}</td>
<td><a href="${url}" target="_blank" style="color:var(--accent); font-size:.85em">${url}</a></td>
<td>
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
onclick="openEditModal('webdav',${shareJson})">Edit</button>
<button class="btn btn-sm btn-danger"
onclick="deleteShare('webdav',${nameJson})">Delete</button>
</td>
</tr>`;
}).join('');
}
const nb = document.getElementById('nfsTableBody');
if (!shares.nfs?.length) {
nb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No NFS exports.</td></tr>`;
@@ -76,6 +99,26 @@ async function addSMBShare() {
loadShares(); loadDashboard();
}
async function addWebDAVShare() {
const name = document.getElementById('webdavName').value.trim();
const path = document.getElementById('webdavPath').value.trim();
const users = document.getElementById('webdavUsers').value.trim();
if (!name || !path) return showToast('Name and path are required.', 'danger');
const res = await apiFetch('/api/share/webdav', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, path, users}),
});
if (!res) return;
const data = await res.json();
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
showToast('WebDAV share created.');
document.getElementById('webdavName').value = '';
document.getElementById('webdavPath').value = '';
document.getElementById('webdavUsers').value = '';
loadShares(); loadDashboard();
}
async function addNFSShare() {
const path = document.getElementById('nfsPath').value.trim();
const clients = document.getElementById('nfsClients').value.trim() || '*';
@@ -200,6 +243,23 @@ async function openEditModal(type, share) {
<label class="form-check-label" for="editRecycleBin" style="color:var(--muted)">Recycle Bin — deleted files go to <code>.recycle</code> instead of being gone</label>
</div>
${permSection}`;
} else if (type === 'webdav') {
body = `
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Share Name</label>
<input type="text" id="editName" class="form-control" value="${esc(share.name)}">
</div>
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Path</label>
<input type="text" id="editPath" class="form-control" value="${esc(share.path)}">
</div>
<div class="mb-3">
<label class="form-label" style="color:var(--muted); font-size:.85rem">Allowed Users
<span style="font-weight:400"> — space-separated, empty = all WebDAV users</span>
</label>
<input type="text" id="editWebdavUsers" class="form-control" value="${esc(share.users || '')}">
</div>
${permSection}`;
} else {
const isRo = /\(ro[,)]/.test(share.clients);
const cleanClients = share.clients.replace(/\s*\(.*?\)/g, '').trim();
@@ -248,6 +308,14 @@ async function submitEdit() {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, path, validUsers, forceUser, forceGroup, guestOk, readOnly, recycleBin}),
});
} else if (ctx.type === 'webdav') {
const name = document.getElementById('editName').value.trim();
const path = document.getElementById('editPath').value.trim();
const users = document.getElementById('editWebdavUsers').value.trim();
res = await apiFetch(`/api/share/webdav/${encodeURIComponent(ctx.original.name)}`, {
method: 'PATCH', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, path, users}),
});
} else {
const clients = document.getElementById('editClients').value.trim();
const readOnly = document.getElementById('editReadOnly').checked;
@@ -270,7 +338,7 @@ async function submitEdit() {
const owner = document.getElementById('editOwner')?.value.trim();
const group = document.getElementById('editGroup')?.value.trim();
const mode = document.getElementById('editMode')?.value.trim();
const permPath = ctx.type === 'smb'
const permPath = (ctx.type === 'smb' || ctx.type === 'webdav')
? document.getElementById('editPath')?.value.trim()
: ctx.original.path;
@@ -306,6 +374,8 @@ function deleteShare(type, identifier) {
let res;
if (type === 'smb') {
res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}`, { method: 'DELETE' });
} else if (type === 'webdav') {
res = await apiFetch(`/api/share/webdav/${encodeURIComponent(identifier)}`, { method: 'DELETE' });
} else {
res = await apiFetch('/api/share/nfs', {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
+41 -2
View File
@@ -88,6 +88,9 @@
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#nfs" type="button">NFS Exports</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#webdav" type="button">WebDAV</button>
</li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#users" type="button">Users</button>
</li>
@@ -98,7 +101,7 @@
<!-- DASHBOARD -->
<div class="tab-pane fade show active" id="dashboard">
<div class="row g-3 mb-4">
<div class="col-6 col-md-2">
<div class="col-4 col-md-2">
<div class="card h-100">
<div class="card-body py-3 px-3">
<div class="card-label">smbd</div>
@@ -108,7 +111,7 @@
</div>
</div>
</div>
<div class="col-6 col-md-2">
<div class="col-4 col-md-2">
<div class="card h-100">
<div class="card-body py-3 px-3">
<div class="card-label">nfsd</div>
@@ -118,6 +121,16 @@
</div>
</div>
</div>
<div class="col-4 col-md-2">
<div class="card h-100">
<div class="card-body py-3 px-3">
<div class="card-label">apache2</div>
<div class="d-flex align-items-center mt-1" id="statusApache2">
<span class="dot dot-red"></span><span class="small">…</span>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-8">
<div class="card h-100">
<div class="card-body py-3 px-3">
@@ -211,6 +224,32 @@
</div>
</div>
<!-- WEBDAV -->
<div class="tab-pane fade" id="webdav">
<div class="fw-semibold mb-3">Add WebDAV Share</div>
<div class="row g-2 mb-4">
<div class="col-md-2"><input type="text" id="webdavName" class="form-control" placeholder="Share name"></div>
<div class="col-md-4"><input type="text" id="webdavPath" class="form-control" placeholder="/srv/data"></div>
<div class="col-md-3"><input type="text" id="webdavUsers" class="form-control" placeholder="alice bob (empty = all)"></div>
<div class="col-md-3"><button type="button" class="btn btn-success w-100" onclick="addWebDAVShare()">Add</button></div>
</div>
<div class="fw-semibold mb-2">Shares</div>
<div class="table-responsive">
<table class="table table-striped mb-0">
<thead><tr><th>Name</th><th>Path</th><th>Users</th><th>URL</th><th>Actions</th></tr></thead>
<tbody id="webdavTableBody">
<tr><td colspan="5" style="color:var(--muted)">Loading…</td></tr>
</tbody>
</table>
</div>
<div class="mt-3" style="font-size:.82rem; color:var(--muted)">
Requires <code>apache2</code> + <code>apache2-utils</code> installed and
<code>mod_dav</code> / <code>mod_dav_fs</code> enabled.
Shares are served at <code>http://&lt;server-ip&gt;/dav/&lt;name&gt;</code>.
User passwords are synced automatically from the Users tab.
</div>
</div>
<!-- USERS -->
<div class="tab-pane fade" id="users">
<div class="fw-semibold mb-3">Create User</div>