Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
14 changes: 10 additions & 4 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 28 additions & 3 deletions TEST-PLAN.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

Expand Down
32 changes: 30 additions & 2 deletions docs/CHANNEL-VPN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
74 changes: 70 additions & 4 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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) ────────────────────────
Expand Down
11 changes: 10 additions & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1117,7 +1117,16 @@ <h2 class="ch-name"></h2>
<span class="switch"></span>
<span class="t"><b>Network</b><small>tells 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</small></span>
</label>
</label>
<div class="ch-vpn-controls" style="margin-top:12px">
<label class="togglerow">
<input type="checkbox" class="ch-vpn-enabled" disabled />
<span class="switch"></span>
<span class="t"><b>VPN</b><small>Start or stop this channel’s configured VPN service. Changes apply immediately.</small></span>
</label>
<p class="state ch-vpn-state" role="status" aria-live="polite">Loading VPN status…</p>
<p class="state ch-vpn-error" role="alert" hidden></p>
<button type="button" class="btn ch-vpn-refresh">Refresh VPN status</button>
</div>

<p class="fldlab" style="margin-top:20px">Guest access — named users</p>
<div class="ch-users checks"></div>
Expand Down
Loading
Loading