diff --git a/.gitignore b/.gitignore index 26f3b6e..d85f138 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ .vscode damnit_proposals.json +certs/ +*.crt +*.key +*.pem \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile index 3b91d8c..7bb98df 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -31,4 +31,4 @@ COPY ./README.md ./README.md EXPOSE 8000 -CMD ["poetry", "run", "uvicorn", "damnit_api.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["poetry", "run", "python3", "-m", "damnit_api.main"] diff --git a/api/compose.dev.yml b/api/compose.dev.yml index f3cdc30..ed1c7e3 100644 --- a/api/compose.dev.yml +++ b/api/compose.dev.yml @@ -2,10 +2,11 @@ name: damnit-web-dev services: api: - userns_mode: keep-id + userns_mode: host ports: - 8123:8000 volumes: + - ./certs:/certs - /gpfs/exfel/data/scratch/xdana/tmp/damnit-web:/var/tmp - /gpfs:/gpfs - /pnfs:/pnfs diff --git a/api/compose.yml b/api/compose.yml index 57b7c52..64b566e 100644 --- a/api/compose.yml +++ b/api/compose.yml @@ -6,6 +6,11 @@ services: env_file: - .env ports: - - 8000 + - 8000:8000 + environment: + DW_API_UVICORN__SSL_CERTFILE: /certs/server.crt + DW_API_UVICORN__SSL_KEYFILE: /certs/server.key + DW_API_UVICORN__SSL_CA_CERTS: /certs/root_ca.crt volumes: - ./tmp-damnit-web/:/tmp/damnit-web/ + - ./certs:/certs diff --git a/api/poetry.lock b/api/poetry.lock index 69f0550..c3a6345 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -38,7 +38,6 @@ files = [ ] [package.dependencies] -exceptiongroup = {version = "*", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" @@ -58,9 +57,6 @@ files = [ {file = "async_lru-2.0.4-py3-none-any.whl", hash = "sha256:ff02944ce3c288c5be660c42dbcca0742b32c3b279d6dceda655190240b99224"}, ] -[package.dependencies] -typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} - [[package]] name = "authlib" version = "1.3.2" @@ -437,20 +433,6 @@ files = [ docs = ["ipython", "matplotlib", "numpydoc", "sphinx"] tests = ["pytest", "pytest-cov", "pytest-xdist"] -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - [[package]] name = "fastapi" version = "0.104.1" @@ -796,28 +778,6 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] -[[package]] -name = "importlib-resources" -version = "6.4.5" -description = "Read resources from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_resources-6.4.5-py3-none-any.whl", hash = "sha256:ac29d5f956f01d5e4bb63102a5a19957f1b9175e45649977264a1416783bb717"}, - {file = "importlib_resources-6.4.5.tar.gz", hash = "sha256:980862a1d16c9e147a59603677fa2aa5fd82b87f223b6cb870695bcfce830065"}, -] - -[package.dependencies] -zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["jaraco.test (>=5.4)", "pytest (>=6,!=8.1.*)", "zipp (>=3.17)"] -type = ["pytest-mypy"] - [[package]] name = "iniconfig" version = "2.0.0" @@ -1094,7 +1054,6 @@ files = [ contourpy = ">=1.0.1" cycler = ">=0.10" fonttools = ">=4.22.0" -importlib-resources = {version = ">=3.2.0", markers = "python_version < \"3.10\""} kiwisolver = ">=1.3.1" numpy = ">=1.23" packaging = ">=20.0" @@ -1301,11 +1260,7 @@ files = [ ] [package.dependencies] -numpy = [ - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, - {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.23.2", markers = "python_version == \"3.11\""}, -] +numpy = {version = ">=1.26.0", markers = "python_version >= \"3.12\""} python-dateutil = ">=2.8.2" pytz = ">=2020.1" tzdata = ">=2022.7" @@ -1674,11 +1629,9 @@ files = [ [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} iniconfig = "*" packaging = "*" pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] @@ -2074,7 +2027,6 @@ files = [ [package.dependencies] anyio = ">=3.4.0,<5" -typing-extensions = {version = ">=3.10.0", markers = "python_version < \"3.10\""} [package.extras] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] @@ -2137,17 +2089,6 @@ docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphi tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] typing = ["mypy (>=1.4)", "rich", "twisted"] -[[package]] -name = "tomli" -version = "2.0.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.7" -files = [ - {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, - {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, -] - [[package]] name = "typer" version = "0.12.5" @@ -2222,7 +2163,6 @@ h11 = ">=0.8" httptools = {version = ">=0.5.0", optional = true, markers = "extra == \"standard\""} python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} -typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} @@ -2464,26 +2404,7 @@ files = [ {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, ] -[[package]] -name = "zipp" -version = "3.20.2" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.8" -files = [ - {file = "zipp-3.20.2-py3-none-any.whl", hash = "sha256:a817ac80d6cf4b23bf7f2828b7cabf326f15a001bea8b1f9b49631780ba28350"}, - {file = "zipp-3.20.2.tar.gz", hash = "sha256:bc9eb26f4506fda01b81bcde0ca78103b6e62f991b381fec825435c836edbc29"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "1b04d19cf36c5332efcd1946e0b7d0e6b9dc157f6908fdd6fccf392c0d2268f3" +python-versions = "^3.12" +content-hash = "52cc0d52a785920625a96b4b10a94bea35da65f842c1e3d5c6ed3e7a8d52d82b" diff --git a/api/src/damnit_api/main.py b/api/src/damnit_api/main.py index 782e891..16ff12d 100644 --- a/api/src/damnit_api/main.py +++ b/api/src/damnit_api/main.py @@ -76,6 +76,11 @@ async def http_exception_handler(request: Request, exc: HTTPException): logger.info("Starting uvicorn with settings", **settings.uvicorn.model_dump()) + if settings.uvicorn.ssl_cert_reqs != 2: + logger.warning( + "Not configured to require mTLS. This is not recommended for production." + ) + uvicorn.run( "damnit_api.main:create_app", **settings.uvicorn.model_dump(), diff --git a/api/src/damnit_api/settings.py b/api/src/damnit_api/settings.py index 87540b9..aeb52b1 100644 --- a/api/src/damnit_api/settings.py +++ b/api/src/damnit_api/settings.py @@ -1,13 +1,15 @@ -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Annotated from pydantic import ( BaseModel, + FilePath, HttpUrl, SecretStr, UrlConstraints, field_validator, + model_validator, ) from pydantic_settings import BaseSettings, SettingsConfigDict @@ -24,6 +26,25 @@ class UvicornSettings(BaseModel): reload: bool = True factory: bool = True + ssl_keyfile: FilePath | None = None + ssl_certfile: FilePath | None = None + ssl_ca_certs: FilePath | None = None + ssl_cert_reqs: int | None = None + + @model_validator(mode="after") + def ssl_all_if_one(self): + """Ensure all SSL settings are set if one is set.""" + files = [self.ssl_keyfile, self.ssl_certfile, self.ssl_ca_certs] + if any(files) and not all(files): + msg = "ssl_keyfile, ssl_certfile, and ssl_ca_certs must all be set" + raise ValueError(msg) + + if all(files): + # Default to 2 (require mTLS) if any SSL settings are set + self.ssl_cert_reqs = self.ssl_cert_reqs or 2 + + return self + @field_validator("factory", mode="after") @classmethod def factory_must_be_true(cls, v, values): @@ -52,7 +73,7 @@ class MyMdCCredentials(BaseSettings): base_url: HttpUrl _access_token: str = "" - _expires_at: datetime = datetime.fromisocalendar(1970, 1, 1).astimezone(timezone.utc) + _expires_at: datetime = datetime.fromisocalendar(1970, 1, 1).astimezone(UTC) class Settings(BaseSettings): diff --git a/frontend/compose.dev.yml b/frontend/compose.dev.yml index 19a0015..370ac87 100644 --- a/frontend/compose.dev.yml +++ b/frontend/compose.dev.yml @@ -6,6 +6,10 @@ networks: services: frontend: + environment: + - NODE_EXTRA_CA_CERTS=/certs/root_ca.crt + volumes: + - ./certs:/certs networks: - proxy labels: diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 81fe8f4..2453b68 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,46 +1,71 @@ import { defineConfig, loadEnv } from "vite" -import path from "path" import react from "@vitejs/plugin-react" +import path from "path" +import fs from "fs" +import https from "https" export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()) - const baseUrl = (env.VITE_BASE_URL || "/").replace(/\/?$/, "/") + + const { VITE_MTLS_KEY, VITE_MTLS_CERT, VITE_MTLS_CA, VITE_URL, VITE_API } = + env + + if (!VITE_URL || !VITE_API) { + throw new Error( + "Missing required environment variables: VITE_URL and/or VITE_API", + ) + } + + const baseURL = new URL(VITE_URL) + const apiURL = new URL(VITE_API) + + let sslConfig + if (VITE_MTLS_KEY && VITE_MTLS_CERT && VITE_MTLS_CA) { + sslConfig = { + key: fs.readFileSync(path.resolve(__dirname, VITE_MTLS_KEY)), + cert: fs.readFileSync(path.resolve(__dirname, VITE_MTLS_CERT)), + ca: fs.readFileSync(path.resolve(__dirname, VITE_MTLS_CA)), + } + } else if (VITE_MTLS_KEY || VITE_MTLS_CERT || VITE_MTLS_CA) { + // If partial mTLS variables are set, that's invalid. + throw new Error( + "mTLS configuration is incomplete. Please provide all three: key, cert, and ca.", + ) + } + + const httpsAgent = sslConfig ? new https.Agent(sslConfig) : undefined + + // If the API server is HTTPS, mTLS configuration is required + if (apiURL.protocol === "https:" && !sslConfig) { + throw new Error("HTTPS API requires mTLS configuration") + } + + const defaultProxyConfig = { + target: apiURL.origin, + secure: !!sslConfig, + changeOrigin: false, + configure: (proxy, options) => { + if (sslConfig) { + options.agent = httpsAgent + } + }, + } return { - base: baseUrl, + base: baseURL.href, plugins: [react()], build: { outDir: "build", }, - // REMOVEME: Use proxy to handle CORS for the meantime server: { host: true, - port: Number(env.VITE_PORT) || 5173, + port: baseURL.port ? Number(baseURL.port) : 5173, proxy: { - [`${baseUrl}graphql`]: { - target: `ws://${env.VITE_BACKEND_API}`, - changeOrigin: false, - secure: false, - ws: true, - rewriteWsOrigin: false, - // REMOVEME: The API will have a base path similar to the frontend at some point - rewrite: (path) => path.replace(new RegExp(`^${baseUrl}`), "/"), - }, - [`${baseUrl}oauth`]: { - target: `http://${env.VITE_BACKEND_API}`, - changeOrigin: false, - secure: false, - // REMOVEME: The API will have a base path similar to the frontend at some point - rewrite: (path) => path.replace(new RegExp(`^${baseUrl}`), "/"), - }, - [`${baseUrl}metadata`]: { - target: `http://${env.VITE_BACKEND_API}`, - changeOrigin: false, - secure: false, - // REMOVEME: The API will have a base path similar to the frontend at some point - rewrite: (path) => path.replace(new RegExp(`^${baseUrl}`), "/"), - }, + "/graphql": { ...defaultProxyConfig, ws: true }, + "/oauth": { ...defaultProxyConfig }, + "/metadata": { ...defaultProxyConfig }, }, + https: sslConfig, }, test: { globals: true,