diff --git a/Dockerfile b/Dockerfile index 36bdcb8..dcea7a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -121,6 +121,7 @@ RUN chmod +x /usr/local/bin/meta-core COPY docker/nginx.conf /etc/nginx/nginx.conf COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf COPY docker/wsgidav.yaml /app/docker/ +COPY docker/webdav_token_controller.py /app/docker/ COPY docker/welcome.html /app/welcome/ COPY docker/start-fuse-driver.sh /app/docker/ RUN chmod 644 /app/welcome/welcome.html && \ diff --git a/docker/__pycache__/webdav_token_controller.cpython-310.pyc b/docker/__pycache__/webdav_token_controller.cpython-310.pyc new file mode 100644 index 0000000..7293fdd Binary files /dev/null and b/docker/__pycache__/webdav_token_controller.cpython-310.pyc differ diff --git a/docker/supervisord.conf b/docker/supervisord.conf index 99d7a3f..9624288 100644 --- a/docker/supervisord.conf +++ b/docker/supervisord.conf @@ -72,3 +72,6 @@ autorestart=true stdout_logfile=/var/log/supervisor/wsgidav.log stderr_logfile=/var/log/supervisor/wsgidav_error.log startsecs=5 +environment= + PYTHONPATH="/app/docker", + TOKEN_STORE_PATH="/meta-fuse/config/webdav-tokens.json" diff --git a/docker/webdav_token_controller.py b/docker/webdav_token_controller.py new file mode 100644 index 0000000..903ecc7 --- /dev/null +++ b/docker/webdav_token_controller.py @@ -0,0 +1,71 @@ +""" +WsgiDAV custom DomainController for meta-fuse WebDAV access tokens. + +Reads `${TOKEN_STORE_PATH}` (a JSON file written by meta-fuse's TokenStore.ts) +fresh on every basic-auth request — no caching, so token revocation from the +dashboard takes effect on the next request. + +Username is ignored: the WebDAV password IS the token. We accept any +non-empty username so generic clients (Finder, Plex, davfs2) that demand both +fields keep working. +""" + +import hashlib +import json +import os +from typing import Any + +from wsgidav.dc.base_dc import BaseDomainController + +# Match the prefix produced by TokenStore.ts. +TOKEN_PREFIX = "mfwd_" +TOKEN_STORE_DEFAULT = "/meta-fuse/config/webdav-tokens.json" + + +class WebdavTokenController(BaseDomainController): + """ + Auth provider backed by meta-fuse's per-device token store. + + WsgiDAV calls `basic_auth_user(realm, user_name, password, environ)` for + every request that requires auth. We hash the candidate password with + sha256 and check it against the persisted records. + """ + + def __init__(self, wsgidav_app: Any, config: dict) -> None: + super().__init__(wsgidav_app, config) + self._store_path = os.environ.get("TOKEN_STORE_PATH", TOKEN_STORE_DEFAULT) + + # --- BaseDomainController contract -------------------------------------- + + def get_domain_realm(self, path_info: str, environ: dict) -> str: + # Single realm — all paths require the same token. + return "metafuse-webdav" + + def require_authentication(self, realm: str, environ: dict) -> bool: + return True + + def supports_http_digest_auth(self) -> bool: + # Tokens are not stored in plaintext, so we cannot answer digest + # challenges — clients must use basic auth (over TLS). + return False + + def basic_auth_user( + self, realm: str, user_name: str, password: str, environ: dict + ) -> bool: + if not password or not password.startswith(TOKEN_PREFIX): + return False + + try: + with open(self._store_path, "r", encoding="utf-8") as fh: + store = json.load(fh) + except FileNotFoundError: + return False + except (OSError, json.JSONDecodeError): + # Treat unreadable store as "no valid tokens" — fail closed. + return False + + candidate = hashlib.sha256(password.encode("utf-8")).hexdigest() + for token in store.get("tokens", []): + if token.get("hash") == candidate: + return True + return False diff --git a/docker/wsgidav.yaml b/docker/wsgidav.yaml index 8867c44..f365860 100644 --- a/docker/wsgidav.yaml +++ b/docker/wsgidav.yaml @@ -3,17 +3,14 @@ host: 0.0.0.0 port: 8080 -# Simple user authentication -simple_dc: - user_mapping: - "/": - metamesh: - password: metamesh - roles: ["editor"] - "/webdav": - metamesh: - password: metamesh - roles: ["editor"] +# Authentication is delegated to a custom DomainController that validates the +# basic-auth password against meta-fuse's per-device token store. Any +# non-empty username is accepted; the password IS the token (`mfwd_*`). +http_authenticator: + domain_controller: webdav_token_controller.WebdavTokenController + accept_basic: true + accept_digest: false + default_to_digest: false # Mount the virtual filesystem provider_mapping: @@ -31,11 +28,6 @@ server: cheroot server_args: numthreads: 10 -# Authentication settings -accept_basic: true -accept_digest: false -default_to_digest: false - # HTTP configuration add_header_MS_Author_Via: false diff --git a/packages/meta-fuse-core/src/api/APIServer.ts b/packages/meta-fuse-core/src/api/APIServer.ts index 4d59503..05f986a 100644 --- a/packages/meta-fuse-core/src/api/APIServer.ts +++ b/packages/meta-fuse-core/src/api/APIServer.ts @@ -13,6 +13,7 @@ import type { RenamingConfig, RenamingRule, PreviewResponse } from '../vfs/types import { TEMPLATE_VARIABLES } from '../vfs/types/RenamingRuleTypes.js'; import { TemplateEngine } from '../vfs/template/TemplateEngine.js'; import { ConditionEvaluator } from '../vfs/template/ConditionEvaluator.js'; +import { TokenStore } from '../webdav/TokenStore.js'; const logger = new Logger({ name: 'APIServer' }); @@ -30,6 +31,7 @@ export class APIServer { private vfs: VirtualFileSystem; private kvManager: KVManager | null = null; private config: Required; + private tokenStore: TokenStore; constructor(vfs: VirtualFileSystem, config: APIServerConfig = {}, kvManager?: KVManager) { this.vfs = vfs; @@ -38,6 +40,10 @@ export class APIServer { port: config.port ?? parseInt(process.env.API_PORT ?? '3000', 10), host: config.host ?? process.env.API_HOST ?? '0.0.0.0', }; + // CONFIG_DIR is the same dir ConfigStorage uses for renaming rules. + this.tokenStore = new TokenStore({ + configDir: process.env.CONFIG_DIR ?? '/meta-fuse/config', + }); this.app = Fastify({ logger: false, @@ -88,6 +94,43 @@ export class APIServer { // Service discovery — proxies to meta-core's /api/services, resolving the // current leader's URL via LeaderClient (no hardcoded hostname). this.app.get('/api/services', this.handleServices.bind(this)); + + // WebDAV access tokens (per-device basic-auth passwords for /webdav). + this.app.get('/api/webdav-tokens', this.handleListTokens.bind(this)); + this.app.post('/api/webdav-tokens', this.handleCreateToken.bind(this)); + this.app.delete('/api/webdav-tokens/:id', this.handleRevokeToken.bind(this)); + } + + private async handleListTokens(_req: FastifyRequest, reply: FastifyReply): Promise { + reply.send({ tokens: this.tokenStore.list() }); + } + + private async handleCreateToken( + req: FastifyRequest<{ Body: { label?: string } }>, + reply: FastifyReply + ): Promise { + const label = (req.body?.label ?? '').toString(); + if (label.length > 200) { + reply.status(400).send({ error: 'label too long (max 200 chars)' }); + return; + } + const { plaintext, view } = this.tokenStore.create(label); + // The plaintext is included exactly once in this response — the dashboard + // surfaces it to the user and then discards it. Subsequent GETs do not + // include it. + reply.status(201).send({ ...view, token: plaintext }); + } + + private async handleRevokeToken( + req: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply + ): Promise { + const ok = this.tokenStore.revoke(req.params.id); + if (!ok) { + reply.status(404).send({ error: 'token not found' }); + return; + } + reply.status(204).send(); } /** diff --git a/packages/meta-fuse-core/src/webdav/TokenStore.ts b/packages/meta-fuse-core/src/webdav/TokenStore.ts new file mode 100644 index 0000000..ad207be --- /dev/null +++ b/packages/meta-fuse-core/src/webdav/TokenStore.ts @@ -0,0 +1,138 @@ +/** + * TokenStore — Per-device WebDAV access tokens. + * + * Tokens are minted from the OIDC-protected dashboard and used as the + * basic-auth password by WebDAV clients (Plex, Finder, etc.). The plaintext + * is shown to the user once at creation time; only sha256 hashes are + * persisted. The matching WsgiDAV DomainController + * (`docker/webdav_token_controller.py`) reads the same JSON file fresh on + * every auth request, so revocation is immediate. + * + * Tokens identify a *device*, not an OIDC user — multi-user support requires + * hash-lock to forward an X-Forwarded-User header (not currently the case), + * at which point a `userId` field can be added to the record. + */ + +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import { Logger } from 'tslog'; + +const logger = new Logger({ name: 'TokenStore' }); + +/** Length of the random portion (in hex chars) of a generated token. */ +const TOKEN_RANDOM_HEX_LEN = 32; // 16 bytes => 128 bits of entropy +/** Public prefix marker so support can grep logs for stray tokens. */ +const TOKEN_PREFIX = 'mfwd_'; +/** Number of hex chars (after the prefix) we surface in the dashboard list. */ +const VISIBLE_PREFIX_LEN = 4; + +export interface WebdavTokenRecord { + id: string; + label: string; + hash: string; // sha256(plaintext) + prefix: string; // first VISIBLE_PREFIX_LEN chars of the random portion + createdAt: string; // ISO 8601 +} + +/** Public view shown in the dashboard list — no plaintext, no hash. */ +export interface WebdavTokenView { + id: string; + label: string; + prefix: string; + createdAt: string; +} + +interface TokenFile { + tokens: WebdavTokenRecord[]; +} + +export interface TokenStoreOptions { + configDir: string; +} + +export class TokenStore { + private readonly file: string; + private readonly configDir: string; + + constructor(opts: TokenStoreOptions) { + this.configDir = opts.configDir; + this.file = path.join(this.configDir, 'webdav-tokens.json'); + this.ensureConfigDir(); + } + + private ensureConfigDir(): void { + if (!fs.existsSync(this.configDir)) { + fs.mkdirSync(this.configDir, { recursive: true }); + } + } + + private read(): TokenFile { + if (!fs.existsSync(this.file)) return { tokens: [] }; + try { + const raw = fs.readFileSync(this.file, 'utf-8'); + const parsed = JSON.parse(raw) as TokenFile; + if (!Array.isArray(parsed.tokens)) return { tokens: [] }; + return parsed; + } catch (err) { + logger.warn(`Token store ${this.file} unreadable, treating as empty:`, err); + return { tokens: [] }; + } + } + + /** + * Atomic write — write to a sibling tmp file and rename. WsgiDAV's + * controller reads this file on every auth request, so a partial write + * would briefly fail authentication. + */ + private write(file: TokenFile): void { + const tmp = `${this.file}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(file, null, 2), 'utf-8'); + fs.renameSync(tmp, this.file); + } + + /** Public list — no plaintext, no hash. */ + list(): WebdavTokenView[] { + return this.read().tokens.map(({ id, label, prefix, createdAt }) => ({ + id, label, prefix, createdAt, + })); + } + + /** + * Mint a new token. Returns the plaintext exactly once — the caller is + * responsible for surfacing it to the user and forgetting it. + */ + create(label: string): { plaintext: string; view: WebdavTokenView } { + const random = crypto.randomBytes(TOKEN_RANDOM_HEX_LEN / 2).toString('hex'); + const plaintext = `${TOKEN_PREFIX}${random}`; + const id = crypto.randomBytes(8).toString('hex'); + const record: WebdavTokenRecord = { + id, + label: label.trim() || 'Unnamed token', + hash: crypto.createHash('sha256').update(plaintext).digest('hex'), + prefix: random.slice(0, VISIBLE_PREFIX_LEN), + createdAt: new Date().toISOString(), + }; + + const file = this.read(); + file.tokens.push(record); + this.write(file); + + logger.info(`Minted WebDAV token id=${id} label=${JSON.stringify(record.label)}`); + return { + plaintext, + view: { id, label: record.label, prefix: record.prefix, createdAt: record.createdAt }, + }; + } + + /** Delete by id. Returns true if a record was removed. */ + revoke(id: string): boolean { + const file = this.read(); + const idx = file.tokens.findIndex(t => t.id === id); + if (idx < 0) return false; + file.tokens.splice(idx, 1); + this.write(file); + logger.info(`Revoked WebDAV token id=${id}`); + return true; + } +} diff --git a/packages/meta-fuse-ui/src/App.tsx b/packages/meta-fuse-ui/src/App.tsx index a899819..2e9fdae 100644 --- a/packages/meta-fuse-ui/src/App.tsx +++ b/packages/meta-fuse-ui/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { RulesEditor } from './components/RulesEditor'; import ServiceNav from './components/ServiceNav'; +import { WebdavTokens } from './components/WebdavTokens'; interface Stats { fileCount: number; @@ -39,6 +40,7 @@ function App() { const [refreshing, setRefreshing] = useState(false); const [showRules, setShowRules] = useState(false); const [showSetup, setShowSetup] = useState(false); + const [showTokens, setShowTokens] = useState(false); const fetchData = useCallback(async () => { try { @@ -223,6 +225,17 @@ function App() { )} + +
+
setShowTokens(!showTokens)} + > +

WebDAV Access Tokens

+ {showTokens ? '-' : '+'} +
+ {showTokens && } +
diff --git a/packages/meta-fuse-ui/src/components/WebdavTokens.tsx b/packages/meta-fuse-ui/src/components/WebdavTokens.tsx new file mode 100644 index 0000000..01d95f4 --- /dev/null +++ b/packages/meta-fuse-ui/src/components/WebdavTokens.tsx @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useState } from 'react'; + +interface TokenView { + id: string; + label: string; + prefix: string; + createdAt: string; +} + +interface CreatedToken extends TokenView { + token: string; // plaintext, surfaced only once +} + +function formatDate(iso: string): string { + try { + return new Date(iso).toLocaleString(); + } catch { + return iso; + } +} + +export function WebdavTokens() { + const [tokens, setTokens] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newLabel, setNewLabel] = useState(''); + const [creating, setCreating] = useState(false); + const [justCreated, setJustCreated] = useState(null); + const [copied, setCopied] = useState(false); + + const refresh = useCallback(async () => { + try { + const res = await fetch('/api/webdav-tokens'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const data = await res.json(); + setTokens(data.tokens ?? []); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + const create = async () => { + setCreating(true); + try { + const res = await fetch('/api/webdav-tokens', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: newLabel }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const created: CreatedToken = await res.json(); + setJustCreated(created); + setNewLabel(''); + setCopied(false); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } finally { + setCreating(false); + } + }; + + const revoke = async (id: string, label: string) => { + if (!confirm(`Revoke token "${label}"? Devices using it will lose WebDAV access immediately.`)) return; + try { + const res = await fetch(`/api/webdav-tokens/${id}`, { method: 'DELETE' }); + if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`); + await refresh(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + } + }; + + const copyToken = async () => { + if (!justCreated) return; + try { + await navigator.clipboard.writeText(justCreated.token); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API may be unavailable on http; fall back silently. + } + }; + + if (loading) return
Loading tokens…
; + + return ( +
+

+ Generate one token per device and paste it as the basic-auth password + when you add the WebDAV mount in Plex / Finder / Windows Explorer / etc. + Any username works — the password is what authenticates. Revoke a + token to cut off that single device without affecting the others. +

+ + {error &&
{error}
} + + {justCreated && ( +
+

New token — copy it now

+

+ This is the only time the full token will be shown. After you close + this banner only the prefix will appear in the list. +

+
+ {justCreated.token} + +
+ +
+ )} + +
+
+ Generate token +
+
+ setNewLabel(e.target.value)} + placeholder="Label (e.g. 'Plex on living-room TV')" + maxLength={200} + style={{ flex: 1, padding: '0.5rem' }} + onKeyDown={(e) => { if (e.key === 'Enter' && !creating) create(); }} + /> + +
+
+ +
+ {tokens.length === 0 ? ( +
+ + No tokens yet — generate one to enable WebDAV access. + +
+ ) : ( + tokens.map((t) => ( +
+
+ {t.label} +
+ mfwd_{t.prefix}… · created {formatDate(t.createdAt)} +
+
+ +
+ )) + )} +
+
+ ); +}