mirror of
https://github.com/DarrylNixon/CrowdTLS-server.git
synced 2024-09-22 18:19:43 -07:00
Initial MVP
This commit is contained in:
parent
8edd610e7c
commit
1e33720feb
14 changed files with 468 additions and 77 deletions
19
.dockerignore
Normal file
19
.dockerignore
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
.gitignore
|
||||||
|
.git/
|
||||||
|
.env
|
||||||
|
.env.sample
|
||||||
|
.flake8
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
build/
|
||||||
|
**/.DS_Store
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.md
|
||||||
|
**/*.pyc
|
||||||
|
**/*.pyo
|
||||||
|
**/*.mo
|
||||||
|
**/*.log
|
||||||
|
*.egg-info/
|
||||||
|
*.png
|
19
.env.sample
Normal file
19
.env.sample
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# POSTGRES_* variables are used both to initially configure the
|
||||||
|
# crowdtls postgresql database and to later access the data from
|
||||||
|
# crowdtls execution.
|
||||||
|
#
|
||||||
|
# POSTGRES_PASSWORD is used for database authentication and should
|
||||||
|
# be secure or randomized (optionally using the method in README.md).
|
||||||
|
POSTGRES_CONTAINER=crowdtls-db
|
||||||
|
POSTGRES_USER=crowdtls
|
||||||
|
POSTGRES_DB=crowdtls
|
||||||
|
POSTGRES_PASSWORD=
|
||||||
|
|
||||||
|
# CROWDTLS_*_WEB_PORT variables are used to determine what
|
||||||
|
# port the web interface is served on within the container (INTERNAL)
|
||||||
|
# and what port this will map to on the host (HOST).
|
||||||
|
#
|
||||||
|
# If you're using a dockerized reverse proxy, you may want to remove
|
||||||
|
# the port mapping entirely within docker-compose.yml.
|
||||||
|
CROWDTLS_HOST_WEB_PORT=1337
|
||||||
|
CROWDTLS_INTERNAL_WEB_PORT=1337
|
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -56,7 +56,7 @@ cover/
|
||||||
*.pot
|
*.pot
|
||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
**/*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
db.sqlite3-journal
|
||||||
|
@ -158,3 +158,5 @@ cython_debug/
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
**/.DS_Store
|
||||||
|
|
26
Dockerfile
Normal file
26
Dockerfile
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
FROM python:3.11-alpine
|
||||||
|
|
||||||
|
# Enforcement to ensure passwords environment variables are not left blank.
|
||||||
|
# This won't stop bad passwords from being used, but at least won't cause
|
||||||
|
# errors or, worse, weaker crypt.
|
||||||
|
ARG POSTGRES_PASSWORD
|
||||||
|
ENV ENV_POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
RUN [ ! -z "${ENV_POSTGRES_PASSWORD}" ] || { echo "CrowdTLS-server build error: Set POSTGRES_PASSWORD in .env."; exit 1; }
|
||||||
|
|
||||||
|
# Copy project into Docker image, skipping entries in .dockerignore.
|
||||||
|
WORKDIR /crowdtls
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Install ghostforge from the work directory.
|
||||||
|
RUN pip install .
|
||||||
|
|
||||||
|
# Expose the web "serve" port specific in the environment variables.
|
||||||
|
ARG CROWDTLS_INTERNAL_WEB_PORT
|
||||||
|
ENV ENV_CROWDTLS_INTERNAL_WEB_PORT=${CROWDTLS_INTERNAL_WEB_PORT}
|
||||||
|
EXPOSE ${ENV_CROWDTLS_INTERNAL_WEB_PORT}
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/ghostforge
|
||||||
|
|
||||||
|
# TODO: Replace with ghostforge_serve when it works.
|
||||||
|
# This currently just keeps the container running for development.
|
||||||
|
CMD ["sh", "-c", "uvicorn crowdtls.cli:app --host 0.0.0.0 --port $CROWDTLS_INTERNAL_WEB_PORT"]
|
15
README.md
15
README.md
|
@ -1,5 +1,5 @@
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="icons/crowdtls.png" alt="CrowdTLS Logo">
|
<img src="crowdtls.png" alt="CrowdTLS Logo">
|
||||||
|
|
||||||
# CrowdTLS-server
|
# CrowdTLS-server
|
||||||
|
|
||||||
|
@ -13,7 +13,18 @@ This is the backend server repository for it.<br/>
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
TODO
|
I recommend that you deploy this with Docker or within a Python virtual environment.
|
||||||
|
|
||||||
|
## Deployment with Docker
|
||||||
|
|
||||||
|
Run the following command on your Linux system:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/darrylnixon/CrowdTLS-server.git && \
|
||||||
|
cd CrowdTLS-server && \
|
||||||
|
cp .env.sample .env && \
|
||||||
|
PW=$(/usr/bin/env python3 -c "import secrets; print(secrets.token_urlsafe(32))") /bin/bash -c 'sed -i "" "s/^POSTGRES_PASSWORD=.*/POSTGRES_PASSWORD=$PW/" .env' && \
|
||||||
|
docker-compose up --detach --build;
|
||||||
|
```
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
|
121
crowdtls/api/v1/api.py
Normal file
121
crowdtls/api/v1/api.py
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
from typing import Dict
|
||||||
|
from typing import List
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi import Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlmodel import select
|
||||||
|
|
||||||
|
from crowdtls.db import get_session
|
||||||
|
from crowdtls.helpers import decode_der
|
||||||
|
from crowdtls.helpers import parse_hostname
|
||||||
|
from crowdtls.helpers import raise_HTTPException
|
||||||
|
from crowdtls.logs import logger
|
||||||
|
from crowdtls.models import Certificate
|
||||||
|
from crowdtls.models import Domain
|
||||||
|
from crowdtls.models import DomainCertificateLink
|
||||||
|
|
||||||
|
app = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def insert_certificate(hostname: str, certificate: Certificate, session: AsyncSession = Depends(get_session)):
|
||||||
|
domain = parse_hostname(hostname)
|
||||||
|
if domain:
|
||||||
|
existing_domain = await session.get(Domain, domain.fqdn)
|
||||||
|
if existing_domain:
|
||||||
|
logger.info("Found existing domain in database: {existing_domain.fqdn}")
|
||||||
|
existing_domain.certificates.append(certificate)
|
||||||
|
session.add(existing_domain)
|
||||||
|
else:
|
||||||
|
logger.info("Did not find existing domain in database. Creating new domain: {domain.fqdn}")
|
||||||
|
domain.certificates.append(certificate)
|
||||||
|
session.add(domain)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Failed to insert certificate into database for domain {domain.fqdn}: {certificate.fingerprint}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/check")
|
||||||
|
async def check_fingerprints(
|
||||||
|
fingerprints: Dict[str, Union[str, List[str]]],
|
||||||
|
request: Request = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
logger.info("Received request to check fingerprints from client {request.client.host}")
|
||||||
|
hostname = parse_hostname(fingerprints.get("host"))
|
||||||
|
fps = fingerprints.get("fps")
|
||||||
|
logger.info(f"Received {len(fps)} fingerprints to check from client {request.client.host}")
|
||||||
|
|
||||||
|
subquery = select(DomainCertificateLink.fqdn).join(Certificate).where(Certificate.fingerprint.in_(fps)).subquery()
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Certificate)
|
||||||
|
.join(DomainCertificateLink)
|
||||||
|
.join(subquery, DomainCertificateLink.fqdn == subquery.c.fqdn)
|
||||||
|
.options(selectinload(Certificate.domains))
|
||||||
|
.where(DomainCertificateLink.fqdn == hostname.fqdn if hostname else True)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Failed to execute stmt: {stmt} (req body {request.body}) and IP address: {request.client.host}")
|
||||||
|
raise_HTTPException()
|
||||||
|
|
||||||
|
certificates = result.scalars().all()
|
||||||
|
logger.info(
|
||||||
|
f"Found {len(certificates)} certificates (of {len(fps)} requested) in the database for client {request.client.host}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(certificates) == len(fps):
|
||||||
|
return {"send": False}
|
||||||
|
|
||||||
|
for certificate in certificates:
|
||||||
|
if hostname and hostname.fqdn not in [domain.fqdn for domain in certificate.domains]:
|
||||||
|
certificate.domains.append(hostname)
|
||||||
|
session.add(certificate)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"Added mappings between {hostname.fqdn} up to {len(fps)} certificates in the database.")
|
||||||
|
return {"send": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/new")
|
||||||
|
async def new_fingerprints(
|
||||||
|
fingerprints: Dict[str, Union[str, Dict[str, List[int]]]],
|
||||||
|
request: Request = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
hostname = parse_hostname(fingerprints.get("host"))
|
||||||
|
certs = fingerprints.get("certs")
|
||||||
|
fps = certs.keys()
|
||||||
|
stmt = select(Certificate).where(Certificate.fingerprint.in_(fps))
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Failed to execute stmt: {stmt} (req body {request.body}) and IP address: {request.client.host}")
|
||||||
|
raise_HTTPException()
|
||||||
|
|
||||||
|
logger.info(f"Received {len(fingerprints)} fingerprints to add from client {request.client.host}")
|
||||||
|
existing_fingerprints = {certificate.fingerprint for certificate in result.scalars().all()}
|
||||||
|
|
||||||
|
certificates_to_add = []
|
||||||
|
for fp, rawDER in certs.items():
|
||||||
|
if fp not in existing_fingerprints:
|
||||||
|
decoded = decode_der(fp, rawDER)
|
||||||
|
certificate = Certificate.from_orm(decoded)
|
||||||
|
certificate.domains.append(hostname)
|
||||||
|
certificates_to_add.append(certificate)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session.add_all(certificates_to_add)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to add certificates to db: {certificates_to_add} after stmt: {stmt} (req body {request.body}) and IP address: {request.client.host}"
|
||||||
|
)
|
||||||
|
raise_HTTPException()
|
||||||
|
return {"status": "OK"}
|
|
@ -1,50 +1,22 @@
|
||||||
from typing import Dict
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine
|
|
||||||
from sqlalchemy.future import select
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from crowdtls.helpers import decode_der
|
from crowdtls.api.v1.api import app as api_v1_app
|
||||||
from db import CertificateChain
|
from crowdtls.db import create_db_and_tables
|
||||||
|
from crowdtls.logs import logger
|
||||||
DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/database"
|
|
||||||
engine = create_async_engine(DATABASE_URL, echo=True)
|
|
||||||
|
|
||||||
app = FastAPI()
|
app = FastAPI()
|
||||||
|
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["POST"], allow_headers=["*"])
|
||||||
|
|
||||||
|
app.include_router(api_v1_app, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
async with engine.begin() as connection:
|
logger.info("Creating database and tables")
|
||||||
await connection.run_sync(SQLModel.metadata.create_all)
|
try:
|
||||||
|
await create_db_and_tables()
|
||||||
|
except Exception:
|
||||||
@app.post("/check")
|
logger.error("Failed to create database and tables")
|
||||||
async def check_fingerprints(fingerprints: Dict[str, List[int]]):
|
raise
|
||||||
fps = fingerprints.get("fps")
|
|
||||||
async with AsyncSession(engine) as session:
|
|
||||||
for fp in fps:
|
|
||||||
stmt = select(CertificateChain).where(CertificateChain.fingerprint == fp)
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
certificate = result.scalars().first()
|
|
||||||
if not certificate:
|
|
||||||
return {"send": True}
|
|
||||||
return {"send": False}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/new")
|
|
||||||
async def new_fingerprints(fingerprints: Dict[str, List[int]]):
|
|
||||||
async with AsyncSession(engine) as session:
|
|
||||||
for fp, _ in fingerprints.items():
|
|
||||||
stmt = select(CertificateChain).where(CertificateChain.fingerprint == fp)
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
certificate = result.scalars().first()
|
|
||||||
if not certificate:
|
|
||||||
new_certificate = decode_der(fp)
|
|
||||||
session.add(new_certificate)
|
|
||||||
pass
|
|
||||||
await session.commit()
|
|
||||||
return {"status": "OK"}
|
|
||||||
|
|
|
@ -1,22 +1,28 @@
|
||||||
from sqlalchemy import Integer
|
import os
|
||||||
from sqlalchemy import LargeBinary
|
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
class CertificateChain(SQLModel, table=True):
|
DATABASE_URL = (
|
||||||
id: int = Field(default=None, primary_key=True)
|
f'postgresql+asyncpg://{os.environ.get("POSTGRES_USER")}:'
|
||||||
fingerprint: str = Field(index=True)
|
+ f'{os.environ.get("POSTGRES_PASSWORD")}@'
|
||||||
domain_name: str
|
+ os.environ.get("POSTGRES_CONTAINER")
|
||||||
raw_der_certificate: LargeBinary
|
+ f':5432/{os.environ.get("POSTGRES_DB")}'
|
||||||
version: int
|
)
|
||||||
serial_number: str
|
|
||||||
signature: LargeBinary
|
engine = create_async_engine(DATABASE_URL, echo=True, future=True)
|
||||||
issuer: JSONB
|
|
||||||
validity: JSONB
|
|
||||||
subject: JSONB
|
async def get_session() -> AsyncSession:
|
||||||
subject_public_key_info: JSONB
|
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
issuer_unique_id: Integer
|
async with async_session() as session:
|
||||||
subject_unique_id: Integer
|
yield session
|
||||||
extensions: JSONB
|
|
||||||
|
|
||||||
|
async def create_db_and_tables():
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(SQLModel.metadata.drop_all)
|
||||||
|
await conn.run_sync(SQLModel.metadata.create_all)
|
||||||
|
|
|
@ -2,29 +2,46 @@ from typing import List
|
||||||
|
|
||||||
from cryptography import x509
|
from cryptography import x509
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from tld import get_tld
|
||||||
|
|
||||||
from crowdtls.db import CertificateChain
|
from crowdtls.logs import logger
|
||||||
|
from crowdtls.models import Certificate
|
||||||
|
from crowdtls.models import Domain
|
||||||
|
|
||||||
|
|
||||||
def decode_der(raw_der_certificate: List[int]) -> CertificateChain:
|
def decode_der(fingerprint: str, raw_der_certificate: List[int]) -> Certificate:
|
||||||
# Convert list of integers to bytes
|
|
||||||
der_cert_bytes = bytes(raw_der_certificate)
|
der_cert_bytes = bytes(raw_der_certificate)
|
||||||
|
|
||||||
# Parse the DER certificate
|
|
||||||
cert = x509.load_der_x509_certificate(der_cert_bytes, default_backend())
|
cert = x509.load_der_x509_certificate(der_cert_bytes, default_backend())
|
||||||
|
|
||||||
certificate_chain = CertificateChain(
|
public_key_bytes = cert.public_key().public_bytes(
|
||||||
raw_der_certificate=der_cert_bytes,
|
encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
return Certificate(
|
||||||
|
fingerprint=fingerprint,
|
||||||
version=cert.version.value,
|
version=cert.version.value,
|
||||||
serial_number=cert.serial_number,
|
serial_number=cert.serial_number,
|
||||||
signature=cert.signature,
|
signature=cert.signature,
|
||||||
issuer=cert.issuer.rfc4514_string(),
|
issuer=cert.issuer.rfc4514_string(),
|
||||||
validity={"not_valid_before": cert.not_valid_before, "not_valid_after": cert.not_valid_after},
|
not_valid_before=cert.not_valid_before,
|
||||||
|
not_valid_after=cert.not_valid_after,
|
||||||
subject=cert.subject.rfc4514_string(),
|
subject=cert.subject.rfc4514_string(),
|
||||||
subject_public_key_info=cert.public_key().public_bytes(),
|
subject_public_key_info=public_key_bytes,
|
||||||
issuer_unique_id=cert.issuer_unique_id,
|
raw_der_certificate=der_cert_bytes,
|
||||||
subject_unique_id=cert.subject_unique_id,
|
|
||||||
extensions=[str(ext) for ext in cert.extensions],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return certificate_chain
|
|
||||||
|
def parse_hostname(hostname: str) -> Domain:
|
||||||
|
try:
|
||||||
|
parsed_domain = get_tld(f"https://{hostname}", as_object=True)
|
||||||
|
return Domain(fqdn=hostname, root=parsed_domain.domain, tld=parsed_domain.tld)
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"Failed to parse hostname: {hostname}")
|
||||||
|
|
||||||
|
|
||||||
|
def raise_HTTPException(
|
||||||
|
status_code: int = 500, detail: str = "Error encountered and reported. Please try again later."
|
||||||
|
) -> None:
|
||||||
|
raise HTTPException(status_code=status_code, detail=detail)
|
||||||
|
|
35
crowdtls/logs.py
Normal file
35
crowdtls/logs.py
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
|
||||||
|
logger.add(
|
||||||
|
"app.log",
|
||||||
|
format="<level><light-blue>{time:YYYY-MM-DD HH:mm:ss} | {message}</light-blue></level>",
|
||||||
|
level="INFO",
|
||||||
|
rotation="1 day",
|
||||||
|
retention="30 days",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.add(
|
||||||
|
"errors.log",
|
||||||
|
format="<level><yellow>ℹ️ {time:YYYY-MM-DD HH:mm:ss} | {message}</yellow></level>",
|
||||||
|
level="WARNING",
|
||||||
|
rotation="1 day",
|
||||||
|
retention="30 days",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.add(
|
||||||
|
"error.log",
|
||||||
|
format="<level><red>⛔️ {time:YYYY-MM-DD HH:mm:ss} | {message}</red></level>",
|
||||||
|
level="ERROR",
|
||||||
|
rotation="1 day",
|
||||||
|
retention="30 days",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
logger.add(
|
||||||
|
"critical.log",
|
||||||
|
format="<level><magenta>🚨 {time:YYYY-MM-DD HH:mm:ss} | {message}</magenta></level>",
|
||||||
|
level="CRITICAL",
|
||||||
|
rotation="1 day",
|
||||||
|
retention="30 days",
|
||||||
|
)
|
36
crowdtls/models.py
Normal file
36
crowdtls/models.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import datetime
|
||||||
|
from typing import List
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import LargeBinary
|
||||||
|
from sqlmodel import Field
|
||||||
|
from sqlmodel import Relationship
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCertificateLink(SQLModel, table=True):
|
||||||
|
fqdn: Optional[str] = Field(default=None, foreign_key="domain.fqdn", primary_key=True)
|
||||||
|
fingerprint: Optional[str] = Field(default=None, foreign_key="certificate.fingerprint", primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Domain(SQLModel, table=True):
|
||||||
|
fqdn: str = Field(primary_key=True)
|
||||||
|
root: str
|
||||||
|
tld: str
|
||||||
|
certificates: Optional[List["Certificate"]] = Relationship(
|
||||||
|
back_populates="domains", link_model=DomainCertificateLink
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Certificate(SQLModel, table=True):
|
||||||
|
fingerprint: str = Field(index=True, primary_key=True)
|
||||||
|
version: int
|
||||||
|
serial_number: str
|
||||||
|
signature: bytes = Field(default_factory=LargeBinary)
|
||||||
|
issuer: str
|
||||||
|
not_valid_before: datetime.datetime
|
||||||
|
not_valid_after: datetime.datetime
|
||||||
|
subject: str
|
||||||
|
subject_public_key_info: str
|
||||||
|
raw_der_certificate: bytes = Field(default_factory=LargeBinary)
|
||||||
|
domains: Optional[List[Domain]] = Relationship(back_populates="certificates", link_model=DomainCertificateLink)
|
90
crowdtls/types.py
Normal file
90
crowdtls/types.py
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
import json
|
||||||
|
from typing import Generic
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from pydantic import parse_obj_as
|
||||||
|
from pydantic.main import ModelMetaclass
|
||||||
|
from sqlmodel import JSON
|
||||||
|
from sqlmodel import TypeDecorator
|
||||||
|
|
||||||
|
# Adapted from: https://github.com/tiangolo/sqlmodel/issues/63#issuecomment-1081555082
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def pydantic_column_type(pydantic_type):
|
||||||
|
class PydanticJSONType(TypeDecorator, Generic[T]):
|
||||||
|
impl = JSON()
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
json_encoder=json,
|
||||||
|
):
|
||||||
|
self.json_encoder = json_encoder
|
||||||
|
super(PydanticJSONType, self).__init__()
|
||||||
|
|
||||||
|
def bind_processor(self, dialect):
|
||||||
|
impl_processor = self.impl.bind_processor(dialect)
|
||||||
|
dumps = self.json_encoder.dumps
|
||||||
|
if impl_processor:
|
||||||
|
|
||||||
|
def process(value: T):
|
||||||
|
if value is not None:
|
||||||
|
if isinstance(pydantic_type, ModelMetaclass):
|
||||||
|
# This allows to assign non-InDB models and if they're
|
||||||
|
# compatible, they're directly parsed into the InDB
|
||||||
|
# representation, thus hiding the implementation in the
|
||||||
|
# background. However, the InDB model will still be returned
|
||||||
|
value_to_dump = pydantic_type.from_orm(value)
|
||||||
|
else:
|
||||||
|
value_to_dump = value
|
||||||
|
value = jsonable_encoder(value_to_dump)
|
||||||
|
return impl_processor(value)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def process(value):
|
||||||
|
if isinstance(pydantic_type, ModelMetaclass):
|
||||||
|
# This allows to assign non-InDB models and if they're
|
||||||
|
# compatible, they're directly parsed into the InDB
|
||||||
|
# representation, thus hiding the implementation in the
|
||||||
|
# background. However, the InDB model will still be returned
|
||||||
|
value_to_dump = pydantic_type.from_orm(value)
|
||||||
|
else:
|
||||||
|
value_to_dump = value
|
||||||
|
value = dumps(jsonable_encoder(value_to_dump))
|
||||||
|
return value
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
def result_processor(self, dialect, coltype) -> T:
|
||||||
|
impl_processor = self.impl.result_processor(dialect, coltype)
|
||||||
|
if impl_processor:
|
||||||
|
|
||||||
|
def process(value):
|
||||||
|
value = impl_processor(value)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
data = value
|
||||||
|
# Explicitly use the generic directly, not type(T)
|
||||||
|
full_obj = parse_obj_as(pydantic_type, data)
|
||||||
|
return full_obj
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def process(value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Explicitly use the generic directly, not type(T)
|
||||||
|
full_obj = parse_obj_as(pydantic_type, value)
|
||||||
|
return full_obj
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
def compare_values(self, x, y):
|
||||||
|
return x == y
|
||||||
|
|
||||||
|
return PydanticJSONType
|
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
crowdtls-db:
|
||||||
|
image: postgres:15.3
|
||||||
|
container_name: crowdtls-db
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "pg_isready" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
volumes:
|
||||||
|
- crowdtls-db-data:/var/lib/postgresql/data
|
||||||
|
ports: [ "5432:5432" ]
|
||||||
|
env_file: [ .env ]
|
||||||
|
crowdtls:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
- CROWDTLS_INTERNAL_WEB_PORT=${CROWDTLS_INTERNAL_WEB_PORT}
|
||||||
|
ports:
|
||||||
|
[
|
||||||
|
"${CROWDTLS_HOST_WEB_PORT}:${CROWDTLS_INTERNAL_WEB_PORT}"
|
||||||
|
]
|
||||||
|
image: crowdtls:latest
|
||||||
|
container_name: crowdtls
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
crowdtls-db:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file: [ .env ]
|
||||||
|
volumes:
|
||||||
|
crowdtls-db-data:
|
||||||
|
name: crowdtls-db-data
|
|
@ -19,6 +19,7 @@ dependencies = [
|
||||||
"greenlet==2.0.2",
|
"greenlet==2.0.2",
|
||||||
"sqlmodel==0.0.8",
|
"sqlmodel==0.0.8",
|
||||||
"sqlalchemy==1.4.41",
|
"sqlalchemy==1.4.41",
|
||||||
|
"tld>=0.13",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|
Loading…
Reference in a new issue