diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..97b7e7f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.venv +build +dist +*.egg-info +**/__pycache__ +.local +.env +etc/certlord/credentials.yml diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..bf0f2f7 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,77 @@ +name: Docker image and Compose +on: + pull_request: + push: + branches: [main] + release: + types: [published] + workflow_dispatch: + inputs: + publish: + description: Publish validated image to Docker Hub + type: boolean + default: false +permissions: + contents: read +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: false +jobs: + compose: + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} + timeout-minutes: 25 + env: + CERTLORD_IMAGE: certlord-compose-test:local + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Validate Compose and build image + run: | + docker compose config --quiet + docker compose build certlord + - name: Start installed stack and verify restart persistence + run: sh docker/smoke.sh + - name: Installed image dependency check + run: docker compose exec -T certlord python -m pip check + - name: Sanitized service state + if: always() + run: | + docker compose ps -a + docker compose logs --tail 15 vault certlord auton + - name: Remove disposable stack + if: always() + run: docker compose down -v --remove-orphans + publish: + needs: compose + if: >- + github.repository == 'decryptus/certlord' && + ((github.event_name == 'workflow_dispatch' && inputs.publish && github.ref == 'refs/heads/main') || + (github.event_name == 'release' && !github.event.release.prerelease)) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + - name: Validate stable image version + id: version + run: | + version=$(cat VERSION) + python -c 'import re; from pathlib import Path; assert re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", Path("VERSION").read_text().strip())' + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + username: decryptus + password: ${{ secrets.DOCKERHUB_TOKEN }} + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + build-args: VERSION=${{ steps.version.outputs.version }} + tags: | + decryptus/certlord:${{ steps.version.outputs.version }} + decryptus/certlord:latest diff --git a/.gitignore b/.gitignore index bc677b5..38a604a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ build/ dist/ *.egg-info/ etc/certlord/credentials.yml + +.local/ +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..652b12d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim-bookworm AS build +RUN apt-get update && apt-get install -y --no-install-recommends build-essential libcurl4-openssl-dev libssl-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /src +COPY . . +RUN python -m pip wheel --wheel-dir /wheels '.[textual]' 'certbot==5.8.0' 'certbot-httpreq==0.0.24' 'acme-http-connector==0.2.1' 'auton==1.2.1' 'autond==1.2.1' + +FROM python:3.12-slim-bookworm +ARG VERSION=1.0.0 +LABEL org.opencontainers.image.title="CertLord" \ + org.opencontainers.image.description="TLS certificate lifecycle automation with ACME, Vault storage and Auton deployment." \ + org.opencontainers.image.source="https://github.com/decryptus/certlord" \ + org.opencontainers.image.version=$VERSION \ + org.opencontainers.image.licenses="GPL-3.0-or-later" +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libcurl4 libmagic1 openssh-client && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 10001 certlord && useradd --uid 10001 --gid certlord --create-home certlord +COPY --from=build /wheels /wheels +RUN python -m pip install --no-index --find-links=/wheels certlord certbot certbot-httpreq acme-http-connector auton autond textual && rm -rf /wheels \ + && mkdir -p /var/lib/certlord /var/lib/auton && chown certlord:certlord /var/lib/certlord /var/lib/auton +COPY docker/ /opt/certlord-docker/ +ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 +USER 10001:10001 +WORKDIR /var/lib/certlord +EXPOSE 8666 +STOPSIGNAL SIGTERM +CMD ["certlord", "-f", "-c", "/etc/certlord/certlord.yml", "-p", "/var/lib/certlord/certlord.pid", "--logfile", "/var/lib/certlord/certlord.log"] diff --git a/MANIFEST.in b/MANIFEST.in index c86c4b7..63799d3 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -32,3 +32,6 @@ include scripts/capture_textual.py include conf.py index.rst docs/requirements.txt recursive-include _templates *.html recursive-include docs *.md *.rst + +include Dockerfile .dockerignore compose.yaml +recursive-include docker *.py *.sh *.hcl *.conf *.yml diff --git a/README.md b/README.md index 018a27b..de77626 100644 --- a/README.md +++ b/README.md @@ -19,8 +19,14 @@ are optional; expiry observations are exposed for an external supervision system It uses DWho, HTTPdis and Sonicprobe. Redis stores temporary ACME HTTP-01 challenge responses and tracks pending work, -retries and deployment leases. Vault stores certificate material. Both services -must be provisioned separately; see the [installation guide](docs/debian-installation.md). +retries and deployment leases. Vault stores certificate material. Use the [Docker Compose installation](docs/docker-compose.md) to run the services +together, or provision them separately for the [Debian installation](docs/debian-installation.md). + +## Docker installation + +Use the [Compose guide](docs/docker-compose.md) for initialization, persistent +Vault storage, API access and deployment configuration. The application image is +`decryptus/certlord:1.0.0`. ## Command line and terminal interface diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..788f110 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,90 @@ +name: certlord +x-image: &image + image: ${CERTLORD_IMAGE:-decryptus/certlord:1.0.0} + build: + context: . +services: + vault: + image: hashicorp/vault:1.21.4 + command: ["server"] + environment: + VAULT_ADDR: http://127.0.0.1:8200 + cap_add: [IPC_LOCK] + volumes: + - vault-data:/vault/file + - ./docker/vault.hcl:/vault/config/server.hcl:ro + networks: [backend] + restart: unless-stopped + redis: + image: redis:7.4-alpine + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] + volumes: [redis-data:/data] + networks: [backend] + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + setup: + <<: *image + profiles: [tools] + user: "0:0" + entrypoint: ["python", "/opt/certlord-docker/setup.py"] + volumes: + - ./.local:/bootstrap + networks: [backend] + certlord: + <<: *image + depends_on: + redis: + condition: service_healthy + vault: + condition: service_started + auton: + condition: service_started + ports: ["127.0.0.1:${CERTLORD_PORT:-8666}:8666"] + volumes: + - ./.local/certlord:/etc/certlord:ro + - certlord-data:/var/lib/certlord + networks: [backend, outbound] + restart: unless-stopped + init: true + stop_grace_period: 30s + security_opt: [no-new-privileges:true] + healthcheck: + test: ["CMD", "python", "/opt/certlord-docker/health.py"] + interval: 20s + timeout: 15s + retries: 3 + start_period: 30s + auton: + <<: *image + command: ["autond", "-f", "-u", "certlord", "-g", "certlord", "-c", "/etc/auton/auton.yml", "-p", "/var/lib/auton/auton.pid", "--logfile", "/var/lib/auton/auton.log"] + volumes: + - ./.local/auton:/etc/auton:ro + - ./.local/deploy:/deploy:ro + - auton-data:/var/lib/auton + networks: [backend, outbound] + restart: unless-stopped + init: true + stop_grace_period: 30s + security_opt: [no-new-privileges:true] + challenges: + image: nginx:1.28-alpine + profiles: [http01] + ports: ["${HTTP01_BIND:-0.0.0.0}:${HTTP01_PORT:-80}:8080"] + volumes: + - ./docker/challenges.conf:/etc/nginx/conf.d/default.conf:ro + networks: [backend, outbound] + depends_on: [certlord] + restart: unless-stopped +volumes: + vault-data: + redis-data: + certlord-data: + auton-data: +networks: + backend: + internal: true + outbound: {} diff --git a/docker/auton-routes.yml b/docker/auton-routes.yml new file mode 100644 index 0000000..2a6b177 --- /dev/null +++ b/docker/auton-routes.yml @@ -0,0 +1,43 @@ +job: + routes: + jobs: + handler: 'job_list' + regexp: '^jobs$' + auth: false + op: 'GET' + job_detail: + handler: 'job_detail' + regexp: '^jobs/(?P[^\/]+)/(?P[a-z0-9][a-z0-9\-]{7,63})$' + auth: false + op: 'GET' + endpoints: + handler: 'endpoint_list' + regexp: '^endpoints$' + auth: false + op: 'GET' + health: + handler: 'daemon_health' + regexp: '^health$' + auth: false + op: 'GET' + maintenance: + handler: 'daemon_maintenance' + regexp: '^maintenance$' + auth: true + op: 'POST' + stats: + handler: 'daemon_stats' + regexp: '^stats$' + auth: false + op: 'GET' + run: + handler: 'job_run' + regexp: '^run/(?P[^\/]+)/(?P[a-z0-9][a-z0-9\-]{7,63})$' + safe_init: true + auth: false + op: 'POST' + status: + handler: 'job_status' + regexp: '^status/(?P[^\/]+)/(?P[a-z0-9][a-z0-9\-]{7,63})$' + auth: false + op: 'GET' diff --git a/docker/certlord.example.yml b/docker/certlord.example.yml new file mode 100644 index 0000000..79307a5 --- /dev/null +++ b/docker/certlord.example.yml @@ -0,0 +1,180 @@ +general: + listen_addr: 0.0.0.0 + listen_port: 8666 + max_workers: 40 + max_requests: 5000 + max_life_time: 3600 + lock_timeout: 60 + charset: utf-8 + content_type: 'application/json; charset=utf-8' + redis: + letsencrypt: + url: 'redis://127.0.0.1:6379?db=0&socket_timeout=5&socket_connect_timeout=5' + ssl_certs: + url: 'redis://127.0.0.1:6379?db=1&socket_timeout=5&socket_connect_timeout=5' + #auth_basic: 'Restricted' + #auth_basic_file: '/etc/certlord/certlord.passwd' +# Enable HTTPdis authentication above and grant operations to verified users. +api_authentication: + backend: httpdis + permissions: {} + # permissions: + # operator: [read, write, deploy] + # acme-connector: [write, challenge] +certificate_storage: + backend: vault +credentials: credentials.yml +ssl_checkers: + statuscake: + enabled: false + timeout: 10 + alert_at: '1,7,28' + alert_broken: true + alert_expiry: true + alert_mixed: true + alert_reminder: true + checkrate: 3600 + contact_groups: [] # Configure your own monitoring contact groups. + updown: + enabled: false + timeout: 10 + apdex_t: 2.0 + disabled_locations: + - bhs + - fra + - mia + - sin + - syd + - tok + period: 3600 + recipients: [] # Configure your own monitoring recipients. +modules: + letsencrypt: + # Atomic Redis expiry bounds orphan retention if the ACME client cannot clean up. + # Keep longer than the longest expected challenge validation/command duration. + challenge_ttl: 3600 + routes: + well_known_delete: + handler: 'well_known_delete' + regexp: '^\.well-known/acme-challenge/(?P[0-9A-Za-z_\-]{43})/?$' + op: 'DELETE' + well_known_get: + handler: 'well_known_get' + regexp: '^\.well-known/acme-challenge/(?P[0-9A-Za-z_\-]{43})/?$' + op: 'GET' + well_known_put: + handler: 'well_known_put' + regexp: '^\.well-known/acme-challenge/(?P[0-9A-Za-z_\-]{43})/?$' + safe_init: true + op: 'PUT' + ssl_certs: + operation_scan_limit: 10000 + renew_before_expiry: 30 + delay_reset_retries: 86400 + max_retries: 5 + processing_delay: 900 + # Bounds for each Certbot/Auton command and shutdown (seconds). + command_timeout: 300 + process_stop_grace: 2 + worker_stop_timeout: 10 + # Optional post-deployment TLS check, one explicit destination per UUID. + # Uses the certificate domain for SNI/hostname verification. No insecure mode. + tls_verification: + timeout: 10 + targets: {} + # targets: + # 12345678-1111-4111-8111-111111111111: + # host: 192.0.2.10 + # port: 443 + # ca_file: /etc/certlord/trusted-ca.pem # omit for system roots + # Shared budget per DNS policy check (CAA ancestors share one budget). + dns_timeout: 5 + certbot_check_interval: 5 + deployer_check_interval: 86400 + vault_check_interval: 43200 + # Optional DNS authorization rules. Empty means no CNAME/TXT allow check. + # Configure your own patterns before enabling this policy. + allow_dns_resolv: {} + expected_caa_records: + - null + - '^letsencrypt\.org$' + auton: + # Opt in to confirmed single-job JSON receipts (Auton 1.2.1 tested). + receipt_mode: false + become: + enabled: false + #search_paths: [] + #args: [] + certbot: + become: + enabled: true + #search_paths: [] + #args: [] + routes: + live: + handler: live + name: api/health/live + op: GET + ready: + handler: ready + name: api/health/ready + op: GET + operation_metrics: + handler: operation_metrics + name: api/operations/metrics + op: GET + operations: + handler: operations + name: api/operations + op: GET + deploy: + handler: 'deploy' + name: 'api/ssl-certs/deploy' + op: 'POST' + save: + handler: 'save' + name: 'api/ssl-certs/save' + op: 'POST' + validate: + handler: 'validate' + regexp: '^api/ssl-certs/validate/(?P[^/]+)/?$' + op: 'GET' + create: + handler: 'create' + name: 'api/certificates' + op: 'POST' + replace_import: + handler: 'replace_import' + regexp: '^api/certificates/(?P[0-9a-f-]{36})/material/?$' + op: 'PUT' + import_certificate: + handler: 'import_certificate' + name: 'api/certificates/import' + op: 'POST' + observations: + handler: observations + name: api/certificates/observations + op: GET + metrics: + handler: metrics + name: api/certificates/metrics + op: GET + inventory: + handler: 'inventory' + name: 'api/certificates' + op: 'GET' + upsert: + handler: 'upsert' + regexp: '^api/certificates/(?P[0-9a-f-]{36})/?$' + op: 'PUT' + remove: + handler: 'remove' + regexp: '^api/certificates/(?P[0-9a-f-]{36})/?$' + op: 'DELETE' + index: + handler: 'index' + regexp: '^api/certificates/(?P[0-9a-f-]{36})/?$' + safe_init: true + at_start: true + at_stop: true + op: 'GET' diff --git a/docker/challenges.conf b/docker/challenges.conf new file mode 100644 index 0000000..54147fe --- /dev/null +++ b/docker/challenges.conf @@ -0,0 +1,11 @@ +server { + listen 8080; + server_name _; + location ~ ^/\.well-known/acme-challenge/[0-9A-Za-z_-]{43}/?$ { + limit_except GET { deny all; } + proxy_pass http://certlord:8666; + proxy_set_header Authorization ""; + proxy_set_header Host $host; + } + location / { return 404; } +} diff --git a/docker/health.py b/docker/health.py new file mode 100644 index 0000000..f5567a5 --- /dev/null +++ b/docker/health.py @@ -0,0 +1,12 @@ +"""Authenticated readiness without printing credentials or response bodies.""" +import sys +import requests +import yaml +try: + with open('/etc/certlord/health.yml') as stream: + credentials = yaml.safe_load(stream) + response = requests.get('http://127.0.0.1:8666/api/health/ready', + auth=(credentials['user'], credentials['password']), timeout=12) + sys.exit(0 if response.status_code == 200 else 1) +except Exception: + sys.exit(1) diff --git a/docker/setup.py b/docker/setup.py new file mode 100644 index 0000000..9e3db56 --- /dev/null +++ b/docker/setup.py @@ -0,0 +1,165 @@ +"""Explicit, local single-host bootstrap; recovery keys never reach app containers.""" +import argparse +import base64 +import crypt +import json +import os +from pathlib import Path +import secrets +import sys +import time + +import hvac +import yaml + +ROOT = Path('/bootstrap') +TEMPLATES = Path('/opt/certlord-docker') + + +def write(path, value, service=False): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + '.tmp') + temporary.write_text(value) + temporary.chmod(0o600) + if service: + os.chown(temporary, 10001, 10001) + temporary.replace(path) + + +def dump(path, value, service=True): + write(path, yaml.safe_dump(value, sort_keys=False), service) + + +def initialize(args, client): + marker = ROOT / 'initialized' + if marker.exists(): + print('Already initialized; configuration and credentials were preserved.') + return + recovery = ROOT / 'vault-recovery.json' + if not client.sys.is_initialized(): + if recovery.exists(): + raise RuntimeError('Recovery file exists but Vault is empty; restore before continuing') + result = client.sys.initialize(secret_shares=1, secret_threshold=1) + write(recovery, json.dumps(result)) + if not recovery.exists(): + raise RuntimeError('Existing Vault without matching recovery file; refusing to take ownership') + keys = json.loads(recovery.read_text()) + client.sys.submit_unseal_key(keys['keys_base64'][0]) + client.token = keys['root_token'] + if not client.is_authenticated(): + raise RuntimeError('Bootstrap root token unavailable; operator recovery required') + engines = client.sys.list_mounted_secrets_engines()['data'] + if 'secret/' not in engines: + client.sys.enable_secrets_engine('kv', path='secret', options={'version': '2'}) + methods = client.sys.list_auth_methods()['data'] + if 'approle/' not in methods: + client.sys.enable_auth_method('approle') + policy = (TEMPLATES / 'vault-policy.hcl').read_text() + client.sys.create_or_update_policy('certlord', policy) + client.auth.approle.create_or_update_approle('certlord', token_policies=['certlord'], + token_no_default_policy=True, token_ttl='1h', token_max_ttl='4h', + secret_id_ttl=0, secret_id_num_uses=0) + role = client.auth.approle.read_role_id('certlord')['data']['role_id'] + secret = client.auth.approle.generate_secret_id('certlord')['data']['secret_id'] + passwords = {name: secrets.token_urlsafe(32) for name in ('operator', 'connector', 'reader', 'auton')} + for directory in ('certlord', 'auton', 'deploy'): + path = ROOT / directory + path.mkdir(exist_ok=True) + path.chmod(0o700) + os.chown(path, 10001, 10001) + def htpasswd(names): + return ''.join(name + ':' + crypt.crypt(passwords[name], + crypt.mksalt(crypt.METHOD_SHA512, rounds=100000)) + '\n' for name in names) + write(ROOT / 'certlord/api.htpasswd', htpasswd(('operator', 'connector', 'reader')), True) + write(ROOT / 'auton/api.htpasswd', htpasswd(('auton',)), True) + dump(ROOT / 'certlord/health.yml', {'user': 'reader', 'password': passwords['reader']}) + write(ROOT / 'admin-login.txt', 'User: operator\nPassword: ' + passwords['operator'] + '\n') + config = yaml.safe_load((TEMPLATES / 'certlord.example.yml').read_text()) + general = config['general'] + general.update(server_id='certlord-compose', auth_basic='CertLord', + auth_basic_file='/etc/certlord/api.htpasswd') + general['redis'] = {name: {'url': 'redis://redis:6379/%d?socket_timeout=5&socket_connect_timeout=5' % db} + for name, db in (('letsencrypt', 0), ('ssl_certs', 1))} + config['credentials'] = '/etc/certlord/credentials.yml' + config['api_authentication'] = {'backend': 'httpdis', 'permissions': { + 'operator': ['read', 'write', 'deploy'], 'connector': ['write', 'challenge'], 'reader': ['read']}} + module = config['modules']['ssl_certs'] + module['certbot'] = {'become': {'enabled': False}, 'args': [ + '--non-interactive', '--agree-tos', '--email', args.email, + '--server', 'https://acme-staging-v02.api.letsencrypt.org/directory', + '--config-dir', '/var/lib/certlord/certbot/config', + '--work-dir', '/var/lib/certlord/certbot/work', + '--logs-dir', '/var/lib/certlord/certbot/logs', + '--key-type', 'rsa', '--rsa-key-size', '2048', + '-a', 'certbot-httpreq:auth', '-i', 'certbot-httpreq:installer', + '--certbot-httpreq:auth-config', '/etc/certlord/connector.yml', + '--certbot-httpreq:installer-config', '/etc/certlord/connector.yml', 'run']} + module['auton'] = {'receipt_mode': True, 'become': {'enabled': False}} + dump(ROOT / 'certlord/certlord.yml', config) + dump(ROOT / 'certlord/credentials.yml', { + 'vault': {'uri': 'http://vault:8200', 'token': '-', 'role_id': role, + 'secret_id': secret, 'mount': 'secret', 'key_name': 'certlord-certificates'}, + 'auton': {'uri': 'http://auton:8080', 'endpoint': 'deploy', + 'auth-user': 'auton', 'auth-passwd': passwords['auton']}}) + authorization = 'Basic ' + base64.b64encode(('connector:' + passwords['connector']).encode()).decode() + connector = {phase: {'uri': 'http://127.0.0.1:8666', 'headers': {'Authorization': authorization}} + for phase in ('perform', 'cleanup', 'deploy')} + connector['deploy']['path'] = '/api/ssl-certs/save' + dump(ROOT / 'certlord/connector.yml', connector) + routes = yaml.safe_load((TEMPLATES / 'auton-routes.yml').read_text()) + for route in routes['job']['routes'].values(): + route['auth'] = True + dump(ROOT / 'auton/auton.yml', { + 'general': {'listen_addr': '0.0.0.0', 'listen_port': 8080, 'auth_mode': 'required', + 'auth_basic': 'Auton', 'auth_basic_file': '/etc/auton/api.htpasswd', 'max_workers': 3, 'lock_timeout': 30}, + 'modules': routes, + 'endpoints': {'deploy': {'plugin': 'subproc', 'config': { + 'prog': '/bin/sh', 'args': ['/deploy/deploy.sh'], 'timeout': 240, + 'disallow-args': True, 'disallow-env': True}}}}) + # No dummy successful job: pending deployment must remain pending until configured. + if not (ROOT / 'deploy/deploy.sh').exists(): + write(ROOT / 'deploy/deploy.sh', '#!/bin/sh\necho "Configure /deploy/deploy.sh for your destination before deployment." >&2\nexit 78\n', True) + write(marker, '1\n') + client.auth.token.revoke_self() + keys.pop('root_token', None) + write(recovery, json.dumps(keys)) + print('Initialized. Read .local/admin-login.txt locally. Protect and back up .local/vault-recovery.json offline.') + print('Vault is unsealed. Configure the deployment job before requesting certificates.') + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('command', choices=('init', 'unseal')) + parser.add_argument('--email') + args = parser.parse_args() + if args.command == 'init' and (not args.email or '@' not in args.email or '\n' in args.email): + parser.error('init requires --email with your ACME contact address') + os.umask(0o077) + ROOT.mkdir(exist_ok=True) + ROOT.chmod(0o700) + client = hvac.Client(url='http://vault:8200', timeout=5) + for attempt in range(30): + try: + client.sys.is_initialized() + break + except Exception: + if attempt == 29: + raise RuntimeError('Vault did not become reachable') from None + time.sleep(1) + if args.command == 'init': + initialize(args, client) + else: + keys = json.loads((ROOT / 'vault-recovery.json').read_text()) + client.sys.submit_unseal_key(keys['keys_base64'][0]) + if client.sys.is_sealed(): + raise RuntimeError('Vault remains sealed') + print('Vault is unsealed.') + + +if __name__ == '__main__': + try: + main() + except Exception as error: + # Backend exceptions can contain sensitive request/response data. + print('Setup failed (' + type(error).__name__ + '); check Vault availability and the protected bootstrap files. No files were deleted.', file=sys.stderr) + sys.exit(1) diff --git a/docker/smoke.sh b/docker/smoke.sh new file mode 100644 index 0000000..c3b8eb1 --- /dev/null +++ b/docker/smoke.sh @@ -0,0 +1,76 @@ +#!/bin/sh +# Disposable CI installation; no real domain or production credentials. +set -eu +docker compose up -d vault redis +docker compose run --rm setup init --email ci@example.invalid +docker compose up -d +for attempt in $(seq 1 60); do + if docker compose exec -T certlord python /opt/certlord-docker/health.py; then + break + fi + if [ "$attempt" = 60 ]; then + echo "CertLord readiness failed" >&2 + exit 1 + fi + sleep 2 +done +docker compose exec -T certlord python - <<'PY' +import os +import requests +import yaml +assert os.getuid() == 10001 +base = 'http://127.0.0.1:8666' +assert requests.get(base + '/api/certificates', timeout=10).status_code == 401 +with open('/etc/certlord/health.yml') as f: + reader = yaml.safe_load(f) +auth = (reader['user'], reader['password']) +assert requests.get(base + '/api/certificates', auth=auth, timeout=10).status_code == 200 +assert requests.post(base + '/api/ssl-certs/deploy', auth=auth, timeout=10).status_code == 403 +with open('/etc/certlord/credentials.yml') as f: + credentials = yaml.safe_load(f) +import hvac +v = credentials['vault'] +client = hvac.Client(url=v['uri']) +client.auth.approle.login(v['role_id'], v['secret_id']) +client.secrets.kv.v2.create_or_update_secret(path='certlord-certificates/compose-smoke', secret={'persistent': True}) +try: + client.sys.list_auth_methods() +except hvac.exceptions.Forbidden: + pass +else: + raise AssertionError('Runtime Vault credentials can administer Vault') +a = credentials['auton'] +import time +for attempt in range(30): + try: + if requests.get(a['uri'] + '/health', auth=(a['auth-user'], a['auth-passwd']), timeout=2).status_code == 200: + break + except requests.RequestException: + pass + time.sleep(1) +else: + raise AssertionError('Auton did not become ready') +assert requests.get(a['uri'] + '/health', timeout=10).status_code == 401 +assert requests.get(a['uri'] + '/health', auth=(a['auth-user'], a['auth-passwd']), timeout=10).status_code == 200 +assert not os.path.exists('/bootstrap/vault-recovery.json') +print('Non-root runtime, protected APIs and scoped Vault authentication passed') +PY +docker compose restart vault +docker compose run --rm setup unseal +docker compose restart certlord +for attempt in $(seq 1 60); do + if docker compose exec -T certlord python /opt/certlord-docker/health.py; then break; fi + if [ "$attempt" = 60 ]; then exit 1; fi + sleep 2 +done +docker compose exec -T certlord python - <<'PY' +import hvac +import yaml +with open('/etc/certlord/credentials.yml') as f: + v = yaml.safe_load(f)['vault'] +client = hvac.Client(url=v['uri']) +client.auth.approle.login(v['role_id'], v['secret_id']) +data = client.secrets.kv.v2.read_secret_version(path='certlord-certificates/compose-smoke', raise_on_deleted_version=True) +assert data['data']['data']['persistent'] is True +print('Vault restart, explicit unseal and persistent data passed') +PY diff --git a/docker/vault-policy.hcl b/docker/vault-policy.hcl new file mode 100644 index 0000000..9df228e --- /dev/null +++ b/docker/vault-policy.hcl @@ -0,0 +1,19 @@ +# Replace the KV v2 mount "secret" and prefix "certlord-certificates" for your installation. +# A role using this policy can permanently destroy certificate versions inside that prefix. +path "auth/token/lookup-self" { + capabilities = ["read"] +} +path "secret/data/certlord-certificates/*" { + capabilities = ["create", "read", "update", "patch"] +} +path "secret/metadata/certlord-certificates" { + capabilities = ["list"] +} +path "secret/metadata/certlord-certificates/*" { + capabilities = ["read", "list"] +} + +# Keep KV metadata/version counters; destroy old certificate material only. +path "secret/destroy/certlord-certificates/*" { + capabilities = ["update"] +} diff --git a/docker/vault.hcl b/docker/vault.hcl new file mode 100644 index 0000000..2115773 --- /dev/null +++ b/docker/vault.hcl @@ -0,0 +1,10 @@ +ui = false +disable_mlock = false +api_addr = "http://vault:8200" +storage "file" { + path = "/vault/file" +} +listener "tcp" { + address = "0.0.0.0:8200" + tls_disable = true +} diff --git a/docs/contributing.md b/docs/contributing.md index 21c7c20..bba3a56 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -93,3 +93,26 @@ Pixel rendering can vary with font/system libraries; inspect the images before updating committed screenshots. Refresh them when UI behavior or examples change. See [authentication and command options](certificate-identity.md) before connecting the client to your own service. + +## Container maintenance + +`Dockerfile` builds the checkout with Python 3.12 and the qualified external +clients. The Docker workflow runs `docker/smoke.sh` on native Linux AMD64 and ARM64 +runners before publishing. The smoke check exercises a real Compose installation, +authenticated APIs, scoped Vault access, explicit unseal and persisted data after +restart. It does not qualify arbitrary user deployment scripts or replace the +full Pebble lifecycle workflow. + +The Docker Hub repository is `decryptus/certlord`. Publication uses the repository +secret `DOCKERHUB_TOKEN` (a token authorized to push to that repository); credentials +configured in another repository are not inherited. Stable release publication or +the Docker workflow's explicit `publish` input on main enables publication after +both architecture checks succeed. Initial image publication for an existing tag +uses that manual workflow; never move an existing source release tag. + +Keep image/version references, Compose templates and the user installation guide +aligned. Base image tags and transitive Python dependencies are not fully locked; +this is not a reproducible/offline build. Rebuild and validate for security updates. +The image embeds `docker/certlord.example.yml`; synchronize it when changing the +main example. Never include `.local/`, user credentials or private acceptance logs +in build contexts, source archives or workflow artifacts. diff --git a/docs/docker-compose.md b/docs/docker-compose.md new file mode 100644 index 0000000..412d41c --- /dev/null +++ b/docs/docker-compose.md @@ -0,0 +1,149 @@ +# Docker Compose installation + +This installation runs one active CertLord daemon, Redis, a persistent Vault and +an authenticated Auton endpoint on one Linux Docker host. It requires Docker Engine +and the Compose v2 plugin. It is not a high-availability deployment. + +## Download and initialize + +Download the repository so the Compose file and its configuration templates stay +together: + +```sh +git clone https://github.com/decryptus/certlord.git +cd certlord +docker compose pull +docker compose up -d vault redis +docker compose run --rm setup init --email you@example.com +docker compose up -d +docker compose ps +``` + +Use your own ACME contact email. Initialization creates independent random API and +Auton passwords, scoped Vault AppRole credentials and configuration under `.local/`. +It starts with Let's Encrypt **staging**, whose certificates are not browser-trusted. +Running initialization again preserves existing configuration and credentials. + +Read your operator login locally with `sudo cat .local/admin-login.txt`. The API +is available only on the host at `http://127.0.0.1:8666`: + +```sh +curl --user operator http://127.0.0.1:8666/api/health/ready +``` + +Curl prompts for the password. HTTP 200 means the scheduler and required backend +probes are ready; it is not proof of successful certificate deployment. See +[operations](operations.md) and the [certificate API](certificate-identity.md). + +The image includes the CLI, Textual browser, Certbot connector and Auton client/ +daemon. It does not embed Redis or Vault inside the application process. To build +the image locally instead of downloading it, use `docker compose build` before +initialization and `docker compose up -d --pull never` afterwards. + +## Protect Vault recovery and persistent data + +Vault runs in normal server mode, **not dev mode**. Its data and Redis AOF are +stored in named volumes. Initialization creates one unseal key for this single-host +installation, saves it in `.local/vault-recovery.json`, and revokes the bootstrap +root token after configuration. Application containers never mount that recovery +file and never use the root token. + +Copy the recovery file to a protected offline backup. Anyone with it and access to +Vault storage can recover the secrets. The convenience of keeping it on the Docker +host does not protect against compromise of that host; remove the local copy after +backing it up if your policy requires separation, and restore it temporarily when +unsealing. Do not commit or share `.local/`, volume backups or private application +logs. Docker administrators can access container data. + +After restarting Vault or rebooting the host, explicitly unseal it: + +```sh +docker compose run --rm setup unseal +docker compose restart certlord +``` + +The recovery file must be present for this command. There is no unattended +auto-unseal in this Compose. AppRole Secret IDs do not expire in this initial +single-host setup; tokens expire after at most four hours and the application logs +in again as needed. Rotate the protected Secret ID using the +[AppRole guide](vault-approle.md). + +`docker compose down` preserves volumes. **`docker compose down -v` deletes them.** +Back up Vault, Redis, configuration and deployment definitions together; rehearse +[restoration](operations.md#backups-and-restoration). Replication, Docker restart +policies and a healthy container are not backups. + +## Enable public ACME challenge reads + +Point the requested domain's DNS to this host and make TCP port 80 reachable. If +port 80 is free, enable the included restricted HTTP frontend: + +```sh +docker compose --profile http01 up -d +``` + +Only challenge GET/HEAD requests are forwarded; administration and mutations are +not exposed. Set `HTTP01_BIND` / `HTTP01_PORT` if another frontend forwards traffic +to this service. The ACME authority still connects to the public domain on port 80. +If you already have Traefik or Nginx, route only the challenge path and read methods +to CertLord as described in the [connector guide](acme-connector.md). + +API access remains bound to host loopback. Use an SSH tunnel for initial remote +administration or an authenticated HTTPS reverse proxy with verified TLS; do not +simply publish the administration port on all interfaces. Internal Vault and Auton +HTTP traffic is confined to this Docker host/network. Configure verified TLS when +moving a service to another host. + +## Configure the deployment destination + +Auton starts with a deliberately failing `/deploy/deploy.sh` job. **It does not +pretend to deploy certificates.** Replace `.local/deploy/deploy.sh` with your +idempotent deployment script, readable by UID 10001. Its directory is mounted +read-only in Auton; use `/var/lib/auton` for temporary work. The job is invoked for +the current batch, without a per-certificate argument: it must discover and deploy +the intended stored material. Grant it separate, read-only Vault credentials for +the needed prefix, rather than reusing CertLord's mutation credentials. + +The script must retrieve the intended certificates, install them at your actual +destination, validate service configuration and reload the service. It must exit +nonzero on any failure. Supply SSH keys/trust material through protected files; +the image includes an SSH client but no host keys or passwords. Never mount the +Docker socket just to reload another container. An existing remote Auton endpoint +can be configured in `.local/certlord/credentials.yml` instead. + +Enable [destination TLS verification](tls-verification.md) for the relevant +certificate UUIDs. Verify issuance, renewal and the certificate actually served +with staging before switching the Certbot `--server` value in +`.local/certlord/certlord.yml` to `https://acme-v02.api.letsencrypt.org/directory`. +Restart CertLord after configuration changes. Publicly trusted issuance and remote +installation require your DNS and deployment configuration; Compose cannot infer +them. + +## External Vault and availability + +To use an existing Vault, provision the documented KV v2 policy and AppRole there, +then replace the Vault settings in `.local/certlord/credentials.yml`. Use HTTPS and +a trusted CA. Stop the unused local Vault after verifying the external connection. +The bundled bootstrap/unseal commands operate only on the bundled local Vault; +they must not be used to initialize or manage your existing cluster. + +A Vault HA cluster requires independent hosts, Raft quorum, a resilient endpoint +and an unseal strategy. Three local containers do not protect against host failure. +CertLord 1.0.0 still supports **one active lifecycle daemon** per state scope; +do not scale the CertLord service. Redis and Auton availability are separate concerns. + +## Updates and logs + +The default image is `decryptus/certlord:1.0.0`. Set `CERTLORD_IMAGE` to an explicit +published version or digest when upgrading. Back up first, review release notes, +then run `docker compose pull` and `docker compose up -d`. Re-running setup does +not migrate an existing configuration. + +Daemon logs are in the `certlord-data` and `auton-data` volumes. For example: + +```sh +docker compose exec certlord tail -n 100 /var/lib/certlord/certlord.log +docker compose exec auton tail -n 100 /var/lib/auton/auton.log +``` + +Keep logs private and configure rotation/retention for long-running installations. diff --git a/index.rst b/index.rst index 46054c4..6afdc43 100644 --- a/index.rst +++ b/index.rst @@ -8,6 +8,7 @@ Installation, configuration, operation and troubleshooting. :caption: User guide docs/certificate-import + docs/docker-compose docs/debian-installation docs/configuration-validation docs/operations