From 4567ee7777613a584c862b43dfd26bce90cf1879 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 17 May 2026 09:06:24 +0000 Subject: [PATCH] feat(ansible): add simple pairing flow --- .env.example | 11 +- PROGRESS.md | 26 +++ apps/ansible-api/Dockerfile | 4 +- apps/ansible-api/server.py | 146 +++++++++++++++- apps/ansible-api/tests/test_contract.py | 58 +++++++ apps/docs/docs/deployment/docker-compose.md | 7 +- apps/docs/docs/features/feature-flags.md | 9 +- .../automation/automation-settings-client.tsx | 158 ++++++------------ apps/web/lib/actions/automation.ts | 58 ++++++- apps/web/lib/actions/mutation-authz.test.mjs | 1 + apps/web/lib/automation/ansible-api.ts | 49 ++++++ .../automation/ansible-pairing-core.test.mjs | 31 ++++ .../lib/automation/ansible-pairing-core.ts | 25 +++ .../lib/automation/ansible-ui-gating.test.mjs | 11 ++ .../web/tests/e2e/settings/automation.spec.ts | 11 +- deploy/customer-bundle/start.sh | 14 +- deploy/scripts/test-ansible-profile-wiring.sh | 3 + docker-compose.single.yml | 6 + 18 files changed, 499 insertions(+), 129 deletions(-) create mode 100644 apps/web/lib/automation/ansible-pairing-core.test.mjs create mode 100644 apps/web/lib/automation/ansible-pairing-core.ts diff --git a/.env.example b/.env.example index 4822a713..9f71eb33 100644 --- a/.env.example +++ b/.env.example @@ -126,9 +126,14 @@ POSTGRES_PASSWORD= # INGEST_IMAGE=ghcr.io/carrtech-dev/ct-ops/ingest@sha256:... # ANSIBLE_API_IMAGE=ghcr.io/carrtech-dev/ct-ops/ansible-api@sha256:... -# Optional Ansible module service-token settings for manually run ansible-api -# containers. Configure matching values in Administration -> Integrations -> -# Automation when using Service token HMAC authentication. +# Optional Ansible API pairing credentials. The ansible-api container uses +# these to issue or rotate CT-Ops generated service tokens, which are stored in +# the ansible_api_data volume and in CT-Ops encrypted settings. +# ANSIBLE_API_PAIRING_USERNAME=ctops +# ANSIBLE_API_PAIRING_PASSWORD= + +# Optional legacy Ansible module service-token settings for manually run +# ansible-api containers. Prefer pairing credentials for new installs. # ANSIBLE_API_SERVICE_TOKEN_ID=ansible-api # ANSIBLE_API_SERVICE_TOKEN_SECRET= diff --git a/PROGRESS.md b/PROGRESS.md index f5d5203c..e836a815 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -15,6 +15,32 @@ ## What Has Been Built +### Session 136 — Simplified Ansible pairing + +**URL, username, password Ansible setup** (`apps/ansible-api/server.py`, `apps/web/lib/automation/ansible-api.ts`, `apps/web/lib/actions/automation.ts`, `apps/web/app/(dashboard)/settings/integrations/automation/automation-settings-client.tsx`, `docker-compose.single.yml`) +- Added an Ansible API pairing endpoint that accepts initial environment-backed + credentials, generates a service-token secret, persists it in the Ansible API + data volume, and uses it for ongoing HMAC verification. +- Allowed later rotation by re-pairing with the same initial credentials; static + legacy `ANSIBLE_API_SERVICE_TOKEN_*` environment variables remain supported + but are treated as externally managed. +- Simplified the CT-Ops automation settings screen so admins pair an Ansible + connection with only URL, username, and password. CT-Ops stores the generated + service secret encrypted and clears the initial password after pairing. +- Added bundled compose/start-script defaults for `ANSIBLE_API_PAIRING_USERNAME` + and generated `ANSIBLE_API_PAIRING_PASSWORD`, plus persistent Ansible token + storage and updated docs/tests. + +**Validation** +- `python3 -m unittest tests/test_contract.py` from `apps/ansible-api` +- `node --experimental-strip-types --test lib/automation/ansible-pairing-core.test.mjs lib/automation/ansible-ui-gating.test.mjs lib/actions/mutation-authz.test.mjs` from `apps/web` +- `pnpm --dir apps/web type-check` +- `pnpm --dir apps/web db:validate` +- `pnpm --dir apps/web lint 'app/(dashboard)/settings/integrations/automation/automation-settings-client.tsx' lib/actions/automation.ts lib/automation/ansible-api.ts lib/automation/ansible-pairing-core.ts lib/automation/ansible-pairing-core.test.mjs lib/automation/ansible-ui-gating.test.mjs lib/actions/mutation-authz.test.mjs tests/e2e/settings/automation.spec.ts` +- `pnpm --dir apps/web test:unit` +- `pnpm --dir apps/web test:e2e tests/e2e/settings/automation.spec.ts` +- `bash deploy/scripts/test-ansible-profile-wiring.sh` + ### Session 135 — Module connection contract framework **External module contract and Ansible conversion** (`apps/web/lib/modules/*`, `apps/web/lib/db/schema/module-connections.ts`, `apps/web/lib/automation/ansible-api.ts`, `apps/ansible-api/server.py`) diff --git a/apps/ansible-api/Dockerfile b/apps/ansible-api/Dockerfile index 9d89e046..0c660206 100644 --- a/apps/ansible-api/Dockerfile +++ b/apps/ansible-api/Dockerfile @@ -2,7 +2,9 @@ FROM python:3.13-alpine RUN apk add --no-cache ansible openssh-client \ && addgroup -S ansible \ - && adduser -S -G ansible ansible + && adduser -S -G ansible ansible \ + && mkdir -p /var/lib/ct-ops/ansible-api \ + && chown -R ansible:ansible /var/lib/ct-ops WORKDIR /app COPY server.py /app/server.py diff --git a/apps/ansible-api/server.py b/apps/ansible-api/server.py index 09a7b1f8..0aaddd34 100644 --- a/apps/ansible-api/server.py +++ b/apps/ansible-api/server.py @@ -5,8 +5,10 @@ import hashlib import hmac import os +import secrets import subprocess import tempfile +import threading import time from dataclasses import dataclass from datetime import datetime, timezone @@ -19,6 +21,12 @@ MAX_CLOCK_SKEW_SECONDS = 300 NONCE_TTL_SECONDS = 600 _seen_nonces: dict[str, float] = {} +PAIRING_RATE_LIMIT_WINDOW_SECONDS = 300 +PAIRING_RATE_LIMIT_ATTEMPTS = 10 +DEFAULT_SERVICE_TOKEN_FILE = "/var/lib/ct-ops/ansible-api/service-token.json" +DEFAULT_SERVICE_TOKEN_ID = "ansible-api" +_pairing_lock = threading.Lock() +_pairing_attempts: dict[str, list[float]] = {} def ansible_version() -> str: @@ -58,6 +66,7 @@ def capabilities_payload() -> dict[str, Any]: "streamingLogs": False, "jobCancel": False, "dryRun": False, + "pairing": True, }, } @@ -97,11 +106,25 @@ def openapi_payload() -> dict[str, Any]: }, }, }, + "/api/v1/pairing/claim": { + "post": { + "summary": "Exchange initial pairing credentials for a CT-Ops service token", + "responses": { + "200": {"description": "Generated service-token credentials"}, + "401": {"description": "Invalid pairing credentials"}, + "409": {"description": "Ansible API token is managed by environment variables"}, + }, + }, + }, }, } -def _configured_service_token() -> tuple[str, str] | None: +def _service_token_file() -> str: + return os.environ.get("ANSIBLE_API_SERVICE_TOKEN_FILE", DEFAULT_SERVICE_TOKEN_FILE).strip() + + +def _configured_env_service_token() -> tuple[str, str] | None: token_id = os.environ.get("ANSIBLE_API_SERVICE_TOKEN_ID", "").strip() token_secret = os.environ.get("ANSIBLE_API_SERVICE_TOKEN_SECRET", "") if not token_id and not token_secret: @@ -111,6 +134,101 @@ def _configured_service_token() -> tuple[str, str] | None: return token_id, token_secret +def _load_service_token_file() -> tuple[str, str] | None: + token_file = _service_token_file() + if not token_file or not os.path.exists(token_file): + return None + try: + with open(token_file, "r", encoding="utf-8") as handle: + payload = json.load(handle) + except Exception as err: + raise PermissionError("Ansible API service token file is not readable") from err + + token_id = str(payload.get("tokenId", "")).strip() + token_secret = str(payload.get("tokenSecret", "")) + if not token_id or len(token_secret.encode("utf-8")) < 32: + raise PermissionError("Ansible API service token file is not configured correctly") + return token_id, token_secret + + +def _configured_service_token() -> tuple[str, str] | None: + return _configured_env_service_token() or _load_service_token_file() + + +def _write_service_token_file(token_id: str, token_secret: str) -> None: + token_file = _service_token_file() + if not token_file: + raise PermissionError("Ansible API service token file is not configured") + directory = os.path.dirname(token_file) + if directory: + os.makedirs(directory, mode=0o700, exist_ok=True) + payload = { + "tokenId": token_id, + "tokenSecret": token_secret, + "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + } + fd, tmp_path = tempfile.mkstemp(prefix=".service-token-", dir=directory or None) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, sort_keys=True) + handle.write("\n") + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, token_file) + except Exception: + try: + os.unlink(tmp_path) + except FileNotFoundError: + pass + raise + + +def _pairing_credentials() -> tuple[str, str]: + username = os.environ.get("ANSIBLE_API_PAIRING_USERNAME", "").strip() + password = os.environ.get("ANSIBLE_API_PAIRING_PASSWORD", "") + if not username or not password: + raise PermissionError("Ansible API pairing is not configured") + return username, password + + +def _check_pairing_rate_limit(client_id: str | None) -> None: + key = client_id or "local" + now = time.time() + cutoff = now - PAIRING_RATE_LIMIT_WINDOW_SECONDS + attempts = [value for value in _pairing_attempts.get(key, []) if value >= cutoff] + if len(attempts) >= PAIRING_RATE_LIMIT_ATTEMPTS: + _pairing_attempts[key] = attempts + raise PermissionError("too many pairing attempts") + attempts.append(now) + _pairing_attempts[key] = attempts + + +def claim_pairing_token(payload: dict[str, Any], client_id: str | None = None) -> dict[str, Any]: + if _configured_env_service_token() is not None: + raise PermissionError("Ansible API service token is managed by environment variables") + + configured_username, configured_password = _pairing_credentials() + _check_pairing_rate_limit(client_id) + + username = payload.get("username") + password = payload.get("password") + if not isinstance(username, str) or not isinstance(password, str): + raise ValueError("username and password are required") + if not hmac.compare_digest(username, configured_username) or not hmac.compare_digest(password, configured_password): + raise PermissionError("invalid pairing credentials") + + with _pairing_lock: + if _configured_env_service_token() is not None: + raise PermissionError("Ansible API service token is managed by environment variables") + token_id = DEFAULT_SERVICE_TOKEN_ID + token_secret = secrets.token_urlsafe(48) + _write_service_token_file(token_id, token_secret) + return { + "ok": True, + "tokenId": token_id, + "tokenSecret": token_secret, + } + + def _header(headers: Any, name: str) -> str: if hasattr(headers, "get"): value = headers.get(name) @@ -394,6 +512,32 @@ def do_GET(self) -> None: self.write_json({"error": "not_found"}, status=404) def do_POST(self) -> None: + if self.path == "/api/v1/pairing/claim": + try: + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(min(length, 65_536)) + payload = json.loads(body.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("request body must be an object") + client_id = self.client_address[0] if self.client_address else None + self.write_json(claim_pairing_token(payload, client_id=client_id)) + except ValueError as err: + self.write_json({"error": str(err)}, status=400) + except PermissionError as err: + message = str(err) + if "managed by environment" in message: + status = 409 + elif "not configured" in message: + status = 503 + elif "too many" in message: + status = 429 + else: + status = 401 + self.write_json({"error": message}, status=status) + except Exception: + self.write_json({"error": "pairing failed"}, status=500) + return + if self.path == "/api/v1/runs/ansible-ping": try: length = int(self.headers.get("content-length", "0")) diff --git a/apps/ansible-api/tests/test_contract.py b/apps/ansible-api/tests/test_contract.py index 3952106c..39bace13 100644 --- a/apps/ansible-api/tests/test_contract.py +++ b/apps/ansible-api/tests/test_contract.py @@ -2,6 +2,7 @@ import hashlib import hmac import base64 +import tempfile import unittest from unittest import mock @@ -43,6 +44,63 @@ def test_verify_service_request_allows_unsigned_when_no_token_configured(self): with mock.patch.dict(server.os.environ, {}, clear=True): self.assertIsNone(server.verify_service_request("POST", "/api/v1/runs/ansible-ping", b"{}", {})) + def test_pairing_claim_requires_configured_initial_credentials(self): + with mock.patch.dict(server.os.environ, {}, clear=True): + with self.assertRaisesRegex(PermissionError, "not configured"): + server.claim_pairing_token({"username": "ctops", "password": "secret"}) + + def test_pairing_claim_generates_persisted_service_token(self): + with tempfile.TemporaryDirectory() as tmpdir: + token_file = f"{tmpdir}/service-token.json" + with mock.patch.dict(server.os.environ, { + "ANSIBLE_API_PAIRING_USERNAME": "ctops", + "ANSIBLE_API_PAIRING_PASSWORD": "initial password", + "ANSIBLE_API_SERVICE_TOKEN_FILE": token_file, + }, clear=True): + payload = server.claim_pairing_token({ + "username": "ctops", + "password": "initial password", + }) + + self.assertEqual(payload["ok"], True) + self.assertEqual(payload["tokenId"], "ansible-api") + self.assertGreaterEqual(len(payload["tokenSecret"]), 32) + configured = server._configured_service_token() + self.assertEqual(configured, ("ansible-api", payload["tokenSecret"])) + + def test_pairing_claim_rotates_existing_generated_service_token(self): + with tempfile.TemporaryDirectory() as tmpdir: + token_file = f"{tmpdir}/service-token.json" + with mock.patch.dict(server.os.environ, { + "ANSIBLE_API_PAIRING_USERNAME": "ctops", + "ANSIBLE_API_PAIRING_PASSWORD": "initial password", + "ANSIBLE_API_SERVICE_TOKEN_FILE": token_file, + }, clear=True): + first = server.claim_pairing_token({ + "username": "ctops", + "password": "initial password", + }) + second = server.claim_pairing_token({ + "username": "ctops", + "password": "initial password", + }) + + self.assertNotEqual(first["tokenSecret"], second["tokenSecret"]) + self.assertEqual(server._configured_service_token(), ("ansible-api", second["tokenSecret"])) + + def test_pairing_claim_rejects_static_environment_service_token(self): + with mock.patch.dict(server.os.environ, { + "ANSIBLE_API_PAIRING_USERNAME": "ctops", + "ANSIBLE_API_PAIRING_PASSWORD": "initial password", + "ANSIBLE_API_SERVICE_TOKEN_ID": "ansible-api", + "ANSIBLE_API_SERVICE_TOKEN_SECRET": "ansible signing secret with enough entropy", + }, clear=True): + with self.assertRaisesRegex(PermissionError, "managed by environment"): + server.claim_pairing_token({ + "username": "ctops", + "password": "initial password", + }) + def test_verify_service_request_accepts_valid_hmac_token(self): body = b'{"ok":true}' timestamp = "2026-05-15T12:00:00Z" diff --git a/apps/docs/docs/deployment/docker-compose.md b/apps/docs/docs/deployment/docker-compose.md index 75e43f50..d713c92c 100644 --- a/apps/docs/docs/deployment/docker-compose.md +++ b/apps/docs/docs/deployment/docker-compose.md @@ -130,9 +130,10 @@ The remaining ports are bound to `127.0.0.1` only, so `web:3000`, `ingest:8080`, The optional `ansible-api` service is not published to the host and CT-Ops does not start it automatically. Run the Ansible module yourself, then configure its -URL and service-token settings in **Settings → Integrations → Automation**. The -web app never mounts the Docker socket and does not orchestrate module -containers. +URL plus the initial pairing username and password in **Settings → Integrations +→ Automation**. The Ansible API generates the service-token secret used for +ongoing signed requests and stores it in its persistent data volume. The web app +never mounts the Docker socket and does not orchestrate module containers. When installing inside a VM, LXC, or Incus instance that sits behind a NAT or private bridge, forward the external HTTPS port and `9443` to the instance. diff --git a/apps/docs/docs/features/feature-flags.md b/apps/docs/docs/features/feature-flags.md index ea43305f..6d4dd139 100644 --- a/apps/docs/docs/features/feature-flags.md +++ b/apps/docs/docs/features/feature-flags.md @@ -34,8 +34,11 @@ Administrators enable it from **Settings → Integrations → Automation**. CT-O ``` CT-Ops does not start Docker containers from the web app or from `./start.sh`. -Administrators configure the Ansible API URL and optional service-token HMAC -settings on the same Automation page. The `ansible-api` container can run on -the CT-Ops host, on a different host, or behind a reverse proxy. +Administrators pair the Ansible API URL with the initial username and password +configured on the `ansible-api` container. The container then generates a +service-token secret for ongoing signed requests; CT-Ops stores that generated +secret encrypted and does not retain the initial password. The `ansible-api` +container can run on the CT-Ops host, on a different host, or behind a reverse +proxy. When the API is healthy, administrators can save encrypted SSH private-key credential profiles and run an Ansible ping task from host or host-group task views. CT-Ops stores task state and redacted output in its task history; the Ansible container only executes the requested ping operation. diff --git a/apps/web/app/(dashboard)/settings/integrations/automation/automation-settings-client.tsx b/apps/web/app/(dashboard)/settings/integrations/automation/automation-settings-client.tsx index 6befbfc6..c6858df9 100644 --- a/apps/web/app/(dashboard)/settings/integrations/automation/automation-settings-client.tsx +++ b/apps/web/app/(dashboard)/settings/integrations/automation/automation-settings-client.tsx @@ -7,7 +7,7 @@ import { Bot, CheckCircle2, CircleOff, KeyRound, RotateCw, ServerCog, Trash2, XC import { createAnsibleCredentialProfile, deleteAnsibleCredentialProfile, - updateAnsibleModuleConnectionSettings, + pairAnsibleModuleConnection, updateAnsibleAutomationSettings, type AnsibleCredentialProfileSummary, type AutomationSettingsResult, @@ -28,10 +28,9 @@ interface AutomationSettingsClientProps { } const ANSIBLE_API_IMAGE = 'ghcr.io/carrtech-dev/ct-ops/ansible-api:latest' -const ANSIBLE_TOKEN_COMMAND = 'openssl rand -base64 48' const ANSIBLE_ENV_SNIPPET = [ - 'ANSIBLE_API_SERVICE_TOKEN_ID=ansible-api', - 'ANSIBLE_API_SERVICE_TOKEN_SECRET=', + 'ANSIBLE_API_PAIRING_USERNAME=ctops', + 'ANSIBLE_API_PAIRING_PASSWORD=', ].join('\n') const ANSIBLE_COMPOSE_COMMAND = 'docker compose -f docker-compose.single.yml --profile ansible up -d ansible-api' const ANSIBLE_HEALTH_COMMAND = 'docker compose -f docker-compose.single.yml ps ansible-api' @@ -39,8 +38,9 @@ const ANSIBLE_DOCKER_RUN_COMMAND = [ 'docker run -d --name ct-ops-ansible-api \\', ' --restart unless-stopped \\', ' -p 127.0.0.1:8080:8080 \\', - ' -e ANSIBLE_API_SERVICE_TOKEN_ID=ansible-api \\', - ' -e ANSIBLE_API_SERVICE_TOKEN_SECRET="$ANSIBLE_API_SERVICE_TOKEN_SECRET" \\', + ' -e ANSIBLE_API_PAIRING_USERNAME=ctops \\', + ' -e ANSIBLE_API_PAIRING_PASSWORD="$ANSIBLE_API_PAIRING_PASSWORD" \\', + ' -v ct-ops-ansible-api-data:/var/lib/ct-ops/ansible-api \\', ` ${ANSIBLE_API_IMAGE}`, ].join('\n') @@ -60,13 +60,9 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro const [credentialName, setCredentialName] = useState('') const [credentialUsername, setCredentialUsername] = useState('') const [credentialPrivateKey, setCredentialPrivateKey] = useState('') - const [connectionName, setConnectionName] = useState(settings.ansibleConnection.name) const [connectionBaseUrl, setConnectionBaseUrl] = useState(settings.ansibleConnection.baseUrl) - const [connectionAuthMode, setConnectionAuthMode] = useState(settings.ansibleConnection.authMode) - const [connectionTokenId, setConnectionTokenId] = useState(settings.ansibleConnection.tokenId ?? 'ansible-api') - const [connectionTokenSecret, setConnectionTokenSecret] = useState('') - const [connectionTlsMode, setConnectionTlsMode] = useState(settings.ansibleConnection.tlsMode) - const [connectionTimeoutMs, setConnectionTimeoutMs] = useState(String(settings.ansibleConnection.timeoutMs)) + const [pairingUsername, setPairingUsername] = useState('ctops') + const [pairingPassword, setPairingPassword] = useState('') const [error, setError] = useState(null) const [credentialError, setCredentialError] = useState(null) const [connectionError, setConnectionError] = useState(null) @@ -93,7 +89,7 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro }, status: nextEnabled ? 'unavailable' : 'disabled', statusMessage: nextEnabled - ? 'Ansible automation is enabled. Check the configured Ansible API URL, service-token settings, and service health.' + ? 'Ansible automation is enabled. Pair the Ansible API connection and check service health.' : 'Ansible automation is disabled for this instance.', ansibleVersion: undefined, }) @@ -102,16 +98,11 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro onError: () => setError('An unexpected error occurred'), }) - const saveConnection = useMutation({ - mutationFn: () => updateAnsibleModuleConnectionSettings({ - enabled, - name: connectionName, + const pairConnection = useMutation({ + mutationFn: () => pairAnsibleModuleConnection({ baseUrl: connectionBaseUrl, - authMode: connectionAuthMode, - tokenId: connectionTokenId, - tokenSecret: connectionTokenSecret, - tlsMode: connectionTlsMode, - timeoutMs: Number(connectionTimeoutMs), + username: pairingUsername, + password: pairingPassword, }), onSuccess: (result) => { if ('error' in result) { @@ -119,20 +110,20 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro return } setConnectionError(null) - setConnectionTokenSecret('') + setPairingPassword('') setConnectionSaved(true) setSettings({ ...settings, ansibleConnection: result.connection, status: enabled ? 'unavailable' : settings.status, statusMessage: enabled - ? 'Ansible automation is enabled. Check the configured Ansible API URL, service-token settings, and service health.' + ? 'Ansible automation connection is paired. Refresh status to confirm service health.' : settings.statusMessage, ansibleVersion: undefined, }) setTimeout(() => setConnectionSaved(false), 3000) }, - onError: () => setConnectionError('Failed to save Ansible connection'), + onError: () => setConnectionError('Failed to pair Ansible connection'), }) const createCredential = useMutation({ @@ -226,7 +217,6 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro Bundled Compose Separate Server - Auth & TLS @@ -234,7 +224,7 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro Use this path when the Ansible API container runs from the CT-Ops Compose file on the same host.

- Add matching service-token values to the CT-Ops .env file: + Add initial pairing credentials to the CT-Ops .env file:

                       {ANSIBLE_ENV_SNIPPET}
@@ -249,7 +239,7 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro
                       {ANSIBLE_HEALTH_COMMAND}
                     

- In this UI, use http://ansible-api:8080, set Authentication to Service token HMAC, enter the same token ID and secret, and set TLS mode to Insecure HTTP/private network. + In this UI, use http://ansible-api:8080 and the initial pairing credentials. CT-Ops stores only the generated service secret after pairing.

@@ -258,7 +248,7 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro Use this path when the Ansible API runs on a different server reachable from CT-Ops.

- On the Ansible server, set the service-token environment variables and run the image behind a private network or reverse proxy: + On the Ansible server, set the initial pairing environment variables and run the image behind a private network or reverse proxy:

                       {ANSIBLE_DOCKER_RUN_COMMAND}
@@ -267,25 +257,7 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro
                       The example binds to loopback for a reverse proxy on that server. If CT-Ops reaches the container directly over a private network, publish the port on a protected interface and allow inbound traffic only from the CT-Ops host.
                     

- Do not set ANSIBLE_API_SERVICE_TOKEN_ID or ANSIBLE_API_SERVICE_TOKEN_SECRET in the CT-Ops Compose file for this setup. Configure this UI with the Ansible server URL, matching token ID and secret, and the appropriate TLS mode. -

- - - -

- A service token is a shared HMAC secret. Generate a new secret yourself, put the same token ID and secret on the Ansible API container and in the Ansible module connection form on this page, and CT-Ops will sign each module request. -

-
-                      {ANSIBLE_TOKEN_COMMAND}
-                    
-

- Use the generated value as ANSIBLE_API_SERVICE_TOKEN_SECRET. The token ID can stay ansible-api. After saving the secret in the Ansible module connection form, CT-Ops stores it encrypted and only lets you replace it. -

-

- Authentication set to None means CT-Ops sends unsigned requests. It will work only when the Ansible API container does not have ANSIBLE_API_SERVICE_TOKEN_ID or ANSIBLE_API_SERVICE_TOKEN_SECRET configured. -

-

- Authentication does not control encryption. Use an https:// URL for encrypted transport, or http:// only on a trusted private network with TLS mode set to Insecure HTTP/private network. + Keep the generated token file on persistent storage. If it is deleted, reset the CT-Ops connection by pairing again with the initial credentials.

@@ -318,23 +290,16 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro Ansible module connection - Point CT-Ops at a manually deployed Ansible API, on this server or behind a reverse proxy. + Connect with the initial username and password from the Ansible container environment. CT-Ops stores the generated service secret, not this password.
-
- - setConnectionName(event.target.value)} - /> -
setConnectionBaseUrl(event.target.value)} @@ -342,68 +307,41 @@ export function AutomationSettingsClient({ initialSettings, initialCredentialPro />
- - -
-
- - + + setPairingUsername(event.target.value)} + autoComplete="username" + />
- {connectionAuthMode === 'service-token-hmac' && ( - <> -
- - setConnectionTokenId(event.target.value)} - /> -
-
- - setConnectionTokenSecret(event.target.value)} - placeholder={settings.ansibleConnection.hasTokenSecret ? 'Leave blank to keep existing' : 'Required for HMAC'} - /> -
- - )} -
- +
+ setConnectionTimeoutMs(event.target.value)} + id="ansible-pairing-password" + data-testid="ansible-pairing-password" + type="password" + value={pairingPassword} + onChange={(event) => setPairingPassword(event.target.value)} + autoComplete="current-password" />
+ {settings.ansibleConnection.hasTokenSecret && ( +
+ Paired with {settings.ansibleConnection.baseUrl}. + Re-pairing with the initial credentials rotates the generated service secret. +
+ )} +
- - {saveConnection.isPending && Saving...} + {pairConnection.isPending && Pairing...} {connectionSaved && Saved} {connectionError && {connectionError}}
diff --git a/apps/web/lib/actions/automation.ts b/apps/web/lib/actions/automation.ts index cb2503c9..bf221a5b 100644 --- a/apps/web/lib/actions/automation.ts +++ b/apps/web/lib/actions/automation.ts @@ -11,6 +11,7 @@ import { checkAnsibleApiHealth, getAnsibleApiBaseUrl, getAnsibleModuleConnectionSummary, + pairAnsibleApi, saveAnsibleModuleConnection, } from '@/lib/automation/ansible-api' import { validateSshPrivateKey } from '@/lib/automation/ansible-runner' @@ -68,6 +69,19 @@ const ansibleConnectionInputSchema = z.object({ timeoutMs: z.number().int().min(1000).max(120_000).optional().nullable(), }) +const ansiblePairingInputSchema = z.object({ + baseUrl: z.string().trim().min(1, 'Ansible API URL is required').max(2048).refine((value) => { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } + }, 'Ansible API URL must be an absolute http(s) URL'), + username: z.string().trim().min(1, 'Username is required').max(120), + password: z.string().min(1, 'Password is required').max(1024), +}) + function toCredentialSummary(row: typeof ansibleCredentialProfiles.$inferSelect): AnsibleCredentialProfileSummary { return { id: row.id, @@ -117,7 +131,7 @@ export async function getAutomationSettings(): Promise ansibleConnection, ansibleDescription: FEATURE_FLAG_REGISTRY['automation.ansible'].description, status: 'unavailable', - statusMessage: 'Ansible automation is enabled. Check the configured Ansible API URL, service-token settings, and service health.', + statusMessage: 'Ansible automation is enabled. Pair the Ansible API connection and check service health.', } } @@ -255,6 +269,48 @@ export async function updateAnsibleModuleConnectionSettings( } } +export async function pairAnsibleModuleConnection( + input: unknown, +): Promise<{ success: true; connection: ModuleConnectionSummary } | { error: string }> { + const parsed = ansiblePairingInputSchema.safeParse(input) + if (!parsed.success) return { error: parsed.error.issues.map((i) => i.message).join('; ') } + + let session + let instanceId + try { + session = await getRequiredSession() + instanceId = resolveCurrentActionScope(session) + await requireInstanceAdminAccess(instanceId) + } catch { + return { error: 'You do not have permission to update automation settings' } + } + + try { + const connection = await pairAnsibleApi(instanceId, parsed.data) + + await writeAuditEvent(db, { + instanceId, + actorUserId: session.user.id, + action: 'automation.ansible.connection.paired', + targetType: 'module_connection', + targetId: connection.id, + summary: `Paired Ansible module connection ${connection.name}`, + metadata: { + moduleType: 'ansible', + baseUrl: connection.baseUrl, + authMode: connection.authMode, + tlsMode: connection.tlsMode, + enabled: connection.enabled, + }, + }) + + return { success: true, connection } + } catch (err) { + logError('Failed to pair Ansible module connection:', err) + return { error: err instanceof Error ? err.message : 'Failed to pair Ansible module connection' } + } +} + export async function listAnsibleCredentialProfiles(): Promise { const session = await getRequiredSession() const instanceId = resolveCurrentActionScope(session) diff --git a/apps/web/lib/actions/mutation-authz.test.mjs b/apps/web/lib/actions/mutation-authz.test.mjs index 45546cad..5da1d81a 100644 --- a/apps/web/lib/actions/mutation-authz.test.mjs +++ b/apps/web/lib/actions/mutation-authz.test.mjs @@ -57,6 +57,7 @@ const adminGuardedActions = [ ['alerts.ts', 'replaceHostMetricAlertsWithGlobalDefaults'], ['alerts.ts', 'replaceAllHostMetricAlertsWithGlobalDefaults'], ['automation.ts', 'updateAnsibleAutomationSettings'], + ['automation.ts', 'pairAnsibleModuleConnection'], ['automation.ts', 'createAnsibleCredentialProfile'], ['automation.ts', 'deleteAnsibleCredentialProfile'], ['host-settings.ts', 'updateInstanceDefaultCollectionSettings'], diff --git a/apps/web/lib/automation/ansible-api.ts b/apps/web/lib/automation/ansible-api.ts index e15d236a..da040393 100644 --- a/apps/web/lib/automation/ansible-api.ts +++ b/apps/web/lib/automation/ansible-api.ts @@ -9,6 +9,7 @@ import { type ModuleConnectionSummary, } from '@/lib/modules/module-connections' import { buildSignedModuleRequestHeaders } from '@/lib/modules/service-token' +import { buildAnsiblePairingConnectionInput, ANSIBLE_PAIRING_TIMEOUT_MS } from './ansible-pairing-core' export interface AnsibleApiHealth { ok: boolean @@ -44,6 +45,12 @@ export interface RunAnsiblePingResponse { hosts: RunAnsiblePingHostResult[] } +export interface PairAnsibleApiInput { + baseUrl: string + username: string + password: string +} + export function getAnsibleApiBaseUrl(): string { return (process.env['ANSIBLE_API_URL'] ?? 'http://ansible-api:8080').replace(/\/+$/, '') } @@ -97,6 +104,48 @@ export async function saveAnsibleModuleConnection( }) } +export async function pairAnsibleApi( + instanceId: string, + input: PairAnsibleApiInput, +): Promise { + const baseUrl = input.baseUrl.trim().replace(/\/+$/, '') + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), ANSIBLE_PAIRING_TIMEOUT_MS) + + try { + const response = await fetch(`${baseUrl}/api/v1/pairing/claim`, { + method: 'POST', + cache: 'no-store', + signal: controller.signal, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + username: input.username, + password: input.password, + }), + }) + const data = await response.json().catch(() => null) + if (!response.ok) { + const message = typeof data?.error === 'string' ? data.error : `Ansible API returned ${response.status}` + throw new Error(message) + } + if ( + data?.ok !== true || + typeof data.tokenId !== 'string' || + typeof data.tokenSecret !== 'string' + ) { + throw new Error('Invalid Ansible pairing response') + } + + return saveAnsibleModuleConnection(instanceId, buildAnsiblePairingConnectionInput({ + baseUrl, + tokenId: data.tokenId, + tokenSecret: data.tokenSecret, + })) + } finally { + clearTimeout(timeout) + } +} + function requestHeaders(connection: ModuleConnectionRuntime, method: string, path: string, body: string | Buffer = '') { if (connection.authMode !== 'service-token-hmac') return {} if (!connection.tokenId || !connection.tokenSecret) { diff --git a/apps/web/lib/automation/ansible-pairing-core.test.mjs b/apps/web/lib/automation/ansible-pairing-core.test.mjs new file mode 100644 index 00000000..c30069a8 --- /dev/null +++ b/apps/web/lib/automation/ansible-pairing-core.test.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + buildAnsiblePairingConnectionInput, + inferAnsibleTlsMode, +} from './ansible-pairing-core.ts' + +test('infers insecure TLS mode only for HTTP Ansible URLs', () => { + assert.equal(inferAnsibleTlsMode('http://ansible-api:8080'), 'insecure') + assert.equal(inferAnsibleTlsMode('https://ansible-api.example.test'), 'public-ca') +}) + +test('builds an HMAC module connection from a pairing response', () => { + const input = buildAnsiblePairingConnectionInput({ + baseUrl: ' https://ansible-api.example.test/ ', + tokenId: 'ansible-api', + tokenSecret: 'generated ansible secret with enough entropy', + }) + + assert.deepEqual(input, { + enabled: true, + name: 'Primary Ansible API', + baseUrl: 'https://ansible-api.example.test/', + authMode: 'service-token-hmac', + tokenId: 'ansible-api', + tokenSecret: 'generated ansible secret with enough entropy', + tlsMode: 'public-ca', + timeoutMs: 5000, + }) +}) diff --git a/apps/web/lib/automation/ansible-pairing-core.ts b/apps/web/lib/automation/ansible-pairing-core.ts new file mode 100644 index 00000000..75c92ca6 --- /dev/null +++ b/apps/web/lib/automation/ansible-pairing-core.ts @@ -0,0 +1,25 @@ +import type { ModuleTlsMode } from '@/lib/db/schema' +import type { ModuleConnectionInput } from '@/lib/modules/module-connections' + +export const ANSIBLE_PAIRING_TIMEOUT_MS = 5000 + +export function inferAnsibleTlsMode(baseUrl: string): ModuleTlsMode { + return new URL(baseUrl.trim()).protocol === 'http:' ? 'insecure' : 'public-ca' +} + +export function buildAnsiblePairingConnectionInput(input: { + baseUrl: string + tokenId: string + tokenSecret: string +}): Omit { + return { + enabled: true, + name: 'Primary Ansible API', + baseUrl: input.baseUrl.trim(), + authMode: 'service-token-hmac', + tokenId: input.tokenId, + tokenSecret: input.tokenSecret, + tlsMode: inferAnsibleTlsMode(input.baseUrl), + timeoutMs: ANSIBLE_PAIRING_TIMEOUT_MS, + } +} diff --git a/apps/web/lib/automation/ansible-ui-gating.test.mjs b/apps/web/lib/automation/ansible-ui-gating.test.mjs index 321c3853..d4c008a0 100644 --- a/apps/web/lib/automation/ansible-ui-gating.test.mjs +++ b/apps/web/lib/automation/ansible-ui-gating.test.mjs @@ -40,3 +40,14 @@ test('automation settings hide Ansible credential management while disabled', () assert.match(settingsClient, /const enabled = settings\.ansibleFeatureEnabled && settings\.provider === 'ansible'/) assert.match(settingsClient, /\{enabled && \([\s\S]*Ansible SSH credentials/) }) + +test('automation settings use the simplified Ansible pairing action', () => { + const settingsClient = source('app/(dashboard)/settings/integrations/automation/automation-settings-client.tsx') + + assert.match(settingsClient, /pairAnsibleModuleConnection/) + assert.match(settingsClient, /data-testid="ansible-pairing-url"/) + assert.match(settingsClient, /data-testid="ansible-pairing-username"/) + assert.match(settingsClient, /data-testid="ansible-pairing-password"/) + assert.doesNotMatch(settingsClient, /id="ansible-auth-mode"/) + assert.doesNotMatch(settingsClient, /id="ansible-token-secret"/) +}) diff --git a/apps/web/tests/e2e/settings/automation.spec.ts b/apps/web/tests/e2e/settings/automation.spec.ts index 284d9e0e..d78e23d5 100644 --- a/apps/web/tests/e2e/settings/automation.spec.ts +++ b/apps/web/tests/e2e/settings/automation.spec.ts @@ -18,18 +18,17 @@ test('admin can enable Ansible automation and create an SSH credential profile', await page.getByTestId('settings-automation-ansible-toggle').click() await expect(page.getByText('Restart required')).toBeVisible() await expect(page.getByText('Ansible module connection')).toBeVisible() - await expect(page.getByText(/Check the configured Ansible API URL/)).toBeVisible() + await expect(page.getByText(/Pair the Ansible API connection/)).toBeVisible() await expect(page.getByText('ghcr.io/carrtech-dev/ct-ops/ansible-api:latest')).toBeVisible() await page.getByText('Run the Ansible container').click() await expect(page.getByText('docker compose -f docker-compose.single.yml --profile ansible up -d ansible-api')).toBeVisible() await expect(page.getByText('http://ansible-api:8080')).toBeVisible() await page.getByRole('tab', { name: 'Separate Server' }).click() await expect(page.getByText('docker run -d --name ct-ops-ansible-api')).toBeVisible() - await expect(page.getByText(/Do not set ANSIBLE_API_SERVICE_TOKEN_ID/)).toBeVisible() - await page.getByRole('tab', { name: 'Auth & TLS' }).click() - await expect(page.getByText('A service token is a shared HMAC secret.')).toBeVisible() - await expect(page.getByText(/Authentication set to None means CT-Ops sends unsigned requests/)).toBeVisible() - await expect(page.getByText(/Authentication does not control encryption/)).toBeVisible() + await expect(page.getByText(/Keep the generated token file on persistent storage/)).toBeVisible() + await expect(page.getByTestId('ansible-pairing-url')).toBeVisible() + await expect(page.getByTestId('ansible-pairing-username')).toBeVisible() + await expect(page.getByTestId('ansible-pairing-password')).toBeVisible() await page.getByTestId('ansible-credential-name').fill('Linux admin key') await page.getByTestId('ansible-credential-username').fill('deploy') diff --git a/deploy/customer-bundle/start.sh b/deploy/customer-bundle/start.sh index d441621a..c9c98621 100755 --- a/deploy/customer-bundle/start.sh +++ b/deploy/customer-bundle/start.sh @@ -30,7 +30,7 @@ REQUIRED_VARS=(BETTER_AUTH_URL BETTER_AUTH_TRUSTED_ORIGINS BETTER_AUTH_SECRET PO # Optional variables — missing values get a warning, not an error. Most have # safe localhost defaults baked into docker-compose.yml. -OPTIONAL_VARS=(AGENT_DOWNLOAD_BASE_URL INGEST_WS_URL CT_OPS_TRUST_PROXY_HEADERS CT_OPS_LOADTEST_ADMIN_KEY WEB_IMAGE INGEST_IMAGE ANSIBLE_API_IMAGE PASSWORD_MANAGER_DB_PASSWORD PASSWORD_MANAGER_CT_OPS_ISSUER PASSWORD_MANAGER_CT_OPS_AUDIENCE PASSWORD_MANAGER_CT_OPS_PRODUCT PASSWORD_MANAGER_CT_OPS_ED25519_PUBLIC_KEY PASSWORD_MANAGER_CT_OPS_ED25519_PRIVATE_KEY PASSWORD_MANAGER_TRUSTED_ORIGINS PASSWORD_MANAGER_SESSION_COOKIE_SECURE CT_OPS_INSTANCE_ID) +OPTIONAL_VARS=(AGENT_DOWNLOAD_BASE_URL INGEST_WS_URL CT_OPS_TRUST_PROXY_HEADERS CT_OPS_LOADTEST_ADMIN_KEY WEB_IMAGE INGEST_IMAGE ANSIBLE_API_IMAGE ANSIBLE_API_PAIRING_USERNAME ANSIBLE_API_PAIRING_PASSWORD PASSWORD_MANAGER_DB_PASSWORD PASSWORD_MANAGER_CT_OPS_ISSUER PASSWORD_MANAGER_CT_OPS_AUDIENCE PASSWORD_MANAGER_CT_OPS_PRODUCT PASSWORD_MANAGER_CT_OPS_ED25519_PUBLIC_KEY PASSWORD_MANAGER_CT_OPS_ED25519_PRIVATE_KEY PASSWORD_MANAGER_TRUSTED_ORIGINS PASSWORD_MANAGER_SESSION_COOKIE_SECURE CT_OPS_INSTANCE_ID) REQUIRED_FILES=( docker-compose.yml password-manager-release.json @@ -511,6 +511,18 @@ check_env() { echo "Generated POSTGRES_PASSWORD and wrote it to .env." fi + if [ -z "${ANSIBLE_API_PAIRING_USERNAME:-}" ]; then + upsert_env_var "ANSIBLE_API_PAIRING_USERNAME" "ctops" + echo "Set ANSIBLE_API_PAIRING_USERNAME in .env." + fi + + if [ -z "${ANSIBLE_API_PAIRING_PASSWORD:-}" ]; then + require_openssl "generate ANSIBLE_API_PAIRING_PASSWORD on first run" + GENERATED_ANSIBLE_PAIRING_PASS=$(openssl rand -base64 24) + upsert_env_var "ANSIBLE_API_PAIRING_PASSWORD" "$GENERATED_ANSIBLE_PAIRING_PASS" + echo "Generated ANSIBLE_API_PAIRING_PASSWORD and wrote it to .env." + fi + ensure_password_manager_bootstrap MISSING=() diff --git a/deploy/scripts/test-ansible-profile-wiring.sh b/deploy/scripts/test-ansible-profile-wiring.sh index 5a2872f9..55045904 100755 --- a/deploy/scripts/test-ansible-profile-wiring.sh +++ b/deploy/scripts/test-ansible-profile-wiring.sh @@ -19,6 +19,9 @@ assert_contains "${REPO_ROOT}/docker-compose.single.yml" "- ansible" assert_contains "${REPO_ROOT}/apps/web/lib/db/schema/module-connections.ts" "module_connections" assert_contains "${REPO_ROOT}/apps/web/lib/automation/ansible-api.ts" "getAnsibleModuleConnectionSummary" assert_contains "${REPO_ROOT}/apps/web/lib/automation/ansible-api.ts" "buildSignedModuleRequestHeaders" +assert_contains "${REPO_ROOT}/apps/web/lib/automation/ansible-api.ts" "pairAnsibleApi" +assert_contains "${REPO_ROOT}/apps/ansible-api/Dockerfile" "/var/lib/ct-ops/ansible-api" +assert_contains "${REPO_ROOT}/apps/ansible-api/server.py" "ANSIBLE_API_PAIRING_USERNAME" assert_contains "${REPO_ROOT}/apps/ansible-api/server.py" "ANSIBLE_API_SERVICE_TOKEN_ID" assert_contains "${REPO_ROOT}/apps/ansible-api/server.py" "verify_service_request" assert_contains "${REPO_ROOT}/deploy/customer-bundle/build-offline-installer.sh" "--profile ansible config --images" diff --git a/docker-compose.single.yml b/docker-compose.single.yml index 967b5eca..6703ea04 100644 --- a/docker-compose.single.yml +++ b/docker-compose.single.yml @@ -227,8 +227,13 @@ services: context: apps/ansible-api dockerfile: Dockerfile environment: + ANSIBLE_API_PAIRING_USERNAME: ${ANSIBLE_API_PAIRING_USERNAME:-ctops} + ANSIBLE_API_PAIRING_PASSWORD: ${ANSIBLE_API_PAIRING_PASSWORD:-} + ANSIBLE_API_SERVICE_TOKEN_FILE: /var/lib/ct-ops/ansible-api/service-token.json ANSIBLE_API_SERVICE_TOKEN_ID: ${ANSIBLE_API_SERVICE_TOKEN_ID:-} ANSIBLE_API_SERVICE_TOKEN_SECRET: ${ANSIBLE_API_SERVICE_TOKEN_SECRET:-} + volumes: + - ansible_api_data:/var/lib/ct-ops/ansible-api profiles: - ansible restart: unless-stopped @@ -306,3 +311,4 @@ volumes: password_manager_db_data: ingest_data: agent_dist: + ansible_api_data: