UNPKG

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

584 lines (553 loc) 25.7 kB
/** * Vultr bandwidth guard for the edge hub. * * The edge VPS is the only machine in the topology that pays for traffic: every * byte a client receives leaves through it, and Vultr bills the overage per GB * once the plan's monthly transfer quota is spent. Nothing in the request path * knows how much of that quota is left — HAProxy forwards bytes it never * counts, and the spokes behind the tunnel cannot see the meter at all. * * This module is that meter. It reads the instance's consumption from the Vultr * API, compares it against the plan's quota, and — once a configured fraction of * it is gone — reaches the VPS over SSH and drops its egress with * {@link module:src/server/dns.js}'s `blockAllEgress`. * * That last step is deliberately blunt: it takes every hostname behind the hub * offline. It is the cheaper failure. An overage accrues silently and without a * ceiling, while a blocked edge is loud, immediate, and reversible with one * command. `blockAllEgress` keeps established and related connections, so a new * inbound SSH session still completes its handshake and the host stays * reachable to undo it. * * The enforcement is latched in the root env rather than re-applied every run, * so a cron firing every ten minutes does not re-open an SSH session to a host * that is already blocked. * * @module src/cli/vultr.js * @namespace UnderpostVultr */ import axios from 'axios'; import { environmentValueFactory } from '../server/environment.js'; import { FORWARD_PROXY, fetchViaForwardProxy } from '../server/forward-proxy.js'; import { loggerFactory } from '../server/logger.js'; import Underpost from '../index.js'; const logger = loggerFactory(import.meta); /** * @constant UNDERPOST_VULTR * @description Fixed identity of the bandwidth guard: API surface, defaults, and * the key the enforcement state is latched under. * @memberof UnderpostVultr */ const UNDERPOST_VULTR = { apiBaseUrl: 'https://api.vultr.com/v2', // Vultr quotes plan `bandwidth` in GB and usage in bytes, so one of the two // has to be converted. GB here is binary, matching how the quota is metered. bytesPerGB: 1024 * 1024 * 1024, defaultThreshold: 0.8, // The documented maximum; fewer pages means fewer round trips before the // instance's plan is found. plansPerPage: 500, // A cursor loop bounded so a malformed `meta.links.next` cannot spin forever. maxPlanPages: 20, requestTimeoutMs: 20000, defaultSshUser: 'root', defaultSshPort: 22, defaultSshKeyPath: './engine-private/deploy/id_rsa', remoteEnginePath: '/home/dd/engine', // Latched in the root env, which the CronJob mounts from the host, so the // decision survives the container that made it. latchKey: 'VULTR_EGRESS_BLOCKED_AT', env: { apiKey: 'VULTR_API_KEY', instanceId: 'VULTR_INSTANCE_ID', threshold: 'VULTR_BANDWIDTH_THRESHOLD', host: ['VULTR_VPS_IP', 'DEFAULT_SSH_HOST'], user: ['VULTR_SSH_USER', 'DEFAULT_SSH_USER'], keyPath: ['VULTR_SSH_KEY_PATH', 'DEFAULT_SSH_KEY_PATH'], port: ['VULTR_SSH_PORT', 'DEFAULT_SSH_PORT'], // The edge hub's forward proxy, named once in its canonical module so the two // ends of it cannot disagree about the variable that configures it. forwardProxyApiKey: FORWARD_PROXY.env.apiKey, forwardProxyHost: FORWARD_PROXY.env.host, forwardProxyPort: FORWARD_PROXY.env.port, }, }; /** * @method envFactory * @description First non-empty value among a list of keys. * * Resolution is {@link module:src/server/environment.js.environmentValueFactory}'s — the process * environment, then the deploy env `underpost env <deploy-id> <environment>` * selects into `./.env`, then the underpost root env — rather than a second * implementation of it, because the three callers differ: a CronJob container has * its deploy env loaded into `process.env` by `loadCronDeployEnv`, an operator * preparing a manual run has `./.env`, and an operator who ran `underpost env * set` has the root env. Never logged — one of the keys this resolves is an API * key. * @param {string|Array<string>} keys - Environment variable name, or names in precedence order. * @returns {string} The resolved value, or an empty string. * @memberof UnderpostVultr */ const envFactory = (keys) => { for (const key of Array.isArray(keys) ? keys : [keys]) { const value = environmentValueFactory(key); if (value) return value; } return ''; }; /** * @method thresholdFactory * @description Normalizes the configured trigger fraction. * * `80` and `0.80` are the same intent expressed two ways, and the first one * silently never fires — a guard that never fires is worse than no guard, since * it reads as protection. Both are accepted and resolve to the same ratio. * @param {string|number} [raw] - Configured value. * @param {number} [fallback] - Ratio used when nothing usable is configured. * @returns {number} Ratio in `(0, 1]`. * @memberof UnderpostVultr */ const thresholdFactory = (raw, fallback = UNDERPOST_VULTR.defaultThreshold) => { const parsed = parseFloat(`${raw ?? ''}`.trim()); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; const ratio = parsed > 1 ? parsed / 100 : parsed; return ratio > 1 ? 1 : ratio; }; /** * @method billingMonthFactory * @description The `YYYY-MM` prefix the daily buckets of the current cycle carry. * * UTC, because the endpoint's day boundaries are UTC. A host in a negative * offset would otherwise drop the current day for part of its evening. * @param {Date} [now] - Reference instant. * @returns {string} `YYYY-MM`. * @memberof UnderpostVultr */ const billingMonthFactory = (now = new Date()) => `${now.getUTCFullYear()}-${`${now.getUTCMonth() + 1}`.padStart(2, '0')}`; /** * @method bandwidthTotalsFactory * @description Folds the endpoint's daily buckets into one consumption figure. * * Scoped to the current month by default rather than summing every key * returned. The response is a rolling window that can still carry the tail of * the previous cycle, and those bytes are against a quota that has already * reset — counting them reports a host as over budget on the first days of a * month when it has barely spent anything. * * Both directions are returned separately because which of them is billable is * a property of the account's plan, not of this code: `total` is the * conservative reading and trips first, `outgoing` is the one that maps to * egress alone. * @param {object} [bandwidth] - `bandwidth` object from the Vultr response. * @param {string} [month] - `YYYY-MM` to scope to; empty sums every bucket. * @returns {{totalBytes: number, incomingBytes: number, outgoingBytes: number, days: number, dates: Array<string>}} Consumption for the window. * @memberof UnderpostVultr */ const bandwidthTotalsFactory = ({ bandwidth = {}, month = billingMonthFactory() } = {}) => { const dates = Object.keys(bandwidth || {}) .filter((date) => !month || `${date}`.startsWith(month)) .sort(); let incomingBytes = 0; let outgoingBytes = 0; for (const date of dates) { const bucket = bandwidth[date] || {}; incomingBytes += Number(bucket.incoming_bytes) || 0; outgoingBytes += Number(bucket.outgoing_bytes) || 0; } return { totalBytes: incomingBytes + outgoingBytes, incomingBytes, outgoingBytes, days: dates.length, dates }; }; /** * @method quotaStateFactory * @description The consumption decision: quota, effective trigger, and whether * it has been crossed. * * A plan with no quota (`bandwidth: 0`, which Vultr uses for unmetered plans) * yields `exceeded: false` and is reported as such rather than dividing by zero * and blocking a host that cannot run out. * @param {number} consumedBytes - Bytes counted for the cycle. * @param {number} planBandwidthGB - Plan quota in GB. * @param {number} [threshold] - Trigger fraction of the quota. * @returns {{maxBytes: number, limitBytes: number, consumedBytes: number, ratio: number, exceeded: boolean, metered: boolean}} Decision inputs and outcome. * @memberof UnderpostVultr */ const quotaStateFactory = ({ consumedBytes = 0, planBandwidthGB = 0, threshold = UNDERPOST_VULTR.defaultThreshold, }) => { const maxBytes = Math.max(0, Number(planBandwidthGB) || 0) * UNDERPOST_VULTR.bytesPerGB; const limitBytes = maxBytes * threshold; const metered = maxBytes > 0; return { maxBytes, limitBytes, consumedBytes, ratio: metered ? consumedBytes / maxBytes : 0, exceeded: metered && consumedBytes >= limitBytes, metered, }; }; /** * @method formatBytes * @description Byte count as GB, for log lines an operator has to read against * a plan quota that is quoted in GB. * @param {number} bytes - Byte count. * @returns {string} e.g. `812.40 GB`. * @memberof UnderpostVultr */ const formatBytes = (bytes) => `${((Number(bytes) || 0) / UNDERPOST_VULTR.bytesPerGB).toFixed(2)} GB`; /** * @method vultrUrlFactory * @description One absolute API URL, with its query already encoded. * * Built here rather than left to the transport because the two transports below * take a query differently — axios takes `params`, the proxy client takes a URL — * and a request that differs by transport is a bug waiting for the day the * fallback is used. * @param {string} path - Path below `/v2`. * @param {object} [params] - Query parameters. * @returns {string} Absolute URL. * @memberof UnderpostVultr */ const vultrUrlFactory = ({ path, params = {} }) => { const url = new URL(`${UNDERPOST_VULTR.apiBaseUrl}${path}`); for (const [key, value] of Object.entries(params)) if (value !== undefined && value !== null && `${value}` !== '') url.searchParams.set(key, `${value}`); return url.href; }; /** * @method vultrGet * @description The transport: through the edge hub's forward proxy when one is * configured, straight out otherwise. * * The proxy is the point of this indirection. Vultr's API sees the address the * request came from, and this job usually runs in a CronJob inside a homelab * cluster — so a direct call arrives from a residential ISP address, while a * proxied one arrives from the very VPS the job is metering. An API key scoped to * the edge's address only works over the proxy. * * Non-2xx answers are returned rather than thrown, so both transports report a * status the same way; only a transport failure throws. * @param {string} url - Absolute URL. * @param {string} apiKey - Vultr API key. * @param {object} [proxy] - Forward proxy endpoint; ignored when it carries no key. * @returns {Promise<{status: number, data: object}>} Status and parsed body. * @memberof UnderpostVultr */ const vultrGet = async ({ url, apiKey, proxy }) => { const headers = { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }; if (!proxy?.apiKey) { const response = await axios.get(url, { headers, timeout: UNDERPOST_VULTR.requestTimeoutMs, validateStatus: () => true, }); return { status: response.status, data: response.data }; } const { status, body } = await fetchViaForwardProxy(url, { headers, timeout: UNDERPOST_VULTR.requestTimeoutMs, proxy, }); try { return { status, data: body ? JSON.parse(body) : {} }; } catch { return { status, data: {} }; } }; /** * @method vultrRequest * @description One authenticated Vultr API call. * * Errors are re-thrown with Vultr's own message and without the request config, * because an axios error carries the `Authorization` header it was sent with and * this module's failures are logged. * @param {string} apiKey - Vultr API key. * @param {string} path - Path below `/v2`. * @param {object} [params] - Query parameters. * @param {object} [proxy] - Forward proxy endpoint from {@link UnderpostVultr.resolveConfig}. * @returns {Promise<object>} Response body. * @throws {Error} With the API's status and message, never the credentials. * @memberof UnderpostVultr */ const vultrRequest = async ({ apiKey, path, params = {}, proxy = null }) => { let status = 0; let data = {}; try { ({ status, data } = await vultrGet({ url: vultrUrlFactory({ path, params }), apiKey, proxy })); } catch (error) { const responseStatus = error?.response?.status; const detail = error?.response?.data?.error || error?.message || 'request failed'; const code = error?.code || error?.cause?.code; const proxyHint = proxy?.apiKey && ['EHOSTUNREACH', 'ENETUNREACH', 'ECONNREFUSED', 'ETIMEDOUT'].includes(code) ? '; verify wg0 on the spoke; if only pods fail, re-run --wireguard-setup --client and restart wg0; if the host also fails, re-run --forward-proxy-server on the hub' : ''; throw new Error( `[vultr] GET /v2${path} failed${responseStatus ? ` (${responseStatus})` : ''}: ${detail}${proxyHint}`, ); } if (status < 200 || status >= 300) throw new Error(`[vultr] GET /v2${path} failed (${status}): ${data?.error || 'request failed'}`); return data; }; /** * @method planBandwidthGBFactory * @description The monthly transfer quota, in GB, of the plan an instance runs. * * `/v2/plans` is paginated and the catalogue is longer than one page, so the * cursor is followed rather than the first page searched — a plan that happens * to sort past the page boundary would otherwise read as "not found" and, on a * naive implementation, as a quota of zero. * @param {string} apiKey - Vultr API key. * @param {string} planId - Plan id from the instance record. * @param {object} [proxy] - Forward proxy endpoint. * @returns {Promise<number>} Quota in GB. * @throws {Error} When the plan is absent from the catalogue. * @memberof UnderpostVultr */ const planBandwidthGBFactory = async ({ apiKey, planId, proxy = null }) => { let cursor = ''; for (let page = 0; page < UNDERPOST_VULTR.maxPlanPages; page++) { const data = await vultrRequest({ apiKey, path: '/plans', params: { type: 'all', per_page: UNDERPOST_VULTR.plansPerPage, ...(cursor ? { cursor } : {}) }, proxy, }); const match = (data?.plans || []).find((plan) => plan?.id === planId); if (match) return Number(match.bandwidth) || 0; cursor = `${data?.meta?.links?.next || ''}`.trim(); if (!cursor) break; } throw new Error(`[vultr] Plan ${planId} was not found in the plan catalogue; cannot resolve its bandwidth quota`); }; /** * @class UnderpostVultr * @description Bandwidth accounting for the edge VPS, and the egress cut-off it * triggers. * @memberof UnderpostVultr */ class UnderpostVultr { static API = { /** * @method callback * @description CLI and cron entry point. * * Takes the same `(deployList, options)` shape every other * {@link UnderpostCron} job does, so `underpost cron default vultr` dispatches * to it unchanged. The deploy list is not used to select an instance — the * edge hub is one machine for the whole cluster, exactly as its WireGuard * peer registry is — but it is logged so a run is attributable. * @param {string} [deployList] - Comma-separated deploy ids, from the cron dispatcher. * @param {object} [options] - CLI flags. * @returns {Promise<object>} Result from {@link UnderpostVultr.checkBandwidth}. * @memberof UnderpostVultr */ callback: async function (deployList = 'default', options = {}) { return await UnderpostVultr.API.checkBandwidth({ ...options, deployList }); }, /** * @method resolveConfig * @description Every input the guard needs, resolved once. * * CLI flags win over the environment so a one-off run can target another * instance without editing any `.env`; the environment is the standing * configuration. Single source of truth for the resolution, so no other * method re-reads `process.env`. * @param {object} [options] - CLI flags. * @returns {object} Resolved configuration; `apiKey` is present but must never be logged. * @memberof UnderpostVultr */ resolveConfig(options = {}) { return { apiKey: `${options.apiKey || ''}`.trim() || envFactory(UNDERPOST_VULTR.env.apiKey), instanceId: `${options.instanceId || ''}`.trim() || envFactory(UNDERPOST_VULTR.env.instanceId), threshold: thresholdFactory(options.threshold || envFactory(UNDERPOST_VULTR.env.threshold)), host: `${options.host || ''}`.trim() || envFactory(UNDERPOST_VULTR.env.host), user: `${options.user || ''}`.trim() || envFactory(UNDERPOST_VULTR.env.user) || UNDERPOST_VULTR.defaultSshUser, keyPath: `${options.keyPath || ''}`.trim() || envFactory(UNDERPOST_VULTR.env.keyPath) || UNDERPOST_VULTR.defaultSshKeyPath, port: Number(options.port || envFactory(UNDERPOST_VULTR.env.port)) || UNDERPOST_VULTR.defaultSshPort, // `total` is the conservative reading and trips first; `outgoing` counts // egress alone, which is what a plan billing outbound-only meters. metric: `${options.metric || 'total'}`.trim() === 'outgoing' ? 'outgoing' : 'total', month: options.allDates === true ? '' : `${options.month || ''}`.trim() || billingMonthFactory(), dryRun: options.dryRun === true, force: options.force === true, autoUnblock: options.autoUnblock === true, // Resolved through the same env precedence as everything else, so the // cron's deploy env can enable the proxy without a flag. An unset key // means no proxy, and the API is called directly. forwardProxy: { apiKey: envFactory(UNDERPOST_VULTR.env.forwardProxyApiKey), host: envFactory(UNDERPOST_VULTR.env.forwardProxyHost), port: envFactory(UNDERPOST_VULTR.env.forwardProxyPort), }, }; }, /** * @method checkBandwidth * @description Reads the instance's consumption for the cycle and enforces * the threshold. * * Three calls, in the only order that works: the instance record names its * plan, the plan carries the quota, and the bandwidth endpoint carries the * consumption. Nothing is enforced until all three have answered — a guard * that blocked an edge on a failed API call would be an outage caused by the * thing meant to prevent one. * @param {object} [options] - CLI flags; see {@link UnderpostVultr.resolveConfig}. * @returns {Promise<{instanceId: string, plan: string, metric: string, month: string, consumedBytes: number, limitBytes: number, maxBytes: number, ratio: number, exceeded: boolean, enforced: boolean, latched: boolean}>} What was measured and what was done about it. * @throws {Error} When credentials are missing or the API cannot be read. * @memberof UnderpostVultr */ checkBandwidth: async function (options = {}) { const config = UnderpostVultr.API.resolveConfig(options); if (!config.apiKey) throw new Error(`[vultr] ${UNDERPOST_VULTR.env.apiKey} is not set`); if (!config.instanceId) throw new Error(`[vultr] ${UNDERPOST_VULTR.env.instanceId} is not set`); const proxy = config.forwardProxy; const instance = (await vultrRequest({ apiKey: config.apiKey, path: `/instances/${config.instanceId}`, proxy })) ?.instance; const planId = `${instance?.plan || ''}`.trim(); if (!planId) throw new Error(`[vultr] Instance ${config.instanceId} returned no plan id`); const planBandwidthGB = await planBandwidthGBFactory({ apiKey: config.apiKey, planId, proxy }); const { bandwidth } = await vultrRequest({ apiKey: config.apiKey, path: `/instances/${config.instanceId}/bandwidth`, proxy, }); const totals = bandwidthTotalsFactory({ bandwidth, month: config.month }); const consumedBytes = config.metric === 'outgoing' ? totals.outgoingBytes : totals.totalBytes; const state = quotaStateFactory({ consumedBytes, planBandwidthGB, threshold: config.threshold }); const latchedAt = `${Underpost.env.get(UNDERPOST_VULTR.latchKey, undefined, { disableLog: true }) ?? ''}`.trim(); const summary = { instanceId: config.instanceId, plan: planId, metric: config.metric, month: config.month || 'all-dates', days: totals.days, consumed: formatBytes(consumedBytes), incoming: formatBytes(totals.incomingBytes), outgoing: formatBytes(totals.outgoingBytes), triggerLimit: formatBytes(state.limitBytes), planQuota: formatBytes(state.maxBytes), usedPercent: `${(state.ratio * 100).toFixed(1)}%`, threshold: `${(config.threshold * 100).toFixed(0)}%`, // Which address Vultr saw the reads come from, which is the difference // between a key scoped to the edge VPS working and failing. via: proxy.apiKey ? `forward-proxy ${proxy.host || FORWARD_PROXY.defaultHost}` : 'direct', }; if (!state.metered) { logger.info('Vultr plan reports no metered bandwidth quota; nothing to enforce', summary); return { ...state, ...summary, enforced: false, latched: !!latchedAt }; } if (!state.exceeded) { logger.info('Vultr bandwidth within budget', summary); // A latch that outlives the cycle it was set in would suppress the next // real trigger, so it is cleared as soon as usage is back under the // threshold — but the host stays blocked until someone says otherwise. if (latchedAt) { if (!config.dryRun) Underpost.env.delete(UNDERPOST_VULTR.latchKey); if (config.autoUnblock) await UnderpostVultr.API.setEdgeEgress({ config, blocked: false }); else logger.warn('Edge egress is still blocked from a previous cycle; unblock it when you are ready', { host: config.host, blockedAt: latchedAt, next: `underpost ip --unblock-all-egress (on ${config.host || 'the edge VPS'})`, }); } return { ...state, ...summary, enforced: false, latched: false }; } logger.warn('Vultr bandwidth threshold reached; edge egress will be blocked', summary); if (latchedAt && !config.force) { logger.info('Edge egress was already blocked for this cycle; not re-applying', { host: config.host, blockedAt: latchedAt, next: 'pass --force to re-apply', }); return { ...state, ...summary, enforced: false, latched: true }; } const enforced = await UnderpostVultr.API.setEdgeEgress({ config, blocked: true }); if (enforced && !config.dryRun) Underpost.env.set(UNDERPOST_VULTR.latchKey, new Date().toISOString()); return { ...state, ...summary, enforced, latched: enforced }; }, /** * @method setEdgeEgress * @description Blocks or restores outbound traffic on the edge VPS over SSH. * * The command is run on the VPS rather than here because the nftables rules * belong to that host — this process usually runs in a CronJob container * inside a spoke cluster, on the far side of the tunnel the rules govern. * * `underpost ip` is preferred when the CLI is installed globally and the * checked-out engine is the fallback, so a VPS provisioned either way is * reachable. The command already elevates internally, so it is not wrapped * in `sudo` here. * @param {object} config - Resolved configuration from {@link UnderpostVultr.resolveConfig}. * @param {boolean} blocked - True to block egress, false to restore it. * @returns {Promise<boolean>} True when the remote command succeeded. * @memberof UnderpostVultr */ setEdgeEgress: async function ({ config, blocked }) { const flag = blocked ? '--block-all-egress' : '--unblock-all-egress'; if (!config.host) { logger.error('No edge host configured; cannot reach the VPS to change its egress', { set: UNDERPOST_VULTR.env.host.join(' or '), }); return false; } const command = [ 'set -e', 'if command -v underpost >/dev/null 2>&1; then', ` underpost ip ${flag}`, 'else', ` cd ${UNDERPOST_VULTR.remoteEnginePath} && node bin ip ${flag}`, 'fi', ].join('\n'); if (config.dryRun) { logger.info('[dry-run] would run on the edge VPS', { target: `${config.user}@${config.host}:${config.port}`, command: `underpost ip ${flag}`, }); return false; } const result = await Underpost.ssh.sshExecBatch({ host: config.host, port: config.port, user: config.user, keyPath: config.keyPath, command, }); if (!result.ok) { logger.error(`Failed to run 'underpost ip ${flag}' on the edge VPS`, { target: `${config.user}@${config.host}:${config.port}`, code: result.code, stderr: `${result.stderr || ''}`.slice(-400), }); return false; } if (blocked) logger.warn('Edge egress blocked; every hostname behind the hub is now offline', { host: config.host, restore: `underpost ip --unblock-all-egress (on ${config.host})`, }); else logger.info('Edge egress restored', { host: config.host }); return true; }, }; } export default UnderpostVultr; export { UNDERPOST_VULTR, bandwidthTotalsFactory, billingMonthFactory, envFactory, formatBytes, planBandwidthGBFactory, quotaStateFactory, thresholdFactory, vultrUrlFactory, };