From 4567ee7777613a584c862b43dfd26bce90cf1879 Mon Sep 17 00:00:00 2001
From: Codex
- 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.
- 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.
+
+ )}
+
-
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: