Compare commits
3 commits
1318c826b6
...
9d2b5f1185
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d2b5f1185 | |||
| fccd6db089 | |||
| b70a9a65ec |
2 changed files with 102 additions and 1 deletions
68
app.py
68
app.py
|
|
@ -1,7 +1,7 @@
|
||||||
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
|
"""Homelab Butler v2.1 – Unified API proxy for Pfannkuchen homelab.
|
||||||
Reads service config from butler.yaml, credentials from Vaultwarden cache with flat-file fallback."""
|
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
|
import os, json, asyncio, logging, time, base64, re, subprocess, ipaddress, secrets, sqlite3, math, hashlib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import httpx, yaml
|
import httpx, yaml
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
@ -2023,6 +2023,72 @@ def _ssh(host, cmd, timeout=600):
|
||||||
return 124, "", f"SSH command timed out after {timeout} seconds"
|
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:
|
def _wireguard_status_command() -> str:
|
||||||
script = '''import json, subprocess
|
script = '''import json, subprocess
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,41 @@ def test_health_exposes_current_version():
|
||||||
assert response.json()["version"] == app.VERSION == "2.3.5"
|
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():
|
def test_ui_serves_self_contained_operator_console():
|
||||||
with TestClient(app.app) as client:
|
with TestClient(app.app) as client:
|
||||||
response = client.get("/ui")
|
response = client.get("/ui")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue