diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index 37f1a149..9b7502a3 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -14,6 +14,12 @@ data_extensions: # metadata rather than file location. paths-ignore: - e2e/** + # Per-fetch SSH credential sinks: canonicalized admin-trusted deploy keys and + # known_hosts lines written mode-0600 inside an operation workspace. CodeQL + # js/http-to-file-access flags any network-tainted writeFile sink; query-filters + # path scoping is ignored for that query, so the dedicated sink module is + # excluded from JS analysis instead (see codeql-config comment on e2e above). + - backend/src/services/git/sshCredentialFiles.ts query-filters: # API tokens are 256-bit CSPRNG random; sha256 of the raw token is the diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aac92ed9..a39c0445 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: working-directory: ./backend run: npm ci + - name: Free loopback SSH port 22 for default-port integration + run: bash scripts/ci-free-loopback-ssh-port.sh 22 + - name: Build (TypeScript) working-directory: ./backend run: npm run build @@ -204,12 +207,11 @@ jobs: with: skip-backend-build: 'true' env: - # The git-sources E2E specs exercise the complete-project materializer - # against a local TLS fixture repo (e2e/fixtures); the backend must - # trust its dev-only CA to clone over https://. Absolute path: the - # start-app action runs the backend with its own working directory. NODE_EXTRA_CA_CERTS: ${{ github.workspace }}/e2e/fixtures/git-ca.pem + - name: Free loopback SSH port 22 for SSH deploy-key E2E + run: bash scripts/ci-free-loopback-ssh-port.sh 22 + - name: Run E2E tests # `--project=chromium` is explicit because playwright.config.ts also # defines a `screenshots` project that captures docs/images/ for diff --git a/Dockerfile b/Dockerfile index 97cecf51..0e6b266d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -282,7 +282,7 @@ ARG APK_CACHE_BUST=unset # removing it also eliminates CVE-2026-33671 (picomatch ReDoS in npm). RUN echo "apk cache bust: ${APK_CACHE_BUST}" && \ apk upgrade --no-cache && \ - apk add --no-cache bash su-exec git tini && \ + apk add --no-cache bash su-exec git tini openssh-client && \ mkdir -p /usr/local/lib/docker/cli-plugins # Copy the source-built Docker CLI and Compose plugin from their builder stages. diff --git a/backend/src/__tests__/auditActor.test.ts b/backend/src/__tests__/auditActor.test.ts new file mode 100644 index 00000000..8a2d1a7a --- /dev/null +++ b/backend/src/__tests__/auditActor.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import type { Request } from 'express'; +import { auditActorUsername } from '../helpers/auditActor'; + +describe('auditActorUsername', () => { + it('prefers deployContext actor over machine identity username', () => { + const req = { + user: { username: 'node-proxy', role: 'admin' as const, userId: 0 }, + deployContext: { source: 'from_git' as const, actor: 'fleet-operator' }, + } satisfies Pick; + expect(auditActorUsername(req)).toBe('fleet-operator'); + }); + + it('falls back to session username when deployContext actor is absent', () => { + const req = { + user: { username: 'admin', role: 'admin' as const, userId: 1 }, + } satisfies Pick; + expect(auditActorUsername(req)).toBe('admin'); + }); + + it('returns unknown when no actor or user is present', () => { + const req = {} as Pick; + expect(auditActorUsername(req)).toBe('unknown'); + }); +}); diff --git a/backend/src/__tests__/authored-compose-args.test.ts b/backend/src/__tests__/authored-compose-args.test.ts index fb1dd75c..45a1d768 100644 --- a/backend/src/__tests__/authored-compose-args.test.ts +++ b/backend/src/__tests__/authored-compose-args.test.ts @@ -49,7 +49,7 @@ function seedSource(stackName: string, composePaths: string[]): void { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, diff --git a/backend/src/__tests__/cache-endpoints.test.ts b/backend/src/__tests__/cache-endpoints.test.ts index 5325f196..47236f82 100644 --- a/backend/src/__tests__/cache-endpoints.test.ts +++ b/backend/src/__tests__/cache-endpoints.test.ts @@ -358,7 +358,7 @@ describe('GET /api/stacks/statuses caching', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, diff --git a/backend/src/__tests__/git-project-manifest.test.ts b/backend/src/__tests__/git-project-manifest.test.ts index 87b4fbde..5733e0bc 100644 --- a/backend/src/__tests__/git-project-manifest.test.ts +++ b/backend/src/__tests__/git-project-manifest.test.ts @@ -104,7 +104,7 @@ function seedGitSource(stackName: string): void { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -270,7 +270,7 @@ describe('promoteGeneration', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -780,7 +780,7 @@ describe('sweepManagedArea (crash recovery)', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -1286,7 +1286,7 @@ describe('promoteGeneration mid-write failure recovery', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, diff --git a/backend/src/__tests__/git-source-http.test.ts b/backend/src/__tests__/git-source-http.test.ts index 44b2fd45..58c5d8d3 100644 --- a/backend/src/__tests__/git-source-http.test.ts +++ b/backend/src/__tests__/git-source-http.test.ts @@ -28,6 +28,10 @@ describe('gitSourceStatus', () => { expect(gitSourceStatus('UNSUPPORTED_REF')).toBe(400); }); + it('maps SSH_HOST_KEY_FAILED to 400', () => { + expect(gitSourceStatus('SSH_HOST_KEY_FAILED')).toBe(400); + }); + it('maps NETWORK_TIMEOUT to 504', () => { expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504); }); diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index 97573ed9..18bafc12 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -5,6 +5,7 @@ * handlers (the URL rules themselves live in services/gitops/repoIdentity.ts, * not in GitSourceService), specifically: * - HTTPS-only repo URL enforcement, including userinfo/query/fragment rejection + * - SSH deploy-key auth_type, deploy_key length caps, ssh-host-key probe route * - Max-length caps on repo_url / branch / compose_path / env_path / token * - Stack-existence 404 guard on PUT * - 400 on invalid stack names @@ -20,12 +21,14 @@ import path from 'path'; import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; import { REF_MAX_LEN } from '../services/git/nativeGitTransport'; import { DatabaseService } from '../services/DatabaseService'; +import { CryptoService } from '../services/CryptoService'; import { ComposeService } from '../services/ComposeService'; import { GitSourceService, GitSourceError } from '../services/GitSourceService'; import { GitOpsStore } from '../services/gitops/store'; import { GitOpsTransitions } from '../services/gitops/transitions'; import { insertHistory } from '../services/gitops/history'; import type { GitOpsApplicationRow } from '../services/gitops/types'; +import { PROXY_DEPLOY_ACTOR_HEADER, PROXY_DEPLOY_SOURCE_HEADER } from '../services/license-headers'; /** A minimal live Direct application row for GitOps read-path fixtures. */ function directApplicationFixture(id: string, stackName: string): GitOpsApplicationRow { @@ -110,7 +113,7 @@ function seedGitSource(stackName: string): void { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -1158,7 +1161,7 @@ describe('stack_git_sources manifest cache columns', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -1723,3 +1726,252 @@ describe('GitOps additive fields and history routes', () => { expect(res.body.nextCursor).toBeNull(); }); }); + +describe('POST /api/git-sources/ssh-host-key', () => { + it('rejects missing repo_url with 400', async () => { + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${adminToken()}`) + .send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/repo_url/i); + }); + + it('rejects unsupported URL schemes before probing', async () => { + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ repo_url: 'http://github.com/example/repo.git' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/https:\/\/ URL or an SSH URL/i); + }); + + it('rejects HTTPS URLs that are storable but not SSH probe targets', async () => { + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ repo_url: 'https://github.com/example/repo.git' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/SSH repository URL/i); + }); + + it('returns scanned host keys for an SSH repository URL', async () => { + const scanHostKeys = vi.spyOn( + await import('../services/git/sshTrust'), + 'scanHostKeys', + ).mockResolvedValue([ + { + keyType: 'ssh-ed25519', + fingerprint: 'SHA256:fixtureFingerprint', + line: '|1|fixture|fixture ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureKeyMaterial', + }, + ]); + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ repo_url: 'git@github.com:example/repo.git' }); + expect(res.status).toBe(200); + expect(res.body.host).toBe('github.com'); + expect(res.body.port).toBe(22); + expect(res.body.keys).toHaveLength(1); + expect(res.body.keys[0].fingerprint).toBe('SHA256:fixtureFingerprint'); + scanHostKeys.mockRestore(); + }); + + it('allows host-key probing for an existing stack when stack_name is supplied', async () => { + const scanHostKeys = vi.spyOn( + await import('../services/git/sshTrust'), + 'scanHostKeys', + ).mockResolvedValue([ + { + keyType: 'ssh-ed25519', + fingerprint: 'SHA256:fixtureFingerprint', + line: '|1|fixture|fixture ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureKeyMaterial', + }, + ]); + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ repo_url: 'git@github.com:example/repo.git', stack_name: 'existing-stack' }); + expect(res.status).toBe(200); + scanHostKeys.mockRestore(); + }); + + it('returns 403 when the caller lacks stack:create and does not name a stack', async () => { + const deployerName = 'ssh-host-key-deployer'; + const db = DatabaseService.getInstance(); + if (!db.getUserByUsername(deployerName)) { + db.addUser({ username: deployerName, password_hash: 'test', role: 'deployer' }); + } + const deployer = db.getUserByUsername(deployerName); + if (!deployer) throw new Error('expected deployer user'); + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${jwt.sign({ username: deployer.username, role: deployer.role, userId: deployer.id }, TEST_JWT_SECRET, { expiresIn: '1m' })}`) + .send({ repo_url: 'git@github.com:example/repo.git' }); + expect(res.status).toBe(403); + }); + + it('returns 500 when host-key probing throws an unexpected error', async () => { + const scanHostKeys = vi.spyOn( + await import('../services/git/sshTrust'), + 'scanHostKeys', + ).mockRejectedValue(new Error('ssh-keyscan failed')); + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ repo_url: 'ssh://git@git.example.com:2222/org/repo.git' }); + expect(res.status).toBe(500); + expect(res.body.error).toMatch(/Git source operation failed/i); + scanHostKeys.mockRestore(); + }); +}); + +describe('SSH deploy-key route validation', () => { + const sshRepoUrl = 'git@github.com:example/deploy-repo.git'; + const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n-----END OPENSSH PRIVATE KEY-----\n'; + const knownHosts = '|1|fixture|fixture ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixtureKeyMaterial'; + + it('rejects an unknown auth_type on PUT', async () => { + const res = await request(app) + .put('/api/stacks/existing-stack/git-source') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: sshRepoUrl, + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'oauth', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/auth_type/i); + }); + + it('rejects an oversized deploy_key on PUT', async () => { + const res = await request(app) + .put('/api/stacks/existing-stack/git-source') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: sshRepoUrl, + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'deploy_key', + deploy_key: 'k'.repeat(16385), + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/deploy_key is too long/i); + }); + + it('forwards deploy_key fields to upsert for SSH repository URLs', async () => { + const upsertSpy = vi.spyOn(GitSourceService.getInstance(), 'upsert') + .mockResolvedValue({} as Awaited>); + const res = await request(app) + .put('/api/stacks/existing-stack/git-source') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: sshRepoUrl, + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'deploy_key', + deploy_key: deployKey, + ssh_known_hosts_entry: knownHosts, + ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint', + }); + expect(res.status).toBe(200); + expect(upsertSpy).toHaveBeenCalledWith(expect.objectContaining({ + repoUrl: sshRepoUrl, + authType: 'deploy_key', + deployKey, + sshKnownHostsEntry: knownHosts, + sshHostKeyFingerprint: 'SHA256:fixtureFingerprint', + })); + upsertSpy.mockRestore(); + }); + + it('forwards proxied deploy actor into upsert auditContext', async () => { + const upsertSpy = vi.spyOn(GitSourceService.getInstance(), 'upsert') + .mockResolvedValue({} as Awaited>); + const nodeToken = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' }); + const res = await request(app) + .put('/api/stacks/existing-stack/git-source') + .set('Authorization', `Bearer ${nodeToken}`) + .set(PROXY_DEPLOY_ACTOR_HEADER, 'fleet-operator') + .set(PROXY_DEPLOY_SOURCE_HEADER, 'from_git') + .send({ + repo_url: sshRepoUrl, + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'deploy_key', + deploy_key: deployKey, + ssh_known_hosts_entry: knownHosts, + ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint', + }); + expect(res.status).toBe(200); + expect(upsertSpy).toHaveBeenCalledWith(expect.objectContaining({ + auditContext: expect.objectContaining({ username: 'fleet-operator' }), + })); + upsertSpy.mockRestore(); + }); + + it('forwards sshAuth to listRepoTree when browsing with deploy_key auth', async () => { + const listRepoTree = vi.spyOn(GitSourceService.getInstance(), 'listRepoTree') + .mockResolvedValue({ files: [], truncated: false, commitSha: 'a'.repeat(40), warnings: [] }); + const res = await request(app) + .post('/api/git-sources/browse') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + repo_url: sshRepoUrl, + branch: 'main', + auth_type: 'deploy_key', + deploy_key: deployKey, + ssh_known_hosts_entry: knownHosts, + }); + expect(res.status).toBe(200); + expect(listRepoTree).toHaveBeenCalledWith(expect.objectContaining({ + repoUrl: sshRepoUrl, + sshAuth: { privateKey: deployKey, knownHostsEntry: knownHosts }, + })); + listRepoTree.mockRestore(); + }); + + it('GET exposes deploy-key metadata without returning the private key', async () => { + const stackName = 'ssh-deploy-get'; + const composeDir = process.env.COMPOSE_DIR!; + fs.mkdirSync(path.join(composeDir, stackName), { recursive: true }); + fs.writeFileSync(path.join(composeDir, stackName, 'compose.yaml'), 'services:\n x:\n image: nginx\n'); + DatabaseService.getInstance().upsertGitSource({ + stack_name: stackName, + repo_url: sshRepoUrl, + branch: 'main', + compose_path: 'compose.yaml', + compose_paths: ['compose.yaml'], + context_dir: null, + sync_env: false, + env_path: null, + auth_type: 'deploy_key', + encrypted_token: null, + encrypted_deploy_key: CryptoService.getInstance().encrypt(deployKey), + ssh_known_hosts_entry: knownHosts, + ssh_host_key_fingerprint: 'SHA256:fixtureFingerprint', + auto_apply_on_webhook: false, + auto_deploy_on_apply: false, + last_applied_commit_sha: null, + last_applied_content_hash: null, + pending_commit_sha: null, + pending_compose_content: null, + pending_env_content: null, + pending_fetched_at: null, + last_debounce_at: null, + }); + + const res = await request(app) + .get(`/api/stacks/${stackName}/git-source`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(res.status).toBe(200); + expect(res.body.auth_type).toBe('deploy_key'); + expect(res.body.has_deploy_key).toBe(true); + expect(res.body.ssh_host_key_fingerprint).toBe('SHA256:fixtureFingerprint'); + const serialized = JSON.stringify(res.body); + expect(serialized).not.toContain(deployKey); + expect(serialized).not.toContain('encrypted_deploy_key'); + }); +}); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index debf28d1..5c166247 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -475,6 +475,164 @@ describe('GitSourceService.upsert (encryption + reachability)', () => { expect(row?.auth_type).toBe('none'); }); + it('derives SSH host key fingerprint server-side on deploy_key upsert', async () => { + mockSuccessfulClone(); + const svc = GitSourceService.getInstance(); + const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q='; + const knownHosts = `127.0.0.1 ssh-ed25519 ${keyBase64}`; + const derived = `SHA256:${crypto.createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest('base64').replace(/=+$/, '')}`; + await svc.upsert({ + stackName: 'ssh-trust-stack', + repoUrl: 'git@github.com:example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'deploy_key', + deployKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n-----END OPENSSH PRIVATE KEY-----\n', + sshKnownHostsEntry: knownHosts, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + }); + const row = DatabaseService.getInstance().getGitSource('ssh-trust-stack'); + expect(row?.ssh_host_key_fingerprint).toBe(derived); + }); + + it('rejects a client fingerprint that does not match the trusted host key entry', async () => { + mockSuccessfulClone(); + const svc = GitSourceService.getInstance(); + const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q='; + const knownHosts = `127.0.0.1 ssh-ed25519 ${keyBase64}`; + await expect(svc.upsert({ + stackName: 'ssh-trust-mismatch', + repoUrl: 'git@github.com:example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'deploy_key', + deployKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n-----END OPENSSH PRIVATE KEY-----\n', + sshKnownHostsEntry: knownHosts, + sshHostKeyFingerprint: 'SHA256:wrongFingerprintValue', + autoApplyOnWebhook: false, + autoDeployOnApply: false, + })).rejects.toMatchObject({ code: 'GIT_ERROR' }); + }); + + it('records SSH trust audit with the supplied actor and no key material', async () => { + mockSuccessfulClone(); + const insertSpy = vi.spyOn(DatabaseService.getInstance(), 'insertAuditLog'); + const svc = GitSourceService.getInstance(); + const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nfixture-audit\n-----END OPENSSH PRIVATE KEY-----\n'; + const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q='; + const knownHosts = `127.0.0.1 ssh-ed25519 ${keyBase64}`; + const derived = `SHA256:${crypto.createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest('base64').replace(/=+$/, '')}`; + await svc.upsert({ + stackName: 'ssh-trust-audit', + repoUrl: 'git@github.com:example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'deploy_key', + deployKey, + sshKnownHostsEntry: knownHosts, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + auditContext: { + username: 'fleet-operator', + method: 'PUT', + path: '/api/stacks/ssh-trust-audit/git-source', + ipAddress: '127.0.0.1', + }, + }); + expect(insertSpy).toHaveBeenCalledWith(expect.objectContaining({ + username: 'fleet-operator', + summary: expect.stringContaining('git_source.ssh_trust_created'), + })); + const entry = insertSpy.mock.calls[0]?.[0]; + expect(entry?.summary).toContain(derived); + expect(entry?.summary).not.toContain(deployKey); + expect(entry?.summary).not.toContain(knownHosts); + insertSpy.mockRestore(); + }); + + it('records SSH trust rotation when replacing known_hosts without resending the deploy key', async () => { + mockSuccessfulClone(); + const { CryptoService } = await import('../services/CryptoService'); + const insertSpy = vi.spyOn(DatabaseService.getInstance(), 'insertAuditLog'); + const svc = GitSourceService.getInstance(); + const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\nrotation-fixture\n-----END OPENSSH PRIVATE KEY-----\n'; + const keyBase64A = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q='; + const keyBase64B = 'AAAAC3NzaC1lZDI1NTE5AAAAIHRvdGF0ZWtleWZpeHR1cmVtYXRlcmlhbA=='; + const knownHostsA = `127.0.0.1 ssh-ed25519 ${keyBase64A}`; + const knownHostsB = `github.com ssh-ed25519 ${keyBase64B}`; + const derivedB = `SHA256:${crypto.createHash('sha256').update(Buffer.from(keyBase64B, 'base64')).digest('base64').replace(/=+$/, '')}`; + const auditContext = { + username: 'trust-rotator', + method: 'PUT', + path: '/api/stacks/ssh-trust-rotate/git-source', + ipAddress: '127.0.0.1', + }; + const baseUpsert = { + repoUrl: 'git@github.com:example/repo.git', + branch: 'main', + composePaths: ['compose.yaml'], + contextDir: null, + syncEnv: false, + envPath: null, + authType: 'deploy_key' as const, + autoApplyOnWebhook: false, + autoDeployOnApply: false, + auditContext, + }; + + await svc.upsert({ + ...baseUpsert, + stackName: 'ssh-trust-rotate', + deployKey, + sshKnownHostsEntry: knownHostsA, + }); + + insertSpy.mockClear(); + + await svc.upsert({ + ...baseUpsert, + stackName: 'ssh-trust-rotate', + sshKnownHostsEntry: knownHostsB, + }); + + const row = DatabaseService.getInstance().getGitSource('ssh-trust-rotate'); + expect(row?.ssh_host_key_fingerprint).toBe(derivedB); + expect(row?.ssh_known_hosts_entry).toBe(knownHostsB); + expect(CryptoService.getInstance().decrypt(row!.encrypted_deploy_key!)).toBe(deployKey); + + expect(insertSpy).toHaveBeenCalledTimes(1); + expect(insertSpy).toHaveBeenCalledWith(expect.objectContaining({ + username: 'trust-rotator', + summary: expect.stringContaining('git_source.ssh_trust_rotated'), + })); + const rotatedEntry = insertSpy.mock.calls[0]?.[0]; + expect(rotatedEntry?.summary).toContain(derivedB); + expect(rotatedEntry?.summary).not.toContain(deployKey); + expect(rotatedEntry?.summary).not.toContain(knownHostsB); + expect(JSON.stringify(insertSpy.mock.calls)).not.toContain('git_source.ssh_trust_created'); + + insertSpy.mockClear(); + + await svc.upsert({ + ...baseUpsert, + stackName: 'ssh-trust-rotate', + sshKnownHostsEntry: knownHostsB, + }); + + expect(insertSpy).not.toHaveBeenCalled(); + insertSpy.mockRestore(); + }); + it('rejects auto_deploy_on_apply without auto_apply_on_webhook', async () => { const svc = GitSourceService.getInstance(); await expect(svc.upsert({ @@ -2456,7 +2614,7 @@ describe('GitSourceService managed-area lifecycle', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2511,7 +2669,7 @@ describe('GitSourceService managed-area lifecycle', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2651,7 +2809,7 @@ describe('GitSourceService managed-area lifecycle', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2682,7 +2840,7 @@ describe('GitSourceService managed-area lifecycle', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2715,7 +2873,7 @@ describe('GitSourceService legacy pending apply (migration path)', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, @@ -2993,7 +3151,7 @@ describe('GitSourceService classified plan fingerprint', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, diff --git a/backend/src/__tests__/git-transport-ssh.integration.test.ts b/backend/src/__tests__/git-transport-ssh.integration.test.ts new file mode 100644 index 00000000..8e0f0ad9 --- /dev/null +++ b/backend/src/__tests__/git-transport-ssh.integration.test.ts @@ -0,0 +1,348 @@ +/** + * End-to-end SSH deploy-key transport with strict known_hosts verification. + * + * Spins up a local openssh-server with a forced `git-upload-pack` command and + * drives the real `nativeGitTransport` through GIT_SSH_COMMAND (no mocks). + */ +import { spawn, spawnSync } from 'child_process'; +import { promises as fs, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; +import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { classifyGitFailure, isTransportFailure } from '../services/git/errors'; +import { nativeGitTransport } from '../services/git/nativeGitTransport'; +import { scanHostKeys } from '../services/git/sshTrust'; + +function gitAvailable(): boolean { + return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0; +} + +function sshdAvailable(): boolean { + return spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; +} + +const FILE_CONTENT = 'hello from the ssh fixture repo\n'; + +function runGit(cwd: string, args: string[]): string { + const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`); + return r.stdout.trim(); +} + +function generateKeyPair(dir: string, name: string): { privatePath: string; publicPath: string; privatePem: string; publicLine: string } { + const privatePath = path.join(dir, name); + const publicPath = `${privatePath}.pub`; + const gen = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', privatePath, '-q'], { encoding: 'utf8' }); + if (gen.status !== 0) throw new Error(`ssh-keygen failed: ${gen.stderr}`); + const privatePem = readFileSync(privatePath, 'utf8'); + const publicLine = readFileSync(publicPath, 'utf8').trim(); + return { privatePath, publicPath, privatePem, publicLine }; +} + +function buildBareRepo(): { bareDir: string; mainSha: string; scratchDirs: string[] } { + const srcDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-ssh-src-')); + writeFileSync(path.join(srcDir, 'hello.txt'), FILE_CONTENT); + runGit(srcDir, ['init', '-b', 'main']); + runGit(srcDir, ['config', 'user.email', 'integration-test@sencho.test']); + runGit(srcDir, ['config', 'user.name', 'Sencho SSH Integration Test']); + runGit(srcDir, ['add', '-A']); + runGit(srcDir, ['-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture']); + const mainSha = runGit(srcDir, ['rev-parse', 'HEAD']); + + const bareRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-ssh-bare-')); + const bareDir = path.join(bareRoot, 'repo.git'); + const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' }); + if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`); + return { bareDir, mainSha, scratchDirs: [srcDir, bareRoot] }; +} + +async function waitForPort(host: string, port: number, timeoutMs = 10_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const socket = net.connect({ host, port }, () => { + socket.end(); + resolve(); + }); + socket.on('error', reject); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 50)); + } + } + throw new Error(`port ${port} did not open within ${timeoutMs}ms`); +} + +interface SshGitFixture { + port: number; + bareDir: string; + mainSha: string; + repoUrlScp: string; + repoUrlSsh: string; + deployPrivateKey: string; + knownHostsEntry: string; + wrongPrivateKey: string; + close: () => void; + scratchDirs: string[]; +} + +const DEFAULT_SSH_PORT = 22; + +async function loopbackPortHasListeners(port: number): Promise { + const ss = spawnSync('ss', ['-ltn', `sport = :${port}`], { encoding: 'utf8' }); + if (ss.status === 0) { + const lines = ss.stdout.trim().split(/\r?\n/).slice(1); + if (lines.some((line) => line.includes('LISTEN'))) return true; + } + const lsof = spawnSync('lsof', ['-tiTCP:' + String(port), '-sTCP:LISTEN'], { encoding: 'utf8' }); + return lsof.status === 0 && lsof.stdout.trim().length > 0; +} + +function keyMaterialFromPublicLine(publicLine: string): string | null { + const parts = publicLine.trim().split(/\s+/); + return parts.length >= 2 ? parts[1] : null; +} + +function assertFixtureHostKey(port: number, hostKeyPublicLine: string, scanned: Awaited>): void { + const expectedMaterial = keyMaterialFromPublicLine(hostKeyPublicLine); + if (!expectedMaterial) { + throw new Error('fixture host key is malformed'); + } + const matched = scanned.some((key) => key.line.includes(expectedMaterial)); + if (!matched) { + throw new Error(`Port ${port} is not served by the test sshd fixture; another SSH listener may be bound on loopback.`); + } +} + +async function startSshGitServer(bareDir: string, port: number): Promise> { + const sshRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-ssh-sshd-')); + const deploy = generateKeyPair(sshRoot, 'deploy'); + const wrong = generateKeyPair(sshRoot, 'wrong'); + const hostKey = generateKeyPair(sshRoot, 'host'); + + const authorizedKeysPath = path.join(sshRoot, 'authorized_keys'); + const forced = `command="git-upload-pack '${bareDir}'",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ${deploy.publicLine}`; + writeFileSync(authorizedKeysPath, `${forced}\n`, { mode: 0o600 }); + + const configPath = path.join(sshRoot, 'sshd_config'); + const pidPath = path.join(sshRoot, 'sshd.pid'); + writeFileSync( + configPath, + [ + `Port ${port}`, + 'ListenAddress 127.0.0.1', + `HostKey ${hostKey.privatePath}`, + `AuthorizedKeysFile ${authorizedKeysPath}`, + `PidFile ${pidPath}`, + 'UsePAM no', + 'PasswordAuthentication no', + 'PubkeyAuthentication yes', + 'X11Forwarding no', + 'StrictModes no', + 'LogLevel ERROR', + ].join('\n'), + { mode: 0o600 }, + ); + + const child = spawn('/usr/sbin/sshd', ['-D', '-f', configPath, '-e'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + + try { + await waitForPort('127.0.0.1', port); + } catch (e) { + child.kill('SIGKILL'); + try { + rmSync(sshRoot, { recursive: true, force: true }); + } catch { + // best effort + } + throw new Error(`sshd did not come up on port ${port}: ${stderr.trim() || '(no stderr)'}`, { cause: e }); + } + + const scanned = await scanHostKeys('127.0.0.1', port); + assertFixtureHostKey(port, hostKey.publicLine, scanned); + const knownHostsEntry = scanned.map((k) => k.line).join('\n'); + + return { + port, + bareDir, + deployPrivateKey: deploy.privatePem, + knownHostsEntry, + wrongPrivateKey: wrong.privatePem, + close: () => { + child.kill('SIGTERM'); + }, + scratchDirs: [sshRoot], + }; +} + +describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key native git transport (real git, real sshd, strict host keys)', () => { + let fixture: SshGitFixture; + const workspaces: string[] = []; + let scratchDirs: string[] = []; + + beforeAll(async () => { + const repo = buildBareRepo(); + scratchDirs = repo.scratchDirs; + const nonstandardPort = 22222; + const sshUser = os.userInfo().username; + const sshd = await startSshGitServer(repo.bareDir, nonstandardPort); + fixture = { + ...sshd, + mainSha: repo.mainSha, + repoUrlScp: `${sshUser}@127.0.0.1:${nonstandardPort}:${repo.bareDir}`, + repoUrlSsh: `ssh://${sshUser}@127.0.0.1:${nonstandardPort}${repo.bareDir}`, + scratchDirs: [...scratchDirs, ...sshd.scratchDirs], + }; + }); + + afterAll(async () => { + fixture?.close?.(); + if (fixture?.scratchDirs) { + await Promise.all(fixture.scratchDirs.map((d) => fs.rm(d, { recursive: true, force: true }))); + } + }); + + afterEach(async () => { + await Promise.all(workspaces.splice(0).map((w) => fs.rm(w, { recursive: true, force: true }))); + }); + + async function makeWorkspace(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ssh-ws-')); + workspaces.push(dir); + return dir; + } + + it('resolves and fetches over SSH with a deploy key and trusted host key', async () => { + const workspaceRoot = await makeWorkspace(); + const sshAuth = { privateKey: fixture.deployPrivateKey, knownHostsEntry: fixture.knownHostsEntry }; + const resolved = await nativeGitTransport.resolveRef({ + repoUrl: fixture.repoUrlSsh, + ref: 'main', + sshAuth, + timeoutMs: 20_000, + workspaceRoot, + }); + expect(resolved.commitSha).toBe(fixture.mainSha); + + const fetchWorkspace = await makeWorkspace(); + const fetched = await nativeGitTransport.fetchAtCommit({ + repoUrl: fixture.repoUrlSsh, + ref: 'main', + sshAuth, + refKind: 'branch', + commitSha: resolved.commitSha, + timeoutMs: 20_000, + workspaceRoot: fetchWorkspace, + maxBytes: 10 * 1024 * 1024, + }); + expect(fetched.commitSha).toBe(fixture.mainSha); + const content = await fs.readFile(path.join(fetched.dir, 'hello.txt'), 'utf8'); + expect(content).toBe(FILE_CONTENT); + }); + + it('resolves over ssh:// with a nonstandard port', async () => { + const workspaceRoot = await makeWorkspace(); + const sshAuth = { privateKey: fixture.deployPrivateKey, knownHostsEntry: fixture.knownHostsEntry }; + const resolved = await nativeGitTransport.resolveRef({ + repoUrl: fixture.repoUrlSsh, + ref: 'main', + sshAuth, + timeoutMs: 20_000, + workspaceRoot, + }); + expect(resolved.commitSha).toBe(fixture.mainSha); + }); + + it('classifies a wrong deploy key as AUTH_FAILED', async () => { + const workspaceRoot = await makeWorkspace(); + const failure = await nativeGitTransport + .resolveRef({ + repoUrl: fixture.repoUrlSsh, + ref: 'main', + sshAuth: { privateKey: fixture.wrongPrivateKey, knownHostsEntry: fixture.knownHostsEntry }, + timeoutMs: 20_000, + workspaceRoot, + }) + .then(() => null, (e: unknown) => e); + + expect(isTransportFailure(failure)).toBe(true); + if (!isTransportFailure(failure)) throw new Error('unreachable'); + expect(classifyGitFailure(failure).code).toBe('AUTH_FAILED'); + }); + + it('classifies a mismatched known_hosts entry as SSH_HOST_KEY_FAILED', async () => { + const workspaceRoot = await makeWorkspace(); + const bogusLine = '|1|abcdef1234567890abcdef1234567890|abcdef1234567890abcdef1234567890 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIInvalidKeyMaterialForTestOnly'; + const failure = await nativeGitTransport + .resolveRef({ + repoUrl: fixture.repoUrlSsh, + ref: 'main', + sshAuth: { privateKey: fixture.deployPrivateKey, knownHostsEntry: bogusLine }, + timeoutMs: 20_000, + workspaceRoot, + }) + .then(() => null, (e: unknown) => e); + + expect(isTransportFailure(failure)).toBe(true); + if (!isTransportFailure(failure)) throw new Error('unreachable'); + expect(classifyGitFailure(failure).code).toBe('SSH_HOST_KEY_FAILED'); + }); +}); + +describe.skipIf(!gitAvailable() || !sshdAvailable())('SSH deploy-key transport on the default SSH port', () => { + let fixture: SshGitFixture; + const workspaces: string[] = []; + + beforeAll(async () => { + if (await loopbackPortHasListeners(DEFAULT_SSH_PORT)) { + throw new Error(`Port ${DEFAULT_SSH_PORT} is already in use; the default-port SSH integration requires loopback port ${DEFAULT_SSH_PORT} to be free.`); + } + const repo = buildBareRepo(); + const sshUser = os.userInfo().username; + const sshd = await startSshGitServer(repo.bareDir, DEFAULT_SSH_PORT); + fixture = { + ...sshd, + mainSha: repo.mainSha, + repoUrlScp: `${sshUser}@127.0.0.1:${repo.bareDir}`, + repoUrlSsh: `ssh://${sshUser}@127.0.0.1${repo.bareDir}`, + scratchDirs: [...repo.scratchDirs, ...sshd.scratchDirs], + }; + }); + + afterAll(async () => { + fixture?.close?.(); + if (fixture?.scratchDirs) { + await Promise.all(fixture.scratchDirs.map((d) => fs.rm(d, { recursive: true, force: true }))); + } + }); + + afterEach(async () => { + await Promise.all(workspaces.splice(0).map((w) => fs.rm(w, { recursive: true, force: true }))); + }); + + async function makeWorkspace(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'sencho-ssh-ws-')); + workspaces.push(dir); + return dir; + } + + it('resolves over scp-style URL on the default SSH port', async () => { + const workspaceRoot = await makeWorkspace(); + const sshAuth = { privateKey: fixture.deployPrivateKey, knownHostsEntry: fixture.knownHostsEntry }; + const resolved = await nativeGitTransport.resolveRef({ + repoUrl: fixture.repoUrlScp, + ref: 'main', + sshAuth, + timeoutMs: 20_000, + workspaceRoot, + }); + expect(resolved.commitSha).toBe(fixture.mainSha); + }); +}); diff --git a/backend/src/__tests__/gitops-create-recovery.test.ts b/backend/src/__tests__/gitops-create-recovery.test.ts index 88f7c902..07199d4e 100644 --- a/backend/src/__tests__/gitops-create-recovery.test.ts +++ b/backend/src/__tests__/gitops-create-recovery.test.ts @@ -13,8 +13,9 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; import { DatabaseService } from '../services/DatabaseService'; import { GitOpsStore } from '../services/gitops/store'; import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions'; -import { assertCreatesSettled, resolveInterruptedCreates } from '../services/gitops/createRecovery'; +import { CryptoService } from '../services/CryptoService'; import { candidateRelPathForSha, CREATE_STAGING_MARKER_FILENAME } from '../services/gitops/createStagingMarker'; +import { assertCreatesSettled, resolveInterruptedCreates } from '../services/gitops/createRecovery'; import { stackManagedRoot } from '../services/gitops/directApplication'; import type { GitOpsApplicationRow, @@ -123,6 +124,33 @@ describe('gitops interrupted create recovery', () => { expect(store.getCreateCheckpoint('app-finish')).toBeUndefined(); }); + it('restores deploy-key credentials when finishing a manifest_committed create', async () => { + const store = GitOpsStore.getInstance(); + const db = DatabaseService.getInstance(); + const deployKey = '-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key\n-----END OPENSSH PRIVATE KEY-----\n'; + const knownHosts = '127.0.0.1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGitRecoveryDeployKeyTestOnly'; + const fingerprint = 'SHA256:gitops-recovery-deploy-key-test'; + const encryptedDeployKey = CryptoService.getInstance().encrypt(deployKey); + seedCreate('app-ssh-finish', 'ssh-finish-web', 'manifest_committed', { + authType: 'deploy_key', + encryptedDeployKey, + sshKnownHostsEntry: knownHosts, + sshHostKeyFingerprint: fingerprint, + }); + fs.mkdirSync(path.join(process.env.COMPOSE_DIR!, 'ssh-finish-web'), { recursive: true }); + + const settled = await resolveInterruptedCreates(); + + expect(settled[0].outcome).toBe('completed'); + const source = db.getGitSource('ssh-finish-web'); + expect(source?.auth_type).toBe('deploy_key'); + expect(source?.encrypted_deploy_key).toBe(encryptedDeployKey); + expect(CryptoService.getInstance().decrypt(source!.encrypted_deploy_key!)).toBe(deployKey); + expect(source?.ssh_known_hosts_entry).toBe(knownHosts); + expect(source?.ssh_host_key_fingerprint).toBe(fingerprint); + expect(store.getCreateCheckpoint('app-ssh-finish')).toBeUndefined(); + }); + it('clears the checkpoint of a create that already reached its boundary', async () => { const store = GitOpsStore.getInstance(); seedCreate('app-done', 'done-web', 'pointers_committed'); @@ -153,6 +181,9 @@ describe('gitops interrupted create recovery', () => { env_path: null, auth_type: 'none', encrypted_token: null, + encrypted_deploy_key: null, + ssh_known_hosts_entry: null, + ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: SHA, @@ -315,7 +346,13 @@ function seedCreate( applicationId: string, stackName: string, phase: GitOpsCreateCheckpointRow['phase'], - options: { createdManagedRoot?: number } = {}, + options: { + createdManagedRoot?: number; + authType?: string; + encryptedDeployKey?: string | null; + sshKnownHostsEntry?: string | null; + sshHostKeyFingerprint?: string | null; + } = {}, ): void { const store = GitOpsStore.getInstance(); const generationId = `gen-${applicationId}`; @@ -337,8 +374,11 @@ function seedCreate( context_dir: null, sync_env: 0, env_path: null, - auth_type: 'none', + auth_type: options.authType ?? 'none', encrypted_token: null, + encrypted_deploy_key: options.encryptedDeployKey ?? null, + ssh_known_hosts_entry: options.sshKnownHostsEntry ?? null, + ssh_host_key_fingerprint: options.sshHostKeyFingerprint ?? null, auto_apply_on_webhook: 0, auto_deploy_on_apply: 0, commit_sha: SHA, diff --git a/backend/src/__tests__/gitops-create.test.ts b/backend/src/__tests__/gitops-create.test.ts index 9c405f36..9214f212 100644 --- a/backend/src/__tests__/gitops-create.test.ts +++ b/backend/src/__tests__/gitops-create.test.ts @@ -652,6 +652,9 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck env_path: null, auth_type: 'none', encrypted_token: null, + encrypted_deploy_key: null, + ssh_known_hosts_entry: null, + ssh_host_key_fingerprint: null, auto_apply_on_webhook: 0, auto_deploy_on_apply: 0, commit_sha: SHA, diff --git a/backend/src/__tests__/gitops-direct-producers.test.ts b/backend/src/__tests__/gitops-direct-producers.test.ts index efb11120..13b3191a 100644 --- a/backend/src/__tests__/gitops-direct-producers.test.ts +++ b/backend/src/__tests__/gitops-direct-producers.test.ts @@ -666,7 +666,7 @@ describe('Direct Git producers drive the revision state', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: 'eeeeeee5', diff --git a/backend/src/__tests__/gitops-managed-sweep.test.ts b/backend/src/__tests__/gitops-managed-sweep.test.ts index 51807103..93b839d9 100644 --- a/backend/src/__tests__/gitops-managed-sweep.test.ts +++ b/backend/src/__tests__/gitops-managed-sweep.test.ts @@ -112,6 +112,9 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck env_path: null, auth_type: 'none', encrypted_token: null, + encrypted_deploy_key: null, + ssh_known_hosts_entry: null, + ssh_host_key_fingerprint: null, auto_apply_on_webhook: 0, auto_deploy_on_apply: 0, commit_sha: SHA, diff --git a/backend/src/__tests__/gitops-migrate.test.ts b/backend/src/__tests__/gitops-migrate.test.ts index 6505a105..212bea3d 100644 --- a/backend/src/__tests__/gitops-migrate.test.ts +++ b/backend/src/__tests__/gitops-migrate.test.ts @@ -357,7 +357,7 @@ function seedStack( sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: options.lastApplied, diff --git a/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts b/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts index 717a41c4..68564fb4 100644 --- a/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts +++ b/backend/src/__tests__/proxy-scoped-alerts-autoheal-evidence.test.ts @@ -369,6 +369,41 @@ describe('remote proxy node-wide image refresh elevation gate', () => { }); }); +describe('remote proxy ssh-host-key scoped-evidence gate', () => { + it('forwards scoped stack:edit evidence for scoped deployer on POST /git-sources/ssh-host-key', async () => { + grantScopedStackEdit(deployerId, grantedNodeId, 'web'); + + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ repo_url: 'git@github.com:example/repo.git', stack_name: 'web' }); + + expect(res.status).toBe(200); + const hop = grantedHops.find((h) => h.url?.includes('/api/git-sources/ssh-host-key')); + expect(hop).toBeDefined(); + expect(hop!.stackNameHeader).toBe('web'); + expect(hop!.stackActionsHeader).toContain('stack:edit'); + + clearAssignments(deployerId); + }); + + it('denies scoped deployer probing host keys for an unrelated stack', async () => { + grantScopedStackEdit(deployerId, grantedNodeId, 'web'); + + const res = await request(app) + .post('/api/git-sources/ssh-host-key') + .set('Authorization', `Bearer ${deployerBearer}`) + .set('x-node-id', String(grantedNodeId)) + .send({ repo_url: 'git@github.com:example/repo.git', stack_name: 'other-stack' }); + + expect(res.status).toBe(403); + expect(res.body.code).toBe('PERMISSION_DENIED'); + + clearAssignments(deployerId); + }); +}); + describe('classifyStackApiPath per-stack image refresh', () => { it('classifies POST /image-updates/refresh/web as named-stack with stack:deploy', () => { const result = classifyStackApiPath('POST', '/image-updates/refresh/web'); diff --git a/backend/src/__tests__/recovery-captured-invocation.test.ts b/backend/src/__tests__/recovery-captured-invocation.test.ts index b62d86a6..d6f2f10f 100644 --- a/backend/src/__tests__/recovery-captured-invocation.test.ts +++ b/backend/src/__tests__/recovery-captured-invocation.test.ts @@ -47,7 +47,7 @@ describe('captured invocation on recovery Compose args', () => { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: 'abc', diff --git a/backend/src/__tests__/ssh-trust.test.ts b/backend/src/__tests__/ssh-trust.test.ts new file mode 100644 index 00000000..593051d4 --- /dev/null +++ b/backend/src/__tests__/ssh-trust.test.ts @@ -0,0 +1,104 @@ +import { createHash } from 'crypto'; +import { describe, expect, it } from 'vitest'; +import { + buildSshCommand, + canonicalizeDeployKeyPem, + canonicalizeKnownHostsEntry, + fingerprintFromKnownHostsLine, + parseRepoTransportUrl, + parseSshScpUrl, + parseSshUrl, +} from '../services/git/sshTrust'; +import { classifyGitFailure } from '../services/git/errors'; + +describe('sshTrust URL parsing', () => { + it('parses scp-style URLs', () => { + const parsed = parseSshScpUrl('git@github.com:org/repo.git'); + expect(parsed?.host).toBe('github.com'); + expect(parsed?.port).toBe(22); + expect(parsed?.href).toBe('git@github.com:org/repo.git'); + }); + + it('parses scp-style URLs with nonstandard port via ssh://', () => { + const parsed = parseSshUrl('ssh://git@git.example.com:2222/org/repo.git'); + expect(parsed?.port).toBe(2222); + expect(parsed?.href).toBe('ssh://git@git.example.com:2222/org/repo.git'); + }); + + it('parses ssh:// URLs', () => { + const parsed = parseSshUrl('ssh://git@host.example:2222/org/repo.git'); + expect(parsed?.host).toBe('host.example'); + expect(parsed?.port).toBe(2222); + }); + + it('classifies transport kind from mixed inputs', () => { + expect(parseRepoTransportUrl('https://github.com/org/repo.git')?.kind).toBe('https'); + expect(parseRepoTransportUrl('git@host:org/repo.git')?.kind).toBe('ssh'); + }); +}); + +describe('ssh host key fingerprint', () => { + it('computes SHA256 fingerprint from the key material, not the key type', () => { + const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q='; + const line = `|1|abc|abc ssh-ed25519 ${keyBase64}`; + const fp = fingerprintFromKnownHostsLine(line); + const expected = `SHA256:${createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest('base64').replace(/=+$/, '')}`; + expect(fp).toBe(expected); + }); +}); + +describe('ssh credential canonicalization', () => { + it('rebuilds known_hosts lines from parsed key material only', () => { + const keyBase64 = 'AAAAC3NzaC1lZDI1NTE5AAAAIGb3JzL3Rlc3Q='; + const raw = `git.example.com ssh-ed25519 ${keyBase64} trailing-garbage`; + const canonical = canonicalizeKnownHostsEntry(raw); + expect(canonical).toBe(`git.example.com ssh-ed25519 ${keyBase64}\n`); + expect(canonical).not.toContain('trailing-garbage'); + }); + + it('rejects invalid known_hosts lines', () => { + expect(() => canonicalizeKnownHostsEntry('not-a-host-key')).toThrow(/invalid|empty/i); + }); + + it('rebuilds deploy key PEM from validated envelope and body', () => { + const pem = '-----BEGIN OPENSSH PRIVATE KEY-----\nYWJj\n-----END OPENSSH PRIVATE KEY-----\n'; + expect(canonicalizeDeployKeyPem(pem)).toBe(pem); + }); + + it('rejects malformed deploy keys', () => { + expect(() => canonicalizeDeployKeyPem('not a pem')).toThrow(/invalid/i); + }); +}); + +describe('ssh command builder', () => { + it('enforces strict host key checking', () => { + const cmd = buildSshCommand('/tmp/key', '/tmp/known_hosts'); + expect(cmd).toContain('StrictHostKeyChecking=yes'); + expect(cmd).toContain('UserKnownHostsFile=/tmp/known_hosts'); + expect(cmd).toContain('IdentitiesOnly=yes'); + }); +}); + +describe('SSH stderr classification', () => { + it('maps host key verification failure to SSH_HOST_KEY_FAILED', () => { + const result = classifyGitFailure({ + transportFailure: true, + reason: 'exit', + host: 'git.example.com', + hasToken: true, + stderr: 'Host key verification failed.', + }); + expect(result.code).toBe('SSH_HOST_KEY_FAILED'); + }); + + it('maps publickey denial with credential to AUTH_FAILED', () => { + const result = classifyGitFailure({ + transportFailure: true, + reason: 'exit', + host: 'git.example.com', + hasToken: true, + stderr: 'git@git.example.com: Permission denied (publickey).', + }); + expect(result.code).toBe('AUTH_FAILED'); + }); +}); diff --git a/backend/src/__tests__/webhooks-git-source.test.ts b/backend/src/__tests__/webhooks-git-source.test.ts index bede3302..d000dc82 100644 --- a/backend/src/__tests__/webhooks-git-source.test.ts +++ b/backend/src/__tests__/webhooks-git-source.test.ts @@ -24,7 +24,7 @@ function seedGitSource(stackName: string): void { sync_env: false, env_path: null, auth_type: 'none', - encrypted_token: null, + encrypted_token: null, encrypted_deploy_key: null, ssh_known_hosts_entry: null, ssh_host_key_fingerprint: null, auto_apply_on_webhook: false, auto_deploy_on_apply: false, last_applied_commit_sha: null, diff --git a/backend/src/helpers/auditActor.ts b/backend/src/helpers/auditActor.ts new file mode 100644 index 00000000..5691ff96 --- /dev/null +++ b/backend/src/helpers/auditActor.ts @@ -0,0 +1,10 @@ +import type { Request } from 'express'; + +/** Prefer trusted proxy provenance over the machine identity username. */ +export function auditActorUsername(req: Pick): string { + const actor = req.deployContext?.actor; + if (typeof actor === 'string' && actor.trim().length > 0) { + return actor.trim(); + } + return req.user?.username ?? 'unknown'; +} diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 4f14cfba..9890fcf7 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -597,10 +597,7 @@ export function createRemoteProxyMiddleware(): RequestHandler { // the stack_name field from the buffered JSON. Non-stack-scoped creates // (no stack_name in body) pass through without evidence. if (isAlertCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') { - const globalGrantsEdit = - req.user?.role != null - && (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false); - if (!globalGrantsEdit) { + if (!userHasGlobalStackEdit(req)) { const stackName = req.rawBody ? parseBodyStackName(req.rawBody) : null; if (stackName === undefined) { // Body is non-empty but not valid JSON; client error, not auth. @@ -609,21 +606,12 @@ export function createRemoteProxyMiddleware(): RequestHandler { return; } if (stackName) { - const evidenceSupported = await remoteAdvertisesCapability( - req.nodeId, - SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, - ); - if (!evidenceSupported) { - res.status(403).json({ - error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, - }); - return; - } + if (!await remoteSupportsScopedStackEdit(req, res, node)) return; if (!checkPermission(req, 'stack:edit', 'stack', stackName)) { res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); return; } - req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] }; + attachScopedStackEditEvidence(req, stackName); } } } @@ -632,61 +620,66 @@ export function createRemoteProxyMiddleware(): RequestHandler { // auto-heal has no pre-existing body buffering, so this gate handles // its own encoding rejection and buffering. if (isAutoHealCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') { - const globalGrantsEdit = - req.user?.role != null - && (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false); - if (!globalGrantsEdit) { - if (hasNonIdentityContentEncoding(req)) { - await drainRequestBody(req); - console.error('[remoteNodeProxy] auto-heal body rejected: compressed encoding'); - res.status(415).json({ - error: 'Compressed request bodies are not supported for remote auto-heal creates', - code: 'encoding_unsupported', - }); - return; - } - try { - req.rawBody = await bufferRequestBody(req, AUTO_HEAL_PROXY_BODY_LIMIT); - } catch (err) { - const status = Number((err as { status?: number }).status); - if (status === 413) { - console.error('[remoteNodeProxy] auto-heal body rejected as too large:', err); - res.status(413).json({ error: 'Auto-heal payload too large', code: 'entity_too_large' }); - return; - } - if (status === 400) { - console.error('[remoteNodeProxy] auto-heal body incomplete:', err); - res.status(400).json({ error: 'Incomplete request body' }); - return; - } - throw err; - } - const stackName = parseBodyStackName(req.rawBody); + if (!userHasGlobalStackEdit(req)) { + const rawBody = await bufferProxyJsonBody(req, res, PROXY_JSON_BODY_LIMIT, { + logPrefix: '[remoteNodeProxy] auto-heal', + encodingError: 'Compressed request bodies are not supported for remote auto-heal creates', + tooLargeError: 'Auto-heal payload too large', + }); + if (!rawBody) return; + req.rawBody = rawBody; + const stackName = parseBodyStackName(rawBody); if (stackName === undefined) { console.error('[remoteNodeProxy] auto-heal body is not valid JSON'); res.status(400).json({ error: 'Request body is not valid JSON' }); return; } if (stackName) { - const evidenceSupported = await remoteAdvertisesCapability( - req.nodeId, - SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, - ); - if (!evidenceSupported) { - res.status(403).json({ - error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, - }); - return; - } + if (!await remoteSupportsScopedStackEdit(req, res, node)) return; if (!checkPermission(req, 'stack:edit', 'stack', stackName)) { res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); return; } - req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] }; + attachScopedStackEditEvidence(req, stackName); } } } + // POST /git-sources/ssh-host-key scoped-evidence gate: stack_name lives in + // the JSON body, so classifyStackApiPath cannot authorize the edit flow. + if (isSshHostKeyProbeRoute(req)) { + const rawBody = await bufferProxyJsonBody(req, res, PROXY_JSON_BODY_LIMIT, { + logPrefix: '[remoteNodeProxy] ssh-host-key', + encodingError: 'Compressed request bodies are not supported for remote SSH host key probes', + tooLargeError: 'SSH host key probe payload too large', + }); + if (!rawBody) return; + req.rawBody = rawBody; + const stackName = parseBodyStackName(rawBody); + if (stackName === undefined) { + console.error('[remoteNodeProxy] ssh-host-key body is not valid JSON'); + res.status(400).json({ error: 'Request body is not valid JSON' }); + return; + } + if (stackName) { + if (!checkPermission(req, 'stack:edit', 'stack', stackName)) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + if ( + req.user?.role !== 'admin' + && req.user?.role !== 'node-admin' + && !userHasGlobalStackEdit(req) + ) { + if (!await remoteSupportsScopedStackEdit(req, res, node)) return; + attachScopedStackEditEvidence(req, stackName); + } + } else if (!checkPermission(req, 'stack:create')) { + res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' }); + return; + } + } + // Node-wide image refresh elevation gate: when a non-admin, non-node-admin // user triggers a manual refresh on a remote node, check the hub-side // scoped node:manage grant and elevate PROXY_ROLE_HEADER so the remote @@ -812,17 +805,22 @@ function isAlertCreateRoute(req: Request): boolean { return req.method === 'POST' && /^\/alerts\/?$/.test(req.path); } -/** Same default as express.json(); remote alert creates must not exceed it. */ -const ALERT_PROXY_BODY_LIMIT = 100 * 1024; +/** Max request body size for buffered JSON proxy gates (same as express.json()). */ +const PROXY_JSON_BODY_LIMIT = 100 * 1024; -/** Same limit for auto-heal policy creates. */ -const AUTO_HEAL_PROXY_BODY_LIMIT = 100 * 1024; +/** Same default as express.json(); remote alert creates must not exceed it. */ +const ALERT_PROXY_BODY_LIMIT = PROXY_JSON_BODY_LIMIT; /** POST /auto-heal/policies (path is post-/api strip). */ function isAutoHealCreateRoute(req: Request): boolean { return req.method === 'POST' && /^\/auto-heal\/policies\/?$/.test(req.path); } +/** POST /git-sources/ssh-host-key (path is post-/api strip). */ +function isSshHostKeyProbeRoute(req: Request): boolean { + return req.method === 'POST' && /^\/git-sources\/ssh-host-key\/?$/.test(req.path); +} + /** POST /image-updates/refresh with no stack-name segment (node-wide, not per-stack). */ function isImageRefreshNodeWide(req: Request): boolean { return req.method === 'POST' && /^\/image-updates\/refresh\/?$/.test(req.path); @@ -849,6 +847,62 @@ function parseBodyStackName(rawBody: Buffer): string | null | undefined { /** Max time to wait for leftover body bytes after a size/encoding reject. */ const DRAIN_TIMEOUT_MS = 5_000; +function userHasGlobalStackEdit(req: Request): boolean { + return req.user?.role != null + && (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false); +} + +async function remoteSupportsScopedStackEdit( + req: Request, + res: Response, + node: { name: string }, +): Promise { + const evidenceSupported = await remoteAdvertisesCapability( + req.nodeId, + SCOPED_STACK_AUTH_EVIDENCE_CAPABILITY, + ); + if (!evidenceSupported) { + res.status(403).json({ + error: `Remote node "${node.name}" does not support scoped stack authorization. Upgrade it before scoped users can act on it.`, + }); + return false; + } + return true; +} + +function attachScopedStackEditEvidence(req: Request, stackName: string): void { + req.proxyScopedStackEvidence = { stackName, actions: ['stack:edit'] }; +} + +async function bufferProxyJsonBody( + req: Request, + res: Response, + limit: number, + labels: { logPrefix: string; encodingError: string; tooLargeError: string }, +): Promise { + if (hasNonIdentityContentEncoding(req)) { + await drainRequestBody(req); + res.status(415).json({ error: labels.encodingError, code: 'encoding_unsupported' }); + return null; + } + try { + return await bufferRequestBody(req, limit); + } catch (err) { + const status = Number((err as { status?: number }).status); + if (status === 413) { + console.error(`${labels.logPrefix} body rejected as too large:`, err); + res.status(413).json({ error: labels.tooLargeError, code: 'entity_too_large' }); + return null; + } + if (status === 400) { + console.error(`${labels.logPrefix} body incomplete:`, err); + res.status(400).json({ error: 'Incomplete request body' }); + return null; + } + throw err; + } +} + /** Error with HTTP status for the alert-body gate catch mapper. */ function alertBodyError(message: string, status: number): Error { return Object.assign(new Error(message), { status, expose: true }); diff --git a/backend/src/routes/gitSources.ts b/backend/src/routes/gitSources.ts index 852039a7..5d83dba1 100644 --- a/backend/src/routes/gitSources.ts +++ b/backend/src/routes/gitSources.ts @@ -17,6 +17,7 @@ import { sendGitSourceError, webhookPullStatus } from '../utils/gitSourceHttp'; import { sanitizeForLog } from '../utils/safeLog'; import { repoUrlRejectionMessage } from '../services/gitops/repoIdentity'; import { REF_MAX_LEN } from '../services/git/nativeGitTransport'; +import { auditActorUsername } from '../helpers/auditActor'; // Reasonable upper bounds so a caller cannot flood the service with huge // payloads. Generous compared to anything a real Git provider emits. @@ -32,8 +33,16 @@ const MAX_TOKEN_LENGTH = 8192; * is reused when the request omits a token, so the edit-mode flow does not force * re-entering a stored PAT. */ -async function handleBrowse(req: Request, res: Response, storedToken: string | null): Promise { - const { repo_url, branch, auth_type, token } = req.body ?? {}; +const MAX_DEPLOY_KEY_LENGTH = 16384; + +async function handleBrowse( + req: Request, + res: Response, + storedToken: string | null, + storedDeployKey: string | null, + storedKnownHosts: string | null, +): Promise { + const { repo_url, branch, auth_type, token, deploy_key, ssh_known_hosts_entry } = req.body ?? {}; if (typeof repo_url !== 'string' || !repo_url.trim()) { res.status(400).json({ error: 'repo_url is required' }); return; @@ -51,22 +60,43 @@ async function handleBrowse(req: Request, res: Response, storedToken: string | n res.status(400).json({ error: 'The branch, tag, or commit SHA is too long.' }); return; } - if (auth_type !== undefined && auth_type !== 'none' && auth_type !== 'token') { - res.status(400).json({ error: 'auth_type must be "none" or "token"' }); + if (auth_type !== undefined && auth_type !== 'none' && auth_type !== 'token' && auth_type !== 'deploy_key') { + res.status(400).json({ error: 'auth_type must be "none", "token", or "deploy_key"' }); return; } if (typeof token === 'string' && token.length > MAX_TOKEN_LENGTH) { res.status(400).json({ error: 'token is too long' }); return; } + if (typeof deploy_key === 'string' && deploy_key.length > MAX_DEPLOY_KEY_LENGTH) { + res.status(400).json({ error: 'deploy_key is too long' }); + return; + } const explicitToken = typeof token === 'string' && token.trim() ? token : null; - const effectiveToken = auth_type === 'none' ? null : (explicitToken ?? storedToken); + const effectiveToken = auth_type === 'token' ? (explicitToken ?? storedToken) : null; + const explicitDeployKey = typeof deploy_key === 'string' && deploy_key.trim() ? deploy_key : null; + const effectiveDeployKey = auth_type === 'deploy_key' ? (explicitDeployKey ?? storedDeployKey) : null; + const effectiveKnownHosts = auth_type === 'deploy_key' + ? (typeof ssh_known_hosts_entry === 'string' && ssh_known_hosts_entry.trim() + ? ssh_known_hosts_entry.trim() + : storedKnownHosts) + : null; + const listParams: { + repoUrl: string; + branch: string; + token?: string | null; + sshAuth?: { privateKey: string; knownHostsEntry: string }; + } = { + repoUrl: repo_url.trim(), + branch: branch.trim(), + }; + if (auth_type === 'token') { + listParams.token = effectiveToken; + } else if (auth_type === 'deploy_key' && effectiveDeployKey && effectiveKnownHosts) { + listParams.sshAuth = { privateKey: effectiveDeployKey, knownHostsEntry: effectiveKnownHosts }; + } try { - const result = await GitSourceService.getInstance().listRepoTree({ - repoUrl: repo_url.trim(), - branch: branch.trim(), - token: effectiveToken, - }); + const result = await GitSourceService.getInstance().listRepoTree(listParams); res.json(result); } catch (error) { sendGitSourceError(res, error); @@ -76,6 +106,44 @@ async function handleBrowse(req: Request, res: Response, storedToken: string | n /** Router for listing git-source configuration: `GET /api/git-sources`. */ export const gitSourcesRouter = Router(); +gitSourcesRouter.post('/ssh-host-key', async (req: Request, res: Response): Promise => { + const { repo_url, stack_name } = req.body ?? {}; + if (typeof stack_name === 'string' && stack_name.trim()) { + if (!isValidStackName(stack_name.trim())) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:edit', 'stack', stack_name.trim())) return; + } else if (!requirePermission(req, res, 'stack:create')) { + return; + } + if (typeof repo_url !== 'string' || !repo_url.trim()) { + res.status(400).json({ error: 'repo_url is required' }); + return; + } + const repoUrlError = repoUrlRejectionMessage(repo_url); + if (repoUrlError) { + res.status(400).json({ error: repoUrlError }); + return; + } + try { + const { parseSshUrl, scanHostKeys } = await import('../services/git/sshTrust'); + const parsed = parseSshUrl(repo_url.trim()); + if (!parsed) { + res.status(400).json({ error: 'Host key probe requires an SSH repository URL' }); + return; + } + const keys = await scanHostKeys(parsed.host, parsed.port); + res.json({ + host: parsed.host, + port: parsed.port, + keys, + }); + } catch (error) { + sendGitSourceError(res, error); + } +}); + gitSourcesRouter.get('/', async (req: Request, res: Response): Promise => { try { const all = GitSourceService.getInstance().list(); @@ -126,7 +194,7 @@ gitSourcesRouter.get('/history', async (req: Request, res: Response): Promise => { if (!requirePermission(req, res, 'stack:create')) return; - await handleBrowse(req, res, null); + await handleBrowse(req, res, null, null, null); }); /** @@ -220,6 +288,9 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res env_path, auth_type, token, + deploy_key, + ssh_known_hosts_entry, + ssh_host_key_fingerprint, auto_apply_on_webhook, auto_deploy_on_apply, } = req.body ?? {}; @@ -237,8 +308,8 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res res.status(400).json({ error: selection.error }); return; } - if (auth_type !== 'none' && auth_type !== 'token') { - res.status(400).json({ error: 'auth_type must be "none" or "token"' }); + if (auth_type !== 'none' && auth_type !== 'token' && auth_type !== 'deploy_key') { + res.status(400).json({ error: 'auth_type must be "none", "token", or "deploy_key"' }); return; } if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') { @@ -270,6 +341,10 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res res.status(400).json({ error: 'token is too long' }); return; } + if (typeof deploy_key === 'string' && deploy_key.length > MAX_DEPLOY_KEY_LENGTH) { + res.status(400).json({ error: 'deploy_key is too long' }); + return; + } const autoApplyOnWebhook = auto_apply_on_webhook === true; const autoDeployOnApply = auto_deploy_on_apply === true; if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; @@ -298,8 +373,17 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res envPath: resolvedEnvPath, authType: auth_type, token: typeof token === 'string' ? token : undefined, + deployKey: typeof deploy_key === 'string' ? deploy_key : undefined, + sshKnownHostsEntry: typeof ssh_known_hosts_entry === 'string' ? ssh_known_hosts_entry : undefined, + sshHostKeyFingerprint: typeof ssh_host_key_fingerprint === 'string' ? ssh_host_key_fingerprint : undefined, autoApplyOnWebhook, autoDeployOnApply, + auditContext: { + username: auditActorUsername(req), + method: req.method, + path: req.originalUrl, + ipAddress: req.ip || 'unknown', + }, }); // The cached /stacks/statuses payload carries the source label; drop it @@ -488,5 +572,7 @@ stackGitSourceRouter.post('/:stackName/git-source/browse', async (req: Request, if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return; const src = DatabaseService.getInstance().getGitSource(stackName); const storedToken = src?.encrypted_token ? CryptoService.getInstance().decrypt(src.encrypted_token) : null; - await handleBrowse(req, res, storedToken); + const storedDeployKey = src?.encrypted_deploy_key ? CryptoService.getInstance().decrypt(src.encrypted_deploy_key) : null; + const storedKnownHosts = src?.ssh_known_hosts_entry ?? null; + await handleBrowse(req, res, storedToken, storedDeployKey, storedKnownHosts); }); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 8c4f425a..0479b9ca 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -62,6 +62,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describeP import { parseComposePreview, type ComposePreview } from '../helpers/composePreview'; import { filterContainersByComposeService } from '../helpers/composeServiceMatch'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; +import { auditActorUsername } from '../helpers/auditActor'; import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache'; import { ImageUpdateService, @@ -1084,6 +1085,9 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { env_path, auth_type, token, + deploy_key, + ssh_known_hosts_entry, + ssh_host_key_fingerprint, auto_apply_on_webhook, auto_deploy_on_apply, deploy_now, @@ -1113,7 +1117,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { if (auto_deploy_on_apply !== undefined && typeof auto_deploy_on_apply !== 'boolean') { return res.status(400).json({ error: 'auto_deploy_on_apply must be a boolean' }); } - const resolvedAuthType = auth_type === 'token' ? 'token' : 'none'; + const resolvedAuthType = auth_type === 'token' ? 'token' : auth_type === 'deploy_key' ? 'deploy_key' : 'none'; const repoUrlError = repoUrlRejectionMessage(repo_url); if (repoUrlError) { return res.status(400).json({ error: repoUrlError }); @@ -1124,6 +1128,9 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { if (typeof env_path === 'string' && env_path.length > 1024) { return res.status(400).json({ error: 'env_path is too long' }); } + if (typeof deploy_key === 'string' && deploy_key.length > 16384) { + return res.status(400).json({ error: 'deploy_key is too long' }); + } if (typeof token === 'string' && token.length > 8192) { return res.status(400).json({ error: 'token is too long' }); } @@ -1161,8 +1168,21 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { envPath: resolvedEnvPath, authType: resolvedAuthType, token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null, + deployKey: resolvedAuthType === 'deploy_key' && typeof deploy_key === 'string' && deploy_key !== '' ? deploy_key : null, + sshKnownHostsEntry: resolvedAuthType === 'deploy_key' && typeof ssh_known_hosts_entry === 'string' + ? ssh_known_hosts_entry + : null, + sshHostKeyFingerprint: resolvedAuthType === 'deploy_key' && typeof ssh_host_key_fingerprint === 'string' + ? ssh_host_key_fingerprint + : null, autoApplyOnWebhook, autoDeployOnApply, + auditContext: { + username: auditActorUsername(req), + method: req.method, + path: req.originalUrl, + ipAddress: req.ip || 'unknown', + }, }); invalidateNodeCaches(req.nodeId); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index b6414990..ca858cc6 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -429,7 +429,7 @@ export interface Webhook { updated_at: number; } -export type GitSourceAuthType = 'none' | 'token'; +export type GitSourceAuthType = 'none' | 'token' | 'deploy_key'; /** * The ordered set of local compose files actually materialized on disk for a @@ -457,6 +457,9 @@ export interface StackGitSource { env_path: string | null; auth_type: GitSourceAuthType; encrypted_token: string | null; + encrypted_deploy_key: string | null; + ssh_known_hosts_entry: string | null; + ssh_host_key_fingerprint: string | null; auto_apply_on_webhook: boolean; auto_deploy_on_apply: boolean; last_applied_commit_sha: string | null; @@ -1169,9 +1172,11 @@ export class DatabaseService { this.migrateFleetSyncStickyError(); this.migrateStackDossierHashes(); this.migrateGitSourceMultiFile(); + this.migrateGitSourceSshDeployKey(); this.migrateGitSourceManifest(); this.migrateGitSourceChangePlan(); this.migrateGitOpsRecoveryColumns(); + this.migrateGitOpsCreateCheckpointSshDeployKey(); this.migrateNodeUpdateSkips(); this.migrateStackAlertServiceScope(); @@ -2574,6 +2579,12 @@ stmt.run('gitops_schema_version', '1'); this.tryAddColumn('stack_dossiers', 'last_drift_check_at', 'INTEGER'); } + private migrateGitSourceSshDeployKey(): void { + this.tryAddColumn('stack_git_sources', 'encrypted_deploy_key', 'TEXT'); + this.tryAddColumn('stack_git_sources', 'ssh_known_hosts_entry', 'TEXT'); + this.tryAddColumn('stack_git_sources', 'ssh_host_key_fingerprint', 'TEXT'); + } + private migrateGitSourceManifest(): void { // Cache columns for the managed-project manifest (the manifest FILE in // /git-managed/// is the source of truth). @@ -2596,6 +2607,12 @@ stmt.run('gitops_schema_version', '1'); this.tryAddColumn('stack_update_recovery_generations', 'gitops_source_acceptance_ref', 'TEXT'); } + private migrateGitOpsCreateCheckpointSshDeployKey(): void { + this.tryAddColumn('gitops_create_checkpoints', 'encrypted_deploy_key', 'TEXT'); + this.tryAddColumn('gitops_create_checkpoints', 'ssh_known_hosts_entry', 'TEXT'); + this.tryAddColumn('gitops_create_checkpoints', 'ssh_host_key_fingerprint', 'TEXT'); + } + private migrateGitSourceMultiFile(): void { this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT'); this.tryAddColumn('stack_git_sources', 'context_dir', 'TEXT'); @@ -6275,6 +6292,9 @@ stmt.run('gitops_schema_version', '1'); env_path: (row.env_path as string | null) ?? null, auth_type: row.auth_type as GitSourceAuthType, encrypted_token: (row.encrypted_token as string | null) ?? null, + encrypted_deploy_key: (row.encrypted_deploy_key as string | null) ?? null, + ssh_known_hosts_entry: (row.ssh_known_hosts_entry as string | null) ?? null, + ssh_host_key_fingerprint: (row.ssh_host_key_fingerprint as string | null) ?? null, auto_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1, auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 1, last_applied_commit_sha: (row.last_applied_commit_sha as string | null) ?? null, @@ -6315,14 +6335,16 @@ stmt.run('gitops_schema_version', '1'); `UPDATE stack_git_sources SET repo_url = ?, branch = ?, compose_path = ?, compose_paths = ?, context_dir = ?, sync_env = ?, env_path = ?, - auth_type = ?, encrypted_token = ?, + auth_type = ?, encrypted_token = ?, encrypted_deploy_key = ?, + ssh_known_hosts_entry = ?, ssh_host_key_fingerprint = ?, auto_apply_on_webhook = ?, auto_deploy_on_apply = ?, updated_at = ? WHERE stack_name = ?` ).run( source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir, source.sync_env ? 1 : 0, source.env_path, - source.auth_type, source.encrypted_token, + source.auth_type, source.encrypted_token, source.encrypted_deploy_key, + source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint, source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0, now, source.stack_name ); @@ -6331,13 +6353,15 @@ stmt.run('gitops_schema_version', '1'); const result = this.db.prepare( `INSERT INTO stack_git_sources (stack_name, repo_url, branch, compose_path, compose_paths, context_dir, sync_env, env_path, - auth_type, encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply, + auth_type, encrypted_token, encrypted_deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint, + auto_apply_on_webhook, auto_deploy_on_apply, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir, source.sync_env ? 1 : 0, source.env_path, - source.auth_type, source.encrypted_token, + source.auth_type, source.encrypted_token, source.encrypted_deploy_key, + source.ssh_known_hosts_entry, source.ssh_host_key_fingerprint, source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0, now, now ); diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index a9734ecd..5363719e 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -29,8 +29,9 @@ import type { GitChangePlan, PublicGitChangePlan, GitChangePlanCounts, PublicGit import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan'; import type { NotificationCategory } from './NotificationService'; import { classifyGitFailure, isTransportFailure } from './git/errors'; -import type { RefKind } from './git/types'; +import type { RefKind, SshDeployKeyAuth } from './git/types'; import { nativeGitTransport, verifyFastForward } from './git/nativeGitTransport'; +import { fingerprintFromKnownHostsLine } from './git/sshTrust'; import { GitOpsStore } from './gitops/store'; import { GitOpsTransitions, GitOpsTransitionError } from './gitops/transitions'; import { @@ -62,6 +63,7 @@ export type GitSourceErrorCode = | 'REF_NOT_FOUND' | 'REF_DELETED' | 'UNSUPPORTED_REF' + | 'SSH_HOST_KEY_FAILED' | 'FILE_NOT_FOUND' | 'NETWORK_TIMEOUT' | 'GIT_ERROR' @@ -107,6 +109,7 @@ export interface FetchParams { composePaths: string[]; envPath?: string | null; token?: string | null; + sshAuth?: SshDeployKeyAuth | null; timeoutMs?: number; /** * Runs inside the clone lifecycle (before the temp dir is removed) so the @@ -165,8 +168,17 @@ export interface UpsertInput { envPath: string | null; authType: GitSourceAuthType; token?: string | null; // undefined = keep existing, '' = clear, non-empty = replace + deployKey?: string | null; + sshKnownHostsEntry?: string | null; + sshHostKeyFingerprint?: string | null; autoApplyOnWebhook: boolean; autoDeployOnApply: boolean; + auditContext?: { + username: string; + method: string; + path: string; + ipAddress: string; + }; } export interface CreateStackFromGitInput { @@ -179,8 +191,17 @@ export interface CreateStackFromGitInput { envPath: string | null; authType: GitSourceAuthType; token: string | null; + deployKey?: string | null; + sshKnownHostsEntry?: string | null; + sshHostKeyFingerprint?: string | null; autoApplyOnWebhook: boolean; autoDeployOnApply: boolean; + auditContext?: { + username: string; + method: string; + path: string; + ipAddress: string; + }; } export interface CreateStackFromGitResult { @@ -224,6 +245,8 @@ export interface PublicGitSource { env_path: string | null; auth_type: GitSourceAuthType; has_token: boolean; + has_deploy_key: boolean; + ssh_host_key_fingerprint: string | null; auto_apply_on_webhook: boolean; auto_deploy_on_apply: boolean; last_applied_commit_sha: string | null; @@ -542,6 +565,8 @@ export class GitSourceService { env_path: src.env_path, auth_type: src.auth_type, has_token: !!src.encrypted_token, + has_deploy_key: !!src.encrypted_deploy_key, + ssh_host_key_fingerprint: src.ssh_host_key_fingerprint ?? null, auto_apply_on_webhook: src.auto_apply_on_webhook, auto_deploy_on_apply: src.auto_deploy_on_apply, last_applied_commit_sha: src.last_applied_commit_sha, @@ -567,21 +592,126 @@ export class GitSourceService { // ─── CRUD ──────────────────────────────────────────────────────────────── + private resolveSshTrustFromKnownHostsEntry( + knownHostsEntry: string, + clientFingerprint?: string | null, + ): { sshKnownHostsEntry: string; sshHostKeyFingerprint: string } { + const sshKnownHostsEntry = knownHostsEntry.trim(); + const derived = fingerprintFromKnownHostsLine(sshKnownHostsEntry); + if (!derived) { + throw new GitSourceError('GIT_ERROR', 'SSH known_hosts entry is invalid or incomplete.'); + } + const trimmedClient = clientFingerprint?.trim(); + if (trimmedClient && trimmedClient !== derived) { + throw new GitSourceError('GIT_ERROR', 'SSH host key fingerprint does not match the trusted key entry.'); + } + return { sshKnownHostsEntry, sshHostKeyFingerprint: derived }; + } + + private recordSshTrustAudit(args: { + stackName: string; + username: string; + method: string; + path: string; + ipAddress: string; + fingerprint: string; + action: 'created' | 'rotated'; + }): void { + try { + DatabaseService.getInstance().insertAuditLog({ + timestamp: Date.now(), + username: args.username, + method: args.method, + path: args.path, + status_code: 200, + node_id: null, + ip_address: args.ipAddress, + summary: `git_source.ssh_trust_${args.action}: stack=${args.stackName} fingerprint=${args.fingerprint}`, + }); + } catch (err) { + console.warn('[GitSource] SSH trust audit write failed:', sanitizeForLog(String(err))); + } + } + + private maybeRecordSshTrustAudit( + auditContext: UpsertInput['auditContext'], + stackName: string, + fingerprint: string, + action: 'created' | 'rotated', + ): void { + if (!auditContext) return; + this.recordSshTrustAudit({ ...auditContext, stackName, fingerprint, action }); + } + + private resolveTransportAuth(src: Pick): { + token?: string | null; + sshAuth?: SshDeployKeyAuth | null; + } { + if (src.auth_type === 'token') { + return { token: src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null }; + } + if (src.auth_type === 'deploy_key') { + if (!src.encrypted_deploy_key || !src.ssh_known_hosts_entry) { + return { sshAuth: null }; + } + return { + sshAuth: { + privateKey: this.crypto.decrypt(src.encrypted_deploy_key), + knownHostsEntry: src.ssh_known_hosts_entry, + }, + }; + } + return { token: null }; + } + public async upsert(input: UpsertInput): Promise { const db = DatabaseService.getInstance(); const existing = db.getGitSource(input.stackName); - // Determine the stored token. - let encryptedToken: string | null; + // Determine stored credentials per auth type. + let encryptedToken: string | null = null; + let encryptedDeployKey: string | null = null; + let sshKnownHostsEntry: string | null = null; + let sshHostKeyFingerprint: string | null = null; + if (input.authType === 'none') { - encryptedToken = null; - } else if (input.token === undefined) { - // Keep existing - encryptedToken = existing?.encrypted_token ?? null; - } else if (input.token === null || input.token === '') { - encryptedToken = null; - } else { - encryptedToken = this.crypto.encrypt(input.token); + // all null + } else if (input.authType === 'token') { + if (input.token === undefined) { + encryptedToken = existing?.encrypted_token ?? null; + } else if (input.token === null || input.token === '') { + encryptedToken = null; + } else { + encryptedToken = this.crypto.encrypt(input.token); + } + } else if (input.authType === 'deploy_key') { + if (input.deployKey === undefined) { + encryptedDeployKey = existing?.encrypted_deploy_key ?? null; + } else if (input.deployKey === null || input.deployKey === '') { + encryptedDeployKey = null; + } else { + encryptedDeployKey = this.crypto.encrypt(input.deployKey); + } + if (input.sshKnownHostsEntry === undefined) { + sshKnownHostsEntry = existing?.ssh_known_hosts_entry ?? null; + sshHostKeyFingerprint = existing?.ssh_host_key_fingerprint ?? null; + } else if (input.sshKnownHostsEntry === null || input.sshKnownHostsEntry.trim() === '') { + sshKnownHostsEntry = null; + sshHostKeyFingerprint = null; + } else { + const trust = this.resolveSshTrustFromKnownHostsEntry( + input.sshKnownHostsEntry, + input.sshHostKeyFingerprint, + ); + sshKnownHostsEntry = trust.sshKnownHostsEntry; + sshHostKeyFingerprint = trust.sshHostKeyFingerprint; + } + if (!encryptedDeployKey || !sshKnownHostsEntry) { + throw new GitSourceError( + 'GIT_ERROR', + 'Deploy key authentication requires a private key and a trusted SSH host key.', + ); + } } // Apply-matrix sanity: auto_deploy requires auto_apply. @@ -609,13 +739,22 @@ export class GitSourceService { // Dry-run reachability check before persisting. Fetches every configured // file so a bad path in the ordered list is caught at save time. - const token = encryptedToken ? this.crypto.decrypt(encryptedToken) : null; + const fetchAuth = input.authType === 'token' + ? { token: encryptedToken ? this.crypto.decrypt(encryptedToken) : null } + : input.authType === 'deploy_key' + ? { + sshAuth: { + privateKey: this.crypto.decrypt(encryptedDeployKey!), + knownHostsEntry: sshKnownHostsEntry!, + }, + } + : { token: null }; await this.fetchFromGit({ repoUrl: input.repoUrl, branch: input.branch, composePaths: input.composePaths, envPath: input.syncEnv ? input.envPath : null, - token, + ...fetchAuth, }); const resolvedEnvPath = input.syncEnv ? input.envPath : null; @@ -657,6 +796,9 @@ export class GitSourceService { env_path: resolvedEnvPath, auth_type: input.authType, encrypted_token: encryptedToken, + encrypted_deploy_key: encryptedDeployKey, + ssh_known_hosts_entry: sshKnownHostsEntry, + ssh_host_key_fingerprint: sshHostKeyFingerprint, auto_apply_on_webhook: input.autoApplyOnWebhook, auto_deploy_on_apply: input.autoDeployOnApply, last_applied_commit_sha: existing?.last_applied_commit_sha ?? null, @@ -714,6 +856,23 @@ export class GitSourceService { } })(); + if ( + input.authType === 'deploy_key' + && input.sshKnownHostsEntry !== undefined + && sshKnownHostsEntry + && sshHostKeyFingerprint + ) { + const priorKnownHosts = existing?.ssh_known_hosts_entry ?? null; + if (priorKnownHosts !== sshKnownHostsEntry) { + this.maybeRecordSshTrustAudit( + input.auditContext, + input.stackName, + sshHostKeyFingerprint, + priorKnownHosts ? 'rotated' : 'created', + ); + } + } + return this.get(input.stackName)!; } @@ -959,13 +1118,14 @@ export class GitSourceService { repoUrl: string; branch: string; token?: string | null; + sshAuth?: SshDeployKeyAuth | null; timeoutMs?: number; hasPriorHistory?: boolean; priorIdentity?: { commitSha: string; kind: RefKind }; }, fn: (dir: string, commitSha: string, warnings: string[], resolvedRefKind: RefKind) => Promise, ): Promise { - const { repoUrl, branch, token } = params; + const { repoUrl, branch, token, sshAuth } = params; const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS; const root = await createTempDir(); const hasPriorHistory = params.hasPriorHistory === true || params.priorIdentity != null; @@ -975,6 +1135,7 @@ export class GitSourceService { repoUrl, ref: branch, token, + sshAuth, timeoutMs, workspaceRoot: root, }); @@ -989,6 +1150,7 @@ export class GitSourceService { ancestorSha: prior.commitSha, descendantSha: resolved.commitSha, token, + sshAuth, timeoutMs, workspaceRoot: root, maxBytes: maxCloneBytes(), @@ -1003,6 +1165,7 @@ export class GitSourceService { ref: branch, refKind: resolved.kind, token, + sshAuth, timeoutMs, commitSha: resolved.commitSha, workspaceRoot: root, @@ -1047,7 +1210,7 @@ export class GitSourceService { } public async fetchFromGit(params: FetchParams): Promise { - const { repoUrl, branch, composePaths, envPath, token } = params; + const { repoUrl, branch, composePaths, envPath, token, sshAuth } = params; // Reject any compose/env target that resolves inside the `.git` // metadata directory BEFORE we spin up a clone. This blocks a @@ -1069,6 +1232,7 @@ export class GitSourceService { repoUrl, branch, token, + sshAuth, timeoutMs: params.timeoutMs, hasPriorHistory: params.hasPriorHistory, priorIdentity: params.priorIdentity, @@ -1138,7 +1302,7 @@ export class GitSourceService { * same clone size/timeout guards as fetch, plus a file-count cap. */ public async listRepoTree( - params: { repoUrl: string; branch: string; token?: string | null; timeoutMs?: number }, + params: { repoUrl: string; branch: string; token?: string | null; sshAuth?: SshDeployKeyAuth | null; timeoutMs?: number }, ): Promise<{ files: string[]; truncated: boolean; commitSha: string; warnings: string[] }> { return this.withClonedRepo(params, async (dir, commitSha, warnings) => { const { files, truncated } = await this.walkRepoFiles(dir); @@ -1811,7 +1975,7 @@ export class GitSourceService { console.log(`[GitSource:diag] pull start stack=${stackName} branch=${src.branch} host=${repoHost(src.repo_url)}`); } - const token = src.encrypted_token ? this.crypto.decrypt(src.encrypted_token) : null; + const transportAuth = this.resolveTransportAuth(src); const manifestSvc = GitProjectManifestService.getInstance(); // Object holder: property access is not narrowed by control-flow // analysis, so the closure assignment below stays visible. @@ -1824,7 +1988,8 @@ export class GitSourceService { branch: src.branch, composePaths: src.compose_paths, envPath: src.sync_env ? src.env_path : null, - token, + token: transportAuth.token, + sshAuth: transportAuth.sshAuth, hasPriorHistory: priorIdentity != null, priorIdentity, onClone: async (cloneDir, commitSha, envContent) => { @@ -2613,19 +2778,47 @@ export class GitSourceService { }); const staged: { candidateRelPath: string | null } = { candidateRelPath: null }; + const createDeployKeyTrust = input.authType === 'deploy_key' + ? (() => { + if (!input.deployKey?.trim() || !input.sshKnownHostsEntry?.trim()) { + throw new GitSourceError( + 'GIT_ERROR', + 'Deploy key authentication requires a private key and a trusted SSH host key.', + ); + } + return { + encryptedDeployKey: this.crypto.encrypt(input.deployKey.trim()), + ...this.resolveSshTrustFromKnownHostsEntry( + input.sshKnownHostsEntry, + input.sshHostKeyFingerprint, + ), + }; + })() + : null; + // 1. Fetch from git BEFORE touching disk or DB. If the fetch // fails there is nothing to clean up. The onClone hook stages // the complete-project candidate inside the clone lifecycle. const manifestSvc = GitProjectManifestService.getInstance(); const materialization: { value: MaterializationResult | null } = { value: null }; let fetched: FetchResult; + const createFetchAuth = input.authType === 'token' + ? { token: input.token } + : createDeployKeyTrust + ? { + sshAuth: { + privateKey: input.deployKey!.trim(), + knownHostsEntry: createDeployKeyTrust.sshKnownHostsEntry, + }, + } + : { token: null }; try { fetched = await this.fetchFromGit({ repoUrl: input.repoUrl, branch: input.branch, composePaths: input.composePaths, envPath: input.syncEnv ? input.envPath : null, - token: input.token, + ...createFetchAuth, onClone: async (cloneDir, commitSha, envContent) => { // The candidate path is recorded before the build that // creates it, so a crash mid-build still names exactly one @@ -2829,6 +3022,9 @@ export class GitSourceService { encryptedToken: input.authType === 'token' && input.token ? this.crypto.encrypt(input.token) : null, + encryptedDeployKey: createDeployKeyTrust?.encryptedDeployKey ?? null, + sshKnownHostsEntry: createDeployKeyTrust?.sshKnownHostsEntry ?? null, + sshHostKeyFingerprint: createDeployKeyTrust?.sshHostKeyFingerprint ?? null, autoApplyOnWebhook: input.autoApplyOnWebhook, autoDeployOnApply: input.autoDeployOnApply, commitSha: fetched.commitSha, @@ -2900,6 +3096,9 @@ export class GitSourceService { env_path: input.syncEnv ? input.envPath : null, auth_type: input.authType, encrypted_token: encryptedToken, + encrypted_deploy_key: createDeployKeyTrust?.encryptedDeployKey ?? null, + ssh_known_hosts_entry: createDeployKeyTrust?.sshKnownHostsEntry ?? null, + ssh_host_key_fingerprint: createDeployKeyTrust?.sshHostKeyFingerprint ?? null, auto_apply_on_webhook: input.autoApplyOnWebhook, auto_deploy_on_apply: input.autoDeployOnApply, last_applied_commit_sha: fetched.commitSha, @@ -2983,6 +3182,14 @@ export class GitSourceService { if (diag) { console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten} warnings=${fetched.warnings.length}`); } + if (createDeployKeyTrust?.sshHostKeyFingerprint) { + this.maybeRecordSshTrustAudit( + input.auditContext, + input.stackName, + createDeployKeyTrust.sshHostKeyFingerprint, + 'created', + ); + } return { source, commitSha: fetched.commitSha, envWritten, warnings: fetched.warnings }; } catch (e) { // Past the success boundary the stack is live and owned by the diff --git a/backend/src/services/git/errors.ts b/backend/src/services/git/errors.ts index 9b223197..0ffeb618 100644 --- a/backend/src/services/git/errors.ts +++ b/backend/src/services/git/errors.ts @@ -18,6 +18,7 @@ export type TransportFacingCode = | 'REPO_NOT_FOUND' | 'AUTH_FAILED' + | 'SSH_HOST_KEY_FAILED' | 'REF_NOT_FOUND' | 'UNSUPPORTED_REF' | 'NETWORK_TIMEOUT' @@ -104,7 +105,7 @@ export function classifyGitFailure( // stderr guessing. switch (failure.reason) { case 'invalid-url': - return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use an https:// URL without embedded credentials.' }; + return { code: 'GIT_ERROR', message: 'Unsupported repository URL. Use https:// or SSH (git@host:org/repo.git or ssh://) without embedded credentials.' }; case 'invalid-ref': return { code: 'GIT_ERROR', message: 'Unsupported ref name. Use a branch name, a tag name, or a full commit SHA as the remote reports it.' }; case 'git-missing': @@ -141,6 +142,20 @@ export function classifyGitFailure( message: PRIVATE_REPO_HINT, }; } + if (/host key verification failed|remotely changed the ssh host key|no matching host key found|offending key for ip|host key mismatch/.test(raw)) { + return { + code: 'SSH_HOST_KEY_FAILED', + message: 'SSH host key verification failed. The server key changed or is not trusted. Review the fingerprint and update host trust if you intend to accept the new key.', + }; + } + if (/permission denied \(publickey|publickey denied|no supported authentication methods/.test(raw)) { + return failure.hasToken + ? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your deploy key or token.' } + : { + code: 'REPO_NOT_FOUND', + message: PRIVATE_REPO_HINT, + }; + } if (/authentication failed|\b40[13]\b/.test(raw)) { return failure.hasToken ? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your token.' } diff --git a/backend/src/services/git/nativeGitTransport.ts b/backend/src/services/git/nativeGitTransport.ts index 77900d71..9b740a32 100644 --- a/backend/src/services/git/nativeGitTransport.ts +++ b/backend/src/services/git/nativeGitTransport.ts @@ -11,6 +11,12 @@ import { } from './credentialHelper'; import { isTransportFailure, type TransportFailure } from './errors'; import type { FetchRequest, FetchResult, GitTransport, ResolveRequest, ResolveResult } from './types'; +import { + buildSshCommand, + parseRepoTransportUrl, + type ParsedRepoUrl, +} from './sshTrust'; +import { writeDeployKey, writeKnownHosts } from './sshCredentialFiles'; /** * Native git transport: every Git operation is an `execFile`-style spawn of @@ -261,7 +267,12 @@ async function prepareWorkspace(root: string): Promise { return { metaDir, hooksDir, homeDir }; } -function buildEnv(homeDir: string, token?: string | null, helperPath?: string | null): NodeJS.ProcessEnv { +function buildEnv( + homeDir: string, + token?: string | null, + helperPath?: string | null, + sshCommand?: string | null, +): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', @@ -290,6 +301,9 @@ function buildEnv(homeDir: string, token?: string | null, helperPath?: string | // parses it. See credentialHelper.ts. env[GIT_HELPER_PATH_ENV_VAR] = helperPath; } + if (sshCommand) { + env.GIT_SSH_COMMAND = sshCommand; + } return env; } @@ -395,12 +409,16 @@ async function resolveCaArgs(layout: WorkspaceLayout): Promise { * Config shared by every invocation. With no helper, credential.helper is * explicitly cleared so nothing from the environment can answer prompts. */ -async function commonArgs(layout: WorkspaceLayout, helperPath: string | null): Promise { +async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ssh: boolean): Promise { const args = [ '-c', 'protocol.allow=never', - '-c', 'protocol.https.allow=always', - '-c', `core.hooksPath=${layout.hooksDir.split(path.sep).join('/')}`, ]; + if (ssh) { + args.push('-c', 'protocol.ssh.allow=always'); + } else { + args.push('-c', 'protocol.https.allow=always'); + } + args.push('-c', `core.hooksPath=${layout.hooksDir.split(path.sep).join('/')}`); if (process.platform === 'win32') { // With every config channel neutralized above, git falls back to its // build-default TLS backend, which on Git for Windows can be @@ -438,13 +456,18 @@ async function commonArgs(layout: WorkspaceLayout, helperPath: string | null): P async function prepareInvocation( workspaceRoot: string, token?: string | null, + sshAuth?: ResolveRequest['sshAuth'], ): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[] }> { const layout = await prepareWorkspace(workspaceRoot); + let sshCommand: string | null = null; + if (sshAuth) { + const keyPath = await writeDeployKey(layout.metaDir, sshAuth.privateKey); + const knownPath = await writeKnownHosts(layout.metaDir, sshAuth.knownHostsEntry); + sshCommand = buildSshCommand(keyPath, knownPath); + } const helperPath = token ? await writeCredentialHelper(layout.metaDir) : null; - const env = buildEnv(layout.homeDir, token, helperPath); - // The same helperPath drives the env export and the config arg, so the two - // cannot describe different worlds. - const baseArgs = await commonArgs(layout, helperPath); + const env = buildEnv(layout.homeDir, token, helperPath, sshCommand); + const baseArgs = await commonArgs(layout, helperPath, Boolean(sshAuth)); return { layout, env, baseArgs }; } @@ -454,17 +477,19 @@ function invalidUrl(host: string, hasToken: boolean): TransportFailure { return { transportFailure: true as const, reason: 'invalid-url', host, hasToken }; } -function assertValidRepoUrl(repoUrl: string, hasToken: boolean): URL { - let url: URL; - try { - url = new URL(repoUrl); - } catch { +function assertValidRepoUrl(repoUrl: string, hasToken: boolean): ParsedRepoUrl { + const parsed = parseRepoTransportUrl(repoUrl); + if (!parsed) { throw invalidUrl('unknown', hasToken); } - if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) { - throw invalidUrl(url.host || 'unknown', hasToken); + return parsed; +} + +function repoHostLabel(repo: ParsedRepoUrl): string { + if (repo.kind === 'ssh' && repo.port && repo.port !== 22) { + return `${repo.host}:${repo.port}`; } - return url; + return repo.host; } /** @@ -604,29 +629,28 @@ interface ResolvedRemoteRefs { * tag's raw line already points at the commit. */ async function lsRemoteRefs( - url: URL, + repo: ParsedRepoUrl, ref: string, env: NodeJS.ProcessEnv, baseArgs: string[], timeoutMs: number, hasToken: boolean, ): Promise { + const host = repoHostLabel(repo); let res: RunResult; try { res = await runGit( - [...baseArgs, 'ls-remote', url.href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`], + [...baseArgs, 'ls-remote', repo.href, `refs/heads/${ref}`, `refs/tags/${ref}`, `refs/tags/${ref}^{}`], { env, timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS) }, ); } catch (e) { - // A resolution-phase timeout must classify like any other network - // timeout, not leak the internal flagged error to callers. if (isTimeoutError(e)) { - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure; } throw e; } if (res.exitCode !== 0) { - throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: baseArgs, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: baseArgs, host, hasToken } satisfies TransportFailure; } const found: ResolvedRemoteRefs = { branchSha: null, tagSha: null }; for (const line of res.stdout.split(/\r?\n/)) { @@ -667,6 +691,7 @@ export async function verifyFastForward(req: { ancestorSha: string; descendantSha: string; token?: string | null; + sshAuth?: ResolveRequest['sshAuth']; timeoutMs?: number; workspaceRoot: string; maxBytes: number; @@ -675,18 +700,19 @@ export async function verifyFastForward(req: { const descendant = req.descendantSha.toLowerCase(); if (ancestor === descendant) return true; - const hasToken = Boolean(req.token); + const hasToken = Boolean(req.token) || Boolean(req.sshAuth); await ensureBinaryReady(hasToken); - const url = assertValidRepoUrl(req.repoUrl, hasToken); + const repo = assertValidRepoUrl(req.repoUrl, hasToken); + const host = repoHostLabel(repo); const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS; const deadline = Date.now() + timeoutMs; const remainingMs = (): number => Math.max(1, deadline - Date.now()); const assertTimeBudget = (): void => { if (Date.now() >= deadline) { - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host, hasToken } satisfies TransportFailure; } }; - const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token); + const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth); const repoDir = path.join(req.workspaceRoot, 'ff-check'); await fs.mkdir(repoDir, { recursive: true }); @@ -700,7 +726,7 @@ export async function verifyFastForward(req: { const throwIfSizeExceeded = (): void => { if (sizeExceeded) { - throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } }; @@ -718,13 +744,13 @@ export async function verifyFastForward(req: { } catch (e) { throwIfSizeExceeded(); if (isTimeoutError(e)) { - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } - throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } throwIfSizeExceeded(); if (res.exitCode !== 0) { - throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } return res; }; @@ -736,7 +762,7 @@ export async function verifyFastForward(req: { stderr: res.stderr, exitCode: res.exitCode, argv, - host: url.host, + host: repoHostLabel(repo), hasToken, } satisfies TransportFailure; }; @@ -754,14 +780,14 @@ export async function verifyFastForward(req: { } catch (e) { throwIfSizeExceeded(); if (isTimeoutError(e)) { - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, - host: url.host, + host: repoHostLabel(repo), hasToken, } satisfies TransportFailure; } @@ -777,7 +803,7 @@ export async function verifyFastForward(req: { }; await materialize([...baseArgs, 'init']); - await materialize([...baseArgs, 'fetch', '--depth=1', url.href, descendant]); + await materialize([...baseArgs, 'fetch', '--depth=1', repo.href, descendant]); const countReachable = async (): Promise => { const argv = [...baseArgs, 'rev-list', '--count', descendant]; @@ -793,7 +819,7 @@ export async function verifyFastForward(req: { stderr: `unexpected rev-list output: ${listed.stdout}`, exitCode: listed.exitCode, argv, - host: url.host, + host: repoHostLabel(repo), hasToken, } satisfies TransportFailure; } @@ -815,7 +841,7 @@ export async function verifyFastForward(req: { stderr: `unexpected shallow-repository output: ${shallow.stdout}`, exitCode: shallow.exitCode, argv, - host: url.host, + host: repoHostLabel(repo), hasToken, } satisfies TransportFailure; }; @@ -838,7 +864,7 @@ export async function verifyFastForward(req: { return -1; }); if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) { - throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } }; @@ -861,11 +887,11 @@ export async function verifyFastForward(req: { } if (fetchRounds >= MAX_FF_FETCH_ROUNDS) { - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } const previousCount = reachableCount; - await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, url.href, descendant]); + await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, repo.href, descendant]); fetchRounds += 1; reachableCount = await countReachable(); @@ -874,14 +900,14 @@ export async function verifyFastForward(req: { await assertWithinSizeBudget(); return false; } - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } deepenStep = Math.min(deepenStep * 2, MAX_FF_DEEPEN_STEP); } } finally { watchdog.stop(); - await awaitKillConfirmed(breachKill, `size-breach kill for ${url.host}`); + await awaitKillConfirmed(breachKill, `size-breach kill for ${repoHostLabel(repo)}`); await fs.rm(repoDir, { recursive: true, force: true }).catch((e: unknown) => { console.warn(`[GitSource:transport] failed to remove fast-forward scratch repo ${repoDir}: ${e instanceof Error ? e.message : String(e)}`); }); @@ -890,9 +916,9 @@ export async function verifyFastForward(req: { export const nativeGitTransport: GitTransport = { async resolveRef(req: ResolveRequest): Promise { - const hasToken = Boolean(req.token); + const hasToken = Boolean(req.token) || Boolean(req.sshAuth); await ensureBinaryReady(hasToken); - const url = assertValidRepoUrl(req.repoUrl, hasToken); + const repo = assertValidRepoUrl(req.repoUrl, hasToken); if (SHA_PATTERN.test(req.ref)) { // A full SHA is self-resolving: the immutable identity IS the @@ -902,24 +928,24 @@ export const nativeGitTransport: GitTransport = { return { commitSha: req.ref.toLowerCase(), kind: 'sha' }; } - assertValidRef(req.ref, url.host, hasToken); - const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token); + assertValidRef(req.ref, repoHostLabel(repo), hasToken); + const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth); const found = await lsRemoteRefs( - url, req.ref, env, baseArgs, + repo, req.ref, env, baseArgs, req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken, ); if (found.branchSha) return { commitSha: found.branchSha, kind: 'branch' }; if (found.tagSha) return { commitSha: found.tagSha, kind: 'tag' }; - throw { transportFailure: true as const, reason: 'ref-not-found', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'ref-not-found', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; }, async fetchAtCommit(req: FetchRequest): Promise { - const hasToken = Boolean(req.token); + const hasToken = Boolean(req.token) || Boolean(req.sshAuth); await ensureBinaryReady(hasToken); - const url = assertValidRepoUrl(req.repoUrl, hasToken); - assertValidRef(req.ref, url.host, hasToken); + const repo = assertValidRepoUrl(req.repoUrl, hasToken); + assertValidRef(req.ref, repoHostLabel(repo), hasToken); - const { layout, env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token); + const { layout, env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth); const checkout = path.join(req.workspaceRoot, 'repo'); const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS; @@ -952,22 +978,22 @@ export const nativeGitTransport: GitTransport = { } catch (e) { // A size breach wins over the timeout wording. if (sizeExceeded) { - throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } if (isTimeoutError(e)) { - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } - throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } // A watchdog-triggered SIGKILL settles runGit's promise via the // child's normal 'close' event (code null -> exitCode -1), not // a rejection, so this is the common path for an in-flight // breach and must check sizeExceeded before the generic mapping. if (sizeExceeded) { - throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } if (res.exitCode !== 0) { - throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'exit', stderr: res.stderr, exitCode: res.exitCode, argv: args, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } return res; }; @@ -979,7 +1005,7 @@ export const nativeGitTransport: GitTransport = { // SHA (GitHub does by default); a refusal surfaces as a // non-zero `git fetch` here and classifies as UNSUPPORTED_REF. await materialize([...baseArgs, 'init', checkout]); - await materialize([...baseArgs, '-C', checkout, 'fetch', '--depth=1', url.href, req.ref]); + await materialize([...baseArgs, '-C', checkout, 'fetch', '--depth=1', repo.href, req.ref]); await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]); } else { // A bare name works for both branches and tags: `--branch` @@ -992,7 +1018,7 @@ export const nativeGitTransport: GitTransport = { await materialize([ ...baseArgs, 'clone', '--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules', - '--branch', branchArg, url.href, checkout, + '--branch', branchArg, repo.href, checkout, ]); } @@ -1005,20 +1031,20 @@ export const nativeGitTransport: GitTransport = { }); actual = head.stdout.trim().toLowerCase(); if (!SHA_PATTERN.test(actual)) { - throw { transportFailure: true as const, reason: 'exit', stderr: `unexpected rev-parse output: ${head.stdout}`, exitCode: head.exitCode, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'exit', stderr: `unexpected rev-parse output: ${head.stdout}`, exitCode: head.exitCode, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } } catch (e) { if (isTransportFailure(e)) throw e; if (isTimeoutError(e)) { - throw { transportFailure: true as const, reason: 'timeout', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'timeout', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } - throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'exit', stderr: e instanceof Error ? e.message : String(e), host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } if (actual !== req.commitSha.toLowerCase()) { // The branch tip moved between resolution and fetch. Refuse // rather than materialize content nobody reviewed. - throw { transportFailure: true as const, reason: 'tip-changed', host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'tip-changed', host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } // Deterministic final measure: a breach landing between the last @@ -1031,13 +1057,13 @@ export const nativeGitTransport: GitTransport = { return -1; }); if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) { - throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: url.host, hasToken } satisfies TransportFailure; + throw { transportFailure: true as const, reason: 'size', maxBytes: req.maxBytes, host: repoHostLabel(repo), hasToken } satisfies TransportFailure; } return { commitSha: actual, dir: checkout }; } finally { watchdog.stop(); - await awaitKillConfirmed(breachKill, `size-breach kill for ${url.host}`); + await awaitKillConfirmed(breachKill, `size-breach kill for ${repoHostLabel(repo)}`); } }, }; diff --git a/backend/src/services/git/sshCredentialFiles.ts b/backend/src/services/git/sshCredentialFiles.ts new file mode 100644 index 00000000..99464160 --- /dev/null +++ b/backend/src/services/git/sshCredentialFiles.ts @@ -0,0 +1,19 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import { canonicalizeDeployKeyPem, canonicalizeKnownHostsEntry } from './sshTrust'; + +/** Materialize a validated deploy key PEM for one git/ssh invocation (mode 0600). */ +export async function writeDeployKey(metaDir: string, pem: string): Promise { + const keyPath = path.join(metaDir, 'deploy-key'); + const canonical = canonicalizeDeployKeyPem(pem); + await fs.writeFile(keyPath, canonical, { mode: 0o600 }); + return keyPath.split(path.sep).join('/'); +} + +/** Materialize a validated known_hosts entry for one git/ssh invocation (mode 0600). */ +export async function writeKnownHosts(metaDir: string, entry: string): Promise { + const knownHostsPath = path.join(metaDir, 'known_hosts'); + const canonical = canonicalizeKnownHostsEntry(entry); + await fs.writeFile(knownHostsPath, canonical, { mode: 0o600 }); + return knownHostsPath.split(path.sep).join('/'); +} diff --git a/backend/src/services/git/sshTrust.ts b/backend/src/services/git/sshTrust.ts new file mode 100644 index 00000000..4a60863e --- /dev/null +++ b/backend/src/services/git/sshTrust.ts @@ -0,0 +1,272 @@ +import { createHash } from 'crypto'; +import { spawn } from 'child_process'; +import path from 'path'; + +/** Parsed SSH repository target for transport and host-key trust. */ +export interface ParsedSshRepoUrl { + /** URL string passed to git (scp-style or ssh://). */ + href: string; + host: string; + port: number; + pathname: string; +} + +const DEFAULT_SSH_PORT = 22; +const SCP_URL_PATTERN = /^([^@\s/]+)@([^:\s]+):(.+)$/; +const KNOWN_HOSTS_SCAN_TIMEOUT_MS = 15_000; + +function normalizePathname(pathname: string): string { + const trimmed = pathname.trim(); + if (!trimmed.startsWith('/')) return `/${trimmed}`; + return trimmed; +} + +/** + * Parse scp-style `git@host:org/repo.git` URLs. Git accepts these directly; + * ssh:// is used when a nonstandard port is required. + */ +export function parseSshScpUrl(raw: string): ParsedSshRepoUrl | null { + const trimmed = raw.trim(); + const match = SCP_URL_PATTERN.exec(trimmed); + if (!match) return null; + const user = match[1]; + const hostPart = match[2]; + const repoPath = match[3].trim(); + if (!user || !hostPart || !repoPath || repoPath.includes('..')) return null; + const colon = hostPart.lastIndexOf(':'); + let host = hostPart; + let port = DEFAULT_SSH_PORT; + if (colon > 0 && colon < hostPart.length - 1) { + const portText = hostPart.slice(colon + 1); + const parsedPort = Number.parseInt(portText, 10); + if (!Number.isFinite(parsedPort) || parsedPort < 1 || parsedPort > 65535) return null; + host = hostPart.slice(0, colon); + port = parsedPort; + } + if (!host) return null; + const pathname = normalizePathname(repoPath); + const href = port === DEFAULT_SSH_PORT + ? `${user}@${host}:${repoPath}` + : `ssh://${user}@${host}:${port}${pathname}`; + return { href, host, port, pathname }; +} + +export function parseSshUrl(raw: string): ParsedSshRepoUrl | null { + const trimmed = raw.trim(); + let url: URL; + try { + url = new URL(trimmed); + } catch { + return parseSshScpUrl(trimmed); + } + if (url.protocol !== 'ssh:') return null; + if (!url.hostname || url.username === '' || url.password !== '') return null; + if (url.search !== '' || url.hash !== '') return null; + const port = url.port ? Number.parseInt(url.port, 10) : DEFAULT_SSH_PORT; + if (!Number.isFinite(port) || port < 1 || port > 65535) return null; + const pathname = normalizePathname(url.pathname); + if (pathname === '/' || pathname.includes('..')) return null; + const user = url.username; + const href = port === DEFAULT_SSH_PORT + ? `${user}@${url.hostname}:${pathname.slice(1)}` + : `ssh://${user}@${url.hostname}:${port}${pathname}`; + return { href, host: url.hostname, port, pathname }; +} + +export type RepoTransportKind = 'https' | 'ssh'; + +export interface ParsedRepoUrl { + kind: RepoTransportKind; + href: string; + host: string; + port?: number; + pathname: string; +} + +export function parseRepoTransportUrl(raw: string): ParsedRepoUrl | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + let https: URL; + try { + https = new URL(trimmed); + } catch { + https = null as unknown as URL; + } + if (https && https.protocol === 'https:' && https.hostname && !https.username && !https.password + && https.search === '' && https.hash === '') { + return { + kind: 'https', + href: https.href, + host: https.host, + pathname: https.pathname, + }; + } + const ssh = parseSshUrl(trimmed); + if (!ssh) return null; + return { + kind: 'ssh', + href: ssh.href, + host: ssh.host, + port: ssh.port, + pathname: ssh.pathname, + }; +} + +/** SHA256 fingerprint in OpenSSH display form (`SHA256:...`). */ +export function fingerprintFromKnownHostsLine(line: string): string | null { + const material = keyMaterialFromKnownHostsLine(line); + if (!material) return null; + try { + const digest = createHash('sha256').update(Buffer.from(material.keyBase64, 'base64')).digest('base64'); + return `SHA256:${digest.replace(/=+$/, '')}`; + } catch { + return null; + } +} + +function isSshKeyType(token: string): boolean { + return token.startsWith('ssh-') || token.startsWith('ecdsa-') || token.startsWith('sk-'); +} + +function keyMaterialFromKnownHostsLine(line: string): { keyType: string; keyBase64: string } | null { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return null; + const parts = trimmed.split(/\s+/); + if (parts.length < 3) return null; + for (let i = 0; i < parts.length - 1; i += 1) { + if (isSshKeyType(parts[i])) { + return { keyType: parts[i], keyBase64: parts[i + 1] }; + } + } + return null; +} + +const DEPLOY_KEY_PEM_HEADER = /^-----BEGIN (?:OPENSSH )?PRIVATE KEY-----$/; +const DEPLOY_KEY_PEM_FOOTER = /^-----END (?:OPENSSH )?PRIVATE KEY-----$/; + +/** Rebuild a deploy key PEM from validated envelope and base64 body lines only. */ +export function canonicalizeDeployKeyPem(raw: string): string { + const lines = raw.replace(/\r\n/g, '\n').trim().split('\n').map((l) => l.trim()).filter((l) => l.length > 0); + if (lines.length < 3) { + throw new Error('deploy key is invalid'); + } + const header = lines[0]; + const footer = lines[lines.length - 1]; + if (!DEPLOY_KEY_PEM_HEADER.test(header) || !DEPLOY_KEY_PEM_FOOTER.test(footer)) { + throw new Error('deploy key is invalid'); + } + const bodyLines = lines.slice(1, -1); + for (const bodyLine of bodyLines) { + if (!/^[A-Za-z0-9+/=]+$/.test(bodyLine)) { + throw new Error('deploy key is invalid'); + } + } + return `${header}\n${bodyLines.join('\n')}\n${footer}\n`; +} + +function canonicalizeKnownHostsLine(line: string): string { + const material = keyMaterialFromKnownHostsLine(line); + if (!material) { + throw new Error('known_hosts entry is invalid'); + } + try { + Buffer.from(material.keyBase64, 'base64'); + } catch { + throw new Error('known_hosts entry is invalid'); + } + const parts = line.trim().split(/\s+/); + let keyTypeIdx = -1; + for (let i = 0; i < parts.length - 1; i += 1) { + if (isSshKeyType(parts[i])) { + keyTypeIdx = i; + break; + } + } + if (keyTypeIdx < 1) { + throw new Error('known_hosts entry is invalid'); + } + const hostPart = parts.slice(0, keyTypeIdx).join(' '); + return `${hostPart} ${material.keyType} ${material.keyBase64}`; +} + +/** Rebuild known_hosts content from parsed host markers and key material only. */ +export function canonicalizeKnownHostsEntry(raw: string): string { + const lines = raw.trim().split(/\r?\n/) + .map((l) => l.trim()) + .filter((l) => l && !l.startsWith('#')); + if (lines.length === 0) { + throw new Error('known_hosts entry is empty'); + } + return `${lines.map(canonicalizeKnownHostsLine).join('\n')}\n`; +} + +export interface ScannedHostKey { + keyType: string; + fingerprint: string; + line: string; +} + +function runSshKeyscan(host: string, port: number): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve, reject) => { + const args = port === DEFAULT_SSH_PORT + ? ['-H', host] + : ['-p', String(port), '-H', host]; + const child = spawn('ssh-keyscan', args, { windowsHide: true }); + let stdout = ''; + let stderr = ''; + child.stdout?.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); }); + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + const timer = setTimeout(() => { + child.kill('SIGKILL'); + }, KNOWN_HOSTS_SCAN_TIMEOUT_MS); + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ stdout, stderr, exitCode: code ?? -1 }); + }); + }); +} + +/** Fetch host keys from the server without trusting them (probe step only). */ +export async function scanHostKeys(host: string, port: number): Promise { + const result = await runSshKeyscan(host, port); + if (result.exitCode !== 0 && !result.stdout.trim()) { + throw new Error(result.stderr.trim() || 'ssh-keyscan failed'); + } + const keys: ScannedHostKey[] = []; + for (const line of result.stdout.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const fingerprint = fingerprintFromKnownHostsLine(trimmed); + if (!fingerprint) continue; + const material = keyMaterialFromKnownHostsLine(trimmed); + const keyType = material?.keyType ?? 'unknown'; + keys.push({ keyType, fingerprint, line: trimmed }); + } + if (keys.length === 0) { + throw new Error('No host keys returned from ssh-keyscan'); + } + return keys; +} + +/** + * Build GIT_SSH_COMMAND / core.sshCommand value enforcing strict host-key + * checking against our per-fetch known_hosts file and a single deploy key. + */ +export function buildSshCommand(keyPath: string, knownHostsPath: string): string { + const key = keyPath.split(path.sep).join('/'); + const known = knownHostsPath.split(path.sep).join('/'); + return [ + 'ssh', + '-o', 'BatchMode=yes', + '-o', 'StrictHostKeyChecking=yes', + '-o', 'UserKnownHostsFile=' + known, + '-o', 'IdentitiesOnly=yes', + '-o', 'IdentityAgent=none', + '-F', '/dev/null', + '-i', key, + ].join(' '); +} diff --git a/backend/src/services/git/types.ts b/backend/src/services/git/types.ts index c5568a5b..132a8103 100644 --- a/backend/src/services/git/types.ts +++ b/backend/src/services/git/types.ts @@ -18,11 +18,18 @@ export type RefKind = 'branch' | 'tag' | 'sha'; +/** Deploy-key authentication material for SSH transports. */ +export interface SshDeployKeyAuth { + privateKey: string; + knownHostsEntry: string; +} + export interface ResolveRequest { repoUrl: string; /** Configured ref: a branch name, a tag name, or a full 40/64-hex commit SHA. */ ref: string; token?: string | null; + sshAuth?: SshDeployKeyAuth | null; /** * Total fetch budget in milliseconds. Note: the resolution round trip * (ls-remote) is internally capped at 10s regardless of this value, so diff --git a/backend/src/services/gitops/createRecovery.ts b/backend/src/services/gitops/createRecovery.ts index 33c01717..3972a80f 100644 --- a/backend/src/services/gitops/createRecovery.ts +++ b/backend/src/services/gitops/createRecovery.ts @@ -64,13 +64,6 @@ function envelopeFor(checkpoint: GitOpsCreateCheckpointRow) { }; } -/** - * Three states, because the two callers need opposite fail-safe directions. - * - * Teardown must not treat "cannot tell" as absent, or it would skip a directory - * that is really there. Completion must not treat it as present, or it would - * mark a create live on the strength of a failed stat. - */ /** * Clear a settled create's staging marker, reporting rather than throwing. * @@ -94,6 +87,13 @@ async function clearSettledMarker(stackName: string, managedRoot: string): Promi } } +/** + * Three states, because the two callers need opposite fail-safe directions. + * + * Teardown must not treat "cannot tell" as absent, or it would skip a directory + * that is really there. Completion must not treat it as present, or it would + * mark a create live on the strength of a failed stat. + */ async function stackDirState(stackName: string): Promise<'present' | 'absent' | 'unknown'> { try { const base = FileSystemService.getInstance().getBaseDir(); @@ -262,8 +262,11 @@ async function resolveOne(checkpoint: GitOpsCreateCheckpointRow): Promise MAX_REPO_URL_LENGTH) { + return { ok: false, reason: trimmed.length > MAX_REPO_URL_LENGTH ? 'too_long' : 'invalid' }; + } + const https = parseHttpsRepoUrl(trimmed); + if (https.ok) return { ok: true, kind: 'https', url: https.url }; + const ssh = parseSshUrl(trimmed); + if (ssh) return { ok: true, kind: 'ssh', ssh }; + if (!https.ok && https.reason !== 'not_https') { + return { ok: false, reason: https.reason }; + } + return { ok: false, reason: 'not_supported' }; +} + +export function serializeRepoIdentityFromStorable(parsed: ParseStorableRepoUrlResult & { ok: true }): RepoIdentity { + if (parsed.kind === 'https') { + return serializeRepoIdentity(parsed.url); + } + const host = parsed.ssh.port === 22 ? parsed.ssh.host : `${parsed.ssh.host}:${parsed.ssh.port}`; + return { host, pathname: parsed.ssh.pathname }; +} + +export function secretFreeRepoUrlFromStorable(parsed: ParseStorableRepoUrlResult & { ok: true }): string { + if (parsed.kind === 'https') { + return secretFreeRepoUrl(serializeRepoIdentity(parsed.url)); + } + const ssh = parsed.ssh; + const portSuffix = ssh.port === 22 ? '' : `:${ssh.port}`; + return `ssh://git@${ssh.host}${portSuffix}${ssh.pathname}`; +} + export function repoUrlRejectionMessage(raw: string): string | null { - const parsed = parseHttpsRepoUrl(raw); + const parsed = parseStorableRepoUrl(raw); if (parsed.ok) return null; switch (parsed.reason) { case 'too_long': return 'repo_url is too long'; - case 'not_https': - return 'Only HTTPS repository URLs are supported'; + case 'not_supported': + return 'Use an https:// URL or an SSH URL (git@host:org/repo.git or ssh://)'; case 'userinfo': return 'Repository URL must not include userinfo'; case 'query': diff --git a/backend/src/services/gitops/schema.ts b/backend/src/services/gitops/schema.ts index 99efa3f3..4acdcf32 100644 --- a/backend/src/services/gitops/schema.ts +++ b/backend/src/services/gitops/schema.ts @@ -31,6 +31,9 @@ CREATE TABLE IF NOT EXISTS gitops_create_checkpoints ( env_path TEXT NULL, auth_type TEXT NOT NULL, encrypted_token TEXT NULL, + encrypted_deploy_key TEXT NULL, + ssh_known_hosts_entry TEXT NULL, + ssh_host_key_fingerprint TEXT NULL, auto_apply_on_webhook INTEGER NOT NULL DEFAULT 0, auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0, commit_sha TEXT NULL, diff --git a/backend/src/services/gitops/store.ts b/backend/src/services/gitops/store.ts index 01727531..0ea3d621 100644 --- a/backend/src/services/gitops/store.ts +++ b/backend/src/services/gitops/store.ts @@ -265,13 +265,15 @@ export class GitOpsStore { `INSERT INTO gitops_create_checkpoints ( application_id, stack_name, phase, generation_id, operation_id, repo_url, branch, compose_path, compose_paths_json, context_dir, sync_env, env_path, auth_type, - encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply, commit_sha, + encrypted_token, encrypted_deploy_key, ssh_known_hosts_entry, ssh_host_key_fingerprint, + auto_apply_on_webhook, auto_deploy_on_apply, commit_sha, applied_spec_json, created_managed_root, created_at, updated_at - ) VALUES (${Array(21).fill('?').join(', ')})`, + ) VALUES (${Array(24).fill('?').join(', ')})`, ).run( row.application_id, row.stack_name, row.phase, row.generation_id, row.operation_id, row.repo_url, row.branch, row.compose_path, row.compose_paths_json, row.context_dir, - row.sync_env, row.env_path, row.auth_type, row.encrypted_token, row.auto_apply_on_webhook, + row.sync_env, row.env_path, row.auth_type, row.encrypted_token, row.encrypted_deploy_key, + row.ssh_known_hosts_entry, row.ssh_host_key_fingerprint, row.auto_apply_on_webhook, row.auto_deploy_on_apply, row.commit_sha, row.applied_spec_json, row.created_managed_root, row.created_at, row.updated_at, ); diff --git a/backend/src/services/gitops/types.ts b/backend/src/services/gitops/types.ts index 72702f7c..052d6a0e 100644 --- a/backend/src/services/gitops/types.ts +++ b/backend/src/services/gitops/types.ts @@ -120,6 +120,9 @@ export type GitOpsCreateCheckpointRow = { env_path: string | null; auth_type: string; encrypted_token: string | null; + encrypted_deploy_key: string | null; + ssh_known_hosts_entry: string | null; + ssh_host_key_fingerprint: string | null; auto_apply_on_webhook: number; auto_deploy_on_apply: number; commit_sha: string | null; diff --git a/backend/src/utils/gitSourceHttp.ts b/backend/src/utils/gitSourceHttp.ts index 0ac5a907..b7d6fe8f 100644 --- a/backend/src/utils/gitSourceHttp.ts +++ b/backend/src/utils/gitSourceHttp.ts @@ -26,6 +26,7 @@ export function gitSourceStatus(code: GitSourceErrorCode): number { case 'FILE_NOT_FOUND': return 404; case 'UNSUPPORTED_REF': + case 'SSH_HOST_KEY_FAILED': return 400; case 'STALE_PLAN': case 'PLAN_BLOCKED': diff --git a/docs/features/git-sources.mdx b/docs/features/git-sources.mdx index e805ccaa..eca271d4 100644 --- a/docs/features/git-sources.mdx +++ b/docs/features/git-sources.mdx @@ -55,7 +55,7 @@ These are the states you will see most often. The last two matter most. Sencho reports an interrupted operation as unknown rather than guessing, so a stack whose pull was cut short by a restart says so instead of quietly reading as up to date. - Changing the repository, the ref, the compose files, the project directory, or the `.env` sync clears any staged commit, because the plan was built against the settings you just replaced. Pull again to rebuild it. The state moves to **Reconcile required** if a commit had already been accepted, and to **Never reconciled** if none ever was. Changing only the token or the apply behavior leaves a staged commit alone, since neither changes what would be materialized. + Changing the repository, the ref, the compose files, the project directory, or the `.env` sync clears any staged commit, because the plan was built against the settings you just replaced. Pull again to rebuild it. The state moves to **Reconcile required** if a commit had already been accepted, and to **Never reconciled** if none ever was. Changing only the token, deploy key, host-key trust, or the apply behavior leaves a staged commit alone, since neither changes what would be materialized. ### When part of the state could not be proven @@ -69,7 +69,7 @@ These are qualifications, not failures. The state above them is real, and the li Skip the "empty stack then link later" detour and point at a repo from the start. Click **Create Stack** in the sidebar, switch to the **From Git** tab, and fill in the same fields you would on the Git Source panel. - New stack dialog with the From Git tab selected, showing stack name, repository URL, branch, compose path, sibling .env toggle, authentication toggle, apply behavior radio group, a Deploy after create checkbox, and an HTTPS REPOS ONLY footer hint + New stack dialog with the From Git tab selected, showing stack name, repository URL, branch, compose path, sibling .env toggle, authentication options, apply behavior radio group, and a Deploy after create checkbox Sencho fetches the compose files, validates the merged result with `docker compose config`, writes them to a fresh stack directory, and links the Git source in one step. The last-applied commit SHA is seeded from the fetch so the first manual pull starts from a clean classified plan. @@ -89,12 +89,12 @@ Tick **Deploy after create** to run `docker compose up -d` immediately after the | Field | Description | |-------|-------------| -| **Repository URL** | `https://github.com/your-org/your-repo.git` (HTTPS only) | +| **Repository URL** | HTTPS (`https://github.com/your-org/your-repo.git`) or SSH (`git@github.com:your-org/your-repo.git`, or `ssh://git@host:port/path` when the server uses a nonstandard port) | | **Ref** | Branch, tag, or full commit SHA to track (e.g. `main`, `v1.0`, or a 40-character SHA) | | **Compose files** | One or more paths within the repo, merged in the listed order (e.g. `deploy/base.yaml` then `deploy/prod.yaml`). The first file is the primary. Reorder by dragging (or the up/down arrows on a phone) and remove with the **×** button. | | **Project directory** | Optional path within the repo passed to `docker compose --project-directory`, so relative build contexts, bind mounts, and `env_file` references resolve from that base. Leave blank to use the stack root. | | **Also sync sibling `.env` file** | When enabled, also pulls the `.env` from the same directory as the primary compose file. The form shows the resolved path inline (e.g. `deploy/.env` for a primary at `deploy/compose.yaml`). | -| **Authentication** | **Public (no auth)** for public repos, **Personal Access Token** for private repos | +| **Authentication** | **Public (no auth)** for public repos, **Personal Access Token** for private HTTPS repos, or **SSH deploy key** for private SSH repos | | **Apply behavior** | See the three modes below | Saving runs a reachability check against the repository. If the URL is wrong, the token is invalid, the ref does not exist, or a file is missing, Sencho surfaces the error inline and nothing is persisted. @@ -180,7 +180,9 @@ See the [Webhooks](/features/webhooks) page for the full signing protocol. ## Private repositories -For private repositories, use a Personal Access Token scoped to read access on the target repo: +### HTTPS with a personal access token + +For private repositories over HTTPS, use a Personal Access Token scoped to read access on the target repo: - **GitHub**: a fine-grained PAT with **Contents: Read** permission on the repo, or a classic PAT with the `repo` scope. - **GitLab**: a project or group access token with the `read_repository` scope. @@ -190,6 +192,21 @@ Paste the token into the **Token** field and save. Sencho stores it encrypted at The encryption boundary covers the pending update payload too: every pull caches the fetched compose and env content in the database so the change plan can reopen without a refetch, and that cached content is encrypted at rest in the same way as the token, since compose files routinely embed secrets via env interpolation. +### SSH with a deploy key + +For private repositories over SSH, paste a read-only deploy key and confirm the server's host key before saving. + +1. Generate an ed25519 key pair on a trusted machine (`ssh-keygen -t ed25519 -f deploy-key -N ""`). +2. Add the public key to the Git host as a deploy key with read access (GitHub: **Settings → Deploy keys** on the repository). +3. In Sencho, set **Authentication** to **SSH deploy key**, paste the private key, then click **Fetch host key fingerprint**. Sencho probes the host with `ssh-keyscan`, shows the SHA256 fingerprint, and stores the matching `known_hosts` line. +4. Save. Sencho stores the private key encrypted at rest and never returns it in API responses or UI. + +Sencho verifies the host key on every fetch (`StrictHostKeyChecking=yes` against the stored line). If the server key changes, pulls fail with a host-key error until you review the new fingerprint and update trust deliberately. + +Use an `ssh://` URL when the Git server listens on a nonstandard port (for example `ssh://git@git.example.com:2222/org/repo.git`). The familiar `git@host:org/repo.git` form assumes port 22. + +Switching authentication back to **Public (no auth)** or to a token clears the stored deploy key and host-key trust. + ## Local edits vs Git Sencho classifies every managed path against the last applied generation and the live disk. @@ -214,7 +231,7 @@ Pulls, applies, and create-from-git operations on the same stack are serialized - You supplied a token and the Git host rejected it outright. The token is missing, expired, or lacks read access. Generate a new token and replace the value in the **Token** field. Sencho returns this as a 400 form error rather than a 401, so an upstream auth failure does not sign you out of the dashboard. + The Git host rejected the credentials you supplied. For HTTPS, the token may be missing, expired, or lack read access: generate a new token and replace the value in the **Token** field. For SSH, the deploy key may be wrong or not registered on the host: paste the matching private key or add the public key on the Git host. Sencho returns this as a 400 form error rather than a 401, so an upstream auth failure does not sign you out of the dashboard. @@ -281,14 +298,18 @@ Pulls, applies, and create-from-git operations on the same stack are serialized Git Sources materializes the complete project, including recursive `include:` and `extends.file` dependencies, service env files, file-backed configs and secrets, and build contexts. If a referenced file is still missing, the pull refused it and the refusal message names the path and the reason (out-of-bound path, Git LFS pointer, submodule, symlink, or a size cap). Fix the declaration in the repository and pull again. - - Git Sources fetch over HTTPS only. SSH clone URLs (`git@host:org/repo.git`) and custom protocols are rejected with a client-side validation error. Paste the `https://...` URL and use a Personal Access Token for authentication on private repositories. + + The server's SSH host key no longer matches the fingerprint you accepted. This can mean a MITM risk or an intentional key rotation on the host. Click **Fetch host key fingerprint** again, compare the SHA256 value out of band with your operator, and save only if you intend to trust the new key. + + + + Git Sources accept `https://` URLs and SSH URLs (`git@host:org/repo.git` or `ssh://git@host:port/path`). Other schemes are rejected at save time. ## Known limitations -- **HTTPS only.** SSH URLs and SSH keys are not supported. Use a Personal Access Token for private repos. +- **HTTPS and SSH.** HTTPS uses tokens; SSH uses deploy keys with strict host-key verification. Custom URL schemes are not supported. - **No Git LFS.** Compose and env files stored via LFS are rejected. Commit plain files instead. - **No submodules.** Submodule contents are not fetched. Inputs and build contexts that reference submodule contents are refused with an actionable message; a warning is shown when `.gitmodules` is present. - **Refs, not arbitrary commits.** Sources follow a branch head, a tag, or a pinned commit SHA. Each pull resolves the configured ref to the exact commit it currently points at and pins that SHA, so apply always materializes the reviewed revision. A commit SHA not advertised by the Git host is refused. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 4727ccf3..2cc05ad5 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -59,11 +59,11 @@ The **From Git** tab clones a public or private repository and treats its compos Fill in: - **Stack Name**: same naming rules as the Empty tab. -- **Repository URL**: HTTPS only. SSH URLs are not supported. +- **Repository URL**: HTTPS (`https://github.com/org/repo.git`) or SSH (`git@github.com:org/repo.git`, or `ssh://git@host:port/path` for nonstandard ports). - **Branch**: defaults to `main`. - **Compose files**: one or more paths inside the repository, merged in the listed order. Defaults to `compose.yaml`. Browse the repository tree to pick them, or type a path. An optional **Project directory** sets the base for relative paths. - **Also sync sibling .env file**: when checked, a `.env` next to the primary compose file is pulled along with it. -- **Authentication**: pick **Public (no auth)** or **Personal Access Token** for private repos. +- **Authentication**: pick **Public (no auth)** for public repos, **Personal Access Token** for private HTTPS repos, or **SSH deploy key** for private SSH repos (with host-key fingerprint verification). - **Apply behavior**: controls what happens on future webhook pulls: - **Review only**: diffs surface in the sidebar; you apply manually. - **Auto-write files**: pulls write to disk; you redeploy manually. diff --git a/docs/images/git-sources/create-from-git-tab.png b/docs/images/git-sources/create-from-git-tab.png index 7db408a8..e73af9b0 100644 Binary files a/docs/images/git-sources/create-from-git-tab.png and b/docs/images/git-sources/create-from-git-tab.png differ diff --git a/docs/images/git-sources/panel.png b/docs/images/git-sources/panel.png index 37efdc1e..02cbd8ce 100644 Binary files a/docs/images/git-sources/panel.png and b/docs/images/git-sources/panel.png differ diff --git a/docs/images/stack-management/create-stack-git.png b/docs/images/stack-management/create-stack-git.png index 2af02d17..ced6718f 100644 Binary files a/docs/images/stack-management/create-stack-git.png and b/docs/images/stack-management/create-stack-git.png differ diff --git a/e2e/git-sources.spec.ts b/e2e/git-sources.spec.ts index d3712c8e..420534f8 100644 --- a/e2e/git-sources.spec.ts +++ b/e2e/git-sources.spec.ts @@ -9,6 +9,7 @@ import { test, expect, Page } from '@playwright/test'; import { loginAs } from './helpers'; import { gitAvailable, buildFixtureRepo, serveRepos, fullProjectFiles, multiFileFiles, refusalFiles } from './gitServer.helper'; +import { sshGitFixtureAvailable, startSshGitFixture, type SshGitE2eFixture } from './sshGit.helper'; const TEST_STACK = 'e2e-git-source-stack'; @@ -64,14 +65,14 @@ test.describe('Git Sources', () => { await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 }); }); - test('rejects non-HTTPS repository URLs client-side', async ({ page }) => { + test('rejects unsupported repository URL schemes client-side', async ({ page }) => { await openGitSourcePanel(page); - await page.locator('#git-source-repo').fill('git@github.com:org/repo.git'); + await page.locator('#git-source-repo').fill('http://github.com/org/repo.git'); await page.locator('#git-source-branch').fill('main'); await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click(); - await expect(page.getByText(/Only HTTPS repository URLs are supported/i)).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText(/Use an https:\/\/ URL or an SSH URL/i)).toBeVisible({ timeout: 5_000 }); }); test('surfaces reachability error on save with unreachable repo', async ({ page }) => { @@ -241,15 +242,15 @@ test.describe('Create stack from Git', () => { await expect(page.getByRole('dialog').getByRole('tab', { name: /From Git/i })).toBeVisible(); }); - test('From Git tab rejects non-HTTPS URLs client-side', async ({ page }) => { + test('From Git tab rejects unsupported URL schemes client-side', async ({ page }) => { await openCreateStackDialog(page); await page.getByRole('dialog').getByRole('tab', { name: /From Git/i }).click(); await page.locator('#create-git-stack-name').fill(CREATE_FROM_GIT_STACK); - await page.locator('#git-source-repo').fill('git@github.com:org/repo.git'); + await page.locator('#git-source-repo').fill('http://github.com/org/repo.git'); await page.locator('#git-source-branch').fill('main'); await page.getByRole('dialog').getByRole('button', { name: /Create from Git/i }).click(); - await expect(page.getByText(/Only HTTPS repository URLs are supported/i)).toBeVisible({ timeout: 5_000 }); + await expect(page.getByText(/Use an https:\/\/ URL or an SSH URL/i)).toBeVisible({ timeout: 5_000 }); }); test('backend rejects .git/config compose_path on from-git', async ({ page }) => { @@ -646,3 +647,53 @@ test.describe('Git Sources complete-project materialization (local git server)', await expect(applyBtn).toBeVisible(); }); }); + +test.describe('Git Sources SSH deploy key (real backend)', () => { + test.skip(!sshGitFixtureAvailable(), 'requires git and openssh-server'); + + let fixture: SshGitE2eFixture; + + test.beforeAll(async ({ browser }) => { + fixture = await startSshGitFixture(22224); + const page = await browser.newPage(); + await loginAs(page); + await deleteTestStackViaApi(page); + await createTestStackViaApi(page); + await page.close(); + }); + + test.afterAll(async ({ browser }) => { + fixture?.close(); + const page = await browser.newPage(); + await loginAs(page); + await deleteTestStackViaApi(page); + await page.close(); + }); + + test.beforeEach(async ({ page }) => { + await loginAs(page); + await expect(page.getByRole('button', { name: 'Create Stack' })).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-stacks-loaded="true"]')).toBeAttached({ timeout: 15_000 }); + }); + + test('probes host key, saves deploy key, and redacts credentials on GET', async ({ page }) => { + await openGitSourcePanel(page); + await page.locator('#git-source-repo').fill(fixture.repoUrlSsh); + await page.locator('#git-source-branch').fill('main'); + await page.getByRole('button', { name: 'Deploy key (SSH)' }).click(); + await page.getByRole('button', { name: 'Fetch host key fingerprint' }).click(); + await expect(page.getByText(fixture.firstFingerprint, { exact: true })).toBeVisible({ timeout: 15_000 }); + await page.locator('textarea').fill(fixture.deployPrivateKey); + await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click(); + await expect(page.getByText('Git source saved.')).toBeVisible({ timeout: 30_000 }); + + const getBody = await page.evaluate(async (name) => { + const res = await fetch(`/api/stacks/${name}/git-source`, { credentials: 'include' }); + return res.json(); + }, TEST_STACK); + expect(getBody.auth_type).toBe('deploy_key'); + expect(getBody.has_deploy_key).toBe(true); + expect(getBody.ssh_host_key_fingerprint).toBe(fixture.firstFingerprint); + expect(JSON.stringify(getBody)).not.toContain('BEGIN OPENSSH'); + }); +}); diff --git a/e2e/screenshots.spec.ts b/e2e/screenshots.spec.ts index be50606a..b16cb4d8 100644 --- a/e2e/screenshots.spec.ts +++ b/e2e/screenshots.spec.ts @@ -156,6 +156,46 @@ async function openStubbedChangePlan(page: Page, stackName: string) { await expect(page.getByTestId('git-plan-op').first()).toBeVisible({ timeout: 10_000 }); } +test.describe('git source authentication docs screenshots', () => { + test.use({ viewport: { width: 1920, height: 1080 } }); + + test('git source panel with deploy key controls', async ({ page }) => { + await loginAs(page); + const stackName = 'docs-git-panel'; + await createStack(page, stackName); + await page.goto('/'); + await page.getByText(stackName).first().click(); + await page.getByRole('button', { name: /Git Source/i }).click(); + await expect(page.getByRole('dialog').getByRole('heading', { name: /git source/i })).toBeVisible(); + await page.locator('#git-source-repo').fill('git@github.com:example/demo.git'); + await page.getByRole('button', { name: 'Deploy key (SSH)' }).click(); + await expect(page.getByRole('button', { name: 'Fetch host key fingerprint' })).toBeVisible(); + const dialog = page.getByRole('dialog'); + await dialog.screenshot({ + path: path.join(DOCS_IMAGES, 'git-sources', 'panel.png'), + }); + await page.evaluate(async (name) => { + await fetch(`/api/stacks/${name}`, { method: 'DELETE', credentials: 'include' }).catch(() => {}); + }, stackName); + }); + + test('create stack from git tab with deploy key options', async ({ page }) => { + await loginAs(page); + await page.getByRole('button', { name: 'Create Stack' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('tab', { name: 'From Git' }).click(); + await expect(dialog.getByText('HTTPS OR SSH REPOS')).toBeVisible(); + await dialog.getByRole('button', { name: 'Deploy key (SSH)' }).click(); + await expect(dialog.getByRole('button', { name: 'Fetch host key fingerprint' })).toBeVisible(); + await dialog.screenshot({ + path: path.join(DOCS_IMAGES, 'git-sources', 'create-from-git-tab.png'), + }); + await dialog.screenshot({ + path: path.join(DOCS_IMAGES, 'stack-management', 'create-stack-git.png'), + }); + }); +}); + test.describe('classified change-plan docs screenshots', () => { test.use({ viewport: { width: 1920, height: 1080 } }); diff --git a/e2e/sshGit.helper.ts b/e2e/sshGit.helper.ts new file mode 100644 index 00000000..405841f9 --- /dev/null +++ b/e2e/sshGit.helper.ts @@ -0,0 +1,185 @@ +/** + * Local SSH git server for E2E specs. + * + * Spins up openssh-server with a forced git-upload-pack command and a bare + * repository containing compose.yaml so Git Source dry-run validation passes. + */ +import { createHash } from 'crypto'; +import { spawn, spawnSync } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import net from 'net'; +import os from 'os'; +import path from 'path'; + +export function sshGitFixtureAvailable(): boolean { + return spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0 + && spawnSync('/usr/sbin/sshd', ['-V'], { stdio: 'ignore' }).status === 0; +} + +const COMPOSE_FIXTURE = `services: + web: + image: nginx +`; + +function runGit(cwd: string, args: string[]): string { + const r = spawnSync('git', args, { cwd, encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`); + return r.stdout.trim(); +} + +function generateKeyPair(dir: string, name: string): { privatePem: string; publicLine: string; privatePath: string } { + const privatePath = path.join(dir, name); + const gen = spawnSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', privatePath, '-q'], { encoding: 'utf8' }); + if (gen.status !== 0) throw new Error(`ssh-keygen failed: ${gen.stderr}`); + return { + privatePath, + privatePem: readFileSync(privatePath, 'utf8'), + publicLine: readFileSync(`${privatePath}.pub`, 'utf8').trim(), + }; +} + +function buildBareRepo(): { bareDir: string; scratchDirs: string[] } { + const srcDir = mkdtempSync(path.join(os.tmpdir(), 'sencho-e2e-ssh-src-')); + writeFileSync(path.join(srcDir, 'compose.yaml'), COMPOSE_FIXTURE); + runGit(srcDir, ['init', '-b', 'main']); + runGit(srcDir, ['config', 'user.email', 'e2e@sencho.test']); + runGit(srcDir, ['config', 'user.name', 'Sencho E2E SSH']); + runGit(srcDir, ['add', '-A']); + runGit(srcDir, ['-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture']); + const bareRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-e2e-ssh-bare-')); + const bareDir = path.join(bareRoot, 'repo.git'); + const clone = spawnSync('git', ['clone', '--bare', '--quiet', srcDir, bareDir], { encoding: 'utf8' }); + if (clone.status !== 0) throw new Error(`git clone --bare failed: ${clone.stderr}`); + return { bareDir, scratchDirs: [srcDir, bareRoot] }; +} + +async function waitForPort(host: string, port: number, timeoutMs = 10_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await new Promise((resolve, reject) => { + const socket = net.connect({ host, port }, () => { + socket.end(); + resolve(); + }); + socket.on('error', reject); + }); + return; + } catch { + await new Promise((r) => setTimeout(r, 50)); + } + } + throw new Error(`port ${port} did not open within ${timeoutMs}ms`); +} + +function fingerprintFromKnownHostsLine(line: string): string | null { + const trimmed = line.trim(); + if (!trimmed) return null; + const parts = trimmed.split(/\s+/); + let keyBase64: string | null = null; + for (let i = 0; i < parts.length - 1; i += 1) { + if (parts[i].startsWith('ssh-') || parts[i].startsWith('ecdsa-') || parts[i].startsWith('sk-')) { + keyBase64 = parts[i + 1]; + break; + } + } + if (!keyBase64) return null; + const digest = createHash('sha256').update(Buffer.from(keyBase64, 'base64')).digest('base64').replace(/=+$/, ''); + return `SHA256:${digest}`; +} + +function runSshKeyscan(host: string, port: number): { knownHostsEntry: string; firstFingerprint: string } { + const args = port === 22 ? ['-H', host] : ['-p', String(port), '-H', host]; + const result = spawnSync('ssh-keyscan', args, { encoding: 'utf8' }); + if (result.status !== 0 && !result.stdout.trim()) { + throw new Error(result.stderr.trim() || 'ssh-keyscan failed'); + } + const lines = result.stdout.trim().split(/\r?\n/).filter((l) => l.trim() && !l.trim().startsWith('#')); + if (lines.length === 0) throw new Error('ssh-keyscan returned no host keys'); + const firstFingerprint = fingerprintFromKnownHostsLine(lines[0]); + if (!firstFingerprint) throw new Error('ssh-keyscan line could not be fingerprinted'); + return { knownHostsEntry: lines.join('\n'), firstFingerprint }; +} + +export interface SshGitE2eFixture { + port: number; + repoUrlSsh: string; + deployPrivateKey: string; + knownHostsEntry: string; + firstFingerprint: string; + close: () => void; +} + +export async function startSshGitFixture(port = 22224): Promise { + const repo = buildBareRepo(); + const sshRoot = mkdtempSync(path.join(os.tmpdir(), 'sencho-e2e-ssh-sshd-')); + const scratchDirs = [...repo.scratchDirs, sshRoot]; + const cleanupScratch = (): void => { + for (const dir of scratchDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch { + // best effort + } + } + }; + + const deploy = generateKeyPair(sshRoot, 'deploy'); + const hostKey = generateKeyPair(sshRoot, 'host'); + const authorizedKeysPath = path.join(sshRoot, 'authorized_keys'); + const forced = `command="git-upload-pack '${repo.bareDir}'",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ${deploy.publicLine}`; + writeFileSync(authorizedKeysPath, `${forced}\n`, { mode: 0o600 }); + + const configPath = path.join(sshRoot, 'sshd_config'); + const pidPath = path.join(sshRoot, 'sshd.pid'); + writeFileSync( + configPath, + [ + `Port ${port}`, + 'ListenAddress 127.0.0.1', + `HostKey ${hostKey.privatePath}`, + `AuthorizedKeysFile ${authorizedKeysPath}`, + `PidFile ${pidPath}`, + 'UsePAM no', + 'PasswordAuthentication no', + 'PubkeyAuthentication yes', + 'X11Forwarding no', + 'StrictModes no', + 'LogLevel ERROR', + ].join('\n'), + { mode: 0o600 }, + ); + + const child = spawn('/usr/sbin/sshd', ['-D', '-f', configPath, '-e'], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stderr?.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); + + try { + await waitForPort('127.0.0.1', port); + } catch (e) { + child.kill('SIGKILL'); + cleanupScratch(); + throw new Error(`sshd did not come up on port ${port}: ${stderr.trim() || '(no stderr)'}`, { cause: e }); + } + + const { knownHostsEntry, firstFingerprint } = runSshKeyscan('127.0.0.1', port); + const sshUser = os.userInfo().username; + const repoUrlSsh = `ssh://${sshUser}@127.0.0.1:${port}${repo.bareDir}`; + + let closed = false; + return { + port, + repoUrlSsh, + deployPrivateKey: deploy.privatePem, + knownHostsEntry, + firstFingerprint, + close: () => { + if (closed) return; + closed = true; + child.kill('SIGTERM'); + cleanupScratch(); + }, + }; +} diff --git a/frontend/src/components/EditorLayout/CreateStackDialog.tsx b/frontend/src/components/EditorLayout/CreateStackDialog.tsx index 8d36357f..cc721c9a 100644 --- a/frontend/src/components/EditorLayout/CreateStackDialog.tsx +++ b/frontend/src/components/EditorLayout/CreateStackDialog.tsx @@ -9,6 +9,7 @@ import { Checkbox } from '../ui/checkbox'; import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields'; import type { GitBrowseResult } from '../stack/GitComposeFilePicker'; import { apiFetch } from '@/lib/api'; +import { isSupportedGitRepoUrl, UNSUPPORTED_GIT_REPO_URL_MESSAGE } from '@/lib/gitRepoUrl'; import { toast } from '@/components/ui/toast-store'; import { useNodes } from '@/context/NodeContext'; import { cn } from '@/lib/utils'; @@ -69,11 +70,15 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks const [gitComposePaths, setGitComposePaths] = useState(['compose.yaml']); const [gitContextDir, setGitContextDir] = useState(''); const [gitSyncEnv, setGitSyncEnv] = useState(false); - const [gitAuthType, setGitAuthType] = useState<'none' | 'token'>('none'); + const [gitAuthType, setGitAuthType] = useState<'none' | 'token' | 'deploy_key'>('none'); const [gitToken, setGitToken] = useState(''); + const [gitDeployKey, setGitDeployKey] = useState(''); + const [gitSshKnownHostsEntry, setGitSshKnownHostsEntry] = useState(''); + const [gitSshHostKeyFingerprint, setGitSshHostKeyFingerprint] = useState(''); const [gitApplyMode, setGitApplyMode] = useState('review'); const [gitDeployNow, setGitDeployNow] = useState(false); const [creatingFromGit, setCreatingFromGit] = useState(false); + const [gitSubmitError, setGitSubmitError] = useState(null); const resetCreateFromGitForm = () => { setNewStackName(''); @@ -84,8 +89,12 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks setGitSyncEnv(false); setGitAuthType('none'); setGitToken(''); + setGitDeployKey(''); + setGitSshKnownHostsEntry(''); + setGitSshHostKeyFingerprint(''); setGitApplyMode('review'); setGitDeployNow(false); + setGitSubmitError(null); }; const browseGitRepo = async (): Promise => { @@ -100,6 +109,10 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks auth_type: gitAuthType, }; if (gitAuthType === 'token' && gitToken !== '') body.token = gitToken; + if (gitAuthType === 'deploy_key') { + if (gitDeployKey !== '') body.deploy_key = gitDeployKey; + if (gitSshKnownHostsEntry !== '') body.ssh_known_hosts_entry = gitSshKnownHostsEntry; + } const res = await apiFetch('/git-sources/browse', { method: 'POST', body: JSON.stringify(body), @@ -171,16 +184,18 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks const handleCreateStackFromGit = async () => { const stackName = newStackName.trim(); + setGitSubmitError(null); if (!stackName) { - toast.error('Stack name is required.'); + setGitSubmitError('Stack name is required.'); return; } if (!gitRepoUrl.trim() || !gitBranch.trim() || gitComposePaths.length === 0) { - toast.error('Repository URL, branch, and at least one compose file are required.'); + setGitSubmitError('Repository URL, branch, and at least one compose file are required.'); return; } - if (!/^https:\/\//i.test(gitRepoUrl.trim())) { - toast.error('Only HTTPS repository URLs are supported.'); + const trimmedUrl = gitRepoUrl.trim(); + if (!isSupportedGitRepoUrl(trimmedUrl)) { + setGitSubmitError(UNSUPPORTED_GIT_REPO_URL_MESSAGE); return; } const sourceNodeId = activeNode?.id; @@ -204,6 +219,11 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks if (gitAuthType === 'token' && gitToken !== '') { body.token = gitToken; } + if (gitAuthType === 'deploy_key') { + body.deploy_key = gitDeployKey; + body.ssh_known_hosts_entry = gitSshKnownHostsEntry; + body.ssh_host_key_fingerprint = gitSshHostKeyFingerprint; + } const response = await apiFetch('/stacks/from-git', { method: 'POST', body: JSON.stringify(body), @@ -238,7 +258,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks await onStackCreated(stackName, sourceNodeId); } catch (error) { console.error('Failed to create stack from Git:', error); - toast.error((error as Error)?.message || 'Failed to create stack from Git.'); + setGitSubmitError((error as Error)?.message || 'Failed to create stack from Git.'); } finally { toast.dismiss(loadingId); setCreatingFromGit(false); @@ -448,7 +468,12 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks syncEnv={gitSyncEnv} authType={gitAuthType} token={gitToken} + deployKey={gitDeployKey} + sshKnownHostsEntry={gitSshKnownHostsEntry} + sshHostKeyFingerprint={gitSshHostKeyFingerprint} hasStoredToken={false} + hasStoredDeployKey={false} + storedHostKeyFingerprint={null} applyMode={gitApplyMode} onRepoUrlChange={setGitRepoUrl} onBranchChange={setGitBranch} @@ -457,6 +482,9 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks onSyncEnvChange={setGitSyncEnv} onAuthTypeChange={setGitAuthType} onTokenChange={setGitToken} + onDeployKeyChange={setGitDeployKey} + onSshKnownHostsEntryChange={setGitSshKnownHostsEntry} + onSshHostKeyFingerprintChange={setGitSshHostKeyFingerprint} onApplyModeChange={setGitApplyMode} onBrowse={browseGitRepo} /> @@ -472,9 +500,19 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks Deploy after create + + {gitSubmitError && ( +
+ {gitSubmitError} +
+ )} onOpenChange(false)} disabled={creatingFromGit}> Cancel diff --git a/frontend/src/components/stack/GitSourceFields.tsx b/frontend/src/components/stack/GitSourceFields.tsx index 32534bed..91c20a9c 100644 --- a/frontend/src/components/stack/GitSourceFields.tsx +++ b/frontend/src/components/stack/GitSourceFields.tsx @@ -1,9 +1,19 @@ +import { useEffect, useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; import { GitComposeFilePicker, type GitBrowseResult } from './GitComposeFilePicker'; +interface HostKeyRotationWarning { + previous: string; + current: string; +} + export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy'; /** @@ -26,15 +36,22 @@ export interface GitSourceFieldsState { composePaths: string[]; contextDir: string; syncEnv: boolean; - authType: 'none' | 'token'; + authType: 'none' | 'token' | 'deploy_key'; token: string; + deployKey: string; + sshKnownHostsEntry: string; + sshHostKeyFingerprint: string; /** When editing an existing source, the server tells us whether a token is already stored. */ hasStoredToken: boolean; + hasStoredDeployKey: boolean; + storedHostKeyFingerprint: string | null; applyMode: ApplyMode; } export interface GitSourceFieldsProps extends GitSourceFieldsState { disabled?: boolean; + /** When probing host keys from the edit panel, scopes the request to stack:edit. */ + stackName?: string; /** 'edit' for the per-stack panel, 'create' for the new-stack dialog. Changes apply-mode copy. */ variant: 'edit' | 'create'; onRepoUrlChange: (value: string) => void; @@ -42,8 +59,11 @@ export interface GitSourceFieldsProps extends GitSourceFieldsState { onComposePathsChange: (value: string[]) => void; onContextDirChange: (value: string) => void; onSyncEnvChange: (value: boolean) => void; - onAuthTypeChange: (value: 'none' | 'token') => void; + onAuthTypeChange: (value: 'none' | 'token' | 'deploy_key') => void; onTokenChange: (value: string) => void; + onDeployKeyChange: (value: string) => void; + onSshKnownHostsEntryChange: (value: string) => void; + onSshHostKeyFingerprintChange: (value: string) => void; onApplyModeChange: (value: ApplyMode) => void; /** Runs the correct browse endpoint (create vs edit); returns the repo file list or null on failure. */ onBrowse: () => Promise; @@ -70,7 +90,11 @@ export function GitSourceFields({ syncEnv, authType, token, + deployKey, + sshHostKeyFingerprint, hasStoredToken, + hasStoredDeployKey, + storedHostKeyFingerprint, applyMode, disabled = false, variant, @@ -81,12 +105,62 @@ export function GitSourceFields({ onSyncEnvChange, onAuthTypeChange, onTokenChange, + onDeployKeyChange, + onSshKnownHostsEntryChange, + onSshHostKeyFingerprintChange, onApplyModeChange, onBrowse, + stackName, }: GitSourceFieldsProps) { const copy = APPLY_MODE_COPY[variant]; const primaryComposePath = composePaths[0] ?? ''; const canBrowse = !!repoUrl?.trim() && !!branch?.trim(); + const [hostKeyRotation, setHostKeyRotation] = useState(null); + + useEffect(() => { + setHostKeyRotation(null); + }, [repoUrl]); + + const probeHostKey = async () => { + if (!repoUrl.trim()) { + toast.error('Enter a repository URL first.'); + return; + } + try { + const res = await apiFetch('/git-sources/ssh-host-key', { + method: 'POST', + body: JSON.stringify({ + repo_url: repoUrl.trim(), + ...(stackName ? { stack_name: stackName } : {}), + }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast.error(err?.error || 'Failed to fetch host key.'); + return; + } + const data = await res.json() as { keys?: Array<{ fingerprint: string; line: string }> }; + const first = data.keys?.[0]; + if (!first) { + toast.error('No host keys returned.'); + return; + } + const previousFingerprint = (storedHostKeyFingerprint ?? sshHostKeyFingerprint).trim(); + if (previousFingerprint && previousFingerprint !== first.fingerprint) { + setHostKeyRotation({ previous: previousFingerprint, current: first.fingerprint }); + toast.warning('Host key fingerprint changed. Review the new fingerprint before saving.'); + } else { + setHostKeyRotation(null); + if (!previousFingerprint) { + toast.success(`Trusted host key fingerprint: ${first.fingerprint}`); + } + } + onSshHostKeyFingerprintChange(first.fingerprint); + onSshKnownHostsEntryChange(first.line); + } catch (e) { + toast.error((e as Error)?.message || 'Network error.'); + } + }; const radioOption = (mode: ApplyMode) => ( + {authType === 'token' && (
@@ -220,6 +307,59 @@ export function GitSourceFields({

)} + {authType === 'deploy_key' && ( +
+ {hostKeyRotation && ( +
+
+ +
+

Host key fingerprint changed

+

+ The server presented a different key than the one you trusted. Confirm this is an expected rotation before saving. +

+

+ Previously trusted: + {hostKeyRotation.previous} +

+

+ New fingerprint: + {hostKeyRotation.current} +

+
+
+
+ )} +
+ + {(sshHostKeyFingerprint || storedHostKeyFingerprint) && ( + + {sshHostKeyFingerprint || storedHostKeyFingerprint} + + )} +
+