diff --git a/.gitignore b/.gitignore index b84d18a..1484f24 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /.venv __pycache__/ *.egg-info/ +provisioner.password diff --git a/Containerfile b/Containerfile index a3a08aa..a0a6872 100644 --- a/Containerfile +++ b/Containerfile @@ -17,9 +17,12 @@ RUN --mount=type=cache,target=/var/cache/apt \ RUN --mount=from=build,source=/tmp/build/dist,target=/tmp/wheels \ python3 -m pip install -f /tmp/wheels \ dch_webhooks \ + python-multipart \ uvicorn \ && : +COPY --from=docker.io/smallstep/step-cli:0.25.0 /usr/local/bin/step /usr/local/bin/step + USER 1000:1000 CMD ["tini", "/usr/local/bin/uvicorn", "dch_webhooks:app"] diff --git a/dch_webhooks.py b/dch_webhooks.py index d5e9b42..50f85ca 100644 --- a/dch_webhooks.py +++ b/dch_webhooks.py @@ -1,10 +1,14 @@ +import asyncio +import base64 import datetime import importlib.metadata import logging import os import re +import tempfile +from pathlib import Path from types import TracebackType -from typing import Optional, Self, Type +from typing import Annotated, Optional, Self, Type import fastapi import httpx @@ -34,10 +38,17 @@ EXCLUDE_DESCRIPTION_WORDS = { 'the', } +ALLOW_RESIGNING_CERTS = os.environ.get('ALLOW_RESIGNING_CERTS') == '1' FIREFLY_URL = os.environ.get( 'FIREFLY_URL', 'http://firefly-iii', ) +MAX_KEY_FILE_SIZE = int( + os.environ.get( + 'MAX_KEY_FILE_SIZE', + 8192, + ) +) MAX_DOCUMENT_SIZE = int( os.environ.get( 'MAX_DOCUMENT_SIZE', @@ -50,6 +61,10 @@ PAPERLESS_URL = os.environ.get( ) +class SignError(Exception): + ... + + class FireflyIIITransactionSplit(pydantic.BaseModel): type: str date: datetime.datetime @@ -78,6 +93,12 @@ class PaperlessNgxSearchResults(pydantic.BaseModel): results: list[PaperlessNgxDocument] +class SSHKeySignResponse(pydantic.BaseModel): + success: bool + errors: Optional[list[str]] = None + certificates: Optional[dict[str, str]] + + class HttpxClientMixin: def __init__(self) -> None: super().__init__() @@ -144,9 +165,13 @@ class Firefly(HttpxClientMixin): rbody = r.json() attachment = rbody['data'] url = f'{FIREFLY_URL}/api/v1/attachments/{attachment["id"]}/upload' - r = await self.client.post(url, content=doc, headers={ - 'Content-Type': 'application/octet-stream', - }) + r = await self.client.post( + url, + content=doc, + headers={ + 'Content-Type': 'application/octet-stream', + }, + ) r.raise_for_status() @@ -247,9 +272,7 @@ class Paperless(HttpxClientMixin): MAX_DOCUMENT_SIZE, ) continue - docs.append( - (response_filename(r), doc.title, await r.aread()) - ) + docs.append((response_filename(r), doc.title, await r.aread())) return docs @@ -279,6 +302,12 @@ async def handle_firefly_transaction(xact: FireflyIIITransaction) -> None: ) +async def check_host(hostname: str) -> bool: + cmd = ['step', 'ssh', 'check-host', hostname] + p = await asyncio.create_subprocess_exec(*cmd) + return await p.wait() == 0 + + def clean_description(text: str) -> str: matches = DESCRIPTION_CLEAN_PATTERN.sub('', text.lower()) if not matches: @@ -308,6 +337,56 @@ def response_filename(response: httpx.Response) -> str: return response.url.path.rstrip('/').rsplit('/', 1)[-1] +async def sign_key(hostname, path: Path) -> tuple[str, str]: + cmd = ['step', 'ssh', 'certificate', '--sign', '--host', hostname, path] + p = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + p_log = log.getChild('step') + assert p.stdout + buf = bytearray() + while line := await p.stdout.readline(): + buf += line + p_log.info(line.rstrip().decode('utf-8', 'replace')) + rc = await p.wait() + if rc != 0: + raise SignError( + f'Signing failed: process returned exit code {rc}: ' + f'{buf.decode("utf-8")}' + ) + cert_path = path.parent / f'{path.stem}-cert.pub' + log.info( + 'Successfully signed %s for %s as %s', + path.name, + hostname, + cert_path.name, + ) + with cert_path.open('r') as f: + cert = await asyncio.to_thread(f.read) + return (cert_path.name, cert) + + +async def sign_uploaded_key( + hostname: str, f: fastapi.UploadFile +) -> tuple[str, str]: + if f.size > MAX_KEY_FILE_SIZE: + raise SignError( + f'Refusing to sign key {f.filename}: file too large ' + f'({f.size} bytes, max {MAX_KEY_FILE_SIZE}' + ) + with tempfile.TemporaryDirectory() as t: + path = Path(t) / f.filename + with path.open('wb') as o: + d = await f.read(MAX_KEY_FILE_SIZE) + if f.headers.get('Content-Transfer-Encoding') == 'base64': + d = base64.b64decode(d) + await asyncio.to_thread(o.write, d) + return await sign_key(hostname, path) + + app = fastapi.FastAPI( name=DIST['Name'], version=DIST['Version'], @@ -331,3 +410,41 @@ def status() -> str: @app.post('/hooks/firefly-iii/create') async def firefly_iii_create(hook: FireflyIIIWebhook) -> None: await handle_firefly_transaction(hook.content) + + +@app.post('/sshkeys/sign', response_model=SSHKeySignResponse) +async def sign_ssh_keys( + response: fastapi.Response, + hostname: Annotated[str, fastapi.Form()], + keys: list[fastapi.UploadFile], +): + errors = [] + certificates = {} + if '.' not in hostname: + errors.append( + f'Cannot sign certificate for Single-label hostname {hostname}' + ) + if await check_host(hostname): + msg = f'{hostname} already has a signed certificate' + if ALLOW_RESIGNING_CERTS: + log.warning('%s', msg) + else: + log.error('%s', msg) + errors.append(msg) + if not errors: + tasks = [sign_uploaded_key(hostname, k) for k in keys] + for coro in asyncio.as_completed(tasks): + try: + name, cert = await coro + except Exception as e: + log.error('%s', e) + errors.append(str(e)) + else: + certificates[name] = cert + if errors: + response.status_code = fastapi.status.HTTP_400_BAD_REQUEST + return SSHKeySignResponse( + success=not errors, + errors=errors or None, + certificates=certificates or None, + )