Skip to content
Draft
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
1 change: 1 addition & 0 deletions apps/fractal-web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"scripts": {
"test": "vitest run",
"test:embed": "node scripts/embed-proof.mjs",
"test:fixture-routing": "node scripts/fixture-instance-routing.mjs",
"changelog:registry": "node scripts/changelog-registry.mjs",
"changelog:registry:check": "node scripts/changelog-registry.mjs --check",
"typecheck": "node scripts/changelog-registry.mjs && tsc --noEmit",
Expand Down
106 changes: 106 additions & 0 deletions apps/fractal-web/scripts/fixture-instance-routing.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict'
import { once } from 'node:events'
import { createServer } from 'node:http'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createServer as createViteServer } from 'vite'

// Exercise the real web app and its production gateway middleware, not fixtureSource.
// The two Unix HTTP servers are transport test doubles, not initialized daemon instances.
const app = fileURLToPath(new URL('..', import.meta.url))
const scratch = fileURLToPath(new URL('../../../tmp/fixture-instance-routing/', import.meta.url))
await mkdir(scratch, { recursive: true })
const root = await mkdtemp(join(scratch, 'routing-'))
const authorization = ['Bearer', 'fixture-routing-test'].join(' ')
const calls = { fixture: [], normal: [] }
const gateways = []
let web, browser
const saved = Object.fromEntries(['WF_ST_GATEWAY', 'WF_ST_AUTHORIZATION', 'WF_ST_GATEWAY_HOST'].map(key => [key, process.env[key]]))
const previousCwd = process.cwd()

async function gateway(kind) {
const server = createServer((request, response) => {
calls[kind].push({ path: request.url, authorization: request.headers.authorization })
response.setHeader('content-type', 'application/json')
if (kind === 'normal' || request.headers.authorization !== authorization) {
response.writeHead(403)
response.end(JSON.stringify({ error: 'fixture-credential-refused' }))
} else {
response.end(JSON.stringify({ instance: 'client-control-fixture', capabilities: [] }))
}
})
gateways.push(server)
const socket = join(root, `${kind}-client.sock`)
server.listen(socket)
await once(server, 'listening')
return socket
}

async function serve(socket) {
process.env.WF_ST_GATEWAY = `unix:${socket}`
process.env.WF_ST_AUTHORIZATION = authorization
delete process.env.WF_ST_GATEWAY_HOST
web = await createViteServer({
configFile: `${app}/vite.config.ts`, configLoader: 'runner',
server: { host: '127.0.0.1', port: 0, open: false },
})
await web.listen()
const address = web.httpServer.address()
assert(address && typeof address === 'object')
return `http://127.0.0.1:${address.port}`
}

try {
const fixtureSocket = await gateway('fixture')
const normalSocket = await gateway('normal')
process.chdir(app)
const origin = await serve(fixtureSocket)
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright')
browser = await chromium.launch({ ...(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {}) })
// Fresh, nonpersistent context: never borrow a normal paired browser profile.
const context = await browser.newContext()
const page = await context.newPage()
await page.goto(origin, { waitUntil: 'domcontentloaded' })
assert.equal(new URL(page.url()).origin, origin)
assert.equal(await page.locator('#root').count(), 1)
const routed = await page.evaluate(async () => {
const response = await fetch('/v1/client/capabilities')
return { status: response.status, body: await response.json() }
})
assert.deepEqual(routed, { status: 200, body: { instance: 'client-control-fixture', capabilities: [] } })
assert(calls.fixture.some(call => call.path === '/v1/client/capabilities' && call.authorization === authorization))
assert.deepEqual(calls.normal, [], 'Fixture web origin must never reach the normal client service')
const privileged = await page.evaluate(async () => (await fetch('/v1/status')).status)
assert.equal(privileged, 404, 'The web boundary must not proxy privileged API routes')
await context.close()
await web.close()
web = undefined

// Negative control: the same fixture credential routed to the normal service is refused.
const normalOrigin = await serve(normalSocket)
const refusedContext = await browser.newContext()
const refusedPage = await refusedContext.newPage()
await refusedPage.goto(normalOrigin, { waitUntil: 'domcontentloaded' })
const refused = await refusedPage.evaluate(async () => {
const response = await fetch('/v1/client/capabilities')
return { status: response.status, body: await response.json() }
})
assert.deepEqual(refused, { status: 403, body: { error: 'fixture-credential-refused' } })
assert(calls.normal.some(call => call.path === '/v1/client/capabilities' && call.authorization === authorization))
await refusedContext.close()
console.log(JSON.stringify({ check: 'fixture-instance-real-web-routing', fixtureOrigin: origin, normalOrigin, fixtureRouted: true, normalRefused: true, privilegedRouteExcluded: true, transport: 'unix-test-double', embeddedFixtureSource: false }))
} finally {
await browser?.close()
await web?.close()
for (const gateway of gateways) {
gateway.closeAllConnections()
await new Promise(resolve => gateway.close(resolve))
}
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key]
else process.env[key] = value
}
process.chdir(previousCwd)
await rm(root, { recursive: true, force: true })
}
2 changes: 2 additions & 0 deletions crates/fractal-api-client-v0/src/api/client_v0.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ mod stream_timeline_tests;
/// The client-v0 routes, added to the API router as a [`RouteFamily`].
pub fn routes(app: Router<AppState>) -> Router<AppState> {
app
.route("/v1/client/test-clients/enroll", post(fractal_api_core::api::client_control_enrollment::enroll_http))
.route("/v1/client/test-clients/revoke", post(fractal_api_core::api::client_control_enrollment::revoke_http))
.route("/v1/client/arrangements", get(arrangements::list))
.route(
"/v1/client/arrangements/{person_name}/{uuid}",
Expand Down
25 changes: 25 additions & 0 deletions crates/fractal-api-core/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ mod request_latency;
mod client_observations;
pub mod client_push;
pub mod client_commands;
pub mod client_control_enrollment;
pub use client_push::start_push_registration_cleanup;
pub mod terminal_view;
mod work_response;
Expand Down Expand Up @@ -189,6 +190,7 @@ pub mod records;
mod write_principal;
mod native_delivery;
pub mod replication;
mod local_only;
mod runs;
pub mod sessions;
pub mod work;
Expand Down Expand Up @@ -1058,6 +1060,7 @@ pub fn router_for_transport(state: AppState, transport: ClientTransportBoundary,
(state.clone(), transport),
response_envelope,
))
.layer(from_fn_with_state(state.clone(), local_only::enforce))
.with_state(state)
}

Expand Down Expand Up @@ -6709,6 +6712,9 @@ pub fn remote_unavailable(host: &str) -> ApiError {

/// A known dial-out owner syncs outward but has no inbound owner transport.
pub fn remote_unavailable_for_owner(state: &AppState, host: &str) -> ApiError {
if state.local_only() {
return local_only::refusal(crate::config::NetworkOperation::PeerRelay);
}
let dial_out = if let Some(relay) = &state.client_relay {
relay.is_dial_out_owner(host)
} else {
Expand Down Expand Up @@ -7386,6 +7392,10 @@ async fn serve_reserved_unix_with_lookup_ready(
// Every local connection names its caller, so request counts by client are always on.
let mut diagnostic_connection = crate::relay_trace::Connection::capture();
let peer_pid = local_peer_pid(&stream);
let operator_peer = UnixOperatorPeer {
// Both values are supplied by the kernel, never by request metadata.
same_uid: stream.peer_cred().is_ok_and(|credentials| credentials.uid() == unsafe { libc::geteuid() }),
};
// This must precede dispatch: the numeric peer PID can be recycled while
// a descendant continues using the connector's inherited socket.
let (peer_process, peer_capture_error) = match local_peer_process(&stream) {
Expand Down Expand Up @@ -7456,6 +7466,7 @@ async fn serve_reserved_unix_with_lookup_ready(
let peer_process = peer_process.clone();
async move {
let mut request = request.map(Body::new);
request.extensions_mut().insert(operator_peer);
if let Some(cause) = ancestry_cause {
request.extensions_mut().insert(cause);
}
Expand Down Expand Up @@ -7785,6 +7796,12 @@ fn observed_harness_ancestor(pid: u32, _peer: Option<&fractal_runtime::PeerProce
reason: AncestryUnavailableReason::UnsupportedPlatform })
}

/// Kernel-established Unix connector identity for privileged fixture enrollment.
#[derive(Clone, Copy, Debug)]
pub struct UnixOperatorPeer {
pub same_uid: bool,
}

#[derive(Clone)]
pub struct NativeDeliveryPeer {
pub agent: String,
Expand Down Expand Up @@ -8243,6 +8260,8 @@ pub fn overlay_seat_admission(state: &AppState, items: &mut [Value], history: bo
}

async fn health(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
let network_policy = crate::config::NetworkPolicy::for_state_dir(&state.state_dir);
let network_enabled = !network_policy.is_local_only();
let mut health = json!({
"status": "ready",
"node": state.node,
Expand All @@ -8251,6 +8270,12 @@ async fn health(State(state): State<AppState>) -> Result<Json<Value>, ApiError>
"isolation": isolation_name(fractal_runtime::isolation_mode()),
"store_index": state.store.index().map_err(ApiError::internal)?,
"security": "trusted-network-no-tls-no-acls",
"network_policy": {
"mode": network_policy,
"fleet_join": network_enabled,
"replication": network_enabled,
"peer_relay": network_enabled,
},
"features": {"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"bounded_legacy_events":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"github_plugin":2},
});
// How far this member's messages stream has come. Absent when it holds no shard, so a
Expand Down
Loading
Loading