underpost 3.2.80 → 3.3.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.
Files changed (84) hide show
  1. package/.github/workflows/ghpkg.ci.yml +7 -1
  2. package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
  3. package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
  4. package/.github/workflows/release.cd.yml +1 -9
  5. package/CHANGELOG.md +291 -1
  6. package/CLI-HELP.md +174 -23
  7. package/README.md +5 -2
  8. package/bin/build.js +7 -5
  9. package/bin/deploy.js +19 -17
  10. package/deploy/lib/logging.sh +96 -0
  11. package/deploy/pwa-microservices-template/deploy.sh +72 -0
  12. package/deploy/release/deploy.sh +62 -0
  13. package/docker-compose.yml +1 -1
  14. package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
  15. package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
  16. package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
  17. package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
  18. package/manifests/deployment/playwright/deployment.yaml +1 -1
  19. package/manifests/mongodb/kustomization.yaml +4 -1
  20. package/manifests/mongodb/statefulset.yaml +4 -0
  21. package/manifests/mongodb/storage-class.yaml +9 -2
  22. package/package.json +19 -19
  23. package/scripts/audit-selinux.sh +64 -0
  24. package/scripts/coverall-test.sh +24 -0
  25. package/scripts/gpu-diag.sh +0 -0
  26. package/scripts/ip-info.sh +0 -0
  27. package/scripts/k3s-node-setup.sh +18 -15
  28. package/scripts/kubeadm-node-setup.sh +12 -23
  29. package/scripts/link-local-underpost-cli.sh +0 -0
  30. package/scripts/lxd-vm-setup.sh +0 -0
  31. package/scripts/maas-nat-firewalld.sh +0 -0
  32. package/scripts/nat-iptables.sh +12 -4
  33. package/scripts/rhel-grpc-setup.sh +0 -0
  34. package/scripts/rocky-kickstart.sh +25 -9
  35. package/scripts/test-monitor.sh +4 -3
  36. package/src/cli/baremetal.js +1 -2
  37. package/src/cli/cloud-init.js +1 -1
  38. package/src/cli/cluster.js +786 -96
  39. package/src/cli/db.js +11 -4
  40. package/src/cli/deploy.js +1698 -177
  41. package/src/cli/docker-compose.js +19 -178
  42. package/src/cli/env.js +1 -1
  43. package/src/cli/image.js +15 -7
  44. package/src/cli/index.js +245 -44
  45. package/src/cli/ipfs.js +82 -11
  46. package/src/cli/lxd.js +1 -1
  47. package/src/cli/monitor.js +2 -2
  48. package/src/cli/release.js +57 -22
  49. package/src/cli/repository.js +12 -10
  50. package/src/cli/run.js +2195 -427
  51. package/src/cli/secrets.js +969 -0
  52. package/src/cli/ssh.js +206 -105
  53. package/src/cli/system.js +26 -13
  54. package/src/cli/test.js +1 -1
  55. package/src/cli/vultr.js +583 -0
  56. package/src/cli/wireguard.js +2125 -0
  57. package/src/client-builder/client-build.js +102 -13
  58. package/src/client-builder/ssr.js +27 -73
  59. package/src/db/mongo/MongoBootstrap.js +295 -54
  60. package/src/db/mongo/MongooseDB.js +51 -32
  61. package/src/index.js +25 -1
  62. package/src/projects/underpost/catalog-underpost.js +4 -1
  63. package/src/server/backup.js +1 -1
  64. package/src/server/conf.js +1216 -168
  65. package/src/server/cri.js +70 -0
  66. package/src/server/cron.js +249 -51
  67. package/src/server/dns.js +100 -6
  68. package/src/server/environment.js +98 -0
  69. package/src/server/forward-proxy.js +549 -0
  70. package/src/server/middlewares.js +56 -1
  71. package/src/server/process.js +0 -1
  72. package/src/server/selinux.js +185 -0
  73. package/src/server/systemd.js +205 -0
  74. package/src/server/underpost-compression.js +186 -0
  75. package/src/server/underpost-gateway.js +1083 -0
  76. package/src/server/underpost-ingress.js +380 -0
  77. package/test/cluster-instances.test.js +435 -0
  78. package/test/deploy-node-placement.test.js +45 -0
  79. package/test/instance-traffic-plan.test.js +710 -0
  80. package/test/selinux.test.js +71 -0
  81. package/test/sops-secret-store.test.js +612 -0
  82. package/test/underpost-gateway.test.js +510 -0
  83. package/test/underpost-ingress.test.js +305 -0
  84. package/test/wireguard-edge.test.js +1177 -0
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Underpost ingress: the shared entry point that lets the Contour and Gateway
3
+ * API data planes run side by side.
4
+ *
5
+ * Only one process can hold a node's 80/443, and a `hostPort` claim is stronger
6
+ * than a listener: the CNI hostport plugin DNATs every packet on those ports to
7
+ * the claiming pod before anything else sees them. So two ingress stacks on one
8
+ * node is not a configuration question — whichever claims first silently takes
9
+ * all traffic, and the other logs nothing at all.
10
+ *
11
+ * This module owns the port instead, and hands each connection to the data plane
12
+ * that actually describes its hostname. Both stacks are then reachable through
13
+ * the same address, and neither has to be uninstalled to try the other.
14
+ *
15
+ * Cleartext and TLS are split deliberately:
16
+ *
17
+ * - `:80` is proxied at L7, because a plaintext request carries a readable `Host`
18
+ * header and the backends answer it with their own redirect or content.
19
+ * - `:443` is forwarded at L4 by SNI, because terminating TLS here would mean
20
+ * holding every host's certificate and re-negotiating ALPN. Passing the bytes
21
+ * through keeps certificates, HTTP/2 and mTLS exactly where they already work.
22
+ *
23
+ * @module src/server/underpost-ingress.js
24
+ * @namespace UnderpostIngress
25
+ */
26
+
27
+ import { compressionConfFactory, compressionModulesConfFactory, nginxImageFactory } from './underpost-compression.js';
28
+
29
+ /**
30
+ * @constant UNDERPOST_INGRESS
31
+ * @description Identity of the underpost ingress workload. One deployment fronts every
32
+ * data plane on the node, so these names are cluster-wide constants.
33
+ * @memberof UnderpostIngress
34
+ */
35
+ const UNDERPOST_INGRESS = {
36
+ name: 'underpost-ingress',
37
+ configMapName: 'underpost-ingress-nginx',
38
+ image: nginxImageFactory(),
39
+ httpPort: 80,
40
+ httpsPort: 443,
41
+ healthPort: 8090,
42
+ healthPath: '/healthz',
43
+ // kube-dns's conventional ClusterIP; overridden from the live Service.
44
+ resolver: '10.96.0.10',
45
+ backends: {
46
+ contour: { http: 'envoy.projectcontour.svc.cluster.local:80', tls: 'envoy.projectcontour.svc.cluster.local:443' },
47
+ },
48
+ };
49
+
50
+ /**
51
+ * @method gatewayBackendFactory
52
+ * @description Upstreams for the Gateway API data plane.
53
+ *
54
+ * Envoy Gateway provisions and names its own Service (`envoy-<class>-<hash>`),
55
+ * so unlike Contour's fixed name this one is discovered from the cluster and
56
+ * passed in.
57
+ * @param {string} service - Provisioned Service name.
58
+ * @param {string} [namespace] - Namespace holding it.
59
+ * @returns {{http: string, tls: string}} Qualified upstreams.
60
+ * @memberof UnderpostIngress
61
+ */
62
+ const gatewayBackendFactory = (service, namespace = 'envoy-gateway-system') => ({
63
+ http: `${service}.${namespace}.svc.cluster.local:80`,
64
+ tls: `${service}.${namespace}.svc.cluster.local:443`,
65
+ });
66
+
67
+ /**
68
+ * @method underpostIngressHostMapFactory
69
+ * @description Decides which data plane each hostname is handed to.
70
+ *
71
+ * A hostname belongs to whichever stack has a route object describing it, which
72
+ * is the only fact that survives a switch between them: manifests for both kinds
73
+ * are always generated, so the objects that actually exist in the cluster are
74
+ * what says who serves what.
75
+ *
76
+ * A hostname described by both is a leftover from switching stacks, not a valid
77
+ * state — both would answer, and which one won would depend on ordering here. It
78
+ * resolves to `preferred` and is reported separately so the duplicate can be
79
+ * removed rather than silently tolerated.
80
+ * @param {Array<string>} [contourHosts] - Hostnames with an HTTPProxy.
81
+ * @param {Array<string>} [gatewayHosts] - Hostnames with an HTTPRoute.
82
+ * @param {string} [preferred] - Backend that wins a hostname described by both.
83
+ * @returns {{entries: Array<{host: string, backend: string}>, conflicts: Array<string>}} Routing table and duplicates.
84
+ * @memberof UnderpostIngress
85
+ */
86
+ const underpostIngressHostMapFactory = ({ contourHosts = [], gatewayHosts = [], preferred = 'gateway' } = {}) => {
87
+ const contour = new Set(contourHosts.filter(Boolean));
88
+ const gateway = new Set(gatewayHosts.filter(Boolean));
89
+ const conflicts = [...contour].filter((host) => gateway.has(host)).sort();
90
+ const entries = [...new Set([...contour, ...gateway])].sort().map((host) => ({
91
+ host,
92
+ backend: contour.has(host) && gateway.has(host) ? preferred : contour.has(host) ? 'contour' : 'gateway',
93
+ }));
94
+ return { entries, conflicts };
95
+ };
96
+
97
+ /**
98
+ * @method underpostIngressConfFactory
99
+ * @description Renders the underpost ingress Nginx configuration.
100
+ *
101
+ * `default` in both maps is what an unknown hostname reaches. It is the stack
102
+ * that owns most of the routing rather than a rejection, so a host whose route
103
+ * object has not been applied yet still reaches a data plane that can answer it
104
+ * — including with its own 404.
105
+ *
106
+ * Compression is rendered for `:80` alone, and that asymmetry is the same split
107
+ * the module makes everywhere else: an L7 hop holds a body it can encode, while
108
+ * `:443` carries bytes this process never decrypts. Whatever leaves through the
109
+ * tunnel on that port was compressed where TLS terminates — see
110
+ * {@link module:src/server/underpost-compression.js}.
111
+ * @param {Array<object>} [entries] - Host table from {@link UnderpostIngress.underpostIngressHostMapFactory}.
112
+ * @param {object} backends - `{contour: {http, tls}, gateway: {http, tls}}`; a missing stack is omitted.
113
+ * @param {string} [defaultBackend] - Backend for an unmatched hostname.
114
+ * @param {string} [resolver] - Cluster DNS ClusterIP.
115
+ * @param {object} [compression] - Overrides for the compression policy.
116
+ * @returns {string} nginx.conf contents.
117
+ * @memberof UnderpostIngress
118
+ */
119
+ const underpostIngressConfFactory = ({
120
+ entries = [],
121
+ backends = {},
122
+ defaultBackend = 'gateway',
123
+ resolver = UNDERPOST_INGRESS.resolver,
124
+ compression = {},
125
+ } = {}) => {
126
+ const available = Object.keys(backends).filter((name) => backends[name]?.http && backends[name]?.tls);
127
+ // With one stack installed the map still renders, so the same workload serves
128
+ // the single-stack case without a second code path.
129
+ const fallback = available.includes(defaultBackend) ? defaultBackend : available[0];
130
+ if (!fallback) throw new Error('[underpost-ingress] No data plane backend to route to');
131
+ const routable = entries.filter((entry) => entry?.host && available.includes(entry.backend));
132
+ const mapEntries = (kind) =>
133
+ routable.map((entry) => ` ${entry.host} ${backends[entry.backend][kind]};`).join('\n') || '';
134
+ // No `staticRoot`: this workload proxies every byte and serves no documents,
135
+ // so there is never a pre-compressed sibling on disk to prefer.
136
+ const policy = { ...compression, staticRoot: false };
137
+ const modules = compressionModulesConfFactory(policy);
138
+ const compress = compressionConfFactory(policy);
139
+
140
+ return `worker_processes auto;
141
+ error_log /dev/stderr warn;
142
+ pid /tmp/nginx.pid;
143
+ ${modules ? `${modules}\n` : ''}
144
+ events {
145
+ worker_connections 4096;
146
+ }
147
+
148
+ http {
149
+ server_tokens off;
150
+ ${compress ? `\n${compress}\n` : ''}
151
+ log_format underpost_ingress '$remote_addr "$request" $status "$host" -> $underpost_ingress_http_upstream';
152
+ access_log /dev/stdout underpost_ingress;
153
+
154
+ map $http_upgrade $connection_upgrade {
155
+ default upgrade;
156
+ '' close;
157
+ }
158
+
159
+ # Cluster DNS as a literal address — nginx cannot resolve its own resolver.
160
+ # Every upstream is passed through a variable, so without this nginx resolves
161
+ # each Service name once at start-up and keeps the address for the life of the
162
+ # process; a re-provisioned data plane gets a new ClusterIP.
163
+ resolver ${resolver} valid=10s ipv6=off;
164
+
165
+ map $host $underpost_ingress_http_upstream {
166
+ default ${backends[fallback].http};
167
+ ${mapEntries('http')}
168
+ }
169
+
170
+ server {
171
+ listen ${UNDERPOST_INGRESS.healthPort} default_server;
172
+ server_name _;
173
+ location = ${UNDERPOST_INGRESS.healthPath} {
174
+ access_log off;
175
+ add_header Content-Type text/plain;
176
+ return 200 'ok';
177
+ }
178
+ location / {
179
+ return 404;
180
+ }
181
+ }
182
+
183
+ server {
184
+ listen ${UNDERPOST_INGRESS.httpPort} default_server;
185
+ server_name _;
186
+
187
+ location / {
188
+ proxy_http_version 1.1;
189
+ proxy_set_header Host $host;
190
+ proxy_set_header X-Real-IP $remote_addr;
191
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
192
+ proxy_set_header X-Forwarded-Proto $scheme;
193
+ # Websocket upgrades must survive this hop or a client that negotiated one
194
+ # at the edge is left holding a half-open connection.
195
+ proxy_set_header Upgrade $http_upgrade;
196
+ proxy_set_header Connection $connection_upgrade;
197
+ proxy_pass http://$underpost_ingress_http_upstream;
198
+ }
199
+ }
200
+ }
201
+
202
+ stream {
203
+ log_format underpost_ingress '$remote_addr -> "$ssl_preread_server_name" $upstream_addr $status';
204
+ access_log /dev/stdout underpost_ingress;
205
+ resolver ${resolver} valid=10s ipv6=off;
206
+
207
+ # SNI is read without terminating the connection, so each data plane keeps
208
+ # serving its own certificates and negotiating its own ALPN. Terminating here
209
+ # would mean holding every host's key and re-offering h2 on the way out.
210
+ map $ssl_preread_server_name $underpost_ingress_tls_upstream {
211
+ default ${backends[fallback].tls};
212
+ ${mapEntries('tls')}
213
+ }
214
+
215
+ server {
216
+ listen ${UNDERPOST_INGRESS.httpsPort};
217
+ ssl_preread on;
218
+ proxy_pass $underpost_ingress_tls_upstream;
219
+ }
220
+ ${
221
+ backends.gateway
222
+ ? `
223
+ # QUIC keeps working, but it cannot be routed by hostname: \`ssl_preread\` is
224
+ # TCP-only, and a QUIC Initial carries its SNI inside an encrypted frame. Only
225
+ # the Gateway API data plane serves HTTP/3 here — Contour advertises no
226
+ # Alt-Svc — so every datagram goes there. A client that tries QUIC against a
227
+ # Contour host gets no answer and falls back to TCP, exactly as it would have
228
+ # if nothing had advertised HTTP/3 at all.
229
+ #
230
+ # The constant is reached through a map because a literal \`proxy_pass\` in
231
+ # \`stream\` is resolved when the config is parsed, not through \`resolver\`:
232
+ # nginx refuses to start while the Service has no DNS record, and once it does
233
+ # start it keeps that address for the life of the process.
234
+ map $remote_addr $underpost_ingress_quic_upstream {
235
+ default ${backends.gateway.tls};
236
+ }
237
+
238
+ server {
239
+ listen ${UNDERPOST_INGRESS.httpsPort} udp;
240
+ proxy_pass $underpost_ingress_quic_upstream;
241
+ proxy_timeout 30s;
242
+ }
243
+ `
244
+ : ''
245
+ }}
246
+ `;
247
+ };
248
+
249
+ /**
250
+ * @method underpostIngressManifestsFactory
251
+ * @description Renders the underpost ingress workload.
252
+ *
253
+ * Host-networked, because it exists to be the thing that holds the node's 80/443
254
+ * — the ports both data planes have just been moved off. `hostPort` is not used:
255
+ * the claim it installs is what made the two stacks exclusive in the first place.
256
+ * @param {string} [namespace] - Namespace to deploy into.
257
+ * @param {string} conf - Rendered nginx.conf.
258
+ * @param {string} [nodeName] - Node to pin the underpost ingress to; empty schedules freely.
259
+ * @returns {string} Multi-document YAML.
260
+ * @memberof UnderpostIngress
261
+ */
262
+ const underpostIngressManifestsFactory = ({ namespace = 'default', conf, nodeName = '' } = {}) => {
263
+ return `
264
+ ---
265
+ apiVersion: v1
266
+ kind: ConfigMap
267
+ metadata:
268
+ name: ${UNDERPOST_INGRESS.configMapName}
269
+ namespace: ${namespace}
270
+ data:
271
+ nginx.conf: |
272
+ ${`${conf}`
273
+ .replace(/\n$/, '')
274
+ .split('\n')
275
+ .map((line) => (line.length > 0 ? ` ${line}` : ''))
276
+ .join('\n')}
277
+ ---
278
+ apiVersion: apps/v1
279
+ kind: Deployment
280
+ metadata:
281
+ name: ${UNDERPOST_INGRESS.name}
282
+ namespace: ${namespace}
283
+ labels:
284
+ app: ${UNDERPOST_INGRESS.name}
285
+ spec:
286
+ replicas: 1
287
+ # Recreate is retained for real pod-template upgrades because the node's ports
288
+ # cannot be held twice. Host-table changes do not alter this template: the
289
+ # installer validates and hot-reloads /tmp/nginx.conf in the existing pod.
290
+ strategy:
291
+ type: Recreate
292
+ selector:
293
+ matchLabels:
294
+ app: ${UNDERPOST_INGRESS.name}
295
+ template:
296
+ metadata:
297
+ labels:
298
+ app: ${UNDERPOST_INGRESS.name}
299
+ spec:
300
+ hostNetwork: true
301
+ dnsPolicy: ClusterFirstWithHostNet${
302
+ nodeName
303
+ ? `
304
+ nodeSelector:
305
+ kubernetes.io/hostname: ${nodeName}`
306
+ : ''
307
+ }
308
+ containers:
309
+ - name: nginx
310
+ image: ${UNDERPOST_INGRESS.image}
311
+ # The latest tag otherwise implies Always. Production edge nodes are often
312
+ # deliberately unable to reach Docker Hub; once the audited image is
313
+ # present, a route-table refresh or pod restart must stay offline-safe.
314
+ imagePullPolicy: IfNotPresent
315
+ command:
316
+ - /bin/sh
317
+ - -c
318
+ - cp /etc/underpost-ingress/nginx.conf /tmp/nginx.conf && exec nginx -c /tmp/nginx.conf -g 'daemon off;'
319
+ # No \`ports:\` block, deliberately. Under \`hostNetwork: true\` Kubernetes
320
+ # sets \`hostPort\` to each \`containerPort\`, and the scheduler then
321
+ # refuses to place the pod unless those host ports are already free —
322
+ # which they are not, because the data planes only release them as this
323
+ # workload arrives. Declaring them turns the ports this exists to take
324
+ # into a precondition for being scheduled at all.
325
+ securityContext:
326
+ runAsNonRoot: false
327
+ runAsUser: 0
328
+ allowPrivilegeEscalation: false
329
+ capabilities:
330
+ drop:
331
+ - ALL
332
+ # The master starts as root and needs all four: NET_BIND_SERVICE to
333
+ # bind 80/443, CHOWN to take ownership of its temp paths, and
334
+ # SETUID/SETGID to drop each worker to the unprivileged \`nginx\`
335
+ # user. Dropping any one of them is a start-up failure rather than a
336
+ # degraded mode — and only the first is visible to \`nginx -t\`, since
337
+ # the other two are reached when workers spawn.
338
+ add:
339
+ - NET_BIND_SERVICE
340
+ - CHOWN
341
+ - SETUID
342
+ - SETGID
343
+ readinessProbe:
344
+ httpGet:
345
+ path: ${UNDERPOST_INGRESS.healthPath}
346
+ port: ${UNDERPOST_INGRESS.healthPort}
347
+ initialDelaySeconds: 2
348
+ periodSeconds: 5
349
+ livenessProbe:
350
+ httpGet:
351
+ path: ${UNDERPOST_INGRESS.healthPath}
352
+ port: ${UNDERPOST_INGRESS.healthPort}
353
+ initialDelaySeconds: 10
354
+ periodSeconds: 20
355
+ volumeMounts:
356
+ - name: nginx-conf
357
+ mountPath: /etc/underpost-ingress
358
+ readOnly: true
359
+ - name: cache
360
+ mountPath: /var/cache/nginx
361
+ - name: run
362
+ mountPath: /tmp
363
+ volumes:
364
+ - name: nginx-conf
365
+ configMap:
366
+ name: ${UNDERPOST_INGRESS.configMapName}
367
+ - name: cache
368
+ emptyDir: {}
369
+ - name: run
370
+ emptyDir: {}
371
+ `;
372
+ };
373
+
374
+ export {
375
+ UNDERPOST_INGRESS,
376
+ gatewayBackendFactory,
377
+ underpostIngressConfFactory,
378
+ underpostIngressHostMapFactory,
379
+ underpostIngressManifestsFactory,
380
+ };