feat: 增加 Gitea webhook relay 诊断

- Gitea client: 新增 hooks 读取并只保留安全诊断字段
- Projects: 在 Gitea 诊断中补充 webhook/relay 匹配状态
- Settings: 增加 GITEA_WEBHOOK_RELAY_BASE_URL 配置说明
This commit is contained in:
湛兮
2026-06-12 04:57:15 +08:00
parent 71c1c017ab
commit f92ee66298
8 changed files with 254 additions and 7 deletions
+1
View File
@@ -24,6 +24,7 @@ JENKINS_AUTO_SYNC_INTERVAL_MS=30000
GITEA_BASE_URL=https://gitea.example.internal GITEA_BASE_URL=https://gitea.example.internal
GITEA_TOKEN=replace-with-secret-manager-value GITEA_TOKEN=replace-with-secret-manager-value
GITEA_WEBHOOK_SECRET=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. # Public platform URL used in notification links.
DEVOPS_PUBLIC_URL=https://devops.mrzhan.top DEVOPS_PUBLIC_URL=https://devops.mrzhan.top
+1 -1
View File
@@ -104,7 +104,7 @@ pnpm prisma:seed
- `USE_DATABASE_READS`:是否启用 Prisma 数据读写。 - `USE_DATABASE_READS`:是否启用 Prisma 数据读写。
- `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。 - `PRISMA_CONNECT_ON_BOOT`:启动时是否主动连接数据库。
- `JENKINS_BASE_URL``JENKINS_USERNAME``JENKINS_API_TOKEN`Jenkins 集成配置。 - `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`:通知集成配置。 - `NOTIFICATION_PROVIDER``WECOM_WEBHOOK_URL``FEISHU_WEBHOOK_URL``NOTIFICATION_WEBHOOK_URL`:通知集成配置。
- `LLM_BASE_URL``LLM_API_KEY``LLM_MODEL`Agent 配置的环境变量后备值。 - `LLM_BASE_URL``LLM_API_KEY``LLM_MODEL`Agent 配置的环境变量后备值。
+1
View File
@@ -65,6 +65,7 @@ export const envSchema = z.object({
GITEA_BASE_URL: optionalUrl, GITEA_BASE_URL: optionalUrl,
GITEA_TOKEN: optionalString, GITEA_TOKEN: optionalString,
GITEA_WEBHOOK_SECRET: optionalString, GITEA_WEBHOOK_SECRET: optionalString,
GITEA_WEBHOOK_RELAY_BASE_URL: optionalUrl,
DEVOPS_PUBLIC_URL: optionalUrl, DEVOPS_PUBLIC_URL: optionalUrl,
NOTIFICATION_PROVIDER: optionalString, NOTIFICATION_PROVIDER: optionalString,
NOTIFICATION_WEBHOOK_URL: optionalUrl, NOTIFICATION_WEBHOOK_URL: optionalUrl,
+30
View File
@@ -10,6 +10,7 @@ import {
GiteaBranch, GiteaBranch,
GiteaCommitSummary, GiteaCommitSummary,
GiteaPullRequestSummary, GiteaPullRequestSummary,
GiteaRepositoryHook,
GiteaRepositoryInfo, GiteaRepositoryInfo,
GiteaTag, GiteaTag,
} from './gitea.types'; } from './gitea.types';
@@ -78,6 +79,35 @@ export class GiteaClient {
}; };
} }
async listHooks(owner: string, repo: string): Promise<GiteaRepositoryHook[]> {
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( async getCommit(
owner: string, owner: string,
repo: string, repo: string,
+11
View File
@@ -31,6 +31,17 @@ export type GiteaPullRequestSummary = {
authorName?: string; authorName?: string;
}; };
export type GiteaRepositoryHook = {
id: number;
type: string;
active: boolean;
events: string[];
branchFilter?: string;
targetUrl?: string;
createdAt?: string;
updatedAt?: string;
};
export type GiteaRepositoryInfo = { export type GiteaRepositoryInfo = {
owner: string; owner: string;
name: string; name: string;
+24
View File
@@ -93,6 +93,30 @@ export type ProjectGiteaRepositoryDiagnostic = {
authorName?: string; authorName?: string;
authoredAt?: 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; updatedAt?: string;
}; };
+180 -4
View File
@@ -2,20 +2,24 @@
* 项目服务统一处理项目权限、Gitea 元数据读取和 Jenkins/Gitea 诊断聚合。 * 项目服务统一处理项目权限、Gitea 元数据读取和 Jenkins/Gitea 诊断聚合。
*/ */
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types'; import { AuthenticatedUser } from '../auth/auth.types';
import { AppError } from '../common/errors/app-error'; import { AppError } from '../common/errors/app-error';
import { IntegrationRequestError } from '../common/errors/integration-error'; import { IntegrationRequestError } from '../common/errors/integration-error';
import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaClient } from '../integrations/gitea/gitea.client';
import { import {
GiteaCommitSummary, GiteaCommitSummary,
GiteaPullRequestSummary, GiteaPullRequestSummary,
GiteaRepositoryHook,
} from '../integrations/gitea/gitea.types'; } from '../integrations/gitea/gitea.types';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { ProjectRepository } from './project.repository'; import { ProjectRepository } from './project.repository';
import { import {
ProjectGiteaDiagnosticsSummary, ProjectGiteaDiagnosticsSummary,
ProjectGiteaRepositoryDiagnostic, ProjectGiteaRepositoryDiagnostic,
ProjectGiteaWebhookHookDiagnostic,
ProjectJenkinsDiagnosticsSummary, ProjectJenkinsDiagnosticsSummary,
ProjectJenkinsJobDiagnostic, ProjectJenkinsJobDiagnostic,
ProjectRefSummary, ProjectRefSummary,
@@ -35,6 +39,7 @@ export class ProjectsService {
private readonly giteaClient: GiteaClient, private readonly giteaClient: GiteaClient,
private readonly jenkinsClient: JenkinsClient, private readonly jenkinsClient: JenkinsClient,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly config: ConfigService<EnvConfig, true>,
) {} ) {}
async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> { async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> {
@@ -159,6 +164,7 @@ export class ProjectsService {
repo: repo?.name, repo: repo?.name,
status: 'not_configured', status: 'not_configured',
message: health.message, message: health.message,
webhook: this.giteaWebhookNotConfigured(health.message),
}; };
}), }),
}; };
@@ -296,6 +302,7 @@ export class ProjectsService {
repositoryUrl: project.repositoryUrl, repositoryUrl: project.repositoryUrl,
status: 'unavailable', status: 'unavailable',
message: 'Gitea 仓库地址无法解析', message: 'Gitea 仓库地址无法解析',
webhook: this.giteaWebhookUnavailable('仓库地址无法解析,无法读取 webhook'),
}; };
} }
@@ -306,10 +313,10 @@ export class ProjectsService {
this.giteaClient.listTags(repo.owner, repo.name), this.giteaClient.listTags(repo.owner, repo.name),
]); ]);
const defaultBranch = repository.defaultBranch ?? project.defaultBranch; const defaultBranch = repository.defaultBranch ?? project.defaultBranch;
const latestCommit = await this.getRepositoryDefaultCommit( const [latestCommit, webhook] = await Promise.all([
repo, this.getRepositoryDefaultCommit(repo, defaultBranch),
defaultBranch, this.getRepositoryWebhookDiagnostic(repo),
); ]);
return { return {
projectKey: project.key, projectKey: project.key,
@@ -327,6 +334,7 @@ export class ProjectsService {
branchCount: branches.length, branchCount: branches.length,
tagCount: tags.length, tagCount: tags.length,
latestCommit, latestCommit,
webhook,
updatedAt: repository.updatedAt, updatedAt: repository.updatedAt,
}; };
} catch (error) { } catch (error) {
@@ -341,6 +349,7 @@ export class ProjectsService {
repo: repo.name, repo: repo.name,
status: 'missing', status: 'missing',
message: 'Gitea 仓库不存在或当前 token 无权访问', message: 'Gitea 仓库不存在或当前 token 无权访问',
webhook: this.giteaWebhookUnavailable('仓库不可达,无法读取 webhook'),
}; };
} }
@@ -351,10 +360,177 @@ export class ProjectsService {
repo: repo.name, repo: repo.name,
status: 'unavailable', status: 'unavailable',
message: error instanceof Error ? error.message : 'Gitea 诊断失败', 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<ProjectGiteaRepositoryDiagnostic['webhook']> {
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 摘要只是展示增强;读取失败不影响仓库可达性诊断。 * 默认分支 commit 摘要只是展示增强;读取失败不影响仓库可达性诊断。
*/ */
+6 -2
View File
@@ -54,8 +54,8 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
key: 'gitea', key: 'gitea',
name: 'Gitea', name: 'Gitea',
required: ['GITEA_BASE_URL', 'GITEA_TOKEN'], required: ['GITEA_BASE_URL', 'GITEA_TOKEN'],
optional: ['GITEA_WEBHOOK_SECRET'], optional: ['GITEA_WEBHOOK_SECRET', 'GITEA_WEBHOOK_RELAY_BASE_URL'],
note: 'GITEA_WEBHOOK_SECRET 用于开启 webhook 签名校验。', note: 'GITEA_WEBHOOK_SECRET 用于开启 webhook 签名校验GITEA_WEBHOOK_RELAY_BASE_URL 用于校验仓库 push hook 是否已接入 Jenkins relay。',
variableHelp: { variableHelp: {
GITEA_BASE_URL: variableHelp('Gitea 服务端 API 地址,用于读取仓库、分支和 tag。'), GITEA_BASE_URL: variableHelp('Gitea 服务端 API 地址,用于读取仓库、分支和 tag。'),
GITEA_TOKEN: variableHelp( GITEA_TOKEN: variableHelp(
@@ -65,6 +65,10 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
GITEA_WEBHOOK_SECRET: variableHelp( GITEA_WEBHOOK_SECRET: variableHelp(
'Gitea push webhook 签名密钥;配置后后端会校验 webhook 来源。', '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' },
),
}, },
}, },
{ {