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
parent
cff7fbabce
commit
e5eff964a1
|
@ -1,3 +1,4 @@
|
||||||
/.venv
|
/.venv
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
provisioner.password
|
||||||
|
|
|
@ -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"]
|
||||||
|
|
129
dch_webhooks.py
129
dch_webhooks.py
|
@ -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,
|
||||||
|
)
|
||||||
|
|
Loading…
Reference in New Issue