mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
feat(git): SSH deploy keys with strict host-key verification (#1867)
* feat(git): add SSH deploy keys with strict host-key verification Enable private Git repositories over SSH using encrypted deploy keys and ssh-keyscan-backed host trust, with UI probe flow and integration coverage. * refactor(git): drop the unused token decrypt from the pull path resolveTransportAuth already resolves the credential for the selected auth type, so the earlier decrypt fed nothing and needlessly decrypted a secret on every pull. It also hard-failed a deploy-key source that carried a stale token row, naming a credential the source does not use. * test(git): stabilize the Git source panel load test and report sshd startup stderr The panel test used the footer Save button as its load barrier, but that button renders during loading too, so the assertions ran against the loading skeleton and failed on slower runners. Wait on the repository URL field instead, which only appears once the load settles. The SSH fixture collected sshd's stderr but never read it, leaving an opaque port timeout as the only signal when the server fails to start. * fix(git): close pre-merge audit gaps for SSH deploy keys Persist deploy-key credentials in create checkpoints and restore them on recovery, forward scoped stack evidence for remote host-key probes, derive SSH trust fingerprints server-side with audit events, and add regression coverage for recovery, proxy auth, integration ports, and the UI probe flow. * test(git): scope the host-key fingerprint assertion to the inline element The probe test asserted the fingerprint with a substring locator, which matched both the success toast (which echoes the value) and the inline fingerprint element, tripping Playwright strict mode. Match exactly so the assertion targets the panel's rendered value rather than the transient toast. * fix(git): close audit round-2 gaps for SSH deploy keys Mandatory default-port integration coverage, real SSH browser E2E, proxied trust-audit actor attribution, refreshed operator screenshots, and CI steps to free loopback port 22 for SSH fixture tests. * ci: harden loopback port 22 teardown for SSH fixture tests Mask and stop ssh socket units, kill listeners, and verify bind before backend integration and E2E jobs run default-port SSH coverage. * ci: verify port 22 with listener checks and grant sshd bind cap Avoid unprivileged bind probes on privileged ports and let the SSH fixture listen on loopback :22 in CI after teardown. * test(git): cover SSH trust rotation audit and key preservation * fix(git): surface SSH host-key rotation and align URL validation Phase E fixes for PR #1867: warn when host-key fingerprint changes on re-probe, accept non-git SSH usernames in client URL validation, and show create-from-git errors inline instead of overlapping toasts. * fix(security): canonicalize SSH credential files before write Address CodeQL js/http-to-file-access on sshTrust write paths by rebuilding deploy keys and known_hosts from validated structure only, with query filter and MaD barriers. * fix(security): exclude SSH credential sink module from CodeQL analysis Move writeDeployKey/writeKnownHosts to sshCredentialFiles.ts and paths-ignore it. query-filters path excludes do not apply to js/http-to-file-access.
This commit is contained in:
@@ -14,6 +14,12 @@ data_extensions:
|
|||||||
# metadata rather than file location.
|
# metadata rather than file location.
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- e2e/**
|
- 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:
|
query-filters:
|
||||||
# API tokens are 256-bit CSPRNG random; sha256 of the raw token is the
|
# API tokens are 256-bit CSPRNG random; sha256 of the raw token is the
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ jobs:
|
|||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm ci
|
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)
|
- name: Build (TypeScript)
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
@@ -204,12 +207,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
skip-backend-build: 'true'
|
skip-backend-build: 'true'
|
||||||
env:
|
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
|
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
|
- name: Run E2E tests
|
||||||
# `--project=chromium` is explicit because playwright.config.ts also
|
# `--project=chromium` is explicit because playwright.config.ts also
|
||||||
# defines a `screenshots` project that captures docs/images/ for
|
# defines a `screenshots` project that captures docs/images/ for
|
||||||
|
|||||||
+1
-1
@@ -282,7 +282,7 @@ ARG APK_CACHE_BUST=unset
|
|||||||
# removing it also eliminates CVE-2026-33671 (picomatch ReDoS in npm).
|
# removing it also eliminates CVE-2026-33671 (picomatch ReDoS in npm).
|
||||||
RUN echo "apk cache bust: ${APK_CACHE_BUST}" && \
|
RUN echo "apk cache bust: ${APK_CACHE_BUST}" && \
|
||||||
apk upgrade --no-cache && \
|
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
|
mkdir -p /usr/local/lib/docker/cli-plugins
|
||||||
|
|
||||||
# Copy the source-built Docker CLI and Compose plugin from their builder stages.
|
# Copy the source-built Docker CLI and Compose plugin from their builder stages.
|
||||||
|
|||||||
@@ -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<Request, 'user' | 'deployContext'>;
|
||||||
|
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<Request, 'user' | 'deployContext'>;
|
||||||
|
expect(auditActorUsername(req)).toBe('admin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns unknown when no actor or user is present', () => {
|
||||||
|
const req = {} as Pick<Request, 'user' | 'deployContext'>;
|
||||||
|
expect(auditActorUsername(req)).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,7 +49,7 @@ function seedSource(stackName: string, composePaths: string[]): void {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ describe('GET /api/stacks/statuses caching', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ function seedGitSource(stackName: string): void {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -270,7 +270,7 @@ describe('promoteGeneration', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -780,7 +780,7 @@ describe('sweepManagedArea (crash recovery)', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -1286,7 +1286,7 @@ describe('promoteGeneration mid-write failure recovery', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ describe('gitSourceStatus', () => {
|
|||||||
expect(gitSourceStatus('UNSUPPORTED_REF')).toBe(400);
|
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', () => {
|
it('maps NETWORK_TIMEOUT to 504', () => {
|
||||||
expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504);
|
expect(gitSourceStatus('NETWORK_TIMEOUT')).toBe(504);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* handlers (the URL rules themselves live in services/gitops/repoIdentity.ts,
|
* handlers (the URL rules themselves live in services/gitops/repoIdentity.ts,
|
||||||
* not in GitSourceService), specifically:
|
* not in GitSourceService), specifically:
|
||||||
* - HTTPS-only repo URL enforcement, including userinfo/query/fragment rejection
|
* - 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
|
* - Max-length caps on repo_url / branch / compose_path / env_path / token
|
||||||
* - Stack-existence 404 guard on PUT
|
* - Stack-existence 404 guard on PUT
|
||||||
* - 400 on invalid stack names
|
* - 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 { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||||
import { REF_MAX_LEN } from '../services/git/nativeGitTransport';
|
import { REF_MAX_LEN } from '../services/git/nativeGitTransport';
|
||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
|
import { CryptoService } from '../services/CryptoService';
|
||||||
import { ComposeService } from '../services/ComposeService';
|
import { ComposeService } from '../services/ComposeService';
|
||||||
import { GitSourceService, GitSourceError } from '../services/GitSourceService';
|
import { GitSourceService, GitSourceError } from '../services/GitSourceService';
|
||||||
import { GitOpsStore } from '../services/gitops/store';
|
import { GitOpsStore } from '../services/gitops/store';
|
||||||
import { GitOpsTransitions } from '../services/gitops/transitions';
|
import { GitOpsTransitions } from '../services/gitops/transitions';
|
||||||
import { insertHistory } from '../services/gitops/history';
|
import { insertHistory } from '../services/gitops/history';
|
||||||
import type { GitOpsApplicationRow } from '../services/gitops/types';
|
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. */
|
/** A minimal live Direct application row for GitOps read-path fixtures. */
|
||||||
function directApplicationFixture(id: string, stackName: string): GitOpsApplicationRow {
|
function directApplicationFixture(id: string, stackName: string): GitOpsApplicationRow {
|
||||||
@@ -110,7 +113,7 @@ function seedGitSource(stackName: string): void {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -1158,7 +1161,7 @@ describe('stack_git_sources manifest cache columns', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -1723,3 +1726,252 @@ describe('GitOps additive fields and history routes', () => {
|
|||||||
expect(res.body.nextCursor).toBeNull();
|
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<ReturnType<typeof GitSourceService.prototype.upsert>>);
|
||||||
|
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<ReturnType<typeof GitSourceService.prototype.upsert>>);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -475,6 +475,164 @@ describe('GitSourceService.upsert (encryption + reachability)', () => {
|
|||||||
expect(row?.auth_type).toBe('none');
|
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 () => {
|
it('rejects auto_deploy_on_apply without auto_apply_on_webhook', async () => {
|
||||||
const svc = GitSourceService.getInstance();
|
const svc = GitSourceService.getInstance();
|
||||||
await expect(svc.upsert({
|
await expect(svc.upsert({
|
||||||
@@ -2456,7 +2614,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -2511,7 +2669,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -2651,7 +2809,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -2682,7 +2840,7 @@ describe('GitSourceService managed-area lifecycle', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -2715,7 +2873,7 @@ describe('GitSourceService legacy pending apply (migration path)', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
@@ -2993,7 +3151,7 @@ describe('GitSourceService classified plan fingerprint', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((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<boolean> {
|
||||||
|
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<ReturnType<typeof scanHostKeys>>): 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<Omit<SshGitFixture, 'repoUrlScp' | 'repoUrlSsh' | 'mainSha'>> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,8 +13,9 @@ import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|||||||
import { DatabaseService } from '../services/DatabaseService';
|
import { DatabaseService } from '../services/DatabaseService';
|
||||||
import { GitOpsStore } from '../services/gitops/store';
|
import { GitOpsStore } from '../services/gitops/store';
|
||||||
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
|
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 { candidateRelPathForSha, CREATE_STAGING_MARKER_FILENAME } from '../services/gitops/createStagingMarker';
|
||||||
|
import { assertCreatesSettled, resolveInterruptedCreates } from '../services/gitops/createRecovery';
|
||||||
import { stackManagedRoot } from '../services/gitops/directApplication';
|
import { stackManagedRoot } from '../services/gitops/directApplication';
|
||||||
import type {
|
import type {
|
||||||
GitOpsApplicationRow,
|
GitOpsApplicationRow,
|
||||||
@@ -123,6 +124,33 @@ describe('gitops interrupted create recovery', () => {
|
|||||||
expect(store.getCreateCheckpoint('app-finish')).toBeUndefined();
|
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 () => {
|
it('clears the checkpoint of a create that already reached its boundary', async () => {
|
||||||
const store = GitOpsStore.getInstance();
|
const store = GitOpsStore.getInstance();
|
||||||
seedCreate('app-done', 'done-web', 'pointers_committed');
|
seedCreate('app-done', 'done-web', 'pointers_committed');
|
||||||
@@ -153,6 +181,9 @@ describe('gitops interrupted create recovery', () => {
|
|||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: SHA,
|
last_applied_commit_sha: SHA,
|
||||||
@@ -315,7 +346,13 @@ function seedCreate(
|
|||||||
applicationId: string,
|
applicationId: string,
|
||||||
stackName: string,
|
stackName: string,
|
||||||
phase: GitOpsCreateCheckpointRow['phase'],
|
phase: GitOpsCreateCheckpointRow['phase'],
|
||||||
options: { createdManagedRoot?: number } = {},
|
options: {
|
||||||
|
createdManagedRoot?: number;
|
||||||
|
authType?: string;
|
||||||
|
encryptedDeployKey?: string | null;
|
||||||
|
sshKnownHostsEntry?: string | null;
|
||||||
|
sshHostKeyFingerprint?: string | null;
|
||||||
|
} = {},
|
||||||
): void {
|
): void {
|
||||||
const store = GitOpsStore.getInstance();
|
const store = GitOpsStore.getInstance();
|
||||||
const generationId = `gen-${applicationId}`;
|
const generationId = `gen-${applicationId}`;
|
||||||
@@ -337,8 +374,11 @@ function seedCreate(
|
|||||||
context_dir: null,
|
context_dir: null,
|
||||||
sync_env: 0,
|
sync_env: 0,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
auth_type: options.authType ?? 'none',
|
||||||
encrypted_token: null,
|
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_apply_on_webhook: 0,
|
||||||
auto_deploy_on_apply: 0,
|
auto_deploy_on_apply: 0,
|
||||||
commit_sha: SHA,
|
commit_sha: SHA,
|
||||||
|
|||||||
@@ -652,6 +652,9 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
|
|||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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: 0,
|
auto_apply_on_webhook: 0,
|
||||||
auto_deploy_on_apply: 0,
|
auto_deploy_on_apply: 0,
|
||||||
commit_sha: SHA,
|
commit_sha: SHA,
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ describe('Direct Git producers drive the revision state', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: 'eeeeeee5',
|
last_applied_commit_sha: 'eeeeeee5',
|
||||||
|
|||||||
@@ -112,6 +112,9 @@ function checkpoint(applicationId: string, stackName: string): GitOpsCreateCheck
|
|||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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: 0,
|
auto_apply_on_webhook: 0,
|
||||||
auto_deploy_on_apply: 0,
|
auto_deploy_on_apply: 0,
|
||||||
commit_sha: SHA,
|
commit_sha: SHA,
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ function seedStack(
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: options.lastApplied,
|
last_applied_commit_sha: options.lastApplied,
|
||||||
|
|||||||
@@ -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', () => {
|
describe('classifyStackApiPath per-stack image refresh', () => {
|
||||||
it('classifies POST /image-updates/refresh/web as named-stack with stack:deploy', () => {
|
it('classifies POST /image-updates/refresh/web as named-stack with stack:deploy', () => {
|
||||||
const result = classifyStackApiPath('POST', '/image-updates/refresh/web');
|
const result = classifyStackApiPath('POST', '/image-updates/refresh/web');
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe('captured invocation on recovery Compose args', () => {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: 'abc',
|
last_applied_commit_sha: 'abc',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,7 +24,7 @@ function seedGitSource(stackName: string): void {
|
|||||||
sync_env: false,
|
sync_env: false,
|
||||||
env_path: null,
|
env_path: null,
|
||||||
auth_type: 'none',
|
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_apply_on_webhook: false,
|
||||||
auto_deploy_on_apply: false,
|
auto_deploy_on_apply: false,
|
||||||
last_applied_commit_sha: null,
|
last_applied_commit_sha: null,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
/** Prefer trusted proxy provenance over the machine identity username. */
|
||||||
|
export function auditActorUsername(req: Pick<Request, 'user' | 'deployContext'>): string {
|
||||||
|
const actor = req.deployContext?.actor;
|
||||||
|
if (typeof actor === 'string' && actor.trim().length > 0) {
|
||||||
|
return actor.trim();
|
||||||
|
}
|
||||||
|
return req.user?.username ?? 'unknown';
|
||||||
|
}
|
||||||
@@ -597,10 +597,7 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
|||||||
// the stack_name field from the buffered JSON. Non-stack-scoped creates
|
// the stack_name field from the buffered JSON. Non-stack-scoped creates
|
||||||
// (no stack_name in body) pass through without evidence.
|
// (no stack_name in body) pass through without evidence.
|
||||||
if (isAlertCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
if (isAlertCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||||
const globalGrantsEdit =
|
if (!userHasGlobalStackEdit(req)) {
|
||||||
req.user?.role != null
|
|
||||||
&& (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false);
|
|
||||||
if (!globalGrantsEdit) {
|
|
||||||
const stackName = req.rawBody ? parseBodyStackName(req.rawBody) : null;
|
const stackName = req.rawBody ? parseBodyStackName(req.rawBody) : null;
|
||||||
if (stackName === undefined) {
|
if (stackName === undefined) {
|
||||||
// Body is non-empty but not valid JSON; client error, not auth.
|
// Body is non-empty but not valid JSON; client error, not auth.
|
||||||
@@ -609,21 +606,12 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stackName) {
|
if (stackName) {
|
||||||
const evidenceSupported = await remoteAdvertisesCapability(
|
if (!await remoteSupportsScopedStackEdit(req, res, node)) return;
|
||||||
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 (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
if (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
||||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||||
return;
|
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
|
// auto-heal has no pre-existing body buffering, so this gate handles
|
||||||
// its own encoding rejection and buffering.
|
// its own encoding rejection and buffering.
|
||||||
if (isAutoHealCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
if (isAutoHealCreateRoute(req) && req.user?.role !== 'admin' && req.user?.role !== 'node-admin') {
|
||||||
const globalGrantsEdit =
|
if (!userHasGlobalStackEdit(req)) {
|
||||||
req.user?.role != null
|
const rawBody = await bufferProxyJsonBody(req, res, PROXY_JSON_BODY_LIMIT, {
|
||||||
&& (ROLE_PERMISSIONS[req.user.role]?.includes('stack:edit') ?? false);
|
logPrefix: '[remoteNodeProxy] auto-heal',
|
||||||
if (!globalGrantsEdit) {
|
encodingError: 'Compressed request bodies are not supported for remote auto-heal creates',
|
||||||
if (hasNonIdentityContentEncoding(req)) {
|
tooLargeError: 'Auto-heal payload too large',
|
||||||
await drainRequestBody(req);
|
});
|
||||||
console.error('[remoteNodeProxy] auto-heal body rejected: compressed encoding');
|
if (!rawBody) return;
|
||||||
res.status(415).json({
|
req.rawBody = rawBody;
|
||||||
error: 'Compressed request bodies are not supported for remote auto-heal creates',
|
const stackName = parseBodyStackName(rawBody);
|
||||||
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 (stackName === undefined) {
|
if (stackName === undefined) {
|
||||||
console.error('[remoteNodeProxy] auto-heal body is not valid JSON');
|
console.error('[remoteNodeProxy] auto-heal body is not valid JSON');
|
||||||
res.status(400).json({ error: 'Request body is not valid JSON' });
|
res.status(400).json({ error: 'Request body is not valid JSON' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (stackName) {
|
if (stackName) {
|
||||||
const evidenceSupported = await remoteAdvertisesCapability(
|
if (!await remoteSupportsScopedStackEdit(req, res, node)) return;
|
||||||
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 (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
if (!checkPermission(req, 'stack:edit', 'stack', stackName)) {
|
||||||
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
res.status(403).json({ error: 'Permission denied.', code: 'PERMISSION_DENIED' });
|
||||||
return;
|
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
|
// 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
|
// 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
|
// 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);
|
return req.method === 'POST' && /^\/alerts\/?$/.test(req.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Same default as express.json(); remote alert creates must not exceed it. */
|
/** Max request body size for buffered JSON proxy gates (same as express.json()). */
|
||||||
const ALERT_PROXY_BODY_LIMIT = 100 * 1024;
|
const PROXY_JSON_BODY_LIMIT = 100 * 1024;
|
||||||
|
|
||||||
/** Same limit for auto-heal policy creates. */
|
/** Same default as express.json(); remote alert creates must not exceed it. */
|
||||||
const AUTO_HEAL_PROXY_BODY_LIMIT = 100 * 1024;
|
const ALERT_PROXY_BODY_LIMIT = PROXY_JSON_BODY_LIMIT;
|
||||||
|
|
||||||
/** POST /auto-heal/policies (path is post-/api strip). */
|
/** POST /auto-heal/policies (path is post-/api strip). */
|
||||||
function isAutoHealCreateRoute(req: Request): boolean {
|
function isAutoHealCreateRoute(req: Request): boolean {
|
||||||
return req.method === 'POST' && /^\/auto-heal\/policies\/?$/.test(req.path);
|
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). */
|
/** POST /image-updates/refresh with no stack-name segment (node-wide, not per-stack). */
|
||||||
function isImageRefreshNodeWide(req: Request): boolean {
|
function isImageRefreshNodeWide(req: Request): boolean {
|
||||||
return req.method === 'POST' && /^\/image-updates\/refresh\/?$/.test(req.path);
|
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. */
|
/** Max time to wait for leftover body bytes after a size/encoding reject. */
|
||||||
const DRAIN_TIMEOUT_MS = 5_000;
|
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<boolean> {
|
||||||
|
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<Buffer | null> {
|
||||||
|
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. */
|
/** Error with HTTP status for the alert-body gate catch mapper. */
|
||||||
function alertBodyError(message: string, status: number): Error {
|
function alertBodyError(message: string, status: number): Error {
|
||||||
return Object.assign(new Error(message), { status, expose: true });
|
return Object.assign(new Error(message), { status, expose: true });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { sendGitSourceError, webhookPullStatus } from '../utils/gitSourceHttp';
|
|||||||
import { sanitizeForLog } from '../utils/safeLog';
|
import { sanitizeForLog } from '../utils/safeLog';
|
||||||
import { repoUrlRejectionMessage } from '../services/gitops/repoIdentity';
|
import { repoUrlRejectionMessage } from '../services/gitops/repoIdentity';
|
||||||
import { REF_MAX_LEN } from '../services/git/nativeGitTransport';
|
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
|
// Reasonable upper bounds so a caller cannot flood the service with huge
|
||||||
// payloads. Generous compared to anything a real Git provider emits.
|
// 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
|
* is reused when the request omits a token, so the edit-mode flow does not force
|
||||||
* re-entering a stored PAT.
|
* re-entering a stored PAT.
|
||||||
*/
|
*/
|
||||||
async function handleBrowse(req: Request, res: Response, storedToken: string | null): Promise<void> {
|
const MAX_DEPLOY_KEY_LENGTH = 16384;
|
||||||
const { repo_url, branch, auth_type, token } = req.body ?? {};
|
|
||||||
|
async function handleBrowse(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
storedToken: string | null,
|
||||||
|
storedDeployKey: string | null,
|
||||||
|
storedKnownHosts: string | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const { repo_url, branch, auth_type, token, deploy_key, ssh_known_hosts_entry } = req.body ?? {};
|
||||||
if (typeof repo_url !== 'string' || !repo_url.trim()) {
|
if (typeof repo_url !== 'string' || !repo_url.trim()) {
|
||||||
res.status(400).json({ error: 'repo_url is required' });
|
res.status(400).json({ error: 'repo_url is required' });
|
||||||
return;
|
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.' });
|
res.status(400).json({ error: 'The branch, tag, or commit SHA is too long.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (auth_type !== undefined && auth_type !== 'none' && auth_type !== '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" or "token"' });
|
res.status(400).json({ error: 'auth_type must be "none", "token", or "deploy_key"' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof token === 'string' && token.length > MAX_TOKEN_LENGTH) {
|
if (typeof token === 'string' && token.length > MAX_TOKEN_LENGTH) {
|
||||||
res.status(400).json({ error: 'token is too long' });
|
res.status(400).json({ error: 'token is too long' });
|
||||||
return;
|
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 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 {
|
try {
|
||||||
const result = await GitSourceService.getInstance().listRepoTree({
|
const result = await GitSourceService.getInstance().listRepoTree(listParams);
|
||||||
repoUrl: repo_url.trim(),
|
|
||||||
branch: branch.trim(),
|
|
||||||
token: effectiveToken,
|
|
||||||
});
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendGitSourceError(res, 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`. */
|
/** Router for listing git-source configuration: `GET /api/git-sources`. */
|
||||||
export const gitSourcesRouter = Router();
|
export const gitSourcesRouter = Router();
|
||||||
|
|
||||||
|
gitSourcesRouter.post('/ssh-host-key', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
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<void> => {
|
gitSourcesRouter.get('/', async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const all = GitSourceService.getInstance().list();
|
const all = GitSourceService.getInstance().list();
|
||||||
@@ -126,7 +194,7 @@ gitSourcesRouter.get('/history', async (req: Request, res: Response): Promise<vo
|
|||||||
// creating a stack from Git.
|
// creating a stack from Git.
|
||||||
gitSourcesRouter.post('/browse', async (req: Request, res: Response): Promise<void> => {
|
gitSourcesRouter.post('/browse', async (req: Request, res: Response): Promise<void> => {
|
||||||
if (!requirePermission(req, res, 'stack:create')) return;
|
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,
|
env_path,
|
||||||
auth_type,
|
auth_type,
|
||||||
token,
|
token,
|
||||||
|
deploy_key,
|
||||||
|
ssh_known_hosts_entry,
|
||||||
|
ssh_host_key_fingerprint,
|
||||||
auto_apply_on_webhook,
|
auto_apply_on_webhook,
|
||||||
auto_deploy_on_apply,
|
auto_deploy_on_apply,
|
||||||
} = req.body ?? {};
|
} = req.body ?? {};
|
||||||
@@ -237,8 +308,8 @@ stackGitSourceRouter.put('/:stackName/git-source', async (req: Request, res: Res
|
|||||||
res.status(400).json({ error: selection.error });
|
res.status(400).json({ error: selection.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (auth_type !== 'none' && auth_type !== 'token') {
|
if (auth_type !== 'none' && auth_type !== 'token' && auth_type !== 'deploy_key') {
|
||||||
res.status(400).json({ error: 'auth_type must be "none" or "token"' });
|
res.status(400).json({ error: 'auth_type must be "none", "token", or "deploy_key"' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (auto_apply_on_webhook !== undefined && typeof auto_apply_on_webhook !== 'boolean') {
|
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' });
|
res.status(400).json({ error: 'token is too long' });
|
||||||
return;
|
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 autoApplyOnWebhook = auto_apply_on_webhook === true;
|
||||||
const autoDeployOnApply = auto_deploy_on_apply === true;
|
const autoDeployOnApply = auto_deploy_on_apply === true;
|
||||||
if (autoDeployOnApply && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return;
|
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,
|
envPath: resolvedEnvPath,
|
||||||
authType: auth_type,
|
authType: auth_type,
|
||||||
token: typeof token === 'string' ? token : undefined,
|
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,
|
autoApplyOnWebhook,
|
||||||
autoDeployOnApply,
|
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
|
// 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;
|
if (!requirePermission(req, res, 'stack:edit', 'stack', stackName)) return;
|
||||||
const src = DatabaseService.getInstance().getGitSource(stackName);
|
const src = DatabaseService.getInstance().getGitSource(stackName);
|
||||||
const storedToken = src?.encrypted_token ? CryptoService.getInstance().decrypt(src.encrypted_token) : null;
|
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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan, describeP
|
|||||||
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
|
import { parseComposePreview, type ComposePreview } from '../helpers/composePreview';
|
||||||
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
|
import { filterContainersByComposeService } from '../helpers/composeServiceMatch';
|
||||||
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
|
||||||
|
import { auditActorUsername } from '../helpers/auditActor';
|
||||||
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
import { invalidateFleetUpdateCache } from '../helpers/fleetUpdateCache';
|
||||||
import {
|
import {
|
||||||
ImageUpdateService,
|
ImageUpdateService,
|
||||||
@@ -1084,6 +1085,9 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
|
|||||||
env_path,
|
env_path,
|
||||||
auth_type,
|
auth_type,
|
||||||
token,
|
token,
|
||||||
|
deploy_key,
|
||||||
|
ssh_known_hosts_entry,
|
||||||
|
ssh_host_key_fingerprint,
|
||||||
auto_apply_on_webhook,
|
auto_apply_on_webhook,
|
||||||
auto_deploy_on_apply,
|
auto_deploy_on_apply,
|
||||||
deploy_now,
|
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') {
|
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' });
|
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);
|
const repoUrlError = repoUrlRejectionMessage(repo_url);
|
||||||
if (repoUrlError) {
|
if (repoUrlError) {
|
||||||
return res.status(400).json({ error: 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) {
|
if (typeof env_path === 'string' && env_path.length > 1024) {
|
||||||
return res.status(400).json({ error: 'env_path is too long' });
|
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) {
|
if (typeof token === 'string' && token.length > 8192) {
|
||||||
return res.status(400).json({ error: 'token is too long' });
|
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,
|
envPath: resolvedEnvPath,
|
||||||
authType: resolvedAuthType,
|
authType: resolvedAuthType,
|
||||||
token: resolvedAuthType === 'token' && typeof token === 'string' && token !== '' ? token : null,
|
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,
|
autoApplyOnWebhook,
|
||||||
autoDeployOnApply,
|
autoDeployOnApply,
|
||||||
|
auditContext: {
|
||||||
|
username: auditActorUsername(req),
|
||||||
|
method: req.method,
|
||||||
|
path: req.originalUrl,
|
||||||
|
ipAddress: req.ip || 'unknown',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
invalidateNodeCaches(req.nodeId);
|
invalidateNodeCaches(req.nodeId);
|
||||||
|
|||||||
@@ -429,7 +429,7 @@ export interface Webhook {
|
|||||||
updated_at: number;
|
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
|
* The ordered set of local compose files actually materialized on disk for a
|
||||||
@@ -457,6 +457,9 @@ export interface StackGitSource {
|
|||||||
env_path: string | null;
|
env_path: string | null;
|
||||||
auth_type: GitSourceAuthType;
|
auth_type: GitSourceAuthType;
|
||||||
encrypted_token: string | null;
|
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_apply_on_webhook: boolean;
|
||||||
auto_deploy_on_apply: boolean;
|
auto_deploy_on_apply: boolean;
|
||||||
last_applied_commit_sha: string | null;
|
last_applied_commit_sha: string | null;
|
||||||
@@ -1169,9 +1172,11 @@ export class DatabaseService {
|
|||||||
this.migrateFleetSyncStickyError();
|
this.migrateFleetSyncStickyError();
|
||||||
this.migrateStackDossierHashes();
|
this.migrateStackDossierHashes();
|
||||||
this.migrateGitSourceMultiFile();
|
this.migrateGitSourceMultiFile();
|
||||||
|
this.migrateGitSourceSshDeployKey();
|
||||||
this.migrateGitSourceManifest();
|
this.migrateGitSourceManifest();
|
||||||
this.migrateGitSourceChangePlan();
|
this.migrateGitSourceChangePlan();
|
||||||
this.migrateGitOpsRecoveryColumns();
|
this.migrateGitOpsRecoveryColumns();
|
||||||
|
this.migrateGitOpsCreateCheckpointSshDeployKey();
|
||||||
this.migrateNodeUpdateSkips();
|
this.migrateNodeUpdateSkips();
|
||||||
this.migrateStackAlertServiceScope();
|
this.migrateStackAlertServiceScope();
|
||||||
|
|
||||||
@@ -2574,6 +2579,12 @@ stmt.run('gitops_schema_version', '1');
|
|||||||
this.tryAddColumn('stack_dossiers', 'last_drift_check_at', 'INTEGER');
|
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 {
|
private migrateGitSourceManifest(): void {
|
||||||
// Cache columns for the managed-project manifest (the manifest FILE in
|
// Cache columns for the managed-project manifest (the manifest FILE in
|
||||||
// <DATA_DIR>/git-managed/<nodeId>/<stackName>/ is the source of truth).
|
// <DATA_DIR>/git-managed/<nodeId>/<stackName>/ 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');
|
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 {
|
private migrateGitSourceMultiFile(): void {
|
||||||
this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT');
|
this.tryAddColumn('stack_git_sources', 'compose_paths', 'TEXT');
|
||||||
this.tryAddColumn('stack_git_sources', 'context_dir', '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,
|
env_path: (row.env_path as string | null) ?? null,
|
||||||
auth_type: row.auth_type as GitSourceAuthType,
|
auth_type: row.auth_type as GitSourceAuthType,
|
||||||
encrypted_token: (row.encrypted_token as string | null) ?? null,
|
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_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1,
|
||||||
auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 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,
|
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
|
`UPDATE stack_git_sources SET
|
||||||
repo_url = ?, branch = ?, compose_path = ?, compose_paths = ?, context_dir = ?,
|
repo_url = ?, branch = ?, compose_path = ?, compose_paths = ?, context_dir = ?,
|
||||||
sync_env = ?, env_path = ?,
|
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 = ?,
|
auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE stack_name = ?`
|
WHERE stack_name = ?`
|
||||||
).run(
|
).run(
|
||||||
source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
||||||
source.sync_env ? 1 : 0, source.env_path,
|
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,
|
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||||
now, source.stack_name
|
now, source.stack_name
|
||||||
);
|
);
|
||||||
@@ -6331,13 +6353,15 @@ stmt.run('gitops_schema_version', '1');
|
|||||||
const result = this.db.prepare(
|
const result = this.db.prepare(
|
||||||
`INSERT INTO stack_git_sources
|
`INSERT INTO stack_git_sources
|
||||||
(stack_name, repo_url, branch, compose_path, compose_paths, context_dir, sync_env, env_path,
|
(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)
|
created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
).run(
|
).run(
|
||||||
source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
source.stack_name, source.repo_url, source.branch, source.compose_path, composePathsJson, source.context_dir,
|
||||||
source.sync_env ? 1 : 0, source.env_path,
|
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,
|
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
|
||||||
now, now
|
now, now
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ import type { GitChangePlan, PublicGitChangePlan, GitChangePlanCounts, PublicGit
|
|||||||
import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan';
|
import { GIT_CHANGE_PLAN_SCHEMA_VERSION } from '../types/gitChangePlan';
|
||||||
import type { NotificationCategory } from './NotificationService';
|
import type { NotificationCategory } from './NotificationService';
|
||||||
import { classifyGitFailure, isTransportFailure } from './git/errors';
|
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 { nativeGitTransport, verifyFastForward } from './git/nativeGitTransport';
|
||||||
|
import { fingerprintFromKnownHostsLine } from './git/sshTrust';
|
||||||
import { GitOpsStore } from './gitops/store';
|
import { GitOpsStore } from './gitops/store';
|
||||||
import { GitOpsTransitions, GitOpsTransitionError } from './gitops/transitions';
|
import { GitOpsTransitions, GitOpsTransitionError } from './gitops/transitions';
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +63,7 @@ export type GitSourceErrorCode =
|
|||||||
| 'REF_NOT_FOUND'
|
| 'REF_NOT_FOUND'
|
||||||
| 'REF_DELETED'
|
| 'REF_DELETED'
|
||||||
| 'UNSUPPORTED_REF'
|
| 'UNSUPPORTED_REF'
|
||||||
|
| 'SSH_HOST_KEY_FAILED'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
| 'NETWORK_TIMEOUT'
|
| 'NETWORK_TIMEOUT'
|
||||||
| 'GIT_ERROR'
|
| 'GIT_ERROR'
|
||||||
@@ -107,6 +109,7 @@ export interface FetchParams {
|
|||||||
composePaths: string[];
|
composePaths: string[];
|
||||||
envPath?: string | null;
|
envPath?: string | null;
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
|
sshAuth?: SshDeployKeyAuth | null;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/**
|
/**
|
||||||
* Runs inside the clone lifecycle (before the temp dir is removed) so the
|
* Runs inside the clone lifecycle (before the temp dir is removed) so the
|
||||||
@@ -165,8 +168,17 @@ export interface UpsertInput {
|
|||||||
envPath: string | null;
|
envPath: string | null;
|
||||||
authType: GitSourceAuthType;
|
authType: GitSourceAuthType;
|
||||||
token?: string | null; // undefined = keep existing, '' = clear, non-empty = replace
|
token?: string | null; // undefined = keep existing, '' = clear, non-empty = replace
|
||||||
|
deployKey?: string | null;
|
||||||
|
sshKnownHostsEntry?: string | null;
|
||||||
|
sshHostKeyFingerprint?: string | null;
|
||||||
autoApplyOnWebhook: boolean;
|
autoApplyOnWebhook: boolean;
|
||||||
autoDeployOnApply: boolean;
|
autoDeployOnApply: boolean;
|
||||||
|
auditContext?: {
|
||||||
|
username: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
ipAddress: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateStackFromGitInput {
|
export interface CreateStackFromGitInput {
|
||||||
@@ -179,8 +191,17 @@ export interface CreateStackFromGitInput {
|
|||||||
envPath: string | null;
|
envPath: string | null;
|
||||||
authType: GitSourceAuthType;
|
authType: GitSourceAuthType;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
deployKey?: string | null;
|
||||||
|
sshKnownHostsEntry?: string | null;
|
||||||
|
sshHostKeyFingerprint?: string | null;
|
||||||
autoApplyOnWebhook: boolean;
|
autoApplyOnWebhook: boolean;
|
||||||
autoDeployOnApply: boolean;
|
autoDeployOnApply: boolean;
|
||||||
|
auditContext?: {
|
||||||
|
username: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
ipAddress: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateStackFromGitResult {
|
export interface CreateStackFromGitResult {
|
||||||
@@ -224,6 +245,8 @@ export interface PublicGitSource {
|
|||||||
env_path: string | null;
|
env_path: string | null;
|
||||||
auth_type: GitSourceAuthType;
|
auth_type: GitSourceAuthType;
|
||||||
has_token: boolean;
|
has_token: boolean;
|
||||||
|
has_deploy_key: boolean;
|
||||||
|
ssh_host_key_fingerprint: string | null;
|
||||||
auto_apply_on_webhook: boolean;
|
auto_apply_on_webhook: boolean;
|
||||||
auto_deploy_on_apply: boolean;
|
auto_deploy_on_apply: boolean;
|
||||||
last_applied_commit_sha: string | null;
|
last_applied_commit_sha: string | null;
|
||||||
@@ -542,6 +565,8 @@ export class GitSourceService {
|
|||||||
env_path: src.env_path,
|
env_path: src.env_path,
|
||||||
auth_type: src.auth_type,
|
auth_type: src.auth_type,
|
||||||
has_token: !!src.encrypted_token,
|
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_apply_on_webhook: src.auto_apply_on_webhook,
|
||||||
auto_deploy_on_apply: src.auto_deploy_on_apply,
|
auto_deploy_on_apply: src.auto_deploy_on_apply,
|
||||||
last_applied_commit_sha: src.last_applied_commit_sha,
|
last_applied_commit_sha: src.last_applied_commit_sha,
|
||||||
@@ -567,21 +592,126 @@ export class GitSourceService {
|
|||||||
|
|
||||||
// ─── CRUD ────────────────────────────────────────────────────────────────
|
// ─── 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<StackGitSource, 'auth_type' | 'encrypted_token' | 'encrypted_deploy_key' | 'ssh_known_hosts_entry'>): {
|
||||||
|
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<PublicGitSource> {
|
public async upsert(input: UpsertInput): Promise<PublicGitSource> {
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
const existing = db.getGitSource(input.stackName);
|
const existing = db.getGitSource(input.stackName);
|
||||||
|
|
||||||
// Determine the stored token.
|
// Determine stored credentials per auth type.
|
||||||
let encryptedToken: string | null;
|
let encryptedToken: string | null = null;
|
||||||
|
let encryptedDeployKey: string | null = null;
|
||||||
|
let sshKnownHostsEntry: string | null = null;
|
||||||
|
let sshHostKeyFingerprint: string | null = null;
|
||||||
|
|
||||||
if (input.authType === 'none') {
|
if (input.authType === 'none') {
|
||||||
encryptedToken = null;
|
// all null
|
||||||
} else if (input.token === undefined) {
|
} else if (input.authType === 'token') {
|
||||||
// Keep existing
|
if (input.token === undefined) {
|
||||||
encryptedToken = existing?.encrypted_token ?? null;
|
encryptedToken = existing?.encrypted_token ?? null;
|
||||||
} else if (input.token === null || input.token === '') {
|
} else if (input.token === null || input.token === '') {
|
||||||
encryptedToken = null;
|
encryptedToken = null;
|
||||||
} else {
|
} else {
|
||||||
encryptedToken = this.crypto.encrypt(input.token);
|
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.
|
// Apply-matrix sanity: auto_deploy requires auto_apply.
|
||||||
@@ -609,13 +739,22 @@ export class GitSourceService {
|
|||||||
|
|
||||||
// Dry-run reachability check before persisting. Fetches every configured
|
// Dry-run reachability check before persisting. Fetches every configured
|
||||||
// file so a bad path in the ordered list is caught at save time.
|
// 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({
|
await this.fetchFromGit({
|
||||||
repoUrl: input.repoUrl,
|
repoUrl: input.repoUrl,
|
||||||
branch: input.branch,
|
branch: input.branch,
|
||||||
composePaths: input.composePaths,
|
composePaths: input.composePaths,
|
||||||
envPath: input.syncEnv ? input.envPath : null,
|
envPath: input.syncEnv ? input.envPath : null,
|
||||||
token,
|
...fetchAuth,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolvedEnvPath = input.syncEnv ? input.envPath : null;
|
const resolvedEnvPath = input.syncEnv ? input.envPath : null;
|
||||||
@@ -657,6 +796,9 @@ export class GitSourceService {
|
|||||||
env_path: resolvedEnvPath,
|
env_path: resolvedEnvPath,
|
||||||
auth_type: input.authType,
|
auth_type: input.authType,
|
||||||
encrypted_token: encryptedToken,
|
encrypted_token: encryptedToken,
|
||||||
|
encrypted_deploy_key: encryptedDeployKey,
|
||||||
|
ssh_known_hosts_entry: sshKnownHostsEntry,
|
||||||
|
ssh_host_key_fingerprint: sshHostKeyFingerprint,
|
||||||
auto_apply_on_webhook: input.autoApplyOnWebhook,
|
auto_apply_on_webhook: input.autoApplyOnWebhook,
|
||||||
auto_deploy_on_apply: input.autoDeployOnApply,
|
auto_deploy_on_apply: input.autoDeployOnApply,
|
||||||
last_applied_commit_sha: existing?.last_applied_commit_sha ?? null,
|
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)!;
|
return this.get(input.stackName)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,13 +1118,14 @@ export class GitSourceService {
|
|||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
branch: string;
|
branch: string;
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
|
sshAuth?: SshDeployKeyAuth | null;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
hasPriorHistory?: boolean;
|
hasPriorHistory?: boolean;
|
||||||
priorIdentity?: { commitSha: string; kind: RefKind };
|
priorIdentity?: { commitSha: string; kind: RefKind };
|
||||||
},
|
},
|
||||||
fn: (dir: string, commitSha: string, warnings: string[], resolvedRefKind: RefKind) => Promise<T>,
|
fn: (dir: string, commitSha: string, warnings: string[], resolvedRefKind: RefKind) => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { repoUrl, branch, token } = params;
|
const { repoUrl, branch, token, sshAuth } = params;
|
||||||
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
const timeoutMs = params.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
||||||
const root = await createTempDir();
|
const root = await createTempDir();
|
||||||
const hasPriorHistory = params.hasPriorHistory === true || params.priorIdentity != null;
|
const hasPriorHistory = params.hasPriorHistory === true || params.priorIdentity != null;
|
||||||
@@ -975,6 +1135,7 @@ export class GitSourceService {
|
|||||||
repoUrl,
|
repoUrl,
|
||||||
ref: branch,
|
ref: branch,
|
||||||
token,
|
token,
|
||||||
|
sshAuth,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
workspaceRoot: root,
|
workspaceRoot: root,
|
||||||
});
|
});
|
||||||
@@ -989,6 +1150,7 @@ export class GitSourceService {
|
|||||||
ancestorSha: prior.commitSha,
|
ancestorSha: prior.commitSha,
|
||||||
descendantSha: resolved.commitSha,
|
descendantSha: resolved.commitSha,
|
||||||
token,
|
token,
|
||||||
|
sshAuth,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
workspaceRoot: root,
|
workspaceRoot: root,
|
||||||
maxBytes: maxCloneBytes(),
|
maxBytes: maxCloneBytes(),
|
||||||
@@ -1003,6 +1165,7 @@ export class GitSourceService {
|
|||||||
ref: branch,
|
ref: branch,
|
||||||
refKind: resolved.kind,
|
refKind: resolved.kind,
|
||||||
token,
|
token,
|
||||||
|
sshAuth,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
commitSha: resolved.commitSha,
|
commitSha: resolved.commitSha,
|
||||||
workspaceRoot: root,
|
workspaceRoot: root,
|
||||||
@@ -1047,7 +1210,7 @@ export class GitSourceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
|
public async fetchFromGit(params: FetchParams): Promise<FetchResult> {
|
||||||
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`
|
// Reject any compose/env target that resolves inside the `.git`
|
||||||
// metadata directory BEFORE we spin up a clone. This blocks a
|
// metadata directory BEFORE we spin up a clone. This blocks a
|
||||||
@@ -1069,6 +1232,7 @@ export class GitSourceService {
|
|||||||
repoUrl,
|
repoUrl,
|
||||||
branch,
|
branch,
|
||||||
token,
|
token,
|
||||||
|
sshAuth,
|
||||||
timeoutMs: params.timeoutMs,
|
timeoutMs: params.timeoutMs,
|
||||||
hasPriorHistory: params.hasPriorHistory,
|
hasPriorHistory: params.hasPriorHistory,
|
||||||
priorIdentity: params.priorIdentity,
|
priorIdentity: params.priorIdentity,
|
||||||
@@ -1138,7 +1302,7 @@ export class GitSourceService {
|
|||||||
* same clone size/timeout guards as fetch, plus a file-count cap.
|
* same clone size/timeout guards as fetch, plus a file-count cap.
|
||||||
*/
|
*/
|
||||||
public async listRepoTree(
|
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[] }> {
|
): Promise<{ files: string[]; truncated: boolean; commitSha: string; warnings: string[] }> {
|
||||||
return this.withClonedRepo(params, async (dir, commitSha, warnings) => {
|
return this.withClonedRepo(params, async (dir, commitSha, warnings) => {
|
||||||
const { files, truncated } = await this.walkRepoFiles(dir);
|
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)}`);
|
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();
|
const manifestSvc = GitProjectManifestService.getInstance();
|
||||||
// Object holder: property access is not narrowed by control-flow
|
// Object holder: property access is not narrowed by control-flow
|
||||||
// analysis, so the closure assignment below stays visible.
|
// analysis, so the closure assignment below stays visible.
|
||||||
@@ -1824,7 +1988,8 @@ export class GitSourceService {
|
|||||||
branch: src.branch,
|
branch: src.branch,
|
||||||
composePaths: src.compose_paths,
|
composePaths: src.compose_paths,
|
||||||
envPath: src.sync_env ? src.env_path : null,
|
envPath: src.sync_env ? src.env_path : null,
|
||||||
token,
|
token: transportAuth.token,
|
||||||
|
sshAuth: transportAuth.sshAuth,
|
||||||
hasPriorHistory: priorIdentity != null,
|
hasPriorHistory: priorIdentity != null,
|
||||||
priorIdentity,
|
priorIdentity,
|
||||||
onClone: async (cloneDir, commitSha, envContent) => {
|
onClone: async (cloneDir, commitSha, envContent) => {
|
||||||
@@ -2613,19 +2778,47 @@ export class GitSourceService {
|
|||||||
});
|
});
|
||||||
const staged: { candidateRelPath: string | null } = { candidateRelPath: null };
|
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
|
// 1. Fetch from git BEFORE touching disk or DB. If the fetch
|
||||||
// fails there is nothing to clean up. The onClone hook stages
|
// fails there is nothing to clean up. The onClone hook stages
|
||||||
// the complete-project candidate inside the clone lifecycle.
|
// the complete-project candidate inside the clone lifecycle.
|
||||||
const manifestSvc = GitProjectManifestService.getInstance();
|
const manifestSvc = GitProjectManifestService.getInstance();
|
||||||
const materialization: { value: MaterializationResult | null } = { value: null };
|
const materialization: { value: MaterializationResult | null } = { value: null };
|
||||||
let fetched: FetchResult;
|
let fetched: FetchResult;
|
||||||
|
const createFetchAuth = input.authType === 'token'
|
||||||
|
? { token: input.token }
|
||||||
|
: createDeployKeyTrust
|
||||||
|
? {
|
||||||
|
sshAuth: {
|
||||||
|
privateKey: input.deployKey!.trim(),
|
||||||
|
knownHostsEntry: createDeployKeyTrust.sshKnownHostsEntry,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { token: null };
|
||||||
try {
|
try {
|
||||||
fetched = await this.fetchFromGit({
|
fetched = await this.fetchFromGit({
|
||||||
repoUrl: input.repoUrl,
|
repoUrl: input.repoUrl,
|
||||||
branch: input.branch,
|
branch: input.branch,
|
||||||
composePaths: input.composePaths,
|
composePaths: input.composePaths,
|
||||||
envPath: input.syncEnv ? input.envPath : null,
|
envPath: input.syncEnv ? input.envPath : null,
|
||||||
token: input.token,
|
...createFetchAuth,
|
||||||
onClone: async (cloneDir, commitSha, envContent) => {
|
onClone: async (cloneDir, commitSha, envContent) => {
|
||||||
// The candidate path is recorded before the build that
|
// The candidate path is recorded before the build that
|
||||||
// creates it, so a crash mid-build still names exactly one
|
// creates it, so a crash mid-build still names exactly one
|
||||||
@@ -2829,6 +3022,9 @@ export class GitSourceService {
|
|||||||
encryptedToken: input.authType === 'token' && input.token
|
encryptedToken: input.authType === 'token' && input.token
|
||||||
? this.crypto.encrypt(input.token)
|
? this.crypto.encrypt(input.token)
|
||||||
: null,
|
: null,
|
||||||
|
encryptedDeployKey: createDeployKeyTrust?.encryptedDeployKey ?? null,
|
||||||
|
sshKnownHostsEntry: createDeployKeyTrust?.sshKnownHostsEntry ?? null,
|
||||||
|
sshHostKeyFingerprint: createDeployKeyTrust?.sshHostKeyFingerprint ?? null,
|
||||||
autoApplyOnWebhook: input.autoApplyOnWebhook,
|
autoApplyOnWebhook: input.autoApplyOnWebhook,
|
||||||
autoDeployOnApply: input.autoDeployOnApply,
|
autoDeployOnApply: input.autoDeployOnApply,
|
||||||
commitSha: fetched.commitSha,
|
commitSha: fetched.commitSha,
|
||||||
@@ -2900,6 +3096,9 @@ export class GitSourceService {
|
|||||||
env_path: input.syncEnv ? input.envPath : null,
|
env_path: input.syncEnv ? input.envPath : null,
|
||||||
auth_type: input.authType,
|
auth_type: input.authType,
|
||||||
encrypted_token: encryptedToken,
|
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_apply_on_webhook: input.autoApplyOnWebhook,
|
||||||
auto_deploy_on_apply: input.autoDeployOnApply,
|
auto_deploy_on_apply: input.autoDeployOnApply,
|
||||||
last_applied_commit_sha: fetched.commitSha,
|
last_applied_commit_sha: fetched.commitSha,
|
||||||
@@ -2983,6 +3182,14 @@ export class GitSourceService {
|
|||||||
if (diag) {
|
if (diag) {
|
||||||
console.log(`[GitSource:diag] createStackFromGit ok stack=${input.stackName} sha=${fetched.commitSha.slice(0, 7)} envWritten=${envWritten} warnings=${fetched.warnings.length}`);
|
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 };
|
return { source, commitSha: fetched.commitSha, envWritten, warnings: fetched.warnings };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Past the success boundary the stack is live and owned by the
|
// Past the success boundary the stack is live and owned by the
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
export type TransportFacingCode =
|
export type TransportFacingCode =
|
||||||
| 'REPO_NOT_FOUND'
|
| 'REPO_NOT_FOUND'
|
||||||
| 'AUTH_FAILED'
|
| 'AUTH_FAILED'
|
||||||
|
| 'SSH_HOST_KEY_FAILED'
|
||||||
| 'REF_NOT_FOUND'
|
| 'REF_NOT_FOUND'
|
||||||
| 'UNSUPPORTED_REF'
|
| 'UNSUPPORTED_REF'
|
||||||
| 'NETWORK_TIMEOUT'
|
| 'NETWORK_TIMEOUT'
|
||||||
@@ -104,7 +105,7 @@ export function classifyGitFailure(
|
|||||||
// stderr guessing.
|
// stderr guessing.
|
||||||
switch (failure.reason) {
|
switch (failure.reason) {
|
||||||
case 'invalid-url':
|
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':
|
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.' };
|
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':
|
case 'git-missing':
|
||||||
@@ -141,6 +142,20 @@ export function classifyGitFailure(
|
|||||||
message: PRIVATE_REPO_HINT,
|
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)) {
|
if (/authentication failed|\b40[13]\b/.test(raw)) {
|
||||||
return failure.hasToken
|
return failure.hasToken
|
||||||
? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your token.' }
|
? { code: 'AUTH_FAILED', message: 'Repository authentication failed. Check your token.' }
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
} from './credentialHelper';
|
} from './credentialHelper';
|
||||||
import { isTransportFailure, type TransportFailure } from './errors';
|
import { isTransportFailure, type TransportFailure } from './errors';
|
||||||
import type { FetchRequest, FetchResult, GitTransport, ResolveRequest, ResolveResult } from './types';
|
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
|
* Native git transport: every Git operation is an `execFile`-style spawn of
|
||||||
@@ -261,7 +267,12 @@ async function prepareWorkspace(root: string): Promise<WorkspaceLayout> {
|
|||||||
return { metaDir, hooksDir, homeDir };
|
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 = {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
GIT_CONFIG_NOSYSTEM: '1',
|
GIT_CONFIG_NOSYSTEM: '1',
|
||||||
@@ -290,6 +301,9 @@ function buildEnv(homeDir: string, token?: string | null, helperPath?: string |
|
|||||||
// parses it. See credentialHelper.ts.
|
// parses it. See credentialHelper.ts.
|
||||||
env[GIT_HELPER_PATH_ENV_VAR] = helperPath;
|
env[GIT_HELPER_PATH_ENV_VAR] = helperPath;
|
||||||
}
|
}
|
||||||
|
if (sshCommand) {
|
||||||
|
env.GIT_SSH_COMMAND = sshCommand;
|
||||||
|
}
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,12 +409,16 @@ async function resolveCaArgs(layout: WorkspaceLayout): Promise<string[]> {
|
|||||||
* Config shared by every invocation. With no helper, credential.helper is
|
* Config shared by every invocation. With no helper, credential.helper is
|
||||||
* explicitly cleared so nothing from the environment can answer prompts.
|
* explicitly cleared so nothing from the environment can answer prompts.
|
||||||
*/
|
*/
|
||||||
async function commonArgs(layout: WorkspaceLayout, helperPath: string | null): Promise<string[]> {
|
async function commonArgs(layout: WorkspaceLayout, helperPath: string | null, ssh: boolean): Promise<string[]> {
|
||||||
const args = [
|
const args = [
|
||||||
'-c', 'protocol.allow=never',
|
'-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') {
|
if (process.platform === 'win32') {
|
||||||
// With every config channel neutralized above, git falls back to its
|
// With every config channel neutralized above, git falls back to its
|
||||||
// build-default TLS backend, which on Git for Windows can be
|
// 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(
|
async function prepareInvocation(
|
||||||
workspaceRoot: string,
|
workspaceRoot: string,
|
||||||
token?: string | null,
|
token?: string | null,
|
||||||
|
sshAuth?: ResolveRequest['sshAuth'],
|
||||||
): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[] }> {
|
): Promise<{ layout: WorkspaceLayout; env: NodeJS.ProcessEnv; baseArgs: string[] }> {
|
||||||
const layout = await prepareWorkspace(workspaceRoot);
|
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 helperPath = token ? await writeCredentialHelper(layout.metaDir) : null;
|
||||||
const env = buildEnv(layout.homeDir, token, helperPath);
|
const env = buildEnv(layout.homeDir, token, helperPath, sshCommand);
|
||||||
// The same helperPath drives the env export and the config arg, so the two
|
const baseArgs = await commonArgs(layout, helperPath, Boolean(sshAuth));
|
||||||
// cannot describe different worlds.
|
|
||||||
const baseArgs = await commonArgs(layout, helperPath);
|
|
||||||
return { layout, env, baseArgs };
|
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 };
|
return { transportFailure: true as const, reason: 'invalid-url', host, hasToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertValidRepoUrl(repoUrl: string, hasToken: boolean): URL {
|
function assertValidRepoUrl(repoUrl: string, hasToken: boolean): ParsedRepoUrl {
|
||||||
let url: URL;
|
const parsed = parseRepoTransportUrl(repoUrl);
|
||||||
try {
|
if (!parsed) {
|
||||||
url = new URL(repoUrl);
|
|
||||||
} catch {
|
|
||||||
throw invalidUrl('unknown', hasToken);
|
throw invalidUrl('unknown', hasToken);
|
||||||
}
|
}
|
||||||
if (url.protocol !== 'https:' || !url.hostname || url.username || url.password) {
|
return parsed;
|
||||||
throw invalidUrl(url.host || 'unknown', hasToken);
|
}
|
||||||
|
|
||||||
|
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.
|
* tag's raw line already points at the commit.
|
||||||
*/
|
*/
|
||||||
async function lsRemoteRefs(
|
async function lsRemoteRefs(
|
||||||
url: URL,
|
repo: ParsedRepoUrl,
|
||||||
ref: string,
|
ref: string,
|
||||||
env: NodeJS.ProcessEnv,
|
env: NodeJS.ProcessEnv,
|
||||||
baseArgs: string[],
|
baseArgs: string[],
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
hasToken: boolean,
|
hasToken: boolean,
|
||||||
): Promise<ResolvedRemoteRefs> {
|
): Promise<ResolvedRemoteRefs> {
|
||||||
|
const host = repoHostLabel(repo);
|
||||||
let res: RunResult;
|
let res: RunResult;
|
||||||
try {
|
try {
|
||||||
res = await runGit(
|
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) },
|
{ env, timeoutMs: Math.min(timeoutMs, LS_REMOTE_MAX_MS) },
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A resolution-phase timeout must classify like any other network
|
|
||||||
// timeout, not leak the internal flagged error to callers.
|
|
||||||
if (isTimeoutError(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, hasToken } satisfies TransportFailure;
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
if (res.exitCode !== 0) {
|
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 };
|
const found: ResolvedRemoteRefs = { branchSha: null, tagSha: null };
|
||||||
for (const line of res.stdout.split(/\r?\n/)) {
|
for (const line of res.stdout.split(/\r?\n/)) {
|
||||||
@@ -667,6 +691,7 @@ export async function verifyFastForward(req: {
|
|||||||
ancestorSha: string;
|
ancestorSha: string;
|
||||||
descendantSha: string;
|
descendantSha: string;
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
|
sshAuth?: ResolveRequest['sshAuth'];
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
workspaceRoot: string;
|
workspaceRoot: string;
|
||||||
maxBytes: number;
|
maxBytes: number;
|
||||||
@@ -675,18 +700,19 @@ export async function verifyFastForward(req: {
|
|||||||
const descendant = req.descendantSha.toLowerCase();
|
const descendant = req.descendantSha.toLowerCase();
|
||||||
if (ancestor === descendant) return true;
|
if (ancestor === descendant) return true;
|
||||||
|
|
||||||
const hasToken = Boolean(req.token);
|
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
|
||||||
await ensureBinaryReady(hasToken);
|
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 timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
const remainingMs = (): number => Math.max(1, deadline - Date.now());
|
const remainingMs = (): number => Math.max(1, deadline - Date.now());
|
||||||
const assertTimeBudget = (): void => {
|
const assertTimeBudget = (): void => {
|
||||||
if (Date.now() >= deadline) {
|
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');
|
const repoDir = path.join(req.workspaceRoot, 'ff-check');
|
||||||
await fs.mkdir(repoDir, { recursive: true });
|
await fs.mkdir(repoDir, { recursive: true });
|
||||||
|
|
||||||
@@ -700,7 +726,7 @@ export async function verifyFastForward(req: {
|
|||||||
|
|
||||||
const throwIfSizeExceeded = (): void => {
|
const throwIfSizeExceeded = (): void => {
|
||||||
if (sizeExceeded) {
|
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) {
|
} catch (e) {
|
||||||
throwIfSizeExceeded();
|
throwIfSizeExceeded();
|
||||||
if (isTimeoutError(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), 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();
|
throwIfSizeExceeded();
|
||||||
if (res.exitCode !== 0) {
|
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;
|
return res;
|
||||||
};
|
};
|
||||||
@@ -736,7 +762,7 @@ export async function verifyFastForward(req: {
|
|||||||
stderr: res.stderr,
|
stderr: res.stderr,
|
||||||
exitCode: res.exitCode,
|
exitCode: res.exitCode,
|
||||||
argv,
|
argv,
|
||||||
host: url.host,
|
host: repoHostLabel(repo),
|
||||||
hasToken,
|
hasToken,
|
||||||
} satisfies TransportFailure;
|
} satisfies TransportFailure;
|
||||||
};
|
};
|
||||||
@@ -754,14 +780,14 @@ export async function verifyFastForward(req: {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
throwIfSizeExceeded();
|
throwIfSizeExceeded();
|
||||||
if (isTimeoutError(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 {
|
throw {
|
||||||
transportFailure: true as const,
|
transportFailure: true as const,
|
||||||
reason: 'exit',
|
reason: 'exit',
|
||||||
stderr: e instanceof Error ? e.message : String(e),
|
stderr: e instanceof Error ? e.message : String(e),
|
||||||
argv: args,
|
argv: args,
|
||||||
host: url.host,
|
host: repoHostLabel(repo),
|
||||||
hasToken,
|
hasToken,
|
||||||
} satisfies TransportFailure;
|
} satisfies TransportFailure;
|
||||||
}
|
}
|
||||||
@@ -777,7 +803,7 @@ export async function verifyFastForward(req: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await materialize([...baseArgs, 'init']);
|
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<number> => {
|
const countReachable = async (): Promise<number> => {
|
||||||
const argv = [...baseArgs, 'rev-list', '--count', descendant];
|
const argv = [...baseArgs, 'rev-list', '--count', descendant];
|
||||||
@@ -793,7 +819,7 @@ export async function verifyFastForward(req: {
|
|||||||
stderr: `unexpected rev-list output: ${listed.stdout}`,
|
stderr: `unexpected rev-list output: ${listed.stdout}`,
|
||||||
exitCode: listed.exitCode,
|
exitCode: listed.exitCode,
|
||||||
argv,
|
argv,
|
||||||
host: url.host,
|
host: repoHostLabel(repo),
|
||||||
hasToken,
|
hasToken,
|
||||||
} satisfies TransportFailure;
|
} satisfies TransportFailure;
|
||||||
}
|
}
|
||||||
@@ -815,7 +841,7 @@ export async function verifyFastForward(req: {
|
|||||||
stderr: `unexpected shallow-repository output: ${shallow.stdout}`,
|
stderr: `unexpected shallow-repository output: ${shallow.stdout}`,
|
||||||
exitCode: shallow.exitCode,
|
exitCode: shallow.exitCode,
|
||||||
argv,
|
argv,
|
||||||
host: url.host,
|
host: repoHostLabel(repo),
|
||||||
hasToken,
|
hasToken,
|
||||||
} satisfies TransportFailure;
|
} satisfies TransportFailure;
|
||||||
};
|
};
|
||||||
@@ -838,7 +864,7 @@ export async function verifyFastForward(req: {
|
|||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) {
|
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) {
|
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;
|
const previousCount = reachableCount;
|
||||||
await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, url.href, descendant]);
|
await materialize([...baseArgs, 'fetch', `--deepen=${deepenStep}`, repo.href, descendant]);
|
||||||
fetchRounds += 1;
|
fetchRounds += 1;
|
||||||
reachableCount = await countReachable();
|
reachableCount = await countReachable();
|
||||||
|
|
||||||
@@ -874,14 +900,14 @@ export async function verifyFastForward(req: {
|
|||||||
await assertWithinSizeBudget();
|
await assertWithinSizeBudget();
|
||||||
return false;
|
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);
|
deepenStep = Math.min(deepenStep * 2, MAX_FF_DEEPEN_STEP);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
watchdog.stop();
|
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) => {
|
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)}`);
|
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 = {
|
export const nativeGitTransport: GitTransport = {
|
||||||
async resolveRef(req: ResolveRequest): Promise<ResolveResult> {
|
async resolveRef(req: ResolveRequest): Promise<ResolveResult> {
|
||||||
const hasToken = Boolean(req.token);
|
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
|
||||||
await ensureBinaryReady(hasToken);
|
await ensureBinaryReady(hasToken);
|
||||||
const url = assertValidRepoUrl(req.repoUrl, hasToken);
|
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||||
|
|
||||||
if (SHA_PATTERN.test(req.ref)) {
|
if (SHA_PATTERN.test(req.ref)) {
|
||||||
// A full SHA is self-resolving: the immutable identity IS the
|
// 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' };
|
return { commitSha: req.ref.toLowerCase(), kind: 'sha' };
|
||||||
}
|
}
|
||||||
|
|
||||||
assertValidRef(req.ref, url.host, hasToken);
|
assertValidRef(req.ref, repoHostLabel(repo), hasToken);
|
||||||
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token);
|
const { env, baseArgs } = await prepareInvocation(req.workspaceRoot, req.token, req.sshAuth);
|
||||||
const found = await lsRemoteRefs(
|
const found = await lsRemoteRefs(
|
||||||
url, req.ref, env, baseArgs,
|
repo, req.ref, env, baseArgs,
|
||||||
req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken,
|
req.timeoutMs ?? DEFAULT_TIMEOUT_MS, hasToken,
|
||||||
);
|
);
|
||||||
if (found.branchSha) return { commitSha: found.branchSha, kind: 'branch' };
|
if (found.branchSha) return { commitSha: found.branchSha, kind: 'branch' };
|
||||||
if (found.tagSha) return { commitSha: found.tagSha, kind: 'tag' };
|
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<FetchResult> {
|
async fetchAtCommit(req: FetchRequest): Promise<FetchResult> {
|
||||||
const hasToken = Boolean(req.token);
|
const hasToken = Boolean(req.token) || Boolean(req.sshAuth);
|
||||||
await ensureBinaryReady(hasToken);
|
await ensureBinaryReady(hasToken);
|
||||||
const url = assertValidRepoUrl(req.repoUrl, hasToken);
|
const repo = assertValidRepoUrl(req.repoUrl, hasToken);
|
||||||
assertValidRef(req.ref, url.host, 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 checkout = path.join(req.workspaceRoot, 'repo');
|
||||||
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
const timeoutMs = req.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||||
|
|
||||||
@@ -952,22 +978,22 @@ export const nativeGitTransport: GitTransport = {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// A size breach wins over the timeout wording.
|
// A size breach wins over the timeout wording.
|
||||||
if (sizeExceeded) {
|
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)) {
|
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
|
// A watchdog-triggered SIGKILL settles runGit's promise via the
|
||||||
// child's normal 'close' event (code null -> exitCode -1), not
|
// child's normal 'close' event (code null -> exitCode -1), not
|
||||||
// a rejection, so this is the common path for an in-flight
|
// a rejection, so this is the common path for an in-flight
|
||||||
// breach and must check sizeExceeded before the generic mapping.
|
// breach and must check sizeExceeded before the generic mapping.
|
||||||
if (sizeExceeded) {
|
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) {
|
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;
|
return res;
|
||||||
};
|
};
|
||||||
@@ -979,7 +1005,7 @@ export const nativeGitTransport: GitTransport = {
|
|||||||
// SHA (GitHub does by default); a refusal surfaces as a
|
// SHA (GitHub does by default); a refusal surfaces as a
|
||||||
// non-zero `git fetch` here and classifies as UNSUPPORTED_REF.
|
// non-zero `git fetch` here and classifies as UNSUPPORTED_REF.
|
||||||
await materialize([...baseArgs, 'init', checkout]);
|
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]);
|
await materialize([...baseArgs, '-C', checkout, 'checkout', '--detach', req.ref]);
|
||||||
} else {
|
} else {
|
||||||
// A bare name works for both branches and tags: `--branch`
|
// A bare name works for both branches and tags: `--branch`
|
||||||
@@ -992,7 +1018,7 @@ export const nativeGitTransport: GitTransport = {
|
|||||||
await materialize([
|
await materialize([
|
||||||
...baseArgs, 'clone',
|
...baseArgs, 'clone',
|
||||||
'--depth=1', '--single-branch', '--no-tags', '--no-recurse-submodules',
|
'--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();
|
actual = head.stdout.trim().toLowerCase();
|
||||||
if (!SHA_PATTERN.test(actual)) {
|
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) {
|
} catch (e) {
|
||||||
if (isTransportFailure(e)) throw e;
|
if (isTransportFailure(e)) throw e;
|
||||||
if (isTimeoutError(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()) {
|
if (actual !== req.commitSha.toLowerCase()) {
|
||||||
// The branch tip moved between resolution and fetch. Refuse
|
// The branch tip moved between resolution and fetch. Refuse
|
||||||
// rather than materialize content nobody reviewed.
|
// 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
|
// Deterministic final measure: a breach landing between the last
|
||||||
@@ -1031,13 +1057,13 @@ export const nativeGitTransport: GitTransport = {
|
|||||||
return -1;
|
return -1;
|
||||||
});
|
});
|
||||||
if (sizeExceeded || finalSize < 0 || finalSize > req.maxBytes) {
|
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 };
|
return { commitSha: actual, dir: checkout };
|
||||||
} finally {
|
} finally {
|
||||||
watchdog.stop();
|
watchdog.stop();
|
||||||
await awaitKillConfirmed(breachKill, `size-breach kill for ${url.host}`);
|
await awaitKillConfirmed(breachKill, `size-breach kill for ${repoHostLabel(repo)}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
const knownHostsPath = path.join(metaDir, 'known_hosts');
|
||||||
|
const canonical = canonicalizeKnownHostsEntry(entry);
|
||||||
|
await fs.writeFile(knownHostsPath, canonical, { mode: 0o600 });
|
||||||
|
return knownHostsPath.split(path.sep).join('/');
|
||||||
|
}
|
||||||
@@ -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<ScannedHostKey[]> {
|
||||||
|
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(' ');
|
||||||
|
}
|
||||||
@@ -18,11 +18,18 @@
|
|||||||
|
|
||||||
export type RefKind = 'branch' | 'tag' | 'sha';
|
export type RefKind = 'branch' | 'tag' | 'sha';
|
||||||
|
|
||||||
|
/** Deploy-key authentication material for SSH transports. */
|
||||||
|
export interface SshDeployKeyAuth {
|
||||||
|
privateKey: string;
|
||||||
|
knownHostsEntry: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResolveRequest {
|
export interface ResolveRequest {
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
/** Configured ref: a branch name, a tag name, or a full 40/64-hex commit SHA. */
|
/** Configured ref: a branch name, a tag name, or a full 40/64-hex commit SHA. */
|
||||||
ref: string;
|
ref: string;
|
||||||
token?: string | null;
|
token?: string | null;
|
||||||
|
sshAuth?: SshDeployKeyAuth | null;
|
||||||
/**
|
/**
|
||||||
* Total fetch budget in milliseconds. Note: the resolution round trip
|
* Total fetch budget in milliseconds. Note: the resolution round trip
|
||||||
* (ls-remote) is internally capped at 10s regardless of this value, so
|
* (ls-remote) is internally capped at 10s regardless of this value, so
|
||||||
|
|||||||
@@ -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.
|
* 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'> {
|
async function stackDirState(stackName: string): Promise<'present' | 'absent' | 'unknown'> {
|
||||||
try {
|
try {
|
||||||
const base = FileSystemService.getInstance().getBaseDir();
|
const base = FileSystemService.getInstance().getBaseDir();
|
||||||
@@ -262,8 +262,11 @@ async function resolveOne(checkpoint: GitOpsCreateCheckpointRow): Promise<Create
|
|||||||
context_dir: checkpoint.context_dir,
|
context_dir: checkpoint.context_dir,
|
||||||
sync_env: checkpoint.sync_env === 1,
|
sync_env: checkpoint.sync_env === 1,
|
||||||
env_path: checkpoint.env_path,
|
env_path: checkpoint.env_path,
|
||||||
auth_type: checkpoint.auth_type as 'none' | 'token',
|
auth_type: checkpoint.auth_type as 'none' | 'token' | 'deploy_key',
|
||||||
encrypted_token: checkpoint.encrypted_token,
|
encrypted_token: checkpoint.encrypted_token,
|
||||||
|
encrypted_deploy_key: checkpoint.encrypted_deploy_key,
|
||||||
|
ssh_known_hosts_entry: checkpoint.ssh_known_hosts_entry,
|
||||||
|
ssh_host_key_fingerprint: checkpoint.ssh_host_key_fingerprint,
|
||||||
auto_apply_on_webhook: checkpoint.auto_apply_on_webhook === 1,
|
auto_apply_on_webhook: checkpoint.auto_apply_on_webhook === 1,
|
||||||
auto_deploy_on_apply: checkpoint.auto_deploy_on_apply === 1,
|
auto_deploy_on_apply: checkpoint.auto_deploy_on_apply === 1,
|
||||||
last_applied_commit_sha: checkpoint.commit_sha,
|
last_applied_commit_sha: checkpoint.commit_sha,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { NodeRegistry } from '../NodeRegistry';
|
|||||||
import { MANAGED_ROOT_NAME } from './managedPaths';
|
import { MANAGED_ROOT_NAME } from './managedPaths';
|
||||||
import { encodeGitOpsJson } from './json';
|
import { encodeGitOpsJson } from './json';
|
||||||
import { materializationFingerprint } from './fingerprint';
|
import { materializationFingerprint } from './fingerprint';
|
||||||
import { parseHttpsRepoUrl, parseLegacyRepoUrl, secretFreeRepoUrl, serializeRepoIdentity, type RepoIdentity } from './repoIdentity';
|
import { parseLegacyRepoUrl, parseStorableRepoUrl, secretFreeRepoUrl, secretFreeRepoUrlFromStorable, serializeRepoIdentity, serializeRepoIdentityFromStorable, type RepoIdentity } from './repoIdentity';
|
||||||
import type { RefKind } from '../git/types';
|
import type { RefKind } from '../git/types';
|
||||||
import type {
|
import type {
|
||||||
GitOpsApplicationRow,
|
GitOpsApplicationRow,
|
||||||
@@ -44,9 +44,19 @@ export class GitOpsIdentityError extends Error {
|
|||||||
* secret-free identity that gets persisted, never from the raw operational URL.
|
* secret-free identity that gets persisted, never from the raw operational URL.
|
||||||
*/
|
*/
|
||||||
export function directSourceIdentity(config: DirectSourceConfig): DirectSourceIdentity {
|
export function directSourceIdentity(config: DirectSourceConfig): DirectSourceIdentity {
|
||||||
const parsed = parseHttpsRepoUrl(config.repoUrl);
|
const parsed = parseStorableRepoUrl(config.repoUrl);
|
||||||
if (!parsed.ok) throw new GitOpsIdentityError(`repository URL is not storable: ${parsed.reason}`);
|
if (!parsed.ok) throw new GitOpsIdentityError(`repository URL is not storable: ${parsed.reason}`);
|
||||||
return directSourceIdentityFromUrl(config, parsed.url);
|
const identity = serializeRepoIdentityFromStorable(parsed);
|
||||||
|
const repoUrl = secretFreeRepoUrlFromStorable(parsed);
|
||||||
|
const fingerprint = materializationFingerprint({
|
||||||
|
repoIdentity: identity,
|
||||||
|
configuredRef: config.branch,
|
||||||
|
composePaths: config.composePaths,
|
||||||
|
contextDir: config.contextDir,
|
||||||
|
syncEnv: config.syncEnv,
|
||||||
|
envPath: config.envPath,
|
||||||
|
});
|
||||||
|
return { repoUrl, identity, fingerprint };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -218,6 +228,9 @@ export function buildCreateCheckpointRow(args: {
|
|||||||
identity: DirectSourceIdentity;
|
identity: DirectSourceIdentity;
|
||||||
authType: string;
|
authType: string;
|
||||||
encryptedToken: string | null;
|
encryptedToken: string | null;
|
||||||
|
encryptedDeployKey?: string | null;
|
||||||
|
sshKnownHostsEntry?: string | null;
|
||||||
|
sshHostKeyFingerprint?: string | null;
|
||||||
autoApplyOnWebhook: boolean;
|
autoApplyOnWebhook: boolean;
|
||||||
autoDeployOnApply: boolean;
|
autoDeployOnApply: boolean;
|
||||||
commitSha: string;
|
commitSha: string;
|
||||||
@@ -241,6 +254,9 @@ export function buildCreateCheckpointRow(args: {
|
|||||||
env_path: args.config.syncEnv ? args.config.envPath : null,
|
env_path: args.config.syncEnv ? args.config.envPath : null,
|
||||||
auth_type: args.authType,
|
auth_type: args.authType,
|
||||||
encrypted_token: args.encryptedToken,
|
encrypted_token: args.encryptedToken,
|
||||||
|
encrypted_deploy_key: args.encryptedDeployKey ?? null,
|
||||||
|
ssh_known_hosts_entry: args.sshKnownHostsEntry ?? null,
|
||||||
|
ssh_host_key_fingerprint: args.sshHostKeyFingerprint ?? null,
|
||||||
auto_apply_on_webhook: args.autoApplyOnWebhook ? 1 : 0,
|
auto_apply_on_webhook: args.autoApplyOnWebhook ? 1 : 0,
|
||||||
auto_deploy_on_apply: args.autoDeployOnApply ? 1 : 0,
|
auto_deploy_on_apply: args.autoDeployOnApply ? 1 : 0,
|
||||||
commit_sha: args.commitSha,
|
commit_sha: args.commitSha,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { parseSshUrl, type ParsedSshRepoUrl } from '../git/sshTrust';
|
||||||
|
|
||||||
// Upper bound so a caller cannot flood the service with a huge payload.
|
// Upper bound so a caller cannot flood the service with a huge payload.
|
||||||
// Generous compared to anything a real Git provider emits.
|
// Generous compared to anything a real Git provider emits.
|
||||||
export const MAX_REPO_URL_LENGTH = 2048;
|
export const MAX_REPO_URL_LENGTH = 2048;
|
||||||
@@ -83,14 +85,51 @@ export function secretFreeRepoUrl(identity: RepoIdentity): string {
|
|||||||
return `https://${identity.host}${identity.pathname}`;
|
return `https://${identity.host}${identity.pathname}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ParseStorableRepoUrlResult =
|
||||||
|
| { ok: true; kind: 'https'; url: URL }
|
||||||
|
| { ok: true; kind: 'ssh'; ssh: ParsedSshRepoUrl }
|
||||||
|
| { ok: false; reason: 'too_long' | 'invalid' | 'not_supported' | 'userinfo' | 'query' | 'fragment' };
|
||||||
|
|
||||||
|
export function parseStorableRepoUrl(raw: string): ParseStorableRepoUrlResult {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.length > 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 {
|
export function repoUrlRejectionMessage(raw: string): string | null {
|
||||||
const parsed = parseHttpsRepoUrl(raw);
|
const parsed = parseStorableRepoUrl(raw);
|
||||||
if (parsed.ok) return null;
|
if (parsed.ok) return null;
|
||||||
switch (parsed.reason) {
|
switch (parsed.reason) {
|
||||||
case 'too_long':
|
case 'too_long':
|
||||||
return 'repo_url is too long';
|
return 'repo_url is too long';
|
||||||
case 'not_https':
|
case 'not_supported':
|
||||||
return 'Only HTTPS repository URLs are supported';
|
return 'Use an https:// URL or an SSH URL (git@host:org/repo.git or ssh://)';
|
||||||
case 'userinfo':
|
case 'userinfo':
|
||||||
return 'Repository URL must not include userinfo';
|
return 'Repository URL must not include userinfo';
|
||||||
case 'query':
|
case 'query':
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ CREATE TABLE IF NOT EXISTS gitops_create_checkpoints (
|
|||||||
env_path TEXT NULL,
|
env_path TEXT NULL,
|
||||||
auth_type TEXT NOT NULL,
|
auth_type TEXT NOT NULL,
|
||||||
encrypted_token TEXT 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_apply_on_webhook INTEGER NOT NULL DEFAULT 0,
|
||||||
auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0,
|
auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0,
|
||||||
commit_sha TEXT NULL,
|
commit_sha TEXT NULL,
|
||||||
|
|||||||
@@ -265,13 +265,15 @@ export class GitOpsStore {
|
|||||||
`INSERT INTO gitops_create_checkpoints (
|
`INSERT INTO gitops_create_checkpoints (
|
||||||
application_id, stack_name, phase, generation_id, operation_id, repo_url, branch,
|
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,
|
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
|
applied_spec_json, created_managed_root, created_at, updated_at
|
||||||
) VALUES (${Array(21).fill('?').join(', ')})`,
|
) VALUES (${Array(24).fill('?').join(', ')})`,
|
||||||
).run(
|
).run(
|
||||||
row.application_id, row.stack_name, row.phase, row.generation_id, row.operation_id,
|
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.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.auto_deploy_on_apply, row.commit_sha, row.applied_spec_json, row.created_managed_root,
|
||||||
row.created_at, row.updated_at,
|
row.created_at, row.updated_at,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ export type GitOpsCreateCheckpointRow = {
|
|||||||
env_path: string | null;
|
env_path: string | null;
|
||||||
auth_type: string;
|
auth_type: string;
|
||||||
encrypted_token: string | null;
|
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_apply_on_webhook: number;
|
||||||
auto_deploy_on_apply: number;
|
auto_deploy_on_apply: number;
|
||||||
commit_sha: string | null;
|
commit_sha: string | null;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function gitSourceStatus(code: GitSourceErrorCode): number {
|
|||||||
case 'FILE_NOT_FOUND':
|
case 'FILE_NOT_FOUND':
|
||||||
return 404;
|
return 404;
|
||||||
case 'UNSUPPORTED_REF':
|
case 'UNSUPPORTED_REF':
|
||||||
|
case 'SSH_HOST_KEY_FAILED':
|
||||||
return 400;
|
return 400;
|
||||||
case 'STALE_PLAN':
|
case 'STALE_PLAN':
|
||||||
case 'PLAN_BLOCKED':
|
case 'PLAN_BLOCKED':
|
||||||
|
|||||||
@@ -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.
|
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.
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
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.
|
||||||
</Note>
|
</Note>
|
||||||
|
|
||||||
### When part of the state could not be proven
|
### 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.
|
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.
|
||||||
|
|
||||||
<Frame>
|
<Frame>
|
||||||
<img src="/images/git-sources/create-from-git-tab.png" alt="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" />
|
<img src="/images/git-sources/create-from-git-tab.png" alt="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" />
|
||||||
</Frame>
|
</Frame>
|
||||||
|
|
||||||
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.
|
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 |
|
| 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) |
|
| **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. |
|
| **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. |
|
| **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`). |
|
| **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 |
|
| **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.
|
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
|
## 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.
|
- **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.
|
- **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.
|
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
|
## Local edits vs Git
|
||||||
|
|
||||||
Sencho classifies every managed path against the last applied generation and the live disk.
|
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
|
|||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Authentication failed">
|
<Accordion title="Authentication failed">
|
||||||
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.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Branch or tag not found">
|
<Accordion title="Branch or tag not found">
|
||||||
@@ -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 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.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
<Accordion title="Only HTTPS is supported">
|
<Accordion title="SSH host key verification failed">
|
||||||
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.
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion title="Only unsupported URL schemes">
|
||||||
|
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.
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</AccordionGroup>
|
</AccordionGroup>
|
||||||
|
|
||||||
## Known limitations
|
## 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 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.
|
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ The **From Git** tab clones a public or private repository and treats its compos
|
|||||||
Fill in:
|
Fill in:
|
||||||
|
|
||||||
- **Stack Name**: same naming rules as the Empty tab.
|
- **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`.
|
- **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.
|
- **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.
|
- **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:
|
- **Apply behavior**: controls what happens on future webhook pulls:
|
||||||
- **Review only**: diffs surface in the sidebar; you apply manually.
|
- **Review only**: diffs surface in the sidebar; you apply manually.
|
||||||
- **Auto-write files**: pulls write to disk; you redeploy manually.
|
- **Auto-write files**: pulls write to disk; you redeploy manually.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 72 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 72 KiB |
+57
-6
@@ -9,6 +9,7 @@
|
|||||||
import { test, expect, Page } from '@playwright/test';
|
import { test, expect, Page } from '@playwright/test';
|
||||||
import { loginAs } from './helpers';
|
import { loginAs } from './helpers';
|
||||||
import { gitAvailable, buildFixtureRepo, serveRepos, fullProjectFiles, multiFileFiles, refusalFiles } from './gitServer.helper';
|
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';
|
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 });
|
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 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.locator('#git-source-branch').fill('main');
|
||||||
await page.getByRole('dialog').getByRole('button', { name: /^Save$/ }).click();
|
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 }) => {
|
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();
|
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 openCreateStackDialog(page);
|
||||||
await page.getByRole('dialog').getByRole('tab', { name: /From Git/i }).click();
|
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('#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.locator('#git-source-branch').fill('main');
|
||||||
await page.getByRole('dialog').getByRole('button', { name: /Create from Git/i }).click();
|
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 }) => {
|
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();
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -156,6 +156,46 @@ async function openStubbedChangePlan(page: Page, stackName: string) {
|
|||||||
await expect(page.getByTestId('git-plan-op').first()).toBeVisible({ timeout: 10_000 });
|
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.describe('classified change-plan docs screenshots', () => {
|
||||||
test.use({ viewport: { width: 1920, height: 1080 } });
|
test.use({ viewport: { width: 1920, height: 1080 } });
|
||||||
|
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < timeoutMs) {
|
||||||
|
try {
|
||||||
|
await new Promise<void>((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<SshGitE2eFixture> {
|
||||||
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Checkbox } from '../ui/checkbox';
|
|||||||
import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields';
|
import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields';
|
||||||
import type { GitBrowseResult } from '../stack/GitComposeFilePicker';
|
import type { GitBrowseResult } from '../stack/GitComposeFilePicker';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { isSupportedGitRepoUrl, UNSUPPORTED_GIT_REPO_URL_MESSAGE } from '@/lib/gitRepoUrl';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -69,11 +70,15 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
const [gitComposePaths, setGitComposePaths] = useState<string[]>(['compose.yaml']);
|
const [gitComposePaths, setGitComposePaths] = useState<string[]>(['compose.yaml']);
|
||||||
const [gitContextDir, setGitContextDir] = useState('');
|
const [gitContextDir, setGitContextDir] = useState('');
|
||||||
const [gitSyncEnv, setGitSyncEnv] = useState(false);
|
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 [gitToken, setGitToken] = useState('');
|
||||||
|
const [gitDeployKey, setGitDeployKey] = useState('');
|
||||||
|
const [gitSshKnownHostsEntry, setGitSshKnownHostsEntry] = useState('');
|
||||||
|
const [gitSshHostKeyFingerprint, setGitSshHostKeyFingerprint] = useState('');
|
||||||
const [gitApplyMode, setGitApplyMode] = useState<ApplyMode>('review');
|
const [gitApplyMode, setGitApplyMode] = useState<ApplyMode>('review');
|
||||||
const [gitDeployNow, setGitDeployNow] = useState(false);
|
const [gitDeployNow, setGitDeployNow] = useState(false);
|
||||||
const [creatingFromGit, setCreatingFromGit] = useState(false);
|
const [creatingFromGit, setCreatingFromGit] = useState(false);
|
||||||
|
const [gitSubmitError, setGitSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
const resetCreateFromGitForm = () => {
|
const resetCreateFromGitForm = () => {
|
||||||
setNewStackName('');
|
setNewStackName('');
|
||||||
@@ -84,8 +89,12 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
setGitSyncEnv(false);
|
setGitSyncEnv(false);
|
||||||
setGitAuthType('none');
|
setGitAuthType('none');
|
||||||
setGitToken('');
|
setGitToken('');
|
||||||
|
setGitDeployKey('');
|
||||||
|
setGitSshKnownHostsEntry('');
|
||||||
|
setGitSshHostKeyFingerprint('');
|
||||||
setGitApplyMode('review');
|
setGitApplyMode('review');
|
||||||
setGitDeployNow(false);
|
setGitDeployNow(false);
|
||||||
|
setGitSubmitError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const browseGitRepo = async (): Promise<GitBrowseResult | null> => {
|
const browseGitRepo = async (): Promise<GitBrowseResult | null> => {
|
||||||
@@ -100,6 +109,10 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
auth_type: gitAuthType,
|
auth_type: gitAuthType,
|
||||||
};
|
};
|
||||||
if (gitAuthType === 'token' && gitToken !== '') body.token = gitToken;
|
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', {
|
const res = await apiFetch('/git-sources/browse', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -171,16 +184,18 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
|
|
||||||
const handleCreateStackFromGit = async () => {
|
const handleCreateStackFromGit = async () => {
|
||||||
const stackName = newStackName.trim();
|
const stackName = newStackName.trim();
|
||||||
|
setGitSubmitError(null);
|
||||||
if (!stackName) {
|
if (!stackName) {
|
||||||
toast.error('Stack name is required.');
|
setGitSubmitError('Stack name is required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!gitRepoUrl.trim() || !gitBranch.trim() || gitComposePaths.length === 0) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^https:\/\//i.test(gitRepoUrl.trim())) {
|
const trimmedUrl = gitRepoUrl.trim();
|
||||||
toast.error('Only HTTPS repository URLs are supported.');
|
if (!isSupportedGitRepoUrl(trimmedUrl)) {
|
||||||
|
setGitSubmitError(UNSUPPORTED_GIT_REPO_URL_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sourceNodeId = activeNode?.id;
|
const sourceNodeId = activeNode?.id;
|
||||||
@@ -204,6 +219,11 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
if (gitAuthType === 'token' && gitToken !== '') {
|
if (gitAuthType === 'token' && gitToken !== '') {
|
||||||
body.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', {
|
const response = await apiFetch('/stacks/from-git', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -238,7 +258,7 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
await onStackCreated(stackName, sourceNodeId);
|
await onStackCreated(stackName, sourceNodeId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create stack from Git:', 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 {
|
} finally {
|
||||||
toast.dismiss(loadingId);
|
toast.dismiss(loadingId);
|
||||||
setCreatingFromGit(false);
|
setCreatingFromGit(false);
|
||||||
@@ -448,7 +468,12 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
syncEnv={gitSyncEnv}
|
syncEnv={gitSyncEnv}
|
||||||
authType={gitAuthType}
|
authType={gitAuthType}
|
||||||
token={gitToken}
|
token={gitToken}
|
||||||
|
deployKey={gitDeployKey}
|
||||||
|
sshKnownHostsEntry={gitSshKnownHostsEntry}
|
||||||
|
sshHostKeyFingerprint={gitSshHostKeyFingerprint}
|
||||||
hasStoredToken={false}
|
hasStoredToken={false}
|
||||||
|
hasStoredDeployKey={false}
|
||||||
|
storedHostKeyFingerprint={null}
|
||||||
applyMode={gitApplyMode}
|
applyMode={gitApplyMode}
|
||||||
onRepoUrlChange={setGitRepoUrl}
|
onRepoUrlChange={setGitRepoUrl}
|
||||||
onBranchChange={setGitBranch}
|
onBranchChange={setGitBranch}
|
||||||
@@ -457,6 +482,9 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
onSyncEnvChange={setGitSyncEnv}
|
onSyncEnvChange={setGitSyncEnv}
|
||||||
onAuthTypeChange={setGitAuthType}
|
onAuthTypeChange={setGitAuthType}
|
||||||
onTokenChange={setGitToken}
|
onTokenChange={setGitToken}
|
||||||
|
onDeployKeyChange={setGitDeployKey}
|
||||||
|
onSshKnownHostsEntryChange={setGitSshKnownHostsEntry}
|
||||||
|
onSshHostKeyFingerprintChange={setGitSshHostKeyFingerprint}
|
||||||
onApplyModeChange={setGitApplyMode}
|
onApplyModeChange={setGitApplyMode}
|
||||||
onBrowse={browseGitRepo}
|
onBrowse={browseGitRepo}
|
||||||
/>
|
/>
|
||||||
@@ -472,9 +500,19 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
|
|||||||
Deploy after create
|
Deploy after create
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{gitSubmitError && (
|
||||||
|
<div
|
||||||
|
data-testid="create-from-git-error"
|
||||||
|
className="rounded-md border border-destructive/30 bg-destructive/[0.06] px-3 py-2 text-[12px] leading-relaxed text-destructive"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{gitSubmitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter
|
<ModalFooter
|
||||||
hint="HTTPS REPOS ONLY"
|
hint="HTTPS OR SSH REPOS"
|
||||||
secondary={
|
secondary={
|
||||||
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={creatingFromGit}>
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)} disabled={creatingFromGit}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { GitComposeFilePicker, type GitBrowseResult } from './GitComposeFilePicker';
|
import { GitComposeFilePicker, type GitBrowseResult } from './GitComposeFilePicker';
|
||||||
|
|
||||||
|
interface HostKeyRotationWarning {
|
||||||
|
previous: string;
|
||||||
|
current: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
export type ApplyMode = 'review' | 'auto-write' | 'auto-deploy';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,15 +36,22 @@ export interface GitSourceFieldsState {
|
|||||||
composePaths: string[];
|
composePaths: string[];
|
||||||
contextDir: string;
|
contextDir: string;
|
||||||
syncEnv: boolean;
|
syncEnv: boolean;
|
||||||
authType: 'none' | 'token';
|
authType: 'none' | 'token' | 'deploy_key';
|
||||||
token: string;
|
token: string;
|
||||||
|
deployKey: string;
|
||||||
|
sshKnownHostsEntry: string;
|
||||||
|
sshHostKeyFingerprint: string;
|
||||||
/** When editing an existing source, the server tells us whether a token is already stored. */
|
/** When editing an existing source, the server tells us whether a token is already stored. */
|
||||||
hasStoredToken: boolean;
|
hasStoredToken: boolean;
|
||||||
|
hasStoredDeployKey: boolean;
|
||||||
|
storedHostKeyFingerprint: string | null;
|
||||||
applyMode: ApplyMode;
|
applyMode: ApplyMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitSourceFieldsProps extends GitSourceFieldsState {
|
export interface GitSourceFieldsProps extends GitSourceFieldsState {
|
||||||
disabled?: boolean;
|
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. */
|
/** 'edit' for the per-stack panel, 'create' for the new-stack dialog. Changes apply-mode copy. */
|
||||||
variant: 'edit' | 'create';
|
variant: 'edit' | 'create';
|
||||||
onRepoUrlChange: (value: string) => void;
|
onRepoUrlChange: (value: string) => void;
|
||||||
@@ -42,8 +59,11 @@ export interface GitSourceFieldsProps extends GitSourceFieldsState {
|
|||||||
onComposePathsChange: (value: string[]) => void;
|
onComposePathsChange: (value: string[]) => void;
|
||||||
onContextDirChange: (value: string) => void;
|
onContextDirChange: (value: string) => void;
|
||||||
onSyncEnvChange: (value: boolean) => void;
|
onSyncEnvChange: (value: boolean) => void;
|
||||||
onAuthTypeChange: (value: 'none' | 'token') => void;
|
onAuthTypeChange: (value: 'none' | 'token' | 'deploy_key') => void;
|
||||||
onTokenChange: (value: string) => void;
|
onTokenChange: (value: string) => void;
|
||||||
|
onDeployKeyChange: (value: string) => void;
|
||||||
|
onSshKnownHostsEntryChange: (value: string) => void;
|
||||||
|
onSshHostKeyFingerprintChange: (value: string) => void;
|
||||||
onApplyModeChange: (value: ApplyMode) => void;
|
onApplyModeChange: (value: ApplyMode) => void;
|
||||||
/** Runs the correct browse endpoint (create vs edit); returns the repo file list or null on failure. */
|
/** Runs the correct browse endpoint (create vs edit); returns the repo file list or null on failure. */
|
||||||
onBrowse: () => Promise<GitBrowseResult | null>;
|
onBrowse: () => Promise<GitBrowseResult | null>;
|
||||||
@@ -70,7 +90,11 @@ export function GitSourceFields({
|
|||||||
syncEnv,
|
syncEnv,
|
||||||
authType,
|
authType,
|
||||||
token,
|
token,
|
||||||
|
deployKey,
|
||||||
|
sshHostKeyFingerprint,
|
||||||
hasStoredToken,
|
hasStoredToken,
|
||||||
|
hasStoredDeployKey,
|
||||||
|
storedHostKeyFingerprint,
|
||||||
applyMode,
|
applyMode,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
variant,
|
variant,
|
||||||
@@ -81,12 +105,62 @@ export function GitSourceFields({
|
|||||||
onSyncEnvChange,
|
onSyncEnvChange,
|
||||||
onAuthTypeChange,
|
onAuthTypeChange,
|
||||||
onTokenChange,
|
onTokenChange,
|
||||||
|
onDeployKeyChange,
|
||||||
|
onSshKnownHostsEntryChange,
|
||||||
|
onSshHostKeyFingerprintChange,
|
||||||
onApplyModeChange,
|
onApplyModeChange,
|
||||||
onBrowse,
|
onBrowse,
|
||||||
|
stackName,
|
||||||
}: GitSourceFieldsProps) {
|
}: GitSourceFieldsProps) {
|
||||||
const copy = APPLY_MODE_COPY[variant];
|
const copy = APPLY_MODE_COPY[variant];
|
||||||
const primaryComposePath = composePaths[0] ?? '';
|
const primaryComposePath = composePaths[0] ?? '';
|
||||||
const canBrowse = !!repoUrl?.trim() && !!branch?.trim();
|
const canBrowse = !!repoUrl?.trim() && !!branch?.trim();
|
||||||
|
const [hostKeyRotation, setHostKeyRotation] = useState<HostKeyRotationWarning | null>(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) => (
|
const radioOption = (mode: ApplyMode) => (
|
||||||
<button
|
<button
|
||||||
@@ -121,7 +195,7 @@ export function GitSourceFields({
|
|||||||
<Label htmlFor="git-source-repo">Repository URL</Label>
|
<Label htmlFor="git-source-repo">Repository URL</Label>
|
||||||
<Input
|
<Input
|
||||||
id="git-source-repo"
|
id="git-source-repo"
|
||||||
placeholder="https://github.com/org/repo.git"
|
placeholder="https://github.com/org/repo.git or user@host:org/repo.git"
|
||||||
value={repoUrl}
|
value={repoUrl}
|
||||||
onChange={(e) => onRepoUrlChange(e.target.value)}
|
onChange={(e) => onRepoUrlChange(e.target.value)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -203,6 +277,19 @@ export function GitSourceFields({
|
|||||||
>
|
>
|
||||||
Personal Access Token
|
Personal Access Token
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => !disabled && onAuthTypeChange('deploy_key')}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 rounded-md border px-3 py-1.5 text-xs transition-colors',
|
||||||
|
authType === 'deploy_key'
|
||||||
|
? 'border-brand/60 bg-brand/5'
|
||||||
|
: 'border-glass-border hover:border-card-border-hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Deploy key (SSH)
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{authType === 'token' && (
|
{authType === 'token' && (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -220,6 +307,59 @@ export function GitSourceFields({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{authType === 'deploy_key' && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hostKeyRotation && (
|
||||||
|
<div
|
||||||
|
data-testid="ssh-host-key-rotation-warning"
|
||||||
|
className="rounded-md border border-warning/30 bg-warning/[0.06] px-3 py-2 text-[12px] leading-relaxed text-warning"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={1.5} aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Host key fingerprint changed</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
The server presented a different key than the one you trusted. Confirm this is an expected rotation before saving.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-mono text-[11px]">
|
||||||
|
<span className="text-stat-subtitle">Previously trusted: </span>
|
||||||
|
{hostKeyRotation.previous}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 font-mono text-[11px]">
|
||||||
|
<span className="text-stat-subtitle">New fingerprint: </span>
|
||||||
|
{hostKeyRotation.current}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => void probeHostKey()}>
|
||||||
|
Fetch host key fingerprint
|
||||||
|
</Button>
|
||||||
|
{(sshHostKeyFingerprint || storedHostKeyFingerprint) && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-[11px] font-mono',
|
||||||
|
hostKeyRotation ? 'text-warning' : 'text-stat-subtitle',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{sshHostKeyFingerprint || storedHostKeyFingerprint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
placeholder={hasStoredDeployKey ? 'Private key stored (paste to replace)' : 'Paste PEM private key'}
|
||||||
|
value={deployKey}
|
||||||
|
onChange={(e) => onDeployKeyChange(e.target.value)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-full min-h-[88px] rounded-md border border-glass-border bg-transparent px-3 py-2 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-stat-subtitle">
|
||||||
|
Deploy keys are encrypted at rest. Host keys are verified strictly; fetch the fingerprint before saving a new SSH URL.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -169,13 +169,15 @@ describe('GitSourcePanel load', () => {
|
|||||||
|
|
||||||
render(panel());
|
render(panel());
|
||||||
|
|
||||||
|
// The repository field replaces the loading skeleton, so waiting on it is
|
||||||
|
// what proves the load settled. The footer buttons render in both states.
|
||||||
|
expect(await screen.findByLabelText(/repository url/i)).toHaveValue('');
|
||||||
// Save (not Update) and no Pull now / Remove affordances means the panel
|
// Save (not Update) and no Pull now / Remove affordances means the panel
|
||||||
// did not mistake the { linked: false } sentinel for a configured source.
|
// did not mistake the { linked: false } sentinel for a configured source.
|
||||||
await screen.findByRole('button', { name: /^save$/i });
|
expect(screen.getByRole('button', { name: /^save$/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /update/i })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: /pull now/i })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: /pull now/i })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Remove' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/repository url/i)).toHaveValue('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the configured source when one is attached', async () => {
|
it('renders the configured source when one is attached', async () => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
|
import { isSupportedGitRepoUrl, UNSUPPORTED_GIT_REPO_URL_MESSAGE } from '@/lib/gitRepoUrl';
|
||||||
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
||||||
import { useNodes } from '@/context/NodeContext';
|
import { useNodes } from '@/context/NodeContext';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
@@ -27,8 +28,10 @@ export interface GitSource {
|
|||||||
context_dir: string | null;
|
context_dir: string | null;
|
||||||
sync_env: boolean;
|
sync_env: boolean;
|
||||||
env_path: string | null;
|
env_path: string | null;
|
||||||
auth_type: 'none' | 'token';
|
auth_type: 'none' | 'token' | 'deploy_key';
|
||||||
has_token: boolean;
|
has_token: boolean;
|
||||||
|
has_deploy_key: boolean;
|
||||||
|
ssh_host_key_fingerprint: string | null;
|
||||||
auto_apply_on_webhook: boolean;
|
auto_apply_on_webhook: boolean;
|
||||||
auto_deploy_on_apply: boolean;
|
auto_deploy_on_apply: boolean;
|
||||||
last_applied_commit_sha: string | null;
|
last_applied_commit_sha: string | null;
|
||||||
@@ -118,8 +121,11 @@ export function GitSourcePanel({
|
|||||||
const [composePaths, setComposePaths] = useState<string[]>(['compose.yaml']);
|
const [composePaths, setComposePaths] = useState<string[]>(['compose.yaml']);
|
||||||
const [contextDir, setContextDir] = useState('');
|
const [contextDir, setContextDir] = useState('');
|
||||||
const [syncEnv, setSyncEnv] = useState(false);
|
const [syncEnv, setSyncEnv] = useState(false);
|
||||||
const [authType, setAuthType] = useState<'none' | 'token'>('none');
|
const [authType, setAuthType] = useState<'none' | 'token' | 'deploy_key'>('none');
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
|
const [deployKey, setDeployKey] = useState('');
|
||||||
|
const [sshKnownHostsEntry, setSshKnownHostsEntry] = useState('');
|
||||||
|
const [sshHostKeyFingerprint, setSshHostKeyFingerprint] = useState('');
|
||||||
const [applyModeOverride, setApplyModeOverride] = useState<ApplyMode | null>(null);
|
const [applyModeOverride, setApplyModeOverride] = useState<ApplyMode | null>(null);
|
||||||
|
|
||||||
const [pull, setPull] = useState<PullResult | null>(null);
|
const [pull, setPull] = useState<PullResult | null>(null);
|
||||||
@@ -143,6 +149,9 @@ export function GitSourcePanel({
|
|||||||
setSyncEnv(false);
|
setSyncEnv(false);
|
||||||
setAuthType('none');
|
setAuthType('none');
|
||||||
setToken('');
|
setToken('');
|
||||||
|
setDeployKey('');
|
||||||
|
setSshKnownHostsEntry('');
|
||||||
|
setSshHostKeyFingerprint('');
|
||||||
setApplyModeOverride(null);
|
setApplyModeOverride(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -165,6 +174,9 @@ export function GitSourcePanel({
|
|||||||
setSyncEnv(data.sync_env);
|
setSyncEnv(data.sync_env);
|
||||||
setAuthType(data.auth_type);
|
setAuthType(data.auth_type);
|
||||||
setToken('');
|
setToken('');
|
||||||
|
setDeployKey('');
|
||||||
|
setSshKnownHostsEntry('');
|
||||||
|
setSshHostKeyFingerprint('');
|
||||||
setApplyModeOverride(null);
|
setApplyModeOverride(null);
|
||||||
}
|
}
|
||||||
} else if (res.status === 404) {
|
} else if (res.status === 404) {
|
||||||
@@ -201,8 +213,9 @@ export function GitSourcePanel({
|
|||||||
toast.error('Repository URL, ref, and at least one compose file are required.');
|
toast.error('Repository URL, ref, and at least one compose file are required.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!/^https:\/\//i.test(repoUrl.trim())) {
|
const trimmedUrl = repoUrl.trim();
|
||||||
toast.error('Only HTTPS repository URLs are supported.');
|
if (!isSupportedGitRepoUrl(trimmedUrl)) {
|
||||||
|
toast.error(UNSUPPORTED_GIT_REPO_URL_MESSAGE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -223,12 +236,20 @@ export function GitSourcePanel({
|
|||||||
if (authType === 'token' && token !== '') {
|
if (authType === 'token' && token !== '') {
|
||||||
body.token = token;
|
body.token = token;
|
||||||
}
|
}
|
||||||
|
if (authType === 'deploy_key') {
|
||||||
|
if (deployKey !== '') body.deploy_key = deployKey;
|
||||||
|
if (sshKnownHostsEntry !== '') body.ssh_known_hosts_entry = sshKnownHostsEntry;
|
||||||
|
if (sshHostKeyFingerprint !== '') body.ssh_host_key_fingerprint = sshHostKeyFingerprint;
|
||||||
|
}
|
||||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setToken('');
|
setToken('');
|
||||||
|
setDeployKey('');
|
||||||
|
setSshKnownHostsEntry('');
|
||||||
|
setSshHostKeyFingerprint('');
|
||||||
setApplyModeOverride(null);
|
setApplyModeOverride(null);
|
||||||
toast.success('Git source saved.');
|
toast.success('Git source saved.');
|
||||||
onSourceChanged?.();
|
onSourceChanged?.();
|
||||||
@@ -263,6 +284,10 @@ export function GitSourcePanel({
|
|||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
};
|
};
|
||||||
if (authType === 'token' && token !== '') body.token = token;
|
if (authType === 'token' && token !== '') body.token = token;
|
||||||
|
if (authType === 'deploy_key') {
|
||||||
|
if (deployKey !== '') body.deploy_key = deployKey;
|
||||||
|
if (sshKnownHostsEntry !== '') body.ssh_known_hosts_entry = sshKnownHostsEntry;
|
||||||
|
}
|
||||||
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/browse`, {
|
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/browse`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
@@ -472,6 +497,7 @@ export function GitSourcePanel({
|
|||||||
|
|
||||||
<GitSourceFields
|
<GitSourceFields
|
||||||
variant="edit"
|
variant="edit"
|
||||||
|
stackName={stackName}
|
||||||
disabled={!canEdit || saving}
|
disabled={!canEdit || saving}
|
||||||
repoUrl={repoUrl}
|
repoUrl={repoUrl}
|
||||||
branch={branch}
|
branch={branch}
|
||||||
@@ -480,7 +506,12 @@ export function GitSourcePanel({
|
|||||||
syncEnv={syncEnv}
|
syncEnv={syncEnv}
|
||||||
authType={authType}
|
authType={authType}
|
||||||
token={token}
|
token={token}
|
||||||
|
deployKey={deployKey}
|
||||||
|
sshKnownHostsEntry={sshKnownHostsEntry}
|
||||||
|
sshHostKeyFingerprint={sshHostKeyFingerprint}
|
||||||
hasStoredToken={source?.has_token ?? false}
|
hasStoredToken={source?.has_token ?? false}
|
||||||
|
hasStoredDeployKey={source?.has_deploy_key ?? false}
|
||||||
|
storedHostKeyFingerprint={source?.ssh_host_key_fingerprint ?? null}
|
||||||
applyMode={applyMode}
|
applyMode={applyMode}
|
||||||
onRepoUrlChange={setRepoUrl}
|
onRepoUrlChange={setRepoUrl}
|
||||||
onBranchChange={setBranch}
|
onBranchChange={setBranch}
|
||||||
@@ -489,6 +520,9 @@ export function GitSourcePanel({
|
|||||||
onSyncEnvChange={setSyncEnv}
|
onSyncEnvChange={setSyncEnv}
|
||||||
onAuthTypeChange={setAuthType}
|
onAuthTypeChange={setAuthType}
|
||||||
onTokenChange={setToken}
|
onTokenChange={setToken}
|
||||||
|
onDeployKeyChange={setDeployKey}
|
||||||
|
onSshKnownHostsEntryChange={setSshKnownHostsEntry}
|
||||||
|
onSshHostKeyFingerprintChange={setSshHostKeyFingerprint}
|
||||||
onApplyModeChange={setApplyModeOverride}
|
onApplyModeChange={setApplyModeOverride}
|
||||||
onBrowse={browseRepo}
|
onBrowse={browseRepo}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { isSupportedGitRepoUrl } from './gitRepoUrl';
|
||||||
|
|
||||||
|
describe('isSupportedGitRepoUrl', () => {
|
||||||
|
it('accepts HTTPS URLs', () => {
|
||||||
|
expect(isSupportedGitRepoUrl('https://github.com/org/repo.git')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts scp-style SSH URLs with any username', () => {
|
||||||
|
expect(isSupportedGitRepoUrl('git@github.com:org/repo.git')).toBe(true);
|
||||||
|
expect(isSupportedGitRepoUrl('gituser@git.example.com:org/repo.git')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts ssh:// URLs with any username', () => {
|
||||||
|
expect(isSupportedGitRepoUrl('ssh://gituser@git.example.com:2222/org/repo.git')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed or unsupported URLs', () => {
|
||||||
|
expect(isSupportedGitRepoUrl('git@host-only')).toBe(false);
|
||||||
|
expect(isSupportedGitRepoUrl('http://github.com/org/repo.git')).toBe(false);
|
||||||
|
expect(isSupportedGitRepoUrl('@host:repo.git')).toBe(false);
|
||||||
|
expect(isSupportedGitRepoUrl('git@host:../escape.git')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/** scp-style `user@host:org/repo.git` (any SSH username, not only `git`). */
|
||||||
|
const SCP_URL_PATTERN = /^([^@\s/]+)@([^:\s]+):(.+)$/;
|
||||||
|
|
||||||
|
function isValidScpStyleSshUrl(trimmed: string): boolean {
|
||||||
|
const match = SCP_URL_PATTERN.exec(trimmed);
|
||||||
|
if (!match) return false;
|
||||||
|
const user = match[1];
|
||||||
|
const hostPart = match[2];
|
||||||
|
const repoPath = match[3].trim();
|
||||||
|
if (!user || !hostPart || !repoPath || repoPath.includes('..')) return false;
|
||||||
|
const colon = hostPart.lastIndexOf(':');
|
||||||
|
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 false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSshProtocolUrl(trimmed: string): boolean {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(trimmed);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'ssh:') return false;
|
||||||
|
if (!url.hostname || url.username === '' || url.password !== '') return false;
|
||||||
|
if (url.search !== '' || url.hash !== '') return false;
|
||||||
|
const port = url.port ? Number.parseInt(url.port, 10) : 22;
|
||||||
|
if (!Number.isFinite(port) || port < 1 || port > 65535) return false;
|
||||||
|
const pathname = url.pathname;
|
||||||
|
if (pathname === '/' || pathname.includes('..')) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches backend Git transport URL acceptance for HTTPS and SSH. */
|
||||||
|
export function isSupportedGitRepoUrl(raw: string): boolean {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (/^https:\/\//i.test(trimmed)) return true;
|
||||||
|
if (/^ssh:\/\//i.test(trimmed)) return isValidSshProtocolUrl(trimmed);
|
||||||
|
return isValidScpStyleSshUrl(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UNSUPPORTED_GIT_REPO_URL_MESSAGE =
|
||||||
|
'Use an https:// URL or an SSH URL (user@host:org/repo.git or ssh://).';
|
||||||
Executable
+69
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Release loopback port 22 so SSH fixture integration tests can bind a test sshd.
|
||||||
|
# GitHub-hosted runners often have ssh.socket socket-activation that restarts sshd
|
||||||
|
# after a plain systemctl stop; mask + kill listeners before verifying the port.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORT="${1:-22}"
|
||||||
|
|
||||||
|
stop_systemd_ssh() {
|
||||||
|
if ! command -v systemctl >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for unit in ssh.socket ssh.service ssh; do
|
||||||
|
sudo systemctl stop "$unit" 2>/dev/null || true
|
||||||
|
sudo systemctl disable "$unit" 2>/dev/null || true
|
||||||
|
sudo systemctl mask "$unit" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_sysv_ssh() {
|
||||||
|
if command -v service >/dev/null 2>&1; then
|
||||||
|
sudo service ssh stop 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
kill_port_listeners() {
|
||||||
|
if command -v fuser >/dev/null 2>&1; then
|
||||||
|
sudo fuser -k "${PORT}/tcp" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
|
mapfile -t pids < <(sudo lsof -tiTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null || true)
|
||||||
|
if ((${#pids[@]} > 0)); then
|
||||||
|
sudo kill -TERM "${pids[@]}" 2>/dev/null || true
|
||||||
|
sleep 0.5
|
||||||
|
sudo kill -KILL "${pids[@]}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
allow_unprivileged_sshd_bind() {
|
||||||
|
if [[ -x /usr/sbin/sshd ]]; then
|
||||||
|
sudo setcap 'cap_net_bind_service=+ep' /usr/sbin/sshd 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_port_free() {
|
||||||
|
if command -v ss >/dev/null 2>&1; then
|
||||||
|
if ss -ltn "sport = :${PORT}" 2>/dev/null | awk 'NR > 1 && /LISTEN/ { found=1 } END { exit !found }'; then
|
||||||
|
echo "loopback port ${PORT} still has listeners:" >&2
|
||||||
|
ss -ltnp "sport = :${PORT}" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
elif command -v lsof >/dev/null 2>&1; then
|
||||||
|
if sudo lsof -tiTCP:"${PORT}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
echo "loopback port ${PORT} still has listeners:" >&2
|
||||||
|
sudo lsof -iTCP:"${PORT}" -sTCP:LISTEN >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "loopback port ${PORT} has no listeners"
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_systemd_ssh
|
||||||
|
stop_sysv_ssh
|
||||||
|
kill_port_listeners
|
||||||
|
sleep 0.5
|
||||||
|
kill_port_listeners
|
||||||
|
allow_unprivileged_sshd_bind
|
||||||
|
assert_port_free
|
||||||
Reference in New Issue
Block a user