feat: 增加 Gitea webhook relay 诊断
- Gitea client: 新增 hooks 读取并只保留安全诊断字段 - Projects: 在 Gitea 诊断中补充 webhook/relay 匹配状态 - Settings: 增加 GITEA_WEBHOOK_RELAY_BASE_URL 配置说明
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 配置的环境变量后备值。
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 摘要只是展示增强;读取失败不影响仓库可达性诊断。
|
||||
*/
|
||||
|
||||
@@ -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' },
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user