From b218c6f4af0b9f1f831820dd6ae53749347031fd Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Thu, 17 Sep 2026 23:21:04 +0300 Subject: [PATCH] feat: add opt-in isolated VPN database services Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 4 + FEATURES.md | 18 +++ TEST-PLAN.md | 35 +++++ docs/CHANNEL-VPN.md | 115 ++++++++++++++ docs/OPERATIONS.md | 5 + package.json | 3 +- scripts/channel-vpn.mjs | 210 +++++++++++++++++++++++++ services/vpn-image/Containerfile | 21 +++ services/vpn-image/checks.py | 178 +++++++++++++++++++++ services/vpn-image/entrypoint.sh | 11 ++ services/vpn-image/firewall.sh | 17 ++ services/vpn-image/health.sh | 3 + services/vpn-image/live_acceptance.py | 129 +++++++++++++++ services/vpn-image/routes.sh | 3 + services/vpn-image/test_checks.py | 120 ++++++++++++++ services/vpn-image/verify.sh | 3 + src/gateway/vpn-profile.js | 136 ++++++++++++++++ src/gateway/vpn-service.js | 218 ++++++++++++++++++++++++++ test/vpn-profile.test.js | 60 +++++++ test/vpn-service.test.js | 124 +++++++++++++++ 20 files changed, 1412 insertions(+), 1 deletion(-) create mode 100644 docs/CHANNEL-VPN.md create mode 100644 scripts/channel-vpn.mjs create mode 100644 services/vpn-image/Containerfile create mode 100644 services/vpn-image/checks.py create mode 100755 services/vpn-image/entrypoint.sh create mode 100755 services/vpn-image/firewall.sh create mode 100755 services/vpn-image/health.sh create mode 100644 services/vpn-image/live_acceptance.py create mode 100755 services/vpn-image/routes.sh create mode 100644 services/vpn-image/test_checks.py create mode 100755 services/vpn-image/verify.sh create mode 100644 src/gateway/vpn-profile.js create mode 100644 src/gateway/vpn-service.js create mode 100644 test/vpn-profile.test.js create mode 100644 test/vpn-service.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fe0b467..d34efc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — ChannelGate +- Add an optional operator-managed OpenVPN/MySQL service per channel, with dedicated tunnel + privileges, database-only routing/firewall, protected channel-secret references, a persistent + user service and read-only verification. Ordinary chat containers retain their existing rights. + - Add an organization-admin-only `/sudo` posture for individual Slack threads. While enabled, admin messages and their background work execute directly on the gateway host as the daemon OS user; non-admin messages are rejected as sudo-thread traffic before work starts. `/sudo off` diff --git a/FEATURES.md b/FEATURES.md index 0c9bf60..efda242 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -1,5 +1,23 @@ # ChannelGate — Features +## Optional isolated VPN database service + +- The host operator can provision a per-channel OpenVPN service and an unprivileged MySQL + verification/extractor container with `npm run vpn`. Only the dedicated VPN receives TUN and + `NET_ADMIN`; ordinary channel images, mounts, networking and agent permissions stay unchanged. +- Imported profiles are allowlisted and regenerated, with database-only tunnel routing, a + firewall kill switch, isolated credentials and no host socket or published ports. Configuration + is stored in channel metadata; protected profile revisions and a standalone operator bundle + survive channel recreation and gateway updates. +- A user systemd supervisor starts the pair after reboot/user-manager startup, monitors routing + and stops both on failure. Kernel locking excludes concurrent changes. Operator controls cover + configuration, build, status, start, stop, enable/disable and read-only `SELECT 1`/schema verification. +- This is an operator-only service, independent of Claude/Codex. It does not expose an agent tool + or authorize arbitrary SQL extraction. See `docs/CHANNEL-VPN.md` for requirements and limitations. + +Regression: `test/vpn-profile.test.js`, `test/vpn-service.test.js`, +`services/vpn-image/test_checks.py`; live isolation: `services/vpn-image/live_acceptance.py`. + ## Admin-only direct-host sudo threads - An organization admin can type `/sudo` (or `/sudo on`) in a Slack thread to make that thread's diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 302fb51..6efae6c 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,5 +1,40 @@ # ChannelGate — Test Plan +## Optional isolated VPN database service (operator-only, engine-independent) + +These cases do not invoke or depend on an engine. Run as the gateway's OS account against +rootless Podman; never grant host runtime access to a chat agent for this fixture. + +- [x] `node test/vpn-profile.test.js`: accepted TCP/CBC profiles produce fixed credential paths, + server verification and only the database route; malicious directives, external files, + malformed inline material, duplicate options and invalid targets fail without echoing secrets. +- [x] `node test/vpn-service.test.js`: foreign ownership is refused before removal; only the VPN + gets TUN/NET_ADMIN; credentials stay out of argv and the other service; missing/unsafe secret + selection, symlink imports, readiness failure and credential rotation are exercised. +- [x] `python3 -B services/vpn-image/test_checks.py`: route/default validation, read-only SQL and + sanitized errors; protected credential-file and permission checks. +- [x] Real rootless fixture: build `localhost/channelgate/vpn:1` with `--format docker`, then run + `python3 -B services/vpn-image/live_acceptance.py`. Require real TUN creation, firewall counter + evidence for rejected non-tunnel DB traffic/wrong tunnel destinations/ports, accepted DB SYN, + working public TCP, zero extractor capabilities/no VPN credentials and blocked DB after TUN + deletion. Require cleanup of only its two uniquely named fixture containers. +- [x] Private provider profile: import into selected channel, require mode 0600 and immutable + revision selection, then call start with channel Secrets absent. Require named missing + variables and no service containers created; no provider authentication attempted. +- [ ] Provider-backed fixture: add VPN and read-only MySQL credentials through that channel's + Secrets panel; enable unit, require VPN readiness and verify returning successful SELECT 1 + plus accessible schema names. Require database route via tun0 and public route via the + original eth0/tap0 interface, unchanged host routes/public IP and no unrelated container in + the service namespace. Never run SQL writes or dump credential/profile content. +- [ ] Rotate credentials, restart the unit and require both containers to be recreated together + with updated protected files; old credentials must not remain mounted. Stop the VPN + process in this fixture, require pair shutdown and visible failed unit; correct the cause + and restart. Restart gateway separately and require no service reaping. Test user-manager + boot recovery on an isolated host with linger already enabled. + +Provider-backed connection/restart gates remain unexecuted until the required channel Secrets +are supplied. The kernel isolation fixture is not a substitute for those connection checks. + ## Admin-only sudo thread → direct host execution Automated regression: `test/sudo-thread.test.js`, `test/runtimes-core.test.js`, diff --git a/docs/CHANNEL-VPN.md b/docs/CHANNEL-VPN.md new file mode 100644 index 0000000..4d0ebef --- /dev/null +++ b/docs/CHANNEL-VPN.md @@ -0,0 +1,115 @@ +# Isolated VPN database service + +The optional operator helper provisions a dedicated rootless Podman OpenVPN service and an +unprivileged MySQL verification/extractor container. Ordinary channel containers keep their existing +capabilities, mounts, image and bridge network. There is no Docker/Podman socket inside either +service container and no published port. This is an operator CLI, not an agent tool or a new Admin +mode permission. + +Only the VPN service has `/dev/net/tun` and `NET_ADMIN`. The extractor shares its network namespace, +but has no network capabilities, TUN device, VPN keys, engine credentials, gateway socket or host +home mount. A firewall permits only the configured database IPv4 address and TCP port through +`tun0`, blocks that database address outside the tunnel even during disconnects, rejects other +tunnel traffic, and blocks tunnel IPv6. Public traffic retains the rootless interface/default route +(`tap0` with slirp4netns on some hosts, `eth0` on others). No host routing/firewall changes occur. + +## Configure and start + +Run as the OS account owning the gateway and its rootless Podman runtime, with its user systemd +bus available. The host needs `/dev/net/tun`, Podman, slirp4netns, flock and Node meeting the gateway's +minimum. No host packages are installed by this helper. The dedicated image contains OpenVPN, +iptables, route tools and the database client; the ordinary ChannelGate image does not change. + +Add these values through the selected channel's Secrets panel, never in command arguments: + +- `VPN_USERNAME`, `VPN_PASSWORD` +- `MYSQL_USERNAME`, `MYSQL_PASSWORD` (use a provider-issued read-only database account) + +Place the `.ovpn` profile in that channel's working folder. The importer requires a bounded regular +file owned by the operator, rejects symlinks/hardlinks and restricts it to mode `0600`. It accepts +one TCP endpoint, a TUN client, inline CA/certificate/private key, optional inline TLS keys and a +small set of validated client options. Scripts, plugins, management endpoints, external files, +extra connections and broad routes are rejected. It regenerates the configuration with +`auth-nocache`, `route-nopull`, server certificate verification, fixed credential paths and exactly +one database route. CBC profiles explicitly configure modern OpenVPN cipher negotiation. + +```sh +npm run vpn -- configure --channel C_EXAMPLE --project crm-readonly \ + --profile /path/to/channel/client.ovpn --db-host 10.20.30.40 --db-port 3306 +npm run vpn -- build --channel C_EXAMPLE +npm run vpn -- install-unit --channel C_EXAMPLE +npm run vpn -- enable --channel C_EXAMPLE +npm run vpn -- status --channel C_EXAMPLE +npm run vpn -- verify --channel C_EXAMPLE +``` + +Secret names can be mapped with `--vpn-user-secret`, `--vpn-password-secret`, +`--mysql-user-secret` and `--mysql-password-secret` during configuration. Only those selected +channel variables are resolved; unrelated credentials are not forwarded. Missing credentials or +disabled channel network policy refuse startup before any service container changes. + +The `verify` command executes only `SELECT 1` and `SHOW DATABASES`, returning schema names and +sanitized readiness results. It does not export customer rows or accept arbitrary SQL. The +extractor container remains available for separately authorized extraction work. SQL read-only +privileges must be enforced by the database account; this helper does not change database grants. +The initial contract uses database TCP without TLS inside the encrypted VPN. Providers requiring +database TLS need a separate configuration extension; it is not silently negotiated here. + +## Persistence, updates and shutdown + +Non-secret desired configuration and secret references live in `channel_meta.vpnService` using +the gateway's existing store. Immutable normalized profile revisions, auth files and the installed +operator bundle live in `~/.channelgate/services/vpn//`, with `0700` directories and +`0600` files. These paths are not mounted into ordinary channel containers. The VPN receives only +its profile/auth files; the extractor receives only its database credential file. Auth files are +refreshed from current channel Secrets when the service starts. Public container fingerprints are +keyed HMACs rather than password hashes. + +`install-unit` reports the exact user service name and the standalone helper path. It installs +without starting or enabling the service. `enable` enables it at user-manager startup and starts +supervision; inspect `status`/`verify` to confirm actual readiness. Boot without an interactive +login requires the operator's existing user-manager/linger setup. `disable` stops it and removes +automatic startup. It preserves configuration and Secrets. + +```sh +npm run vpn -- disable --channel C_EXAMPLE +systemctl --user status channelgate-vpn-OWNER.service +journalctl --user -u channelgate-vpn-OWNER.service -n 30 +systemctl --user restart channelgate-vpn-OWNER.service +``` + +The supervisor holds a kernel lock for its lifetime; concurrent reconfiguration and manual +start/stop are refused. Disable the user service before configuring a new profile/target, rebuilding +the helper or installing an updated unit. Restart the service after secret rotation. Kernel locks +release on process exit, including crashes. There are no independent container restarts that could +leave the extractor attached to an obsolete VPN namespace. Lost containers or routes stop the pair +and fail the unit visibly; the operator can correct the cause and restart. The OpenVPN process can +perform its own connection retries while its namespace and firewall remain intact. + +For manual foreground-independent operation, `start` creates the pair, and `stop` removes only +containers bearing this service's ownership/role labels. Use `disable` for a supervised service. +Names owned by another workload are never removed. Services use `cg.service.*` labels, not the +normal channel lifecycle's `cg.install` label, so ordinary gateway restarts and idle reaping do +not sweep them. + +To use the helper with a separately installed stable gateway, pass +`--gateway-source /path/to/existing/gateway` on each command. It imports that gateway's store +modules and current schema and installs only its own standalone helper closure. It never changes +the stable checkout, upgrades the gateway, or runs newer database migrations against it. An +existing `CHANNELGATE_DIR` override must identify that deployment's runtime root. + +## Verification + +```sh +node --test test/vpn-profile.test.js test/vpn-service.test.js +python3 -B services/vpn-image/test_checks.py +podman build --format docker -t localhost/channelgate/vpn:1 services/vpn-image +python3 -B services/vpn-image/live_acceptance.py +``` + +The live fixture creates two uniquely named disposable containers with synthetic addresses. It +proves real TUN/network capabilities, firewall counters, allowed/denied destinations, public +connectivity, extractor isolation and tunnel-loss blocking, then removes only those fixtures. It +uses no customer credentials and does not claim that a real VPN authentication or MySQL login +succeeded. A provider-backed `verify`, secret rotation, service restart and boot recovery remain +separate live acceptance gates. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index b92d916..0517daf 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -438,6 +438,11 @@ like the channel's work directory. Both are visible on the host under `~/ChannelGate/.runtime///`, so an operator can see (and, if a channel ever hoards, clear) what an agent parked there. Nothing in the daemon deletes them. +**Optional VPN database service.** `npm run vpn` provisions an operator-managed OpenVPN service +and isolated database extractor without granting tunnel privileges to ordinary channel containers. +See [CHANNEL-VPN.md](CHANNEL-VPN.md) for protected profiles, channel Secrets, systemd persistence, +database-only routing and live acceptance requirements. + **Installing tools in a channel.** An agent can install whatever it needs, and it stays installed — everything below writes inside the per-channel HOME volume: diff --git a/package.json b/package.json index acfc897..ec761f4 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "with-landing-lock": "node scripts/with-landing-lock.mjs --", "whisper:install": "node scripts/install-whisper.mjs", "build:image": "node scripts/build-image.mjs", - "vscode": "node scripts/open-vscode.mjs" + "vscode": "node scripts/open-vscode.mjs", + "vpn": "node scripts/channel-vpn.mjs" }, "engines": { "node": ">=22.13" diff --git a/scripts/channel-vpn.mjs b/scripts/channel-vpn.mjs new file mode 100644 index 0000000..3c9bbe3 --- /dev/null +++ b/scripts/channel-vpn.mjs @@ -0,0 +1,210 @@ +#!/usr/bin/env node +// Host-operator entry point. Can install a private standalone bundle beside an existing stable +// gateway without changing its checkout, its runtime image, or any ordinary channel container. +import path from "node:path"; +import os from "node:os"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { createHash, randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { lstat, readdir } from "node:fs/promises"; +import { normalizeVpnProfile, validateVpnTarget } from "../src/gateway/vpn-profile.js"; +import { SECRET_REFS, serviceIdentity, selectedCredentials, serviceFingerprint, createVpnService, + plainPath, privateDirectory, readPrivate, writePrivate, runCommand } from "../src/gateway/vpn-service.js"; + +const bundleRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const args = process.argv.slice(2); +const action = args.shift(); +const allowed = new Set(["channel", "gateway-source", "profile", "project", "db-host", "db-port", "vpn-user-secret", "vpn-password-secret", "mysql-user-secret", "mysql-password-secret"]); +const opts = {}; +const shutdown = new AbortController(); +process.on("SIGTERM",()=>shutdown.abort()); +process.on("SIGINT",()=>shutdown.abort()); +for (let i=0;i --channel ID [--gateway-source /existing/gateway]"); + console.log("configure also requires --profile /channel/client.ovpn --project lowercase-name --db-host IPv4 [--db-port 3306]."); + console.log("Credentials are resolved only from the selected channel's Secrets: VPN_USERNAME, VPN_PASSWORD, MYSQL_USERNAME, MYSQL_PASSWORD."); + process.exit(0); +} + +function systemdWord(value) { + if (/[\r\n\0]/.test(value)) throw new Error("Invalid service argument."); + return `"${value.replace(/\\/g,"\\\\").replace(/"/g,'\\"').replace(/%/g,"%%").replace(/\$/g,"$$")}"`; +} + +async function main() { + if (!["configure","build","start","supervise","status","verify","stop","install-unit","enable","disable"].includes(action)) throw new Error("Unknown service command."); + if (!opts.channel || !/^[A-Za-z0-9:_-]{1,100}$/.test(opts.channel)) throw new Error("An exact registered channel ID is required."); + const source = await plainPath(opts["gateway-source"] || bundleRoot); + // Store imports use the selected gateway's own modules/schema, never a copied newer migration. + const store = await import(pathToFileURL(path.join(source,"src/config/store.js"))); + const paths = await import(pathToFileURL(path.join(source,"src/config/paths.js"))); + const channelEnv = await import(pathToFileURL(path.join(source,"src/config/channel-env.js"))); + const entry = await store.getChannelEntry(opts.channel); + if (!entry) throw new Error("Channel is not registered in this gateway."); + let meta = await store.getChannelMeta(entry.slug); + if (!meta) throw new Error("Channel has no configuration."); + const root = await plainPath(paths.gatewayRoot()); + const dir = path.join(root,"services","vpn",serviceIdentity(root,opts.channel,"service").owner); + const mutations = !["status","verify","enable","disable"].includes(action); + if (mutations) await privateDirectory(dir); + const lock = path.join(dir,"operation.lock"); + if (mutations && process.env.CG_VPN_LOCK_HELD !== lock) { + // A kernel lock covers the entire supervisor lifetime and is released even after a crash. + // --no-fork lets the wrapper forward shutdown directly to the supervised Node process. + const code = await new Promise((resolve,reject)=>{ + const child = spawn("/usr/bin/flock",["--nonblock","--conflict-exit-code","75","--no-fork",lock,process.execPath,fileURLToPath(import.meta.url),...process.argv.slice(2)],{ + stdio:"inherit",env:{...process.env,CG_VPN_LOCK_HELD:lock}, + }); + const terminate = ()=>child.kill("SIGTERM"); + process.on("SIGTERM",terminate); process.on("SIGINT",terminate); + child.on("error",()=>reject(new Error("Could not acquire the service operation lock."))); + child.on("close",result=>{process.off("SIGTERM",terminate);process.off("SIGINT",terminate);resolve(result ?? 1);}); + }); + if (code === 75) throw new Error("Service is supervised or another operation is active. Disable its user service before changing configuration or starting it manually."); + process.exitCode = code; return; + } + { + if (mutations) meta = await store.getChannelMeta(entry.slug); + if (action === "configure") { + if (!opts.profile || !opts.project) throw new Error("configure requires --profile and --project."); + const identity = serviceIdentity(root,opts.channel,opts.project); + const previous = meta.vpnService; + if (previous && previous.project !== opts.project) throw new Error("Changing an existing project's name is not supported; stop and retire it explicitly first."); + const target = validateVpnTarget(opts["db-host"],opts["db-port"] || 3306); + const workspace = await plainPath(meta.workDir || paths.workspaceFolder(entry.slug,entry.platform)); + const profilePath = await plainPath(opts.profile); + if (!profilePath.startsWith(`${workspace}${path.sep}`)) throw new Error("Profile must be a regular file inside the selected channel's working folder."); + const normalized = normalizeVpnProfile(await readPrivate(profilePath,{tighten:true}),target); + const refs = { ...SECRET_REFS }; + for (const [option,key] of Object.entries({"vpn-user-secret":"vpnUsername","vpn-password-secret":"vpnPassword","mysql-user-secret":"mysqlUsername","mysql-password-secret":"mysqlPassword"})) { + if (opts[option]) refs[key] = opts[option]; + } + selectedCredentials({},refs); // validate references without resolving values + const profileRevision = createHash("sha256").update(normalized.config).digest("hex"); + const config = { version:1, project:opts.project, ...target, remote:normalized.remote, secrets:refs, profileRevision }; + await writePrivate(path.join(dir,`profile-${profileRevision}.ovpn`),normalized.config); + await store.patchChannelMeta(entry.slug,{vpnService:config}); + meta = { ...meta,vpnService:config }; + console.log(JSON.stringify({configured:true,project:config.project,remote:config.remote,target,unit:identity.unit,credentials:inventory(meta,channelEnv,refs)},null,2)); + return; + } + const config = meta.vpnService; + if (!config || config.version !== 1) throw new Error("Configure this channel's VPN service first."); + validateVpnTarget(config.dbHost,config.dbPort); + selectedCredentials({},config.secrets); + const identity = serviceIdentity(root,opts.channel,config.project); + const image = "localhost/channelgate/vpn:1"; + const imageDir = path.join(bundleRoot,"services/vpn-image"); + const service = createVpnService({identity,serviceDir:dir,config}); + if (action === "status") { + console.log(JSON.stringify({...(await service.status()),credentials:inventory(meta,channelEnv,config.secrets),unit:identity.unit},null,2)); + return; + } + const info = await runCommand("/usr/bin/podman",["info","--format","{{.Host.Security.Rootless}}"]); + if (info.code !== 0 || info.stdout.trim() !== "true") throw new Error("This service requires a working rootless Podman runtime owned by the gateway operator."); + if (action === "build") { + const result = await runCommand("/usr/bin/podman",["build","--format","docker","--tag",image,"--file",path.join(imageDir,"Containerfile"),imageDir],{timeoutMs:600_000}); + if (result.code !== 0) throw new Error("VPN image build failed. Run podman build on services/vpn-image to inspect package/build diagnostics."); + console.log(JSON.stringify({image,built:true})); return; + } + if (action === "stop") { await service.stop(); console.log("VPN and extractor stopped; profile and channel Secrets preserved."); return; } + if (action === "verify") { console.log(JSON.stringify(await service.verify(),null,2)); return; } + if (action === "install-unit") { + // Copy only the reviewed helper's closure. No git checkout, channel credentials or other + // gateway source is copied. The existing stable gateway remains on its exact revision. + const installed = path.join(dir,"operator"); + if (path.resolve(bundleRoot) !== path.resolve(installed)) { + const files = ["scripts/channel-vpn.mjs","src/gateway/vpn-service.js","src/gateway/vpn-profile.js"]; + for (const name of await readdir(imageDir)) { + const data = await lstat(path.join(imageDir,name)); + if (data.isFile() && !name.startsWith("test")) files.push(`services/vpn-image/${name}`); + } + for (const file of files) { + const destination = path.join(installed,file); + await privateDirectory(path.dirname(destination)); + await writePrivate(destination,await readPrivate(path.join(bundleRoot,file)),0o600); + } + await writePrivate(path.join(installed,"package.json"),'{"type":"module"}\n'); + } + const unitDir = path.join(os.homedir(),".config/systemd/user"); + await privateDirectory(unitDir); + const base = [process.execPath,path.join(installed,"scripts/channel-vpn.mjs")]; + const flags = ["--channel",opts.channel,"--gateway-source",source]; + const command = name => [...base,name,...flags].map(systemdWord).join(" "); + const unit = `[Unit]\nDescription=ChannelGate isolated VPN and database extractor\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nUMask=0077\nWorkingDirectory=${dir.replace(/%/g,"%%")}\nEnvironment=${systemdWord(`CHANNELGATE_DIR=${root}`)}\nExecStart=${command("supervise")}\nKillMode=mixed\nTimeoutStopSec=180\nRestart=no\n\n[Install]\nWantedBy=default.target\n`; + await writePrivate(path.join(unitDir,identity.unit),unit); + const result = await runCommand("/usr/bin/systemctl",["--user","daemon-reload"]); + if (result.code !== 0) throw new Error("Unit written, but user systemd reload failed."); + console.log(JSON.stringify({installed:true,unit:identity.unit,operator:path.join(installed,"scripts/channel-vpn.mjs"),enabled:false})); return; + } + if (action === "enable" || action === "disable") { + if (action === "enable") { + if (!meta.allowNetwork) throw new Error("Channel network policy is off."); + const {missing} = selectedCredentials(await resolveSelected(meta,channelEnv,config.secrets),config.secrets); + if (missing.length) throw new Error(`Missing channel Secrets: ${missing.join(", ")}. Service was not enabled.`); + } + const result = await runCommand("/usr/bin/systemctl",["--user",action,"--now",identity.unit],{timeoutMs:210_000}); + if (result.code !== 0) throw new Error(`Service ${action} failed; check its status.`); + console.log(JSON.stringify({unit:identity.unit,enabled:action === "enable"})); return; + } + if (!meta.allowNetwork) throw new Error("Channel network policy is off; an administrator must enable it before starting the VPN."); + const {selected,missing} = selectedCredentials(await resolveSelected(meta,channelEnv,config.secrets),config.secrets); + if (missing.length) throw new Error(`Missing channel Secrets: ${missing.join(", ")}. No containers were changed.`); + const tun = await lstat("/dev/net/tun"); + if (!tun.isCharacterDevice()) throw new Error("Host TUN device is unavailable."); + const imageResult = await runCommand("/usr/bin/podman",["image","inspect","--format","{{.Id}}",image]); + if (imageResult.code !== 0 || !/^(sha256:)?[a-f0-9]{64}$/.test(imageResult.stdout.trim())) throw new Error("Build the dedicated VPN image before starting the service."); + const imageId = imageResult.stdout.trim(); + let salt; + try { salt = await readPrivate(path.join(dir,"fingerprint-key")); } + catch (error) { + if (error.code !== "ENOENT") throw error; + salt = randomBytes(32).toString("hex"); await writePrivate(path.join(dir,"fingerprint-key"),salt); + } + if (!/^[a-f0-9]{64}$/.test(config.profileRevision)) throw new Error("Profile revision is invalid; configure this service again."); + const profile = await readPrivate(path.join(dir,`profile-${config.profileRevision}.ovpn`)); + if (createHash("sha256").update(profile).digest("hex") !== config.profileRevision) throw new Error("Protected profile revision changed; configure this service again."); + const fingerprint = serviceFingerprint({config,profile},selected,imageId,salt); + const runtime = createVpnService({identity,serviceDir:dir,config,imageId}); + const result = await runtime.start({fingerprint,profile,signal:shutdown.signal,auth:`${selected.vpnUsername}\n${selected.vpnPassword}\n`,database:{username:selected.mysqlUsername,password:selected.mysqlPassword}}); + try { + console.log(JSON.stringify({...result,...(await runtime.status())},null,2)); + if (action === "supervise") { + while (!shutdown.signal.aborted) { + await new Promise(resolve => { + const done = () => { clearTimeout(timer); shutdown.signal.removeEventListener("abort",done); resolve(); }; + const timer = setTimeout(done,10_000); + shutdown.signal.addEventListener("abort",done,{once:true}); + }); + if (shutdown.signal.aborted) break; + const state = await runtime.status(); + if (state.vpn.state !== "running" || state.extractor.state !== "running" || !await runtime.routesReady()) throw new Error("VPN service lost its isolated route or container; stopped the pair. Check credentials/network and restart the service."); + } + } + } finally { if (action === "supervise") await runtime.stop(); } + } +} + +function inventory(meta,channelEnv,refs) { + const names = new Set(channelEnv.listChannelEnv(meta).map(item => item.name)); + return Object.values(refs).map(name => ({name,present:names.has(name)})); +} + +function resolveSelected(meta,channelEnv,refs) { + const env = {}; + for (const name of Object.values(refs)) if (Object.hasOwn(meta.env || {},name)) env[name] = meta.env[name]; + return channelEnv.resolveChannelEnv({env}); +} + +main().catch(error => { + // Never relay provider/process output: it may include credentials or profile key material. + const message = error instanceof Error && !error.code ? error.message : "Service operation failed (filesystem or runtime access)."; + console.error(message); process.exitCode = 1; +}); diff --git a/services/vpn-image/Containerfile b/services/vpn-image/Containerfile new file mode 100644 index 0000000..5681a4a --- /dev/null +++ b/services/vpn-image/Containerfile @@ -0,0 +1,21 @@ +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates openvpn iproute2 iptables python3 python3-pymysql tini \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /usr/local/bin/cg-vpn-start +COPY firewall.sh /usr/local/bin/cg-vpn-firewall +COPY checks.py /usr/local/lib/channelgate-vpn/checks.py +COPY health.sh /usr/local/bin/cg-vpn-health +COPY routes.sh /usr/local/bin/cg-vpn-routes +COPY verify.sh /usr/local/bin/cg-vpn-verify +RUN chmod 0555 /usr/local/bin/cg-vpn-start /usr/local/bin/cg-vpn-health \ + /usr/local/bin/cg-vpn-routes /usr/local/bin/cg-vpn-verify /usr/local/bin/cg-vpn-firewall \ + && chmod 0444 /usr/local/lib/channelgate-vpn/checks.py + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 \ + CMD ["/usr/local/bin/cg-vpn-health"] +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/cg-vpn-start"] diff --git a/services/vpn-image/checks.py b/services/vpn-image/checks.py new file mode 100644 index 0000000..a04ed29 --- /dev/null +++ b/services/vpn-image/checks.py @@ -0,0 +1,178 @@ +"""Bounded VPN/database readiness checks. Never render exception text or secrets.""" + +import argparse +import ipaddress +import json +import os +import re +import socket +import stat +import subprocess +import sys + + +class CheckFailed(Exception): + def __init__(self, error_class): + self.error_class = error_class + + +def configuration(environ): + try: + host = str(ipaddress.IPv4Address(environ.get("DB_HOST", ""))) + except ipaddress.AddressValueError: + raise CheckFailed("invalid_database_host") from None + port_text = environ.get("DB_PORT", "3306") + if not re.fullmatch(r"[0-9]{1,5}", port_text): + raise CheckFailed("invalid_database_port") + port = int(port_text) + if not 1 <= port <= 65535: + raise CheckFailed("invalid_database_port") + return host, port + + +def route_data(args, runner=subprocess.run): + try: + result = runner( + ["ip", "-j", "-4", "route", *args], + capture_output=True, + check=True, + timeout=5, + text=True, + ) + value = json.loads(result.stdout) + if not isinstance(value, list) or not value or not all(isinstance(row, dict) for row in value): + raise ValueError() + return value + except (OSError, subprocess.SubprocessError, ValueError): + raise CheckFailed("route_not_ready") from None + + +def check_routes(host, runner=subprocess.run): + defaults = route_data(["show", "default"], runner) + public_devices = {route.get("dev") for route in defaults} + # Podman's rootless slirp4netns names its interface tap0; bridge networking + # uses eth0. Preserve exactly one public default, never a tunnel default. + if len(public_devices) != 1 or not public_devices.issubset({"eth0", "tap0"}): + raise CheckFailed("public_default_route_changed") + public_device = next(iter(public_devices)) + target = route_data(["get", host], runner) + if any(route.get("dev") != "tun0" for route in target): + raise CheckFailed("database_route_not_tunnel") + # A default route can remain listed while more-specific routes divert public + # traffic. Check the actual lookup too, including OpenVPN's /1 default trick. + for public_ip in ("1.1.1.1", "208.67.222.222"): + if public_ip == host: + continue + if any(route.get("dev") != public_device for route in route_data(["get", public_ip], runner)): + raise CheckFailed("public_default_route_changed") + + +def check_tcp(host, port, connect=socket.create_connection): + try: + with connect((host, port), timeout=5): + pass + except OSError: + raise CheckFailed("database_unreachable") from None + + +def read_credentials(filename="/db/credentials.json"): + try: + fd = os.open(filename, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(fd, "r", encoding="utf-8") as handle: + info = os.fstat(handle.fileno()) + if not stat.S_ISREG(info.st_mode) or info.st_mode & 0o077 or info.st_size > 16384: + raise CheckFailed("invalid_database_credentials_file") + value = json.loads(handle.read(16385)) + except CheckFailed: + raise + except (OSError, ValueError, UnicodeError): + raise CheckFailed("database_credentials_unavailable") from None + if not isinstance(value, dict): + raise CheckFailed("invalid_database_credentials") + if not isinstance(value.get("username"), str) or not value["username"]: + raise CheckFailed("invalid_database_credentials") + if not isinstance(value.get("password"), str) or not value["password"]: + raise CheckFailed("invalid_database_credentials") + if "database" in value and (not isinstance(value["database"], str) or not value["database"]): + raise CheckFailed("invalid_database_credentials") + return {name: value[name] for name in ("username", "password", "database") if name in value} + + +def check_database(host, port, credentials, connect=None): + if connect is None: + import pymysql + connect = pymysql.connect + try: + connection = connect( + host=host, + port=port, + user=credentials["username"], + password=credentials["password"], + database=credentials.get("database"), + connect_timeout=8, + read_timeout=8, + write_timeout=8, + charset="utf8mb4", + autocommit=True, + # This operator-enabled connection uses the encrypted VPN transport; + # it does not negotiate database TLS without a separate TLS contract. + ssl=None, + ) + try: + with connection.cursor() as cursor: + cursor.execute("SELECT 1") + if cursor.fetchone() != (1,): + raise CheckFailed("database_query_failed") + cursor.execute("SHOW DATABASES") + schemas = sorted(str(row[0]) for row in cursor.fetchall()) + finally: + connection.close() + return {"schemaNames": schemas} + except CheckFailed: + raise + except Exception as error: + # Error text can contain credentials, SQL and endpoint details. Only + # classify known server codes; never return repr(error) or its message. + code = error.args[0] if error.args and isinstance(error.args[0], int) else None + if code == 1045: + error_class = "database_authentication_failed" + elif code in (1044, 1142): + error_class = "database_access_denied" + elif code == 1049: + error_class = "database_not_found" + else: + error_class = "database_connection_or_query_failed" + raise CheckFailed(error_class) from None + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("action", choices=("validate-env", "health", "verify")) + parser.add_argument("--no-connect", action="store_true") + args = parser.parse_args(argv) + try: + host, port = configuration(os.environ) + result = {"ok": True} + if args.action != "validate-env": + check_routes(host) + result["routes"] = "ready" + if args.action == "health" and not args.no_connect: + check_tcp(host, port) + result["tcp"] = "ready" + elif args.action == "verify": + if args.no_connect: + raise CheckFailed("invalid_check_options") + result.update(check_database(host, port, read_credentials())) + result["database"] = "ready" + print(json.dumps(result, ensure_ascii=True)) + return 0 + except CheckFailed as error: + print(json.dumps({"ok": False, "errorClass": error.error_class})) + return 1 + except Exception: + print(json.dumps({"ok": False, "errorClass": "readiness_check_failed"})) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/vpn-image/entrypoint.sh b/services/vpn-image/entrypoint.sh new file mode 100755 index 0000000..78493e7 --- /dev/null +++ b/services/vpn-image/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# The operator generates this config from an allowlisted profile. Never accept an +# arbitrary path or additional OpenVPN command-line options from the container. +set -eu + +test -r /vpn/client.ovpn +test -r /vpn/auth +test -c /dev/net/tun + +/usr/local/bin/cg-vpn-firewall +exec openvpn --config /vpn/client.ovpn diff --git a/services/vpn-image/firewall.sh b/services/vpn-image/firewall.sh new file mode 100755 index 0000000..1a7d9ce --- /dev/null +++ b/services/vpn-image/firewall.sh @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu +python3 /usr/local/lib/channelgate-vpn/checks.py validate-env >/dev/null + +# Install the kill switch before opening the tunnel. Insert rules instead of +# flushing a chain: on an in-place retry there is never an unprotected interval. +# Public/default interface traffic remains available; the database address must NEVER +# leave by any interface other than tun0, including before/after a reconnect. +iptables -w 10 -I OUTPUT 1 ! -o tun0 -d "$DB_HOST/32" -j REJECT +iptables -w 10 -I OUTPUT 1 -o tun0 -j REJECT +iptables -w 10 -I OUTPUT 1 -o tun0 -d "$DB_HOST/32" -p tcp --dport "${DB_PORT:-3306}" -j ACCEPT + +# Do not expose the extractor to unsolicited connections from the VPN network. +iptables -w 10 -I INPUT 1 -i tun0 -j DROP +iptables -w 10 -I INPUT 1 -i tun0 -s "$DB_HOST/32" -p tcp --sport "${DB_PORT:-3306}" -m conntrack --ctstate ESTABLISHED -j ACCEPT +ip6tables -w 10 -I OUTPUT 1 -o tun0 -j REJECT +ip6tables -w 10 -I INPUT 1 -i tun0 -j DROP diff --git a/services/vpn-image/health.sh b/services/vpn-image/health.sh new file mode 100755 index 0000000..217ae01 --- /dev/null +++ b/services/vpn-image/health.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec python3 /usr/local/lib/channelgate-vpn/checks.py health "$@" diff --git a/services/vpn-image/live_acceptance.py b/services/vpn-image/live_acceptance.py new file mode 100644 index 0000000..c26942b --- /dev/null +++ b/services/vpn-image/live_acceptance.py @@ -0,0 +1,129 @@ +"""Isolated rootless acceptance, no customer profile, credentials or VPN traffic. + +Run as the account owning rootless Podman, after building localhost/channelgate/vpn:1. +Creates and removes only two uniquely named QA containers. No host routes change. +""" + +import json +import re +import subprocess +import uuid + + +IMAGE = "localhost/channelgate/vpn:1" +DB_HOST = "10.254.255.254" +OTHER_HOST = "10.254.255.253" + + +def podman(*args): + result = subprocess.run(["podman", *args], capture_output=True, text=True, timeout=60) + if result.returncode: + raise RuntimeError(f"Podman QA command failed: {args[0]}: {result.stderr.strip()}") + return result.stdout.strip() + + +def main(): + suffix = uuid.uuid4().hex[:12] + vpn = f"cg-vpn-qa-{suffix}" + extractor = f"cg-vpn-qa-extractor-{suffix}" + created = [] + evidence = {} + try: + podman( + "run", "-d", "--name", vpn, + "--label", "cg.vpn.qa=1", "--cap-drop", "ALL", "--cap-add", "NET_ADMIN", + "--device", "/dev/net/tun", "--network", "slirp4netns:allow_host_loopback=false", + "--read-only", "--tmpfs", "/run:rw,noexec,nosuid,size=8m", + "--tmpfs", "/tmp:rw,noexec,nosuid,size=8m", "--security-opt", "no-new-privileges", + "--health-cmd", "none", "-e", f"DB_HOST={DB_HOST}", "-e", "DB_PORT=3306", + "--entrypoint", "/usr/bin/tini", IMAGE, "--", "sleep", "infinity", + ) + created.append(vpn) + podman("exec", vpn, "cg-vpn-firewall") + podman("exec", vpn, "ip", "tuntap", "add", "dev", "tun0", "mode", "tun") + podman("exec", vpn, "ip", "addr", "add", "10.253.0.2/30", "dev", "tun0") + podman("exec", vpn, "ip", "link", "set", "tun0", "up") + evidence["tunCreatedWithNetAdminOnly"] = True + + def lookup(host): + return json.loads(podman("exec", vpn, "ip", "-j", "route", "get", host))[0]["dev"] + + def probe(container, host, port): + code = ( + "import socket,json; s=socket.socket(); s.settimeout(2); " + f"result=s.connect_ex(({host!r},{port})); " + "s.close(); print(json.dumps({'errno':result}))" + ) + return json.loads(podman("exec", container, "python3", "-c", code))["errno"] + + public_device = lookup(DB_HOST) + assert public_device in ("eth0", "tap0") + evidence["publicInterface"] = public_device + assert probe(vpn, DB_HOST, 3306) == 111 # ECONNREFUSED from REJECT + saved = podman("exec", vpn, "iptables-save", "-c") + kill_rule = next(line for line in saved.splitlines() if f"-d {DB_HOST}/32 ! -o tun0" in line) + assert re.match(r"\[[1-9][0-9]*:", kill_rule), kill_rule + evidence["databaseOutsideTunnelRejected"] = True + + podman("exec", vpn, "ip", "route", "add", f"{OTHER_HOST}/32", "dev", "tun0") + assert lookup(OTHER_HOST) == "tun0" + assert probe(vpn, OTHER_HOST, 3306) == 111 + saved = podman("exec", vpn, "iptables-save", "-c") + tunnel_rule = next(line for line in saved.splitlines() if "-A OUTPUT -o tun0 -j REJECT" in line) + assert re.match(r"\[[1-9][0-9]*:", tunnel_rule), tunnel_rule + evidence["otherTunnelDestinationRejected"] = True + + podman("exec", vpn, "ip", "route", "add", f"{DB_HOST}/32", "dev", "tun0") + assert probe(vpn, DB_HOST, 3307) == 111 + probe(vpn, DB_HOST, 3306) # No peer exists, but the firewall must ACCEPT this SYN. + saved = podman("exec", vpn, "iptables-save", "-c") + allow_rule = next(line for line in saved.splitlines() if "--dport 3306 -j ACCEPT" in line) + assert re.match(r"\[[1-9][0-9]*:", allow_rule), allow_rule + evidence["databasePortAcceptedOtherPortRejected"] = True + assert lookup("1.1.1.1") == public_device + assert probe(vpn, "1.1.1.1", 443) == 0 + evidence["publicDefaultAndTcpPreserved"] = True + evidence["routeHealth"] = json.loads(podman("exec", vpn, "cg-vpn-routes")) + + ipv6_rules = podman("exec", vpn, "ip6tables-save") + assert "-A OUTPUT -o tun0 -j REJECT" in ipv6_rules + assert "-A INPUT -i tun0 -j DROP" in ipv6_rules + evidence["ipv6TunnelBlocked"] = True + + # A harmless marker in VPN-only tmpfs demonstrates filesystem separation. + podman("exec", vpn, "sh", "-c", "umask 077; printf fixture > /run/vpn-only-marker") + podman( + "run", "-d", "--name", extractor, "--label", "cg.vpn.qa=1", + "--cap-drop", "ALL", "--network", f"container:{vpn}", "--read-only", + "--security-opt", "no-new-privileges", "--health-cmd", "none", + "--entrypoint", "/usr/bin/tini", IMAGE, "--", "sleep", "infinity", + ) + created.append(extractor) + confinement = json.loads(podman("exec", extractor, "python3", "-c", """ +import json, pathlib, subprocess +status = pathlib.Path('/proc/self/status').read_text() +cap = next(line.split()[1] for line in status.splitlines() if line.startswith('CapEff:')) +add = subprocess.run(['ip', 'link', 'add', 'qa-forbidden', 'type', 'dummy'], capture_output=True) +print(json.dumps({'capabilitiesZero': int(cap,16)==0, 'networkMutationDenied': add.returncode!=0, + 'tunDeviceAbsent': not pathlib.Path('/dev/net/tun').exists(), + 'vpnFilesystemAbsent': not pathlib.Path('/run/vpn-only-marker').exists(), + 'vpnAuthAbsent': not pathlib.Path('/vpn/auth').exists()})) +""")) + assert all(confinement.values()), confinement + evidence["extractorConfinement"] = confinement + assert probe(extractor, OTHER_HOST, 3306) == 111 + evidence["extractorSharesFirewall"] = True + + podman("exec", vpn, "ip", "link", "delete", "tun0") + assert lookup(DB_HOST) == public_device + assert probe(extractor, DB_HOST, 3306) == 111 + evidence["tunnelLossStillRejectsDatabase"] = True + evidence["imageId"] = podman("image", "inspect", "--format", "{{.Id}}", IMAGE) + finally: + for name in reversed(created): + podman("rm", "-f", name) + print(json.dumps({"ok": True, "evidence": evidence, "fixtureContainersRemoved": created}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/services/vpn-image/routes.sh b/services/vpn-image/routes.sh new file mode 100755 index 0000000..f7160d7 --- /dev/null +++ b/services/vpn-image/routes.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec python3 /usr/local/lib/channelgate-vpn/checks.py health --no-connect "$@" diff --git a/services/vpn-image/test_checks.py b/services/vpn-image/test_checks.py new file mode 100644 index 0000000..8d722e1 --- /dev/null +++ b/services/vpn-image/test_checks.py @@ -0,0 +1,120 @@ +"""Run with python3 -m unittest discover -s services/vpn-image -p 'test_*.py'.""" + +import contextlib +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location("vpn_checks", Path(__file__).with_name("checks.py")) +checks = importlib.util.module_from_spec(spec) +spec.loader.exec_module(checks) + + +class ChecksTests(unittest.TestCase): + def test_configuration_rejects_command_text_hostnames_and_invalid_ports(self): + for host in ("db.internal", "::1", "10.0.0.2;id", "10.0.0.2/24", ""): + with self.subTest(host=host), self.assertRaises(checks.CheckFailed): + checks.configuration({"DB_HOST": host}) + for port in ("0", "65536", "-1", "3306\n", "3306;id", "123"): + with self.subTest(port=port), self.assertRaises(checks.CheckFailed): + checks.configuration({"DB_HOST": "10.0.0.2", "DB_PORT": port}) + self.assertEqual(checks.configuration({"DB_HOST": "10.0.0.2"}), ("10.0.0.2", 3306)) + + def route_runner(self, *, db_dev="tun0", default_dev="eth0", diverted_public=False): + def runner(argv, **kwargs): + self.assertFalse(kwargs.get("shell", False)) + if argv[-2:] == ["show", "default"]: + dev = default_dev + elif argv[-1] == "10.0.0.2": + dev = db_dev + else: + dev = "tun0" if diverted_public else default_dev + return subprocess.CompletedProcess(argv, 0, stdout=json.dumps([{"dev": dev}])) + return runner + + def test_routes_require_database_tunnel_and_public_default(self): + checks.check_routes("10.0.0.2", self.route_runner()) + checks.check_routes("10.0.0.2", self.route_runner(default_dev="tap0")) + for options in ({"db_dev": "eth0"}, {"default_dev": "tun0"}, {"diverted_public": True}): + with self.subTest(options=options), self.assertRaises(checks.CheckFailed): + checks.check_routes("10.0.0.2", self.route_runner(**options)) + + def test_routes_fail_closed_for_missing_or_malformed_ip_output(self): + for output in ("[]", "{}", "[1]", "broken"): + with self.subTest(output=output), self.assertRaises(checks.CheckFailed): + checks.route_data(["get", "10.0.0.2"], lambda *a, **k: subprocess.CompletedProcess([], 0, stdout=output)) + + def test_tcp_error_is_sanitized(self): + def unavailable(*args, **kwargs): + raise OSError("secret test value") + with self.assertRaises(checks.CheckFailed) as failure: + checks.check_tcp("10.0.0.2", 3306, unavailable) + self.assertEqual(failure.exception.error_class, "database_unreachable") + + def test_credentials_accept_private_regular_file_and_reject_symlink_or_public_file(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "credentials.json" + source.write_text(json.dumps({"username": "fixture", "password": "fixture-password", "extra": "ignored"})) + source.chmod(0o600) + self.assertEqual(checks.read_credentials(source), {"username": "fixture", "password": "fixture-password"}) + alias = Path(directory) / "alias.json" + alias.symlink_to(source) + with self.assertRaises(checks.CheckFailed): + checks.read_credentials(alias) + source.chmod(0o644) + with self.assertRaises(checks.CheckFailed): + checks.read_credentials(source) + + def test_verification_only_runs_two_readonly_queries_and_returns_schema_metadata(self): + queries = [] + closed = [] + + class Cursor: + def __enter__(self): return self + def __exit__(self, *args): pass + def execute(self, statement): queries.append(statement) + def fetchone(self): return (1,) + def fetchall(self): return [("z_schema",), ("a_schema",)] + + class Connection: + def cursor(self): return Cursor() + def close(self): closed.append(True) + + def connect(**kwargs): + self.assertEqual(kwargs["user"], "fixture") + self.assertEqual(kwargs["password"], "fixture-password") + self.assertIsNone(kwargs["ssl"]) + return Connection() + + result = checks.check_database("10.0.0.2", 3306, {"username": "fixture", "password": "fixture-password"}, connect) + self.assertEqual(queries, ["SELECT 1", "SHOW DATABASES"]) + self.assertEqual(result, {"schemaNames": ["a_schema", "z_schema"]}) + self.assertEqual(closed, [True]) + + def test_database_errors_never_render_connection_messages(self): + def denied(**kwargs): + raise Exception(1045, "fixture-password is secret") + with self.assertRaises(checks.CheckFailed) as failure: + checks.check_database("10.0.0.2", 3306, {"username": "fixture", "password": "fixture-password"}, denied) + self.assertEqual(failure.exception.error_class, "database_authentication_failed") + self.assertNotIn("fixture-password", str(failure.exception)) + + def test_route_only_mode_does_not_connect_or_load_credentials(self): + output = io.StringIO() + with patch.dict(os.environ, {"DB_HOST": "10.0.0.2", "DB_PORT": "3306"}), \ + patch.object(checks, "check_routes"), \ + patch.object(checks, "check_tcp", side_effect=AssertionError("must not connect")), \ + patch.object(checks, "read_credentials", side_effect=AssertionError("must not read")), \ + contextlib.redirect_stdout(output): + self.assertEqual(checks.main(["health", "--no-connect"]), 0) + self.assertEqual(json.loads(output.getvalue()), {"ok": True, "routes": "ready"}) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/vpn-image/verify.sh b/services/vpn-image/verify.sh new file mode 100755 index 0000000..ef20f79 --- /dev/null +++ b/services/vpn-image/verify.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec python3 /usr/local/lib/channelgate-vpn/checks.py verify "$@" diff --git a/src/gateway/vpn-profile.js b/src/gateway/vpn-profile.js new file mode 100644 index 0000000..225368a --- /dev/null +++ b/src/gateway/vpn-profile.js @@ -0,0 +1,136 @@ +// Deliberately small OpenVPN import format. Never execute an uploaded configuration verbatim: +// reconstruct a client config from allowlisted data, keeping all file paths operator-controlled. +import { isIP } from "node:net"; + +const MAX_PROFILE_BYTES = 256 * 1024; +const MAX_BLOCK_BYTES = 96 * 1024; +const INLINE = new Set(["ca", "cert", "key", "tls-auth", "tls-crypt"]); +const CIPHERS = new Set(["AES-256-GCM", "AES-128-GCM", "AES-256-CBC", "AES-128-CBC", "CHACHA20-POLY1305"]); +function invalid(reason) { throw new Error(`Unsupported VPN profile: ${reason}`); } +function expect(condition, reason) { if (!condition) invalid(reason); } + +export function validateVpnTarget(dbHost, dbPort) { + expect(typeof dbHost === "string" && isIP(dbHost) === 4, "database must be an IPv4 address"); + const first = Number(dbHost.split(".")[0]); + expect(first > 0 && first < 224 && first !== 127, "database address must be unicast and non-loopback"); + expect((typeof dbPort === "number" && Number.isInteger(dbPort)) || (typeof dbPort === "string" && /^[1-9]\d{0,4}$/.test(dbPort)), "invalid database port"); + const port = Number(dbPort); + expect(port > 0 && port <= 65535, "invalid database port"); + return { dbHost, dbPort: port }; +} + +// OpenVPN accepts quoted arguments. Do not copy raw quoted strings into generated directives. +function tokenize(line) { + const words = []; + let word = "", quote = null, started = false; + for (let i = 0; i < line.length; i++) { + const c = line[i]; + if (c === "\\") { + const next = line[++i]; + expect(next === "\\" || next === '"' || next === "'", "unsupported escape"); + word += next; started = true; + } else if (quote) { + if (c === quote) quote = null; + else word += c; + } else if (c === '"' || c === "'") { + quote = c; started = true; + } else if (/\s/.test(c)) { + if (started) { words.push(word); word = ""; started = false; } + } else if ((c === "#" || c === ";") && !started) { + break; + } else { word += c; started = true; } + } + expect(!quote, "unclosed quote"); + if (started) words.push(word); + return words; +} +function quoted(value) { return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } +function hostValid(host) { + if (isIP(host) === 4) return true; + return host.length <= 253 && !/^[\d.]+$/.test(host) && host.split(".").every((part) => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/.test(part)); +} +function validateBlock(kind, body) { + expect(Buffer.byteLength(body) <= MAX_BLOCK_BYTES, "inline block too large"); + if (kind === "tls-auth" || kind === "tls-crypt") { + const data = body.split("\n").filter((line) => !line.trim().startsWith("#") && line.trim()).join("\n"); + expect(/^-----BEGIN OpenVPN Static key V1-----\n(?:[a-fA-F0-9]{32}\n){16}-----END OpenVPN Static key V1-----$/.test(data), "invalid inline TLS key"); + return data; + } + const labels = kind === "key" ? ["PRIVATE KEY", "RSA PRIVATE KEY", "EC PRIVATE KEY"] : ["CERTIFICATE"]; + let remainder = body.trim(), count = 0; + while (remainder) { + const match = /^-----BEGIN ([A-Z ]+)-----\n([A-Za-z0-9+/=\n]+)\n-----END \1-----(?:\n|$)/.exec(remainder); + expect(match && labels.includes(match[1]) && match[2].split("\n").every((line) => /^[A-Za-z0-9+/]+={0,2}$/.test(line)), "invalid inline PEM block"); + count++; + remainder = remainder.slice(match[0].length).trim(); + } + expect(count > 0 && (kind === "ca" || count === 1), "invalid inline PEM count"); + return body.trim(); +} + +export function normalizeVpnProfile(text, { dbHost, dbPort } = {}) { + validateVpnTarget(dbHost, dbPort); + expect(typeof text === "string" && Buffer.byteLength(text) <= MAX_PROFILE_BYTES, "profile must be bounded text"); + text = text.replace(/\r\n/g, "\n"); + expect(!/[\x00-\x08\x0b-\x1f\x7f\u0080-\uffff]/.test(text), "invalid control or non-ASCII character"); + const lines = text.split("\n"), values = new Map(), blocks = new Map(); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith("#") || line.startsWith(";")) continue; + const inline = /^<([a-z-]+)>$/.exec(line); + if (inline) { + const kind = inline[1]; + expect(INLINE.has(kind) && !blocks.has(kind), "unknown or duplicate inline block"); + const body = []; + while (++i < lines.length && lines[i].trim() !== ``) body.push(lines[i]); + expect(i < lines.length, "unclosed inline block"); + blocks.set(kind, validateBlock(kind, body.join("\n"))); + continue; + } + const [name, ...args] = tokenize(line); + if (!name) continue; + expect(!values.has(name), "duplicate directive"); + const single = (pattern) => args.length === 1 && pattern.test(args[0]); + let valid = false; + switch (name) { + case "client": case "nobind": case "persist-key": case "persist-tun": case "auth-user-pass": + case "auth-nocache": case "route-nopull": valid = args.length === 0; break; + case "dev": valid = single(/^tun$/); break; + case "proto": valid = single(/^(tcp|tcp-client)$/); break; + case "remote": valid = args.length >= 2 && args.length <= 3 && hostValid(args[0]) && /^[1-9]\d{0,4}$/.test(args[1]) && Number(args[1]) <= 65535 && (args.length === 2 || /^(tcp|tcp-client)$/.test(args[2])); break; + case "remote-cert-tls": valid = single(/^server$/); break; + case "verify-x509-name": valid = args.length >= 1 && args.length <= 2 && args[0].length > 0 && args[0].length <= 1024 && (args.length === 1 || /^(subject|name|name-prefix)$/.test(args[1])); break; + case "cipher": case "data-ciphers-fallback": valid = args.length === 1 && CIPHERS.has(args[0]); break; + case "data-ciphers": valid = args.length === 1 && args[0].split(":").every((cipher) => CIPHERS.has(cipher)); break; + case "auth": valid = single(/^SHA(256|384|512)$/); break; + case "resolv-retry": valid = single(/^(infinite|[1-9]\d{0,3})$/); break; + case "route-delay": valid = single(/^([0-9]|[1-5][0-9]|60)$/); break; + case "reneg-sec": valid = single(/^(0|[1-9]\d{0,6})$/); break; + case "verb": valid = single(/^[0-6]$/); break; + case "key-direction": valid = single(/^[01]$/); break; + case "route": valid = args.join(" ") === "remote_host 255.255.255.255 net_gateway"; break; + default: invalid("directive is not allowed"); + } + expect(valid, "invalid directive arguments"); + values.set(name, args); + } + for (const name of ["client", "dev", "proto", "remote"]) expect(values.has(name), "missing required client directive"); + for (const name of ["ca", "cert", "key"]) expect(blocks.has(name), "missing inline client identity"); + expect(!(blocks.has("tls-auth") && blocks.has("tls-crypt")), "conflicting TLS key modes"); + expect(!values.has("key-direction") || blocks.has("tls-auth"), "key-direction requires tls-auth"); + const [host, port] = values.get("remote"); + const output = ["client", "dev tun0", "proto tcp-client", `remote ${host} ${port}`, "nobind", "persist-key", "persist-tun", + "auth-user-pass /vpn/auth", "auth-nocache", "route-nopull", "script-security 1", "remote-cert-tls server", "verb 3", + "route remote_host 255.255.255.255 net_gateway", `route ${dbHost} 255.255.255.255 vpn_gateway`]; + for (const name of ["verify-x509-name", "auth", "resolv-retry", "route-delay", "reneg-sec", "key-direction"]) { + if (values.has(name)) output.push(`${name} ${values.get(name).map(quoted).join(" ")}`); + } + const cipher = values.get("cipher")?.[0]; + if (cipher) output.push(`cipher ${cipher}`); + const dataCiphers = values.get("data-ciphers")?.[0] || (cipher?.endsWith("-CBC") ? `AES-256-GCM:AES-128-GCM:${cipher}` : "AES-256-GCM:AES-128-GCM"); + output.push(`data-ciphers ${dataCiphers}`); + const fallback = values.get("data-ciphers-fallback")?.[0] || (cipher?.endsWith("-CBC") ? cipher : null); + if (fallback) output.push(`data-ciphers-fallback ${fallback}`); + for (const [kind, body] of blocks) output.push(`<${kind}>\n${body}\n`); + return { config: `${output.join("\n")}\n`, remote: { host, port: Number(port), proto: "tcp-client" }, hasClientKey: true }; +} diff --git a/src/gateway/vpn-service.js b/src/gateway/vpn-service.js new file mode 100644 index 0000000..3f340a0 --- /dev/null +++ b/src/gateway/vpn-service.js @@ -0,0 +1,218 @@ +// Operator-managed services. Deliberately independent of the engine container lifecycle: +// cg.service.* labels keep these containers out of the ordinary idle reaper and cg-sweep. +import { createHash, createHmac, randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; +import { constants } from "node:fs"; +import { chmod, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +export const SERVICE_LABEL = "cg.service.owner"; +export const SECRET_REFS = Object.freeze({ vpnUsername: "VPN_USERNAME", vpnPassword: "VPN_PASSWORD", mysqlUsername: "MYSQL_USERNAME", mysqlPassword: "MYSQL_PASSWORD" }); + +export function serviceIdentity(root, channelId, project) { + if (!/^[a-z][a-z0-9-]{0,47}$/.test(project)) throw new Error("Project must be a lowercase name of at most 48 characters."); + const owner = createHash("sha256").update(`${root}\0${channelId}`).digest("hex").slice(0,24); + return { owner, vpn: `${project}-vpn`, extractor: `${project}-extractor`, unit: `channelgate-vpn-${owner}.service` }; +} + +// Paths are selected by the operator. Refuse symlinks, including parent components, rather than +// letting an agent's uploaded profile redirect a privileged file read. +export async function plainPath(file) { + const absolute = path.resolve(file); + let current = path.parse(absolute).root; + for (const segment of absolute.slice(current.length).split(path.sep)) { + current = path.join(current, segment); + const info = await lstat(current); + if (info.isSymbolicLink()) throw new Error("Service paths must not contain symbolic links."); + } + return absolute; +} + +export async function readPrivate(file, { maxBytes = 256 * 1024, tighten = false } = {}) { + // Pin directories while walking. O_NOFOLLOW on just the leaf does not stop a writable upload + // parent being swapped for a symlink between lstat and open. + const parts = path.resolve(file).split(path.sep).filter(Boolean); + const parents = [await open(path.parse(path.resolve(file)).root,constants.O_RDONLY | constants.O_DIRECTORY)]; + let handle; + try { + for (const segment of parts.slice(0,-1)) { + parents.push(await open(`/proc/self/fd/${parents.at(-1).fd}/${segment}`,constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)); + } + handle = await open(`/proc/self/fd/${parents.at(-1).fd}/${parts.at(-1)}`, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const info = await handle.stat(); + if (!info.isFile() || info.size > maxBytes) throw new Error("Expected a bounded regular service file."); + if (tighten && (info.uid !== process.getuid() || info.nlink !== 1)) throw new Error("Imported profile must have one link and belong to the operator."); + if (tighten) await handle.chmod(0o600); + const buffer = Buffer.alloc(maxBytes + 1); + let total = 0; + while (total < buffer.length) { + const {bytesRead} = await handle.read(buffer,total,buffer.length-total,null); + if (!bytesRead) break; + total += bytesRead; + } + if (total > maxBytes) throw new Error("Service file exceeds the size limit."); + return buffer.subarray(0,total).toString("utf8"); + } catch (error) { + if (["ELOOP","ENOTDIR"].includes(error.code)) throw new Error("Service paths must not contain symbolic links."); + throw error; + } finally { await handle?.close(); for (const parent of parents.reverse()) await parent.close(); } +} + +export async function privateDirectory(dir) { + // The gateway root already belongs to the operator. Every newly created descendant is private. + const absolute = path.resolve(dir); + const parent = path.dirname(absolute); + if (parent !== absolute) { + try { await plainPath(parent); } catch (error) { + if (error.code !== "ENOENT") throw error; + await privateDirectory(parent); + } + } + await mkdir(absolute, { mode: 0o700 }).catch(error => { if (error.code !== "EEXIST") throw error; }); + await plainPath(absolute); + const info = await lstat(absolute); + if (!info.isDirectory() || info.uid !== process.getuid()) throw new Error("Service directory must belong to the current operator."); + await chmod(absolute, 0o700); + return realpath(absolute); +} + +export async function writePrivate(file, content, mode = 0o600) { + await plainPath(path.dirname(file)); + const temp = `${file}.${randomBytes(8).toString("hex")}.tmp`; + const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode); + try { await handle.writeFile(content); } finally { await handle.close(); } + try { await rename(temp, file); } catch (error) { await unlink(temp).catch(() => {}); throw error; } +} + +export function selectedCredentials(env, refs = SECRET_REFS) { + const selected = {}; + const missing = []; + for (const [role, name] of Object.entries(SECRET_REFS)) { + const ref = refs[role] || name; + if (!/^[A-Z][A-Z0-9_]{0,63}$/.test(ref)) throw new Error("Invalid service secret reference."); + const value = env[ref]; + if (typeof value !== "string" || !value) missing.push(ref); + else if (/[\r\n\0]/.test(value)) throw new Error(`Secret ${ref} contains an unsupported line break.`); + else selected[role] = value; + } + return { selected, missing }; +} + +export function serviceFingerprint(config, credentials, imageId, salt) { + // HMAC prevents the public container label becoming an offline password-guessing oracle. + return createHmac("sha256", salt).update(JSON.stringify({ config, credentials, imageId })).digest("hex"); +} + +export function createArgs({ identity, config, serviceDir, imageId, fingerprint, vpnId = "" }, role) { + if (!["vpn", "extractor"].includes(role)) throw new Error("Unknown service role."); + const args = ["run", "-d", "--name", identity[role], "--label", `${SERVICE_LABEL}=${identity.owner}`, + "--label", `cg.service.role=${role}`, "--label", `cg.service.fingerprint=${fingerprint}`, + "--cap-drop=ALL", "--security-opt=no-new-privileges", "--read-only", "--pids-limit=128", "--memory=256m", + "--tmpfs=/run:rw,noexec,nosuid,size=16m", "--tmpfs=/tmp:rw,noexec,nosuid,size=16m", + "--log-driver=k8s-file", "--log-opt=max-size=2mb", "--restart=no", + "-e", `DB_HOST=${config.dbHost}`, "-e", `DB_PORT=${config.dbPort}`]; + if (role === "vpn") args.push("--network=slirp4netns:allow_host_loopback=false", "--cap-add=NET_ADMIN", "--device=/dev/net/tun", + "--volume", `${path.join(serviceDir,"client.ovpn")}:/vpn/client.ovpn:ro`, + "--volume", `${path.join(serviceDir,"auth")}:/vpn/auth:ro`, + "--health-cmd=/usr/local/bin/cg-vpn-health", "--health-interval=30s", "--health-timeout=8s", "--health-retries=3", "--health-start-period=60s"); + else { + if (!/^[a-f0-9]{12,64}$/.test(vpnId)) throw new Error("Extractor requires the verified VPN container id."); + args.push(`--network=container:${vpnId}`, "--volume", `${path.join(serviceDir,"credentials.json")}:/db/credentials.json:ro`, "--entrypoint=/usr/bin/tini"); + } + args.push(imageId); + if (role === "extractor") args.push("--", "sleep", "infinity"); + return args; +} + +export function runCommand(bin, args, { timeoutMs = 120_000, input, cwd, env = process.env } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(bin, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = "", stderr = ""; + child.stdout.on("data", chunk => { stdout = (stdout + chunk).slice(-1024 * 1024); }); + child.stderr.on("data", chunk => { stderr = (stderr + chunk).slice(-1024 * 1024); }); + let forceTimer; + const timer = setTimeout(() => { + child.kill("SIGTERM"); + forceTimer = setTimeout(()=>child.kill("SIGKILL"),2000); + }, timeoutMs); + child.on("error", () => { clearTimeout(timer); clearTimeout(forceTimer); reject(new Error(`Unable to execute ${path.basename(bin)}.`)); }); + child.on("close", code => { clearTimeout(timer); clearTimeout(forceTimer); resolve({ code, stdout, stderr }); }); + child.stdin.on("error", () => {}); + child.stdin.end(input); + }); +} + +export function createVpnService({ run = runCommand, bin = "/usr/bin/podman", identity, serviceDir, config, imageId }) { + const podman = (args, opts) => run(bin, args, {timeoutMs:15_000,...opts}); + async function inspect(role) { + const exists = await podman(["container", "exists", identity[role]]); + if (exists.code === 1) return null; + if (exists.code !== 0) throw new Error("Could not inspect Podman service containers."); + const result = await podman(["inspect", identity[role]]); + if (result.code !== 0) throw new Error("Service container inspection failed."); + const data = JSON.parse(result.stdout)[0]; + if (data?.Config?.Labels?.[SERVICE_LABEL] !== identity.owner || data?.Config?.Labels?.["cg.service.role"] !== role) { + throw new Error("Container name belongs to another workload; refusing to change it."); + } + return data; + } + async function stop() { + // Prove ownership of BOTH names before touching either one. + const vpn = await inspect("vpn"), extractor = await inspect("extractor"); + for (const data of [extractor, vpn]) if (data) { + const result = await podman(["rm", "--force", data.Id]); + if (result.code !== 0) throw new Error("Could not stop this service's container."); + } + } + async function ready() { + const result = await podman(["exec", identity.vpn, "/usr/local/bin/cg-vpn-health"]); + return result.code === 0; + } + async function routesReady() { + const result = await podman(["exec",identity.extractor,"/usr/local/bin/cg-vpn-routes"]); + return result.code === 0; + } + async function start({ fingerprint, auth, database, profile, attempts = 30, signal, wait = ms => new Promise(resolve => setTimeout(resolve,ms)) }) { + const vpn = await inspect("vpn"), extractor = await inspect("extractor"); + if (vpn?.State?.Running && extractor?.State?.Running && + [vpn, extractor].every(item => item.Config.Labels["cg.service.fingerprint"] === fingerprint) && await ready() && await routesReady()) return { reused: true }; + await stop(); + await writePrivate(path.join(serviceDir,"client.ovpn"), profile); + await writePrivate(path.join(serviceDir,"auth"), auth); + await writePrivate(path.join(serviceDir,"credentials.json"), JSON.stringify(database)); + try { + const result = await podman(createArgs({ identity, config, serviceDir, imageId, fingerprint }, "vpn")); + if (result.code !== 0) throw new Error("VPN container could not start; check rootless TUN support."); + let healthy = false; + for (let n=0; n `<${kind}>\n-----BEGIN ${label}-----\nVEVTVA==\n-----END ${label}-----\n`; +const profile = `client\ndev tun\nproto tcp\nremote vpn.example.test 8443\nverify-x509-name "VPN server" name\nroute remote_host 255.255.255.255 net_gateway\nresolv-retry infinite\nnobind\npersist-key\npersist-tun\nauth-user-pass\ncipher AES-256-CBC\nauth SHA512\nroute-delay 4\nverb 3\nreneg-sec 0\n${pem("ca", "CERTIFICATE")}\n${pem("cert", "CERTIFICATE")}\n${pem("key", "PRIVATE KEY")}\n`; +const target = { dbHost: "10.42.0.7", dbPort: 3306 }; +const normalize = (text = profile, options = target) => normalizeVpnProfile(text, options); + +test("normalizes a legacy TCP client to a database-only generated config", () => { + const result = normalize(); + assert.deepEqual(result.remote, { host: "vpn.example.test", port: 8443, proto: "tcp-client" }); + assert.equal(result.hasClientKey, true); + for (const line of ["dev tun0", "proto tcp-client", "auth-user-pass /vpn/auth", "auth-nocache", "route-nopull", "script-security 1", "remote-cert-tls server", "route 10.42.0.7 255.255.255.255 vpn_gateway", "data-ciphers AES-256-GCM:AES-128-GCM:AES-256-CBC", "data-ciphers-fallback AES-256-CBC", 'verify-x509-name "VPN server" "name"']) { + assert.ok(result.config.split("\n").includes(line), line); + } + assert.equal(result.config.split("\n").filter((line) => line.startsWith("route ")).length, 2); +}); + +test("accepts CRLF, comments, explicit tcp-client and safe escaped certificate identity", () => { + const result = normalize(profile.replace("proto tcp", "proto tcp-client # note").replace('"VPN server"', '"VPN \\"server\\""').replaceAll("\n", "\r\n")); + assert.match(result.config, /verify-x509-name "VPN \\"server\\"" "name"/); +}); + +test("supports an inline TLS static key and checks its direction", () => { + const key = `-----BEGIN OpenVPN Static key V1-----\n${"a".repeat(32)}\n`.replace(/a{32}\n$/, `${(`${"a".repeat(32)}\n`).repeat(16)}`) + "-----END OpenVPN Static key V1-----"; + assert.match(normalize(`${profile}\n# static key\n${key}\n\nkey-direction 1\n`).config, /key-direction "1"/); + assert.match(normalize(`${profile}\n${key}\n\n`).config, //); + assert.throws(() => normalize(`${profile}key-direction 1\n`), /requires tls-auth/); + assert.throws(() => normalize(`${profile}\n${key}\n\n\n${key}\n\n`), /conflicting/); +}); + +test("rejects executable hooks, external files, broad routes and connection overrides without echoing inputs", () => { + for (const directive of ["up /secret-path", "plugin /secret-path", "config /secret-path", "management 127.0.0.1 1", "log /secret-path", "ca /secret-path", "auth-user-pass /secret-path", "redirect-gateway def1", "route 0.0.0.0 0.0.0.0", "script-security 2", "setenv opt up /secret-path", "tls-verify /secret-path", "http-proxy attacker.test 80", "remote-random", "\nremote attacker.test 443\n"]) { + assert.throws(() => normalize(`${profile}${directive}\n`), (error) => error.message.startsWith("Unsupported VPN profile:") && !error.message.includes("secret-path"), directive); + } +}); + +test("rejects malformed quoting, duplicate directives and incompatible transports", () => { + for (const text of [profile + "remote attacker.test 443\n", profile.replace("proto tcp", "proto udp"), profile.replace("dev tun", "dev tap"), profile.replace("8443", "0"), profile.replace("8443", "65536"), profile.replace("vpn.example.test", "-bad.test"), profile.replace("vpn.example.test", "127.000.0.1"), profile.replace('"VPN server"', '"unclosed'), profile.replace('"VPN server"', '"bad\\nname"'), profile + "remote-cert-tls client\n", profile + "cipher BAD\n", profile + "\0"]) { + assert.throws(() => normalize(text), /Unsupported VPN profile/); + } +}); + +test("requires bounded, well-formed inline credentials and never admits hidden directives", () => { + for (const text of [profile.replace(pem("key", "PRIVATE KEY"), ""), profile.replace("", ""), profile + pem("key", "PRIVATE KEY"), profile.replace("VEVTVA==", "up /secret-path"), profile.replace("VEVTVA==", "\nup /secret-path\n"), profile.replace("VEVTVA==", "a".repeat(100_000)), "#".repeat(300_000), profile.replace("client\n", ""), profile.replace("-----END PRIVATE KEY-----", "-----END CERTIFICATE-----")]) { + assert.throws(() => normalize(text), /Unsupported VPN profile/); + } +}); + +test("validates a literal database target and port before rendering", () => { + assert.deepEqual(validateVpnTarget("10.42.0.7", "3306"), target); + for (const dbHost of ["db.example.test", "10.0.0.1\nup /bin/sh", "0.0.0.0", "127.0.0.1", "224.0.0.1", "255.255.255.255", "::1", "10.01.0.1"]) { + assert.throws(() => normalize(profile, { ...target, dbHost }), /database/); + } + for (const dbPort of [0, -1, 65536, 1.5, "3306\n", "", true, null]) { + assert.throws(() => normalize(profile, { ...target, dbPort }), /database port/); + } +}); diff --git a/test/vpn-service.test.js b/test/vpn-service.test.js new file mode 100644 index 0000000..dd9cc5e --- /dev/null +++ b/test/vpn-service.test.js @@ -0,0 +1,124 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, symlink, stat, rm, mkdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { createArgs, createVpnService, SERVICE_LABEL, serviceIdentity, selectedCredentials, serviceFingerprint, privateDirectory, readPrivate, writePrivate, runCommand } from "../src/gateway/vpn-service.js"; + +const identity = serviceIdentity("/private/gateway","C-TEST","test-database"); +const config = {dbHost:"10.20.30.40",dbPort:3306}; +const id = "a".repeat(64); +const fingerprint = "reviewed"; +const owned = (role,running = true) => ({Id:role === "vpn" ? id : "b".repeat(64),Config:{Labels:{[SERVICE_LABEL]:identity.owner,"cg.service.role":role,"cg.service.fingerprint":fingerprint}},State:{Running:running,Status:running?"running":"exited"},HostConfig:{NetworkMode:`container:${id}`}}); +function fake({foreign=false,healthy=true,existing=false} = {}) { + const calls = [], containers = new Map(existing ? [[identity.vpn,owned("vpn")],[identity.extractor,owned("extractor")]] : []); + if(foreign)containers.set(identity.extractor,{...owned("extractor"),Config:{Labels:{}}}); + const run = async (_bin,args) => { + calls.push(args); + if(args[0]==="container")return {code:containers.has(args[2])?0:1}; + if(args[0]==="inspect")return {code:0,stdout:JSON.stringify([containers.get(args[1])])}; + if(args[0]==="rm"){for(const [name,data] of containers)if(data.Id===args[2])containers.delete(name);return {code:0};} + if(args[0]==="run"){ + const name=args[args.indexOf("--name")+1]; + containers.set(name,owned(name===identity.vpn?"vpn":"extractor"));return {code:0,stdout:id}; + } + if(args[0]==="exec")return {code:healthy?0:1,stdout:'{"select1":true}'}; + throw new Error("Unexpected fake command"); + }; + return {calls,containers,run}; +} + +test("privilege and secret separation: only VPN receives TUN/NET_ADMIN; no host socket, publish or engine label",()=>{ + const opts={identity,config,serviceDir:"/private/service",imageId:"sha256:"+id,fingerprint,vpnId:id}; + const vpn=createArgs(opts,"vpn"), extractor=createArgs(opts,"extractor"); + assert.ok(vpn.includes("--cap-add=NET_ADMIN"));assert.ok(vpn.includes("--device=/dev/net/tun")); + assert.ok(vpn.includes("--network=slirp4netns:allow_host_loopback=false")); + assert.ok(extractor.includes(`--network=container:${id}`)); + for(const args of [vpn,extractor]) { + assert.ok(args.includes("--cap-drop=ALL"));assert.ok(args.includes("--read-only")); + assert.doesNotMatch(args.join(" "),/cg\.install=|docker\.sock|podman\.sock|--privileged|--publish|--network=host/); + } + assert.doesNotMatch(extractor.join(" "),/NET_ADMIN|\/dev\/net\/tun|client.ovpn|\/vpn\/auth/); + assert.doesNotMatch(vpn.join(" "),/credentials.json/); +}); + +test("credential selection never includes unrelated channel variables or permits auth-file injection",()=>{ + const env={VPN_USERNAME:"user",VPN_PASSWORD:"password",MYSQL_USERNAME:"reader",MYSQL_PASSWORD:"mysqlpass",UNRELATED_TOKEN:"excluded"}; + const {selected,missing}=selectedCredentials(env); + assert.deepEqual(missing,[]);assert.equal(Object.keys(selected).length,4); + assert.ok(!JSON.stringify(selected).includes("excluded")); + assert.deepEqual(selectedCredentials({}).missing,["VPN_USERNAME","VPN_PASSWORD","MYSQL_USERNAME","MYSQL_PASSWORD"]); + assert.throws(()=>selectedCredentials({...env,VPN_PASSWORD:"password\ninjected"}),/line break/); + assert.notEqual(serviceFingerprint(config,selected,id,"private1"),serviceFingerprint(config,selected,id,"private2")); +}); + +test("foreign ownership is refused before either workload can be removed",async()=>{ + const f=fake({foreign:true,existing:true}); + const service=createVpnService({...f,identity,config,serviceDir:"/unused",imageId:id}); + await assert.rejects(service.stop(),/another workload/); + assert.equal(f.calls.filter(args=>args[0]==="rm").length,0); +}); + +test("healthy unchanged service is reused without rewriting mounted credential files",async()=>{ + const f=fake({existing:true}); + const service=createVpnService({...f,identity,config,serviceDir:"/unused",imageId:id}); + assert.deepEqual(await service.start({fingerprint}),{reused:true}); + assert.equal(f.calls.filter(args=>["rm","run"].includes(args[0])).length,0); +}); + +test("VPN readiness failure never starts an extractor and removes only owned service containers",async()=>{ + const dir=await mkdtemp(path.join(os.tmpdir(),"cg-vpn-service-")); + try { + const f=fake({healthy:false}); + const service=createVpnService({...f,identity,config,serviceDir:dir,imageId:id}); + await assert.rejects(service.start({fingerprint,auth:"u\np\n",database:{username:"r",password:"p"},profile:"client\n",attempts:1,wait:async()=>{}}),/did not become ready/); + assert.equal(f.calls.filter(args=>args[0]==="run").length,1); + assert.equal(f.containers.size,0); + assert.equal((await stat(path.join(dir,"auth"))).mode&0o777,0o600); + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test("credential rotation recreates extractor first, then VPN, before mounting new auth files",async()=>{ + const dir=await mkdtemp(path.join(os.tmpdir(),"cg-vpn-service-")); + try { + const f=fake({existing:true}); + const service=createVpnService({...f,identity,config,serviceDir:dir,imageId:id}); + const result=await service.start({fingerprint:"rotated",auth:"u\nnew\n",database:{username:"r",password:"new"},profile:"client\n"}); + assert.deepEqual(result,{reused:false}); + assert.deepEqual(f.calls.filter(args=>args[0]==="rm").map(args=>args[2]),["b".repeat(64),id]); + assert.equal(f.calls.filter(args=>args[0]==="run").length,2); + assert.equal(await readFile(path.join(dir,"auth"),"utf8"),"u\nnew\n"); + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test("profile staging refuses symlinks and tightens regular files without following targets",async()=>{ + const dir=await mkdtemp(path.join(os.tmpdir(),"cg-vpn-service-")); + try { + await privateDirectory(path.join(dir,"private")); + const target=path.join(dir,"private","profile"); + await writePrivate(target,"sensitive"); + await symlink(target,path.join(dir,"link")); + await assert.rejects(readPrivate(path.join(dir,"link")),/symbolic links/); + await mkdir(path.join(dir,"real"));await symlink(path.join(dir,"real"),path.join(dir,"parent-link")); + await assert.rejects(privateDirectory(path.join(dir,"parent-link","nested")),/symbolic links/); + assert.equal(await readPrivate(target),"sensitive"); + assert.equal((await stat(target)).mode&0o777,0o600); + } finally {await rm(dir,{recursive:true,force:true});} +}); + + +test("command timeout forcibly ends a process that ignores SIGTERM", { timeout: 10_000 }, async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), "cg-vpn-timeout-")); + const marker = path.join(dir, "signal-state"); + try { + const result = await runCommand(process.execPath, ["--input-type=module", "-e", ` + import { writeFileSync } from "node:fs"; + const marker = process.argv[1]; + process.on("SIGTERM", () => writeFileSync(marker, "term-ignored")); + writeFileSync(marker, "ready"); + setInterval(() => {}, 1000); + `, marker], { timeoutMs: 1000 }); + assert.equal(result.code, null, "the child must end by a signal, not a successful exit"); + assert.equal(await readFile(marker, "utf8"), "term-ignored", "SIGTERM was handled before SIGKILL ended the process"); + } finally { await rm(dir, { recursive: true, force: true }); } +});