underpost
Version:
Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc
381 lines (361 loc) • 14.9 kB
JavaScript
/**
* Underpost ingress: the shared entry point that lets the Contour and Gateway
* API data planes run side by side.
*
* Only one process can hold a node's 80/443, and a `hostPort` claim is stronger
* than a listener: the CNI hostport plugin DNATs every packet on those ports to
* the claiming pod before anything else sees them. So two ingress stacks on one
* node is not a configuration question — whichever claims first silently takes
* all traffic, and the other logs nothing at all.
*
* This module owns the port instead, and hands each connection to the data plane
* that actually describes its hostname. Both stacks are then reachable through
* the same address, and neither has to be uninstalled to try the other.
*
* Cleartext and TLS are split deliberately:
*
* - `:80` is proxied at L7, because a plaintext request carries a readable `Host`
* header and the backends answer it with their own redirect or content.
* - `:443` is forwarded at L4 by SNI, because terminating TLS here would mean
* holding every host's certificate and re-negotiating ALPN. Passing the bytes
* through keeps certificates, HTTP/2 and mTLS exactly where they already work.
*
* @module src/server/underpost-ingress.js
* @namespace UnderpostIngress
*/
import { compressionConfFactory, compressionModulesConfFactory, nginxImageFactory } from './underpost-compression.js';
/**
* @constant UNDERPOST_INGRESS
* @description Identity of the underpost ingress workload. One deployment fronts every
* data plane on the node, so these names are cluster-wide constants.
* @memberof UnderpostIngress
*/
const UNDERPOST_INGRESS = {
name: 'underpost-ingress',
configMapName: 'underpost-ingress-nginx',
image: nginxImageFactory(),
httpPort: 80,
httpsPort: 443,
healthPort: 8090,
healthPath: '/healthz',
// kube-dns's conventional ClusterIP; overridden from the live Service.
resolver: '10.96.0.10',
backends: {
contour: { http: 'envoy.projectcontour.svc.cluster.local:80', tls: 'envoy.projectcontour.svc.cluster.local:443' },
},
};
/**
* @method gatewayBackendFactory
* @description Upstreams for the Gateway API data plane.
*
* Envoy Gateway provisions and names its own Service (`envoy-<class>-<hash>`),
* so unlike Contour's fixed name this one is discovered from the cluster and
* passed in.
* @param {string} service - Provisioned Service name.
* @param {string} [namespace] - Namespace holding it.
* @returns {{http: string, tls: string}} Qualified upstreams.
* @memberof UnderpostIngress
*/
const gatewayBackendFactory = (service, namespace = 'envoy-gateway-system') => ({
http: `${service}.${namespace}.svc.cluster.local:80`,
tls: `${service}.${namespace}.svc.cluster.local:443`,
});
/**
* @method underpostIngressHostMapFactory
* @description Decides which data plane each hostname is handed to.
*
* A hostname belongs to whichever stack has a route object describing it, which
* is the only fact that survives a switch between them: manifests for both kinds
* are always generated, so the objects that actually exist in the cluster are
* what says who serves what.
*
* A hostname described by both is a leftover from switching stacks, not a valid
* state — both would answer, and which one won would depend on ordering here. It
* resolves to `preferred` and is reported separately so the duplicate can be
* removed rather than silently tolerated.
* @param {Array<string>} [contourHosts] - Hostnames with an HTTPProxy.
* @param {Array<string>} [gatewayHosts] - Hostnames with an HTTPRoute.
* @param {string} [preferred] - Backend that wins a hostname described by both.
* @returns {{entries: Array<{host: string, backend: string}>, conflicts: Array<string>}} Routing table and duplicates.
* @memberof UnderpostIngress
*/
const underpostIngressHostMapFactory = ({ contourHosts = [], gatewayHosts = [], preferred = 'gateway' } = {}) => {
const contour = new Set(contourHosts.filter(Boolean));
const gateway = new Set(gatewayHosts.filter(Boolean));
const conflicts = [...contour].filter((host) => gateway.has(host)).sort();
const entries = [...new Set([...contour, ...gateway])].sort().map((host) => ({
host,
backend: contour.has(host) && gateway.has(host) ? preferred : contour.has(host) ? 'contour' : 'gateway',
}));
return { entries, conflicts };
};
/**
* @method underpostIngressConfFactory
* @description Renders the underpost ingress Nginx configuration.
*
* `default` in both maps is what an unknown hostname reaches. It is the stack
* that owns most of the routing rather than a rejection, so a host whose route
* object has not been applied yet still reaches a data plane that can answer it
* — including with its own 404.
*
* Compression is rendered for `:80` alone, and that asymmetry is the same split
* the module makes everywhere else: an L7 hop holds a body it can encode, while
* `:443` carries bytes this process never decrypts. Whatever leaves through the
* tunnel on that port was compressed where TLS terminates — see
* {@link module:src/server/underpost-compression.js}.
* @param {Array<object>} [entries] - Host table from {@link UnderpostIngress.underpostIngressHostMapFactory}.
* @param {object} backends - `{contour: {http, tls}, gateway: {http, tls}}`; a missing stack is omitted.
* @param {string} [defaultBackend] - Backend for an unmatched hostname.
* @param {string} [resolver] - Cluster DNS ClusterIP.
* @param {object} [compression] - Overrides for the compression policy.
* @returns {string} nginx.conf contents.
* @memberof UnderpostIngress
*/
const underpostIngressConfFactory = ({
entries = [],
backends = {},
defaultBackend = 'gateway',
resolver = UNDERPOST_INGRESS.resolver,
compression = {},
} = {}) => {
const available = Object.keys(backends).filter((name) => backends[name]?.http && backends[name]?.tls);
// With one stack installed the map still renders, so the same workload serves
// the single-stack case without a second code path.
const fallback = available.includes(defaultBackend) ? defaultBackend : available[0];
if (!fallback) throw new Error('[underpost-ingress] No data plane backend to route to');
const routable = entries.filter((entry) => entry?.host && available.includes(entry.backend));
const mapEntries = (kind) =>
routable.map((entry) => ` ${entry.host} ${backends[entry.backend][kind]};`).join('\n') || '';
// No `staticRoot`: this workload proxies every byte and serves no documents,
// so there is never a pre-compressed sibling on disk to prefer.
const policy = { ...compression, staticRoot: false };
const modules = compressionModulesConfFactory(policy);
const compress = compressionConfFactory(policy);
return `worker_processes auto;
error_log /dev/stderr warn;
pid /tmp/nginx.pid;
${modules ? `${modules}\n` : ''}
events {
worker_connections 4096;
}
http {
server_tokens off;
${compress ? `\n${compress}\n` : ''}
log_format underpost_ingress '$remote_addr "$request" $status "$host" -> $underpost_ingress_http_upstream';
access_log /dev/stdout underpost_ingress;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# Cluster DNS as a literal address — nginx cannot resolve its own resolver.
# Every upstream is passed through a variable, so without this nginx resolves
# each Service name once at start-up and keeps the address for the life of the
# process; a re-provisioned data plane gets a new ClusterIP.
resolver ${resolver} valid=10s ipv6=off;
map $host $underpost_ingress_http_upstream {
default ${backends[fallback].http};
${mapEntries('http')}
}
server {
listen ${UNDERPOST_INGRESS.healthPort} default_server;
server_name _;
location = ${UNDERPOST_INGRESS.healthPath} {
access_log off;
add_header Content-Type text/plain;
return 200 'ok';
}
location / {
return 404;
}
}
server {
listen ${UNDERPOST_INGRESS.httpPort} default_server;
server_name _;
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Websocket upgrades must survive this hop or a client that negotiated one
# at the edge is left holding a half-open connection.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_pass http://$underpost_ingress_http_upstream;
}
}
}
stream {
log_format underpost_ingress '$remote_addr -> "$ssl_preread_server_name" $upstream_addr $status';
access_log /dev/stdout underpost_ingress;
resolver ${resolver} valid=10s ipv6=off;
# SNI is read without terminating the connection, so each data plane keeps
# serving its own certificates and negotiating its own ALPN. Terminating here
# would mean holding every host's key and re-offering h2 on the way out.
map $ssl_preread_server_name $underpost_ingress_tls_upstream {
default ${backends[fallback].tls};
${mapEntries('tls')}
}
server {
listen ${UNDERPOST_INGRESS.httpsPort};
ssl_preread on;
proxy_pass $underpost_ingress_tls_upstream;
}
${
backends.gateway
? `
# QUIC keeps working, but it cannot be routed by hostname: \`ssl_preread\` is
# TCP-only, and a QUIC Initial carries its SNI inside an encrypted frame. Only
# the Gateway API data plane serves HTTP/3 here — Contour advertises no
# Alt-Svc — so every datagram goes there. A client that tries QUIC against a
# Contour host gets no answer and falls back to TCP, exactly as it would have
# if nothing had advertised HTTP/3 at all.
#
# The constant is reached through a map because a literal \`proxy_pass\` in
# \`stream\` is resolved when the config is parsed, not through \`resolver\`:
# nginx refuses to start while the Service has no DNS record, and once it does
# start it keeps that address for the life of the process.
map $remote_addr $underpost_ingress_quic_upstream {
default ${backends.gateway.tls};
}
server {
listen ${UNDERPOST_INGRESS.httpsPort} udp;
proxy_pass $underpost_ingress_quic_upstream;
proxy_timeout 30s;
}
`
: ''
}}
`;
};
/**
* @method underpostIngressManifestsFactory
* @description Renders the underpost ingress workload.
*
* Host-networked, because it exists to be the thing that holds the node's 80/443
* — the ports both data planes have just been moved off. `hostPort` is not used:
* the claim it installs is what made the two stacks exclusive in the first place.
* @param {string} [namespace] - Namespace to deploy into.
* @param {string} conf - Rendered nginx.conf.
* @param {string} [nodeName] - Node to pin the underpost ingress to; empty schedules freely.
* @returns {string} Multi-document YAML.
* @memberof UnderpostIngress
*/
const underpostIngressManifestsFactory = ({ namespace = 'default', conf, nodeName = '' } = {}) => {
return `
---
apiVersion: v1
kind: ConfigMap
metadata:
name: ${UNDERPOST_INGRESS.configMapName}
namespace: ${namespace}
data:
nginx.conf: |
${`${conf}`
.replace(/\n$/, '')
.split('\n')
.map((line) => (line.length > 0 ? ` ${line}` : ''))
.join('\n')}
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${UNDERPOST_INGRESS.name}
namespace: ${namespace}
labels:
app: ${UNDERPOST_INGRESS.name}
spec:
replicas: 1
# Recreate is retained for real pod-template upgrades because the node's ports
# cannot be held twice. Host-table changes do not alter this template: the
# installer validates and hot-reloads /tmp/nginx.conf in the existing pod.
strategy:
type: Recreate
selector:
matchLabels:
app: ${UNDERPOST_INGRESS.name}
template:
metadata:
labels:
app: ${UNDERPOST_INGRESS.name}
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet${
nodeName
? `
nodeSelector:
kubernetes.io/hostname: ${nodeName}`
: ''
}
containers:
- name: nginx
image: ${UNDERPOST_INGRESS.image}
# The latest tag otherwise implies Always. Production edge nodes are often
# deliberately unable to reach Docker Hub; once the audited image is
# present, a route-table refresh or pod restart must stay offline-safe.
imagePullPolicy: IfNotPresent
command:
- /bin/sh
- -c
- cp /etc/underpost-ingress/nginx.conf /tmp/nginx.conf && exec nginx -c /tmp/nginx.conf -g 'daemon off;'
# No \`ports:\` block, deliberately. Under \`hostNetwork: true\` Kubernetes
# sets \`hostPort\` to each \`containerPort\`, and the scheduler then
# refuses to place the pod unless those host ports are already free —
# which they are not, because the data planes only release them as this
# workload arrives. Declaring them turns the ports this exists to take
# into a precondition for being scheduled at all.
securityContext:
runAsNonRoot: false
runAsUser: 0
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
# The master starts as root and needs all four: NET_BIND_SERVICE to
# bind 80/443, CHOWN to take ownership of its temp paths, and
# SETUID/SETGID to drop each worker to the unprivileged \`nginx\`
# user. Dropping any one of them is a start-up failure rather than a
# degraded mode — and only the first is visible to \`nginx -t\`, since
# the other two are reached when workers spawn.
add:
- NET_BIND_SERVICE
- CHOWN
- SETUID
- SETGID
readinessProbe:
httpGet:
path: ${UNDERPOST_INGRESS.healthPath}
port: ${UNDERPOST_INGRESS.healthPort}
initialDelaySeconds: 2
periodSeconds: 5
livenessProbe:
httpGet:
path: ${UNDERPOST_INGRESS.healthPath}
port: ${UNDERPOST_INGRESS.healthPort}
initialDelaySeconds: 10
periodSeconds: 20
volumeMounts:
- name: nginx-conf
mountPath: /etc/underpost-ingress
readOnly: true
- name: cache
mountPath: /var/cache/nginx
- name: run
mountPath: /tmp
volumes:
- name: nginx-conf
configMap:
name: ${UNDERPOST_INGRESS.configMapName}
- name: cache
emptyDir: {}
- name: run
emptyDir: {}
`;
};
export {
UNDERPOST_INGRESS,
gatewayBackendFactory,
underpostIngressConfFactory,
underpostIngressHostMapFactory,
underpostIngressManifestsFactory,
};