@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as plugins from '../plugins.js';
|
|
2
|
+
import * as data from '../data/index.js';
|
|
2
3
|
export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_WebhookNotification> {
|
|
3
4
|
method: 'webhookNotification';
|
|
4
5
|
request: {
|
|
@@ -11,3 +12,33 @@ export interface IReq_WebhookNotification extends plugins.typedrequestInterfaces
|
|
|
11
12
|
ok: boolean;
|
|
12
13
|
};
|
|
13
14
|
}
|
|
15
|
+
export interface IReq_GetWebhookSettings extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_GetWebhookSettings> {
|
|
16
|
+
method: 'getWebhookSettings';
|
|
17
|
+
request: {
|
|
18
|
+
identity: data.IIdentity;
|
|
19
|
+
connectionId: string;
|
|
20
|
+
};
|
|
21
|
+
response: {
|
|
22
|
+
webhookSettings: data.IWebhookSettings;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export interface IReq_RotateWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_RotateWebhookSecret> {
|
|
26
|
+
method: 'rotateWebhookSecret';
|
|
27
|
+
request: {
|
|
28
|
+
identity: data.IIdentity;
|
|
29
|
+
connectionId: string;
|
|
30
|
+
};
|
|
31
|
+
response: {
|
|
32
|
+
webhookSettings: data.IWebhookSettings;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export interface IReq_ConfirmWebhookSecret extends plugins.typedrequestInterfaces.implementsTR<plugins.typedrequestInterfaces.ITypedRequest, IReq_ConfirmWebhookSecret> {
|
|
36
|
+
method: 'confirmWebhookSecret';
|
|
37
|
+
request: {
|
|
38
|
+
identity: data.IIdentity;
|
|
39
|
+
connectionId: string;
|
|
40
|
+
};
|
|
41
|
+
response: {
|
|
42
|
+
status: data.TWebhookStatus;
|
|
43
|
+
};
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serve.zone/gitops",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "32.1.0",
|
|
4
4
|
"description": "GitOps management app for Gitea and GitLab - manage secrets, browse projects, view CI pipelines, and stream build logs",
|
|
5
5
|
"main": "./dist_ts/index.js",
|
|
6
6
|
"typings": "./dist_ts/index.d.ts",
|
|
@@ -34,18 +34,18 @@
|
|
|
34
34
|
"@push.rocks/smartdata": "7.1.7",
|
|
35
35
|
"@push.rocks/smartguard": "^3.1.0",
|
|
36
36
|
"@push.rocks/smartjwt": "^2.2.2",
|
|
37
|
-
"@push.rocks/smartmongo": "
|
|
37
|
+
"@push.rocks/smartmongo": "9.0.0",
|
|
38
38
|
"@push.rocks/smartsecret": "^1.0.2"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@git.zone/tsbuild": "^4.
|
|
42
|
-
"@git.zone/tsbundle": "^2.
|
|
43
|
-
"@git.zone/tsdeno": "^1.
|
|
44
|
-
"@git.zone/tsdocker": "
|
|
45
|
-
"@git.zone/tsrun": "^2.0.
|
|
46
|
-
"@git.zone/tstest": "
|
|
41
|
+
"@git.zone/tsbuild": "^4.5.0",
|
|
42
|
+
"@git.zone/tsbundle": "^2.13.0",
|
|
43
|
+
"@git.zone/tsdeno": "^1.8.0",
|
|
44
|
+
"@git.zone/tsdocker": "^3.6.0",
|
|
45
|
+
"@git.zone/tsrun": "^2.0.6",
|
|
46
|
+
"@git.zone/tstest": "6.1.1",
|
|
47
47
|
"@git.zone/tswatch": "^3.3.5",
|
|
48
|
-
"@types/node": "^
|
|
48
|
+
"@types/node": "^26.5.1"
|
|
49
49
|
},
|
|
50
50
|
"files": [
|
|
51
51
|
"ts/**/*",
|
package/readme.md
CHANGED
|
@@ -15,7 +15,7 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|
|
15
15
|
- Mirrors repositories from a source connection to a target connection with group offset support, stable source-project mapping for subgroup/path shifts, metadata sync, optional release/asset and OCI image sync, stale target handling, and live sync logs.
|
|
16
16
|
- Manages reusable secret definitions that can be pushed to selected project or group targets.
|
|
17
17
|
- Runs scheduled jobs, currently focused on syncing `readme.md`, `changelog.md`, and `docs/*.md` content into BookStack shelves, books, and pages.
|
|
18
|
-
- Receives provider webhooks at `POST /webhook/:connectionId
|
|
18
|
+
- Receives provider webhooks at `POST /webhook/:connectionId`, verifies every delivery against the connection's webhook secret, and broadcasts accepted events to connected browser clients.
|
|
19
19
|
|
|
20
20
|
## Runtime Shape
|
|
21
21
|
|
|
@@ -78,7 +78,7 @@ import type * as gitopsInterfaces from '@serve.zone/gitops/interfaces';
|
|
|
78
78
|
| `GITOPS_ADMIN_USERNAME` | `admin` | Dashboard login username. |
|
|
79
79
|
| `GITOPS_ADMIN_PASSWORD` | `admin` | Dashboard login password. |
|
|
80
80
|
|
|
81
|
-
Provider connections, sync configs, jobs, managed secrets, and action logs are persisted as JSON records below `~/.serve.zone/gitops/storage/`. Cached projects and secrets
|
|
81
|
+
Provider connections, sync configs, jobs, managed secrets, and action logs are persisted as JSON records below `~/.serve.zone/gitops/storage/`. Cached projects and secrets use a disposable NoSQLDB memory server through SmartMongo 9. The initial provider scan repopulates this cache after startup; it does not persist between processes.
|
|
82
82
|
|
|
83
83
|
Provider connections may also define `registryUrl`, `registryUsername`, and `registryToken` for OCI registry mirroring. Registry tokens are stored through SmartSecret and masked in API/UI responses. GitLab image sync falls back to `oauth2:<provider token>` when no dedicated registry credentials are configured; Gitea requires an explicit registry username when registry auth is needed.
|
|
84
84
|
|
|
@@ -96,7 +96,64 @@ The backend exposes TypedRequest methods through `/typedrequest` and a custom we
|
|
|
96
96
|
| Pipelines and Logs | Pipeline listing, jobs, retry/cancel, and raw job log fetches |
|
|
97
97
|
| Sync | Sync config CRUD, preview, trigger, status, logs, optional `syncReleases` and `syncContainerImages` artifact mirroring |
|
|
98
98
|
| Jobs | Scheduled job CRUD, trigger, pause/resume, and logs |
|
|
99
|
-
| Webhooks | `POST /webhook/:connectionId` for provider event fan-out |
|
|
99
|
+
| Webhooks | `POST /webhook/:connectionId` for verified provider event fan-out, plus `getWebhookSettings`, `rotateWebhookSecret`, `confirmWebhookSecret` |
|
|
100
|
+
|
|
101
|
+
## Webhooks
|
|
102
|
+
|
|
103
|
+
Every connection carries its own webhook secret. It is minted with the connection (32 random
|
|
104
|
+
bytes, hex) and kept in the same secret store as the provider token — never in the connection
|
|
105
|
+
JSON, never in an API response other than the reveal below.
|
|
106
|
+
|
|
107
|
+
GitOps does not create webhooks through the provider API, so the secret is transferred by hand:
|
|
108
|
+
|
|
109
|
+
1. Open **Connections → Webhook** for the connection. The dialog shows the payload URL
|
|
110
|
+
(`https://<gitops host>/webhook/<connectionId>`), the secret, and the header the provider
|
|
111
|
+
will use.
|
|
112
|
+
2. In Gitea, create the webhook with that payload URL, content type `application/json`, and the
|
|
113
|
+
secret in the **Secret** field. Gitea signs each delivery with HMAC-SHA256 over the raw body
|
|
114
|
+
and sends the hex digest in `X-Gitea-Signature`.
|
|
115
|
+
3. In GitLab, create the webhook with that URL and put the secret into **Secret token**. GitLab
|
|
116
|
+
echoes it verbatim in `X-Gitlab-Token`.
|
|
117
|
+
4. Back in GitOps choose **Mark Configured**. Until then the connection's webhook status is
|
|
118
|
+
`unverified` and its deliveries are refused.
|
|
119
|
+
|
|
120
|
+
**Rotate Secret** mints a new secret and sets the status back to `unverified`, so the previous
|
|
121
|
+
secret stops being accepted immediately.
|
|
122
|
+
|
|
123
|
+
Connections created before webhook verification existed are loaded with a freshly minted secret
|
|
124
|
+
and the status `unverified`: their provider side still holds no secret, so nothing they deliver
|
|
125
|
+
can be trusted until step 2/3/4 has been done for them.
|
|
126
|
+
|
|
127
|
+
A refused delivery never triggers a broadcast and is answered with the refusal name:
|
|
128
|
+
|
|
129
|
+
| Refusal | Status | Meaning |
|
|
130
|
+
| --- | --- | --- |
|
|
131
|
+
| `webhook-connection-unknown` | 404 | no connection with that id |
|
|
132
|
+
| `webhook-connection-unverified` | 401 | the secret has not been confirmed at the provider |
|
|
133
|
+
| `webhook-secret-missing` | 401 | the connection has no webhook secret in the secret store |
|
|
134
|
+
| `webhook-secret-unreadable` | 503 | the secret store holds the secret but did not return it |
|
|
135
|
+
| `webhook-length-missing` | 411 | no parsable `Content-Length` on the delivery |
|
|
136
|
+
| `webhook-signature-missing` | 401 | no `X-Gitea-Signature` / `X-Gitlab-Token` header |
|
|
137
|
+
| `webhook-signature-invalid` | 401 | signature or token does not match the connection secret |
|
|
138
|
+
| `webhook-body-too-large` | 413 | delivery body exceeds 1 MiB |
|
|
139
|
+
| `webhook-body-unreadable` | 400 | the request body could not be read |
|
|
140
|
+
|
|
141
|
+
The signature is computed over the bytes as received; GitOps never re-serializes the payload
|
|
142
|
+
before hashing it.
|
|
143
|
+
|
|
144
|
+
Delivery bodies are bounded twice. Gitea and GitLab always declare a `Content-Length`, so a
|
|
145
|
+
delivery without a parsable one is refused as `webhook-length-missing` before its body is
|
|
146
|
+
touched — reading it would mean buffering a stream of unknown size from an unauthenticated
|
|
147
|
+
caller. The declared length itself is only a claim, so the body is then read against a running
|
|
148
|
+
1 MiB budget and a delivery that sends more than it declared is refused the moment the budget
|
|
149
|
+
is crossed, with nothing beyond the bound buffered.
|
|
150
|
+
|
|
151
|
+
`webhook-secret-unreadable` is the one refusal that is not about the caller: the connection's
|
|
152
|
+
secret is in the secret store but a read of it failed (for example the SmartSecret keyring
|
|
153
|
+
quota). GitOps then refuses the connection's deliveries and leaves the stored secret exactly
|
|
154
|
+
as it is — it never mints a replacement over a secret an operator has already configured at
|
|
155
|
+
the provider. Deliveries resume on their own once the secret store answers again; **Rotate
|
|
156
|
+
Secret** replaces the stored secret deliberately if it really is gone.
|
|
100
157
|
|
|
101
158
|
## Auto BookStack Documentation
|
|
102
159
|
|
package/ts/00_commitinfo_data.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface ICacheDbOptions {
|
|
|
8
8
|
}
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Singleton wrapper around
|
|
11
|
+
* Singleton wrapper around SmartMongo's NoSQLDB memory server and SmartData.
|
|
12
12
|
* Provides a managed MongoDB-compatible cache database.
|
|
13
13
|
*/
|
|
14
14
|
export class CacheDb {
|
|
@@ -9,6 +9,13 @@ const CONNECTIONS_PREFIX = '/connections/';
|
|
|
9
9
|
const KEYCHAIN_PREFIX = 'keychain:';
|
|
10
10
|
|
|
11
11
|
const getRegistryTokenSecretId = (connectionId: string): string => `${connectionId}:registry`;
|
|
12
|
+
const getWebhookSecretId = (connectionId: string): string => `${connectionId}:webhook`;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Mints a webhook secret. 32 random bytes as hex: long enough that a delivery carrying
|
|
16
|
+
* it proves the sender was configured by us, short enough to paste into a provider UI.
|
|
17
|
+
*/
|
|
18
|
+
const mintWebhookSecret = (): string => plugins.crypto.randomBytes(32).toString('hex');
|
|
12
19
|
|
|
13
20
|
/**
|
|
14
21
|
* Manages provider connections — persists each connection as an
|
|
@@ -112,7 +119,26 @@ export class ConnectionManager {
|
|
|
112
119
|
} else if (conn.registryToken && conn.registryToken !== '***') {
|
|
113
120
|
await this.migrateRegistryTokenToKeychain(conn);
|
|
114
121
|
}
|
|
122
|
+
if (conn.webhookSecret?.startsWith(KEYCHAIN_PREFIX)) {
|
|
123
|
+
const webhookSecretId = conn.webhookSecret.substring(KEYCHAIN_PREFIX.length);
|
|
124
|
+
const realWebhookSecret = await this.smartSecret.getSecret(webhookSecretId);
|
|
125
|
+
if (realWebhookSecret) {
|
|
126
|
+
conn.webhookSecret = realWebhookSecret;
|
|
127
|
+
} else {
|
|
128
|
+
// The sentinel proves a secret was stored under this id, so an empty read is a
|
|
129
|
+
// keychain failure, not a missing secret. Minting a replacement here would
|
|
130
|
+
// overwrite the operator's confirmed secret under the same id and lose it, so
|
|
131
|
+
// the connection is flagged instead and its deliveries are refused by name.
|
|
132
|
+
logger.warn(
|
|
133
|
+
`Webhook secret for connection ${conn.id} is stored but unreadable — deliveries are ` +
|
|
134
|
+
'refused until the secret store returns it again; the stored secret is left untouched',
|
|
135
|
+
);
|
|
136
|
+
conn.webhookSecret = undefined;
|
|
137
|
+
conn.webhookSecretUnreadable = true;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
115
140
|
this.connections.push(conn);
|
|
141
|
+
await this.backfillWebhookSecret(conn);
|
|
116
142
|
}
|
|
117
143
|
}
|
|
118
144
|
if (this.connections.length > 0) {
|
|
@@ -157,6 +183,30 @@ export class ConnectionManager {
|
|
|
157
183
|
}
|
|
158
184
|
}
|
|
159
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Connections stored before webhook verification existed carry no secret. Nothing in
|
|
188
|
+
* gitops can create the webhook at the provider (neither provider implementation nor
|
|
189
|
+
* the Gitea/GitLab clients expose a hooks endpoint), so the secret is minted here and
|
|
190
|
+
* the connection is marked 'unverified': its deliveries stay refused until an operator
|
|
191
|
+
* copies the secret into the provider's webhook configuration and confirms it.
|
|
192
|
+
*/
|
|
193
|
+
private async backfillWebhookSecret(conn: interfaces.data.IProviderConnection): Promise<void> {
|
|
194
|
+
// A stored-but-unreadable secret is not a missing one. Minting here would write a new
|
|
195
|
+
// secret over the same keychain id and destroy confirmed operator material because of
|
|
196
|
+
// a transient read failure, so this connection is left exactly as it is on disk.
|
|
197
|
+
if (conn.webhookSecretUnreadable) return;
|
|
198
|
+
if (conn.webhookSecret && conn.webhookStatus) return;
|
|
199
|
+
if (!conn.webhookSecret) {
|
|
200
|
+
conn.webhookSecret = mintWebhookSecret();
|
|
201
|
+
}
|
|
202
|
+
conn.webhookStatus = 'unverified';
|
|
203
|
+
await this.persistConnection(conn);
|
|
204
|
+
logger.warn(
|
|
205
|
+
`Connection "${conn.name}" had no webhook secret — minted one and marked the webhook unverified. ` +
|
|
206
|
+
'Webhook deliveries are refused until the secret is set at the provider and confirmed.',
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
160
210
|
private async persistConnection(conn: interfaces.data.IProviderConnection): Promise<void> {
|
|
161
211
|
// Store real token in keychain
|
|
162
212
|
await this.smartSecret.setSecret(conn.id, conn.token);
|
|
@@ -165,11 +215,29 @@ export class ConnectionManager {
|
|
|
165
215
|
} else {
|
|
166
216
|
await this.smartSecret.deleteSecret(getRegistryTokenSecretId(conn.id));
|
|
167
217
|
}
|
|
218
|
+
// A connection whose secret is stored but unreadable keeps both its keychain entry and
|
|
219
|
+
// its sentinel: writing would replace material we could not read, deleting would throw
|
|
220
|
+
// it away. Every other persist path (status updates, health checks) runs through here,
|
|
221
|
+
// so this is where the stored secret survives a secret-store outage.
|
|
222
|
+
if (conn.webhookSecretUnreadable) {
|
|
223
|
+
// nothing to write — the stored secret and sentinel stay as they are
|
|
224
|
+
} else if (conn.webhookSecret) {
|
|
225
|
+
await this.smartSecret.setSecret(getWebhookSecretId(conn.id), conn.webhookSecret);
|
|
226
|
+
} else {
|
|
227
|
+
await this.smartSecret.deleteSecret(getWebhookSecretId(conn.id));
|
|
228
|
+
}
|
|
168
229
|
// Save JSON with sentinel value
|
|
169
230
|
const jsonConn = {
|
|
170
231
|
...conn,
|
|
171
232
|
token: `${KEYCHAIN_PREFIX}${conn.id}`,
|
|
172
233
|
registryToken: conn.registryToken ? `${KEYCHAIN_PREFIX}${getRegistryTokenSecretId(conn.id)}` : undefined,
|
|
234
|
+
webhookSecret:
|
|
235
|
+
conn.webhookSecret || conn.webhookSecretUnreadable
|
|
236
|
+
? `${KEYCHAIN_PREFIX}${getWebhookSecretId(conn.id)}`
|
|
237
|
+
: undefined,
|
|
238
|
+
// Runtime-only flag: it describes this process's view of the secret store, not the
|
|
239
|
+
// connection, and persisting it would outlive the outage that caused it.
|
|
240
|
+
webhookSecretUnreadable: undefined,
|
|
173
241
|
};
|
|
174
242
|
await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn);
|
|
175
243
|
}
|
|
@@ -177,15 +245,26 @@ export class ConnectionManager {
|
|
|
177
245
|
private async removeConnection(id: string): Promise<void> {
|
|
178
246
|
await this.smartSecret.deleteSecret(id);
|
|
179
247
|
await this.smartSecret.deleteSecret(getRegistryTokenSecretId(id));
|
|
248
|
+
await this.smartSecret.deleteSecret(getWebhookSecretId(id));
|
|
180
249
|
await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`);
|
|
181
250
|
}
|
|
182
251
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
252
|
+
/**
|
|
253
|
+
* The shape handed to API callers: every secret is masked, never the real value.
|
|
254
|
+
*/
|
|
255
|
+
private toPublicConnection(
|
|
256
|
+
conn: interfaces.data.IProviderConnection,
|
|
257
|
+
): interfaces.data.IProviderConnection {
|
|
258
|
+
return {
|
|
259
|
+
...conn,
|
|
186
260
|
token: '***',
|
|
187
|
-
registryToken:
|
|
188
|
-
|
|
261
|
+
registryToken: conn.registryToken ? '***' : undefined,
|
|
262
|
+
webhookSecret: conn.webhookSecret ? '***' : undefined,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
getConnections(): interfaces.data.IProviderConnection[] {
|
|
267
|
+
return this.connections.map((c) => this.toPublicConnection(c));
|
|
189
268
|
}
|
|
190
269
|
|
|
191
270
|
getConnection(id: string): interfaces.data.IProviderConnection | undefined {
|
|
@@ -214,11 +293,15 @@ export class ConnectionManager {
|
|
|
214
293
|
registryUrl: registryUrl || undefined,
|
|
215
294
|
registryUsername: registryUsername || undefined,
|
|
216
295
|
registryToken: registryToken || undefined,
|
|
296
|
+
// Minted with the connection so the operator can paste it into the provider's
|
|
297
|
+
// webhook form right away; no earlier provider state can hold a stale secret.
|
|
298
|
+
webhookSecret: mintWebhookSecret(),
|
|
299
|
+
webhookStatus: 'active',
|
|
217
300
|
};
|
|
218
301
|
this.connections.push(connection);
|
|
219
302
|
await this.persistConnection(connection);
|
|
220
303
|
logger.success(`Connection created: ${name} (${providerType})`);
|
|
221
|
-
return
|
|
304
|
+
return this.toPublicConnection(connection);
|
|
222
305
|
}
|
|
223
306
|
|
|
224
307
|
async updateConnection(
|
|
@@ -238,7 +321,7 @@ export class ConnectionManager {
|
|
|
238
321
|
if (updates.registryUsername !== undefined) conn.registryUsername = updates.registryUsername || undefined;
|
|
239
322
|
if (updates.registryToken !== undefined) conn.registryToken = updates.registryToken || undefined;
|
|
240
323
|
await this.persistConnection(conn);
|
|
241
|
-
return
|
|
324
|
+
return this.toPublicConnection(conn);
|
|
242
325
|
}
|
|
243
326
|
|
|
244
327
|
async deleteConnection(id: string): Promise<void> {
|
|
@@ -255,7 +338,66 @@ export class ConnectionManager {
|
|
|
255
338
|
conn.status = paused ? 'paused' : 'disconnected';
|
|
256
339
|
await this.persistConnection(conn);
|
|
257
340
|
logger.info(`Connection ${paused ? 'paused' : 'resumed'}: ${conn.name}`);
|
|
258
|
-
return
|
|
341
|
+
return this.toPublicConnection(conn);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Reveals the webhook secret to an already authenticated caller — the operator has to
|
|
346
|
+
* transfer it into the provider's webhook configuration by hand, because no provider
|
|
347
|
+
* API surface in this app creates webhooks.
|
|
348
|
+
*/
|
|
349
|
+
/**
|
|
350
|
+
* A stored-but-unreadable secret is reported as such, so an operator sees a secret-store
|
|
351
|
+
* outage instead of a connection that appears to have lost its secret.
|
|
352
|
+
*/
|
|
353
|
+
private assertWebhookSecretReadable(conn: interfaces.data.IProviderConnection): void {
|
|
354
|
+
if (conn.webhookSecretUnreadable) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Webhook secret for connection ${conn.id} is stored but unreadable — the secret store did not return it`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
getWebhookSecret(id: string): { secret: string; status: interfaces.data.TWebhookStatus } {
|
|
362
|
+
const conn = this.connections.find((c) => c.id === id);
|
|
363
|
+
if (!conn) throw new Error(`Connection not found: ${id}`);
|
|
364
|
+
this.assertWebhookSecretReadable(conn);
|
|
365
|
+
if (!conn.webhookSecret) throw new Error(`Connection has no webhook secret: ${id}`);
|
|
366
|
+
return { secret: conn.webhookSecret, status: conn.webhookStatus ?? 'unverified' };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Mints a fresh webhook secret. The provider still holds the previous one, so the
|
|
371
|
+
* connection goes back to 'unverified' until the new secret is confirmed.
|
|
372
|
+
*/
|
|
373
|
+
async rotateWebhookSecret(
|
|
374
|
+
id: string,
|
|
375
|
+
): Promise<{ secret: string; status: interfaces.data.TWebhookStatus }> {
|
|
376
|
+
const conn = this.connections.find((c) => c.id === id);
|
|
377
|
+
if (!conn) throw new Error(`Connection not found: ${id}`);
|
|
378
|
+
conn.webhookSecret = mintWebhookSecret();
|
|
379
|
+
conn.webhookStatus = 'unverified';
|
|
380
|
+
// Rotation is the operator's explicit decision to replace the stored secret, which is
|
|
381
|
+
// also the way out of an unreadable one — unlike the automatic backfill.
|
|
382
|
+
conn.webhookSecretUnreadable = undefined;
|
|
383
|
+
await this.persistConnection(conn);
|
|
384
|
+
logger.info(`Webhook secret rotated for connection "${conn.name}" — webhook is unverified again`);
|
|
385
|
+
return { secret: conn.webhookSecret, status: conn.webhookStatus };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Records that the provider side now holds the current secret; deliveries that carry a
|
|
390
|
+
* valid signature are accepted from here on.
|
|
391
|
+
*/
|
|
392
|
+
async confirmWebhookSecret(id: string): Promise<{ status: interfaces.data.TWebhookStatus }> {
|
|
393
|
+
const conn = this.connections.find((c) => c.id === id);
|
|
394
|
+
if (!conn) throw new Error(`Connection not found: ${id}`);
|
|
395
|
+
this.assertWebhookSecretReadable(conn);
|
|
396
|
+
if (!conn.webhookSecret) throw new Error(`Connection has no webhook secret: ${id}`);
|
|
397
|
+
conn.webhookStatus = 'active';
|
|
398
|
+
await this.persistConnection(conn);
|
|
399
|
+
logger.success(`Webhook confirmed for connection "${conn.name}"`);
|
|
400
|
+
return { status: conn.webhookStatus };
|
|
259
401
|
}
|
|
260
402
|
|
|
261
403
|
async testConnection(id: string): Promise<{ ok: boolean; error?: string }> {
|
|
@@ -2,30 +2,191 @@ import * as plugins from '../../plugins.js';
|
|
|
2
2
|
import { logger } from '../../logging.js';
|
|
3
3
|
import type { OpsServer } from '../classes.opsserver.js';
|
|
4
4
|
import * as interfaces from '../../../ts_interfaces/index.js';
|
|
5
|
+
import { requireValidIdentity } from '../helpers/guards.js';
|
|
6
|
+
import {
|
|
7
|
+
checkWebhookConnection,
|
|
8
|
+
readBoundedWebhookBody,
|
|
9
|
+
verifyWebhookDelivery,
|
|
10
|
+
webhookEventHeaderFor,
|
|
11
|
+
webhookSecretHeaderFor,
|
|
12
|
+
type TWebhookRefusal,
|
|
13
|
+
} from '../helpers/webhookverification.js';
|
|
14
|
+
|
|
15
|
+
/** Route pattern of the delivery endpoint. */
|
|
16
|
+
export const webhookRoutePattern = '/webhook/:connectionId';
|
|
17
|
+
|
|
18
|
+
/** The concrete delivery path an operator configures at the provider. */
|
|
19
|
+
export const webhookDeliveryPath = (connectionIdArg: string): string =>
|
|
20
|
+
`/webhook/${connectionIdArg}`;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* HTTP status per refusal. Everything the caller could fix by presenting the right
|
|
24
|
+
* secret is a 401; the rest describes the request itself — except
|
|
25
|
+
* 'webhook-secret-unreadable', which is our own transient fault and is answered 503 so a
|
|
26
|
+
* provider retries instead of an operator rotating a secret that is still correct.
|
|
27
|
+
*/
|
|
28
|
+
const refusalStatusCodes: Record<TWebhookRefusal, number> = {
|
|
29
|
+
'webhook-connection-unknown': 404,
|
|
30
|
+
'webhook-connection-unverified': 401,
|
|
31
|
+
'webhook-secret-missing': 401,
|
|
32
|
+
'webhook-secret-unreadable': 503,
|
|
33
|
+
'webhook-length-missing': 411,
|
|
34
|
+
'webhook-body-too-large': 413,
|
|
35
|
+
'webhook-body-unreadable': 400,
|
|
36
|
+
'webhook-signature-missing': 401,
|
|
37
|
+
'webhook-signature-invalid': 401,
|
|
38
|
+
};
|
|
5
39
|
|
|
6
40
|
export class WebhookHandler {
|
|
7
|
-
|
|
41
|
+
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
42
|
+
|
|
43
|
+
constructor(private opsServerRef: OpsServer) {
|
|
44
|
+
this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter);
|
|
45
|
+
this.registerHandlers();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private get connectionManager() {
|
|
49
|
+
return this.opsServerRef.gitopsAppRef.connectionManager;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private get actionLog() {
|
|
53
|
+
return this.opsServerRef.gitopsAppRef.actionLog;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Refuses a delivery. The refusal name is the whole answer — no payload is echoed and
|
|
58
|
+
* nothing about the presented or expected secret is logged.
|
|
59
|
+
*/
|
|
60
|
+
private refuse(
|
|
61
|
+
refusalArg: TWebhookRefusal,
|
|
62
|
+
connectionIdArg: string,
|
|
63
|
+
connectionNameArg?: string,
|
|
64
|
+
): Response {
|
|
65
|
+
const target = connectionNameArg
|
|
66
|
+
? `connection ${connectionNameArg} (${connectionIdArg})`
|
|
67
|
+
: `connection ${connectionIdArg}`;
|
|
68
|
+
logger.warn(`Webhook refused: ${refusalArg} for ${target}`);
|
|
69
|
+
return new Response(JSON.stringify({ error: refusalArg }), {
|
|
70
|
+
status: refusalStatusCodes[refusalArg],
|
|
71
|
+
headers: { 'Content-Type': 'application/json' },
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private webhookSettingsFor(
|
|
76
|
+
connectionIdArg: string,
|
|
77
|
+
): interfaces.data.IWebhookSettings {
|
|
78
|
+
const connection = this.connectionManager.getConnection(connectionIdArg);
|
|
79
|
+
if (!connection) {
|
|
80
|
+
throw new plugins.typedrequest.TypedResponseError(`Connection not found: ${connectionIdArg}`);
|
|
81
|
+
}
|
|
82
|
+
const { secret, status } = this.connectionManager.getWebhookSecret(connectionIdArg);
|
|
83
|
+
return {
|
|
84
|
+
connectionId: connection.id,
|
|
85
|
+
providerType: connection.providerType,
|
|
86
|
+
deliveryPath: webhookDeliveryPath(connection.id),
|
|
87
|
+
headerName: webhookSecretHeaderFor(connection.providerType),
|
|
88
|
+
secret,
|
|
89
|
+
status,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Operator-facing side of the webhook: reveal, rotate and confirm the shared secret.
|
|
95
|
+
* Neither provider implementation creates webhooks through the API, so the secret is
|
|
96
|
+
* transferred into the provider's webhook configuration by hand.
|
|
97
|
+
*/
|
|
98
|
+
private registerHandlers(): void {
|
|
99
|
+
this.typedrouter.addTypedHandler(
|
|
100
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetWebhookSettings>(
|
|
101
|
+
'getWebhookSettings',
|
|
102
|
+
async (dataArg) => {
|
|
103
|
+
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
|
104
|
+
return { webhookSettings: this.webhookSettingsFor(dataArg.connectionId) };
|
|
105
|
+
},
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
this.typedrouter.addTypedHandler(
|
|
110
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_RotateWebhookSecret>(
|
|
111
|
+
'rotateWebhookSecret',
|
|
112
|
+
async (dataArg) => {
|
|
113
|
+
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
|
114
|
+
await this.connectionManager.rotateWebhookSecret(dataArg.connectionId);
|
|
115
|
+
const webhookSettings = this.webhookSettingsFor(dataArg.connectionId);
|
|
116
|
+
this.actionLog.append({
|
|
117
|
+
actionType: 'update',
|
|
118
|
+
entityType: 'connection',
|
|
119
|
+
entityId: dataArg.connectionId,
|
|
120
|
+
entityName: this.connectionManager.getConnection(dataArg.connectionId)?.name || dataArg.connectionId,
|
|
121
|
+
details: 'Rotated webhook secret — webhook is unverified until the new secret is set at the provider',
|
|
122
|
+
username: dataArg.identity.username,
|
|
123
|
+
});
|
|
124
|
+
return { webhookSettings };
|
|
125
|
+
},
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
this.typedrouter.addTypedHandler(
|
|
130
|
+
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_ConfirmWebhookSecret>(
|
|
131
|
+
'confirmWebhookSecret',
|
|
132
|
+
async (dataArg) => {
|
|
133
|
+
await requireValidIdentity(this.opsServerRef.adminHandler, dataArg);
|
|
134
|
+
const result = await this.connectionManager.confirmWebhookSecret(dataArg.connectionId);
|
|
135
|
+
this.actionLog.append({
|
|
136
|
+
actionType: 'update',
|
|
137
|
+
entityType: 'connection',
|
|
138
|
+
entityId: dataArg.connectionId,
|
|
139
|
+
entityName: this.connectionManager.getConnection(dataArg.connectionId)?.name || dataArg.connectionId,
|
|
140
|
+
details: 'Confirmed the webhook secret is configured at the provider',
|
|
141
|
+
username: dataArg.identity.username,
|
|
142
|
+
});
|
|
143
|
+
return result;
|
|
144
|
+
},
|
|
145
|
+
),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
8
148
|
|
|
9
149
|
public registerRoutes(typedserver: plugins.typedserver.TypedServer): void {
|
|
10
|
-
typedserver.addRoute(
|
|
150
|
+
typedserver.addRoute(webhookRoutePattern, 'POST', async (ctx) => {
|
|
11
151
|
const connectionId = ctx.params.connectionId;
|
|
12
152
|
|
|
13
|
-
//
|
|
14
|
-
const connection = this.
|
|
153
|
+
// The connection carries the secret, so it is resolved before any body work.
|
|
154
|
+
const connection = this.connectionManager.getConnection(connectionId);
|
|
15
155
|
if (!connection) {
|
|
16
|
-
return
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
156
|
+
return this.refuse('webhook-connection-unknown', connectionId);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Everything that does not need the body is decided before buffering it.
|
|
160
|
+
const precondition = checkWebhookConnection(connection);
|
|
161
|
+
if (precondition) {
|
|
162
|
+
return this.refuse(precondition, connectionId, connection.name);
|
|
163
|
+
}
|
|
164
|
+
// The raw bytes are what the provider signed; parsing and re-serializing would
|
|
165
|
+
// change key order and whitespace and invalidate every genuine signature. The body
|
|
166
|
+
// is read through the bounded reader rather than ctx.arrayBuffer(), which buffers
|
|
167
|
+
// whatever an unauthenticated caller sends.
|
|
168
|
+
const body = await readBoundedWebhookBody({
|
|
169
|
+
headers: ctx.headers,
|
|
170
|
+
body: () => ctx.request.body,
|
|
171
|
+
});
|
|
172
|
+
if (!body.ok) {
|
|
173
|
+
return this.refuse(body.refusal, connectionId, connection.name);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const verification = verifyWebhookDelivery({
|
|
177
|
+
connection,
|
|
178
|
+
headers: ctx.headers,
|
|
179
|
+
rawBody: body.rawBody,
|
|
180
|
+
});
|
|
181
|
+
if (!verification.ok) {
|
|
182
|
+
return this.refuse(verification.refusal!, connectionId, connection.name);
|
|
20
183
|
}
|
|
21
184
|
|
|
22
|
-
//
|
|
23
|
-
const
|
|
24
|
-
const
|
|
25
|
-
const event = giteaEvent || gitlabEvent || 'unknown';
|
|
26
|
-
const provider = giteaEvent ? 'gitea' : gitlabEvent ? 'gitlab' : 'unknown';
|
|
185
|
+
// The provider is the one we have a connection with, never the one the caller claims.
|
|
186
|
+
const provider = connection.providerType;
|
|
187
|
+
const event = ctx.headers.get(webhookEventHeaderFor(provider)) || 'unknown';
|
|
27
188
|
|
|
28
|
-
logger.info(`Webhook
|
|
189
|
+
logger.info(`Webhook accepted: ${provider}/${event} for connection ${connection.name} (${connectionId})`);
|
|
29
190
|
|
|
30
191
|
// Broadcast to all connected frontends via TypedSocket
|
|
31
192
|
try {
|