The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 lines
6.4 KiB
JSON
15 lines
6.4 KiB
JSON
{
|
|
"assetKey": "a13-f008-host-wire",
|
|
"kind": "terminal",
|
|
"command": "set -e\nset -o pipefail\nD=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT\nC=/shared/codebase/clean-architecture-backend-template\nN=$C/src/adapter/outbound/notification\nCACHE=/root/.gradle/caches/modules-2/files-2.1\njava -version 2>&1 | head -1\ncp=\"\"\nwhile IFS= read -r line; do\n coord=${line%%=*}; confs=${line#*=}\n case \",$confs,\" in *\",runtimeClasspath,\"*) ;; *) continue ;; esac\n g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}\n jar=$(find \"$CACHE/$g/$n/$v\" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)\n [ -n \"$jar\" ] && cp=\"$cp:$jar\"\ndone < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' \"$N/gradle.lockfile\")\nCP=$(find \"$N/build/libs\" \"$C/src/application-core/build/libs\" -name '*+21234e38cdb9.jar' | tr '\\n' ':')${cp#:}\ncat > \"$D/SignedHostProbe.java\" <<'JAVA'\nimport com.sun.net.httpserver.HttpServer;\nimport dev.caskeleton.adapter.outbound.notification.platform.provider.http.JdkNotificationHttpGateway;\nimport dev.caskeleton.adapter.outbound.notification.platform.provider.http.NotificationHttpRequest;\nimport java.net.InetSocketAddress;\nimport java.net.URI;\nimport java.nio.charset.StandardCharsets;\nimport java.time.Duration;\nimport java.util.LinkedHashMap;\nimport java.util.Map;\nimport java.util.concurrent.atomic.AtomicReference;\n\n/** 매퍼가 서명하는 호스트 값과 JDK 가 실제로 보내는 Host 헤더를 나란히 잰다. */\npublic final class SignedHostProbe {\n\n private static final String PATH = \"/v2/email/outbound-emails\";\n\n /** SesRequestMapper:103 이 쓰는 식 그대로. */\n private static String signedHost(URI endpoint) {\n return endpoint.getHost();\n }\n\n /** ExternalRequestUrlResolver:57-63 이 쓰는 판정. 전선의 Host 가 따르는 규칙이다. */\n private static boolean defaultPort(String scheme, int port) {\n return port < 0\n || (\"https\".equalsIgnoreCase(scheme) && port == 443)\n || (\"http\".equalsIgnoreCase(scheme) && port == 80);\n }\n\n private static String expectedWireHost(URI endpoint) {\n return defaultPort(endpoint.getScheme(), endpoint.getPort())\n ? endpoint.getHost()\n : endpoint.getHost() + \":\" + endpoint.getPort();\n }\n\n public static void main(String[] args) throws Exception {\n HttpServer server = HttpServer.create(new InetSocketAddress(\"127.0.0.1\", 0), 0);\n AtomicReference<String> received = new AtomicReference<>(\"(받지 못함)\");\n server.createContext(PATH, exchange -> {\n received.set(String.valueOf(exchange.getRequestHeaders().getFirst(\"Host\")));\n byte[] ok = \"{}\".getBytes(StandardCharsets.UTF_8);\n exchange.sendResponseHeaders(200, ok.length);\n exchange.getResponseBody().write(ok);\n exchange.close();\n });\n server.start();\n int port = server.getAddress().getPort();\n URI endpoint = URI.create(\"http://127.0.0.1:\" + port);\n String signed = signedHost(endpoint);\n JdkNotificationHttpGateway gateway = new JdkNotificationHttpGateway(Duration.ofSeconds(5));\n\n try {\n System.out.println();\n System.out.println(\"[헤더 맵 세 모양으로 같은 서버에 보낸다]\");\n Map<String, Map<String, String>> shapes = new LinkedHashMap<>();\n shapes.put(\"매퍼가 실제로 보내는 넷\", Map.of(\n \"content-type\", \"application/json\", \"x-amz-date\", \"20260903T000000Z\",\n \"x-amz-content-sha256\", \"UNSIGNED-PAYLOAD\", \"authorization\", \"AWS4-HMAC-SHA256 ...\"));\n shapes.put(\"거기에 host 를 더한 것\", Map.of(\"content-type\", \"application/json\", \"host\", signed));\n shapes.put(\"대문자 Host 로 더한 것\", Map.of(\"content-type\", \"application/json\", \"Host\", signed));\n for (var shape : shapes.entrySet()) {\n received.set(\"(받지 못함)\");\n NotificationHttpRequest request =\n new NotificationHttpRequest(\"POST\", URI.create(endpoint + PATH),\n JdkNotificationHttpGateway.headers(shape.getValue()),\n \"{}\".getBytes(StandardCharsets.UTF_8), Duration.ofSeconds(5));\n int status = gateway.exchange(request).statusCode();\n // 포트는 실행마다 다르다. 재는 것은 두 값이 갈리느냐이므로 자리표시로 바꾼다.\n System.out.printf(\" %-22s 상태 %d · 서버가 받은 Host %s%n\",\n shape.getKey(), status, received.get().replace(Integer.toString(port), \"<포트>\"));\n }\n System.out.printf(\" %-22s %s%n\", \"서명 대상\", signed);\n System.out.printf(\" %-22s %s%n\", \"둘이 같은가\",\n received.get().equals(signed) ? \"예\" : \"아니오\");\n System.out.println();\n\n System.out.println(\"[갈리는 조건 — 위 규칙을 엔드포인트마다 적용한다]\");\n System.out.println(\" 전선과 갈리면 서명이 어긋난다. 권한부는 갈려도 어긋나지 않는 자리가 있다.\");\n System.out.printf(\" %-46s %-14s %s%n\", \"엔드포인트\", \"전선과 갈리나\", \"권한부와 갈리나\");\n URI[] endpoints = {\n URI.create(\"https://email.eu-central-1.amazonaws.com\"),\n URI.create(\"https://email.eu-central-1.amazonaws.com:8443\"),\n URI.create(\"https://email.eu-central-1.amazonaws.com:443\"),\n URI.create(\"http://127.0.0.1:80\"),\n URI.create(\"https://user@email.example.com\"),\n URI.create(\"http://localhost:4566\"),\n };\n for (URI each : endpoints) {\n String host = signedHost(each);\n System.out.printf(\" %-46s %-16s %s%n\", each,\n host.equals(expectedWireHost(each)) ? \"아니오\" : \"예\",\n host.equals(each.getAuthority()) ? \"아니오\" : \"예\");\n }\n } finally {\n server.stop(0);\n }\n }\n}\nJAVA\njavac -encoding UTF-8 -cp \"$CP\" -d \"$D\" \"$D/SignedHostProbe.java\"\njava -Dstdout.encoding=UTF-8 -cp \"$CP:$D\" SignedHostProbe\n",
|
|
"cwd": "/shared/codebase/clean-architecture-backend-template/src",
|
|
"exitCode": 0,
|
|
"executedAt": "2026-09-02T17:45:59+00:00",
|
|
"sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916",
|
|
"raw": "evidence/raw/a13-f008-host-wire.txt",
|
|
"svg": "evidence/rendered/a13-f008-host-wire.svg",
|
|
"rawSha256": "f84c7e457cf0302579f938585c75582132cde272b0d521f9c68e0e9a9e0cfcea",
|
|
"lines": 18,
|
|
"redaction": "none — 엔드포인트와 헤더 값과 응답은 이 프로브가 만든 합성 값이다"
|
|
}
|