From de756aea076f0c734cf6c9d2ac6d7462e2260c6b Mon Sep 17 00:00:00 2001 From: Ryan Fowler Date: Mon, 26 Jan 2026 13:06:27 -0800 Subject: [PATCH] Add support for mTLS --- docs/configuration.md | 104 +++++++++++++++++++++++++++--------------- index.ts | 6 ++- lib/cli.ts | 21 +++++++++ lib/server.ts | 6 +++ 4 files changed, 100 insertions(+), 37 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 76d38367..6346ea6e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -4,45 +4,47 @@ imaged can be configured via CLI flags or environment variables. CLI flags take ## CLI Options -| Flag | Description | Default | -| ------------------------------- | ------------------------------------------- | ----------- | -| `-p, --port ` | HTTP port to listen on | 8000 | -| `-H, --host
` | HTTP host to bind to | - | -| `-u, --unix ` | Unix socket path (overrides port/host) | - | -| `-c, --concurrency ` | Max concurrent image operations | CPU cores | -| `-b, --body-limit ` | Max request body size in bytes | 16,777,216 | -| `-x, --pixel-limit ` | Max input image pixels | 100,000,000 | -| `-d, --dimension-limit ` | Max output width/height in pixels | 16,384 | -| `-f, --enable-fetch` | Enable GET endpoints that fetch remote URLs | false | -| `-a, --allowed-hosts ` | Regex pattern for allowed fetch hosts | - | -| ` --disable-ssrf-protection` | Disable SSRF protection for fetch requests | false | -| `-P, --enable-pipeline` | Enable the /pipeline endpoint (Bun only) | false | -| ` --max-pipeline-tasks ` | Max tasks per pipeline request | 10 | -| `-l, --log-format ` | Log format: `json` or `text` | text | -| `-L, --log-level ` | Log level: `debug`, `info`, `warn`, `error` | info | -| ` --tls-cert ` | Path to TLS certificate file | - | -| ` --tls-key ` | Path to TLS private key file | - | +| Flag | Description | Default | +| ------------------------------- | ------------------------------------------------------------- | ----------- | +| `-p, --port ` | HTTP port to listen on | 8000 | +| `-H, --host
` | HTTP host to bind to | - | +| `-u, --unix ` | Unix socket path (overrides port/host) | - | +| `-c, --concurrency ` | Max concurrent image operations | CPU cores | +| `-b, --body-limit ` | Max request body size in bytes | 16,777,216 | +| `-x, --pixel-limit ` | Max input image pixels | 100,000,000 | +| `-d, --dimension-limit ` | Max output width/height in pixels | 16,384 | +| `-f, --enable-fetch` | Enable GET endpoints that fetch remote URLs | false | +| `-a, --allowed-hosts ` | Regex pattern for allowed fetch hosts | - | +| ` --disable-ssrf-protection` | Disable SSRF protection for fetch requests | false | +| `-P, --enable-pipeline` | Enable the /pipeline endpoint (Bun only) | false | +| ` --max-pipeline-tasks ` | Max tasks per pipeline request | 10 | +| `-l, --log-format ` | Log format: `json` or `text` | text | +| `-L, --log-level ` | Log level: `debug`, `info`, `warn`, `error` | info | +| ` --tls-cert ` | Path to TLS certificate file | - | +| ` --tls-key ` | Path to TLS private key file | - | +| ` --mtls-ca ` | Path to CA certificate for client verification (enables mTLS) | - | ## Environment Variables -| Environment Variable | CLI Equivalent | Description | -| ------------------------- | --------------------------- | ------------------------------------------- | -| `PORT` | `--port` | HTTP port to listen on | -| `HOST` | `--host` | HTTP host to bind to | -| `UNIX_SOCKET` | `--unix` | Unix socket path | -| `CONCURRENCY` | `--concurrency` | Max concurrent image operations | -| `BODY_LIMIT` | `--body-limit` | Max request body size in bytes | -| `PIXEL_LIMIT` | `--pixel-limit` | Max input image pixels | -| `DIMENSION_LIMIT` | `--dimension-limit` | Max output width/height in pixels | -| `ENABLE_FETCH` | `--enable-fetch` | Enable GET endpoints (`true`/`false`) | -| `ALLOWED_HOSTS` | `--allowed-hosts` | Regex pattern for allowed fetch hosts | -| `DISABLE_SSRF_PROTECTION` | `--disable-ssrf-protection` | Disable SSRF protection (`true`/`false`) | -| `ENABLE_PIPELINE` | `--enable-pipeline` | Enable pipeline endpoint (`true`/`false`) | -| `MAX_PIPELINE_TASKS` | `--max-pipeline-tasks` | Max tasks per pipeline request | -| `LOG_FORMAT` | `--log-format` | Log format: `json` or `text` | -| `LOG_LEVEL` | `--log-level` | Log level: `debug`, `info`, `warn`, `error` | -| `TLS_CERT` | `--tls-cert` | Path to TLS certificate file | -| `TLS_KEY` | `--tls-key` | Path to TLS private key file | +| Environment Variable | CLI Equivalent | Description | +| ------------------------- | --------------------------- | ---------------------------------------------- | +| `PORT` | `--port` | HTTP port to listen on | +| `HOST` | `--host` | HTTP host to bind to | +| `UNIX_SOCKET` | `--unix` | Unix socket path | +| `CONCURRENCY` | `--concurrency` | Max concurrent image operations | +| `BODY_LIMIT` | `--body-limit` | Max request body size in bytes | +| `PIXEL_LIMIT` | `--pixel-limit` | Max input image pixels | +| `DIMENSION_LIMIT` | `--dimension-limit` | Max output width/height in pixels | +| `ENABLE_FETCH` | `--enable-fetch` | Enable GET endpoints (`true`/`false`) | +| `ALLOWED_HOSTS` | `--allowed-hosts` | Regex pattern for allowed fetch hosts | +| `DISABLE_SSRF_PROTECTION` | `--disable-ssrf-protection` | Disable SSRF protection (`true`/`false`) | +| `ENABLE_PIPELINE` | `--enable-pipeline` | Enable pipeline endpoint (`true`/`false`) | +| `MAX_PIPELINE_TASKS` | `--max-pipeline-tasks` | Max tasks per pipeline request | +| `LOG_FORMAT` | `--log-format` | Log format: `json` or `text` | +| `LOG_LEVEL` | `--log-level` | Log level: `debug`, `info`, `warn`, `error` | +| `TLS_CERT` | `--tls-cert` | Path to TLS certificate file | +| `TLS_KEY` | `--tls-key` | Path to TLS private key file | +| `MTLS_CA` | `--mtls-ca` | Path to CA certificate for client verification | Boolean environment variables accept `true`, `1`, `false`, or `0`. @@ -84,3 +86,33 @@ docker run -p 8443:8000 \ ghcr.io/ryanfowler/imaged:latest \ --tls-cert /certs/cert.pem --tls-key /certs/key.pem ``` + +## mTLS (Mutual TLS) + +Enable client certificate verification by providing a CA certificate. When mTLS is enabled, clients must present a valid certificate signed by the specified CA. + +```bash +bun run index.ts \ + --tls-cert server.pem \ + --tls-key server-key.pem \ + --mtls-ca ca.pem +``` + +The `--mtls-ca` option requires TLS to be enabled (`--tls-cert` and `--tls-key`). Clients without valid certificates will be rejected. + +**Testing with curl:** + +```bash +curl --cert client.pem --key client-key.pem --cacert ca.pem https://localhost:8000/healthz +``` + +**Docker with mTLS:** + +```bash +docker run -p 8443:8000 \ + -v /path/to/certs:/certs:ro \ + ghcr.io/ryanfowler/imaged:latest \ + --tls-cert /certs/server.pem \ + --tls-key /certs/server-key.pem \ + --mtls-ca /certs/ca.pem +``` diff --git a/index.ts b/index.ts index 31e226a2..f55f607e 100644 --- a/index.ts +++ b/index.ts @@ -46,6 +46,10 @@ const url = await server.serve({ enableFetch: opts.enableFetch, tlsCert: opts.tlsCert, tlsKey: opts.tlsKey, + mtlsCa: opts.mtlsCa, pipelineExecutor, }); -logger.info({ url }, "server listening"); +logger.info( + { tls: !!opts.tlsCert && !!opts.tlsKey, mtls: !!opts.mtlsCa, url }, + "server listening", +); diff --git a/lib/cli.ts b/lib/cli.ts index 74e7765d..bd955aff 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -38,6 +38,7 @@ export interface CLIOptions { logLevel: LogLevel; tlsCert?: string; tlsKey?: string; + mtlsCa?: string; enablePipeline: boolean; maxPipelineTasks: number; s3Config?: S3Config; @@ -58,6 +59,7 @@ interface RawOptions { logLevel: string; tlsCert?: string; tlsKey?: string; + mtlsCa?: string; enablePipeline: boolean; maxPipelineTasks: string; } @@ -146,6 +148,12 @@ export function parseArgs(): CLIOptions { .addOption( new Option("--tls-key ", "Path to TLS private key file").env("TLS_KEY"), ) + .addOption( + new Option( + "--mtls-ca ", + "Path to CA certificate for client verification (enables mTLS)", + ).env("MTLS_CA"), + ) .addOption( new Option("-u, --unix ", "Unix socket path (overrides port/host)").env( "UNIX_SOCKET", @@ -248,6 +256,18 @@ export function parseArgs(): CLIOptions { } } + const mtlsCa = opts.mtlsCa; + if (mtlsCa) { + if (!fs.existsSync(mtlsCa)) { + logger.fatal({ path: mtlsCa }, "mTLS CA certificate file not found"); + process.exit(1); + } + if (!tlsCert || !tlsKey) { + logger.fatal("--mtls-ca requires TLS to be enabled (--tls-cert and --tls-key)"); + process.exit(1); + } + } + const maxPipelineTasks = parseInt(opts.maxPipelineTasks, 10); if (!Number.isInteger(maxPipelineTasks) || maxPipelineTasks <= 0) { logger.fatal({ value: opts.maxPipelineTasks }, "Invalid max-pipeline-tasks"); @@ -288,6 +308,7 @@ export function parseArgs(): CLIOptions { logLevel, tlsCert, tlsKey, + mtlsCa, enablePipeline: opts.enablePipeline, maxPipelineTasks, s3Config, diff --git a/lib/server.ts b/lib/server.ts index 817c8d5f..afe64f3a 100644 --- a/lib/server.ts +++ b/lib/server.ts @@ -50,6 +50,7 @@ export interface ServeOptions { enableFetch: boolean; tlsCert?: string; tlsKey?: string; + mtlsCa?: string; pipelineExecutor?: PipelineExecutor; } @@ -87,6 +88,11 @@ export class Server { https: { cert: fs.readFileSync(options.tlsCert), key: fs.readFileSync(options.tlsKey), + ...(options.mtlsCa && { + ca: fs.readFileSync(options.mtlsCa), + requestCert: true, + rejectUnauthorized: true, + }), }, }), });