|
|
|
@@ -851,9 +851,9 @@ if (/\|\|\s*true|;\s*true\b/.test(updateShell)) throw new Error("POSIX source up
|
|
|
|
|
requirePattern("POSIX source update does not fail closed: source pull", updateShell,
|
|
|
|
|
/if ! git pull --ff-only; then abort_update/);
|
|
|
|
|
requirePattern("POSIX source update does not fail closed: installation status", updateShell,
|
|
|
|
|
/if ! INSTALLATION_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/);
|
|
|
|
|
/if ! INSTALLATION_STATUS="\$\("\$THT_BIN" --installation "\$INSTALLATION" status\)"; then/);
|
|
|
|
|
requirePattern("POSIX source update does not fail closed: Pi status", updateShell,
|
|
|
|
|
/if ! RUNNING_PI_VERSION="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/);
|
|
|
|
|
/if ! RUNNING_PI_VERSION="\$\("\$THT_BIN" --installation "\$INSTALLATION" pi status\)"; then/);
|
|
|
|
|
requirePattern("POSIX source update does not fail closed: local build", updateShell,
|
|
|
|
|
/if ! bash scripts\/build-local\.sh; then/);
|
|
|
|
|
requirePattern("POSIX source update does not fail closed: tht build", updateShell,
|
|
|
|
@@ -861,12 +861,12 @@ requirePattern("POSIX source update does not fail closed: tht build", updateShel
|
|
|
|
|
requirePattern("POSIX source update lacks the same-version/no-selector path", updateShell,
|
|
|
|
|
/if \[\[ "\$NEXT_PI_VERSION" == "\$RUNNING_PI_VERSION" \]\]; then[\s\S]*"\$USES_BASE_CORE" == true[\s\S]*TRANSACTIONAL_PI_UPDATE=false/);
|
|
|
|
|
for (const [label, pattern] of [
|
|
|
|
|
["installation start", /if ! "\$THTCTL" --installation "\$INSTALLATION" start; then/],
|
|
|
|
|
["installation start", /if ! "\$THT_BIN" --installation "\$INSTALLATION" start; then/],
|
|
|
|
|
["frontend health", /if ! curl --fail http:\/\/127\.0\.0\.1:8080\/health; then/],
|
|
|
|
|
["core health", /if ! curl --fail http:\/\/127\.0\.0\.1:8787\/health; then/],
|
|
|
|
|
["final status", /if ! FINAL_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" status\)"; then/],
|
|
|
|
|
["final Pi status", /if ! FINAL_PI_STATUS="\$\("\$THTCTL" --installation "\$INSTALLATION" pi status\)"; then/],
|
|
|
|
|
["final doctor", /if ! "\$THTCTL" --installation "\$INSTALLATION" doctor; then/],
|
|
|
|
|
["final status", /if ! FINAL_STATUS="\$\("\$THT_BIN" --installation "\$INSTALLATION" status\)"; then/],
|
|
|
|
|
["final Pi status", /if ! FINAL_PI_STATUS="\$\("\$THT_BIN" --installation "\$INSTALLATION" pi status\)"; then/],
|
|
|
|
|
["final doctor", /if ! "\$THT_BIN" --installation "\$INSTALLATION" doctor; then/],
|
|
|
|
|
]) requirePattern(`POSIX source update does not fail closed: ${label}`, updateShell, pattern);
|
|
|
|
|
const provenance = updateShell.indexOf("printf 'Built source revision:");
|
|
|
|
|
if (provenance < updateShell.lastIndexOf("require_clean_source") ||
|
|
|
|
@@ -883,14 +883,14 @@ requireTokens("native PowerShell source update", updatePowerShell, [
|
|
|
|
|
]);
|
|
|
|
|
for (const [command, step] of [
|
|
|
|
|
["git pull --ff-only", "source pull"],
|
|
|
|
|
["$InstallationStatus = @(& $THTCTL --installation $INSTALLATION status)", "installation status"],
|
|
|
|
|
["$RunningPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "Pi status"],
|
|
|
|
|
["$InstallationStatus = @(& $THT_BIN --installation $INSTALLATION status)", "installation status"],
|
|
|
|
|
["$RunningPiStatus = (& $THT_BIN --installation $INSTALLATION pi status)", "Pi status"],
|
|
|
|
|
["powershell -ExecutionPolicy Bypass -File scripts/build-local.ps1", "local image build"],
|
|
|
|
|
["& \"C:\\Program Files\\Git\\bin\\bash.exe\" scripts/build-tht.sh", "tht build"],
|
|
|
|
|
["curl.exe --fail --silent --show-error http://127.0.0.1:8080/health", "frontend health check"],
|
|
|
|
|
["curl.exe --fail --silent --show-error http://127.0.0.1:8787/health", "core health check"],
|
|
|
|
|
["$FinalPiStatus = (& $THTCTL --installation $INSTALLATION pi status)", "final Pi status"],
|
|
|
|
|
["& $THTCTL --installation $INSTALLATION doctor", "final doctor"],
|
|
|
|
|
["$FinalPiStatus = (& $THT_BIN --installation $INSTALLATION pi status)", "final Pi status"],
|
|
|
|
|
["& $THT_BIN --installation $INSTALLATION doctor", "final doctor"],
|
|
|
|
|
]) {
|
|
|
|
|
const commandAt = updatePowerShell.indexOf(command);
|
|
|
|
|
const checkAt = updatePowerShell.indexOf(`Assert-NativeSuccess '${step}'`, commandAt);
|
|
|
|
@@ -963,7 +963,7 @@ NODE
|
|
|
|
|
(
|
|
|
|
|
cd "$update_fixture/project"
|
|
|
|
|
env PATH="$update_fixture/bin:$PATH" CALLS="$calls" FAIL_STEP="$fixture_step" \
|
|
|
|
|
THTCTL="$update_fixture/bin/tht" INSTALLATION="$update_fixture/installation.yaml" \
|
|
|
|
|
THT_BIN="$update_fixture/bin/tht" INSTALLATION="$update_fixture/installation.yaml" \
|
|
|
|
|
/bin/bash "$update_script"
|
|
|
|
|
) >"$output" 2>&1
|
|
|
|
|
status=$?
|
|
|
|
@@ -1339,7 +1339,6 @@ verify_reverse_proxy_nginx_guide() {
|
|
|
|
|
}
|
|
|
|
|
require_headings "$guide" "Nginx reverse-proxy guide" \
|
|
|
|
|
"Trust boundary" \
|
|
|
|
|
"Example configuration" \
|
|
|
|
|
"Validate and reload" \
|
|
|
|
|
"Test authentication and SSE"
|
|
|
|
|
require_text "$guide" "Nginx reverse-proxy guide" \
|
|
|
|
@@ -1437,16 +1436,69 @@ function nginxLocations(text) {
|
|
|
|
|
return locations;
|
|
|
|
|
}
|
|
|
|
|
const locations = nginxLocations(effectiveBlock);
|
|
|
|
|
const authLocations = locations.filter((location) => location.selector === "= /_authenticate");
|
|
|
|
|
if (authLocations.length !== 1) {
|
|
|
|
|
throw new Error("Nginx proxy must define exactly one authentication location");
|
|
|
|
|
}
|
|
|
|
|
const authLocation = authLocations[0].body;
|
|
|
|
|
const frontendLocations = locations.filter((location) =>
|
|
|
|
|
/proxy_pass\s+http:\/\/127\.0\.0\.1:8080\s*;/.test(location.body));
|
|
|
|
|
if (frontendLocations.length === 0) {
|
|
|
|
|
throw new Error("Nginx proxy lacks a frontend upstream location");
|
|
|
|
|
}
|
|
|
|
|
const authenticatedFrontendLocations = frontendLocations.filter((location) =>
|
|
|
|
|
/auth_request\s+\/_authenticate\s*;/.test(location.body));
|
|
|
|
|
const directFrontendLocations = frontendLocations.filter((location) =>
|
|
|
|
|
!/auth_request\s+\/_authenticate\s*;/.test(location.body));
|
|
|
|
|
if (directFrontendLocations.length > 1) {
|
|
|
|
|
throw new Error("Nginx direct OIDC mode contains an additional frontend bypass location");
|
|
|
|
|
}
|
|
|
|
|
for (const frontendLocation of frontendLocations) {
|
|
|
|
|
for (const token of [
|
|
|
|
|
"proxy_http_version 1.1;", "proxy_buffering off;", "proxy_cache off;",
|
|
|
|
|
"proxy_read_timeout 3600s;",
|
|
|
|
|
]) {
|
|
|
|
|
if (!frontendLocation.body.includes(token)) {
|
|
|
|
|
throw new Error(`Nginx proxy lacks structural token: ${token}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (authenticatedFrontendLocations.length > 0) {
|
|
|
|
|
const authLocations = locations.filter((location) => location.selector === "= /_authenticate");
|
|
|
|
|
if (authLocations.length !== 1) {
|
|
|
|
|
throw new Error("Nginx proxy must define exactly one authentication location for upstream mode");
|
|
|
|
|
}
|
|
|
|
|
const authLocation = authLocations[0].body;
|
|
|
|
|
for (const [label, publicName, variable, upstream] of identities) {
|
|
|
|
|
const trustedName = publicName === "Is-Admin" ? "Is-Admin" : publicName;
|
|
|
|
|
const publicClear = new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`);
|
|
|
|
|
const trustedHeader = `X-Thoth-Trusted-${trustedName}`;
|
|
|
|
|
const trustedClear = new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+"";`);
|
|
|
|
|
const authPublicAt = authLocation.search(publicClear);
|
|
|
|
|
const authTrustedAt = authLocation.search(trustedClear);
|
|
|
|
|
if (authPublicAt < 0) {
|
|
|
|
|
throw new Error(`Nginx auth location does not clear inbound ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
if (authTrustedAt < 0) {
|
|
|
|
|
throw new Error(`Nginx auth location does not clear inbound trusted ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
for (const frontendLocation of authenticatedFrontendLocations) {
|
|
|
|
|
const frontendPublicAt = frontendLocation.body.search(publicClear);
|
|
|
|
|
if (frontendPublicAt < 0) {
|
|
|
|
|
throw new Error(`Nginx frontend location does not clear inbound ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
const normalizedFrontend = frontendLocation.body.replace(/\s+/g, " ");
|
|
|
|
|
const captureAt = normalizedFrontend.search(new RegExp(`auth_request_set\\s+\\$${variable}\\s+\\$upstream_http_${upstream};`));
|
|
|
|
|
if (captureAt < 0) {
|
|
|
|
|
throw new Error(`Nginx frontend location does not capture authenticated ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
const mapAt = normalizedFrontend.search(new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`));
|
|
|
|
|
if (mapAt < 0) {
|
|
|
|
|
throw new Error(`Nginx frontend location does not map authenticated ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (new RegExp(`auth_request_set\\s+\\$${variable}|proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`).test(authLocation)) {
|
|
|
|
|
throw new Error(`Nginx auth location performs a forbidden ${label} capture or mapping`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if (directFrontendLocations.length === 0) {
|
|
|
|
|
throw new Error("Nginx proxy lacks a direct or authenticated frontend path");
|
|
|
|
|
}
|
|
|
|
|
for (const location of locations) {
|
|
|
|
|
const upstreams = [...location.body.matchAll(/proxy_pass\s+([^;]+);/g)].map((match) => match[1].trim());
|
|
|
|
|
for (const upstream of upstreams) {
|
|
|
|
@@ -1455,41 +1507,9 @@ for (const location of locations) {
|
|
|
|
|
throw new Error(`Nginx location proxies to an unreviewed upstream: ${upstream}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const frontendLocation of frontendLocations) {
|
|
|
|
|
for (const frontendLocation of authenticatedFrontendLocations) {
|
|
|
|
|
if (!/auth_request\s+\/_authenticate\s*;/.test(frontendLocation.body)) {
|
|
|
|
|
throw new Error("Nginx frontend upstream location bypasses complete authentication contract");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (const [label, publicName, variable, upstream] of identities) {
|
|
|
|
|
const trustedName = publicName === "Is-Admin" ? "Is-Admin" : publicName;
|
|
|
|
|
const publicClear = new RegExp(`proxy_set_header\\s+X-Thoth-${escaped(publicName)}\\s+"";`);
|
|
|
|
|
const trustedHeader = `X-Thoth-Trusted-${trustedName}`;
|
|
|
|
|
const trustedClear = new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+"";`);
|
|
|
|
|
const authPublicAt = authLocation.search(publicClear);
|
|
|
|
|
const authTrustedAt = authLocation.search(trustedClear);
|
|
|
|
|
if (authPublicAt < 0) {
|
|
|
|
|
throw new Error(`Nginx auth location does not clear inbound ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
if (authTrustedAt < 0) {
|
|
|
|
|
throw new Error(`Nginx auth location does not clear inbound trusted ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
for (const frontendLocation of frontendLocations) {
|
|
|
|
|
const frontendPublicAt = frontendLocation.body.search(publicClear);
|
|
|
|
|
if (frontendPublicAt < 0) {
|
|
|
|
|
throw new Error(`Nginx frontend location does not clear inbound ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
const normalizedFrontend = frontendLocation.body.replace(/\s+/g, " ");
|
|
|
|
|
const captureAt = normalizedFrontend.search(new RegExp(`auth_request_set\\s+\\$${variable}\\s+\\$upstream_http_${upstream};`));
|
|
|
|
|
if (captureAt < 0) {
|
|
|
|
|
throw new Error(`Nginx frontend location does not capture authenticated ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
const mapAt = normalizedFrontend.search(new RegExp(`proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`));
|
|
|
|
|
if (mapAt < 0) {
|
|
|
|
|
throw new Error(`Nginx frontend location does not map authenticated ${label} identity`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (new RegExp(`auth_request_set\\s+\\$${variable}|proxy_set_header\\s+${escaped(trustedHeader)}\\s+\\$${variable};`).test(authLocation)) {
|
|
|
|
|
throw new Error(`Nginx auth location performs a forbidden ${label} capture or mapping`);
|
|
|
|
|
throw new Error("Nginx authenticated frontend path bypasses complete authentication contract");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
NODE
|
|
|
|
@@ -1504,21 +1524,20 @@ verify_reverse_proxy_caddy_guide() {
|
|
|
|
|
}
|
|
|
|
|
require_headings "$guide" "Caddy reverse-proxy guide" \
|
|
|
|
|
"Trust boundary" \
|
|
|
|
|
"Example configuration" \
|
|
|
|
|
"Validate and reload" \
|
|
|
|
|
"Test authentication and SSE"
|
|
|
|
|
require_text "$guide" "Caddy reverse-proxy guide" \
|
|
|
|
|
"Forwarding identity headers alone does not authenticate a user" \
|
|
|
|
|
"authentication gateway" \
|
|
|
|
|
"2xx" \
|
|
|
|
|
"automatic HTTPS" \
|
|
|
|
|
"Caddy terminates TLS" \
|
|
|
|
|
"frontend"
|
|
|
|
|
node - "$guide" <<'NODE'
|
|
|
|
|
const fs = require("fs");
|
|
|
|
|
const source = fs.readFileSync(process.argv[2], "utf8");
|
|
|
|
|
const block = [...source.matchAll(/```caddyfile\n([\s\S]*?)```/g)].map((match) => match[1]).join("\n");
|
|
|
|
|
const tokens = [
|
|
|
|
|
"thoth.example.com {", "route {",
|
|
|
|
|
"thoth.example.invalid {", "route {",
|
|
|
|
|
"forward_auth auth-gateway:4180 {", "uri /verify", "copy_headers {",
|
|
|
|
|
"reverse_proxy 127.0.0.1:8080 {", "flush_interval -1",
|
|
|
|
|
];
|
|
|
|
@@ -1730,15 +1749,15 @@ verify_manual() {
|
|
|
|
|
'export THT_SOURCE_ROOT=/absolute/path/to/ThothII'
|
|
|
|
|
'thothii-installation.yaml'
|
|
|
|
|
'workspaceRepository'
|
|
|
|
|
'"$THTCTL" --installation "$INSTALLATION" start'
|
|
|
|
|
'"$THTCTL" --installation "$INSTALLATION" doctor'
|
|
|
|
|
'"$THT_BIN" --installation "$INSTALLATION" start'
|
|
|
|
|
'"$THT_BIN" --installation "$INSTALLATION" doctor'
|
|
|
|
|
)
|
|
|
|
|
else
|
|
|
|
|
expected_steps=(
|
|
|
|
|
'THTCTL=/srv/thothii/operator/tht'
|
|
|
|
|
'THT_BIN=/srv/thothii/operator/tht'
|
|
|
|
|
'INSTALLATION=/srv/thothii/operator/thothii-installation.yaml'
|
|
|
|
|
'"$THTCTL" --installation "$INSTALLATION" start'
|
|
|
|
|
'"$THTCTL" --installation "$INSTALLATION" doctor'
|
|
|
|
|
'"$THT_BIN" --installation "$INSTALLATION" start'
|
|
|
|
|
'"$THT_BIN" --installation "$INSTALLATION" doctor'
|
|
|
|
|
'docs/install/examples/thothii-installation.server.yaml'
|
|
|
|
|
'compose.yaml'
|
|
|
|
|
'deploy/compose.server.yaml'
|
|
|
|
@@ -1829,7 +1848,7 @@ for required in (
|
|
|
|
|
|
|
|
|
|
ui = (root / "frontend/src/shell/WorkspaceManager.tsx").read_text()
|
|
|
|
|
for required in (
|
|
|
|
|
"Create a local workspace",
|
|
|
|
|
"Create a workspace repository",
|
|
|
|
|
"Update workspace repository",
|
|
|
|
|
"No workspace selection is required",
|
|
|
|
|
"Temporary files are deleted after the test",
|
|
|
|
@@ -2024,8 +2043,9 @@ if (Object.keys(config.services).sort().join(",") !== "core,embedding,embedding-
|
|
|
|
|
}
|
|
|
|
|
const core = config.services.core;
|
|
|
|
|
const frontend = config.services.frontend;
|
|
|
|
|
if (core.environment?.AUTH_MODE !== "upstream" || core.environment?.THOTH_PUBLIC_EXPOSURE !== "true") {
|
|
|
|
|
throw new Error("server installation example must fail closed behind upstream authentication");
|
|
|
|
|
if (core.environment?.THOTH_PUBLIC_EXPOSURE !== "true" ||
|
|
|
|
|
core.environment?.THT_AUTH_CONFIG_FILE !== "/run/thothii-auth/auth.yaml") {
|
|
|
|
|
throw new Error("server installation example must expose only the authenticated frontend");
|
|
|
|
|
}
|
|
|
|
|
if ((core.ports || []).length !== 0) throw new Error("server installation example published core");
|
|
|
|
|
const ports = frontend.ports || [];
|
|
|
|
|