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_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
+1 -1
View File
@@ -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 配置的环境变量后备值。
+1
View File
@@ -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,
+30
View File
@@ -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<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(
owner: string,
repo: string,
+11
View File
@@ -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;
+24
View File
@@ -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;
};
+180 -4
View File
@@ -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<EnvConfig, true>,
) {}
async listProjects(user?: AuthenticatedUser): Promise<ProjectSummary[]> {
@@ -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<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 摘要只是展示增强;读取失败不影响仓库可达性诊断。
*/
+6 -2
View File
@@ -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' },
),
},
},
{