diff --git a/app.py b/app.py index c50cede..4b0b3c5 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ """Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab. Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback.""" -import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math, hashlib +import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math from datetime import datetime, timezone import httpx, yaml from typing import Literal @@ -2023,72 +2023,6 @@ def _ssh(host, cmd, timeout=600): return 124, "", f"SSH command timed out after {timeout} seconds" -PAPERLESS_SSH = "sascha@10.5.1.120" -PAPERLESS_CONSUME_DIR = "/app-config/paperless/consume" -PAPERLESS_MAX_IMPORT_BYTES = 50 * 1024 * 1024 - - -def _ssh_bytes(host: str, cmd: str, payload: bytes, timeout: int = 120): - """Send a binary payload to a fixed remote command over Butler-managed SSH.""" - try: - result = _sp.run( - ["ssh", "-o", "ConnectTimeout=10", "-o", "StrictHostKeyChecking=accept-new", - "-o", "UserKnownHostsFile=/tmp/butler_known_hosts", host, cmd], - input=payload, capture_output=True, timeout=timeout, - ) - return result.returncode, result.stdout.decode(errors="replace"), result.stderr.decode(errors="replace") - except _sp.TimeoutExpired: - return 124, "", f"SSH upload timed out after {timeout} seconds" - - -def _paperless_import_filename(filename: str, digest: str) -> str: - base_name = os.path.basename(filename or "document.pdf") - stem = re.sub(r"[^A-Za-z0-9._-]+", "_", base_name.rsplit(".", 1)[0]).strip("._-") - if not stem: - stem = "document" - return f"{stem[:100]}-{digest[:12]}.pdf" - - -@app.post("/paperless/import") -async def paperless_import(request: Request, filename: str = Query(..., min_length=1, max_length=180), _=Depends(_verify)): - """Queue a PDF in Paperless without exposing Paperless or SSH to the caller.""" - payload = await request.body() - if not payload or not payload.startswith(b"%PDF-"): - raise HTTPException(400, "Only valid PDF documents are accepted") - if len(payload) > PAPERLESS_MAX_IMPORT_BYTES: - raise HTTPException(413, "PDF exceeds the 50 MiB import limit") - digest = hashlib.sha256(payload).hexdigest() - import_name = _paperless_import_filename(filename, digest) - remote_path = f"{PAPERLESS_CONSUME_DIR}/{import_name}" - command = ( - f"sudo -n install -d -o sascha -g sascha -m 0755 {PAPERLESS_CONSUME_DIR} && " - f"tmp=$(mktemp /tmp/paperless-import.XXXXXX) && " - f"cat > \"$tmp\" && sudo -n install -o sascha -g sascha -m 0644 \"$tmp\" {remote_path} && rm -f \"$tmp\"" - ) - rc, out, err = await asyncio.to_thread(_ssh_bytes, PAPERLESS_SSH, command, payload, 180) - if rc != 0: - raise HTTPException(502, (err or out).strip()[-500:] or "Paperless import transfer failed") - _audit("/paperless/import", "POST", 202, f"sha256={digest}; bytes={len(payload)}") - return {"status": "queued", "filename": import_name, "sha256": digest, "bytes": len(payload)} - - -@app.get("/paperless/import/status") -async def paperless_import_status(filename: str = Query(..., min_length=1, max_length=180), _=Depends(_verify)): - if not re.fullmatch(r"[A-Za-z0-9._-]+\.pdf", filename): - raise HTTPException(400, "Invalid import filename") - remote_path = f"{PAPERLESS_CONSUME_DIR}/{filename}" - command = ( - f"if sudo -n test -f {remote_path}; then echo QUEUED; else echo CONSUMED; fi; " - f"sudo -n docker logs --since 15m paperless-ngx 2>&1 | grep -F -- {filename} | tail -20 || true" - ) - rc, out, err = await asyncio.to_thread(_ssh, PAPERLESS_SSH, command, 45) - if rc != 0: - raise HTTPException(502, (err or out).strip()[-500:] or "Paperless status check failed") - lines = out.splitlines() - state = lines[0].strip().lower() if lines else "unknown" - return {"status": state, "filename": filename, "recent_log": lines[1:]} - - def _wireguard_status_command() -> str: script = '''import json, subprocess diff --git a/tests/test_app.py b/tests/test_app.py index 67c0621..fe4a29b 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -46,41 +46,6 @@ def test_health_exposes_current_version(): assert response.json()["version"] == app.VERSION == "2.3.5" -def test_paperless_import_queues_pdf_through_butler(monkeypatch): - captured = {} - - def fake_upload(host, command, payload, timeout): - captured.update(host=host, command=command, payload=payload, timeout=timeout) - return 0, "", "" - - monkeypatch.setattr(app, "_ssh_bytes", fake_upload) - pdf = b"%PDF-1.7\nvalid-test-payload" - with TestClient(app.app) as client: - response = client.post( - "/paperless/import?filename=Hausordnung%20Stand%2009.05.2022.pdf", - content=pdf, - headers={"Authorization": "Bearer test-token", "Content-Type": "application/pdf"}, - ) - assert response.status_code == 200 - body = response.json() - assert body["status"] == "queued" - assert body["filename"].startswith("Hausordnung_Stand_09.05.2022-") - assert captured["host"] == app.PAPERLESS_SSH - assert captured["payload"] == pdf - assert app.PAPERLESS_CONSUME_DIR in captured["command"] - - -def test_paperless_import_rejects_non_pdf(monkeypatch): - monkeypatch.setattr(app, "_ssh_bytes", lambda *args: (_ for _ in ()).throw(AssertionError("must not upload"))) - with TestClient(app.app) as client: - response = client.post( - "/paperless/import?filename=bad.pdf", - content=b"not a pdf", - headers={"Authorization": "Bearer test-token"}, - ) - assert response.status_code == 400 - - def test_ui_serves_self_contained_operator_console(): with TestClient(app.app) as client: response = client.get("/ui")