Files
document-haness/docs/clean-architecture-backend-template/final/evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
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>
2026-09-04 22:51:59 +09:00

130 lines
9.6 KiB
Plaintext

EVD-332 adapter-outbound-httpclient
듀얼스택 localhost 가 영구 TLS 실패를 재시도 가능한 CONNECT 로 강등시킨다
측정일 2026-08-31 · workmachine 컨테이너
────────────────────────────────────────────────────────────────────────
1. 관측: 이 저장소에서 이번 사이클 유일하게 실패한 테스트
────────────────────────────────────────────────────────────────────────
$ ./gradlew :adapter:outbound:httpclient:test
MutualTlsHandshakeContractTest > anUntrustedAuthorityIsAPermanentTlsFailure() FAILED
org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168
MutualTlsHandshakeContractTest > anExpiredCertificateIsAPermanentTlsFailure() FAILED
org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168
MutualTlsHandshakeContractTest > aHostnameMismatchIsAPermanentTlsFailure() FAILED
org.opentest4j.AssertionFailedError at MutualTlsHandshakeContractTest.java:168
283 tests completed, 3 failed (모듈 전체)
5 tests completed, 3 failed (해당 클래스만)
XML 리포트에서 뽑은 실제 메시지 — 세 건 모두 동일:
expected: TLS_HANDSHAKE
but was: CONNECT
실패 지점 MutualTlsHandshakeContractTest.java:164-171
private void assertPermanent(Throwable captured) {
TransportFailure classified =
new ApacheFailureClassifier().classify(captured, AttemptStage.TLS_HANDSHAKE);
assertThat(classified.evidence()).isEqualTo(ExecutionEvidence.NOT_SENT); // 167 통과
assertThat(classified.stage()).isEqualTo(AttemptStage.TLS_HANDSHAKE); // 168 실패
assertThat(classified.category()).isEqualTo(FailureCategory.TLS_PERMANENT);
assertThat(classified.category().permanent()).isTrue();
}
같은 클래스의 나머지 2건은 통과했다:
aClientCertificateSatisfiesAServerThatRequiresOne (startTls(..., true), 핸드셰이크 성공)
aMissingClientCertificateCannotReachTheUpstream (startTls(..., true), RuntimeException 여부만 단언)
실패한 3건만 startTls(..., false) 를 쓴다 — 이것이 처음 눈에 띈 차이였고, 오해의 출발점이었다.
────────────────────────────────────────────────────────────────────────
2. 첫 가설과 그 기각
────────────────────────────────────────────────────────────────────────
가설 A: ApacheFailureClassifier.classify 가 원인 사슬을 바깥→안으로 훑으며 첫 매치를
반환(ApacheFailureClassifier.java:36-43)하므로, Apache 가 SSLHandshakeException 을
연결 예외로 감싸면 CONNECT 규칙(58-64행)이 TLS 규칙(65-72행)을 가린다.
기각. jshell 로 실제 예외 사슬을 떠보니 SSL 예외가 사슬에 아예 없었다:
[0] ResourceAccessException :: ... Connect to https://localhost:56507 failed: Connection refused
[1] HttpHostConnectException :: Connect to https://localhost:56507 failed: Connection refused
분류기는 자기가 받은 것을 정확히 분류했다. 정보는 도착 전에 이미 사라졌다.
가설 B: startTls(..., false) 일 때 MockWebServer 가 뜨지 않는다.
기각. 두 경우 모두 원시 소켓 접속이 성공한다:
=== requireClientAuth=TRUE port=49531 === raw 127.0.0.1: OK raw localhost: OK
=== requireClientAuth=FALSE port=37221 === raw 127.0.0.1: OK raw localhost: OK
────────────────────────────────────────────────────────────────────────
3. 확정된 기전 — 접속 호스트만 바꾼 대조 실험
────────────────────────────────────────────────────────────────────────
동일 서버·동일 클라이언트 신뢰재료, baseUrl 의 호스트만 교체:
=== untrusted-authority @127.0.0.1 ===
[0] ResourceAccessException :: (bad_certificate) PKIX path validation failed: ... signature check failed
[1] javax.net.ssl.SSLHandshakeException :: (bad_certificate) PKIX path validation failed
[2] sun.security.validator.ValidatorException :: PKIX path validation failed
[3] java.security.cert.CertPathValidatorException :: signature check failed
[4] java.security.SignatureException :: Signature does not match.
-> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true ← 테스트가 기대하는 값
=== untrusted-authority @localhost ===
[0] ResourceAccessException :: Connect to https://localhost:40485 failed: Connection refused
[1] HttpHostConnectException :: Connect to https://localhost:40485 failed: Connection refused
-> stage=CONNECT category=CONNECT permanent=false ← 테스트가 본 값
=== expired-certificate @127.0.0.1 ===
[1] SSLHandshakeException :: (certificate_expired) PKIX path validation failed: validity check failed
[4] java.security.cert.CertificateExpiredException :: NotAfter: Sun Aug 30 13:52:15 UTC 2026
-> stage=TLS_HANDSHAKE category=TLS_PERMANENT permanent=true
환경:
/etc/hosts 127.0.0.1 localhost
::1 localhost ip6-localhost ip6-loopback
lo inet6 ::1/128 scope host
InetAddress.getAllByName("localhost") -> [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1]
preferIPv4Stack / preferIPv6Addresses 설정: 저장소·모듈 어디에도 없음
기전:
MockWebServer 는 IPv4 루프백에만 바인딩한다. MockHttpServer.uri() 는 호스트명 "localhost"
를 돌려준다(MockHttpServer.java:70-72). Apache HttpClient 5 의 연결 오퍼레이터는 해석된
주소 전체를 순회하며, 마지막 주소가 아니면 그 주소의 실패를 삼키고 다음으로 넘어간다.
127.0.0.1 → TCP 성공 → TLS 핸드셰이크 실패(진짜 실패) → 삼켜짐
::1 → TCP 거부(듣는 소켓 없음) → 마지막 주소 → HttpHostConnectException 으로 승격
따라서 호출자에게 도달하는 유일한 예외는 두 번째 주소의 연결 거부이고, 진짜 원인인
SSLHandshakeException 은 사슬 어디에도 남지 않는다.
핸드셰이크가 성공하는 테스트가 통과하는 이유도 같은 루프다. 127.0.0.1 에서 성공하면
루프가 즉시 반환하므로 ::1 을 시도하지 않는다. 즉 startTls 의 boolean 은 원인이 아니라
상관관계였다 — 실패하는 케이스가 곧 두 번째 주소까지 가는 케이스다.
────────────────────────────────────────────────────────────────────────
4. 판정
────────────────────────────────────────────────────────────────────────
테스트 실패 자체는 환경 의존이다. ApacheFailureClassifier 에 결함은 없다.
localhost 가 IPv4 로만 풀리는 환경에서는 세 건 모두 통과한다. 프로덕션 코드를 고쳐서
고칠 문제가 아니며, 고쳐야 할 것은 픽스처가 호스트명을 돌려준다는 점이다.
다만 이 실패가 드러낸 런타임 성질은 테스트 잡음이 아니다:
FailureCategory.java:34-44 TLS_PERMANENT.permanent() == true
CONNECT.permanent() == false
DefaultRetryEligibilityEngine.java:37-39
permanent() 이면 RetryDenied.permanentFailure
DefaultRetryEligibilityEngine.failureDecision
case CONNECT -> RetryAllowed.of("CONNECT")
듀얼스택 업스트림에서 한쪽 주소 패밀리는 TLS 를 영구 거절하고 다른 쪽은 연결이 거부되면,
절대 검증되지 않을 인증서에 대한 호출이 CONNECT 로 분류되어 예산·데드라인이 소진될 때까지
매 시도 재시도된다. 분류기가 볼 수 있는 정보 안에서는 이 강등을 막을 방법이 없다.
Apache 의 다중 주소 루프가 앞선 주소의 실패를 버리기 때문이다.
────────────────────────────────────────────────────────────────────────
5. 재현 절차
────────────────────────────────────────────────────────────────────────
CP=<:adapter:outbound:httpclient 의 test 런타임 클래스패스, 존재하는 항목만>
jshell --class-path "$CP" 로 아래를 실행:
MockHttpServer s = MockHttpServer.startTls(TlsFixture.trusted().serverSocketFactory(), false);
// baseUrl 호스트를 "localhost" / "127.0.0.1" 로 각각 바꿔 ApacheBlockingTransportProvider 로 GET
// 잡은 예외의 getCause 사슬 전체와 ApacheFailureClassifier.classify(t, TLS_HANDSHAKE) 결과를 출력
클래스패스는 init 스크립트로 test.classpath 를 출력해 얻었다(저장소 미수정).
애플리케이션 소스는 한 줄도 건드리지 않았다.