Skip to content

fix(source): 원격 prefer 연결의 암호화 판정을 서버 자기 보고 대신 클라이언트 TLS 협상 결과로 한다 - #49

Merged
ictechgy merged 3 commits into
mainfrom
fix/tls-prefer-client-side-20261020
Oct 20, 2026
Merged

ictechgy merged 3 commits into
mainfrom
fix/tls-prefer-client-side-20261020

Conversation

@ictechgy

Copy link
Copy Markdown
Owner

요약

앞선 보안 리뷰에서 설계 결정이 필요해 보류했던 schemagraph 항목을 승인된 설계 결정에 따라 구현했습니다. 이 항목은 PR #47(네이티브 reader TLS)을 검토한 보안 리뷰의 후속입니다. 이 PR 범위는 1건이고 구현 1건, 부분 구현 0건, 미구현 0건입니다.

항목 심각도 지적 커밋
followup-tls-prefer-client-side medium 원격 prefer 연결의 암호화 판정이 서버의 자기 보고(pg_stat_ssl·Ssl_cipher)에 기대어, 평문 연결에서 connection-not-encrypted 경고가 가려질 수 있음 3b8bef9, 9e9453b, 리뷰 후속 f21b194

결함

  • PR #47은 네트워크를 지나는 prefer 연결마다 after_connect에서 서버에 TLS 여부를 물었습니다. PG는 pg_stat_ssl, MySQL·MariaDB는 SHOW SESSION STATUS LIKE 'Ssl_cipher'입니다. 답이 "암호화"면 limitation을 내지 않았습니다(transport.rs의 TlsPolicy::Prefer => observed?).
  • 중간자는 reader 쪽 TLS만 거절해 그 구간을 평문으로 만들고, 서버와는 TLS로 붙을 수 있습니다. 그러면 서버의 답은 중간자와 서버 사이 구간을 말하므로 "암호화"가 되고, reader와 중간자 사이 평문 구간의 경고가 사라집니다. 답을 위조할 필요도 없습니다.
  • 가짜 서버는 답을 그냥 꾸며 낼 수도 있습니다. 평문으로 위조할 수 있는 답에 기댄 fail-open입니다.
  • origin/main(04b4a22)에서 수정 전에 공격 입력으로 재현했습니다(아래 "검증"의 downgrade-proxy-network-host).

바뀌는 것

  • 루프백·Unix 소켓이 아닌 호스트로 가는 기본 sslmode=prefer·ssl-mode=PREFERRED 연결은 먼저 TLS를 요구해(require·REQUIRED) 엽니다. 서버가 TLS를 거절했을 때만 TLS 없이 새 연결로 다시 엽니다.
  • 판정은 서버가 알려 주는 세션 정보가 아니라 reader 자신의 TLS 협상 결과로 합니다. 그래서 reader 쪽 TLS만 거절하는 프록시나 답을 꾸미는 서버가 limitation을 숨길 수 없습니다.
  • 서버 확인 질의와 "could not be confirmed" limitation을 지웠습니다.
  • 변경 기록은 RELEASE-NOTES.md # Unreleased의 기존 Security·Changed 불릿을 고쳐 적었습니다. 버전 번호는 그대로입니다.

설계 결정

결정 문서(원칙 1, 클라이언트가 아는 사실로 판정)

  • 원격 prefer(PG prefer, MySQL PREFERRED)는 sqlx 자체 prefer에 맡기지 않는다.
  • 같은 옵션에서 ssl_mode만 require(PgSslMode::Require, MySqlSslMode::Required)로 바꿔 연결 하나를 먼저 열어 본다(PgConnection/MySqlConnection::connect_with).
    • 성공하면 그 연결을 닫고 풀 전체를 require로 연다. 이후 연결은 sqlx가 TLS를 강제하므로 limitation이 없다.
    • 실패가 sqlx::Error::Tls(서버의 TLS 거절이나 핸드셰이크 실패)면 disable(PgSslMode::Disable, MySqlSslMode::Disabled)로 평문 풀을 연다. 그리고 원인을 담은 limitation connection-not-encrypted: the server did not accept TLS, so the catalog was read over an unencrypted connection; <remedy>를 남긴다.
    • 그 밖의 오류(인증·네트워크)는 평문으로 다시 시도하지 않고 기존 접속 실패로 돌려준다.
  • 로컬(Unix 소켓·루프백)·disable·require 이상은 지금 동작을 그대로 둔다.
  • 서버 확인 질의(observe_transport, transport_from_answer, Ssl_cipher 조회), needs_server_check, Transport::Unconfirmed는 지운다. 문서의 판정 설명도 맞춘다.
  • 정상 서버에서는 출력이 같아야 한다. TLS를 받는 서버는 limitation이 없고, 거절하는 서버는 같은 접두사의 limitation이 남는다.

결정 문서의 문구와 다르게 정한 것이 두 가지 있습니다. 결정 문서가 내다보지 못한 sqlx 0.8.6의 동작 때문이고, 공통 원칙으로 정했습니다. 두 결정 모두 3b8bef9 커밋 본문의 결정: 줄에도 적었습니다. 둘 다 출력 계약을 바꾸지 않습니다. 첫째는 결정 문서의 문구보다 fail-closed 쪽이고, 둘째는 기존 접속 재시도 동작을 지킵니다.

  1. 평문 재시도는 Error::Tls("server does not support TLS")일 때만 한다 (3b8bef9)
    • sqlx 0.8.6은 서버가 TLS를 거절할 때 이 문구를 냅니다. PG는 SSLRequest에 N을 받았을 때, MySQL은 서버 핸드셰이크에 CLIENT_SSL이 없을 때입니다(sqlx-postgres·sqlx-mysql connection/tls.rs).
    • 결정 문서의 괄호는 핸드셰이크 실패도 Error::Tls로 보았습니다. 하지만 sqlx 0.8.6에서 서버가 TLS를 받은 뒤의 핸드셰이크 실패(인증서·프로토콜·EOF)는 complete_io를 거쳐 Error::Io로 나옵니다. sqlx-core tls_rustls.rs와 실서버 stderr로 확인했습니다.
    • Error::Tls의 나머지 경우는 서버의 거절이 아니라 클라이언트 쪽 설정 오류입니다. 서버 이름으로 쓸 수 없는 호스트(127.1 등)와 클라이언트 인증서 오류가 여기에 듭니다. 여기서 평문으로 내려가면 사용자가 준 TLS 설정을 무시하고, limitation의 원인도 틀리게 적힙니다.
    • 그래서 원칙 1(fail-closed)과 원칙 2(핸드셰이크 실패는 종료 2라는 기존 계약)를 따랐습니다. sqlx를 올려 문구가 바뀌면 거절도 종료 2가 되는 쪽으로만 틀립니다. 평문으로 내려가는 쪽으로는 틀리지 않고, 가짜 서버 테스트가 그 변화를 잡습니다.
  2. 시도용 연결을 따로 열지 않고 풀의 첫 접속을 시도로 쓴다 (3b8bef9)
    • 결정 문서는 PgConnection/MySqlConnection::connect_with로 연결 하나를 따로 열어 보라고 했습니다.
    • 따로 열면 sqlx 풀이 하던 재시도가 원격 prefer에서만 사라집니다. 풀은 접속 거절과 서버 기동 중 오류를 acquire 상한(30초)까지 다시 시도합니다(sqlx-core pool/inner.rs). 기존 동작이 바뀌므로 원칙 2에 어긋납니다.
    • 성공하면 시도한 풀이 곧 require 풀이라 연결도 하나 덜 엽니다.
    • 판정과 해결 방향 로직은 transport.rs(TransportCheck::open, after_tls_attempt) 한 곳에 둡니다. 두 reader는 ConnectMode를 자기 sslmode로 옮기기만 하고, options_for는 sslmode 말고는 URL 그대로입니다.
    • require·REQUIRED는 prefer·PREFERRED와 인증서 검증 설정이 같습니다(둘 다 인증서를 검증하지 않음). 그래서 서버가 TLS를 받는 한 sqlx 자체 prefer와 같은 연결입니다. 리뷰가 sqlx 0.8.6 소스로 확인했습니다.

함께 정한 것

  • 변경 기록은 기존 Unreleased 불릿만 고친다 (9e9453b): fix(engine)!: 네이티브 reader TLS, --cache-dir 항목 HMAC 인증, MCP 응답 상한, language-traversal 종료 코드 64 #47 뒤로 태그가 없습니다. 원격 최신 태그는 v0.7.1이고 git tag --contains 27f665e는 비어 있습니다. 그래서 서버에 묻던 방식은 릴리스된 적이 없습니다. 새 불릿을 더하지 않고 ## Security 다섯째 불릿과 ## Changed 첫 불릿만 최종 동작으로 고쳤습니다. ## Breaking의 "기본 prefer에서 핸드셰이크가 실패하면 종료 2"는 새 동작에서도 맞아 그대로 두었습니다.
  • 검증 스크립트 중간자의 종료 방식 (f21b194, 커밋 본문 결정: 줄): 리스너를 닫기 전에 shutdown(SHUT_RDWR)을 부르는 대안은 쓰지 않았습니다. 실측하니 Linux는 shutdown 뒤 accept가 EINVAL로 깨어났지만, macOS는 리스닝 소켓의 shutdown이 ENOTCONN을 내고 accept를 깨우지 못했습니다. 플랫폼마다 다른 errno를 가려 받아야 합니다. 대신 accept timeout(0.2초)과 threading.Event를 써서 두 플랫폼이 같은 경로로 끝나게 했습니다. 비용은 종료마다 최대 0.2초입니다.

계약 변경

결정 문서에 따라 의도한 사용자 계약 변경입니다. 같은 내용을 RELEASE-NOTES.md # Unreleased와 README 두 언어에 적었습니다. 아래는 main(#47)과 비교한 변경입니다. #47의 동작은 아직 릴리스되지 않았으므로, v0.7.1 사용자에게는 Unreleased에 적힌 TLS 항목(이번에 고친 설명)이 바뀌는 것의 전부입니다.

  • 원격 기본 prefer의 접속 순서: 루프백·Unix 소켓이 아닌 호스트로 가는 PostgreSQL sslmode=prefer와 MySQL·MariaDB ssl-mode=PREFERRED scan은 먼저 require·REQUIRED로 접속합니다. 서버가 TLS를 거절했을 때만 TLS 없이 새 연결로 다시 접속합니다. 거절된 연결로는 더 보내지 않습니다. 거절되면 TCP 연결이 하나 더 듭니다.
  • limitation 문구: 그때 남는 limitation은 다음과 같습니다.
    • connection-not-encrypted: the server did not accept TLS, so the catalog was read over an unencrypted connection; <remedy>
    • #47에서는 서버가 평문이라고 답하면 connection-not-encrypted: the catalog was read over an unencrypted connection; <remedy>였습니다. 서버가 답하지 못하면 connection-not-encrypted: the catalog connection could not be confirmed as encrypted (<원인>); <remedy>였습니다. 뒤의 문구는 더 나오지 않습니다.
    • 접두사 connection-not-encrypted:, 위치(document·graph limitations 맨 앞), <remedy> 문구, catalog_complete는 그대로입니다.
  • 서버 확인 질의 제거: reader가 pg_stat_ssl과 SHOW SESSION STATUS LIKE 'Ssl_cipher' 질의를 더 보내지 않습니다.
  • 풀의 모든 연결이 같은 모드: 첫 연결이 TLS였으면 풀의 이후 연결도 모두 TLS여야 합니다. 이후 연결이 TLS를 맺지 못하면 평문으로 내려가지 않고 오류로 끝납니다. 전에는 sqlx 자체 prefer라 이후 연결이 평문이 될 수 있었고, 그 사실을 limitation으로 드러냈습니다. 반대로 거절 뒤 다시 연 평문 풀의 연결은 모두 TLS를 시도하지 않습니다.
  • 그대로인 것
    • 서버가 TLS를 받은 뒤 핸드셰이크가 실패하거나, 인증·네트워크 오류, 클라이언트 TLS 설정 오류가 나면 평문으로 다시 시도하지 않고 종료 2로 끝납니다. 기존과 같습니다.
    • TLS를 받는 원격 서버의 기본 prefer는 limitation이 없습니다.
    • 루프백·Unix 소켓 연결과 disable·allow·DISABLED, require 이상 모드의 출력과 종료 코드는 그대로입니다.
    • CLI 옵션, 입력, 기본값, 설정 검증, 출력 필드는 바뀌지 않습니다. 새 의존성도 없고 engine/Cargo.lock도 그대로입니다.

변경

수정 (fix)

  • fix(source): 원격 prefer 연결의 암호화 판정을 서버 자기 보고 대신 클라이언트의 TLS 협상 결과로 한다 (3b8bef9)

테스트 (test)

  • test(source): TLS 검증 중간자가 Linux에서 종료할 때 10초를 기다리고 포트를 붙잡지 않게 한다 (f21b194)

문서 (docs)

  • docs: 릴리스 노트의 원격 prefer 암호화 판정 설명을 클라이언트 TLS 협상 방식에 맞춘다 (9e9453b)

바뀐 파일은 8개(+863/−301)입니다. 대부분 테스트와 공격 재현 하네스이고, 운영 코드는 transport.rs 판정부와 두 reader의 접속부만 바뀌었습니다.

  • 엔진: engine/source/src/transport.rs(TransportCheck::open, negotiate, after_tls_attempt, limitation 문구), postgres.rs·mysql.rs(connect, options_for, 서버 확인 질의 삭제)
  • 테스트: transport.rs 단위 테스트, postgres.rs·mysql.rs 테스트, engine/cli/tests/database_url_errors.rs(가짜 서버 통합 테스트 3개)
  • 스크립트: Scripts/verify-native-tls.py(중간자 재현 사례 downgrade-proxy-network-host, DowngradeProxy 종료 수정)
  • 문서: README.md, README.ko.md, RELEASE-NOTES.md

머지 참고

  • 열린 PR은 없습니다.
  • .github/workflows/, engine/source/src/sql/*.sql, probe/, probe-go/, Fixtures/, Dockerfile, action.yml은 바꾸지 않았습니다.

검증

로컬 macOS(arm64)에서 HEAD f21b194로 실행했고, MySQL·MariaDB와 일부 재현은 Linux 컨테이너(Colima)에서 돌렸습니다. origin/main(04b4a22)이 이미 이 브랜치의 조상이라 merge가 필요 없었습니다(PR 생성 직전 git fetch origin으로 확인). GitHub CI는 push 전이라 아직 돌지 않았습니다.

HEAD f21b194 검사

  • git diff --check origin/main...HEAD: pass — 공백 오류 없음
  • cargo fmt --manifest-path engine/Cargo.toml --all -- --check: pass — 출력 없음
  • cargo build·cargo test --manifest-path engine/Cargo.toml --workspace --locked -j 3 -- --test-threads=3(XDG_CONFIG_HOME=<scratch>/xdg, SCHEMAGRAPH_CACHE_KEY·PGSSL* 제거): pass — 23개 묶음에서 595개 통과, 실패 0, 39초. 격리용 XDG 디렉터리는 끝까지 비어 있었고 ~/.config/schemagraph도 생기지 않았습니다.
  • cargo clippy --workspace --all-targets --locked --message-format=json(HEAD와 origin/main 임시 worktree 비교): pass — 경고 집합이 같습니다. 양쪽 모두 고유 28건·총 58건이고 추가 0·제거 0입니다. 비교 키는 (파일, lint, 메시지, 소스 줄)입니다.
  • cargo clippy … -- -D warnings: 기존 실패 — HEAD와 origin/main 모두 rc 101이고 같은 지점(schemagraph-source unnecessary_unwrap, schemagraph-analysis too_many_arguments 등)에서 멈춥니다. CI에는 이 단계가 없습니다.
  • Python 단위 검사 11개(test-mysql-ready, test-fixture-ports, test-postgres-fixture-diagnostics, test-postgres-fixture-auth, test-sqlserver-oracle-accuracy, test-benchmark-scale, test-probe-sqlite-benchmark, test-review-action, test-container, test-dml-accuracy, test-dml-producers): pass — 합계 115건 OK
  • bash Scripts/test-publish-release-assets.sh: pass — 오프라인 릴리스 게시 36사례(macOS에서만 실행)
  • pyflakes 3.2.0 Scripts/verify-native-tls.py, Python 3.9.6·3.14.7 -I -B -W error import: pass — 지적 0건(origin/main도 0건), 두 버전 모두 경고 없이 import됩니다.
  • CI "Verify JSON contracts and competitive CLI behavior"·"Check bundled documents and licenses" 단계: pass — SQL cmp 5개, verify-contracts.py(jsonschema 4.23.0·sqlglot 30.18.0), verify-competitive.py(14초), verify-cancellation.py, verify-html.py --screenshot(Explorer 화면 정상 렌더링), SARIF 스키마 sha256(c3b4bb2d…) 확인 뒤 verify-review-action.py(findings=1, baseline=0, incomplete=2), SKILL 두 사본·schemagraph skill cmp, crate LICENSE cmp
  • consumer Action 재현(verify-review-action.py --prepare 뒤 GITHUB_WORKSPACE·RUNNER_TEMP·GITHUB_OUTPUT과 action.yml 입력으로 Scripts/run-review-action.py 실행): pass — CI 기대대로 rc 1(outcome failure), exit_code=1, json·sarif·markdown 출력이 비어 있지 않음
  • verify-accuracy.py --sqlglot --suite all --strict(PostgreSQL 16): pass — chinook 23사례, pagila 20사례, 실패 0
  • verify-external-imports.py --dbt(dbt-core 1.12.5, dbt-postgres 1.11.0): pass — status ok
  • verify-mysql-accuracy.py --sqlglot --strict: pass — MySQL 8.4·MariaDB 11.4 각 16/16, 13초
  • verify-openlineage.py, verify-bridge-facts.py: pass — openlineage status ok(직접 7·간접 3·이벤트 4). bridge-facts rc 0이고, status partial은 --isthmus를 주지 않아서입니다(CI와 같고 기존과 같음).
  • verify-native-tls.py --mysql-environments Fixtures/accuracy/mysql-environments.json(CI와 같은 옵션): pass — PG 17사례(평문 6·TLS 11, downgrade-proxy-network-host 포함), MySQL 7사례, MariaDB 7사례, 24초. 남은 컨테이너 0
  • cargo package --workspace --locked -j 3: pass — crate 6개 Packaging·Verifying, 69초. yoke-derive 0.8.3 yanked 경고는 origin/main부터 있던 것입니다.
  • gradle -p probe check shadowJar --max-workers=2(Java 17): pass — 테스트 55개, 실패 0
  • test-accuracy-sql.py --jdbc-jar …, verify-probe-maven.sh: pass — Maven CLI가 없어 Gradle consumer 경로로 확인
  • probe-go gofmt -l, go vet, go test, CGO_ENABLED=0 go build: pass
  • JDBC 드라이버(mysql-connector-j 26.7.0, ojdbc8 19.3.0.0) sha256: pass — CI 고정 해시와 같음
  • verify-catalog-dependencies, verify-sqlite-nullability, verify-collection-scope(native·go·jdbc), verify-schema-lint, verify-unused: pass — 9단계 모두 rc 0
  • Scripts/verify-fixtures.sh(CI처럼 ^주의: 줄을 실패로 판정): pass — verify-fixtures: OK, 주의 0, 86초(MySQL·MariaDB·MSSQL azure-sql-edge·Oracle 포함)
  • DML verify-dml-producers.py --database sqlite|postgres|oracle(v0.4.3 aarch64-apple-darwin 배포본)과 verify-dml-replay.py: pass — SHA256SUMS와 jar 해시를 CI 고정 해시와 대조, replay status ok·failures 0
  • verify-sqlserver-oracle-accuracy.py --database oracle --strict: pass — 18사례, 정확도 실패 0
  • Release binaries 재현(워크플로 YAML의 prepare, build-native aarch64-apple-darwin, build-jdbc run 본문을 bash -eo pipefail로 실행): pass — version=0.7.1·publish=false, release 빌드 88초, 추출 바이너리 smoke(cancellation·SQLite scan·Go probe·query), 압축 파일 구성, JDBC jar smoke
  • docker build 뒤 docker run --rm … --version: pass — 128초, 출력 schemagraph 0.7.1
  • 비공개 이름·홈 경로 검사(커밋 메시지, diff, PR 본문)와 비밀값 패턴 검사: pass — 0건

수정 전 실패와 회귀 검출

  • 가짜 서버 통합 테스트(engine/cli/tests/database_url_errors.rs)
    • remote_default_postgres_url_reconnects_without_tls_after_the_server_refuses_it: 수정 전 코드에서 실패합니다. 거절된 접속으로 평문을 이어 가서 접속 수가 1입니다(기대 2).
    • remote_default_mysql_url_reconnects_without_tls_when_the_server_lacks_it: CLIENT_SSL이 없는 서버입니다. 같은 이유로 수정 전 코드에서 실패합니다.
    • remote_default_postgres_url_does_not_retry_without_tls_after_a_failed_handshake: 계약 고정용이라 수정 전후 모두 통과합니다. 핸드셰이크 실패 뒤 평문 재접속이 없어야 합니다.
  • transport.rs 단위 테스트
    • 결정 함수: 성공은 require 풀을 그대로 쓰고, 서버의 TLS 거절만 평문 재시도로 이어지며, 그 밖의 Error::Tls·Io·PoolTimedOut은 접속 실패입니다.
    • TransportCheck::open 흐름(모드별로 기록한 접속 순서)과 limitation 문구도 고정했습니다.
  • postgres.rs·mysql.rs: URL별 negotiates_tls 표, options_for가 sslmode만 바꾸는지, 해결 방향 문구 전체를 고정했습니다.
  • 중간자 재현 downgrade-proxy-network-host(Scripts/verify-native-tls.py의 DowngradeProxy): reader 쪽 SSLRequest에는 N을 답하고, 서버 쪽에는 검증된 TLS로 붙어 중계합니다. 수정 전 엔진은 서버의 pg_stat_ssl이 ssl=true를 보고하므로 limitation 없이 scan에 성공해 이 사례에서 실패합니다. 수정 후에는 PG16 17사례와 MySQL 8.4·MariaDB 11.4 14사례가 모두 통과합니다.
  • 내부 리뷰가 커밋 직전 트리를 git archive로 따로 빌드해 위 red를 다시 확인했습니다.

측정

  • 이 항목은 성능 항목이 아닙니다. 대신 기존 정상 입력의 출력 바이트 보존을 쟀습니다.
  • 수정 전후 엔진으로 같은 입력을 scan했습니다(측정 스크립트 <scratch>/compare_scans.py). 입력은 Fixtures/postgres/basic.sql을 넣은 PostgreSQL 16 클러스터 12사례와 SQLite basic입니다.
    • PG 사례: TLS를 끈 클러스터 7사례와 TLS를 켠(hostssl) 클러스터 5사례입니다. 호스트(루프백, localhost, 네트워크 호스트 0.0.0.0)와 모드(기본, require, disable)를 조합했습니다.
    • 결과: 종료 코드는 13사례 모두 같습니다. graph.json sha256은 성공한 11사례 가운데 하나만 빼고 모두 같습니다.
    • 다른 하나는 TLS를 끈 서버에 원격 기본 prefer로 붙은 사례입니다. 전송 limitation 문구가 the catalog was read over an unencrypted connection에서 the server did not accept TLS, so the catalog was read over an unencrypted connection으로 바뀐 것뿐이고, 접두사와 나머지 그래프는 같습니다.
  • verify-bridge-facts.py 결과도 수정 전과 같습니다.
  • DowngradeProxy.__exit__ 시간(f21b194): python:3.12-slim(Linux 6.8)에서 수정 전 10.01초 → 수정 후 0.12초입니다. 수정 전에는 serve 스레드가 살아 있었고 종료 뒤에도 포트가 LISTEN이었습니다. macOS(Python 3.14.7·3.9.6)는 수정 전 0.0초, 수정 후 0.09초 안팎입니다. 사례 판정은 바뀌지 않았고, PG만 돌린 출력 JSON은 수정 전과 바이트 단위로 같습니다.

실행하지 않은 검사

  • container job verify-container.py --image …: not-run — 스크립트가 저장소 루트 아래 임시 디렉터리를 bind mount하는데, Colima VM은 홈만 마운트합니다. 이미지 빌드와 --version 실행은 위에서 통과했습니다.
  • SQL Server accuracy, DML sqlserver: not-run — verify-sqlserver-oracle-accuracy.py가 Linux x86_64 Docker만 받고, 로컬 VM은 aarch64입니다.
  • ibm-fixtures.yml(Db2·Informix): not-run — linux/amd64 privileged 컨테이너와 메모리 5g가 필요합니다. 로컬 VM은 aarch64, CPU 2개, 4GiB입니다. 이 PR은 IBM 경로를 바꾸지 않습니다.
  • Release binaries x86_64-unknown-linux-gnu leg·assemble, CI release-assets ubuntu-24.04 leg: not-run — Linux x86_64 러너와 두 leg의 산출물이 필요합니다. macOS leg의 같은 스크립트는 로컬에서 통과했습니다.
  • 원격 CI(CI, DML, IBM, SQL Server and Oracle, Release binaries): not-run — 이 PR을 만들면서 처음 돕니다.

원격 CI에서 꼭 볼 단계

  • verify job "Verify native PostgreSQL, MySQL, and MariaDB TLS modes against real servers": 새 downgrade-proxy-network-host 사례가 Linux 러너에서 처음 돕니다. main(04b4a22) CI에서 이 단계는 30초였습니다. f21b194가 없으면 중간자 종료가 Linux에서 10초를 더 기다리므로, 새 사례가 더해져도 그만큼 늘지 않았는지 봅니다.
  • "Measure MySQL and MariaDB public schema accuracy"와 "Verify every database fixture": MySQL·MariaDB 컨테이너에 루프백으로 붙습니다. 이 PR이 판정을 바꾸지 않은 경로(URL 모드 그대로)지만 reader 접속부를 고쳤으므로 확인합니다.

구현하지 않은 항목

  • 없습니다. 이 PR 범위 1건을 모두 구현했고, 계획에서 이 레인 몫으로 뺀 항목(excluded)도, 건너뛰거나 부분만 구현한 항목도 없습니다.

후속 참고(이 PR 밖)

  1. sqlx를 올릴 때 거절 문구 재확인: 평문 재시도는 sqlx 0.8.6의 거절 문구 server does not support TLS와 일치할 때만 합니다. 문구가 바뀌면 거절도 종료 2가 되는 쪽으로만 틀리고, 위 가짜 서버 테스트 2개가 실패해 알려 줍니다. 그때 SERVER_REFUSED_TLS를 새 문구에 맞춥니다.
  2. 범위 밖 관찰: Scripts/verify-fixtures.sh는 컨테이너를 docker rm -f로(볼륨 없이) 지워, 실행마다 익명 볼륨 6개(약 0.5GB)를 남깁니다. 프로브 쪽 스크립트라 이 PR에서는 고치지 않았습니다.

리뷰

  • 내부 독립 리뷰(3b8bef9, 9e9453b 대상): 머지를 막을 결함은 없고 low 1건이 나왔습니다. 직접 재현한 뒤 고쳤습니다.
    1. Scripts/verify-native-tls.py의 DowngradeProxy.__exit__가 리스너를 닫으면 accept가 깨어난다고 가정함. Linux(CI)에서는 깨어나지 않아 실행마다 join(timeout=10)을 10초 내내 기다리고, 프로세스가 끝날 때까지 프록시 포트를 붙잡음 → 고침(f21b194). 위 "측정"과 "설계 결정"을 참고하세요.
      • 예전의 except OSError: return은 오류를 조용히 삼켰습니다. 이제 accept가 timeout 말고 다른 OSError를 내거나 스레드가 상한 안에 멈추지 않으면 errors에 남기고, 사례 판정이 실패로 냅니다.
      • timeout은 socket.timeout으로 받습니다. Python 3.9에서는 이 예외가 TimeoutError의 별칭이 아니어서, TimeoutError로 받으면 회귀가 생깁니다.
      • 변이 확인: 닫은 뒤 join하는 예전 순서는 Linux에서 accept failed: OSError: [Errno 9]로 드러납니다. 멈춤 요청을 무시하는 serve는 두 플랫폼에서 did not stop within 10 seconds로 드러납니다.
  • 리뷰가 함께 확인한 것
    • 결정과 맞습니다. 결정 문서와 다른 두 선택(풀의 첫 접속을 시도로 씀, 거절 문구로만 평문 재시도)은 커밋 본문에 이유가 있습니다. 리뷰는 둘 다 출력 계약을 바꾸지 않고 더 fail-closed 쪽이라고 판단했고, 그 근거를 sqlx 0.8.6 소스로 확인했습니다(핸드셰이크 실패는 complete_io를 거쳐 Error::Io, 거절 문구는 PG·MySQL tls.rs에 그대로, REQUIRED와 PREFERRED의 인증서 검증 설정이 같음).
    • 우회 경로가 없습니다. 모든 네이티브 읽기는 main.rs의 source::read 하나를 거치고, MCP에는 DB 접속 경로가 없습니다.
    • 수정 후 바이너리를 TLS를 거절하는 가짜 서버(0.0.0.0)에 붙여 봤습니다. sslmode=require와 verify-full은 접속 하나에 종료 2이고 평문으로 내려가지 않습니다. 기본 모드와 명시 prefer는 거절된 접속에 더 보내지 않고, 새 StartupMessage 접속으로 다시 붙습니다.
    • 동작이 보존됩니다. 종료 코드, limitation 위치(맨 앞), 결정성은 그대로이고, 새 의존성이나 lock 변경은 없습니다. 문서와 실제 동작이 맞습니다.
    • 커밋 형식(한국어 원인·수정·검증, 마지막 줄 Co-Authored-By)을 지켰고, 비공개 이름과 홈 경로가 diff와 메시지에 없습니다.
  • 리뷰 후속 커밋을 더한 HEAD f21b194에서 위 "HEAD f21b194 검사"를 다시 돌렸습니다.
  • 외부 GLM 리뷰는 PR 생성 직후 진행하고, 결과를 이 PR에 코멘트로 남깁니다.

🤖 Generated with Claude Code

ictechgy and others added 3 commits October 20, 2026 21:06
원인:
- 네트워크를 지나는 prefer(PG prefer, MySQL PREFERRED) 연결은 sqlx 자체 prefer로
  연 뒤 after_connect에서 서버에 세션의 TLS 여부를 물었고(pg_stat_ssl, Ssl_cipher),
  답이 암호화면 connection-not-encrypted limitation을 내지 않았다.
- 클라이언트 쪽 TLS만 거절하고 서버와는 TLS로 붙는 중간자 뒤에서는 서버의 답이
  중간자-서버 구간을 말해 "암호화됨"이 되고, 클라이언트 평문 구간의 경보가 사라졌다.
  가짜 서버는 답을 그냥 위조할 수도 있다. 평문으로 위조할 수 있는 답에 기댄 fail-open이다.

수정:
- 네트워크를 지나는 prefer 연결은 sslmode만 require(MySQL REQUIRED)로 바꿔 먼저 연다.
  열리면 그 풀을 그대로 쓴다. 이후 연결도 sqlx가 TLS를 강제한다. 서버가 TLS를
  거절했을 때만 disable(DISABLED)로 다시 열고 "the server did not accept TLS, so the
  catalog was read over an unencrypted connection; <해결 방향>" limitation을 남긴다.
  그 밖의 오류는 기존 접속 실패(종료 2)다.
- 로컬(Unix 소켓·루프백)·disable·require 이상은 그대로다. 서버 확인 질의
  (observe_transport, transport_from_answer, transport_from_cipher), needs_server_check,
  Transport::Unconfirmed와 "could not be confirmed" 문구를 지웠다.
- README 두 언어의 판정 설명과 Scripts/verify-native-tls.py의 기대값을 맞췄다.

결정: 평문 재시도는 sqlx가 서버의 TLS 거절에 내는 Error::Tls("server does not support
TLS")에서만 한다 (이유: sqlx 0.8.6에서 서버가 TLS를 받은 뒤의 핸드셰이크 실패는
Error::Io로 나와 결정의 Error::Tls 기준에 들지 않는다. 같은 Error::Tls의 나머지(서버
이름으로 쓸 수 없는 호스트, 클라이언트 인증서 오류)는 서버의 거절이 아니다. 여기서
평문으로 내려가면 사용자가 준 TLS 설정을 무시하고 limitation의 원인도 틀린다. 그래서
원칙 1(fail-closed)과 원칙 2(핸드셰이크 실패는 종료 2라는 기존 계약)를 따랐다. sqlx를
올려 문구가 바뀌면 거절도 종료 2가 되는 쪽으로만 틀린다.)

결정: 시도용 연결을 PgConnection/MySqlConnection::connect_with로 따로 열지 않고 풀의
첫 접속을 시도로 쓴다 (이유: 따로 열면 sqlx 풀이 하던 접속 거절·기동 중 오류의
재시도(acquire 상한 30초)가 원격 prefer에서만 사라져 기존 동작이 바뀐다(원칙 2).
성공하면 그 풀이 곧 require 풀이라 연결도 하나 덜 연다.)

검증:
- cargo test -p schemagraph-source -p schemagraph-cli 통과. 결정 함수(성공은 require
  풀, 거절만 평문, 그 밖의 오류는 실패), open 흐름, 문구의 단위 테스트를 더했다. 가짜
  서버 통합 테스트 3개(PG 거절 뒤 새 평문 접속, PG 핸드셰이크 실패 뒤 재접속 없음,
  CLIENT_SSL 없는 MySQL 서버 뒤 새 평문 접속)를 더했다. 이 가운데 PG 거절과 MySQL
  사례는 수정 전 코드에서 실패한다(거절된 접속으로 평문을 이어 간다).
- Scripts/verify-native-tls.py에 중간자 재현 사례(downgrade-proxy-network-host)를
  더했다. 수정 전 엔진은 limitation 없이 scan에 성공해 이 사례에서 실패한다. 수정 뒤
  PG16 17사례와 MySQL 8.4·MariaDB 11.4 14사례가 모두 통과한다.
- 수정 전후 엔진으로 PG fixture(basic.sql)를 넣은 평문·TLS 클러스터의 12사례와 SQLite
  basic을 scan했다. 종료 코드는 모두 같다. graph.json은 TLS를 끈 서버의 원격 기본
  prefer 하나만 다르고, 그 차이는 전송 limitation 문구뿐이다. verify-bridge-facts 결과도
  같다. clippy 경고는 기준선과 같다(고유 29건).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인:
- 3b8bef9가 네트워크를 지나는 prefer(MySQL PREFERRED) 연결의 판정을 서버의 자기
  보고(pg_stat_ssl, Ssl_cipher) 대신 reader 자신의 TLS 협상 결과로 바꿨다. 그런데
  Unreleased의 Security·Changed 문단은 서버에 묻는 방식과 "encryption could not be
  confirmed" limitation을 그대로 설명하고 있었다.

수정:
- Security 다섯째 불릿: 기본 모드에서 원격 호스트는 sslmode=require(ssl-mode=REQUIRED)
  처럼 먼저 붙고 서버가 TLS를 거절했을 때만 TLS 없이 다시 붙으며, 그때 limitation은
  서버가 TLS를 받지 않았다고 말한다고 적었다. 판정이 서버 보고가 아니라 reader의
  협상이라 reader 쪽 TLS를 거절하는 프록시가 limitation을 숨기지 못한다는 것과,
  limitation이 풀러·프록시의 다음 구간이 아니라 reader 자신의 연결을 말한다는 것도
  README 두 언어와 같게 적었다.
- Changed 첫 불릿에서 "or encryption could not be confirmed"를 지웠다.
- 서버 확인 방식은 #47 뒤로 태그가 없어(원격 최신 태그 v0.7.1, git tag --contains
  27f665e 비어 있음) 릴리스된 적이 없다. 그래서 새 불릿을 더하지 않고 기존 두 문단만
  최종 동작으로 고쳤다. 버전 번호는 그대로다.

검증:
- transport.rs의 limitation 문구, README.md·README.ko.md의 같은 문단,
  Scripts/verify-native-tls.py 사례(downgrade-proxy-network-host 등)와 대조했다.
- 바뀐 줄은 모두 80자 이내이고 git diff --check가 깨끗하다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
원인:
- Scripts/verify-native-tls.py의 DowngradeProxy.__exit__는 리스너를 닫아 serve 스레드의
  accept를 끝내려 했다. Linux는 다른 스레드가 리스닝 소켓을 닫아도 막힌 accept를 깨우지
  않는다(macOS는 ECONNABORTED로 깨운다). 그래서 CI verify job(ubuntu-24.04)에서는
  join(timeout=10)이 상한을 다 기다려 TLS 검증 단계 실행마다 10초가 늘었다. 그 accept가
  소켓을 붙잡아 프로세스가 끝날 때까지 프록시 포트도 LISTEN으로 남았다.
- serve 문서 주석("리스너를 닫으면 accept가 OSError로 끝난다")은 Linux에서 틀렸다.
  사례 판정 결과는 바뀌지 않았다. 스레드가 daemon이고 판정이 종료 전에 끝나기 때문이다.

수정:
- 리스너에 ACCEPT_POLL_SECONDS(0.2초) timeout을 건다. __exit__는 threading.Event로
  멈춤을 요청하고, serve가 accept에서 나와 끝나기를 기다린 뒤 리스너를 닫는다. 그래서
  포트도 바로 놓인다.
- timeout은 socket.timeout으로 받는다. Python 3.9(macOS 기본 python3)에서는 이 예외가
  TimeoutError의 별칭이 아니다. TimeoutError로 받으면 timeout이 일반 OSError로 떨어진다.
- 리스너는 serve가 끝난 뒤에 닫으므로, accept가 내는 다른 OSError는 실제 실패다. 예전처럼
  조용히 끝내지 않고 errors에 남겨 사례 판정이 실패로 낸다. PROXY_STOP_SECONDS(10초) 안에
  스레드가 멈추지 않을 때도 같은 방식으로 실패를 낸다. 이번 결함처럼 join 상한이 조용히
  지나가지 않게 하기 위해서다.
- serve·__exit__ 문서 주석을 실제 동작에 맞췄다.

결정: 리스너를 닫기 전에 shutdown(SHUT_RDWR)을 부르는 대안은 쓰지 않았다 (이유: 실측해
보니 Linux는 shutdown 뒤 accept가 EINVAL로 깼다. 하지만 macOS는 리스닝 소켓의 shutdown이
ENOTCONN을 내고 accept를 깨우지 못했다. 플랫폼마다 다른 errno를 가려 받아야 한다. timeout과
Event는 두 플랫폼에서 같은 경로로 끝나고, 비용은 종료마다 최대 0.2초다.)

검증:
- 실제 DowngradeProxy를 열고 닫는 측정을 python:3.12-slim 컨테이너(Linux 6.8)에서 했다.
  수정 전에는 __exit__가 10.01초 걸렸고, serve 스레드가 살아 있었고, 종료 뒤에도 포트가
  LISTEN(/proc/net/tcp 0A)이라 새 접속이 받아졌다. 수정 후에는 0.12초에 스레드가 끝나고
  접속이 거절된다. macOS(Python 3.14.7·3.9.6)는 수정 전 0.0초, 수정 후 0.09초 안팎이다.
- 가짜 TLS upstream으로 중계 경로(SSLRequest 거절 → 평문 StartupMessage → 서버와 TLS
  중계)를 Linux·macOS에서 끝까지 확인했다. 수정 후 Linux 종료는 0.17초이고, 중계 결과는
  수정 전과 같다.
- 변이 확인: 닫은 뒤 join하는 예전 순서는 Linux에서 "accept failed: OSError: [Errno 9]
  Bad file descriptor"로 드러난다. 멈춤 요청을 보지 않는 serve는 두 플랫폼에서 "did not
  stop within 10 seconds"로 드러난다.
- PostgreSQL 16으로 python3 Scripts/verify-native-tls.py --engine <debug 엔진>
  --mysql-environments Fixtures/accuracy/mysql-environments.json 실행: rc 0, PG 17·MySQL
  7·MariaDB 7사례, 21초. PG만 돌린 출력 JSON은 수정 전과 바이트 단위로 같다.
- pyflakes 3.2.0 지적 0건(수정 전후 같음). Python 3.14.7·3.9.6에서 -W error로 import가
  통과한다.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@ictechgy

Copy link
Copy Markdown
Owner Author

GLM 외부 리뷰 결과 (packet-ask, effort high)

저장소 규칙(AGENTS.md)대로 PR 전체 diff를 GLM에 packet-ask로 보내 리뷰받았습니다. 결과는 지적 없음입니다. 그래서 보완 커밋도 push도 없고, PR head는 f21b194 그대로입니다.

수행 범위

기준점은 merge-base origin/main HEAD = 04b4a22이고, 리뷰 대상 head는 f21b194입니다. 패킷 1개로 PR 커밋 3개(3b8bef9, 9e9453b, f21b194)를 빠짐없이 덮었습니다. merge 커밋은 없습니다.

패킷 커밋 범위 커밋 파일 diff 바이트 보낸 패킷 바이트 가림(redaction)
1/1 04b4a22..f21b194 3 8 82,170 87,181 5건 (secret 값 3, 이메일 2)
  • --effort high로 한 번에 응답을 받았습니다. 재시도는 없었고, 약 15분 걸렸습니다.
  • 가림 5건은 모두 테스트 코드의 접속 URL입니다.
    • 가짜 비밀번호 3건: engine/cli/tests/database_url_errors.rs의 새 사례 3개
    • 사용자@호스트 표기 2건: postgres.rs·mysql.rs의 단위 테스트 connect_modes_change_only_the_ssl_mode
    • 가림 자리를 문법 오류로 본 지적은 없었습니다.
  • 질문에는 결정 문서가 의도한 계약 변경 6가지를 적었습니다. 결정 문서와 다르게 정한 두 가지(거절 문구로만 평문 재시도, 풀의 첫 접속을 시도로 씀)도 적었습니다. 의도한 변경과 결함을 구분하게 하려는 것입니다.

지적별 처리 (0건)

GLM은 "지적 없음"으로 답하면서, 결함으로 보지 않은 근거도 함께 적었습니다. 그 가운데 판정에 직접 걸리는 네 가지는 코드, sqlx 원본, 실행으로 다시 확인했습니다.

# GLM이 든 근거 확인
1 루프백·Unix 소켓과 Never·Required 정책은 접속 전에 전송이 정해져 URL대로 한 번 연다. 원격 prefer만 negotiate로 간다 transport.rs TransportCheck::known·open, 단위 테스트 known_transports_open_once_as_given·the_mode_decides_every_connection_except_remote_prefer
2 평문 재시도는 sqlx의 거절 문구와 정확히 같을 때만 한다. 문구가 바뀌면 종료 2 쪽으로 닫힌다 sqlx-postgres·sqlx-mysql 0.8.6 src/connection/tls.rs의 require·REQUIRED 분기가 Error::Tls("server does not support TLS")를 냅니다(PG 43행, MySQL 54행). CLI 가짜 서버 테스트와 실서버 required-without-server-tls 사례가 이 문구를 고정합니다
3 connect_with가 첫 접속을 바로 열므로, 첫 접속 결과가 곧 전송 판정이다 sqlx-core 0.8.6 src/pool/options.rs의 connect_with는 풀을 돌려주기 전에 inner.acquire()로 연결 하나를 엽니다(554행). pool/inner.rs는 ConnectionRefused와 일시적 DB 오류만 다시 시도하고, Error::Tls는 바로 돌려줍니다(374~381행)
4 중간자 사례 downgrade-proxy-network-host는 서버 자기 보고로 판정하는 구현에서 실제로 실패한다 아래 "다시 돌린 검사"에서 수정 전 엔진으로 돌려 확인했습니다

다시 돌린 검사

PR head f21b194에서 로컬로 돌렸습니다. debug 빌드, 병렬도 3이고, XDG_CONFIG_HOME은 빈 임시 디렉터리로 격리했습니다(끝까지 비어 있었습니다).

  • cargo fmt --all -- --check: 통과
  • cargo test --workspace --locked: 595개 통과
  • cargo clippy --workspace --all-targets --locked: 경고 총 58건으로 PR 본문의 기록과 같습니다. 이번 리뷰로 바뀐 코드는 없습니다.
  • Scripts/verify-native-tls.py(PostgreSQL 16, PG 사례):
    • 수정 후 엔진: 17사례 모두 통과
    • 수정 전 엔진(fix(engine)!: 네이티브 reader TLS, --cache-dir 항목 HMAC 인증, MCP 응답 상한, language-traversal 종료 코드 64 #47 방식): downgrade-proxy-network-host에서 expected one limitation starting with 'connection-not-encrypted: the server did not accept TLS, …', got []; relay errors: []로 실패했습니다.
    • 수정 전 엔진은 바이너리에 pg_stat_ssl 질의와 "could not be confirmed as encrypted" 문구가 있고 새 거절 문구가 없는 것을 확인했습니다.
    • 중계 오류 없이 limitation만 사라졌습니다. 이 사례가 서버 자기 보고에 기대는 판정을 실제로 잡는다는 뜻입니다.

CI(f21b194, pull_request 실행 5개 모두 성공):

  • CI 39431780811
    • Build, package, and database fixtures: 실서버 TLS 단계 "Verify native PostgreSQL, MySQL, and MariaDB TLS modes against real servers"가 35초였습니다. main(04b4a22) push CI 39413791433의 30초보다 새 중간자 사례 몫만 늘었습니다.
    • Container build and consumer smoke, Offline release publication(ubuntu-24.04·macos-14)
  • SQL Server and Oracle accuracy 39431780875, IBM database fixtures 39431780710, DML producer accuracy 39431780815
  • Release binaries 39431780652: PR이라 "Publish GitHub release assets"는 skipped
  • 같은 SHA의 push 실행 4개는 동시성 설정으로 cancelled됐습니다(실패 아님).

🤖 Generated with Claude Code

@ictechgy
ictechgy merged commit 81c0d4d into main Oct 20, 2026
17 of 28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant