1
0
Fork 0

sign_ssh_keys: Add hook to sign SSH host cert

The *POST /sshkeys/sign* operation accepts a host name and a list of SSH
host public keys and returns a signed SSH host certificate for each key.
It uses the `step ssh certificate` command to sign the certificates,
which in turn contacts the configured *step-ca* service.  This operation
will allow hosts to obtain their initial certificates.  Once obtained,
the certificates can be renewed directly using the `step ssh renew`
command with the SSH private keys themselves for authentication.
master
Dustin 2023-09-29 18:06:23 -05:00
parent cff7fbabce
commit e5eff964a1
3 changed files with 128 additions and 7 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
/.venv /.venv
__pycache__/ __pycache__/
*.egg-info/ *.egg-info/
provisioner.password

View File

@ -17,9 +17,12 @@ RUN --mount=type=cache,target=/var/cache/apt \
RUN --mount=from=build,source=/tmp/build/dist,target=/tmp/wheels \ RUN --mount=from=build,source=/tmp/build/dist,target=/tmp/wheels \
python3 -m pip install -f /tmp/wheels \ python3 -m pip install -f /tmp/wheels \
dch_webhooks \ dch_webhooks \
python-multipart \
uvicorn \ uvicorn \
&& : && :
COPY --from=docker.io/smallstep/step-cli:0.25.0 /usr/local/bin/step /usr/local/bin/step
USER 1000:1000 USER 1000:1000
CMD ["tini", "/usr/local/bin/uvicorn", "dch_webhooks:app"] CMD ["tini", "/usr/local/bin/uvicorn", "dch_webhooks:app"]

View File

@ -1,10 +1,14 @@
import asyncio
import base64
import datetime import datetime
import importlib.metadata import importlib.metadata
import logging import logging
import os import os
import re import re
import tempfile
from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import Optional, Self, Type from typing import Annotated, Optional, Self, Type
import fastapi import fastapi
import httpx import httpx
@ -34,10 +38,17 @@ EXCLUDE_DESCRIPTION_WORDS = {
'the', 'the',
} }
ALLOW_RESIGNING_CERTS = os.environ.get('ALLOW_RESIGNING_CERTS') == '1'
FIREFLY_URL = os.environ.get( FIREFLY_URL = os.environ.get(
'FIREFLY_URL', 'FIREFLY_URL',
'http://firefly-iii', 'http://firefly-iii',
) )
MAX_KEY_FILE_SIZE = int(
os.environ.get(
'MAX_KEY_FILE_SIZE',
8192,
)
)
MAX_DOCUMENT_SIZE = int( MAX_DOCUMENT_SIZE = int(
os.environ.get( os.environ.get(
'MAX_DOCUMENT_SIZE', 'MAX_DOCUMENT_SIZE',
@ -50,6 +61,10 @@ PAPERLESS_URL = os.environ.get(
) )
class SignError(Exception):
...
class FireflyIIITransactionSplit(pydantic.BaseModel): class FireflyIIITransactionSplit(pydantic.BaseModel):
type: str type: str
date: datetime.datetime date: datetime.datetime
@ -78,6 +93,12 @@ class PaperlessNgxSearchResults(pydantic.BaseModel):
results: list[PaperlessNgxDocument] results: list[PaperlessNgxDocument]
class SSHKeySignResponse(pydantic.BaseModel):
success: bool
errors: Optional[list[str]] = None
certificates: Optional[dict[str, str]]
class HttpxClientMixin: class HttpxClientMixin:
def __init__(self) -> None: def __init__(self) -> None:
super().__init__() super().__init__()
@ -144,9 +165,13 @@ class Firefly(HttpxClientMixin):
rbody = r.json() rbody = r.json()
attachment = rbody['data'] attachment = rbody['data']
url = f'{FIREFLY_URL}/api/v1/attachments/{attachment["id"]}/upload' url = f'{FIREFLY_URL}/api/v1/attachments/{attachment["id"]}/upload'
r = await self.client.post(url, content=doc, headers={ r = await self.client.post(
url,
content=doc,
headers={
'Content-Type': 'application/octet-stream', 'Content-Type': 'application/octet-stream',
}) },
)
r.raise_for_status() r.raise_for_status()
@ -247,9 +272,7 @@ class Paperless(HttpxClientMixin):
MAX_DOCUMENT_SIZE, MAX_DOCUMENT_SIZE,
) )
continue continue
docs.append( docs.append((response_filename(r), doc.title, await r.aread()))
(response_filename(r), doc.title, await r.aread())
)
return docs 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: def clean_description(text: str) -> str:
matches = DESCRIPTION_CLEAN_PATTERN.sub('', text.lower()) matches = DESCRIPTION_CLEAN_PATTERN.sub('', text.lower())
if not matches: if not matches:
@ -308,6 +337,56 @@ def response_filename(response: httpx.Response) -> str:
return response.url.path.rstrip('/').rsplit('/', 1)[-1] 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( app = fastapi.FastAPI(
name=DIST['Name'], name=DIST['Name'],
version=DIST['Version'], version=DIST['Version'],
@ -331,3 +410,41 @@ def status() -> str:
@app.post('/hooks/firefly-iii/create') @app.post('/hooks/firefly-iii/create')
async def firefly_iii_create(hook: FireflyIIIWebhook) -> None: async def firefly_iii_create(hook: FireflyIIIWebhook) -> None:
await handle_firefly_transaction(hook.content) 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,
)