From 3d5d7c33cfed78d01f227877075f1311655b8687 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 18 Sep 2026 14:24:10 +0300 Subject: [PATCH 1/2] feat: control configured channel VPN from chat and settings Signed-off-by: Tiberiu Socaci --- CHANGELOG.md | 4 + FEATURES.md | 14 +- TEST-PLAN.md | 31 +++- docs/CHANNEL-VPN.md | 32 +++- public/app.js | 74 +++++++- public/index.html | 11 +- scripts/channel-vpn.mjs | 34 +++- src/gateway/channel-vpn-control.js | 110 ++++++++++++ .../references/administration.md | 7 + src/gateway/mcp-catalog.js | 2 + src/gateway/vpn-service.js | 53 +++++- src/mcp/gateway-server.js | 12 +- src/mcp/tools/channel-admin.js | 23 +++ src/slack/app.js | 84 ++++++++- src/slack/channel-settings.js | 60 ++++++- src/web/routes/channels.js | 31 ++++ test/channel-env.test.js | 4 +- test/channel-settings-modal.test.js | 4 +- test/channel-vpn-control.test.js | 141 +++++++++++++++ test/channel-vpn-web.test.js | 166 ++++++++++++++++++ test/channel-workdir-ui.test.js | 4 +- test/mcp-control-plane-approval.test.js | 4 +- test/slack-vpn-settings.test.js | 166 ++++++++++++++++++ 23 files changed, 1032 insertions(+), 39 deletions(-) create mode 100644 src/gateway/channel-vpn-control.js create mode 100644 test/channel-vpn-control.test.js create mode 100644 test/channel-vpn-web.test.js create mode 100644 test/slack-vpn-settings.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 768d01f..87030d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog — ChannelGate +- Control a prepared channel VPN through the agent, the web channel Network controls, and Slack + Settings → Network. Managers/admins can turn it on/off; status distinguishes connecting from + connected and reports safe certificate/authentication errors. Stopping also cleans up manual starts. + - 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. diff --git a/FEATURES.md b/FEATURES.md index 0d335ca..e0335f0 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -12,10 +12,16 @@ - 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`, +- After operator provisioning, channel managers/admins can turn VPN on/off through Claude or + Codex, the web channel Network controls, or Slack Settings → Network. Admitted members can read + status. All paths use the same fixed helper, current authorization and sanitized diagnostics. +- Status distinguishes automatic startup, connecting, connected and failed; readiness is freshly + checked. OFF also cleans up manually started owned containers. Network off blocks startup and + stops a supervised pair. No arbitrary commands, profile import or SQL extraction are granted + through these controls. See `docs/CHANNEL-VPN.md` for setup and limitations. + +Regression: `test/channel-vpn-control.test.js`, `test/channel-vpn-web.test.js`, +`test/slack-vpn-settings.test.js`, `test/vpn-profile.test.js`, `test/vpn-service.test.js`, `services/vpn-image/test_checks.py`; live isolation: `services/vpn-image/live_acceptance.py`. ## System health diff --git a/TEST-PLAN.md b/TEST-PLAN.md index 97c834a..24d4bf5 100644 --- a/TEST-PLAN.md +++ b/TEST-PLAN.md @@ -1,6 +1,29 @@ # ChannelGate — Test Plan -## Optional isolated VPN database service (operator-only, engine-independent) +## VPN controls through agents, web and Slack + +- [x] `test/channel-vpn-control.test.js`: current-channel-only tools, fresh admission/management + checks, queued revocation, serialized toggles, secret-safe responses, start/connected + distinction, lost readiness and OFF cleanup of supervised/manual owned containers. +- [x] `test/channel-vpn-web.test.js`: active admin session, CSRF and narrow boolean payload; + real Chromium channel switch, immediate save, missing setup/Secrets, Network off, + connecting/failure refresh, and manual-start OFF while Network is disabled. +- [x] `test/slack-vpn-settings.test.js`: Network tab, signed owner/channel-bound actions, + current membership/manager revocation, stale views, status states and manual-start OFF. +- [x] Live Claude and Codex fixture: ask each engine to read status, enable, read starting status, + and disable using the actual MCP handlers with an isolated injected service. Require tool + invocations bound to the fixture channel and no claim that starting means connected. + Passed 2026-09-18 with Claude CLI 2.1.265 and Codex CLI 0.153.4: real MCP handlers/controller, + injected service only, channel `C_VPN_LIVE_FIXTURE`; exact read → enable → read → disable → + revoked enable sequence returned off → starting → starting → off → denied. Both engines + executed exactly two allowed mutations, with no secret sentinel exposure. No provider or + Slack traffic was sent by this fixture. +- [ ] Private installed-service acceptance: use web and Slack controls against the prepared unit; + require certificate failures to appear safely and OFF to leave no owned containers. Once the + provider certificate is fixed, require actual connection and database readiness. An isolated + engine/controller fixture cannot establish provider connection success. + +## Optional isolated VPN database service (operator provisioning, 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. @@ -32,8 +55,10 @@ rootless Podman; never grant host runtime access to a chat agent for this fixtur 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. +Provider credentials are now present in the private acceptance fixture. Connection was attempted +but is blocked by the VPN server certificate missing its required Key Usage extension. No SQL +verification has run. Keep server verification enabled; provider connection/restart gates remain +open. The kernel isolation fixture is not a substitute for those connection checks. ## System health — engine-independent acceptance diff --git a/docs/CHANNEL-VPN.md b/docs/CHANNEL-VPN.md index 4d0ebef..61ca9a4 100644 --- a/docs/CHANNEL-VPN.md +++ b/docs/CHANNEL-VPN.md @@ -3,8 +3,8 @@ 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. +service container and no published port. Provisioning is operator-only; a channel manager or +organization admin can then switch the prepared service on/off through chat or settings. 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 @@ -13,6 +13,31 @@ home mount. A firewall permits only the configured database IPv4 address and TCP 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. +## Use from chat and settings + +After the operator completes setup below, use any of these controls: + +- Ask the channel agent to “turn VPN on”, “turn VPN off”, or “check VPN status”. Claude and Codex + use `set_channel_vpn({enabled:true|false})` and `get_channel_vpn_status` for the current channel. +- In the admin web UI, open the channel and use **VPN** beside **Network**. Changes save immediately. +- In Slack, open the channel's **Settings → Network** tab, then **Turn VPN on/off** or **Refresh**. + +Channel managers and organization admins may switch it; admitted members may read its status. +Tool calls retain the gateway's normal control-plane approval policy. Every mutation rechecks +current access at the effect boundary. The web interface requires an active admin session. +Uploading an `.ovpn` file and adding Secrets alone does not perform the operator setup. + +ON enables automatic startup and starts connecting. **Starting** is not **Connected**: connected +requires both containers, a working tunnel and database route. OFF disables automatic startup and +removes the owned pair, including containers previously started manually. Network off or missing +Secrets prevent startup but never prevent stopping. The supervisor also stops an active pair when +Network is disabled. Status shows only fixed diagnostic messages and missing secret names; +provider logs, profile keys and credential values never appear in these controls. + +A server certificate missing the required Key Usage extension is a provider configuration error. +Correct the VPN server certificate; do not disable `remote-cert-tls server` to bypass verification. +The tunnel serves only the dedicated database extractor, not the ordinary agent container. + ## Configure and start Run as the OS account owning the gateway and its rootless Podman runtime, with its user systemd @@ -113,3 +138,6 @@ connectivity, extractor isolation and tunnel-loss blocking, then removes only th 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. + +After upgrading gateway code that changes the VPN helper, rerun `install-unit` for each configured +channel to refresh its protected supervisor bundle. This does not enable or start the service. diff --git a/public/app.js b/public/app.js index 2648343..96c22bb 100644 --- a/public/app.js +++ b/public/app.js @@ -61,10 +61,9 @@ let CONV_COSTS = null; // { byId: {channelId→cost}, bySlug: {slug→cost} }; n let convCostsFetched = false; let detailDirty = false; // whether the open conversation detail has unsaved edits (drives the savebar) // Controls that save through their OWN request are never part of a card's "Unsaved changes" state. -// The per-conversation environment secrets are the case that exists: write-only values stored the -// moment "Save variable" is pressed (they must never round-trip through the card's Save), so typing -// in them — or storing one — must not tell the admin the card has edits waiting. -const SELF_SAVING_CONTROLS = ".channel-env-card"; +// Environment secrets and VPN control have independent writes and must never round-trip through +// the card's Save or tell the admin the card has edits waiting. +const SELF_SAVING_CONTROLS = ".channel-env-card, .ch-vpn-controls"; const viewLoaded = {}; const EFFORT_OPTIONS = { @@ -1401,6 +1400,71 @@ function wireChecksTools(box, filterInput, countEl) { return refresh; } +// Mount only for the selected conversation. Listing channels never probes their services. +function mountChannelVpnControls(card, channelId) { + const toggle = card.querySelector(".ch-vpn-enabled"); + const status = card.querySelector(".ch-vpn-state"); + const errorBox = card.querySelector(".ch-vpn-error"); + const refresh = card.querySelector(".ch-vpn-refresh"); + const endpoint = `/api/channels/${encodeURIComponent(channelId)}/vpn`; + let snapshot = null; + let pending = false; + let timer; + const paint = () => { + const canStop = !!(snapshot?.enabled || snapshot?.running); + toggle.checked = canStop; + const cannotStart = !snapshot?.allowNetwork || !!snapshot?.missingSecrets?.length; + toggle.disabled = pending || !snapshot?.configured || !!snapshot?.busy + || snapshot.state === "unavailable" || (!canStop && cannotStart); + refresh.disabled = pending; + if (snapshot) { + const labels = { unconfigured: "Not configured", unavailable: "Unavailable", off: "Off", starting: "Starting", on: "Connected", stopping: "Stopping", failed: "Failed" }; + const parts = [labels[snapshot.state] || "Unknown", snapshot.message]; + if (!snapshot.configured) parts.push("An administrator must import the VPN profile and prepare the channel’s VPN service first."); + if (snapshot.missingSecrets?.length) parts.push(`Add in Environment: ${snapshot.missingSecrets.join(", ")}.`); + if (snapshot.configured && !snapshot.allowNetwork) parts.push("Enable Network and save the channel before starting VPN."); + status.textContent = parts.filter(Boolean).join(" · "); + } + }; + const scheduleRefresh = () => { + clearTimeout(timer); + if (card.isConnected && ["starting", "stopping"].includes(snapshot?.state)) { + timer = setTimeout(() => { if (card.isConnected) void request(); }, 2000); + } + }; + const request = async (enabled) => { + if (pending || !card.isConnected) return; + pending = true; + clearTimeout(timer); + errorBox.hidden = true; + paint(); + try { + snapshot = await api(endpoint, typeof enabled === "boolean" + ? { method: "PUT", body: JSON.stringify({ enabled }) } : undefined); + } catch (error) { + errorBox.textContent = `VPN request failed: ${error.message}`; + errorBox.hidden = false; + if (typeof enabled === "boolean") { + // A lost response may follow an accepted write. Reconcile before offering another toggle. + try { snapshot = await api(endpoint); } catch { snapshot = null; } + } else { + snapshot = null; + } + if (!snapshot) { + status.textContent = "VPN status unavailable. Refresh to retry."; + } + } finally { + pending = false; + paint(); + scheduleRefresh(); + } + }; + toggle.addEventListener("change", () => { void request(toggle.checked); }); + refresh.addEventListener("click", () => { void request(); }); + void request(); + return request; +} + function renderChannelDetail(ch) { const detail = document.getElementById("channel-detail"); detailDirty = false; @@ -1843,6 +1907,7 @@ function renderChannelDetail(ch) { // The server response is the validated, committed record. Reconcile the cached channel from // that whole record so a later SPA re-render cannot resurrect stale MCP/skill selections. ch.meta = reconcileChannelMeta(ch.meta, result.meta); + void refreshVpn(); skillsPicker?.update({ selected: ch.meta.skills || [] }); const acceptedGuests = channelGuestAcceptedIds( usersBox.dataset.ready === "1", @@ -1988,6 +2053,7 @@ function renderChannelDetail(ch) { detail.innerHTML = ""; detail.appendChild(node); + const refreshVpn = mountChannelVpnControls(card, ch.channelId); } // ── Reusable config editor (Access Templates / custom DM) ──────────────────────── diff --git a/public/index.html b/public/index.html index 8af1580..26fec7e 100644 --- a/public/index.html +++ b/public/index.html @@ -1117,7 +1117,16 @@

Networktells the engine whether this channel is meant to use the network; the container itself stays on the bridge network until the egress proxy ships — editable by admins and channel managers in Slack Access settings - +
+ +

Loading VPN status…

+ + +

Guest access — named users

diff --git a/scripts/channel-vpn.mjs b/scripts/channel-vpn.mjs index 3c9bbe3..17a3787 100644 --- a/scripts/channel-vpn.mjs +++ b/scripts/channel-vpn.mjs @@ -9,7 +9,7 @@ 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"; + plainPath, privateDirectory, readPrivate, writePrivate, runCommand, vpnUnitStatus, vpnFailureMessage, disableVpnUnit } from "../src/gateway/vpn-service.js"; const bundleRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const args = process.argv.slice(2); @@ -52,9 +52,9 @@ async function main() { 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); + const mutations = !["status","verify"].includes(action); if (mutations) await privateDirectory(dir); - const lock = path.join(dir,"operation.lock"); + const lock = path.join(dir,["enable","disable"].includes(action) ? "control.lock" : "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. @@ -104,7 +104,13 @@ async function main() { 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)); + const runtime = await service.status(); + if (runtime.vpn.state === "running" && runtime.extractor.state === "running") runtime.ready = await service.ready() && await service.routesReady(); + let last = {}; + try { last = JSON.parse(await readPrivate(path.join(dir,"status.json"),{maxBytes:4096})); } catch { /* absent/invalid status has no authority */ } + const unit = await runCommand("/usr/bin/systemctl",["--user","show",identity.unit,"--property=LoadState,ActiveState,UnitFileState"],{timeoutMs:10_000}).catch(() => ({code:1,stdout:""})); + const control = vpnUnitStatus(unit.stdout,runtime,last,unit.code === 0); + console.log(JSON.stringify({...runtime,credentials:inventory(meta,channelEnv,config.secrets),unit:identity.unit,control},null,2)); return; } const info = await runCommand("/usr/bin/podman",["info","--format","{{.Host.Security.Rootless}}"]); @@ -150,8 +156,13 @@ async function main() { 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.`); + if (action === "enable") { + const result = await runCommand("/usr/bin/systemctl",["--user","enable","--now",identity.unit],{timeoutMs:210_000}); + if (result.code !== 0) throw new Error("Service enable failed; check its status."); + } else { + await disableVpnUnit({unit:identity.unit,stopArgs:[fileURLToPath(import.meta.url),"stop","--channel",opts.channel,"--gateway-source",source]}); + await writePrivate(path.join(dir,"status.json"),JSON.stringify({state:"off"})); + } 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."); @@ -173,8 +184,10 @@ async function main() { 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}}); + await writePrivate(path.join(dir,"status.json"),JSON.stringify({state:"starting"})); try { + const result = await runtime.start({fingerprint,profile,signal:shutdown.signal,auth:`${selected.vpnUsername}\n${selected.vpnPassword}\n`,database:{username:selected.mysqlUsername,password:selected.mysqlPassword}}); + await writePrivate(path.join(dir,"status.json"),JSON.stringify({state:"on"})); console.log(JSON.stringify({...result,...(await runtime.status())},null,2)); if (action === "supervise") { while (!shutdown.signal.aborted) { @@ -184,10 +197,15 @@ async function main() { shutdown.signal.addEventListener("abort",done,{once:true}); }); if (shutdown.signal.aborted) break; + const currentMeta = await store.getChannelMeta(entry.slug); + if (!currentMeta?.allowNetwork) throw Object.assign(new Error(vpnFailureMessage("network_disabled")),{vpnErrorClass:"network_disabled"}); 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."); + if (state.vpn.state !== "running" || state.extractor.state !== "running" || !await runtime.routesReady() || !await runtime.ready()) throw Object.assign(new Error(vpnFailureMessage("connection_lost")),{vpnErrorClass:"connection_lost"}); } } + } catch (error) { + await writePrivate(path.join(dir,"status.json"),JSON.stringify({state:"failed",errorClass:error.vpnErrorClass || "startup_failed"})); + throw error; } finally { if (action === "supervise") await runtime.stop(); } } } diff --git a/src/gateway/channel-vpn-control.js b/src/gateway/channel-vpn-control.js new file mode 100644 index 0000000..86a8b39 --- /dev/null +++ b/src/gateway/channel-vpn-control.js @@ -0,0 +1,110 @@ +// The sole chat/web control path: fixed helper + channel identity, never caller-supplied +// commands, paths, profile content or credentials. Provisioning stays operator-only. +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { getChannelEntry, getChannelMeta } from "../config/store.js"; +import { listChannelEnv } from "../config/channel-env.js"; +import { gatewayRoot } from "../config/paths.js"; +import { logEvent } from "../util/logger.js"; +import { acquireKeyedLock } from "../util/keyed-lock.js"; +import { runCommand, SECRET_REFS, vpnFailureMessage } from "./vpn-service.js"; + +const helper = fileURLToPath(new URL("../../scripts/channel-vpn.mjs", import.meta.url)); +const states = new Set(["off", "starting", "on", "stopping", "failed"]); +const fail = (message, statusCode = 409) => Object.assign(new Error(message), { statusCode }); + +function helperEnv() { + const env = { CHANNELGATE_DIR: gatewayRoot() }; + for (const name of ["HOME", "USER", "LOGNAME", "PATH", "LANG", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "CHANNELGATE_DB", "CG_WORKSPACE_DIR"]) { + if (process.env[name]) env[name] = process.env[name]; + } + return env; +} + +export function createChannelVpnControl({ + entryFor = getChannelEntry, metaFor = getChannelMeta, inventory = listChannelEnv, + execute = (action, channelId) => runCommand(process.execPath, [helper, action, "--channel", channelId], { + cwd: path.dirname(path.dirname(helper)), env: helperEnv(), timeoutMs: action === "status" ? 45_000 : 350_000, + }), + audit = logEvent, +} = {}) { + const pending = new Map(); + const reads = new Map(); + + async function context(channelId) { + if (typeof channelId !== "string" || !/^[A-Za-z0-9:_-]{1,100}$/.test(channelId)) throw fail("Invalid channel.", 400); + const entry = await entryFor(channelId); + const meta = entry && await metaFor(entry.slug); + if (!entry || !meta) throw fail("Channel is not registered.", 404); + const configured = meta.vpnService?.version === 1; + const names = new Set(inventory(meta).map(item => item.name)); + const refs = { ...SECRET_REFS, ...meta.vpnService?.secrets }; + // Metadata is operator-owned, but never let malformed refs become response content. + const selected = Object.keys(SECRET_REFS).map(role => refs[role]); + if (selected.some(name => typeof name !== "string" || !/^[A-Z][A-Z0-9_]{0,63}$/.test(name))) throw fail("VPN secret configuration is invalid."); + return { entry, meta, base: { configured, allowNetwork: meta.allowNetwork === true, + missingSecrets: configured ? selected.filter(name => !names.has(name)) : [], enabled: false, running: false, busy: false } }; + } + + async function readStatus(channelId) { + const { base } = await context(channelId); + if (!base.configured) return { ...base, state: "unconfigured", message: "VPN is not configured. An administrator must import the profile and prepare the service first." }; + let raw; + try { + const result = await execute("status", channelId); + if (result.code !== 0) throw new Error("unavailable"); + raw = JSON.parse(result.stdout); + } catch { + return { ...base, state: "unavailable", message: "VPN status is unavailable. Check the host VPN service." }; + } + const c = raw?.control; + if (!c || c.available !== true || c.installed !== true) return { ...base, state: "unavailable", message: "VPN service is not installed or its service manager is unavailable. Ask an administrator to finish setup." }; + const state = states.has(c.state) ? c.state : "failed"; + const message = state === "failed" ? vpnFailureMessage(c.errorClass) : { + on: "VPN connected. The isolated database service is ready.", + off: "VPN is off.", starting: "VPN is connecting. The connection is not ready yet.", stopping: "VPN is stopping.", + }[state]; + return { ...base, enabled: c.enabled === true, running: c.running === true, state, message, + busy: pending.has(channelId) || state === "stopping" }; + } + + async function getStatus(channelId) { + if (reads.has(channelId)) return reads.get(channelId); + const promise = readStatus(channelId).finally(() => reads.delete(channelId)); + reads.set(channelId, promise); + return promise; + } + + async function setEnabled(channelId, enabled, { actor = "", source = "", authorize = async () => false } = {}) { + if (typeof enabled !== "boolean") throw fail("enabled must be a boolean.", 400); + if (!await authorize()) throw fail("Only this channel's managers or an administrator can control its VPN.", 403); + const release = await acquireKeyedLock("channel-vpn-control", channelId); + try { + const { entry, base } = await context(channelId); + if (!base.configured) throw fail("VPN is not configured. Ask an administrator to import the profile and prepare the service first."); + if (enabled && !base.allowNetwork) throw fail("Turn on Network for this channel before enabling VPN."); + if (enabled && base.missingSecrets.length) throw fail(`Missing channel Secrets: ${base.missingSecrets.join(", ")}.`); + // A permission revoked while waiting for another operation must win at the effect boundary. + if (!await authorize()) throw fail("Your permission to control this channel's VPN has changed.", 403); + pending.set(channelId, enabled); + await audit("channel_vpn_requested", { channel: channelId, slug: entry.slug, author: actor, source, enabled }); + let result; + try { result = await execute(enabled ? "enable" : "disable", channelId); } + catch { throw fail("Could not control the VPN service. Refresh its status before retrying.", 503); } + if (result.code !== 0) { + await audit("channel_vpn_control_failed", { channel: channelId, slug: entry.slug, author: actor, source, enabled }); + throw fail("Could not change VPN state. Check the service setup and refresh its status.", 503); + } + pending.delete(channelId); + // Do not return an in-flight read taken before the command, and never equate enabled with connected. + const status = await readStatus(channelId); + await audit("channel_vpn_controlled", { channel: channelId, slug: entry.slug, author: actor, source, enabled, state: status.state }); + return status; + } finally { pending.delete(channelId); release(); } + } + return { getStatus, setEnabled }; +} + +const controls = createChannelVpnControl(); +export const getChannelVpnStatus = controls.getStatus; +export const setChannelVpnEnabled = controls.setEnabled; diff --git a/src/gateway/gateway-usage/references/administration.md b/src/gateway/gateway-usage/references/administration.md index 893bc40..3ebb8bd 100644 --- a/src/gateway/gateway-usage/references/administration.md +++ b/src/gateway/gateway-usage/references/administration.md @@ -102,6 +102,13 @@ Every run remains inside its channel container. - `set_channel_admin_mode` (admin) — for **admin authors**, every tool without prompts (`--dangerously-skip-permissions`). Non-admin authors get Worker with the selected Auto/Lean options. Still inside the container — see "Admin access & the container" below. +- `get_channel_vpn_status` — read this channel's prepared VPN status without secrets. +- `set_channel_vpn` (managers/admins) — `{enabled:true}` starts the prepared VPN and enables + automatic startup; `{enabled:false}` stops it and disables automatic startup. Use these tools + when asked to turn VPN on/off, then check status. “Starting” is not a successful connection. + Setup remains operator-only: an uploaded profile plus Secrets alone is insufficient. Report + the returned setup/certificate error; never bypass server verification or grant shell privileges. + This VPN connects only the dedicated database extractor, not the agent's ordinary container. - `set_channel_network` (admin) — record whether this channel is meant to have network access (needs Bash on to be useful) so `git`/`gh`/`curl` and deploy CLIs may be used; the engines are told the answer (Codex read mode refuses network on its own). There is no per-domain allow-list diff --git a/src/gateway/mcp-catalog.js b/src/gateway/mcp-catalog.js index ef7f59c..b9f8f3e 100644 --- a/src/gateway/mcp-catalog.js +++ b/src/gateway/mcp-catalog.js @@ -79,6 +79,8 @@ export const GATEWAY_TOOL_NAMES = [ "set_channel_admin_mode", "set_channel_bash", "set_channel_network", + "get_channel_vpn_status", + "set_channel_vpn", "set_channel_auto_mode", "get_channel_workdir", "set_channel_workdir", diff --git a/src/gateway/vpn-service.js b/src/gateway/vpn-service.js index 3f340a0..0c35a9a 100644 --- a/src/gateway/vpn-service.js +++ b/src/gateway/vpn-service.js @@ -9,6 +9,51 @@ 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" }); +// Only these fixed diagnostics may leave the host. Provider logs never ride status responses. +const VPN_FAILURES = Object.freeze({ + server_certificate_usage: "The VPN server certificate is missing the required Key Usage extension. Ask the VPN administrator to correct its certificate.", + server_certificate_invalid: "The VPN server certificate could not be verified. Check the server certificate and supplied profile.", + authentication_failed: "VPN authentication failed. Check this channel's VPN credentials.", + tls_failed: "VPN TLS negotiation failed. Check the server certificate and profile compatibility.", + network_disabled: "VPN stopped because Network is disabled for this channel.", + startup_failed: "VPN did not become ready. Check credentials, server compatibility and the database route.", + connection_lost: "VPN lost its route or service container and was stopped. Check the connection before restarting.", +}); +export function vpnFailureMessage(code) { + return Object.hasOwn(VPN_FAILURES,code) ? VPN_FAILURES[code] : VPN_FAILURES.startup_failed; +} +export function classifyVpnFailure(logs = "") { + if (/VERIFY KU ERROR|Certificate does not have key usage extension/.test(logs)) return "server_certificate_usage"; + if (/VERIFY ERROR|certificate verify failed/.test(logs)) return "server_certificate_invalid"; + if (/AUTH_FAILED/.test(logs)) return "authentication_failed"; + if (/TLS Error|TLS handshake failed/.test(logs)) return "tls_failed"; + return "startup_failed"; +} +export function vpnUnitStatus(stdout = "", runtime = {}, last = {}, available = true) { + const fields = Object.fromEntries(stdout.split(/\r?\n/).filter(line => line.includes("=")).map(line => { + const i = line.indexOf("="); return [line.slice(0,i),line.slice(i+1)]; + })); + const installed = fields.LoadState === "loaded"; + const enabled = ["enabled", "enabled-runtime"].includes(fields.UnitFileState); + let state = "off"; + if (fields.ActiveState === "failed") state = "failed"; + else if (fields.ActiveState === "deactivating") state = "stopping"; + else if (["active","activating","reloading"].includes(fields.ActiveState)) { + state = runtime.vpn?.state === "running" && runtime.extractor?.state === "running" && runtime.ready === true ? "on" : last.state === "on" ? "failed" : "starting"; + } else if (runtime.vpn?.state === "running" || runtime.extractor?.state === "running") state = "failed"; + return { available, installed, enabled, running: [runtime.vpn, runtime.extractor].some(item => item?.state && item.state !== "absent"), state, + errorClass: state === "failed" && Object.hasOwn(VPN_FAILURES,last.errorClass) ? last.errorClass : state === "failed" ? last.state === "on" ? "connection_lost" : "startup_failed" : null }; +} + +// Stop the supervisor first, then acquire the ordinary operation lock through the helper. +// This also removes a pair created by the supported manual `start` command. +export async function disableVpnUnit({ unit, stopArgs, run = runCommand }) { + const disabled = await run("/usr/bin/systemctl", ["--user", "disable", "--now", unit], { timeoutMs: 210_000 }); + if (disabled.code !== 0) throw new Error("Service disable failed; check its status."); + const stopped = await run(process.execPath, stopArgs, { timeoutMs: 120_000 }); + if (stopped.code !== 0) throw new Error("VPN containers could not be stopped; refresh status before retrying."); +} + 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); @@ -191,7 +236,11 @@ export function createVpnService({ run = runCommand, bin = "/usr/bin/podman", id if (!state?.State?.Running) break; await wait(2000); } - if (!healthy) throw new Error("VPN did not become ready; check credentials, server compatibility and the database route. Extractor was not started."); + if (!healthy) { + const logs = await podman(["logs","--tail","80",identity.vpn]).catch(() => ({ stdout:"", stderr:"" })); + const errorClass = classifyVpnFailure(`${logs.stdout || ""}\n${logs.stderr || ""}`); + throw Object.assign(new Error(vpnFailureMessage(errorClass)), { vpnErrorClass:errorClass }); + } const state = await inspect("vpn"); const extracted = await podman(createArgs({ identity, config, serviceDir, imageId, fingerprint, vpnId: state.Id }, "extractor")); if (extracted.code !== 0) throw new Error("Isolated extractor could not start."); @@ -214,5 +263,5 @@ export function createVpnService({ run = runCommand, bin = "/usr/bin/podman", id if (result.code !== 0) throw new Error("Read-only database verification failed; inspect the service status and credentials."); return JSON.parse(result.stdout); } - return { start, stop, status, verify, inspect, routesReady }; + return { start, stop, status, verify, inspect, ready, routesReady }; } diff --git a/src/mcp/gateway-server.js b/src/mcp/gateway-server.js index 2522a74..9857823 100644 --- a/src/mcp/gateway-server.js +++ b/src/mcp/gateway-server.js @@ -20,7 +20,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; import { getChannelMeta, isAdmin, isApproved } from "../config/store.js"; -import { canManage } from "../gateway/modes.js"; +import { canManage, isAuthorized } from "../gateway/modes.js"; import { getEngine as getDefaultEngine } from "../config/settings.js"; import { gatewayRoot } from "../config/paths.js"; import { verifyGatewayCapability } from "../gateway/mcp-capability.js"; @@ -139,6 +139,14 @@ export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progress }); }; + const requireChannelAccess = async () => { + if (!principalTrusted || !createdBy) return false; + const meta = await loadMeta(); + return Boolean(meta) && isAuthorized(meta, createdBy, meta.isDM, { + isAdminUser: await isAdmin(createdBy), isApprovedUser: await isApproved(createdBy), + }); + }; + return { channelId, slug, @@ -163,6 +171,7 @@ export function ctxFromClaims(claims = {}, { engine = "", toolset = "", progress text, requireAdmin, requireManage, + requireChannelAccess, loadMeta, }; } @@ -197,6 +206,7 @@ const onOff = (v) => (v ? "ON" : "OFF"); export function buildControlPlane({ loadMeta }) { return new Map([ ["set_channel_admin_mode", { authz: "admin", details: ({ enabled }) => `Turn ADMIN MODE (no sandbox, no prompts for admin authors) ${onOff(enabled)} for this channel.` }], + ["set_channel_vpn", { authz: "manage", details: ({ enabled }) => `Turn the configured isolated VPN service ${onOff(enabled)} for this channel. This also changes automatic startup.` }], ["set_channel_network", { authz: "admin", details: ({ enabled }) => `Turn network access ${onOff(enabled)} for this channel.` }], ["set_channel_bash", { authz: "manage", details: ({ enabled }) => `Turn shell access (Bash + file edits) ${onOff(enabled)} for this channel.` }], ["set_channel_auto_mode", { authz: "manage", details: ({ enabled }) => `Turn AUTO MODE (tools auto-approved) ${onOff(enabled)} for this channel.` }], diff --git a/src/mcp/tools/channel-admin.js b/src/mcp/tools/channel-admin.js index d910892..21160b3 100644 --- a/src/mcp/tools/channel-admin.js +++ b/src/mcp/tools/channel-admin.js @@ -3,6 +3,7 @@ // instructions + memory, the gateway updater, and the gateway-usage guide. Split out of // gateway-server.js — registered via register(server, ctx); the tool contracts are unchanged. import { z } from "zod"; +import { getChannelVpnStatus, setChannelVpnEnabled } from "../../gateway/channel-vpn-control.js"; import { statSync } from "node:fs"; import { readdir } from "node:fs/promises"; import path from "node:path"; @@ -227,6 +228,28 @@ export function register(server, ctx) { } ); + // A run can act only on its capability-bound channel. The daemon controls the host + // helper; no container gains a shell, Podman socket, NET_ADMIN or credential mount. + const vpnControl = ctx.vpnControl || { getStatus: getChannelVpnStatus, setEnabled: setChannelVpnEnabled }; + const vpnAccess = async () => ctx.verifyCapability?.().ok === true && await ctx.requireChannelAccess?.(); + server.registerTool("get_channel_vpn_status", { + description: "Read this channel's configured VPN status and missing secret names. Distinguishes connecting, connected, off and failed. Never returns secrets or profiles.", + inputSchema: {}, + }, async () => { + if (!await vpnAccess()) return text("You no longer have access to this channel's VPN status."); + try { return text(JSON.stringify(await vpnControl.getStatus(channelId))); } + catch { return text("VPN status is unavailable. Ask an administrator to check the service."); } + }); + server.registerTool("set_channel_vpn", { + description: "ADMINS / CHANNEL MANAGERS. Enable or disable this channel's already configured isolated VPN service, including automatic startup. Use when the user asks to turn VPN on/off. Does not configure profiles, change routes or grant container rights. A starting result is NOT a connected VPN; check get_channel_vpn_status for readiness and safe errors.", + inputSchema: { enabled: z.boolean() }, + }, async ({ enabled }) => { + const authorize = async () => await vpnAccess() && await requireManage(); + if (!await authorize()) return text("Only this channel's current managers or an administrator can control its VPN."); + try { return text(JSON.stringify(await vpnControl.setEnabled(channelId, enabled, { actor: createdBy, source: "mcp", authorize }))); } + catch (error) { return text(error.statusCode ? error.message : "VPN control failed. Refresh its status before retrying."); } + }); + // ── Channel working folder (admins only) ──────────────────────────────────────── server.registerTool( "get_channel_workdir", diff --git a/src/slack/app.js b/src/slack/app.js index e47cf73..5a2441e 100644 --- a/src/slack/app.js +++ b/src/slack/app.js @@ -37,6 +37,7 @@ import { listSkills } from "../gateway/skills/catalog.js"; import { engineLabel, effortBelongsToModel, effortsForModel, modelBelongsToEngine, modelsForEngine, requireAdapter } from "../engines/registry.js"; import { persistedSelectionForEngine, selectionFieldForEngine } from "../gateway/mcp-discovery.js"; import { resolveMakeToolboxUpdate } from "../gateway/make-toolbox.js"; +import { getChannelVpnStatus, setChannelVpnEnabled } from "../gateway/channel-vpn-control.js"; import { logChannelPolicyChange } from "../config/channel-audit.js"; import { createTtlSet } from "./util.js"; @@ -54,7 +55,8 @@ import { buildCatalogManagerView, buildChannelSettingsErrorView, buildChannelSettingsView, buildConnectionsEditorView, buildRuntimeEditorView, buildTemplateEditorView, maskedCredential, parseActionValue as parseChannelSettingsActionValue, editorMetadata, parseEditorMetadata, parseSettingsMetadata, - readConnectionsForm, readRuntimeForm, readTemplateForm, + readConnectionsForm, readRuntimeForm, readTemplateForm, assertVpnActionBinding, + CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID, CHANNEL_SETTINGS_VPN_REFRESH_ACTION_ID, CHANNEL_SETTINGS_MODE_PREFIX, CHANNEL_SETTINGS_OPTION_PREFIX, CHANNEL_SETTINGS_ACTION_PATTERN, CHANNEL_SETTINGS_CLEAR_COMPOSIO_ACTION_ID, CHANNEL_SETTINGS_CLEAR_MAKE_ACTION_ID, CHANNEL_SETTINGS_CLEAR_TOOLBOX_ACTION_ID, @@ -562,6 +564,7 @@ export function channelSettingsEditOptions(meta, userIsAdmin, { authorId = "", i canEditRuntime: true, canEditSecrets: true, canManageCloudMcp: Boolean(userIsAdmin), + canManageVpn: canManage(meta, { authorId, isAdminUser: userIsAdmin, isApprovedUser }), canEditAccess: !meta.isDM && canManage(meta, { authorId, isAdminUser: userIsAdmin, isApprovedUser }), }; } @@ -781,8 +784,8 @@ export async function saveAccessSettings(client, state, userId, form) { }); } -async function settingsRootView(entry, meta, state, userIsAdmin, { tab = state.tab, notice = "" } = {}) { - return buildChannelSettingsView(channelSettingsSnapshot(meta), { ...state, tab }, { +async function settingsRootView(entry, meta, state, userIsAdmin, { tab = state.tab, notice = "", vpn } = {}) { + return buildChannelSettingsView({ ...channelSettingsSnapshot(meta), vpn }, { ...state, tab }, { channelName: entry.name, tab, notice, @@ -790,6 +793,71 @@ async function settingsRootView(entry, meta, state, userIsAdmin, { tab = state.t }); } +// Membership is a live Slack request. Re-read both channel policy and global roles AFTER it +// resolves, so revocation during that request cannot authorize a VPN mutation. +export async function channelVpnSettingsContext(client, state, userId, { manage = false } = {}) { + if (!userId || state.ownerId !== userId) throw new Error("This channel settings view isn't yours. Open your own from a recent reply."); + await channelSettingsContext(client, { channelId: state.channelId, userId, expectedSlug: state.slug, verifyMembership: true }); + const fresh = await channelSettingsContext(client, { channelId: state.channelId, userId, expectedSlug: state.slug }); + if (manage && !canManage(fresh.meta, { authorId: userId, isAdminUser: fresh.userIsAdmin, isApprovedUser: fresh.userIsApproved })) { + throw new Error("Only admins and current channel managers can turn the VPN on or off."); + } + return fresh; +} + +// Never spend a Slack trigger's lifetime on service subprocesses. Render first, then hydrate; +// the returned view hash prevents a slow status response overwriting a newer tab selection. +export async function hydrateChannelVpnSettings(client, view, state, { + status = getChannelVpnStatus, context = channelVpnSettingsContext, rootView = settingsRootView, +} = {}) { + try { + await context(client, state, state.ownerId); + const vpn = await status(state.channelId); + const { entry, meta, userIsAdmin } = await context(client, state, state.ownerId); + await client.views.update({ view_id: view.id, ...(view.hash ? { hash: view.hash } : {}), + view: await rootView(entry, meta, { ...state, tab: "network" }, userIsAdmin, { vpn }), + }); + } catch (error) { + // Hash conflicts mean the user already moved on; do not replace that newer view. + if (error?.data?.error === "hash_conflict") return; + await client.views.update({ view_id: view.id, ...(view.hash ? { hash: view.hash } : {}), + view: buildChannelSettingsErrorView(error.message || "Couldn't read VPN status. Reopen Settings and try again."), + }).catch(() => {}); + } +} + +export async function handleChannelVpnSettingsAction({ ack, body, action, client }, { + status = getChannelVpnStatus, setEnabled = setChannelVpnEnabled, + context = channelVpnSettingsContext, rootView = settingsRootView, +} = {}) { + await ack(); + try { + if (body?.view?.callback_id !== "cg_channel_settings_modal") throw new Error(SETTINGS_PURPOSE.expired); + const state = parseSettingsMetadata(body.view.private_metadata); + const userId = body?.user?.id; + if (!userId || state.ownerId !== userId) throw new Error("This channel settings view isn't yours. Open your own from a recent reply."); + const command = parseChannelSettingsActionValue(action?.value); + assertVpnActionBinding(state, command, action?.action_id); + const manage = action.action_id === CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID; + await context(client, state, userId, { manage }); + const vpn = manage + ? await setEnabled(state.channelId, command.enabled, { actor: userId, source: "slack_settings", authorize: async () => { + await context(client, state, userId, { manage: true }); + return true; + } }) + : await status(state.channelId); + const { entry, meta, userIsAdmin } = await context(client, state, userId); + await client.views.update({ view_id: body.view.id, ...(body.view.hash ? { hash: body.view.hash } : {}), + view: await rootView(entry, meta, { ...state, tab: "network" }, userIsAdmin, { vpn }), + }); + } catch (error) { + if (body?.view?.id && error?.data?.error !== "hash_conflict") await client.views.update({ + view_id: body.view.id, ...(body.view.hash ? { hash: body.view.hash } : {}), + view: buildChannelSettingsErrorView(error.message || "Couldn't change the VPN. Reopen Settings to check its status."), + }).catch(() => {}); + } +} + async function openChannelSettings(client, triggerId, { channelId, userId, threadTs = "", tab = "runtime" } = {}) { const { entry, meta, userIsAdmin } = await channelSettingsContext(client, { channelId, @@ -797,7 +865,7 @@ async function openChannelSettings(client, triggerId, { channelId, userId, threa verifyMembership: true, }); const state = { channelId, slug: entry.slug, threadTs, ownerId: userId, tab }; - await client.views.open({ + const opened = await client.views.open({ trigger_id: triggerId, view: buildChannelSettingsView(channelSettingsSnapshot(meta), state, { channelName: entry.name, @@ -805,6 +873,7 @@ async function openChannelSettings(client, triggerId, { channelId, userId, threa ...channelSettingsEditOptions(meta, userIsAdmin, { authorId: state.ownerId, isApprovedUser: await isApproved(state.ownerId) }), }), }); + if (tab === "network" && opened.view?.id) await hydrateChannelVpnSettings(client, opened.view, state); await logEvent("channel_settings_opened", { channel: channelId, author: userId, slug: entry.slug }); } @@ -1148,6 +1217,10 @@ async function connectAndWire(app) { }); const handleChannelSettingsAction = async ({ ack, body, action, client }) => { + if ([CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID, CHANNEL_SETTINGS_VPN_REFRESH_ACTION_ID].includes(action?.action_id)) { + await handleChannelVpnSettingsAction({ ack, body, action, client }); + return; + } await ack(); const clicker = body?.user?.id; const command = parseChannelSettingsActionValue(action?.value); @@ -1191,7 +1264,8 @@ async function connectAndWire(app) { if (command.o === "tab") { const tab = String(command.p || "runtime"); - await updateCurrent(await settingsRootView(entry, meta, { ...state, tab }, userIsAdmin)); + const updated = await updateCurrent(await settingsRootView(entry, meta, { ...state, tab }, userIsAdmin)); + if (tab === "network" && updated.view?.id) await hydrateChannelVpnSettings(client, updated.view, { ...state, tab }); return; } diff --git a/src/slack/channel-settings.js b/src/slack/channel-settings.js index 87519a1..52a5e3f 100644 --- a/src/slack/channel-settings.js +++ b/src/slack/channel-settings.js @@ -1,6 +1,7 @@ // Channel Settings modal for Slack. It mirrors the web conversation editor's safe channel-level // controls while keeping credential values write-only and re-authorizing every interaction in the // controller. Dangerous gateway-wide/admin-only settings remain in the web admin UI. +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { ACCESS_EDIT_ACTION_ID, accessSummary } from "./access-settings.js"; import { channelMode, modeLabel } from "../gateway/modes.js"; import { MIN_MASKABLE_LENGTH } from "../config/channel-env.js"; @@ -30,8 +31,10 @@ export const CHANNEL_SETTINGS_SKILL_PAGE_PREFIX = "cg_channel_settings_skill_pag export const CHANNEL_SETTINGS_TEMPLATE_EDIT_ACTION_ID = "cg_channel_settings_template_edit"; export const CHANNEL_SETTINGS_TEMPLATE_CALLBACK_ID = "cg_channel_settings_template_form"; export const CHANNEL_SETTINGS_SECRETS_MANAGE_ACTION_ID = "cg_channel_settings_secrets_manage"; +export const CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID = "cg_channel_settings_vpn_toggle"; +export const CHANNEL_SETTINGS_VPN_REFRESH_ACTION_ID = "cg_channel_settings_vpn_refresh"; export const CHANNEL_SETTINGS_ACTION_PATTERN = /^cg_channel_settings(?:$|_)/; -export const CHANNEL_SETTINGS_TABS = Object.freeze(["runtime", "mcp", "skills", "secrets", "access"]); +export const CHANNEL_SETTINGS_TABS = Object.freeze(["runtime", "mcp", "skills", "secrets", "network", "access"]); export const SETTINGS_DEFAULT_VALUE = "__default__"; export const SETTINGS_NONE_VALUE = "__none__"; export const SETTINGS_PAGE_SIZE = 12; @@ -319,8 +322,58 @@ function secretsBlocks(snapshot = {}, state = {}, { canEditSecrets = false } = { }).blocks; } +// Bind privileged VPN actions to the view's channel, slug and owner. A restart intentionally +// expires old VPN controls; the user can reopen Settings to obtain a fresh binding. +const vpnActionKey = randomBytes(32); +function vpnActionSignature(state, operation, enabled) { + return createHmac("sha256", vpnActionKey).update(JSON.stringify([ + state.channelId, state.slug, state.ownerId, operation, enabled ?? null, + ])).digest("hex"); +} + +export function assertVpnActionBinding(state, command, actionId) { + const operation = actionId === CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID ? "vpn_toggle" : "vpn_refresh"; + if (![CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID, CHANNEL_SETTINGS_VPN_REFRESH_ACTION_ID].includes(actionId) + || command.o !== operation || command.c !== state.channelId || command.u !== state.ownerId + || (operation === "vpn_toggle" && typeof command.enabled !== "boolean")) throw new Error(EXPIRED); + const actual = Buffer.from(String(command.signature || ""), "hex"); + const expected = Buffer.from(vpnActionSignature(state, operation, command.enabled), "hex"); + if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) throw new Error(EXPIRED); +} + +function vpnButton(state, enabled) { + const toggle = typeof enabled === "boolean"; + const operation = toggle ? "vpn_toggle" : "vpn_refresh"; + return button(toggle ? CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID : CHANNEL_SETTINGS_VPN_REFRESH_ACTION_ID, + toggle ? (enabled ? "Turn VPN on" : "Turn VPN off") : "Refresh VPN status", state, operation, + { ...(toggle ? { enabled } : {}), signature: vpnActionSignature(state, operation, enabled) }, + toggle && enabled ? { style: "primary" } : {}); +} + +function networkBlocks(snapshot, state, { canManageVpn }) { + const vpn = snapshot.vpn; + const labels = { unconfigured: "Not configured", unavailable: "Unavailable", off: "Off", starting: "Starting — not connected yet", on: "On — connected", stopping: "Stopping", failed: "Failed — not connected" }; + const buttons = [vpnButton(state)]; + // Stopping remains possible while a tunnel is starting or failed. A running control operation + // must settle before another one can be accepted by the service. + if (canManageVpn && vpn?.configured && !vpn.busy && vpn.state !== "unavailable") { + if (vpn.enabled || vpn.running || ["on", "starting"].includes(vpn.state)) buttons.unshift(vpnButton(state, false)); + else if (vpn.state !== "stopping" && vpn.allowNetwork && !vpn.missingSecrets?.length) buttons.unshift(vpnButton(state, true)); + } + return [ + fieldBlock("Network use", snapshot.mode?.allowNetwork ? "Allowed" : "Off"), + { type: "context", elements: [mrkdwn("Network use is the engine's channel policy. Managers can change it under Access.")] }, + fieldBlock("VPN", vpn ? (labels[vpn.state] || "Unknown") : "Checking status…"), + ...(vpn?.message ? [{ type: "section", text: mrkdwn(escapeMrkdwn(vpn.message)) }] : []), + ...(vpn?.missingSecrets?.length ? [fieldBlock("Missing channel secrets", vpn.missingSecrets.map(inlineCode).join(", "))] : []), + { type: "actions", elements: buttons }, + { type: "context", elements: [mrkdwn("VPN connects the channel's dedicated VPN service and extractor. It does not route the ordinary agent container through the tunnel. Only admins and current channel managers can turn it on or off.")] }, + ]; +} + const TAB_LABELS = Object.freeze({ access: "Access", + network: "Network", runtime: "Engine & model", mcp: "MCP", skills: "Skills", @@ -349,6 +402,7 @@ export function buildChannelSettingsView(snapshot = {}, state = {}, { canEditSecrets = false, canManageCloudMcp = false, canEditAccess = false, + canManageVpn = false, notice = "", } = {}) { const requested = normalizeTab(tab); @@ -358,6 +412,8 @@ export function buildChannelSettingsView(snapshot = {}, state = {}, { { type: "section", text: mrkdwn(accessSummary(snapshot.access || {})) }, { type: "actions", elements: [button(ACCESS_EDIT_ACTION_ID, "Change access settings", state, "access_edit", {}, { style: "primary" })] }, ] + : active === "network" + ? networkBlocks(snapshot, state, { canManageVpn }) : active === "mcp" ? mcpBlocks(snapshot, state, { canManageCloudMcp }) : active === "skills" @@ -372,7 +428,7 @@ export function buildChannelSettingsView(snapshot = {}, state = {}, { title: plain("Channel settings"), close: plain("Done"), blocks: [ - { type: "context", elements: [mrkdwn(`Settings for *#${escapeMrkdwn(channelName || "this channel")}*. Anyone authorized to use the agent here can edit these settings. Access settings require a channel manager or admin. Cloud MCP is admin-only.`)] }, + { type: "context", elements: [mrkdwn(`Settings for *#${escapeMrkdwn(channelName || "this channel")}*. Anyone authorized to use the agent here can edit these settings. Access settings and VPN controls require a channel manager or admin. Cloud MCP is admin-only.`)] }, ...(notice ? [{ type: "section", text: mrkdwn(notice) }] : []), tabButtons(state, active, canEditAccess), { type: "divider" }, diff --git a/src/web/routes/channels.js b/src/web/routes/channels.js index 947907b..e562c2a 100644 --- a/src/web/routes/channels.js +++ b/src/web/routes/channels.js @@ -61,6 +61,8 @@ import { ADMIN_UI_ACTOR, logChannelPolicyChange } from "../../config/channel-aud // — which is precisely why they must not ride out on a spread of the whole record. import { stripDeadFields } from "../../config/dead-fields.js"; import { cliEnvKeys, cliIntegrationIds } from "../../config/cli-catalog.js"; +import { getChannelVpnStatus, setChannelVpnEnabled } from "../../gateway/channel-vpn-control.js"; +import { isAuthenticated } from "../auth.js"; const WEB_ADMIN_ACTOR = "admin UI"; @@ -99,9 +101,38 @@ export function maskChannelMeta(meta = {}) { export function createChannelsRouter({ slack, testMakeToolbox = listMakeToolboxTools, + getVpnStatus = getChannelVpnStatus, + setVpnEnabled = setChannelVpnEnabled, } = {}) { const router = Router(); + // The enclosing admin stack authenticates these routes. No profile, command, path or + // service metadata is accepted from the browser: only this registered conversation's switch. + router.get("/channels/:channelId/vpn", async (req, res, next) => { + try { + res.json(await getVpnStatus(req.params.channelId)); + } catch (error) { + if (error.statusCode) return res.status(error.statusCode).json({ error: error.message }); + next(error); + } + }); + router.put("/channels/:channelId/vpn", async (req, res, next) => { + const body = req.body; + if (!body || typeof body.enabled !== "boolean" || Object.keys(body).some((key) => key !== "enabled")) { + return res.status(400).json({ error: "Send only enabled: true or false." }); + } + try { + res.json(await setVpnEnabled(req.params.channelId, body.enabled, { + actor: ADMIN_UI_ACTOR, + source: "admin_ui", + authorize: async () => isAuthenticated(req), + })); + } catch (error) { + if (error.statusCode) return res.status(error.statusCode).json({ error: error.message }); + next(error); + } + }); + const currentChannelRoster = async (channelId) => { const client = slack?.getClient?.(); if (!client) { diff --git a/test/channel-env.test.js b/test/channel-env.test.js index 04b6ac1..383b28c 100644 --- a/test/channel-env.test.js +++ b/test/channel-env.test.js @@ -70,7 +70,9 @@ test("the admin env form upper-cases the name it shows and sends", () => { // do not exist. test("the env card is exempt from the conversation card's unsaved-changes tracking", () => { const client = readFileSync(new URL("../public/app.js", import.meta.url), "utf8"); - assert.match(client, /const SELF_SAVING_CONTROLS = "\.channel-env-card";/); + const selfSaving = client.match(/const SELF_SAVING_CONTROLS = "([^"]+)";/)?.[1].split(/,\s*/); + assert.ok(selfSaving?.includes(".channel-env-card")); + assert.ok(selfSaving?.includes(".ch-vpn-controls")); // Each of the three dirty-trackers (conversation card, DM/template card, Settings page) exempts it. assert.match(client, /\[data-pane="instructions"\], \[data-pane="memory"\], \.detail-savebar, \.checks-filter, \.skill-assignment-filters, \$\{SELF_SAVING_CONTROLS\}/); assert.match(client, /\.detail-savebar, \.checks-filter, \$\{SELF_SAVING_CONTROLS\}`\)\) mark\(\)/); diff --git a/test/channel-settings-modal.test.js b/test/channel-settings-modal.test.js index 3b53a43..a70cae5 100644 --- a/test/channel-settings-modal.test.js +++ b/test/channel-settings-modal.test.js @@ -122,7 +122,7 @@ test("authorized user reply footer adds Settings after the existing workspace co assert.equal(ordinary.some((button) => button.action_id === CHANNEL_SETTINGS_ACTION_ID), false); }); -test("Channel Settings modal renders five working tabs for managers with one active state", () => { +test("Channel Settings modal renders all working tabs for managers with one active state", () => { const view = buildChannelSettingsView(snapshot, state, { channelName: "project-alpha", tab: "mcp", canManageCloudMcp: true, canEditAccess: true }); const buttons = allButtons(view).filter((button) => button.action_id.startsWith("cg_channel_settings_tab_")); assert.equal(buttons.length, CHANNEL_SETTINGS_TABS.length); @@ -360,7 +360,7 @@ test("Settings and secrets admit authorized members and guests, but Cloud MCP re await store.saveChannelMeta(entry.slug, { ...base, managers: [], ...flags }); assert.equal((await secretsContext(memberClient, args)).mayEdit, true); assert.deepEqual(channelSettingsEditOptions({ ...base, ...flags }, false), { - canEnableAdmin: false, canEditRuntime: true, canEditSecrets: true, canManageCloudMcp: false, canEditAccess: false, + canEnableAdmin: false, canEditRuntime: true, canEditSecrets: true, canManageCloudMcp: false, canManageVpn: false, canEditAccess: false, }); } await store.setUser(args.userId, { approved: false }); diff --git a/test/channel-vpn-control.test.js b/test/channel-vpn-control.test.js new file mode 100644 index 0000000..451b21a --- /dev/null +++ b/test/channel-vpn-control.test.js @@ -0,0 +1,141 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { createChannelVpnControl } = await import("../src/gateway/channel-vpn-control.js"); +const { classifyVpnFailure, vpnUnitStatus, vpnFailureMessage, disableVpnUnit } = await import("../src/gateway/vpn-service.js"); +const { register } = await import("../src/mcp/tools/channel-admin.js"); + +function fixture() { + const f = { calls: [], events: [], meta: { allowNetwork:true, vpnService:{version:1} }, state:"off", names:["VPN_USERNAME","VPN_PASSWORD","MYSQL_USERNAME","MYSQL_PASSWORD"] }; + f.control = createChannelVpnControl({ + entryFor: async id => id === "C_VPN" ? {slug:"vpn-test"} : null, + metaFor: async () => f.meta, + inventory: () => f.names.map(name=>({name})), + audit: async (...event)=>f.events.push(event), + execute: async (action,id) => { + f.calls.push([action,id]); + if (f.execute) return f.execute(action,id); + if (action === "enable") f.state="starting"; + if (action === "disable") f.state="off"; + return {code:0,stdout:JSON.stringify({control:{available:true,installed:true,enabled:f.state!=="off",state:f.state},password:"must-not-leak",credentials:[{name:"SECRET",value:"must-not-leak"}]})}; + }, + }); + return f; +} +const allowed = {actor:"U_MANAGER",source:"test",authorize:async()=>true}; + +test("unconfigured and unknown channels do not spawn a host helper", async()=>{ + const f=fixture(); f.meta.vpnService=null; + assert.equal((await f.control.getStatus("C_VPN")).state,"unconfigured"); + await assert.rejects(f.control.getStatus("C_OTHER"),{statusCode:404}); + await assert.rejects(f.control.getStatus("C_VPN;sh"),{statusCode:400}); + await assert.rejects(f.control.setEnabled("C_VPN",true,allowed),/not configured/); + assert.equal(f.calls.length,0); +}); + +test("responses are allowlisted and start returns connecting, not a successful connection",async()=>{ + const f=fixture(); + const status=await f.control.setEnabled("C_VPN",true,allowed); + assert.equal(status.state,"starting"); assert.equal(status.enabled,true); + assert.doesNotMatch(JSON.stringify(status),/must-not-leak|password|SECRET/); + assert.deepEqual(f.calls,[['enable','C_VPN'],['status','C_VPN']]); + assert.ok(f.events.some(([event,data])=>event==='channel_vpn_controlled'&&data.author==='U_MANAGER'&&data.enabled===true)); + assert.equal((await f.control.setEnabled("C_VPN",false,allowed)).state,"off"); +}); + +test("missing credentials and Network off block enable, but never block stop",async()=>{ + const f=fixture(); f.names=[]; + await assert.rejects(f.control.setEnabled("C_VPN",true,allowed),/Missing channel Secrets/); + f.meta.allowNetwork=false; + await assert.rejects(f.control.setEnabled("C_VPN",true,allowed),/Turn on Network/); + assert.equal(f.calls.length,0); + await f.control.setEnabled("C_VPN",false,allowed); + assert.equal(f.calls[0][0],"disable"); +}); + +test("authorization is mandatory and rechecked after queuing at the effect boundary",async()=>{ + const f=fixture(); + await assert.rejects(f.control.setEnabled("C_VPN",true),{statusCode:403}); + let n=0; + await assert.rejects(f.control.setEnabled("C_VPN",true,{authorize:async()=>++n===1}),{statusCode:403}); + assert.equal(f.calls.length,0); +}); + +test("concurrent toggles serialize and cannot use stale permission",async()=>{ + const f=fixture(); let finish, entered; + const inCommand=new Promise(resolve=>{entered=resolve;}); + const command=new Promise(resolve=>{finish=resolve;}); + f.execute=async action=>{if(action==='enable'){entered();await command;}return {code:0,stdout:JSON.stringify({control:{available:true,installed:true,state:'starting',enabled:true}})};}; + const first=f.control.setEnabled("C_VPN",true,allowed); + await inCommand; + let permitted=true; + const second=f.control.setEnabled("C_VPN",false,{authorize:async()=>permitted}); + await new Promise(resolve=>setImmediate(resolve)); + permitted=false; finish(); + await first; await assert.rejects(second,{statusCode:403}); + assert.equal(f.calls.filter(([a])=>a==='disable').length,0); +}); + +test("raw subprocess errors and malformed status never disclose provider output",async()=>{ + const f=fixture();f.execute=async()=>({code:1,stdout:'token=PRIVATE',stderr:'password=PRIVATE'}); + assert.equal((await f.control.getStatus('C_VPN')).state,'unavailable'); + await assert.rejects(f.control.setEnabled('C_VPN',true,allowed),e=>e.statusCode===503&&!e.message.includes('PRIVATE')); + f.execute=async()=>({code:0,stdout:JSON.stringify({control:{available:true,installed:true,state:'failed',enabled:true,errorClass:'token=PRIVATE'}})}); + const status=await f.control.getStatus('C_VPN'); + assert.equal(status.state,'failed'); assert.doesNotMatch(JSON.stringify(status),/PRIVATE/); +}); + +test("server errors reduce to fixed diagnostics; TLS verification is preserved",()=>{ + assert.equal(classifyVpnFailure('secret=PRIVATE\nVERIFY KU ERROR'),'server_certificate_usage'); + assert.match(vpnFailureMessage('server_certificate_usage'),/Key Usage/); + assert.equal(classifyVpnFailure('AUTH_FAILED user=PRIVATE'),'authentication_failed'); + assert.doesNotMatch(vpnFailureMessage('PRIVATE'),/PRIVATE/); + assert.equal(typeof vpnFailureMessage('__proto__'),'string'); + const running={vpn:{state:'running'},extractor:{state:'running'}}; + const status=(active,last={})=>vpnUnitStatus(`LoadState=loaded\nActiveState=${active}\nUnitFileState=enabled`,running,last); + assert.equal(status('active').state,'starting'); + assert.equal(status('active',{state:'on'}).state,'failed'); + running.vpn.health='unhealthy'; + assert.equal(status('active',{state:'on'}).errorClass,'connection_lost'); + running.ready=true; + assert.equal(status('active',{state:'on'}).state,'on'); + assert.equal(status('failed',{errorClass:'server_certificate_usage'}).errorClass,'server_certificate_usage'); + assert.equal(status('failed',{errorClass:'PRIVATE'}).errorClass,'startup_failed'); + assert.equal(status('deactivating').state,'stopping'); + assert.equal(vpnUnitStatus('LoadState=not-found').installed,false); +}); + +test("MCP VPN operations stay bound to current channel and recheck access/management",async()=>{ + const tools=new Map(), calls=[]; + let access=true,manager=true,valid=true; + register({registerTool:(name,_schema,handler)=>tools.set(name,handler)}, { + channelId:'C_VPN',slug:'vpn-test',createdBy:'U_MANAGER',text:t=>t, + verifyCapability:()=>({ok:valid}),requireChannelAccess:async()=>access,requireManage:async()=>manager, + vpnControl:{getStatus:async id=>{calls.push(['read',id]);return {state:'off'};},setEnabled:async(id,enabled,options)=>{ + assert.equal(await options.authorize(),true);calls.push(['write',id,enabled]);return {state:'starting'}; + }}, + }); + await tools.get('get_channel_vpn_status')({channelId:'C_OTHER'}); + await tools.get('set_channel_vpn')({channelId:'C_OTHER',enabled:true}); + assert.deepEqual(calls,[['read','C_VPN'],['write','C_VPN',true]]); + manager=false; await tools.get('set_channel_vpn')({enabled:false}); + access=false; await tools.get('get_channel_vpn_status')({}); + valid=false; await tools.get('set_channel_vpn')({enabled:true}); + assert.equal(calls.length,2); +}); + + +test("OFF stops the supervisor before locked manual-pair cleanup and propagates failures",async()=>{ + const calls=[]; + const opts={unit:"fixture.service",stopArgs:["fixed-helper","stop","--channel","C_VPN"],run:async(bin,args)=>{calls.push([bin,args]);return {code:0};}}; + await disableVpnUnit(opts); + assert.deepEqual(calls[0],["/usr/bin/systemctl",["--user","disable","--now","fixture.service"]]); + assert.deepEqual(calls[1],[process.execPath,opts.stopArgs]); + let count=0; + await assert.rejects(disableVpnUnit({...opts,run:async()=>({code:++count===1?0:75})}),/could not be stopped/); + count=0; + await assert.rejects(disableVpnUnit({...opts,run:async()=>{count++;return {code:1};}}),/disable failed/); + assert.equal(count,1); + assert.equal(vpnUnitStatus("LoadState=loaded\nActiveState=inactive\nUnitFileState=disabled",{vpn:{state:"running"},extractor:{state:"absent"}}).running,true); +}); diff --git a/test/channel-vpn-web.test.js b/test/channel-vpn-web.test.js new file mode 100644 index 0000000..88af240 --- /dev/null +++ b/test/channel-vpn-web.test.js @@ -0,0 +1,166 @@ +import test, { after } from "node:test"; +import assert from "node:assert/strict"; +import express from "express"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureTestEnv } from "./helpers.js"; + +ensureTestEnv(); +const { createChannelsRouter } = await import("../src/web/routes/channels.js"); +const { createAdminRouter } = await import("../src/web/routes/admin.js"); +const { authMiddleware, noPasswordLockdown, handleLogin, invalidateAllSessions } = await import("../src/web/auth.js"); +const { saveSettings } = await import("../src/config/settings.js"); +const { defaultChannelMeta, saveChannelMeta, upsertChannelEntry } = await import("../src/config/store.js"); +saveSettings({ adminPassword: "vpn-web-test-password", apiKey: "vpn-web-run-api-key" }); +const calls = []; +const statuses = new Map(); +const off = { configured: true, enabled: false, running: false, state: "off", message: "VPN is off.", missingSecrets: [], busy: false, allowNetwork: true }; +let controlFailure = false; +let revokeOnControl = false; +const app = express(); +app.use(express.json()); +app.post("/api/login", handleLogin); +app.use(noPasswordLockdown, authMiddleware); +app.get("/api/mcp/available", (_req, res) => res.json({ servers: [] })); +app.get("/api/health", (_req, res) => res.json({ slack: { connected: false, status: "off" }, engines: {} })); +app.get("/api/channels/:channelId/members", (_req, res) => res.json({ members: [] })); +app.use("/api", createChannelsRouter({ + getVpnStatus: async (channelId) => { + calls.push({ read: channelId }); + if (channelId === "UNKNOWN") throw Object.assign(new Error("Unknown channel."), { statusCode: 404 }); + return statuses.get(channelId) || off; + }, + setVpnEnabled: async (channelId, enabled, options) => { + calls.push({ channelId, enabled, actor: options.actor, source: options.source }); + if (revokeOnControl) invalidateAllSessions(); + if (!await options.authorize()) throw Object.assign(new Error("Admin session expired."), { statusCode: 403 }); + if (controlFailure) throw Object.assign(new Error("VPN server certificate verification failed."), { statusCode: 409 }); + const result = { ...off, enabled, state: enabled ? "starting" : "off", message: enabled ? "Connecting…" : "VPN is off." }; + statuses.set(channelId, result); + return result; + }, +})); +app.use("/api", createAdminRouter({ slack: { snapshot: () => ({ status: "disconnected", connected: false }), getClient: () => null } })); +const publicDir = fileURLToPath(new URL("../public", import.meta.url)); +app.use(express.static(publicDir, { dotfiles: "allow" })); +app.get("/conversations/channel/:channelId", (_req, res) => res.sendFile(path.join(publicDir, "index.html"))); +const server = await new Promise((resolve) => { const instance = app.listen(0, "127.0.0.1", () => resolve(instance)); }); +const base = `http://127.0.0.1:${server.address().port}`; +after(() => { server.closeAllConnections(); server.close(); }); +async function login() { + const response = await fetch(`${base}/api/login`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: "vpn-web-test-password" }) }); + assert.equal(response.status, 200); + return response.headers.get("set-cookie").split(";")[0]; +} +const request = (cookie, body, channelId = "C_VPN_WEB") => fetch(`${base}/api/channels/${channelId}/vpn`, { + method: body === undefined ? "GET" : "PUT", + headers: { cookie, "content-type": "application/json", "x-cg-request": "1" }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), +}); + +test("VPN API requires an admin session; run API tokens and missing CSRF header grant no control", async () => { + calls.length = 0; + for (const method of ["GET", "PUT"]) { + const response = await fetch(`${base}/api/channels/C_VPN_WEB/vpn`, { method, headers: { authorization: "Bearer vpn-web-run-api-key", "content-type": "application/json" }, ...(method === "PUT" ? { body: '{"enabled":true}' } : {}) }); + assert.equal(response.status, 401); + } + const cookie = await login(); + const response = await fetch(`${base}/api/channels/C_VPN_WEB/vpn`, { method: "PUT", headers: { cookie, "content-type": "application/json" }, body: '{"enabled":true}' }); + assert.equal(response.status, 403); + assert.deepEqual(calls, []); +}); + +test("VPN API accepts only a boolean switch and never forwards injected service configuration", async () => { + const cookie = await login(); + calls.length = 0; + for (const body of [{}, { enabled: "true" }, { enabled: 1 }, { enabled: true, profile: "/tmp/evil.ovpn" }, { enabled: true, command: "reboot" }, { enabled: true, channelId: "OTHER" }]) { + assert.equal((await request(cookie, body)).status, 400); + } + assert.deepEqual(calls, []); + const response = await request(cookie, { enabled: true }, "C_EXACT_TARGET"); + assert.equal(response.status, 200); + assert.equal((await response.json()).state, "starting", "accepted start does not claim a connected tunnel"); + assert.deepEqual(calls, [{ channelId: "C_EXACT_TARGET", enabled: true, actor: "admin-ui", source: "admin_ui" }]); + assert.equal((await request(cookie, { enabled: false }, "C_EXACT_TARGET")).status, 200); +}); + +test("VPN API reports safe control and unknown-channel failures", async () => { + const cookie = await login(); + assert.equal((await request(cookie, undefined, "UNKNOWN")).status, 404); + controlFailure = true; + try { + const response = await request(cookie, { enabled: true }); + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: "VPN server certificate verification failed." }); + } finally { controlFailure = false; } +}); + +test("queued VPN control rechecks the live admin session", async () => { + const cookie = await login(); + revokeOnControl = true; + try { assert.equal((await request(cookie, { enabled: true })).status, 403); } + finally { revokeOnControl = false; } +}); + +test("browser VPN switch applies immediately, polls connection state, and shows setup/error states", { skip: !process.env.CG_BROWSER_MODULE }, async (t) => { + const { chromium } = await import(process.env.CG_BROWSER_MODULE); + for (const id of ["C_VPN_BROWSER", "C_VPN_SETUP"]) { + const entry = await upsertChannelEntry(id, { name: id.toLowerCase(), type: "channel", isDM: false, platform: "slack" }); + await saveChannelMeta(entry.slug, { ...defaultChannelMeta({ channelId: id, name: id.toLowerCase(), type: "channel", isDM: false }), allowNetwork: true }); + } + statuses.set("C_VPN_SETUP", { ...off, configured: false, state: "unconfigured", message: "No VPN configured." }); + const browser = await chromium.launch({ headless: true, args: ["--no-sandbox"] }); + t.after(() => browser.close()); + const context = await browser.newContext(); + await context.request.post(`${base}/api/login`, { data: { password: "vpn-web-test-password" } }); + const page = await context.newPage(); + await page.addInitScript(() => Object.defineProperty(globalThis, "EventSource", { value: undefined })); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + page.on("console", (message) => { + if (message.type() === "error" && !(controlFailure && message.text().includes("409"))) errors.push(message.text()); + }); + calls.length = 0; + await page.goto(`${base}/conversations/channel/C_VPN_BROWSER`); + const toggle = page.locator("#channel-detail .ch-vpn-enabled"); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-enabled")?.disabled === false); + assert.deepEqual(calls.filter((c) => c.read).map((c) => c.read), ["C_VPN_BROWSER"], "only selected channel is probed"); + await page.locator("#channel-detail .ch-vpn-controls .togglerow").click(); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.includes("Starting")); + assert.equal(await page.locator("#channel-detail .detail-savebar").isHidden(), true); + assert.equal(await toggle.isDisabled(), false, "stop stays accessible while the VPN negotiates its connection"); + statuses.set("C_VPN_BROWSER", { ...off, enabled: true, state: "on", message: "Tunnel is connected." }); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.includes("Connected")); + await page.locator("#channel-detail .ch-vpn-controls .togglerow").click(); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.startsWith("Off")); + controlFailure = true; + try { + await page.locator("#channel-detail .ch-vpn-controls .togglerow").click(); + await page.locator("#channel-detail .ch-vpn-error").waitFor(); + assert.match(await page.locator("#channel-detail .ch-vpn-error").textContent(), /certificate verification failed/); + assert.equal(await toggle.isChecked(), false); + } finally { controlFailure = false; } + statuses.set("C_VPN_BROWSER", { ...off, missingSecrets: ["VPN_PASSWORD"] }); + await page.locator("#channel-detail .ch-vpn-refresh").click(); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.includes("VPN_PASSWORD")); + assert.equal(await toggle.isDisabled(), true); + statuses.set("C_VPN_BROWSER", { ...off, enabled: true, state: "on", allowNetwork: false, missingSecrets: ["VPN_PASSWORD"] }); + await page.locator("#channel-detail .ch-vpn-refresh").click(); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-enabled")?.disabled === false); + assert.equal(await toggle.isChecked(), true, "network off or missing credentials must never prevent stopping a running VPN"); + await page.locator("#channel-detail .ch-vpn-controls .togglerow").click(); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.startsWith("Off")); + statuses.set("C_VPN_BROWSER", { ...off, enabled: false, running: true, state: "failed", allowNetwork: false, missingSecrets: ["VPN_PASSWORD"], message: "VPN service needs attention." }); + await page.locator("#channel-detail .ch-vpn-refresh").click(); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.startsWith("Failed")); + assert.equal(await toggle.isChecked(), true, "manually started containers remain stoppable even when systemd is disabled"); + assert.equal(await toggle.isDisabled(), false); + await page.locator("#channel-detail .ch-vpn-controls .togglerow").click(); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.startsWith("Off")); + assert.equal(calls.filter((call) => Object.hasOwn(call, "enabled")).at(-1).enabled, false); + await page.goto(`${base}/conversations/channel/C_VPN_SETUP`); + await page.waitForFunction(() => globalThis.document.querySelector("#channel-detail .ch-vpn-state")?.textContent.includes("Not configured")); + assert.equal(await page.locator("#channel-detail .ch-vpn-enabled").isDisabled(), true); + assert.match(await page.locator("#channel-detail .ch-vpn-state").textContent(), /administrator must import/); + assert.deepEqual(errors, []); +}); diff --git a/test/channel-workdir-ui.test.js b/test/channel-workdir-ui.test.js index 0c09ebe..45f105c 100644 --- a/test/channel-workdir-ui.test.js +++ b/test/channel-workdir-ui.test.js @@ -55,7 +55,7 @@ function fixture({ workDir = "/home/operator/project", saveError } = {}) { const ch = { channelId: "C/FOLDER", slug: "folder-fixture", meta: { workDir, engine: "codex" } }; const calls = []; const context = { - card, ch, meta: ch.meta, Event, detailDirty: false, SELF_SAVING_CONTROLS: ".channel-env-card", + card, ch, meta: ch.meta, Event, detailDirty: false, SELF_SAVING_CONTROLS: ".channel-env-card, .ch-vpn-controls", engineSelect: { value: "codex" }, usersBox: { dataset: { ready: "" } }, mcpsBox: {}, skillsPicker: null, makeToolboxKeyInput: control(".ch-make-toolbox-key"), makeToolboxUrlInput: control(".ch-make-toolbox-url"), makeToolboxState: new Control(), @@ -63,7 +63,7 @@ function fixture({ workDir = "/home/operator/project", saveError } = {}) { tokenValue: () => "", selectedMcpEntries: () => [], checkedValues: () => [], explicitCheckedValues: () => [], channelGuestSavePatch: () => ({}), channelGuestAcceptedIds: () => null, reconcileChannelMeta, - attachReveal() {}, revealSecret() {}, paintModePill() {}, renderConvList() {}, setTimeout() {}, + attachReveal() {}, revealSecret() {}, paintModePill() {}, renderConvList() {}, refreshVpn() {}, setTimeout() {}, openFolderPicker() { throw new Error("Reset must not browse folders"); }, async api(url, request) { calls.push({ url, method: request.method, body: JSON.parse(request.body) }); diff --git a/test/mcp-control-plane-approval.test.js b/test/mcp-control-plane-approval.test.js index 60d0ae1..9f54c70 100644 --- a/test/mcp-control-plane-approval.test.js +++ b/test/mcp-control-plane-approval.test.js @@ -352,7 +352,7 @@ test("every registered gateway tool is consciously classified as gated or open ( // omission. This inventory forces the classification to be a reviewed decision: an // unclassified tool fails here until it is added to exactly one of these lists. const GATED = new Set([ - "set_channel_admin_mode", "set_channel_network", "set_channel_bash", "set_channel_auto_mode", + "set_channel_vpn", "set_channel_admin_mode", "set_channel_network", "set_channel_bash", "set_channel_auto_mode", "set_channel_workdir", "clear_channel_workdir", "set_channel_drive_folder", "clear_channel_drive_folder", "add_channel_mcps", "remove_channel_mcps", "update_channel_instructions", "update_gateway", "restart_gateway", "update_gateway_guide", "reset_gateway_guide", @@ -367,7 +367,7 @@ test("every registered gateway tool is consciously classified as gated or open ( ]); const OPEN = new Set([ // read-only - "list_available_mcps", "list_channel_mcps", "list_schedules", "list_folders", + "get_channel_vpn_status", "list_available_mcps", "list_channel_mcps", "list_schedules", "list_folders", "get_channel_workdir", "get_channel_drive_folder", "get_gateway_guide", "workspace_list", "workspace_read", "workspace_search", "search_channel_memory", "read_channel_memory", // channel-scoped read-only retrieval diff --git a/test/slack-vpn-settings.test.js b/test/slack-vpn-settings.test.js new file mode 100644 index 0000000..8e4a558 --- /dev/null +++ b/test/slack-vpn-settings.test.js @@ -0,0 +1,166 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ensureTestEnv } from "./helpers.js"; +ensureTestEnv(); +const { buildChannelSettingsView, assertVpnActionBinding, parseActionValue, settingsMetadata, + CHANNEL_SETTINGS_VPN_TOGGLE_ACTION_ID: TOGGLE, CHANNEL_SETTINGS_VPN_REFRESH_ACTION_ID: REFRESH } = await import("../src/slack/channel-settings.js"); +const { handleChannelVpnSettingsAction, channelVpnSettingsContext, hydrateChannelVpnSettings } = await import("../src/slack/app.js"); +const store = await import("../src/config/store.js"); +const state = { channelId: "CVPNSETTINGS", slug: "vpn-settings", ownerId: "UVPNMANAGER", tab: "network" }; +const off = { configured: true, enabled: false, state: "off", message: "VPN is off.", missingSecrets: [], busy: false, allowNetwork: true }; +const buttons = (view) => view.blocks.flatMap((b) => b.elements || []).filter((el) => el.type === "button"); +const render = (vpn, canManageVpn = true, actorState = state) => buildChannelSettingsView({ mode: { allowNetwork: true }, vpn }, actorState, { tab: "network", canManageVpn }); +const actionFor = (vpn, actorState = state) => buttons(render(vpn, true, actorState)).find((b) => b.action_id === TOGGLE); + +// User-visible states must distinguish starting a service from an established VPN, including +// failures with autostart still enabled. No button may silently change the channel network policy. +test("Network shows honest state, missing credentials and scoped manager controls", () => { + for (const [status, label] of [["off", "Off"], ["starting", "Starting — not connected yet"], ["on", "On — connected"], ["failed", "Failed — not connected"], ["unconfigured", "Not configured"], ["unavailable", "Unavailable"]]) { + const vpn = { ...off, state: status, enabled: ["starting", "on", "failed"].includes(status), configured: status !== "unconfigured" }; + const view = render(vpn); + assert.match(JSON.stringify(view), new RegExp(label)); + assert.ok(buttons(view).some((b) => b.action_id === REFRESH)); + assert.equal(buttons(view).some((b) => b.action_id === TOGGLE), !["unconfigured", "unavailable"].includes(status)); + if (status === "starting") assert.equal(parseActionValue(actionFor(vpn).value).enabled, false); + assert.equal(buttons(render(vpn, false)).some((b) => b.action_id === TOGGLE), false); + } + for (const vpn of [{ ...off, allowNetwork: false }, { ...off, busy: true }, { ...off, missingSecrets: ["VPN_PASSWORD"] }]) { + assert.equal(buttons(render(vpn)).some((b) => b.action_id === TOGGLE), false); + } + assert.match(JSON.stringify(render({ ...off, missingSecrets: ["VPN_PASSWORD"] })), /VPN_PASSWORD/); + assert.match(JSON.stringify(render(undefined)), /Checking status/); + assert.equal(buttons(render({ ...off, state: "failed" })).filter((b) => b.action_id === TOGGLE).length, 1); + const manuallyStarted = { ...off, enabled: false, state: "failed", running: true }; + assert.equal(parseActionValue(actionFor(manuallyStarted).value).enabled, false); + assert.equal(parseActionValue(actionFor({ ...manuallyStarted, allowNetwork: false, missingSecrets: ["VPN_PASSWORD"] }).value).enabled, false); + assert.match(JSON.stringify(render(off)), /does not route the ordinary agent container/); +}); + +test("VPN actions bind channel, slug, owner and requested operation", () => { + const action = actionFor(off); + const command = parseActionValue(action.value); + assert.doesNotThrow(() => assertVpnActionBinding(state, command, TOGGLE)); + for (const forged of [{ ...state, channelId: "COTHER" }, { ...state, slug: "other" }, { ...state, ownerId: "UOTHER" }]) { + assert.throws(() => assertVpnActionBinding(forged, { ...command, c: forged.channelId, u: forged.ownerId }, TOGGLE), /expired/); + } + for (const patch of [{ enabled: false }, { signature: "" }, { signature: "00" }, { o: "vpn_refresh" }, { enabled: "true" }]) { + assert.throws(() => assertVpnActionBinding(state, { ...command, ...patch }, TOGGLE), /expired/); + } +}); + +function request(vpn = off, actorState = state) { + const updates = []; + const events = []; + return { updates, events, params: { + ack: async () => events.push("ack"), action: actionFor(vpn, actorState), + body: { user: { id: actorState.ownerId }, view: { ...render(vpn, true, actorState), id: "VVPN", hash: "view-hash" } }, + client: { views: { update: async (payload) => { updates.push(payload); return { view: payload.view }; } } }, + } }; +} + +const fixtureContext = async () => ({ entry: { name: "VPN test" }, meta: {}, userIsAdmin: true }); +const fixtureRootView = async (_entry, _meta, actorState, _admin, { vpn }) => render(vpn, true, actorState); + +test("VPN actions ACK before authority checks/service calls and show returned starting status", async () => { + const { events, updates, params } = request(); + const starting = { ...off, enabled: true, state: "starting" }; + await handleChannelVpnSettingsAction(params, { + context: async (...args) => { assert.equal(events[0], "ack"); events.push(args[3]?.manage ? "manage" : "read"); return fixtureContext(); }, + setEnabled: async (channel, enabled, options) => { + assert.equal(channel, state.channelId); assert.equal(enabled, true); + assert.equal(options.actor, state.ownerId); assert.equal(options.source, "slack_settings"); + assert.equal(await options.authorize(), true); + return starting; + }, rootView: fixtureRootView, + }); + assert.deepEqual(events, ["ack", "manage", "manage", "read"]); + assert.equal(updates[0].hash, "view-hash"); + assert.match(JSON.stringify(updates[0]), /Starting — not connected yet/); +}); + +test("forged owner or metadata cannot call the service; safe backend errors reach the view", async () => { + for (const forge of [ + (p) => { p.body.user.id = "UOTHER"; }, + (p) => { p.body.view.private_metadata = settingsMetadata({ ...state, slug: "forged" }); }, + (p) => { p.action.value = JSON.stringify({ ...parseActionValue(p.action.value), enabled: false }); }, + ]) { + const { params, updates } = request(); forge(params); + await handleChannelVpnSettingsAction(params, { context: async () => assert.fail("must fail before context"), setEnabled: async () => assert.fail("must not mutate") }); + assert.match(JSON.stringify(updates), /expired|isn't yours/); + } + const { params, updates } = request(); + await handleChannelVpnSettingsAction(params, { context: fixtureContext, setEnabled: async () => { throw new Error("VPN server certificate validation failed."); } }); + assert.match(JSON.stringify(updates), /certificate validation failed/); +}); + +async function fixture() { + await store.ensureRoot(); + const entry = await store.upsertChannelEntry(state.channelId, { name: state.slug, type: "channel", isDM: false }); + await store.setUser(state.ownerId, { approved: true, isAdmin: false }); + await store.saveChannelMeta(entry.slug, { ...store.defaultChannelMeta({ channelId: entry.channelId, name: entry.name }), access: "approved", manageAccess: "custom", managers: [state.ownerId] }); + const client = { conversations: { members: async () => ({ members: [state.ownerId] }) } }; + return { entry, client, actorState: { ...state, slug: entry.slug } }; +} + +test("fresh VPN authority rejects revocation during membership lookup and channel departure", async () => { + const { entry, client, actorState } = await fixture(); + await channelVpnSettingsContext(client, actorState, state.ownerId, { manage: true }); + client.conversations.members = async () => { + await store.patchChannelMeta(entry.slug, { managers: [] }); + return { members: [state.ownerId] }; + }; + await assert.rejects(() => channelVpnSettingsContext(client, actorState, state.ownerId, { manage: true }), /current channel managers/); + // Reading remains allowed for an authorized member who cannot manage the channel. + await channelVpnSettingsContext(client, actorState, state.ownerId); + client.conversations.members = async () => ({ members: [] }); + await assert.rejects(() => channelVpnSettingsContext(client, actorState, state.ownerId), /no longer a member/); +}); + +test("a manager demoted after the initial check cannot authorize queued VPN changes", async () => { + const { entry, client, actorState } = await fixture(); + const { params, updates } = request(off, actorState); + params.client.conversations = client.conversations; + let applied = false; + await handleChannelVpnSettingsAction(params, { setEnabled: async (_channel, _enabled, { authorize }) => { + await store.patchChannelMeta(entry.slug, { managers: [] }); + await authorize(); + applied = true; + return off; + } }); + assert.equal(applied, false); + assert.match(JSON.stringify(updates), /current channel managers/); +}); + +test("status hydration targets the opened view hash and never overwrites newer navigation", async () => { + const updates = []; + await hydrateChannelVpnSettings({ views: { update: async (payload) => { updates.push(payload); throw Object.assign(new Error("stale view"), { data: { error: "hash_conflict" } }); } } }, + { id: "VALREADYOPEN", hash: "opened-hash" }, state, + { context: fixtureContext, status: async () => off, rootView: fixtureRootView }); + assert.equal(updates.length, 1); + assert.equal(updates[0].view_id, "VALREADYOPEN"); + assert.equal(updates[0].hash, "opened-hash"); +}); + + +test("authorized members refresh VPN status without mutation authority", async () => { + const { client, actorState, entry } = await fixture(); + await store.patchChannelMeta(entry.slug, { managers: [] }); + const { params, updates, events } = request(off, actorState); + params.client.conversations = client.conversations; + params.action = buttons(render(off, false, actorState)).find((b) => b.action_id === REFRESH); + await handleChannelVpnSettingsAction(params, { + status: async (channel) => { assert.equal(events[0], "ack"); assert.equal(channel, state.channelId); return off; }, + setEnabled: async () => assert.fail("refresh must not change VPN"), + }); + assert.match(JSON.stringify(updates), /VPN is off/); + assert.equal(buttons(updates[0].view).some((b) => b.action_id === TOGGLE), false); +}); + +test("global role revoked during Slack membership lookup blocks VPN read and write", async () => { + const { client, actorState } = await fixture(); + client.conversations.members = async () => { + await store.setUser(state.ownerId, { approved: false, isAdmin: false }); + return { members: [state.ownerId] }; + }; + await assert.rejects(() => channelVpnSettingsContext(client, actorState, state.ownerId, { manage: true }), /not authorized/); +}); From 8f76b18f8c75dc4f8267e42d1d83317360849704 Mon Sep 17 00:00:00 2001 From: Tiberiu Socaci Date: Fri, 18 Sep 2026 14:27:19 +0300 Subject: [PATCH 2/2] test: recognize inline VPN tool registrations in stable catalog Signed-off-by: Tiberiu Socaci --- test/folders-settings.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/folders-settings.test.js b/test/folders-settings.test.js index f23eb66..c0992d4 100644 --- a/test/folders-settings.test.js +++ b/test/folders-settings.test.js @@ -232,7 +232,7 @@ test("gateway MCP permission list tracks registered gateway tools", () => { const source = toolModules .map((file) => readFileSync(new URL(`../src/mcp/tools/${file}`, import.meta.url), "utf8")) .join("\n"); - const registered = [...source.matchAll(/server\.registerTool\(\s*\n\s*"([^"]+)"/g)].map((m) => m[1]); + const registered = [...source.matchAll(/server\.registerTool\(\s*"([^"]+)"/g)].map((m) => m[1]); assert.deepEqual([...GATEWAY_TOOL_NAMES].sort(), [...registered].sort()); });