Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ playwright-report/
.parts/
*.partial

# Phase 2 W3a — runtime CA / cert dirs (controller + executor bootstrap)
.ca/
.executor-certs/

# Database (dev)
*.db
*.sqlite
Expand Down
22 changes: 22 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# gitleaks config — extends the default ruleset with project-specific allowlist.
# CI runs: gitleaks detect over the PR commit range.

[extend]
useDefault = true

[allowlist]
description = "modelpull false positives"

# `key: ed25519.Ed25519PrivateKey` and similar dataclass field annotations in
# dlw.auth.* trip the default generic-api-key rule (a `key:` token followed by
# a high-entropy identifier). These are Python type annotations, not secrets —
# the actual CA / JWT keys are generated at runtime and persisted to
# chmod-600 files under ${DLW_CA_DIR}, never committed.
regexes = [
'''ed25519\.Ed25519PrivateKey''',
'''ed25519\.Ed25519PublicKey''',
]

# Test fixtures + auth modules legitimately contain the words "key", "token",
# "secret", "cert" in identifiers and docstrings; scope the regexes above
# rather than broad path excludes so real leaks are still caught.
56 changes: 48 additions & 8 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
paths:
# ========== Tasks ==========
/tasks:
get:

Check warning on line 70 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: List tasks
operationId: listTasks
Expand All @@ -90,7 +90,7 @@
'401': {$ref: '#/components/responses/Unauthenticated'}
'429': {$ref: '#/components/responses/RateLimited'}

post:

Check warning on line 93 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: Create download task
operationId: createTask
Expand Down Expand Up @@ -191,7 +191,7 @@
parameters:
- $ref: '#/components/parameters/TaskId'

get:

Check warning on line 194 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: Get task by ID
operationId: getTask
Expand All @@ -202,7 +202,7 @@
application/json:
schema: {$ref: '#/components/schemas/DownloadTask'}

patch:

Check warning on line 205 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: Update task (priority only)
operationId: updateTask
Expand All @@ -224,7 +224,7 @@
/tasks/{taskId}/cancel:
parameters:
- $ref: '#/components/parameters/TaskId'
post:

Check warning on line 227 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: Cancel task (async)
operationId: cancelTask
Expand All @@ -250,7 +250,7 @@
/tasks/{taskId}/retry:
parameters:
- $ref: '#/components/parameters/TaskId'
post:

Check warning on line 253 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: Retry failed subtasks
operationId: retrySubtasks
Expand Down Expand Up @@ -296,7 +296,7 @@
/tasks/{taskId}/upgrade:
parameters:
- $ref: '#/components/parameters/TaskId'
post:

Check warning on line 299 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: Upgrade to new revision (incremental)
operationId: upgradeTask
Expand All @@ -320,7 +320,7 @@
/tasks/{taskId}/subtasks:
parameters:
- $ref: '#/components/parameters/TaskId'
get:

Check warning on line 323 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [subtasks]
summary: List subtasks of a task
operationId: listSubtasks
Expand All @@ -343,7 +343,7 @@
/tasks/{taskId}/source-allocation:
parameters:
- $ref: '#/components/parameters/TaskId'
get:

Check warning on line 346 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: View source allocation (multi-source visualization)
operationId: getSourceAllocation
Expand All @@ -357,7 +357,7 @@
/tasks/{taskId}/events:
parameters:
- $ref: '#/components/parameters/TaskId'
get:

Check warning on line 360 in api/openapi.yaml

View workflow job for this annotation

GitHub Actions / OpenAPI lint

operation-description Operation "description" must be present and non-empty string.
tags: [tasks]
summary: Task event log
operationId: getTaskEvents
Expand Down Expand Up @@ -496,6 +496,21 @@
name: X-Heartbeat-HMAC
required: true
schema: {type: string}
- in: header
name: X-HMAC-Timestamp
required: true
schema: {type: integer}
description: Unix epoch seconds; validated within ±5 min of server clock
- in: header
name: X-HMAC-Nonce
required: true
schema: {type: string}
description: 128-bit random hex nonce; replay window enforced by server
- in: header
name: X-HMAC-Signature
required: true
schema: {type: string}
description: HMAC-SHA256(hmac_seed_hex, body || X-HMAC-Timestamp || X-HMAC-Nonce)
requestBody:
required: true
content:
Expand Down Expand Up @@ -638,18 +653,39 @@
schema: {type: string}
post:
tags: [executors]
summary: Renew JWT (proactive)
summary: Renew executor JWT and optionally rotate mTLS cert
operationId: renewExecutorJwt
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
client_csr_pem:
type: string
nullable: true
description: X.509 CSR PEM for cert rotation; omit or null to renew JWT only
responses:
'200':
description: New JWT
description: Renewed credentials
content:
application/json:
schema:
type: object
properties:
executor_jwt: {type: string}
expires_at: {type: string, format: date-time}
jwt_renew_in_seconds: {type: integer}
client_cert_pem:
type: string
nullable: true
description: New PEM-encoded client cert; null if no CSR was provided
cert_renew_in_seconds:
type: integer
nullable: true
description: Seconds until next cert renewal; null if cert not rotated
'401':
description: Invalid or expired executor JWT / mTLS cert

/executors/{executorId}/poll:
parameters:
Expand Down Expand Up @@ -1525,7 +1561,7 @@
# ===== Executor =====
ExecutorRegisterRequest:
type: object
required: [host_id, executor_id_proposal, capabilities, client_csr]
required: [host_id, executor_id_proposal, capabilities, client_csr_pem]
properties:
host_id: {type: string}
executor_id_proposal: {type: string}
Expand Down Expand Up @@ -1565,22 +1601,26 @@
properties:
alias: {type: string}
source_id: {type: string}
client_csr:
client_csr_pem:
type: string
description: X.509 CSR PEM
description: X.509 CSR in PEM format

ExecutorRegisterResponse:
type: object
properties:
executor_id: {type: string}
epoch: {type: integer, format: int64}
client_cert: {type: string, description: PEM-encoded X.509}
client_cert_pem: {type: string, description: PEM-encoded X.509 client certificate}
ca_chain:
type: array
items: {type: string}
description: CA certificate chain PEM strings
executor_jwt: {type: string}
hmac_seed_hex: {type: string, description: 256-bit hex HMAC seed for heartbeat signatures}
cert_renew_in_seconds: {type: integer, description: Seconds until certificate renewal recommended}
jwt_renew_in_seconds: {type: integer, description: Seconds until JWT renewal recommended}
jwt_signing_alg: {type: string, const: EdDSA}
next_renew_in_seconds: {type: integer}
next_renew_in_seconds: {type: integer, description: "Deprecated: use cert_renew_in_seconds"}

HeartbeatRequest:
type: object
Expand Down
49 changes: 49 additions & 0 deletions docs/operator/executor-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,52 @@ any paused subtasks that appeared after the original cancel.
A future Phase 2 W3 release will add heartbeat-carried cancellation
signals so executors abort in-flight downloads on chunk boundaries,
reducing latency to sub-minute.

## mTLS + Executor JWT + HMAC (Phase 2 W3a+)

### Controller bootstrap

On first launch the controller generates, under `${DLW_CA_DIR}` (default
`./.ca`, chmod 700):

- `ca-cert.pem` / `ca-key.pem` — the self-signed CA (10-year validity).
- `server-cert.pem` / `server-key.pem` — the controller's TLS server cert
(SAN: localhost, $DLW_CONTROLLER_HOSTNAME, 127.0.0.1, ::1).
- `jwt-signing.pem` — Ed25519 JWT signing key.
- `enrollment.token` — 256-bit hex token (also logged once at INFO).

Run uvicorn with TLS:

uvicorn dlw.main:app --host 0.0.0.0 --port 8443 \
--ssl-keyfile ${DLW_CA_DIR}/server-key.pem \
--ssl-certfile ${DLW_CA_DIR}/server-cert.pem \
--ssl-ca-certs ${DLW_CA_DIR}/ca-cert.pem \
--ssl-cert-reqs 1

`--ssl-cert-reqs 1` (CERT_OPTIONAL) — the server requests a client cert but
does not reject connections that lack one at the TLS layer. `/register`
(enrollment-token auth, no client cert) and `/health/*` need this. The
application layer (`require_executor_mtls`) enforces the cert where required.

### Enrolling an executor

1. Copy the controller's enrollment token to the executor host out-of-band.
2. Set `DLW_EXECUTOR_ENROLLMENT_TOKEN` on the executor.
3. On first boot the executor generates a keypair, builds a CSR, calls
`/register`, and persists `client-cert.pem` / `client-key.pem` /
`ca-chain.pem` / `hmac-seed` under `${DLW_EXECUTOR_EXECUTOR_CERT_DIR}`
(default `./.executor-certs`).
4. Certs auto-renew (24h cert, 1h JWT) via the executor's renew loop.

### `DLW_TLS_TRUSTED_PROXY` — security warning

`DLW_TLS_TRUSTED_PROXY=1` makes the controller honor the
`X-Client-Cert-PEM` header instead of the direct TLS peer cert. Only
enable this when a real TLS-terminating reverse proxy sits in front AND
the uvicorn port is NOT directly reachable. With it on and no proxy,
anyone can forge the header. Default is `0` (direct uvicorn TLS only).

### Host clock sync

Heartbeats carry an HMAC timestamp validated within ±5 min. Run
`chrony` / `systemd-timesyncd` on all executor + controller hosts.
Loading
Loading