@serve.zone/gitops 3.1.1 → 32.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +26 -7
- package/changelog.md +42 -0
- package/deno.json +1 -1
- package/dist_serve/bundle.js +327 -299
- package/dist_serve/bundle.js.map +1 -0
- package/dist_ts/00_commitinfo_data.js +2 -2
- package/dist_ts/cache/classes.cachedb.d.ts +1 -1
- package/dist_ts/cache/classes.cachedb.js +1 -1
- package/dist_ts/classes/connectionmanager.d.ts +41 -0
- package/dist_ts/classes/connectionmanager.js +143 -9
- package/dist_ts/opsserver/handlers/webhook.handler.d.ts +20 -0
- package/dist_ts/opsserver/handlers/webhook.handler.js +132 -14
- package/dist_ts/opsserver/helpers/webhookverification.d.ts +70 -0
- package/dist_ts/opsserver/helpers/webhookverification.js +158 -0
- package/dist_ts/plugins.d.ts +2 -1
- package/dist_ts/plugins.js +3 -2
- package/dist_ts/providers/classes.giteaprovider.d.ts +10 -0
- package/dist_ts/providers/classes.giteaprovider.js +11 -1
- package/dist_ts/providers/classes.gitlabprovider.d.ts +10 -0
- package/dist_ts/providers/classes.gitlabprovider.js +11 -1
- package/dist_ts_interfaces/data/connection.d.ts +27 -0
- package/dist_ts_interfaces/requests/webhook.d.ts +31 -0
- package/package.json +9 -9
- package/readme.md +60 -3
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/cache/classes.cachedb.ts +1 -1
- package/ts/classes/connectionmanager.ts +150 -8
- package/ts/opsserver/handlers/webhook.handler.ts +175 -14
- package/ts/opsserver/helpers/webhookverification.ts +202 -0
- package/ts/plugins.ts +2 -1
- package/ts/providers/classes.giteaprovider.ts +11 -0
- package/ts/providers/classes.gitlabprovider.ts +11 -0
- package/ts_interfaces/data/connection.ts +29 -0
- package/ts_interfaces/requests/webhook.ts +42 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/appstate.ts +46 -0
- package/ts_web/elements/views/connections/index.ts +89 -0
- package/readme.todo.md +0 -3
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import * as plugins from '../../plugins.js';
|
|
2
|
+
import type * as interfaces from '../../../ts_interfaces/index.js';
|
|
3
|
+
import { GiteaProvider, GitLabProvider } from '../../providers/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Largest webhook delivery gitops buffers. Gitea and GitLab push payloads stay far below
|
|
7
|
+
* this. The bound is enforced while the body is being read (see readBoundedWebhookBody),
|
|
8
|
+
* so it caps what an unauthenticated caller can make the process hold in memory even when
|
|
9
|
+
* the delivery declares a smaller Content-Length than it sends.
|
|
10
|
+
*/
|
|
11
|
+
export const webhookMaxBodyBytes = 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Stable refusal names. They are returned to the caller and written to the log, so an
|
|
15
|
+
* operator can tell a stale secret from a missing one without inspecting payloads.
|
|
16
|
+
*/
|
|
17
|
+
export type TWebhookRefusal =
|
|
18
|
+
| 'webhook-connection-unknown'
|
|
19
|
+
| 'webhook-connection-unverified'
|
|
20
|
+
| 'webhook-secret-missing'
|
|
21
|
+
| 'webhook-secret-unreadable'
|
|
22
|
+
| 'webhook-length-missing'
|
|
23
|
+
| 'webhook-body-too-large'
|
|
24
|
+
| 'webhook-body-unreadable'
|
|
25
|
+
| 'webhook-signature-missing'
|
|
26
|
+
| 'webhook-signature-invalid';
|
|
27
|
+
|
|
28
|
+
export interface IWebhookVerificationResult {
|
|
29
|
+
ok: boolean;
|
|
30
|
+
refusal?: TWebhookRefusal;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Outcome of reading a delivery body: the raw bytes, or the reason the read was refused. */
|
|
34
|
+
export type TWebhookBodyResult =
|
|
35
|
+
| { ok: true; rawBody: Uint8Array }
|
|
36
|
+
| { ok: false; refusal: TWebhookRefusal };
|
|
37
|
+
|
|
38
|
+
/** Non-negative integer, nothing else — a duplicated or padded header value is not one. */
|
|
39
|
+
const contentLengthPattern = /^\d+$/;
|
|
40
|
+
|
|
41
|
+
const hexPattern = /^[0-9a-f]+$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compares two strings without leaking their content through timing. Length differs
|
|
45
|
+
* openly: timingSafeEqual throws on unequal buffer lengths, and the length of a
|
|
46
|
+
* signature header is observable anyway.
|
|
47
|
+
*/
|
|
48
|
+
const timingSafeEqualStrings = (leftArg: string, rightArg: string): boolean => {
|
|
49
|
+
const left = plugins.Buffer.from(leftArg, 'utf8');
|
|
50
|
+
const right = plugins.Buffer.from(rightArg, 'utf8');
|
|
51
|
+
if (left.length !== right.length) return false;
|
|
52
|
+
return plugins.crypto.timingSafeEqual(left, right);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The header that names the delivered event for a given provider.
|
|
57
|
+
*/
|
|
58
|
+
export const webhookEventHeaderFor = (
|
|
59
|
+
providerTypeArg: interfaces.data.TProviderType,
|
|
60
|
+
): string => {
|
|
61
|
+
return providerTypeArg === 'gitea'
|
|
62
|
+
? GiteaProvider.webhookEventHeader
|
|
63
|
+
: GitLabProvider.webhookEventHeader;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The header a provider uses to prove it holds the webhook secret.
|
|
68
|
+
*/
|
|
69
|
+
export const webhookSecretHeaderFor = (
|
|
70
|
+
providerTypeArg: interfaces.data.TProviderType,
|
|
71
|
+
): string => {
|
|
72
|
+
return providerTypeArg === 'gitea'
|
|
73
|
+
? GiteaProvider.webhookSignatureHeader
|
|
74
|
+
: GitLabProvider.webhookTokenHeader;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Checks everything that does not depend on the request body, so an unusable
|
|
79
|
+
* connection is refused before a single byte is buffered.
|
|
80
|
+
*/
|
|
81
|
+
export const checkWebhookConnection = (
|
|
82
|
+
connectionArg: interfaces.data.IProviderConnection,
|
|
83
|
+
): TWebhookRefusal | null => {
|
|
84
|
+
// The secret store holds this connection's secret but would not return it. Nothing can
|
|
85
|
+
// be verified against it, and it must not be replaced by a fresh one, so deliveries are
|
|
86
|
+
// refused under their own name until the secret store recovers.
|
|
87
|
+
if (connectionArg.webhookSecretUnreadable) return 'webhook-secret-unreadable';
|
|
88
|
+
if (!connectionArg.webhookSecret) return 'webhook-secret-missing';
|
|
89
|
+
// A connection whose secret was minted by the backfill is not yet known to the
|
|
90
|
+
// provider, so nothing it delivers can be trusted until the operator confirms it.
|
|
91
|
+
if (connectionArg.webhookStatus !== 'active') return 'webhook-connection-unverified';
|
|
92
|
+
return null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verifies a delivery against the connection's webhook secret.
|
|
97
|
+
*
|
|
98
|
+
* Gitea: HMAC-SHA256 over the raw delivery bytes, hex, in X-Gitea-Signature.
|
|
99
|
+
* GitLab: the secret token echoed verbatim in X-Gitlab-Token.
|
|
100
|
+
*
|
|
101
|
+
* rawBody must be the bytes as received — re-serializing the parsed JSON changes key
|
|
102
|
+
* order and whitespace and would invalidate every genuine signature.
|
|
103
|
+
*/
|
|
104
|
+
export const verifyWebhookDelivery = (optionsArg: {
|
|
105
|
+
connection: interfaces.data.IProviderConnection;
|
|
106
|
+
headers: Headers;
|
|
107
|
+
rawBody: Uint8Array;
|
|
108
|
+
}): IWebhookVerificationResult => {
|
|
109
|
+
const precondition = checkWebhookConnection(optionsArg.connection);
|
|
110
|
+
if (precondition) return { ok: false, refusal: precondition };
|
|
111
|
+
const secret = optionsArg.connection.webhookSecret!;
|
|
112
|
+
|
|
113
|
+
if (optionsArg.connection.providerType === 'gitea') {
|
|
114
|
+
const signature = optionsArg.headers.get(GiteaProvider.webhookSignatureHeader);
|
|
115
|
+
if (!signature) return { ok: false, refusal: 'webhook-signature-missing' };
|
|
116
|
+
const expected = plugins.crypto.createHmac('sha256', secret).update(optionsArg.rawBody).digest();
|
|
117
|
+
const provided = signature.trim().toLowerCase();
|
|
118
|
+
// Length and alphabet are checked before decoding: Buffer.from(..., 'hex') silently
|
|
119
|
+
// truncates malformed input, and timingSafeEqual throws on a length mismatch.
|
|
120
|
+
if (provided.length !== expected.length * 2 || !hexPattern.test(provided)) {
|
|
121
|
+
return { ok: false, refusal: 'webhook-signature-invalid' };
|
|
122
|
+
}
|
|
123
|
+
const providedBuffer = plugins.Buffer.from(provided, 'hex');
|
|
124
|
+
if (!plugins.crypto.timingSafeEqual(providedBuffer, expected)) {
|
|
125
|
+
return { ok: false, refusal: 'webhook-signature-invalid' };
|
|
126
|
+
}
|
|
127
|
+
return { ok: true };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const token = optionsArg.headers.get(GitLabProvider.webhookTokenHeader);
|
|
131
|
+
if (!token) return { ok: false, refusal: 'webhook-signature-missing' };
|
|
132
|
+
if (!timingSafeEqualStrings(token, secret)) {
|
|
133
|
+
return { ok: false, refusal: 'webhook-signature-invalid' };
|
|
134
|
+
}
|
|
135
|
+
return { ok: true };
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Reads a delivery body under a hard byte budget.
|
|
140
|
+
*
|
|
141
|
+
* Two separate guards, because each covers what the other cannot:
|
|
142
|
+
* - Content-Length is required. Gitea and GitLab always declare one; a delivery without a
|
|
143
|
+
* parsable length is chunked or headerless, and reading it would mean buffering a stream
|
|
144
|
+
* of unknown size from an unauthenticated caller. It is refused as
|
|
145
|
+
* 'webhook-length-missing' before the body is touched at all.
|
|
146
|
+
* - The declared length is the caller's claim, never a fact, so the stream is consumed
|
|
147
|
+
* chunk by chunk against a running budget. A forged short Content-Length is refused the
|
|
148
|
+
* moment the bound is crossed, with only the bound buffered.
|
|
149
|
+
*/
|
|
150
|
+
export const readBoundedWebhookBody = async (optionsArg: {
|
|
151
|
+
headers: Headers;
|
|
152
|
+
/** Lazy on purpose: not called when the delivery is refused on its headers alone. */
|
|
153
|
+
body: () => ReadableStream<Uint8Array> | null;
|
|
154
|
+
maxBytes?: number;
|
|
155
|
+
}): Promise<TWebhookBodyResult> => {
|
|
156
|
+
const maxBytes = optionsArg.maxBytes ?? webhookMaxBodyBytes;
|
|
157
|
+
const declared = optionsArg.headers.get('content-length');
|
|
158
|
+
if (declared === null || !contentLengthPattern.test(declared.trim())) {
|
|
159
|
+
return { ok: false, refusal: 'webhook-length-missing' };
|
|
160
|
+
}
|
|
161
|
+
const declaredLength = Number(declared.trim());
|
|
162
|
+
if (declaredLength > maxBytes) {
|
|
163
|
+
return { ok: false, refusal: 'webhook-body-too-large' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const stream = optionsArg.body();
|
|
167
|
+
if (!stream) {
|
|
168
|
+
// No stream at all is only consistent with a delivery that declared no bytes.
|
|
169
|
+
return declaredLength === 0
|
|
170
|
+
? { ok: true, rawBody: new Uint8Array(0) }
|
|
171
|
+
: { ok: false, refusal: 'webhook-body-unreadable' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const reader = stream.getReader();
|
|
175
|
+
const chunks: Uint8Array[] = [];
|
|
176
|
+
let received = 0;
|
|
177
|
+
try {
|
|
178
|
+
while (true) {
|
|
179
|
+
const { done, value } = await reader.read();
|
|
180
|
+
if (done) break;
|
|
181
|
+
if (!value) continue;
|
|
182
|
+
received += value.byteLength;
|
|
183
|
+
if (received > maxBytes) {
|
|
184
|
+
// Stop pulling: the rest of the body is never buffered.
|
|
185
|
+
await reader.cancel().catch(() => undefined);
|
|
186
|
+
return { ok: false, refusal: 'webhook-body-too-large' };
|
|
187
|
+
}
|
|
188
|
+
chunks.push(value);
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
await reader.cancel().catch(() => undefined);
|
|
192
|
+
return { ok: false, refusal: 'webhook-body-unreadable' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const rawBody = new Uint8Array(received);
|
|
196
|
+
let offset = 0;
|
|
197
|
+
for (const chunk of chunks) {
|
|
198
|
+
rawBody.set(chunk, offset);
|
|
199
|
+
offset += chunk.byteLength;
|
|
200
|
+
}
|
|
201
|
+
return { ok: true, rawBody };
|
|
202
|
+
};
|
package/ts/plugins.ts
CHANGED
|
@@ -8,9 +8,10 @@ import * as fs from 'node:fs/promises';
|
|
|
8
8
|
import * as os from 'node:os';
|
|
9
9
|
import * as nodeUrl from 'node:url';
|
|
10
10
|
import * as childProcess from 'node:child_process';
|
|
11
|
+
import * as crypto from 'node:crypto';
|
|
11
12
|
import { Buffer } from 'node:buffer';
|
|
12
13
|
|
|
13
|
-
export { path, fs, os, nodeUrl, childProcess, Buffer };
|
|
14
|
+
export { path, fs, os, nodeUrl, childProcess, crypto, Buffer };
|
|
14
15
|
|
|
15
16
|
// TypedRequest/TypedServer infrastructure
|
|
16
17
|
import * as typedrequest from '@api.global/typedrequest';
|
|
@@ -6,6 +6,17 @@ import { BaseProvider, type IReleaseAssetPayload, type IReleaseAssetTransferOpti
|
|
|
6
6
|
* Gitea API v1 provider implementation
|
|
7
7
|
*/
|
|
8
8
|
export class GiteaProvider extends BaseProvider {
|
|
9
|
+
/**
|
|
10
|
+
* Gitea signs a webhook delivery by HMAC-SHA256 over the raw request body with the
|
|
11
|
+
* webhook secret and sends the digest as lowercase hex in this header.
|
|
12
|
+
*
|
|
13
|
+
* Spelled the way Gitea documents it: header lookup is case-insensitive, so the same
|
|
14
|
+
* constant serves the verification and the operator-facing webhook dialog.
|
|
15
|
+
*/
|
|
16
|
+
public static readonly webhookSignatureHeader = 'X-Gitea-Signature';
|
|
17
|
+
/** Header naming the delivered event, e.g. 'push'. */
|
|
18
|
+
public static readonly webhookEventHeader = 'X-Gitea-Event';
|
|
19
|
+
|
|
9
20
|
private client: plugins.giteaClient.GiteaClient;
|
|
10
21
|
|
|
11
22
|
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
|
@@ -6,6 +6,17 @@ import { BaseProvider, type IReleaseAssetPayload, type IReleaseAssetTransferOpti
|
|
|
6
6
|
* GitLab API v4 provider implementation
|
|
7
7
|
*/
|
|
8
8
|
export class GitLabProvider extends BaseProvider {
|
|
9
|
+
/**
|
|
10
|
+
* GitLab does not sign webhook bodies: it echoes the configured secret token
|
|
11
|
+
* verbatim in this header, so the comparison is against the secret itself.
|
|
12
|
+
*
|
|
13
|
+
* Spelled the way GitLab documents it: header lookup is case-insensitive, so the same
|
|
14
|
+
* constant serves the verification and the operator-facing webhook dialog.
|
|
15
|
+
*/
|
|
16
|
+
public static readonly webhookTokenHeader = 'X-Gitlab-Token';
|
|
17
|
+
/** Header naming the delivered event, e.g. 'Push Hook'. */
|
|
18
|
+
public static readonly webhookEventHeader = 'X-Gitlab-Event';
|
|
19
|
+
|
|
9
20
|
private client: plugins.gitlabClient.GitLabClient;
|
|
10
21
|
|
|
11
22
|
constructor(connectionId: string, baseUrl: string, token: string, groupFilterId?: string) {
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
export type TProviderType = 'gitea' | 'gitlab';
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Whether the provider side of a connection is known to hold the webhook secret.
|
|
5
|
+
* 'unverified' connections were created before gitops minted webhook secrets: the
|
|
6
|
+
* operator still has to copy the minted secret into the provider's webhook config,
|
|
7
|
+
* so their deliveries are refused until they confirm it.
|
|
8
|
+
*/
|
|
9
|
+
export type TWebhookStatus = 'active' | 'unverified';
|
|
10
|
+
|
|
3
11
|
export interface IProviderConnection {
|
|
4
12
|
id: string;
|
|
5
13
|
name: string;
|
|
@@ -13,4 +21,25 @@ export interface IProviderConnection {
|
|
|
13
21
|
registryUrl?: string; // Optional OCI registry host/base URL when it differs from provider baseUrl
|
|
14
22
|
registryUsername?: string; // Optional OCI registry username; provider token is used first when omitted
|
|
15
23
|
registryToken?: string; // Optional OCI registry token/password; stored in keychain when configured
|
|
24
|
+
webhookSecret?: string; // Shared secret for incoming provider webhooks; stored in keychain, masked on read
|
|
25
|
+
webhookStatus?: TWebhookStatus; // 'active' once the provider side is known to hold the secret
|
|
26
|
+
/**
|
|
27
|
+
* Set while the secret store holds this connection's webhook secret but did not return
|
|
28
|
+
* it. Runtime-only and never persisted: the stored sentinel and status stay valid, so a
|
|
29
|
+
* failed read must neither mint a replacement nor overwrite the stored material.
|
|
30
|
+
*/
|
|
31
|
+
webhookSecretUnreadable?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Everything an operator needs to configure the webhook at the provider.
|
|
36
|
+
* Handed out only to an authenticated caller — it carries the real secret.
|
|
37
|
+
*/
|
|
38
|
+
export interface IWebhookSettings {
|
|
39
|
+
connectionId: string;
|
|
40
|
+
providerType: TProviderType;
|
|
41
|
+
deliveryPath: string; // path the provider posts to, e.g. '/webhook/<id>'
|
|
42
|
+
headerName: string; // header carrying the signature (Gitea) or the token (GitLab)
|
|
43
|
+
secret: string;
|
|
44
|
+
status: TWebhookStatus;
|
|
16
45
|
}
|
|
@@ -16,3 +16,45 @@ export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces
|
|
|
16
16
|
ok: boolean;
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
export interface IReq_GetWebhookSettings extends plugins.typedrequestInterfaces.implementsTR<
|
|
21
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
22
|
+
IReq_GetWebhookSettings
|
|
23
|
+
> {
|
|
24
|
+
method: 'getWebhookSettings';
|
|
25
|
+
request: {
|
|
26
|
+
identity: data.IIdentity;
|
|
27
|
+
connectionId: string;
|
|
28
|
+
};
|
|
29
|
+
response: {
|
|
30
|
+
webhookSettings: data.IWebhookSettings;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IReq_RotateWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<
|
|
35
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
36
|
+
IReq_RotateWebhookSecret
|
|
37
|
+
> {
|
|
38
|
+
method: 'rotateWebhookSecret';
|
|
39
|
+
request: {
|
|
40
|
+
identity: data.IIdentity;
|
|
41
|
+
connectionId: string;
|
|
42
|
+
};
|
|
43
|
+
response: {
|
|
44
|
+
webhookSettings: data.IWebhookSettings;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface IReq_ConfirmWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<
|
|
49
|
+
plugins.typedrequestInterfaces.ITypedRequest,
|
|
50
|
+
IReq_ConfirmWebhookSecret
|
|
51
|
+
> {
|
|
52
|
+
method: 'confirmWebhookSecret';
|
|
53
|
+
request: {
|
|
54
|
+
identity: data.IIdentity;
|
|
55
|
+
connectionId: string;
|
|
56
|
+
};
|
|
57
|
+
response: {
|
|
58
|
+
status: data.TWebhookStatus;
|
|
59
|
+
};
|
|
60
|
+
}
|
package/ts_web/appstate.ts
CHANGED
|
@@ -205,6 +205,52 @@ export const createConnectionAction = connectionsStatePart.createAction<{
|
|
|
205
205
|
}
|
|
206
206
|
});
|
|
207
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Webhook secrets are fetched on demand and handed straight to the caller — they are
|
|
210
|
+
* never put into a state part, where every subscriber would see them.
|
|
211
|
+
*/
|
|
212
|
+
export const fetchWebhookSettings = async (
|
|
213
|
+
connectionIdArg: string,
|
|
214
|
+
): Promise<interfaces.data.IWebhookSettings> => {
|
|
215
|
+
const context = getActionContext();
|
|
216
|
+
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
217
|
+
interfaces.requests.IReq_GetWebhookSettings
|
|
218
|
+
>('/typedrequest', 'getWebhookSettings');
|
|
219
|
+
const response = await typedRequest.fire({
|
|
220
|
+
identity: context.identity!,
|
|
221
|
+
connectionId: connectionIdArg,
|
|
222
|
+
});
|
|
223
|
+
return response.webhookSettings;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export const rotateWebhookSecret = async (
|
|
227
|
+
connectionIdArg: string,
|
|
228
|
+
): Promise<interfaces.data.IWebhookSettings> => {
|
|
229
|
+
const context = getActionContext();
|
|
230
|
+
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
231
|
+
interfaces.requests.IReq_RotateWebhookSecret
|
|
232
|
+
>('/typedrequest', 'rotateWebhookSecret');
|
|
233
|
+
const response = await typedRequest.fire({
|
|
234
|
+
identity: context.identity!,
|
|
235
|
+
connectionId: connectionIdArg,
|
|
236
|
+
});
|
|
237
|
+
return response.webhookSettings;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
export const confirmWebhookSecret = async (
|
|
241
|
+
connectionIdArg: string,
|
|
242
|
+
): Promise<interfaces.data.TWebhookStatus> => {
|
|
243
|
+
const context = getActionContext();
|
|
244
|
+
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
|
245
|
+
interfaces.requests.IReq_ConfirmWebhookSecret
|
|
246
|
+
>('/typedrequest', 'confirmWebhookSecret');
|
|
247
|
+
const response = await typedRequest.fire({
|
|
248
|
+
identity: context.identity!,
|
|
249
|
+
connectionId: connectionIdArg,
|
|
250
|
+
});
|
|
251
|
+
return response.status;
|
|
252
|
+
};
|
|
253
|
+
|
|
208
254
|
export const testConnectionAction = connectionsStatePart.createAction<{
|
|
209
255
|
connectionId: string;
|
|
210
256
|
}>(async (statePartArg, dataArg) => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as plugins from '../../../plugins.js';
|
|
2
2
|
import * as appstate from '../../../appstate.js';
|
|
3
|
+
import type * as interfaces from '../../../../ts_interfaces/index.js';
|
|
3
4
|
import { viewHostCss } from '../../shared/index.js';
|
|
4
5
|
import {
|
|
5
6
|
DeesElement,
|
|
@@ -65,6 +66,7 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
65
66
|
'Group Filter': item.groupFilter || '-',
|
|
66
67
|
Registry: item.registryUrl || '-',
|
|
67
68
|
'Registry User': item.registryUsername || '-',
|
|
69
|
+
Webhook: item.webhookStatus || 'unverified',
|
|
68
70
|
Status: item.status,
|
|
69
71
|
Created: new Date(item.createdAt).toLocaleDateString(),
|
|
70
72
|
})}
|
|
@@ -86,6 +88,12 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
86
88
|
);
|
|
87
89
|
},
|
|
88
90
|
},
|
|
91
|
+
{
|
|
92
|
+
name: 'Webhook',
|
|
93
|
+
iconName: 'lucide:webhook',
|
|
94
|
+
type: ['inRow', 'contextmenu'],
|
|
95
|
+
actionFunc: async ({ item }: any) => { await this.showWebhookSettings(item); },
|
|
96
|
+
},
|
|
89
97
|
{
|
|
90
98
|
name: 'Pause/Resume',
|
|
91
99
|
iconName: 'lucide:pauseCircle',
|
|
@@ -149,6 +157,87 @@ export class GitopsViewConnections extends DeesElement {
|
|
|
149
157
|
await appstate.connectionsStatePart.dispatchAction(appstate.fetchConnectionsAction, null);
|
|
150
158
|
}
|
|
151
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Shows the shared secret the provider has to send with every delivery. gitops cannot
|
|
162
|
+
* create the webhook through the provider API, so the operator pastes the secret into
|
|
163
|
+
* the provider's webhook form and confirms it here.
|
|
164
|
+
*/
|
|
165
|
+
private async showWebhookSettings(item: any) {
|
|
166
|
+
let settings: interfaces.data.IWebhookSettings;
|
|
167
|
+
try {
|
|
168
|
+
settings = await appstate.fetchWebhookSettings(item.id);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error('Failed to load webhook settings:', err);
|
|
171
|
+
await plugins.deesCatalog.DeesModal.createAndShow({
|
|
172
|
+
heading: 'Webhook',
|
|
173
|
+
content: html`<p style="color: #fff;">Could not load the webhook settings for "${item.name}".</p>`,
|
|
174
|
+
menuOptions: [{ name: 'Close', action: async (modal: any) => { modal.destroy(); } }],
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const deliveryUrl = `${window.location.origin}${settings.deliveryPath}`;
|
|
179
|
+
const secretLabel = settings.providerType === 'gitea' ? 'Secret' : 'Secret Token';
|
|
180
|
+
await plugins.deesCatalog.DeesModal.createAndShow({
|
|
181
|
+
heading: `Webhook — ${item.name}`,
|
|
182
|
+
content: html`
|
|
183
|
+
<style>
|
|
184
|
+
.webhook-row { margin-bottom: 12px; color: #fff; }
|
|
185
|
+
.webhook-label { font-size: 12px; color: #888; }
|
|
186
|
+
.webhook-value { font-family: monospace; word-break: break-all; }
|
|
187
|
+
.webhook-hint { font-size: 13px; color: #888; margin-top: 16px; }
|
|
188
|
+
</style>
|
|
189
|
+
<div class="webhook-row">
|
|
190
|
+
<div class="webhook-label">Payload URL</div>
|
|
191
|
+
<div class="webhook-value">${deliveryUrl}</div>
|
|
192
|
+
</div>
|
|
193
|
+
<div class="webhook-row">
|
|
194
|
+
<div class="webhook-label">${secretLabel}</div>
|
|
195
|
+
<div class="webhook-value">${settings.secret}</div>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="webhook-row">
|
|
198
|
+
<div class="webhook-label">Verification header</div>
|
|
199
|
+
<div class="webhook-value">${settings.headerName}</div>
|
|
200
|
+
</div>
|
|
201
|
+
<div class="webhook-row">
|
|
202
|
+
<div class="webhook-label">Status</div>
|
|
203
|
+
<div class="webhook-value">${settings.status}</div>
|
|
204
|
+
</div>
|
|
205
|
+
<div class="webhook-hint">
|
|
206
|
+
Set this ${secretLabel.toLowerCase()} on the webhook in ${settings.providerType === 'gitea' ? 'Gitea' : 'GitLab'},
|
|
207
|
+
then choose "Mark configured". Deliveries are refused while the webhook is unverified or
|
|
208
|
+
signed with a different secret.
|
|
209
|
+
</div>
|
|
210
|
+
`,
|
|
211
|
+
menuOptions: [
|
|
212
|
+
{ name: 'Close', action: async (modal: any) => { modal.destroy(); } },
|
|
213
|
+
{
|
|
214
|
+
name: 'Rotate Secret',
|
|
215
|
+
action: async (modal: any) => {
|
|
216
|
+
try {
|
|
217
|
+
await appstate.rotateWebhookSecret(item.id);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error('Failed to rotate webhook secret:', err);
|
|
220
|
+
}
|
|
221
|
+
modal.destroy();
|
|
222
|
+
await this.refresh();
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
name: 'Mark Configured',
|
|
227
|
+
action: async (modal: any) => {
|
|
228
|
+
try {
|
|
229
|
+
await appstate.confirmWebhookSecret(item.id);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error('Failed to confirm webhook secret:', err);
|
|
232
|
+
}
|
|
233
|
+
modal.destroy();
|
|
234
|
+
await this.refresh();
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
152
241
|
private async editConnection(item: any) {
|
|
153
242
|
await plugins.deesCatalog.DeesModal.createAndShow({
|
|
154
243
|
heading: 'Edit Connection',
|
package/readme.todo.md
DELETED