underpost 3.2.90 → 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.
- package/.github/workflows/ghpkg.ci.yml +7 -1
- package/.github/workflows/pwa-microservices-template-page.cd.yml +1 -16
- package/.github/workflows/pwa-microservices-template-test.ci.yml +1 -1
- package/.github/workflows/release.cd.yml +1 -9
- package/CHANGELOG.md +110 -1
- package/CLI-HELP.md +139 -9
- package/README.md +5 -2
- package/bin/build.js +7 -5
- package/bin/deploy.js +1 -1
- package/deploy/lib/logging.sh +96 -0
- package/deploy/pwa-microservices-template/deploy.sh +72 -0
- package/deploy/release/deploy.sh +62 -0
- package/docker-compose.yml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-backup.yaml +5 -1
- package/manifests/cronjobs/dd-cron/dd-cron-dns.yaml +1 -1
- package/manifests/cronjobs/dd-cron/dd-cron-vultr.yaml +52 -0
- package/manifests/deployment/dd-default-development/deployment.yaml +2 -2
- package/package.json +5 -5
- package/scripts/audit-selinux.sh +64 -0
- package/scripts/coverall-test.sh +24 -0
- package/scripts/gpu-diag.sh +0 -0
- package/scripts/ip-info.sh +0 -0
- package/scripts/k3s-node-setup.sh +18 -15
- package/scripts/kubeadm-node-setup.sh +12 -23
- package/scripts/link-local-underpost-cli.sh +0 -0
- package/scripts/lxd-vm-setup.sh +0 -0
- package/scripts/maas-nat-firewalld.sh +0 -0
- package/scripts/nat-iptables.sh +2 -0
- package/scripts/rhel-grpc-setup.sh +0 -0
- package/scripts/rocky-kickstart.sh +25 -9
- package/scripts/test-monitor.sh +1 -1
- package/src/cli/baremetal.js +1 -2
- package/src/cli/cloud-init.js +1 -1
- package/src/cli/cluster.js +73 -68
- package/src/cli/db.js +9 -2
- package/src/cli/deploy.js +21 -5
- package/src/cli/docker-compose.js +1 -1
- package/src/cli/env.js +1 -1
- package/src/cli/image.js +0 -1
- package/src/cli/index.js +121 -9
- package/src/cli/lxd.js +1 -1
- package/src/cli/monitor.js +1 -1
- package/src/cli/release.js +57 -22
- package/src/cli/repository.js +11 -9
- package/src/cli/run.js +36 -9
- package/src/cli/ssh.js +198 -77
- package/src/cli/system.js +26 -13
- package/src/cli/test.js +1 -1
- package/src/cli/vultr.js +583 -0
- package/src/cli/wireguard.js +2125 -0
- package/src/client-builder/client-build.js +20 -14
- package/src/db/mongo/MongooseDB.js +4 -0
- package/src/index.js +25 -1
- package/src/projects/underpost/catalog-underpost.js +4 -1
- package/src/server/backup.js +1 -1
- package/src/server/conf.js +18 -108
- package/src/server/cron.js +249 -51
- package/src/server/dns.js +100 -6
- package/src/server/environment.js +98 -0
- package/src/server/forward-proxy.js +549 -0
- package/src/server/middlewares.js +56 -1
- package/src/server/process.js +0 -1
- package/src/server/selinux.js +185 -0
- package/src/server/systemd.js +205 -0
- package/src/server/underpost-compression.js +186 -0
- package/src/server/underpost-gateway.js +20 -10
- package/src/server/underpost-ingress.js +18 -2
- package/test/selinux.test.js +71 -0
- package/test/underpost-gateway.test.js +41 -0
- package/test/underpost-ingress.test.js +52 -0
- package/test/wireguard-edge.test.js +1177 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response compression policy for the Nginx workloads on the request path.
|
|
3
|
+
*
|
|
4
|
+
* @module src/server/underpost-compression.js
|
|
5
|
+
* @namespace UnderpostCompression
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @constant UNDERPOST_COMPRESSION
|
|
10
|
+
* @description The compression policy both edge workloads render from.
|
|
11
|
+
*
|
|
12
|
+
* `text/html` appears in no type list on purpose: nginx and ngx_brotli both
|
|
13
|
+
* always compress it, and naming it again only invites the two lists to drift.
|
|
14
|
+
* Every entry is a format that is still text on the wire — already-compressed
|
|
15
|
+
* media (png, woff2, mp4) is excluded, since re-encoding it spends CPU to add
|
|
16
|
+
* bytes.
|
|
17
|
+
* @memberof UnderpostCompression
|
|
18
|
+
*/
|
|
19
|
+
const UNDERPOST_COMPRESSION = {
|
|
20
|
+
types: [
|
|
21
|
+
'application/atom+xml',
|
|
22
|
+
'application/geo+json',
|
|
23
|
+
'application/javascript',
|
|
24
|
+
'application/json',
|
|
25
|
+
'application/ld+json',
|
|
26
|
+
'application/manifest+json',
|
|
27
|
+
'application/rss+xml',
|
|
28
|
+
'application/vnd.api+json',
|
|
29
|
+
'application/wasm',
|
|
30
|
+
'application/x-javascript',
|
|
31
|
+
'application/xhtml+xml',
|
|
32
|
+
'application/xml',
|
|
33
|
+
'font/otf',
|
|
34
|
+
'font/ttf',
|
|
35
|
+
'image/svg+xml',
|
|
36
|
+
'image/x-icon',
|
|
37
|
+
'text/css',
|
|
38
|
+
'text/javascript',
|
|
39
|
+
'text/markdown',
|
|
40
|
+
'text/plain',
|
|
41
|
+
'text/xml',
|
|
42
|
+
],
|
|
43
|
+
// Below roughly this size a compressed body plus its headers is no smaller
|
|
44
|
+
// than the original, and can be larger.
|
|
45
|
+
minLength: 512,
|
|
46
|
+
// Level 5 of 9: within a few percent of maximum ratio at a fraction of the
|
|
47
|
+
// CPU. The edge compresses every response of every host, so the cost of a
|
|
48
|
+
// higher level is paid on the shared workload, not on one deploy.
|
|
49
|
+
gzipLevel: 5,
|
|
50
|
+
// Brotli's scale runs to 11, where dynamic compression becomes slower than
|
|
51
|
+
// the transfer it saves. 5 is the usual on-the-fly ceiling.
|
|
52
|
+
brotliLevel: 5,
|
|
53
|
+
filterModule: 'ngx_http_brotli_filter_module.so',
|
|
54
|
+
staticModule: 'ngx_http_brotli_static_module.so',
|
|
55
|
+
defaultImage: 'nginx:alpine',
|
|
56
|
+
env: {
|
|
57
|
+
image: 'UNDERPOST_NGINX_IMAGE',
|
|
58
|
+
brotliModules: 'UNDERPOST_NGINX_BROTLI_MODULES',
|
|
59
|
+
enabled: 'UNDERPOST_NGINX_COMPRESSION',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @method nginxImageFactory
|
|
65
|
+
* @description The image both edge workloads run.
|
|
66
|
+
*
|
|
67
|
+
* Resolved in one place because the two have to agree: they sit on the same
|
|
68
|
+
* request path, and brotli is a property of the image rather than of the
|
|
69
|
+
* config. An image carrying ngx_brotli is the only way the brotli directives
|
|
70
|
+
* below can be rendered at all.
|
|
71
|
+
* @returns {string} Container image reference.
|
|
72
|
+
* @memberof UnderpostCompression
|
|
73
|
+
*/
|
|
74
|
+
const nginxImageFactory = () =>
|
|
75
|
+
`${process.env[UNDERPOST_COMPRESSION.env.image] || ''}`.trim() || UNDERPOST_COMPRESSION.defaultImage;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @method brotliModuleDirFactory
|
|
79
|
+
* @description Directory holding the brotli dynamic modules, or an empty string
|
|
80
|
+
* when the image is not declared to carry them.
|
|
81
|
+
*
|
|
82
|
+
* Empty is the default and the safe state: gzip alone, on a stock image, with
|
|
83
|
+
* no `load_module` line that could fail to resolve.
|
|
84
|
+
* @returns {string} Absolute directory path, or `''`.
|
|
85
|
+
* @memberof UnderpostCompression
|
|
86
|
+
*/
|
|
87
|
+
const brotliModuleDirFactory = () =>
|
|
88
|
+
`${process.env[UNDERPOST_COMPRESSION.env.brotliModules] || ''}`.trim().replace(/\/+$/, '');
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @method compressionEnabledFactory
|
|
92
|
+
* @description Whether responses are compressed at all.
|
|
93
|
+
*
|
|
94
|
+
* The escape hatch for a CPU-bound edge node: compression trades cycles for
|
|
95
|
+
* bytes, and an operator who is out of the former rather than the latter needs
|
|
96
|
+
* to turn it off without editing generated configuration.
|
|
97
|
+
* @returns {boolean} True unless explicitly disabled.
|
|
98
|
+
* @memberof UnderpostCompression
|
|
99
|
+
*/
|
|
100
|
+
const compressionEnabledFactory = () =>
|
|
101
|
+
!['off', '0', 'false', 'no'].includes(`${process.env[UNDERPOST_COMPRESSION.env.enabled] || ''}`.trim().toLowerCase());
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* @method compressionModulesConfFactory
|
|
105
|
+
* @description The `load_module` lines brotli needs, in nginx's main context.
|
|
106
|
+
*
|
|
107
|
+
* Rendered only alongside the brotli directives that require them, so a config
|
|
108
|
+
* never loads a module it does not use or uses one it did not load — using one
|
|
109
|
+
* that was not loaded is a start-up failure, and the reverse is surface on a
|
|
110
|
+
* workload that has no documents to serve from it.
|
|
111
|
+
* @param {string} [brotliModuleDir] - Directory holding the modules; empty renders nothing.
|
|
112
|
+
* @param {boolean} [enabled] - Whether compression is on at all.
|
|
113
|
+
* @param {boolean} [staticRoot] - Whether the workload serves documents from disk.
|
|
114
|
+
* @returns {string} Main-context directives, or an empty string.
|
|
115
|
+
* @memberof UnderpostCompression
|
|
116
|
+
*/
|
|
117
|
+
const compressionModulesConfFactory = ({
|
|
118
|
+
brotliModuleDir = brotliModuleDirFactory(),
|
|
119
|
+
enabled = compressionEnabledFactory(),
|
|
120
|
+
staticRoot = false,
|
|
121
|
+
} = {}) => {
|
|
122
|
+
if (!enabled || !brotliModuleDir) return '';
|
|
123
|
+
return [
|
|
124
|
+
`load_module ${brotliModuleDir}/${UNDERPOST_COMPRESSION.filterModule};`,
|
|
125
|
+
...(staticRoot ? [`load_module ${brotliModuleDir}/${UNDERPOST_COMPRESSION.staticModule};`] : []),
|
|
126
|
+
].join('\n');
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @method compressionConfFactory
|
|
131
|
+
* @description The compression block for an nginx `http` context.
|
|
132
|
+
*
|
|
133
|
+
* `gzip_proxied any` is what makes this worth rendering on a reverse proxy at
|
|
134
|
+
* all: the default is `off`, which suppresses compression for exactly the
|
|
135
|
+
* responses these workloads exist to forward. A response the upstream already
|
|
136
|
+
* encoded is passed through untouched either way — nginx never re-compresses
|
|
137
|
+
* one that carries `Content-Encoding`.
|
|
138
|
+
*
|
|
139
|
+
* `*_static` is only rendered where a document root exists. It costs one stat()
|
|
140
|
+
* per request to serve an operator-placed `.gz`/`.br` sibling with no CPU at
|
|
141
|
+
* all, and has nothing to look for on a workload that serves no files.
|
|
142
|
+
* @param {boolean} [enabled] - Whether compression is on at all.
|
|
143
|
+
* @param {string} [brotliModuleDir] - Directory holding the brotli modules; empty renders gzip alone.
|
|
144
|
+
* @param {boolean} [staticRoot] - Whether the workload serves documents from disk.
|
|
145
|
+
* @param {string} [indent] - Leading whitespace for each line.
|
|
146
|
+
* @returns {string} `http`-context directives, or an empty string.
|
|
147
|
+
* @memberof UnderpostCompression
|
|
148
|
+
*/
|
|
149
|
+
const compressionConfFactory = ({
|
|
150
|
+
enabled = compressionEnabledFactory(),
|
|
151
|
+
brotliModuleDir = brotliModuleDirFactory(),
|
|
152
|
+
staticRoot = false,
|
|
153
|
+
indent = ' ',
|
|
154
|
+
} = {}) => {
|
|
155
|
+
if (!enabled) return '';
|
|
156
|
+
const types = UNDERPOST_COMPRESSION.types.join(' ');
|
|
157
|
+
const lines = [
|
|
158
|
+
'gzip on;',
|
|
159
|
+
// Compressed and uncompressed bodies share a URL, so a cache that ignores
|
|
160
|
+
// this header serves one to a client that asked for the other.
|
|
161
|
+
'gzip_vary on;',
|
|
162
|
+
`gzip_comp_level ${UNDERPOST_COMPRESSION.gzipLevel};`,
|
|
163
|
+
`gzip_min_length ${UNDERPOST_COMPRESSION.minLength};`,
|
|
164
|
+
'gzip_proxied any;',
|
|
165
|
+
`gzip_types ${types};`,
|
|
166
|
+
...(staticRoot ? ['gzip_static on;'] : []),
|
|
167
|
+
];
|
|
168
|
+
if (brotliModuleDir)
|
|
169
|
+
lines.push(
|
|
170
|
+
'brotli on;',
|
|
171
|
+
`brotli_comp_level ${UNDERPOST_COMPRESSION.brotliLevel};`,
|
|
172
|
+
`brotli_min_length ${UNDERPOST_COMPRESSION.minLength};`,
|
|
173
|
+
`brotli_types ${types};`,
|
|
174
|
+
...(staticRoot ? ['brotli_static on;'] : []),
|
|
175
|
+
);
|
|
176
|
+
return lines.map((line) => `${indent}${line}`).join('\n');
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export {
|
|
180
|
+
UNDERPOST_COMPRESSION,
|
|
181
|
+
brotliModuleDirFactory,
|
|
182
|
+
compressionConfFactory,
|
|
183
|
+
compressionEnabledFactory,
|
|
184
|
+
compressionModulesConfFactory,
|
|
185
|
+
nginxImageFactory,
|
|
186
|
+
};
|
|
@@ -32,6 +32,7 @@ import fs from 'fs-extra';
|
|
|
32
32
|
import nodePath from 'node:path';
|
|
33
33
|
import { timer } from '../client/components/core/CommonJs.js';
|
|
34
34
|
import { instanceStatusPageEntriesFactory, loadConfServerJson, loadReplicas } from './conf.js';
|
|
35
|
+
import { compressionConfFactory, compressionModulesConfFactory, nginxImageFactory } from './underpost-compression.js';
|
|
35
36
|
import Underpost from '../index.js';
|
|
36
37
|
import { loggerFactory } from './logger.js';
|
|
37
38
|
import { shellExec } from './process.js';
|
|
@@ -51,7 +52,7 @@ const UNDERPOST_GATEWAY = {
|
|
|
51
52
|
configMapName: 'underpost-gateway-nginx',
|
|
52
53
|
claimName: 'pvc-underpost-gateway',
|
|
53
54
|
volumeName: 'pv-underpost-gateway',
|
|
54
|
-
image:
|
|
55
|
+
image: nginxImageFactory(),
|
|
55
56
|
root: '/var/www/static',
|
|
56
57
|
port: 80,
|
|
57
58
|
healthPath: '/healthz',
|
|
@@ -189,13 +190,24 @@ const defaultStatusPagePath = (status = 404) => `${UNDERPOST_GATEWAY.defaultHost
|
|
|
189
190
|
* 200. That status is what stops the document being stored: the PWA service
|
|
190
191
|
* worker caches navigations for hours and would otherwise keep serving the
|
|
191
192
|
* fallback long after the host's real page landed in the tree.
|
|
193
|
+
*
|
|
194
|
+
* Compression is applied here rather than further out because this is the last
|
|
195
|
+
* hop that holds a response body in the clear — and, for every host whose
|
|
196
|
+
* errors are intercepted, the hop every site path already passes through. See
|
|
197
|
+
* {@link module:src/server/underpost-compression.js}.
|
|
198
|
+
* @param {string} [resolver] - Cluster DNS ClusterIP.
|
|
199
|
+
* @param {object} [compression] - Overrides for the compression policy.
|
|
192
200
|
* @returns {string} nginx.conf contents.
|
|
193
201
|
* @memberof UnderpostGateway
|
|
194
202
|
*/
|
|
195
|
-
const nginxConfFactory = ({ resolver = UNDERPOST_GATEWAY.resolver } = {}) =>
|
|
203
|
+
const nginxConfFactory = ({ resolver = UNDERPOST_GATEWAY.resolver, compression = {} } = {}) => {
|
|
204
|
+
const policy = { ...compression, staticRoot: true };
|
|
205
|
+
const modules = compressionModulesConfFactory(policy);
|
|
206
|
+
const compress = compressionConfFactory(policy);
|
|
207
|
+
return `worker_processes auto;
|
|
196
208
|
error_log /dev/stderr warn;
|
|
197
209
|
pid /tmp/nginx.pid;
|
|
198
|
-
|
|
210
|
+
${modules ? `${modules}\n` : ''}
|
|
199
211
|
events {
|
|
200
212
|
worker_connections 1024;
|
|
201
213
|
}
|
|
@@ -206,12 +218,7 @@ http {
|
|
|
206
218
|
sendfile on;
|
|
207
219
|
tcp_nopush on;
|
|
208
220
|
server_tokens off;
|
|
209
|
-
|
|
210
|
-
gzip on;
|
|
211
|
-
gzip_vary on;
|
|
212
|
-
gzip_min_length 512;
|
|
213
|
-
gzip_types text/html text/css text/plain application/javascript application/json image/svg+xml;
|
|
214
|
-
|
|
221
|
+
${compress ? `\n${compress}\n` : ''}
|
|
215
222
|
log_format concise '$remote_addr "$request" $status $body_bytes_sent "$host"';
|
|
216
223
|
access_log /dev/stdout concise;
|
|
217
224
|
|
|
@@ -259,6 +266,7 @@ http {
|
|
|
259
266
|
}
|
|
260
267
|
}
|
|
261
268
|
`;
|
|
269
|
+
};
|
|
262
270
|
|
|
263
271
|
/**
|
|
264
272
|
* @method statusPageLocationsFactory
|
|
@@ -388,6 +396,7 @@ ${blocks
|
|
|
388
396
|
* @param {string} hostPath - Node directory backing the static root.
|
|
389
397
|
* @param {string} [nodeName] - Node the hostPath volume is pinned to.
|
|
390
398
|
* @param {string} [storage] - Volume size.
|
|
399
|
+
* @param {object} [compression] - Overrides for the compression policy.
|
|
391
400
|
* @returns {string} Multi-document YAML.
|
|
392
401
|
* @memberof UnderpostGateway
|
|
393
402
|
*/
|
|
@@ -397,8 +406,9 @@ const underpostGatewayManifestsFactory = ({
|
|
|
397
406
|
nodeName = '',
|
|
398
407
|
storage = '1Gi',
|
|
399
408
|
resolver,
|
|
409
|
+
compression = {},
|
|
400
410
|
} = {}) => {
|
|
401
|
-
const nginxConf = nginxConfFactory({ resolver });
|
|
411
|
+
const nginxConf = nginxConfFactory({ resolver, compression });
|
|
402
412
|
// The config is mounted with `subPath`, which Kubernetes never refreshes in
|
|
403
413
|
// place, and the pod template is otherwise identical across rebuilds — so
|
|
404
414
|
// without this annotation an edited nginx.conf reaches the ConfigMap and
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* @namespace UnderpostIngress
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
import { compressionConfFactory, compressionModulesConfFactory, nginxImageFactory } from './underpost-compression.js';
|
|
28
|
+
|
|
27
29
|
/**
|
|
28
30
|
* @constant UNDERPOST_INGRESS
|
|
29
31
|
* @description Identity of the underpost ingress workload. One deployment fronts every
|
|
@@ -33,7 +35,7 @@
|
|
|
33
35
|
const UNDERPOST_INGRESS = {
|
|
34
36
|
name: 'underpost-ingress',
|
|
35
37
|
configMapName: 'underpost-ingress-nginx',
|
|
36
|
-
image:
|
|
38
|
+
image: nginxImageFactory(),
|
|
37
39
|
httpPort: 80,
|
|
38
40
|
httpsPort: 443,
|
|
39
41
|
healthPort: 8090,
|
|
@@ -100,10 +102,17 @@ const underpostIngressHostMapFactory = ({ contourHosts = [], gatewayHosts = [],
|
|
|
100
102
|
* that owns most of the routing rather than a rejection, so a host whose route
|
|
101
103
|
* object has not been applied yet still reaches a data plane that can answer it
|
|
102
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}.
|
|
103
111
|
* @param {Array<object>} [entries] - Host table from {@link UnderpostIngress.underpostIngressHostMapFactory}.
|
|
104
112
|
* @param {object} backends - `{contour: {http, tls}, gateway: {http, tls}}`; a missing stack is omitted.
|
|
105
113
|
* @param {string} [defaultBackend] - Backend for an unmatched hostname.
|
|
106
114
|
* @param {string} [resolver] - Cluster DNS ClusterIP.
|
|
115
|
+
* @param {object} [compression] - Overrides for the compression policy.
|
|
107
116
|
* @returns {string} nginx.conf contents.
|
|
108
117
|
* @memberof UnderpostIngress
|
|
109
118
|
*/
|
|
@@ -112,6 +121,7 @@ const underpostIngressConfFactory = ({
|
|
|
112
121
|
backends = {},
|
|
113
122
|
defaultBackend = 'gateway',
|
|
114
123
|
resolver = UNDERPOST_INGRESS.resolver,
|
|
124
|
+
compression = {},
|
|
115
125
|
} = {}) => {
|
|
116
126
|
const available = Object.keys(backends).filter((name) => backends[name]?.http && backends[name]?.tls);
|
|
117
127
|
// With one stack installed the map still renders, so the same workload serves
|
|
@@ -121,17 +131,23 @@ const underpostIngressConfFactory = ({
|
|
|
121
131
|
const routable = entries.filter((entry) => entry?.host && available.includes(entry.backend));
|
|
122
132
|
const mapEntries = (kind) =>
|
|
123
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);
|
|
124
139
|
|
|
125
140
|
return `worker_processes auto;
|
|
126
141
|
error_log /dev/stderr warn;
|
|
127
142
|
pid /tmp/nginx.pid;
|
|
128
|
-
|
|
143
|
+
${modules ? `${modules}\n` : ''}
|
|
129
144
|
events {
|
|
130
145
|
worker_connections 4096;
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
http {
|
|
134
149
|
server_tokens off;
|
|
150
|
+
${compress ? `\n${compress}\n` : ''}
|
|
135
151
|
log_format underpost_ingress '$remote_addr "$request" $status "$host" -> $underpost_ingress_http_upstream';
|
|
136
152
|
access_log /dev/stdout underpost_ingress;
|
|
137
153
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
import { expect } from 'chai';
|
|
4
|
+
import SELinuxService, {
|
|
5
|
+
runSELinuxCommands,
|
|
6
|
+
selinuxContainerSharedContextCommandsFactory,
|
|
7
|
+
selinuxEnforcingCommandsFactory,
|
|
8
|
+
selinuxFileContextCommandFactory,
|
|
9
|
+
selinuxRestoreconCommandFactory,
|
|
10
|
+
selinuxSshContextCommandsFactory,
|
|
11
|
+
selinuxSshPortCommandsFactory,
|
|
12
|
+
shellArgumentFactory,
|
|
13
|
+
} from '../src/server/selinux.js';
|
|
14
|
+
|
|
15
|
+
describe('SELinux utilities', () => {
|
|
16
|
+
it('provides one canonical class API with named factory aliases', () => {
|
|
17
|
+
expect(SELinuxService.selinuxEnforcingCommandsFactory).to.equal(selinuxEnforcingCommandsFactory);
|
|
18
|
+
expect(SELinuxService.runSELinuxCommands).to.equal(runSELinuxCommands);
|
|
19
|
+
expect(SELinuxService.shellArgumentFactory).to.equal(shellArgumentFactory);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('quotes shell arguments and restores policy contexts', () => {
|
|
23
|
+
expect(shellArgumentFactory("/home/o'hara/.ssh")).to.equal("'/home/o'\"'\"'hara/.ssh'");
|
|
24
|
+
const command = selinuxRestoreconCommandFactory(['/root/.ssh', '/etc/sudoers.d/90_admin']);
|
|
25
|
+
expect(command).to.include("restorecon -RF '/root/.ssh'");
|
|
26
|
+
expect(command).to.include("restorecon -RF '/etc/sudoers.d/90_admin'");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('shares container-mounted host paths with a persistent container_file_t mapping', () => {
|
|
30
|
+
const commands = selinuxContainerSharedContextCommandsFactory(['/etc/kubernetes', '/var/lib/etcd']);
|
|
31
|
+
expect(commands).to.have.length(3);
|
|
32
|
+
expect(commands[0]).to.include("semanage fcontext -a -t container_file_t '/etc/kubernetes(/.*)?'");
|
|
33
|
+
expect(commands[0]).to.include("semanage fcontext -m -t container_file_t '/etc/kubernetes(/.*)?'");
|
|
34
|
+
expect(commands[1]).to.include("container_file_t '/var/lib/etcd(/.*)?'");
|
|
35
|
+
expect(commands[2]).to.include("restorecon -RF '/etc/kubernetes'");
|
|
36
|
+
expect(() => selinuxContainerSharedContextCommandsFactory([])).to.throw(TypeError);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('requires both a path and a type for file context mappings', () => {
|
|
40
|
+
expect(() => selinuxFileContextCommandFactory('', { type: 'container_file_t' })).to.throw(TypeError);
|
|
41
|
+
expect(() => selinuxFileContextCommandFactory('/var/lib/etcd', {})).to.throw(TypeError);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('adds persistent mappings only for non-standard SSH homes', () => {
|
|
45
|
+
expect(selinuxSshContextCommandsFactory({ sshDirectory: '/home/admin/.ssh' })).to.have.length(1);
|
|
46
|
+
expect(selinuxSshContextCommandsFactory({ sshDirectory: '/srv/admin/.ssh' })[0]).to.include(
|
|
47
|
+
'semanage fcontext -a -t ssh_home_t',
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('restores requested contexts before activating Enforcing mode', () => {
|
|
52
|
+
const commands = selinuxEnforcingCommandsFactory({ restorePaths: ['/var/lib/containerd'] });
|
|
53
|
+
expect(commands[1]).to.include('touch /.autorelabel');
|
|
54
|
+
expect(commands[2]).to.include('restorecon');
|
|
55
|
+
expect(commands[3]).to.include('setenforce 1');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('maps only custom SSH ports and validates the port range', () => {
|
|
59
|
+
expect(selinuxSshPortCommandsFactory({ port: 22 })).to.deep.equal([]);
|
|
60
|
+
expect(selinuxSshPortCommandsFactory({ port: 2222 })[0]).to.include(
|
|
61
|
+
'semanage port -a -t ssh_port_t -p tcp 2222',
|
|
62
|
+
);
|
|
63
|
+
expect(() => selinuxSshPortCommandsFactory({ port: 70000 })).to.throw(RangeError);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('executes generated commands with an injected executor', () => {
|
|
67
|
+
const executed = [];
|
|
68
|
+
runSELinuxCommands(['one', 'two'], { execute: (command) => executed.push(command) });
|
|
69
|
+
expect(executed).to.deep.equal(['one', 'two']);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -119,6 +119,47 @@ describe('underpost gateway edge tier', () => {
|
|
|
119
119
|
expect(conf).to.include('map $http_upgrade $connection_upgrade');
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
+
// For every host whose errors are intercepted, this workload is the hop the
|
|
123
|
+
// site path already passes through — and the last one holding the body in
|
|
124
|
+
// the clear. Compressing anywhere further out is compressing ciphertext.
|
|
125
|
+
describe('response compression', () => {
|
|
126
|
+
it('compresses what it serves and what it proxies', () => {
|
|
127
|
+
expect(conf).to.include('gzip on;');
|
|
128
|
+
expect(conf).to.include('gzip_vary on;');
|
|
129
|
+
// Nginx's default is `off`, which skips the proxied responses that carry
|
|
130
|
+
// most of the bytes this workload forwards.
|
|
131
|
+
expect(conf).to.include('gzip_proxied any;');
|
|
132
|
+
expect(conf).to.match(/gzip_types [^;]*application\/javascript[^;]*;/);
|
|
133
|
+
expect(conf).to.match(/gzip_types [^;]*text\/css[^;]*;/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Documents are served from a volume, so an operator who places a `.gz`
|
|
137
|
+
// beside one gets it sent with no compression pass at all.
|
|
138
|
+
it('prefers a pre-compressed sibling from the static root', () => {
|
|
139
|
+
expect(conf).to.include('gzip_static on;');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('renders brotli and its modules only when the image is declared to carry them', () => {
|
|
143
|
+
expect(conf).to.not.include('brotli');
|
|
144
|
+
expect(conf).to.not.include('load_module');
|
|
145
|
+
const declared = nginxConfFactory({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } });
|
|
146
|
+
expect(declared).to.include('load_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;');
|
|
147
|
+
expect(declared).to.include('brotli on;');
|
|
148
|
+
expect(declared).to.include('brotli_static on;');
|
|
149
|
+
// Main context: nginx rejects `load_module` anywhere else.
|
|
150
|
+
expect(declared.indexOf('load_module')).to.be.lessThan(declared.indexOf('events {'));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// The config is mounted with `subPath`, which Kubernetes never refreshes,
|
|
154
|
+
// so only a changed pod template rolls the workload onto a new policy.
|
|
155
|
+
it('rolls the deployment when the policy changes', () => {
|
|
156
|
+
const hash = (docs) => kind(docs, 'Deployment').match(/nginx-conf-hash: '([0-9a-f]+)'/)[1];
|
|
157
|
+
expect(hash(manifests({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } }))).to.not.equal(
|
|
158
|
+
hash(manifests()),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
122
163
|
// A 200 would let the PWA service worker store the shared page as the
|
|
123
164
|
// host's own and keep serving it after the real document lands.
|
|
124
165
|
it('serves the shared fallback as a 404 that is never stored', () => {
|
|
@@ -123,6 +123,58 @@ describe('shared ingress front', () => {
|
|
|
123
123
|
const out = conf({ entries: [], resolver: '10.43.0.10' });
|
|
124
124
|
expect(out.match(/resolver 10\.43\.0\.10 valid=10s ipv6=off;/g)).to.have.lengthOf(2);
|
|
125
125
|
});
|
|
126
|
+
|
|
127
|
+
describe('response compression', () => {
|
|
128
|
+
const halves = (input) => {
|
|
129
|
+
const out = conf({ entries: [], ...input });
|
|
130
|
+
const split = out.indexOf('\nstream {');
|
|
131
|
+
return { http: out.slice(0, split), stream: out.slice(split) };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
it('compresses the cleartext hop it proxies at L7', () => {
|
|
135
|
+
const { http } = halves({ compression: { brotliModuleDir: '' } });
|
|
136
|
+
expect(http).to.include('gzip on;');
|
|
137
|
+
expect(http).to.include('gzip_proxied any;');
|
|
138
|
+
expect(http).to.include('gzip_vary on;');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// `:443` is forwarded by SNI without being decrypted, so there is no body
|
|
142
|
+
// in this context to encode — and no compression directive is even valid
|
|
143
|
+
// in `stream`.
|
|
144
|
+
it('leaves the L4 contexts alone', () => {
|
|
145
|
+
const { stream } = halves({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } });
|
|
146
|
+
expect(stream).to.not.include('gzip');
|
|
147
|
+
expect(stream).to.not.include('brotli');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// This workload proxies every byte and serves no documents, so there is
|
|
151
|
+
// never a pre-compressed sibling on disk to look for.
|
|
152
|
+
it('does not look for pre-compressed files it can never have', () => {
|
|
153
|
+
const { http } = halves({ compression: { brotliModuleDir: '/usr/lib/nginx/modules' } });
|
|
154
|
+
expect(http).to.not.include('_static');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// `brotli on;` in an image without the module is an unknown directive, and
|
|
158
|
+
// this workload holds the node's 80/443 — a start-up failure here is the
|
|
159
|
+
// whole edge, so brotli is rendered only where it is declared to exist.
|
|
160
|
+
it('renders brotli and its modules only when the image is declared to carry them', () => {
|
|
161
|
+
const stock = conf({ entries: [], compression: { brotliModuleDir: '' } });
|
|
162
|
+
expect(stock).to.not.include('brotli');
|
|
163
|
+
expect(stock).to.not.include('load_module');
|
|
164
|
+
const declared = conf({ entries: [], compression: { brotliModuleDir: '/usr/lib/nginx/modules' } });
|
|
165
|
+
expect(declared).to.include('load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;');
|
|
166
|
+
expect(declared).to.include('brotli on;');
|
|
167
|
+
// Main context: nginx rejects `load_module` anywhere else.
|
|
168
|
+
expect(declared.indexOf('load_module')).to.be.lessThan(declared.indexOf('events {'));
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('renders no compression directive when compression is switched off', () => {
|
|
172
|
+
const out = conf({ entries: [], compression: { enabled: false, brotliModuleDir: '/usr/lib/nginx/modules' } });
|
|
173
|
+
expect(out).to.not.include('gzip');
|
|
174
|
+
expect(out).to.not.include('brotli');
|
|
175
|
+
expect(out).to.not.include('load_module');
|
|
176
|
+
});
|
|
177
|
+
});
|
|
126
178
|
});
|
|
127
179
|
|
|
128
180
|
describe('underpostIngressManifestsFactory', () => {
|