@homeflare/alchemy 0.11.0 → 0.13.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/README.md +3 -3
- package/dist/caddy/admin-guard.d.ts +1 -1
- package/dist/caddy/index.js.map +2 -2
- package/dist/cloudflare/client.d.ts +1 -1
- package/dist/cloudflare/index.js.map +2 -2
- package/dist/cloudflare/lock-rules.d.ts +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +1 -1
- package/dist/forgejo/branch-protection.d.ts +1 -1
- package/dist/forgejo/index.js.map +10 -10
- package/dist/forgejo/org-actions-secrets.d.ts +1 -1
- package/dist/forgejo/org-label.d.ts +1 -1
- package/dist/forgejo/org-team.d.ts +1 -1
- package/dist/forgejo/repo-webhook.d.ts +1 -1
- package/dist/forgejo/repository.d.ts +2 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +2 -2
- package/dist/forgejo/team-member.d.ts +1 -1
- package/dist/openbao/auth-role.d.ts +1 -1
- package/dist/openbao/bao-http.d.ts +4 -4
- package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
- package/dist/openbao/cloudflare-role-form.d.ts +2 -2
- package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
- package/dist/openbao/digest.d.ts +1 -1
- package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
- package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
- package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
- package/dist/openbao/index.js.map +14 -14
- package/dist/openbao/mount.d.ts +1 -1
- package/dist/openbao/pki-role.d.ts +1 -1
- package/dist/openbao/policy.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts +10 -10
- package/dist/openbao/ssh-role.d.ts +3 -3
- package/dist/proxmox/acl.d.ts +2 -2
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/api-token-form.d.ts +2 -2
- package/dist/proxmox/api-token.d.ts +11 -11
- package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
- package/dist/proxmox/ceph-daemon.d.ts +19 -19
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-flag.d.ts +5 -5
- package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
- package/dist/proxmox/ceph-fs.d.ts +16 -16
- package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
- package/dist/proxmox/ceph-osd.d.ts +5 -5
- package/dist/proxmox/ceph-pool-form.d.ts +13 -13
- package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool.d.ts +3 -3
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +8 -8
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-form.d.ts +1 -1
- package/dist/proxmox/firewall-alias.d.ts +1 -1
- package/dist/proxmox/generated/pbs.d.ts +279 -1
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +58 -1
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/group.d.ts +8 -8
- package/dist/proxmox/ha-resource.d.ts +3 -2
- package/dist/proxmox/ha-resource.d.ts.map +1 -1
- package/dist/proxmox/ha-rule-form.d.ts +5 -5
- package/dist/proxmox/ha-rule.d.ts +3 -3
- package/dist/proxmox/index.d.ts +14 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +935 -223
- package/dist/proxmox/index.js.map +69 -56
- package/dist/proxmox/lease-cache.d.ts +4 -4
- package/dist/proxmox/lxc-harness.d.ts +1 -1
- package/dist/proxmox/lxc-props.d.ts +2 -2
- package/dist/proxmox/lxc.d.ts +3 -3
- package/dist/proxmox/members.d.ts +3 -3
- package/dist/proxmox/network-apply-read.d.ts +5 -5
- package/dist/proxmox/network-apply.d.ts +9 -9
- package/dist/proxmox/node-network-form.d.ts +6 -6
- package/dist/proxmox/node-network.d.ts +17 -17
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/notification-target.d.ts +3 -2
- package/dist/proxmox/notification-target.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
- package/dist/proxmox/pbs-datastore.d.ts +10 -10
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job.d.ts +4 -4
- package/dist/proxmox/pbs-verify-job.d.ts +1 -1
- package/dist/proxmox/pool.d.ts +4 -4
- package/dist/proxmox/provision-baseline.d.ts +74 -0
- package/dist/proxmox/provision-baseline.d.ts.map +1 -0
- package/dist/proxmox/provision-bootstrap.d.ts +33 -0
- package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
- package/dist/proxmox/provision-cli-fake.d.ts +38 -0
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
- package/dist/proxmox/provision-declare.d.ts +25 -0
- package/dist/proxmox/provision-declare.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +9 -8
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +6 -5
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +7 -7
- package/dist/proxmox/sdn-apply.d.ts +1 -1
- package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
- package/dist/proxmox/sdn-subnet.d.ts +5 -5
- package/dist/proxmox/sdn-vnet.d.ts +5 -6
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +4 -3
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +3 -2
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/values.d.ts +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-write.d.ts +1 -1
- package/dist/proxmox/zfs-pool.d.ts +5 -5
- package/dist/talos/credentials.d.ts +1 -1
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/index.js.map +1 -1
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopt-verify.md +21 -5
- package/docs/adopted-deploys.md +9 -1
- package/docs/ownership.md +26 -1
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/provision-baseline.md +106 -0
- package/docs/proxmox-lxc-adopt.md +2 -2
- package/docs/proxmox-lxc.md +5 -4
- package/docs/proxmox.md +9 -0
- package/package.json +1 -1
- package/src/caddy/admin-guard.ts +1 -1
- package/src/caddy/local-admin.ts +1 -1
- package/src/cloudflare/client.ts +1 -1
- package/src/cloudflare/lock-rules.ts +1 -1
- package/src/forgejo/branch-protection-form.ts +1 -1
- package/src/forgejo/branch-protection.ts +1 -1
- package/src/forgejo/client.ts +1 -1
- package/src/forgejo/org-actions-secrets.ts +1 -1
- package/src/forgejo/org-label.ts +1 -1
- package/src/forgejo/org-team.ts +1 -1
- package/src/forgejo/repo-webhook.ts +1 -1
- package/src/forgejo/repository.ts +2 -2
- package/src/forgejo/resource.ts +2 -2
- package/src/forgejo/team-member.ts +1 -1
- package/src/openbao/README.md +4 -4
- package/src/openbao/auth-role.ts +1 -1
- package/src/openbao/bao-http.test.ts +2 -2
- package/src/openbao/bao-http.ts +6 -6
- package/src/openbao/bao-status.test.ts +1 -1
- package/src/openbao/bao-status.ts +1 -1
- package/src/openbao/cloudflare-group-scope.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.ts +3 -3
- package/src/openbao/cloudflare-policy.ts +1 -1
- package/src/openbao/cloudflare-role-form.ts +2 -2
- package/src/openbao/cloudflare-roles-expand.ts +1 -1
- package/src/openbao/digest.ts +1 -1
- package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
- package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
- package/src/openbao/forgejo-bootstrap.ts +4 -4
- package/src/openbao/kubernetes-role-form.test.ts +5 -5
- package/src/openbao/mount.ts +1 -1
- package/src/openbao/pki-role.ts +1 -1
- package/src/openbao/policy-wire.test.ts +3 -3
- package/src/openbao/policy.ts +2 -2
- package/src/openbao/proxmox-role-form.ts +5 -5
- package/src/openbao/proxmox-role.ts +11 -11
- package/src/openbao/ssh-role.ts +3 -3
- package/src/proxmox/acl.ts +4 -4
- package/src/proxmox/adopt-noop.test.ts +6 -6
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/api-token-form.ts +4 -4
- package/src/proxmox/api-token.ts +13 -13
- package/src/proxmox/ceph-daemon-form.ts +3 -3
- package/src/proxmox/ceph-daemon.ts +20 -20
- package/src/proxmox/ceph-flag.ts +7 -7
- package/src/proxmox/ceph-fs-wire.ts +7 -7
- package/src/proxmox/ceph-fs.ts +18 -18
- package/src/proxmox/ceph-osd-tree.ts +16 -16
- package/src/proxmox/ceph-osd.ts +6 -6
- package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
- package/src/proxmox/ceph-pool-form.ts +13 -13
- package/src/proxmox/ceph-pool.ts +6 -6
- package/src/proxmox/client.test.ts +3 -3
- package/src/proxmox/client.ts +3 -3
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/credentials.test.ts +6 -8
- package/src/proxmox/credentials.ts +8 -8
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias.ts +1 -1
- package/src/proxmox/generated/pbs.ts +330 -1
- package/src/proxmox/generated/pve.ts +63 -1
- package/src/proxmox/group.ts +10 -10
- package/src/proxmox/ha-resource.ts +3 -2
- package/src/proxmox/ha-rule-form.ts +5 -5
- package/src/proxmox/ha-rule.ts +3 -3
- package/src/proxmox/index.test.ts +10 -0
- package/src/proxmox/index.ts +32 -0
- package/src/proxmox/lease-cache.test.ts +19 -19
- package/src/proxmox/lease-cache.ts +4 -4
- package/src/proxmox/lxc-adopt.test.ts +3 -3
- package/src/proxmox/lxc-create-form.ts +1 -1
- package/src/proxmox/lxc-guards.test.ts +4 -4
- package/src/proxmox/lxc-harness.ts +3 -3
- package/src/proxmox/lxc-judge.test.ts +5 -5
- package/src/proxmox/lxc-lifecycle.ts +1 -1
- package/src/proxmox/lxc-props.ts +2 -2
- package/src/proxmox/lxc-read.test.ts +3 -3
- package/src/proxmox/lxc-strict-adopt.test.ts +1 -1
- package/src/proxmox/lxc-volume.test.ts +9 -9
- package/src/proxmox/lxc-volume.ts +1 -1
- package/src/proxmox/lxc-wire.ts +1 -1
- package/src/proxmox/lxc.ts +3 -3
- package/src/proxmox/members.test.ts +1 -1
- package/src/proxmox/members.ts +3 -3
- package/src/proxmox/metric-server-otel.ts +2 -2
- package/src/proxmox/network-apply-read.ts +10 -10
- package/src/proxmox/network-apply.ts +10 -10
- package/src/proxmox/node-network-form.ts +7 -7
- package/src/proxmox/node-network.ts +18 -18
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/notification-target.ts +3 -2
- package/src/proxmox/pbs-datastore-guard.ts +1 -1
- package/src/proxmox/pbs-datastore.ts +10 -10
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/pbs-prune-job.ts +2 -2
- package/src/proxmox/pbs-sync-job.ts +4 -4
- package/src/proxmox/pbs-verify-job.ts +1 -1
- package/src/proxmox/pool.test.ts +2 -2
- package/src/proxmox/pool.ts +4 -4
- package/src/proxmox/provision-baseline.test.ts +87 -0
- package/src/proxmox/provision-baseline.ts +162 -0
- package/src/proxmox/provision-bootstrap.test.ts +132 -0
- package/src/proxmox/provision-bootstrap.ts +135 -0
- package/src/proxmox/provision-cli-fake.ts +217 -0
- package/src/proxmox/provision-declare.test.ts +111 -0
- package/src/proxmox/provision-declare.ts +103 -0
- package/src/proxmox/replication-job.ts +9 -8
- package/src/proxmox/resource.ts +9 -9
- package/src/proxmox/sdn-apply-read.test.ts +16 -16
- package/src/proxmox/sdn-apply-read.ts +7 -7
- package/src/proxmox/sdn-apply.ts +3 -3
- package/src/proxmox/sdn-subnet-form.ts +1 -1
- package/src/proxmox/sdn-subnet.ts +5 -5
- package/src/proxmox/sdn-vnet.ts +5 -6
- package/src/proxmox/storage.ts +4 -3
- package/src/proxmox/user.ts +3 -2
- package/src/proxmox/values.test.ts +2 -2
- package/src/proxmox/values.ts +1 -1
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/proxmox/zfs-pool-write.ts +1 -1
- package/src/proxmox/zfs-pool.ts +6 -6
- package/src/talos/credentials.ts +3 -3
- package/src/talos/values.test.ts +9 -9
- package/src/verify/cli.test.ts +1 -1
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` through Alchemy's own Plan and Apply, over a PBS that remembers writes
|
|
3
|
+
* (fake-pbs-notify.ts) — the write-only contract, end to end.
|
|
4
|
+
*
|
|
5
|
+
* ★ WHAT IS PINNED, AND WHERE A REGRESSION WOULD LEAK:
|
|
6
|
+
* - the secret, the password and a `fromEnv` header reach the WIRE and never the STATE STORE
|
|
7
|
+
* (the store is dumped as JSON and searched, raw and base64);
|
|
8
|
+
* - a rotated value is found by the seal and written, and ONLY its group is written;
|
|
9
|
+
* - a plan without the variables is presence-only: noop, not a guess;
|
|
10
|
+
* - a write that needs a missing variable fails by the variable's NAME, before any request;
|
|
11
|
+
* - adopting a webhook that already has secrets writes nothing.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
14
|
+
import * as Layer from 'effect/Layer';
|
|
15
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
16
|
+
import type { PbsTarget } from './credentials.ts';
|
|
17
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
18
|
+
import { withoutBao } from './fake-pve.ts';
|
|
19
|
+
import {
|
|
20
|
+
PbsNotificationTarget,
|
|
21
|
+
type PbsNotificationTargetProps,
|
|
22
|
+
PbsNotificationTargetProvider,
|
|
23
|
+
} from './pbs-notification-target.ts';
|
|
24
|
+
import { keyAndValue, unbase64 } from './pbs-notification-target-wire.ts';
|
|
25
|
+
|
|
26
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
27
|
+
const PATH = 'config/notifications/endpoints/webhook/alertmanager';
|
|
28
|
+
const FIRST = 'tok-1111-not-real';
|
|
29
|
+
const SECOND = 'tok-2222-not-real';
|
|
30
|
+
const HEADER = 'hdr-3333-not-real';
|
|
31
|
+
|
|
32
|
+
const hook = (over: Partial<PbsNotificationTargetProps> = {}) =>
|
|
33
|
+
PbsNotificationTarget('alertmanager', {
|
|
34
|
+
body: '[{"labels": {"alertname": "x"}}]',
|
|
35
|
+
header: { 'Content-Type': 'application/json', 'X-Scope': { fromEnv: 'HF_TEST_HEADER' } },
|
|
36
|
+
method: 'post',
|
|
37
|
+
name: 'alertmanager',
|
|
38
|
+
secret: { token: { fromEnv: 'HF_TEST_TOKEN' } },
|
|
39
|
+
target: PBS,
|
|
40
|
+
type: 'webhook',
|
|
41
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
42
|
+
...over,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const setEnv = (values: Record<string, string | undefined>) => {
|
|
46
|
+
for (const [key, value] of Object.entries(values)) {
|
|
47
|
+
if (value === undefined) delete process.env[key];
|
|
48
|
+
else process.env[key] = value;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
afterEach(() => setEnv({ HF_TEST_HEADER: undefined, HF_TEST_TOKEN: undefined }));
|
|
53
|
+
|
|
54
|
+
const engineFor = (fake: ReturnType<typeof fakeNotify>) =>
|
|
55
|
+
engineOver(PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)));
|
|
56
|
+
|
|
57
|
+
const leaks = (stored: string, values: readonly string[]) =>
|
|
58
|
+
values.filter((v) => stored.includes(v) || stored.includes(Buffer.from(v).toString('base64')));
|
|
59
|
+
|
|
60
|
+
const verifyDiff = async (engine: ReturnType<typeof engineFor>, body = hook()) =>
|
|
61
|
+
(await engine.verify(body, { all: true })).rows[0]?.diff;
|
|
62
|
+
|
|
63
|
+
describe('a webhook created with write-only values', () => {
|
|
64
|
+
test('sends them, stores none of them, and plans noop again', async () => {
|
|
65
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
66
|
+
const fake = fakeNotify();
|
|
67
|
+
await withoutBao(async () => {
|
|
68
|
+
const engine = engineFor(fake);
|
|
69
|
+
await engine.deploy(hook());
|
|
70
|
+
expect(leaks(engine.stored(), [FIRST, HEADER])).toEqual([]);
|
|
71
|
+
expect(engine.stored()).toContain('HF_TEST_TOKEN');
|
|
72
|
+
expect(await verifyDiff(engine)).toBe('noop');
|
|
73
|
+
await engine.deploy(hook());
|
|
74
|
+
});
|
|
75
|
+
expect(fake.writes()).toEqual(['POST config/notifications/endpoints/webhook']);
|
|
76
|
+
const held = (fake.secrets.get(PATH) ?? []).map(keyAndValue);
|
|
77
|
+
expect(held.map((s) => [s.name, unbase64(s.value ?? '')])).toEqual([['token', FIRST]]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a rotated secret is found by the seal and PUT alone — headers are not resent', async () => {
|
|
81
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
82
|
+
const fake = fakeNotify();
|
|
83
|
+
await withoutBao(async () => {
|
|
84
|
+
const engine = engineFor(fake);
|
|
85
|
+
await engine.deploy(hook());
|
|
86
|
+
setEnv({ HF_TEST_TOKEN: SECOND });
|
|
87
|
+
expect(await verifyDiff(engine)).toBe('update');
|
|
88
|
+
await engine.deploy(hook());
|
|
89
|
+
expect(leaks(engine.stored(), [FIRST, SECOND, HEADER])).toEqual([]);
|
|
90
|
+
expect(await verifyDiff(engine)).toBe('noop');
|
|
91
|
+
});
|
|
92
|
+
const put = fake.calls.filter((call) => call.method === 'PUT');
|
|
93
|
+
expect(put).toHaveLength(1);
|
|
94
|
+
expect(put[0]?.pairs.map(([key]) => key)).toContain('secret');
|
|
95
|
+
expect(put[0]?.pairs.map(([key]) => key)).not.toContain('header');
|
|
96
|
+
expect(unbase64(keyAndValue(fake.secrets.get(PATH)?.[0] ?? '').value ?? '')).toBe(SECOND);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a header changed on the server is drift, found by digest, repaired from the env', async () => {
|
|
100
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
101
|
+
const fake = fakeNotify();
|
|
102
|
+
await withoutBao(async () => {
|
|
103
|
+
const engine = engineFor(fake);
|
|
104
|
+
await engine.deploy(hook());
|
|
105
|
+
const object = fake.objects.get(PATH) ?? {};
|
|
106
|
+
const edited = `name=X-Scope,value=${Buffer.from('edited-by-hand').toString('base64')}`;
|
|
107
|
+
fake.objects.set(PATH, { ...object, header: [(object['header'] as string[])[0], edited] });
|
|
108
|
+
expect(await verifyDiff(engine)).toBe('update');
|
|
109
|
+
await engine.deploy(hook());
|
|
110
|
+
expect(leaks(engine.stored(), [HEADER, 'edited-by-hand'])).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
113
|
+
expect(put?.pairs.map(([key]) => key)).toContain('header');
|
|
114
|
+
expect(put?.pairs.map(([key]) => key)).not.toContain('secret');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('without the variables', () => {
|
|
119
|
+
test('a plan is presence-only: noop, and a deploy writes nothing', async () => {
|
|
120
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
121
|
+
const fake = fakeNotify();
|
|
122
|
+
await withoutBao(async () => {
|
|
123
|
+
const engine = engineFor(fake);
|
|
124
|
+
await engine.deploy(hook());
|
|
125
|
+
setEnv({ HF_TEST_HEADER: undefined, HF_TEST_TOKEN: undefined });
|
|
126
|
+
expect(await verifyDiff(engine)).toBe('noop');
|
|
127
|
+
await engine.deploy(hook());
|
|
128
|
+
});
|
|
129
|
+
expect(fake.writes()).toEqual(['POST config/notifications/endpoints/webhook']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a comment change is still written — without the write-only groups, which the server keeps', async () => {
|
|
133
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
134
|
+
const fake = fakeNotify();
|
|
135
|
+
await withoutBao(async () => {
|
|
136
|
+
const engine = engineFor(fake);
|
|
137
|
+
await engine.deploy(hook());
|
|
138
|
+
setEnv({ HF_TEST_HEADER: undefined, HF_TEST_TOKEN: undefined });
|
|
139
|
+
await engine.deploy(hook({ comment: 'pager' }));
|
|
140
|
+
});
|
|
141
|
+
const put = fake.calls.find((call) => call.method === 'PUT');
|
|
142
|
+
expect(put?.pairs.map(([key]) => key).sort()).toEqual([
|
|
143
|
+
'body',
|
|
144
|
+
'comment',
|
|
145
|
+
'disable',
|
|
146
|
+
'method',
|
|
147
|
+
'url',
|
|
148
|
+
]);
|
|
149
|
+
expect(unbase64(keyAndValue(fake.secrets.get(PATH)?.[0] ?? '').value ?? '')).toBe(FIRST);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('a write that needs a missing value fails by variable name, before any request', async () => {
|
|
153
|
+
const fake = fakeNotify();
|
|
154
|
+
let failure = '';
|
|
155
|
+
await withoutBao(async () => {
|
|
156
|
+
await engineFor(fake)
|
|
157
|
+
.deploy(hook())
|
|
158
|
+
.catch((error: unknown) => {
|
|
159
|
+
failure = String(error);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
expect(failure).toContain('HF_TEST_HEADER, HF_TEST_TOKEN');
|
|
163
|
+
expect(fake.writes()).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('adopting a webhook that already holds secrets', () => {
|
|
168
|
+
test('reads only: names match, values are unknown, nothing is written', async () => {
|
|
169
|
+
setEnv({ HF_TEST_HEADER: HEADER, HF_TEST_TOKEN: FIRST });
|
|
170
|
+
const fake = fakeNotify({
|
|
171
|
+
[PATH]: {
|
|
172
|
+
body: Buffer.from('[{"labels": {"alertname": "x"}}]').toString('base64'),
|
|
173
|
+
header: [
|
|
174
|
+
`name=Content-Type,value=${Buffer.from('application/json').toString('base64')}`,
|
|
175
|
+
`name=X-Scope,value=${Buffer.from(HEADER).toString('base64')}`,
|
|
176
|
+
],
|
|
177
|
+
method: 'post',
|
|
178
|
+
name: 'alertmanager',
|
|
179
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
fake.secrets.set(PATH, [`name=token,value=${Buffer.from('set-by-hand').toString('base64')}`]);
|
|
183
|
+
await withoutBao(async () => {
|
|
184
|
+
const engine = engineFor(fake);
|
|
185
|
+
expect((await engine.verify(hook())).rows[0]).toMatchObject({ diff: 'noop', ok: true });
|
|
186
|
+
await engine.deploy(hook());
|
|
187
|
+
expect(leaks(engine.stored(), [HEADER, 'set-by-hand', FIRST])).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
expect(fake.writes()).toEqual([]);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a PBS notification target looks on the wire, and what of it may be remembered.
|
|
3
|
+
*
|
|
4
|
+
* ★ SPLIT OUT OF pbs-notification-target.ts FOR THE 250-LINE CAP, on the seam
|
|
5
|
+
* notification-target-form.ts cut for PVE: the resource file says what a target IS and when it
|
|
6
|
+
* changed; this file turns a live answer into attributes and a value into a wire item. The
|
|
7
|
+
* `import type` back is erased, so the cycle is on paper only.
|
|
8
|
+
*
|
|
9
|
+
* ⛔ THE LIVE ANSWER HOLDS HEADER VALUES, AND NONE OF THEM REACHES AN ATTRIBUTE. PBS returns a
|
|
10
|
+
* webhook's headers in full — `name=Authorization,value=<base64>` — to anyone with Sys.Audit on
|
|
11
|
+
* /system/notifications (proxmox-notify api/webhook.rs `get_endpoint`, read at HEAD 2026-09-22).
|
|
12
|
+
* Copying them into attributes would copy whatever a human pasted into a header, a bearer token
|
|
13
|
+
* included, into the unencrypted state store. So attributes keep header NAMES and a fixed-salt
|
|
14
|
+
* scrypt digest of the pairs (write-only.ts), enough to notice a change and nothing to send.
|
|
15
|
+
* ⚠️ SECRETS ARE SAFER ON THE SERVER, AND THE READ SHOWS IT: `get_endpoint` blanks every secret's
|
|
16
|
+
* value and returns names only, from a root-only `notifications-priv.cfg`. A credential belongs
|
|
17
|
+
* in `secret` and is referenced as `{{ secrets.<name> }}` from a header, the URL or the body.
|
|
18
|
+
*/
|
|
19
|
+
import { Buffer } from 'node:buffer';
|
|
20
|
+
import { addressList, portOf } from './notification-target-form.ts';
|
|
21
|
+
import type {
|
|
22
|
+
PbsNotificationTargetAttributes,
|
|
23
|
+
PbsNotificationTargetProps,
|
|
24
|
+
} from './pbs-notification-target.ts';
|
|
25
|
+
import { bool, text } from './values.ts';
|
|
26
|
+
import { seal } from './write-only.ts';
|
|
27
|
+
|
|
28
|
+
export const base64 = (value: string): string => Buffer.from(value, 'utf8').toString('base64');
|
|
29
|
+
|
|
30
|
+
/** ⚠️ Node's decoder skips characters it does not know rather than throwing, so this never throws. */
|
|
31
|
+
export const unbase64 = (value: string): string => Buffer.from(value, 'base64').toString('utf8');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One `KeyAndBase64Val` property string — `name=<name>,value=<base64>` — split into its halves.
|
|
35
|
+
* ⚠️ KEYED BY NAME, NOT BY POSITION: the server serialises `name` first today, and a parser that
|
|
36
|
+
* relied on that would read a reordered item as a header called `value=…`.
|
|
37
|
+
* ★ A comma cannot occur inside either half: base64 has none, and `refusals` keeps names clean.
|
|
38
|
+
*/
|
|
39
|
+
export const keyAndValue = (item: string): { name: string; value?: string } => {
|
|
40
|
+
const parts: Record<string, string> = {};
|
|
41
|
+
for (const part of item.split(',')) {
|
|
42
|
+
const at = part.indexOf('=');
|
|
43
|
+
if (at > 0) parts[part.slice(0, at).trim()] = part.slice(at + 1).trim();
|
|
44
|
+
}
|
|
45
|
+
const value = parts['value'];
|
|
46
|
+
return value === undefined ? { name: parts['name'] ?? '' } : { name: parts['name'] ?? '', value };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The wire item for one header or secret. The value is base64 — the server decodes it to use it. */
|
|
50
|
+
export const wireItem = (name: string, value: string): string =>
|
|
51
|
+
`name=${name},value=${base64(value)}`;
|
|
52
|
+
|
|
53
|
+
const list = (value: unknown): readonly string[] =>
|
|
54
|
+
Array.isArray(value) ? value.map((item: unknown) => String(item)) : [];
|
|
55
|
+
|
|
56
|
+
/** Sorted, comma-joined names — presence, the only thing the store may know about the values. */
|
|
57
|
+
export const names = (keys: readonly string[]): string =>
|
|
58
|
+
[...keys]
|
|
59
|
+
.filter((key) => key !== '')
|
|
60
|
+
.sort()
|
|
61
|
+
.join(',');
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* ★ A FIXED SALT PER TARGET, SO AN UNCHANGED HEADER SET DIGESTS THE SAME ON EVERY READ. A random
|
|
65
|
+
* one would rewrite the state row on every plan. The server shows these values to its auditors
|
|
66
|
+
* anyway; what the salt and scrypt buy is that the STORE does not become a second, weaker copy.
|
|
67
|
+
*/
|
|
68
|
+
export const headerDigest = (target: string, pairs: Readonly<Record<string, string>>): string =>
|
|
69
|
+
seal(pairs, `hf:pbs-notification-target:${target}:header`);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Live JSON to attributes. `sealed` is left empty here: it is not the server's to report, and the
|
|
73
|
+
* handlers in pbs-notification-target.ts carry it forward from the previous state.
|
|
74
|
+
*/
|
|
75
|
+
export const targetAttributes = (
|
|
76
|
+
live: Record<string, unknown>,
|
|
77
|
+
props: PbsNotificationTargetProps,
|
|
78
|
+
): PbsNotificationTargetAttributes => {
|
|
79
|
+
const headers: Record<string, string> = {};
|
|
80
|
+
for (const item of list(live['header'])) {
|
|
81
|
+
const { name, value } = keyAndValue(item);
|
|
82
|
+
if (name !== '') headers[name] = unbase64(value ?? '');
|
|
83
|
+
}
|
|
84
|
+
const secrets = list(live['secret']).map((item) => keyAndValue(item).name);
|
|
85
|
+
return {
|
|
86
|
+
author: text(live['author']),
|
|
87
|
+
// ⚠️ DECODED, unlike the PVE family's: the server stores exactly the base64 it was handed and
|
|
88
|
+
// decodes it to render (endpoints/webhook.rs `build_request`), so decoding here is a pure
|
|
89
|
+
// function of what was written and `matches` compares readable text rather than base64.
|
|
90
|
+
body: unbase64(text(live['body'])),
|
|
91
|
+
comment: text(live['comment']),
|
|
92
|
+
disable: bool(live['disable']),
|
|
93
|
+
'from-address': text(live['from-address']),
|
|
94
|
+
header: names(Object.keys(headers)),
|
|
95
|
+
headerDigest: headerDigest(props.name, headers),
|
|
96
|
+
mailto: addressList(live['mailto']),
|
|
97
|
+
'mailto-user': addressList(live['mailto-user']),
|
|
98
|
+
method: text(live['method']),
|
|
99
|
+
mode: text(live['mode']),
|
|
100
|
+
name: props.name,
|
|
101
|
+
// ★ ON THE PER-OBJECT READ, MEASURED on PBS 4.2 (`notification endpoint sendmail show
|
|
102
|
+
// mail-to-root` answers `origin: builtin`). Reported, never compared.
|
|
103
|
+
origin: text(live['origin']),
|
|
104
|
+
port: portOf(live['port']),
|
|
105
|
+
secret: names(secrets),
|
|
106
|
+
sealed: '',
|
|
107
|
+
server: text(live['server']),
|
|
108
|
+
type: props.type,
|
|
109
|
+
url: text(live['url']),
|
|
110
|
+
username: text(live['username']),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` — refusals, the wire forms against the generated PBS schema, and the
|
|
3
|
+
* rule that no header or secret VALUE reaches an attribute.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ THE DRIFT CHECK IS A TYPED KEY LIST PER FAMILY. `targetForm` builds one flat body for three
|
|
6
|
+
* families, so it cannot be typed as any one of them; instead each list below `satisfies` the
|
|
7
|
+
* generated parameter keys (and each `delete` value the generated enum), and every form this file
|
|
8
|
+
* builds is asserted to stay inside its list. A renamed parameter fails `tsc` on the list.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, test } from 'bun:test';
|
|
11
|
+
import type {
|
|
12
|
+
ConfigNotificationsEndpointsSendmailNamePutParams,
|
|
13
|
+
ConfigNotificationsEndpointsSmtpPostParams,
|
|
14
|
+
ConfigNotificationsEndpointsWebhookNamePutParams,
|
|
15
|
+
ConfigNotificationsEndpointsWebhookPostParams,
|
|
16
|
+
} from './generated/pbs.ts';
|
|
17
|
+
import type { PbsTarget } from './credentials.ts';
|
|
18
|
+
import type { PbsNotificationTargetProps } from './pbs-notification-target.ts';
|
|
19
|
+
import { refusals, resolveGroups, targetForm } from './pbs-notification-target-form.ts';
|
|
20
|
+
import { keyAndValue, targetAttributes, unbase64 } from './pbs-notification-target-wire.ts';
|
|
21
|
+
|
|
22
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
23
|
+
const TOKEN = 'tok-5f0c1d2e-not-real';
|
|
24
|
+
const ENV = { HOOK_TOKEN: TOKEN, SMTP_PASSWORD: 'pw-not-real' };
|
|
25
|
+
|
|
26
|
+
const WEBHOOK_POST = [
|
|
27
|
+
'body',
|
|
28
|
+
'comment',
|
|
29
|
+
'disable',
|
|
30
|
+
'header',
|
|
31
|
+
'method',
|
|
32
|
+
'name',
|
|
33
|
+
'secret',
|
|
34
|
+
'url',
|
|
35
|
+
] as const satisfies readonly (keyof ConfigNotificationsEndpointsWebhookPostParams)[];
|
|
36
|
+
const WEBHOOK_PUT = [
|
|
37
|
+
'body',
|
|
38
|
+
'comment',
|
|
39
|
+
'delete',
|
|
40
|
+
'disable',
|
|
41
|
+
'header',
|
|
42
|
+
'method',
|
|
43
|
+
'secret',
|
|
44
|
+
'url',
|
|
45
|
+
] as const satisfies readonly (keyof ConfigNotificationsEndpointsWebhookNamePutParams)[];
|
|
46
|
+
const WEBHOOK_DELETE = [
|
|
47
|
+
'body',
|
|
48
|
+
'comment',
|
|
49
|
+
'header',
|
|
50
|
+
'secret',
|
|
51
|
+
] as const satisfies readonly NonNullable<
|
|
52
|
+
ConfigNotificationsEndpointsWebhookNamePutParams['delete']
|
|
53
|
+
>[number][];
|
|
54
|
+
const SMTP_POST = [
|
|
55
|
+
'author',
|
|
56
|
+
'comment',
|
|
57
|
+
'disable',
|
|
58
|
+
'from-address',
|
|
59
|
+
'mailto',
|
|
60
|
+
'mailto-user',
|
|
61
|
+
'mode',
|
|
62
|
+
'name',
|
|
63
|
+
'password',
|
|
64
|
+
'port',
|
|
65
|
+
'server',
|
|
66
|
+
'username',
|
|
67
|
+
] as const satisfies readonly (keyof ConfigNotificationsEndpointsSmtpPostParams)[];
|
|
68
|
+
const SENDMAIL_DELETE = [
|
|
69
|
+
'author',
|
|
70
|
+
'comment',
|
|
71
|
+
'from-address',
|
|
72
|
+
'mailto',
|
|
73
|
+
'mailto-user',
|
|
74
|
+
] as const satisfies readonly NonNullable<
|
|
75
|
+
ConfigNotificationsEndpointsSendmailNamePutParams['delete']
|
|
76
|
+
>[number][];
|
|
77
|
+
|
|
78
|
+
const webhook = (over: Partial<PbsNotificationTargetProps> = {}): PbsNotificationTargetProps => ({
|
|
79
|
+
body: '{"text": {{ json title }}}',
|
|
80
|
+
header: { Authorization: 'Bearer {{ secrets.token }}', 'Content-Type': 'application/json' },
|
|
81
|
+
method: 'post',
|
|
82
|
+
name: 'alertmanager',
|
|
83
|
+
secret: { token: { fromEnv: 'HOOK_TOKEN' } },
|
|
84
|
+
target: PBS,
|
|
85
|
+
type: 'webhook',
|
|
86
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
87
|
+
...over,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const all = { header: true, sealed: true } as const;
|
|
91
|
+
const keysOf = (form: object) => Object.keys(form).sort();
|
|
92
|
+
|
|
93
|
+
describe('refusals, at plan, by field name', () => {
|
|
94
|
+
test('a field outside its family, a missing required field, a name that breaks the wire', () => {
|
|
95
|
+
expect(refusals(webhook())).toEqual([]);
|
|
96
|
+
expect(refusals({ name: 'm', target: PBS, type: 'sendmail', url: 'https://x.test' })).toEqual([
|
|
97
|
+
"'url' belongs to webhook targets, not sendmail",
|
|
98
|
+
]);
|
|
99
|
+
expect(refusals(webhook({ url: undefined as never }))).toContain("webhook needs 'url'");
|
|
100
|
+
expect(refusals({ name: 's', server: 'mx.test', target: PBS, type: 'smtp' })).toEqual([
|
|
101
|
+
"smtp needs 'from-address'",
|
|
102
|
+
]);
|
|
103
|
+
expect(refusals(webhook({ header: { 'X-A,b': 'v' } }))).toEqual([
|
|
104
|
+
"'X-A,b' cannot be a header or secret name",
|
|
105
|
+
]);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('webhook forms', () => {
|
|
110
|
+
const groups = resolveGroups(webhook(), ENV);
|
|
111
|
+
|
|
112
|
+
test('a create carries base64 body, header and secret items, and stays inside the schema', () => {
|
|
113
|
+
const form = targetForm(webhook(), groups, all, 'create');
|
|
114
|
+
expect(keysOf(form).every((key) => (WEBHOOK_POST as readonly string[]).includes(key))).toBe(
|
|
115
|
+
true,
|
|
116
|
+
);
|
|
117
|
+
expect(unbase64(String(form['body']))).toBe('{"text": {{ json title }}}');
|
|
118
|
+
const secret = (form['secret'] as readonly string[]).map(keyAndValue);
|
|
119
|
+
expect(secret).toEqual([{ name: 'token', value: Buffer.from(TOKEN).toString('base64') }]);
|
|
120
|
+
const headers = (form['header'] as readonly string[]).map(keyAndValue);
|
|
121
|
+
expect(headers.map((h) => [h.name, unbase64(h.value ?? '')])).toEqual([
|
|
122
|
+
['Authorization', 'Bearer {{ secrets.token }}'],
|
|
123
|
+
['Content-Type', 'application/json'],
|
|
124
|
+
]);
|
|
125
|
+
expect(form['disable']).toBe('0');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('an update carries only the groups it was told to — omitted means "keep"', () => {
|
|
129
|
+
const form = targetForm(
|
|
130
|
+
webhook({ comment: 'x' }),
|
|
131
|
+
groups,
|
|
132
|
+
{ header: false, sealed: false },
|
|
133
|
+
'update',
|
|
134
|
+
);
|
|
135
|
+
expect(keysOf(form)).toEqual(['body', 'comment', 'disable', 'method', 'url']);
|
|
136
|
+
expect(keysOf(form).every((key) => (WEBHOOK_PUT as readonly string[]).includes(key))).toBe(
|
|
137
|
+
true,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('clearing is a `delete` inside the generated enum', () => {
|
|
142
|
+
const cleared = webhook({ body: '', comment: '', header: {}, secret: {} });
|
|
143
|
+
const form = targetForm(cleared, resolveGroups(cleared, ENV), all, 'update');
|
|
144
|
+
expect(form['delete']).toEqual(['body', 'comment', 'header', 'secret']);
|
|
145
|
+
expect(
|
|
146
|
+
(form['delete'] as readonly string[]).every((d) =>
|
|
147
|
+
(WEBHOOK_DELETE as readonly string[]).includes(d),
|
|
148
|
+
),
|
|
149
|
+
).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('mail forms', () => {
|
|
154
|
+
test('smtp: recipients sorted, password sent only when its group is carried', () => {
|
|
155
|
+
const smtp: PbsNotificationTargetProps = {
|
|
156
|
+
'from-address': 'pbs@example.com',
|
|
157
|
+
mailto: ['b@example.com', 'a@example.com'],
|
|
158
|
+
name: 'relay',
|
|
159
|
+
password: { fromEnv: 'SMTP_PASSWORD' },
|
|
160
|
+
server: 'mx.example.com',
|
|
161
|
+
target: PBS,
|
|
162
|
+
type: 'smtp',
|
|
163
|
+
username: 'pbs',
|
|
164
|
+
};
|
|
165
|
+
const groups = resolveGroups(smtp, ENV);
|
|
166
|
+
const create = targetForm(smtp, groups, all, 'create');
|
|
167
|
+
expect(create['mailto']).toEqual(['a@example.com', 'b@example.com']);
|
|
168
|
+
expect(create['password']).toBe('pw-not-real');
|
|
169
|
+
expect(keysOf(create).every((key) => (SMTP_POST as readonly string[]).includes(key))).toBe(
|
|
170
|
+
true,
|
|
171
|
+
);
|
|
172
|
+
expect(
|
|
173
|
+
targetForm(smtp, groups, { header: false, sealed: false }, 'update')['password'],
|
|
174
|
+
).toBeUndefined();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('sendmail: an emptied list and an empty from-address clear, inside the enum', () => {
|
|
178
|
+
const sendmail: PbsNotificationTargetProps = {
|
|
179
|
+
'from-address': '',
|
|
180
|
+
mailto: [],
|
|
181
|
+
name: 'mail-to-root',
|
|
182
|
+
target: PBS,
|
|
183
|
+
type: 'sendmail',
|
|
184
|
+
};
|
|
185
|
+
const form = targetForm(sendmail, resolveGroups(sendmail, ENV), all, 'update');
|
|
186
|
+
expect(form['delete']).toEqual(['from-address', 'mailto']);
|
|
187
|
+
expect(
|
|
188
|
+
(form['delete'] as readonly string[]).every((d) =>
|
|
189
|
+
(SENDMAIL_DELETE as readonly string[]).includes(d),
|
|
190
|
+
),
|
|
191
|
+
).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('attributes', () => {
|
|
196
|
+
const live = {
|
|
197
|
+
body: Buffer.from('{}').toString('base64'),
|
|
198
|
+
header: [`name=Authorization,value=${Buffer.from(`Bearer ${TOKEN}`).toString('base64')}`],
|
|
199
|
+
method: 'post',
|
|
200
|
+
name: 'alertmanager',
|
|
201
|
+
origin: 'user-created',
|
|
202
|
+
secret: ['name=token'],
|
|
203
|
+
url: 'https://alertmanager.example.com/api/v2/alerts',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
test('keep header and secret NAMES and a digest — never a value, raw or base64', () => {
|
|
207
|
+
const attributes = targetAttributes(live, webhook());
|
|
208
|
+
expect(attributes.header).toBe('Authorization');
|
|
209
|
+
expect(attributes.secret).toBe('token');
|
|
210
|
+
expect(attributes.headerDigest).toStartWith('scrypt:');
|
|
211
|
+
const text = JSON.stringify(attributes);
|
|
212
|
+
expect(text).not.toContain(TOKEN);
|
|
213
|
+
expect(text).not.toContain(Buffer.from(`Bearer ${TOKEN}`).toString('base64'));
|
|
214
|
+
expect(attributes.body).toBe('{}');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('the header digest is stable across reads and moves with the value', () => {
|
|
218
|
+
const again = targetAttributes(live, webhook());
|
|
219
|
+
expect(again.headerDigest).toBe(targetAttributes(live, webhook()).headerDigest);
|
|
220
|
+
const other = {
|
|
221
|
+
...live,
|
|
222
|
+
header: [`name=Authorization,value=${Buffer.from('Bearer other').toString('base64')}`],
|
|
223
|
+
};
|
|
224
|
+
expect(targetAttributes(other, webhook()).headerDigest).not.toBe(again.headerDigest);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Pbs.NotificationTarget` — where a Proxmox Backup Server sends a notification: a `webhook`, or
|
|
3
|
+
* mail through `sendmail` or `smtp`.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE WEBHOOK IS THE POINT. A failed verify, sync or garbage collection reaches a human through a
|
|
6
|
+
* target a matcher names (pbs-notification-matcher.ts). Out of the box that is `mail-to-root`,
|
|
7
|
+
* one mailbox on one host; a webhook can post an Alertmanager alert instead, and then the failure
|
|
8
|
+
* pages like everything else. docs/pbs-alertmanager-body.md has the body template.
|
|
9
|
+
*
|
|
10
|
+
* ⛔ SECRETS ARE DECLARED BY THE NAME OF AN ENVIRONMENT VARIABLE, NEVER BY VALUE, and NO VALUE EVER
|
|
11
|
+
* REACHES STATE — not as a prop, not as an attribute. write-only.ts has the whole argument; the
|
|
12
|
+
* short form: Alchemy stores props and attributes unencrypted, so `secret`, `password` and any
|
|
13
|
+
* header holding a credential take `{ fromEnv: 'NAME' }`, read by the deploying process at call
|
|
14
|
+
* time. What the store keeps is presence (names) and digests, and the plan diffs on those.
|
|
15
|
+
* ★ THIS IS WHERE THE PBS FAMILY PARTS FROM notification-target.ts (PVE), which refuses every
|
|
16
|
+
* secret and so cannot create a signed webhook at all.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ THE PER-OBJECT PATH CARRIES THE FAMILY: `config/notifications/endpoints/{type}/{name}`. `type`
|
|
19
|
+
* is identity, and changing it or `name` plans a REPLACE here — `diff` compares them with the
|
|
20
|
+
* recorded props. `/config/notifications/targets` is a read-only union view, as on PVE.
|
|
21
|
+
*
|
|
22
|
+
* ⚠️ A BUILT-IN TARGET REVERTS ON DELETE instead of disappearing (`origin: builtin`, measured on
|
|
23
|
+
* PBS 4.2 for `mail-to-root`), and PBS reports success either way — notification-target.ts has
|
|
24
|
+
* the consequence. Read `origin` before declaring a name you did not create.
|
|
25
|
+
*
|
|
26
|
+
* ★ WHAT THIS NEEDS (generated/pbs.ts source): Sys.Audit on /system/notifications to read,
|
|
27
|
+
* Sys.Modify there to write. A datastore-scoped credential reads every target as absent.
|
|
28
|
+
*/
|
|
29
|
+
import { Resource } from 'alchemy';
|
|
30
|
+
import * as Provider from 'alchemy/Provider';
|
|
31
|
+
import * as Effect from 'effect/Effect';
|
|
32
|
+
import { handlers } from './pbs-notification-target-lifecycle.ts';
|
|
33
|
+
import type { PveRequirements, WithPbsTarget } from './resource.ts';
|
|
34
|
+
import type { FromEnv } from './write-only.ts';
|
|
35
|
+
|
|
36
|
+
export type PbsNotificationTargetType = 'sendmail' | 'smtp' | 'webhook';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ⚠️ PBS'S OWN KEY NAMES, DASHES INCLUDED, for the reason backup-job.ts gives. A field outside its
|
|
40
|
+
* family (a `url` on a sendmail target) is refused at plan by name, not sent for a 400.
|
|
41
|
+
* ⚠️ AN UNDECLARED FIELD IS NEITHER SENT NOR COMPARED; `''` or `[]` (or `{}`) CLEARS ONE.
|
|
42
|
+
*/
|
|
43
|
+
export interface PbsNotificationTargetProps extends WithPbsTarget {
|
|
44
|
+
/** ⛔ IDENTITY: it picks the family and the URL. Changing it is a replace. */
|
|
45
|
+
type: PbsNotificationTargetType;
|
|
46
|
+
/** Unique across ALL families on the host: a webhook cannot be called `mail-to-root`. */
|
|
47
|
+
name: string;
|
|
48
|
+
comment?: string;
|
|
49
|
+
/** Configured but not delivering. Always sent and always compared. */
|
|
50
|
+
disable?: boolean;
|
|
51
|
+
/** sendmail, smtp: recipient addresses. Order is not meaning. */
|
|
52
|
+
mailto?: readonly string[];
|
|
53
|
+
/** sendmail, smtp: PBS users whose configured address receives the mail. */
|
|
54
|
+
'mailto-user'?: readonly string[];
|
|
55
|
+
/** smtp: required. sendmail: optional. */
|
|
56
|
+
'from-address'?: string;
|
|
57
|
+
author?: string;
|
|
58
|
+
/** smtp: the relay host. Required. */
|
|
59
|
+
server?: string;
|
|
60
|
+
/** smtp. Unset lets PBS pick from `mode`. */
|
|
61
|
+
port?: number;
|
|
62
|
+
/** smtp. PBS's default is `tls`. */
|
|
63
|
+
mode?: 'insecure' | 'starttls' | 'tls';
|
|
64
|
+
/** smtp. */
|
|
65
|
+
username?: string;
|
|
66
|
+
/** smtp: ⛔ WRITE-ONLY. The server never returns it; the plan compares its seal. */
|
|
67
|
+
password?: FromEnv;
|
|
68
|
+
/** webhook: required. Supports templating: `{{ secrets.<name> }}` keeps a token out of it. */
|
|
69
|
+
url?: string;
|
|
70
|
+
/** webhook: required. */
|
|
71
|
+
method?: 'get' | 'post' | 'put';
|
|
72
|
+
/**
|
|
73
|
+
* webhook: header name → template text, or `{ fromEnv }` for a value that is itself a secret.
|
|
74
|
+
* ⚠️ PLAIN VALUES ARE PROPS, AND PROPS ARE STORED: `Content-Type` is fine, a token is not. Put a
|
|
75
|
+
* credential in `secret` and write `'Bearer {{ secrets.token }}'` here instead.
|
|
76
|
+
*/
|
|
77
|
+
header?: Readonly<Record<string, FromEnv | string>>;
|
|
78
|
+
/**
|
|
79
|
+
* webhook: the body TEMPLATE, as text. ★ Declared DECODED — the wire is base64, and this family
|
|
80
|
+
* encodes on write and decodes on read, so a plan shows a readable diff.
|
|
81
|
+
*/
|
|
82
|
+
body?: string;
|
|
83
|
+
/** webhook: ⛔ WRITE-ONLY. Name → variable. Referenced as `{{ secrets.<name> }}`. */
|
|
84
|
+
secret?: Readonly<Record<string, FromEnv>>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** ⛔ NO VALUE OF A SECRET, PASSWORD OR HEADER — names and digests only. */
|
|
88
|
+
export interface PbsNotificationTargetAttributes {
|
|
89
|
+
name: string;
|
|
90
|
+
type: PbsNotificationTargetType;
|
|
91
|
+
/** `builtin`, `modified-builtin`, `user-created` — reported, never compared. */
|
|
92
|
+
origin: string;
|
|
93
|
+
comment: string;
|
|
94
|
+
disable: boolean;
|
|
95
|
+
mailto: string;
|
|
96
|
+
'mailto-user': string;
|
|
97
|
+
'from-address': string;
|
|
98
|
+
author: string;
|
|
99
|
+
server: string;
|
|
100
|
+
/** 0 means PBS holds no port. */
|
|
101
|
+
port: number;
|
|
102
|
+
mode: string;
|
|
103
|
+
username: string;
|
|
104
|
+
url: string;
|
|
105
|
+
method: string;
|
|
106
|
+
/** The decoded body template. */
|
|
107
|
+
body: string;
|
|
108
|
+
/** Header NAMES, sorted — presence. */
|
|
109
|
+
header: string;
|
|
110
|
+
/** scrypt digest of the live header pairs, fixed salt per target. */
|
|
111
|
+
headerDigest: string;
|
|
112
|
+
/** Secret NAMES, sorted — all PBS will say. */
|
|
113
|
+
secret: string;
|
|
114
|
+
/**
|
|
115
|
+
* Seal of the secret values and password THIS PROVIDER LAST WROTE; `''` if it never wrote them
|
|
116
|
+
* (an adoption). ⚠️ `''` makes those values presence-only until the first write that carries them.
|
|
117
|
+
*/
|
|
118
|
+
sealed: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface PbsNotificationTarget extends Resource<
|
|
122
|
+
'Pbs.NotificationTarget',
|
|
123
|
+
PbsNotificationTargetProps,
|
|
124
|
+
PbsNotificationTargetAttributes,
|
|
125
|
+
never,
|
|
126
|
+
PveRequirements
|
|
127
|
+
> {}
|
|
128
|
+
|
|
129
|
+
export const PbsNotificationTarget = Resource<PbsNotificationTarget>('Pbs.NotificationTarget');
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* ⛔ `Provider.of` STAYS HERE, for resource.ts's reason: it is the one place the handlers are
|
|
133
|
+
* checked against the concrete resource types, so the lifecycle file cannot drift from them.
|
|
134
|
+
*/
|
|
135
|
+
export const PbsNotificationTargetProvider = () =>
|
|
136
|
+
Provider.effect(
|
|
137
|
+
PbsNotificationTarget,
|
|
138
|
+
Effect.succeed(PbsNotificationTarget.Provider.of(handlers)),
|
|
139
|
+
);
|