Skip to content

feat: 외부 도메인 레코드 관리 도입 - #124

Merged
yessjun merged 15 commits into
mainfrom
feat/domain-record-sets
Sep 10, 2026
Merged

feat: 외부 도메인 레코드 관리 도입#124
yessjun merged 15 commits into
mainfrom
feat/domain-record-sets

Conversation

@yessjun

@yessjun yessjun commented Sep 10, 2026

Copy link
Copy Markdown
Member

📝 작업내용

  • 외부 도메인 종류 EXTERNAL 추가
    • 플랫폼 루트 아래 이름만 발급받고 대상은 소유자가 정하는 도메인
    • 플랫폼이 서빙하지 않으므로 라우트와 인증서를 만들지 않고 프록시 에이전트에 닿지 않음
    • 이름 공간이 플랫폼 서브도메인과 같으므로 해제 뒤 예약도 동일
  • 레코드 세트 편집 DomainRecordsService
    • 요청은 원하는 상태 전체이고 차이 계산은 서버가 (이름, 종류) 단위로 수행
    • 도메인 단위 세대로 푸시 정렬. 새 편집이 들어오면 진행 중인 푸시는 중단
    • 존에 닿은 적 없는 세트는 제거 대기가 아니라 삭제
    • 값은 검증 전에 정규화해 규칙과 저장된 행이 같은 것을 보게 함
  • 값 가드 DomainRecordPolicy
    • 플랫폼 인그레스와 캠퍼스 대역 거절. 대학 이름을 같은 망의 다른 기계에 빌려주는 모양 차단
    • 공인 목적지가 아닌 v4와 v6 대역 거절. IPv4 매핑 IPv6 포함
    • 플랫폼 루트로 되돌아오는 CNAME 거절, 교내 도메인을 가리키는 CNAME 거절
    • 부모의 메일 정책을 대신 말하는 TXT 거절. MX 미제공이 곧 안전은 아님
  • 적용 작업 DomainRecordApplyJob
    • 이름 잠금 아래에서 푸시하고 제공자 호출마다 세대 재확인
    • 상태 기록은 밀어 넣은 값을 그 행이 아직 들고 있을 때만
    • 제거는 존이 확인한 뒤에야 행 삭제. 제거 실패는 제거 의사를 유지
    • 해제된 이름에서는 무엇을 만나든 제거로 전환
  • 리컨실러 DomainRecordsReconciler
    • 관리자 전체 재동기화의 셋째 단계. 앞의 둘은 라우트와 플랫폼 주소로 판정하므로 외부 도메인이 그 밖에 놓임
    • 미는 쪽은 행 기준. 질문을 레코드 행에 하므로 회수된 도메인도 대상에 포함
    • 존 목록이 답하는 것은 아무 행도 자기 것이라 하지 않는 세트 하나. 기본은 보고만
  • 연장 기한 DomainRenewalSweeper
    • 기본 180일, 알림 D-30과 D-7과 D-1
    • 기한 경과 시 레코드 제거와 이름 해제. 삭제가 아니라 기존 예약 유예로 이관
  • 이름 회수 시 존 정리
    • 외부 이름의 모든 회수가 존을 읽고 그 아래 남은 세트를 정리
    • 치우지 못하면 이름을 한 주기 더 예약. 다음 주인이 앞 주인의 레코드를 물려받는 것을 막기 위함
  • 통지 두 자리
    • VM 없는 도메인의 관리자 해제를 도메인 자기 접근 목록으로 통지
    • 예약 통지의 명사와 동사를 종류에 맞게 선택
  • API 명세 v0.73.0
    • DomainKindEXTERNAL, ResourceTypeDOMAIN. 연산 변화 없음

⭐️ 검증

  • 시험 1407개 통과, 체크스타일과 빌드
  • 새 시험 40개. 세대 경합, 푸시 중 편집, 회수 시 전 타입 삭제, 회수된 이름의 재시도, 값 가드 거절, 기한 경과가 해제로만 가는 것
  • 변이 검사 열두 곳에서 빨간색 확인. 그중 하나는 처음에 거짓 초록이어서 상황 설정을 정정
  • 명세 대조는 마스터 문서를 걸고 실행. 여섯 축 전부 통과, 생략 0
  • 독립 서브에이전트 둘로 적대 검토. 여덟 건 재현되어 전부 처분, 기각 0
  • 라이브 미배포

💬 리뷰 포인트

  • 교내 도메인 접미사 목록을 코드 상수에 둘지 설정으로 뺄지. 설정이면 목록 확장이 쉬운 대신 값 가드가 설정 변경만으로 느슨해지는 대가

The edit is a desired state rather than a list of operations, so a repeat
of the same intent is one change and a retry is safe. Pushes are ordered
by a per-domain generation and run under the shared name lock; a set that
never reached the zone is dropped rather than queued for a removal the
zone would refuse forever.

Values are refused before they are stored: our own ingress and the campus
range, addresses that are not destinations on the public internet, a
CNAME back into a platform root, and TXT that would speak for the
parent's mail policy.
Nothing came back for them. The resync walks routes and an external
domain has none, and the zone pass beside it decides by the platform's
own address, which no external record carries, so an apply that died
mid-push left rows owed a write that nothing retried.

The push half is driven by the rows, because the provider already reads
a set before writing it and re-deciding that here would be a second copy
of the comparison. The listing answers the other direction, which rows
cannot: a set the zone holds that no row claims, left in place and
reported unless orphan pruning is on.
FAILED means the set is owed a write, so a failed removal recorded that
way had the next apply put the set back into the zone instead of taking
it down. The row goes only once the provider confirms the deletion, so
it would then be owed a write for good and the name it belongs to could
never be reclaimed.
Nothing above such a name expires and it is issued without approval, so
whoever typed it first would hold it for good. Its own deadline replaces
that: notices at the configured stages, then a release that takes the
records down and puts the name into the same reservation grace a release
by hand gets.

Renewing is a button rather than a liveness check the platform makes. A
site that is briefly down and a name whose owner has gone look the same
from outside, and only the owner can tell them apart.

Taking a domain down goes through one door still, which now has an
external branch: without it a name with no route read as one that never
served and was freed the instant its owner let go of it. The reclaim of
such a name waits on its record sets being out of the zone, so the next
holder cannot inherit the last one's DNS, and its notices are addressed
through its own access list because it has no VM to ask.
Two enum values reach the public spec: the external domain kind, and the
resource type that will carry its access list. No operation changes.
The generation guard runs before the provider call, so an edit that
commits during one is invisible to it. The row was then stamped APPLIED
holding values the zone had never received, and the push that superseded
it skips applied rows, so the zone and the row disagreed with nothing
left to find it.

The status write now lands only while the row still holds what was
pushed. Compared on the values rather than the generation, because the
generation moves for any edit to the domain and a set that edit did not
touch was written correctly.
An administrator's takedown frees the name in the same transaction that
marks the sets for removal, and every scan started from the domains
table is blind to a retired row. One failed push therefore left records
standing in the platform's zone on a name anybody could now register,
which is what the takedown exists to prevent.

The question is asked of the record rows instead, which is where the
debt is written down. A retired domain never writes: its owed sets
become removals, and when a live row has since taken the name they are
forgotten rather than removed, since removing them would delete the new
owner's records.
A released row keeps its name through the reservation grace, and only
the retired case was refused. So a set added or still owed during that
grace was pushed into the zone for a name mid-reclaim, and being owed a
write rather than a removal it made the reclaim find work forever: the
name was never freed and the sweep said so once an hour with nothing
changing.

An edit is refused once the name is let go, and a push under one turns
whatever it finds into a removal.
Addresses in the campus range are refused because a university name
aimed at another machine on that network is the shape a phishing page
takes. A CNAME reaches the same place and no address rule can see it,
because the target is deliberately never resolved here, so the whole
family was one record type away from being bypassed.

Values are also normalized before they are checked and stored. The
guards ran on a stripped and folded copy while the raw string was what
the zone received, so a leading space passed every rule and then failed
at the provider for as long as the row existed.
The notice was addressed through the VM, so a name with none reached
nobody at all. The audit row alone reaches no user, and a public address
disappearing must not be discovered from a dead link, which is what the
method already said it was for.

The sentence has a form that names no VM rather than naming an empty
one, which is how a reader tells a notice from a defect.
The reclaim asked the rows and nothing else, and rows going is not the
same as the zone being clear. A push that reached the zone and then
failed to record itself leaves a set no row remembers, and the next edit
drops the row that would have owed its removal, so the row side has
nothing left to say. Freeing the name then handed its next holder a
record pointing at the last one's server.

Every reclaim of such a name now reads the zone under it and takes down
whatever is there, or keeps the name reserved another cycle if it
cannot. Not gated behind the orphan-pruning switch: that switch guards a
scan that has to guess which records are the platform's to remove, and
here the platform still holds the name.
Two reservation notices now reach names with no VM, and both were
written for one: they called the name a platform subdomain and told its
owner to re-attach it, which is not an action such a name has. The noun
and the verb are chosen the same way the link already was.
The locked record query was never called, and its javadoc described it
as what serialises two applies for one domain. That defence is the name
lock, so the comment was documenting protection the code did not have,
which is the sort of thing that makes a real gap read as covered.

Two more statements corrected: a push that stands down does not itself
write the final state, and the reader-facing list now has the order it
claimed. A dead set in the policy goes too.
@yessjun
yessjun merged commit f7aa842 into main Sep 10, 2026
1 check passed
@yessjun
yessjun deleted the feat/domain-record-sets branch September 10, 2026 14:28
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