diff --git a/.env.example b/.env.example index 81a02c0..18a565c 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,7 @@ JENKINS_AUTO_SYNC_INTERVAL_MS=30000 GITEA_BASE_URL=https://gitea.example.internal GITEA_TOKEN=replace-with-secret-manager-value GITEA_WEBHOOK_SECRET=replace-with-secret-manager-value +GITEA_WEBHOOK_RELAY_BASE_URL=https://jenkins.example.internal/gitea-jenkins-notify # Public platform URL used in notification links. DEVOPS_PUBLIC_URL=https://devops.mrzhan.top diff --git a/README.md b/README.md index 0056b24..eed64a8 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ pnpm prisma:seed - `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 - `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。 - `JENKINS_BASE_URL`、`JENKINS_USERNAME`、`JENKINS_API_TOKEN`:Jenkins 集成配置。 -- `GITEA_BASE_URL`、`GITEA_TOKEN`、`GITEA_WEBHOOK_SECRET`:Gitea 集成配置。 +- `GITEA_BASE_URL`、`GITEA_TOKEN`、`GITEA_WEBHOOK_SECRET`、`GITEA_WEBHOOK_RELAY_BASE_URL`:Gitea 集成与 Jenkins relay 诊断配置。 - `NOTIFICATION_PROVIDER`、`WECOM_WEBHOOK_URL`、`FEISHU_WEBHOOK_URL`、`NOTIFICATION_WEBHOOK_URL`:通知集成配置。 - `LLM_BASE_URL`、`LLM_API_KEY`、`LLM_MODEL`:Agent 配置的环境变量后备值。 diff --git a/src/config/env.schema.ts b/src/config/env.schema.ts index d618b7f..85516dc 100644 --- a/src/config/env.schema.ts +++ b/src/config/env.schema.ts @@ -65,6 +65,7 @@ export const envSchema = z.object({ GITEA_BASE_URL: optionalUrl, GITEA_TOKEN: optionalString, GITEA_WEBHOOK_SECRET: optionalString, + GITEA_WEBHOOK_RELAY_BASE_URL: optionalUrl, DEVOPS_PUBLIC_URL: optionalUrl, NOTIFICATION_PROVIDER: optionalString, NOTIFICATION_WEBHOOK_URL: optionalUrl, diff --git a/src/integrations/gitea/gitea.client.ts b/src/integrations/gitea/gitea.client.ts index c04961d..5faec84 100644 --- a/src/integrations/gitea/gitea.client.ts +++ b/src/integrations/gitea/gitea.client.ts @@ -10,6 +10,7 @@ import { GiteaBranch, GiteaCommitSummary, GiteaPullRequestSummary, + GiteaRepositoryHook, GiteaRepositoryInfo, GiteaTag, } from './gitea.types'; @@ -78,6 +79,35 @@ export class GiteaClient { }; } + async listHooks(owner: string, repo: string): Promise { + const payload = await this.request< + Array<{ + id: number; + type?: string; + name?: string; + active?: boolean; + events?: string[]; + branch_filter?: string; + config?: { + url?: string; + }; + created_at?: string; + updated_at?: string; + }> + >(`/api/v1/repos/${owner}/${repo}/hooks`); + + return payload.map((hook) => ({ + id: hook.id, + type: hook.type ?? hook.name ?? 'unknown', + active: hook.active === true, + events: Array.isArray(hook.events) ? hook.events : [], + branchFilter: hook.branch_filter, + targetUrl: hook.config?.url, + createdAt: hook.created_at, + updatedAt: hook.updated_at, + })); + } + async getCommit( owner: string, repo: string, diff --git a/src/integrations/gitea/gitea.types.ts b/src/integrations/gitea/gitea.types.ts index d7f7d66..99db2e3 100644 --- a/src/integrations/gitea/gitea.types.ts +++ b/src/integrations/gitea/gitea.types.ts @@ -31,6 +31,17 @@ export type GiteaPullRequestSummary = { authorName?: string; }; +export type GiteaRepositoryHook = { + id: number; + type: string; + active: boolean; + events: string[]; + branchFilter?: string; + targetUrl?: string; + createdAt?: string; + updatedAt?: string; +}; + export type GiteaRepositoryInfo = { owner: string; name: string; diff --git a/src/projects/project.types.ts b/src/projects/project.types.ts index 1650070..3236a3f 100644 --- a/src/projects/project.types.ts +++ b/src/projects/project.types.ts @@ -93,6 +93,30 @@ export type ProjectGiteaRepositoryDiagnostic = { authorName?: string; authoredAt?: string; }; + webhook?: ProjectGiteaWebhookDiagnostic; + updatedAt?: string; +}; + +export type ProjectGiteaWebhookDiagnostic = { + status: 'ok' | 'missing' | 'not_configured' | 'unavailable'; + message?: string; + hookCount: number; + activePushHookCount: number; + expectedRelayConfigured: boolean; + expectedRelayTarget?: string; + matchedHookId?: number; + hooks: ProjectGiteaWebhookHookDiagnostic[]; +}; + +export type ProjectGiteaWebhookHookDiagnostic = { + id: number; + type: string; + active: boolean; + events: string[]; + branchFilter?: string; + target?: string; + matchesExpectedRelay: boolean; + createdAt?: string; updatedAt?: string; }; diff --git a/src/projects/projects.service.ts b/src/projects/projects.service.ts index e3b2f25..b2c61d7 100644 --- a/src/projects/projects.service.ts +++ b/src/projects/projects.service.ts @@ -2,20 +2,24 @@ * 项目服务统一处理项目权限、Gitea 元数据读取和 Jenkins/Gitea 诊断聚合。 */ import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from '../auth/auth.service'; import { AuthenticatedUser } from '../auth/auth.types'; import { AppError } from '../common/errors/app-error'; import { IntegrationRequestError } from '../common/errors/integration-error'; +import { EnvConfig } from '../config/env.schema'; import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaCommitSummary, GiteaPullRequestSummary, + GiteaRepositoryHook, } from '../integrations/gitea/gitea.types'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { ProjectRepository } from './project.repository'; import { ProjectGiteaDiagnosticsSummary, ProjectGiteaRepositoryDiagnostic, + ProjectGiteaWebhookHookDiagnostic, ProjectJenkinsDiagnosticsSummary, ProjectJenkinsJobDiagnostic, ProjectRefSummary, @@ -35,6 +39,7 @@ export class ProjectsService { private readonly giteaClient: GiteaClient, private readonly jenkinsClient: JenkinsClient, private readonly authService: AuthService, + private readonly config: ConfigService, ) {} async listProjects(user?: AuthenticatedUser): Promise { @@ -159,6 +164,7 @@ export class ProjectsService { repo: repo?.name, status: 'not_configured', message: health.message, + webhook: this.giteaWebhookNotConfigured(health.message), }; }), }; @@ -296,6 +302,7 @@ export class ProjectsService { repositoryUrl: project.repositoryUrl, status: 'unavailable', message: 'Gitea 仓库地址无法解析', + webhook: this.giteaWebhookUnavailable('仓库地址无法解析,无法读取 webhook'), }; } @@ -306,10 +313,10 @@ export class ProjectsService { this.giteaClient.listTags(repo.owner, repo.name), ]); const defaultBranch = repository.defaultBranch ?? project.defaultBranch; - const latestCommit = await this.getRepositoryDefaultCommit( - repo, - defaultBranch, - ); + const [latestCommit, webhook] = await Promise.all([ + this.getRepositoryDefaultCommit(repo, defaultBranch), + this.getRepositoryWebhookDiagnostic(repo), + ]); return { projectKey: project.key, @@ -327,6 +334,7 @@ export class ProjectsService { branchCount: branches.length, tagCount: tags.length, latestCommit, + webhook, updatedAt: repository.updatedAt, }; } catch (error) { @@ -341,6 +349,7 @@ export class ProjectsService { repo: repo.name, status: 'missing', message: 'Gitea 仓库不存在或当前 token 无权访问', + webhook: this.giteaWebhookUnavailable('仓库不可达,无法读取 webhook'), }; } @@ -351,10 +360,177 @@ export class ProjectsService { repo: repo.name, status: 'unavailable', message: error instanceof Error ? error.message : 'Gitea 诊断失败', + webhook: this.giteaWebhookUnavailable('仓库诊断失败,无法读取 webhook'), }; } } + /** + * Webhook 诊断只暴露 active/push/目标 host+path 等安全字段,不返回 secret、query 或原始 config。 + */ + private async getRepositoryWebhookDiagnostic( + repo: ParsedRepository, + ): Promise { + try { + const hooks = await this.giteaClient.listHooks(repo.owner, repo.name); + const expectedRelayTarget = this.expectedWebhookRelayTarget(repo.name); + const normalizedExpected = this.normalizeWebhookTarget(expectedRelayTarget); + const hookDiagnostics = hooks.map((hook) => + this.toWebhookHookDiagnostic(hook, normalizedExpected), + ); + const activePushHookCount = hookDiagnostics.filter( + (hook) => hook.active && hook.events.includes('push'), + ).length; + const matchedHook = hookDiagnostics.find( + (hook) => hook.active && hook.events.includes('push') && hook.matchesExpectedRelay, + ); + + if (!expectedRelayTarget) { + return { + status: 'not_configured', + message: '未配置 GITEA_WEBHOOK_RELAY_BASE_URL,无法校验 Jenkins relay', + hookCount: hooks.length, + activePushHookCount, + expectedRelayConfigured: false, + hooks: hookDiagnostics, + }; + } + + if (!matchedHook) { + return { + status: 'missing', + message: + activePushHookCount > 0 + ? '未发现匹配预期 Jenkins relay 的 active push hook' + : '未发现 active push hook', + hookCount: hooks.length, + activePushHookCount, + expectedRelayConfigured: true, + expectedRelayTarget, + hooks: hookDiagnostics, + }; + } + + return { + status: 'ok', + hookCount: hooks.length, + activePushHookCount, + expectedRelayConfigured: true, + expectedRelayTarget, + matchedHookId: matchedHook.id, + hooks: hookDiagnostics, + }; + } catch (error) { + return this.giteaWebhookUnavailable( + error instanceof Error ? error.message : 'Gitea webhook 诊断失败', + ); + } + } + + private toWebhookHookDiagnostic( + hook: GiteaRepositoryHook, + normalizedExpected?: string, + ): ProjectGiteaWebhookHookDiagnostic { + const target = this.sanitizeWebhookTarget(hook.targetUrl); + const normalizedTarget = this.normalizeWebhookTarget(target); + + return { + id: hook.id, + type: hook.type, + active: hook.active, + events: hook.events, + branchFilter: hook.branchFilter, + target, + matchesExpectedRelay: + Boolean(normalizedExpected) && normalizedTarget === normalizedExpected, + createdAt: hook.createdAt, + updatedAt: hook.updatedAt, + }; + } + + private expectedWebhookRelayTarget(repoName: string): string | undefined { + const relayBaseUrl = this.config.get('GITEA_WEBHOOK_RELAY_BASE_URL', { + infer: true, + }); + + if (!relayBaseUrl) { + return undefined; + } + + return this.sanitizeWebhookTarget( + `${relayBaseUrl.replace(/\/+$/, '')}/${encodeURIComponent(repoName)}`, + ); + } + + private sanitizeWebhookTarget(value?: string): string | undefined { + if (!value) { + return undefined; + } + + try { + const url = new URL(value); + const pathname = this.redactWebhookPath(url.pathname); + return `${url.protocol}//${url.host}${pathname}`.replace(/\/+$/, ''); + } catch { + return undefined; + } + } + + private normalizeWebhookTarget(value?: string): string | undefined { + return this.sanitizeWebhookTarget(value)?.replace(/\/+$/, ''); + } + + private redactWebhookPath(pathname: string): string { + const segments = pathname.split('/').map((segment) => { + if (!segment) { + return segment; + } + + const decodedSegment = this.safeDecodeURIComponent(segment).toLowerCase(); + + return /token|secret|key|password|signature|auth/.test(decodedSegment) || + decodedSegment.length > 96 + ? '[redacted]' + : segment; + }); + + return segments.join('/') || '/'; + } + + private safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + + private giteaWebhookNotConfigured( + message?: string, + ): ProjectGiteaRepositoryDiagnostic['webhook'] { + return { + status: 'not_configured', + message, + hookCount: 0, + activePushHookCount: 0, + expectedRelayConfigured: false, + hooks: [], + }; + } + + private giteaWebhookUnavailable( + message?: string, + ): ProjectGiteaRepositoryDiagnostic['webhook'] { + return { + status: 'unavailable', + message, + hookCount: 0, + activePushHookCount: 0, + expectedRelayConfigured: false, + hooks: [], + }; + } + /** * 默认分支 commit 摘要只是展示增强;读取失败不影响仓库可达性诊断。 */ diff --git a/src/settings/settings.service.ts b/src/settings/settings.service.ts index 6f6b0b3..e79b5cc 100644 --- a/src/settings/settings.service.ts +++ b/src/settings/settings.service.ts @@ -54,8 +54,8 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [ key: 'gitea', name: 'Gitea', required: ['GITEA_BASE_URL', 'GITEA_TOKEN'], - optional: ['GITEA_WEBHOOK_SECRET'], - note: 'GITEA_WEBHOOK_SECRET 用于开启 webhook 签名校验。', + optional: ['GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_RELAY_BASE_URL'], + note: 'GITEA_WEBHOOK_SECRET 用于开启 webhook 签名校验;GITEA_WEBHOOK_RELAY_BASE_URL 用于校验仓库 push hook 是否已接入 Jenkins relay。', variableHelp: { GITEA_BASE_URL: variableHelp('Gitea 服务端 API 地址,用于读取仓库、分支和 tag。'), GITEA_TOKEN: variableHelp( @@ -65,6 +65,10 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [ GITEA_WEBHOOK_SECRET: variableHelp( 'Gitea push webhook 签名密钥;配置后后端会校验 webhook 来源。', ), + GITEA_WEBHOOK_RELAY_BASE_URL: variableHelp( + 'Jenkins relay 的公开 webhook 基地址,用于诊断仓库是否存在匹配的 active push hook。', + { example: 'https://jenkins.ops.mrzhan.top/gitea-jenkins-notify' }, + ), }, }, {