Geosite 변환 서비스를 1년 반 동안 운영했지만 규칙 오판이 해결되지 않아, 프로젝트 전체를 다시 작성하고 미리보기 패널을 추가했습니다.
이전 Geosite 프로젝트는 1년 반 동안 안정적으로 운영되었고, 요청 한도 초과와 규칙 오판에 관한 이슈 몇 개만 있었습니다. 요청 한도는 몇 가지 임시방편으로 해결했지만, 규칙 오판은 좀처럼 고치지 못했습니다. 최근 시간이 생겨 프로젝트 전체를 다시 작성하기로 했습니다.
구버전의 원리와 구현은 이전 글에서 다뤘으므로, 이번 글에서는 주로 새 버전을 설명하겠습니다.
재작성한 이유
규칙 오판에 관한 두 이슈는 모두 정규식 변환 오류 때문에 발생했습니다. #3은 private 목록에 원래 있던 ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ 정규식이 그대로 DOMAIN-WILDCARD,?*로 변환되어 아무 도메인이나 매칭되는 문제였습니다. #4는 category-porn의 (^|\.)[a-z][1-9][0-9][a-z]\.com$가 *????.com으로 변환되어 google.com까지 porn으로 분류되는 문제였습니다.
이런 문제는 사실 예상된 것이었습니다. 변환 알고리즘을 설계할 때부터 문자 클래스는 모두 ?로, 수량자와 분기는 모두 *로 바뀌며, 정규식의 “첫 문자는 반드시 영문자여야 한다”, “최대 62자”, “하이픈으로 끝날 수 없다” 같은 제약은 변환 후 전부 사라지고 매우 넓은 와일드카드만 남습니다. #3이 발생한 뒤에는 임시 워크어라운드를 추가해, 오직 ?와 *로만 구성된 결과를 바로 건너뛰게 했습니다. 하지만 #4의 *????.com 같은 결과는 배제할 수 없었습니다.
또한 코드를 다시 검토하던 중 더 심각한 문제도 발견했습니다. Geosite의 include: 뒤에는 속성을 붙일 수 있습니다. 예를 들어 category-ads에는 다음과 같은 줄이 있습니다.
TEXT
include:adobe @ads
의미는 adobe 안에서 @ads 속성이 붙은 규칙만 가져오라는 것입니다. 하지만 구버전은 include를 처리할 때 목록 이름을 이렇게 추출했습니다.
뒤에 붙은 속성은 곧바로 버려져서 adobe 목록 전체가 category-ads에 펼쳐졌습니다. 처음 작성할 때 업스트림에는 없던 문법이지만, 다시 작성할 당시에는 1566줄의 include 중 77줄에 속성이 붙어 있었습니다. 구버전 로직을 당시 업스트림 데이터로 테스트하니 category-ads는 8045개의 규칙을 출력했고, 여기에는 adobe.com, amazon.com, alibaba.com까지 들어 있었습니다. 올바른 결과는 638개뿐입니다. 이 목록에 REJECT를 적용하면 amazon.com도 차단됩니다.
이 문제들은 코드 몇 줄을 고쳐서 해결할 수 있는 것이 아니어서 프로젝트 전체를 다시 작성하기로 했습니다. 새 버전은 저장소의 next 브랜치에 있고, main과는 Git 이력이 완전히 독립적입니다. 기존 URL은 모두 그대로 사용할 수 있습니다.
전체 구조
새 버전은 pnpm 모노레포로, core, cli, worker, panel 네 개의 패키지로 나뉩니다.
core는 전부 순수 함수로 이루어져 있으며, Geosite 텍스트를 입력받아 Surge 규칙을 출력하고 파일을 읽거나 요청을 보내지 않습니다. 네 단계로 나뉩니다. parser는 각 목록을 타입과 줄 번호가 있는 항목으로 파싱하므로, 오류가 나면 어느 목록의 몇 번째 줄인지 알 수 있습니다. resolver는 include 확장, 속성 필터링, 중복 제거를 담당하며 순환 참조는 바로 오류로 처리합니다. regex는 정규식을 따로 처리합니다. 마지막으로 surge가 규칙 텍스트를 출력합니다.
위에서 말한 include 문제는 resolver에서는 함수 하나면 됩니다. include:xxx @a @-b는 parser에서 “속성 a 필수”와 “속성 b 금지”라는 두 종류의 조건으로 나뉘며, resolver가 각 규칙을 하나씩 확인합니다.
TypeScript
function matchesAttrFilters(rule: DomainRule, inclusion: Inclusion): boolean {
if (inclusion.mustAttrs.length === 0 && inclusion.banAttrs.length === 0) return true;
if (rule.attrs.length === 0) return inclusion.mustAttrs.length === 0;
for (const attr of inclusion.mustAttrs) if (!rule.attrs.includes(attr)) return false;
for (const attr of inclusion.banAttrs) if (rule.attrs.includes(attr)) return false;
return true;
}
core는 변환 결과와 함께 보고서도 출력합니다. 각 정규식을 동등하게 변환했는지, 매칭 범위를 넓혔는지, 변환을 포기했는지와 그 이유를 기록합니다.
CLI는 core를 한 겹 감싼 것입니다. 로컬 domain-list-community의 data 디렉터리를 지정하면, 모든 목록을 각 모드별로 한 번씩 변환하고 규칙, include 확장 후 중간 결과, 각 목록의 통계를 출력합니다. 변환 로직을 수정한 뒤 로컬에서 한 번 실행하면 어떤 규칙이 바뀌었는지 확인할 수 있습니다.
변환 모드
정규식 변환에는 정답이 하나만 있는 것은 아닙니다. 완전한 동등성을 원해서 몇 개를 포기하더라도 괜찮은 사람도 있고, 최대한 많이 커버하고 어느 정도의 추가 매칭은 받아들일 수 있는 사람도 있으므로, 변환을 세 가지 모드로 나누었습니다.
strict는 동등함을 증명할 수 있는 변환만 출력합니다. 예를 들어 ^a\.b$는 DOMAIN, (^|\.)a\.b$는 DOMAIN-SUFFIX에 대응합니다. 그 외에는 출력하지 않습니다. balanced는 기본 모드로, 휴리스틱을 이용해 DOMAIN-WILDCARD로 변환합니다. 제로 폭 어서션이나 역참조처럼 와일드카드로 표현할 수 없는 구문, 또는 의미 없는 변환 결과는 건너뜁니다. full은 balanced를 기반으로 하며, 변환하지 못한 정규식에서 리터럴 도메인 접미사를 추출하려고 시도합니다. 그래도 안 되면 *를 출력합니다.
당시 업스트림 데이터에는 서로 다른 정규식이 총 181개 있었습니다. strict에서는 하나도 남지 않았습니다. Surge 규칙으로 완전히 동등하게 변환할 수 있는 정규식이 없었기 때문입니다. 따라서 이 데이터에서 strict는 정규식을 모두 버리는 것과 사실상 같습니다. balanced에서는 180개, full에서는 181개 모두를 변환할 수 있습니다. 예를 들어 .+\.dkr\.ecr\.[^\.]+\.amazonaws\.com$는 balanced에서 *.dkr.ecr.*.amazonaws.com로 변합니다. 반면 #3에 있던 ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$는 balanced에서는 건너뛰고, full에서만 *로 변환됩니다.
Worker
구버전은 요청이 들어온 뒤에야 업스트림을 가져오고 압축을 풀고 변환했지만, 새 버전은 이 작업을 모두 예약 작업으로 옮겼고 요청은 R2만 읽으면 됩니다.
Cron은 5분마다 실행되며 업스트림 ZIP에 HEAD 요청을 보내 ETag를 비교하고, 변경이 없으면 아무것도 하지 않습니다. 변경이 있을 때만 한 번 다운로드해 data 디렉터리의 모든 목록을 압축 해제하고, core로 전체 파싱 및 확장을 수행합니다. 오류가 없으면 스냅샷과 인덱스를 R2에 기록한 뒤 마지막으로 state/latest.json을 전환합니다. 각 버전 디렉터리는 업스트림 ETag로 이름이 붙고, 새 스냅샷과 이전 스냅샷은 공존할 수 있습니다.
변환된 규칙 세트는 “목록·모드·속성” 조합마다 산출물로 저장합니다. 첫 요청이 들어올 때 생성해 R2에 기록하고, 이후에는 바로 읽습니다. 현재 버전에 산출물이 없고 이전 버전에는 있다면, 이전 것을 먼저 반환하면서 waitUntil로 백그라운드에서 새 산출물을 생성합니다. 요청은 기다릴 필요가 없습니다. 응답 헤더에는 ETag와 x-upstream-etag가 포함됩니다. 이전 산출물을 반환할 때는 x-stale: 1도 붙습니다. Cache-Control은 평소에는 public, max-age=300, s-maxage=1800, stale-while-revalidate=86400이고, 이전 산출물을 반환할 때는 max-age=60, s-maxage=120으로 줄어듭니다.
이렇게 하면 요청 경로에서 GitHub에 접근하지 않습니다. 업스트림이 느리거나 불안정해도 다음 갱신에만 영향을 주고, 이미 준비된 규칙 세트는 영향을 받지 않습니다.
Tip
기존 URL은 모두 유지되며, /geosite/apple@cn도 그대로 사용할 수 있고 기본값은 balanced입니다. 모드를 지정하려면 목록 이름 앞에 붙이면 됩니다. 예를 들어 /geosite/strict/apple@cn입니다. 또한 /geosite-srs/:name과 /geosite-mrs/:name 두 엔드포인트도 추가했습니다. 다른 클라이언트를 쓰는 사람을 위해 sing-geosite와 meta-rules-dat에서 이미 변환한 바이너리 규칙 세트를 캐시해 전달하는 용도입니다.
전체 도메인에서 /geosite*는 이 Worker가 처리하고, 나머지 경로는 패널이 처리합니다.
패널
구버전은 규칙 세트 변환과 간단한 목록만 지원했습니다. 사용 가능한 규칙 세트를 찾으려면 README에서 제가 주기적으로 갱신하는 아주 긴 목록을 보거나, 업스트림 저장소의 data 디렉터리를 뒤져야 했는데, 그 페이지는 GitHub에서 열면 브라우저가 바로 멈출 정도였습니다. Surge를 설정할 때 특정 목록을 쉽게 찾고, 그것이 어떻게 변환되는지, 모드를 바꾸면 어떻게 되는지, @cn을 붙이면 어떤 규칙이 남는지 쉽게 확인하려고 프런트엔드 패널을 추가하기로 했습니다.
프로젝트 전체가 Cloudflare에 배포되어 있지만, 설정 중에 패널을 여는 사람이 프록시를 사용하고 있다고 단정할 수는 없습니다. Surge는 프록시로 규칙을 가져올 수 있어도 브라우저는 직접 접속하고 있을 수 있습니다. 그래서 패널을 가볍게 만들고 프런트엔드 파일도 작게 유지해야 합니다. 이런 용도에는 Svelte가 제격입니다.
첫 화면을 빠르게 보여주려면 SSR이 필요합니다. 다만 업스트림에는 1.4k개가 넘는 목록이 있어, 전체 인덱스를 처음부터 브라우저에 보내면 gzip으로 압축해도 큽니다. 그래서 서버에서는 처음 40개의 목록 이름, 약 7 KB만 렌더링해 곧바로 볼 수 있게 했습니다. 나머지는 페이지 로딩이 끝난 뒤 브라우저에서 가져옵니다. 패널 서버는 최근 64개의 미리보기를 캐시하고, 20초마다 If-None-Match를 붙여 인덱스의 변경 여부를 확인합니다. Worker에는 service binding으로 접근해 공용 인터넷을 거치지 않습니다.
또한 인터페이스는 중국어와 영어 두 가지가 있으며, Accept-Language에 따라 자동으로 리디렉션합니다. 검색창에는 디바운스를 적용했고, 목록을 빠르게 전환할 때 늦게 도착한 이전 응답은 버립니다.
이렇게 해서 surge.bojin.co는 거의 즉시 열리고 바로 사용할 수 있게 되었습니다.
이전 Geosite 프로젝트는 1년 반 동안 안정적으로 운영되었고, 요청 한도 초과와 규칙 오판에 관한 이슈 몇 개만 있었습니다. 요청 한도는 몇 가지 임시방편으로 해결했지만, 규칙 오판은 좀처럼 고치지 못했습니다. 최근 시간이 생겨 프로젝트 전체를 다시 작성하기로 했습니다.
구버전의 원리와 구현은 이전 글에서 다뤘으므로, 이번 글에서는 주로 새 버전을 설명하겠습니다.
재작성한 이유
규칙 오판에 관한 두 이슈는 모두 정규식 변환 오류 때문에 발생했습니다. #3은 private 목록에 원래 있던 ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$ 정규식이 그대로 DOMAIN-WILDCARD,?*로 변환되어 아무 도메인이나 매칭되는 문제였습니다. #4는 category-porn의 (^|\.)[a-z][1-9][0-9][a-z]\.com$가 *????.com으로 변환되어 google.com까지 porn으로 분류되는 문제였습니다.
이런 문제는 사실 예상된 것이었습니다. 변환 알고리즘을 설계할 때부터 문자 클래스는 모두 ?로, 수량자와 분기는 모두 *로 바뀌며, 정규식의 “첫 문자는 반드시 영문자여야 한다”, “최대 62자”, “하이픈으로 끝날 수 없다” 같은 제약은 변환 후 전부 사라지고 매우 넓은 와일드카드만 남습니다. #3이 발생한 뒤에는 임시 워크어라운드를 추가해, 오직 ?와 *로만 구성된 결과를 바로 건너뛰게 했습니다. 하지만 #4의 *????.com 같은 결과는 배제할 수 없었습니다.
또한 코드를 다시 검토하던 중 더 심각한 문제도 발견했습니다. Geosite의 include: 뒤에는 속성을 붙일 수 있습니다. 예를 들어 category-ads에는 다음과 같은 줄이 있습니다.
의미는 adobe 안에서 @ads 속성이 붙은 규칙만 가져오라는 것입니다. 하지만 구버전은 include를 처리할 때 목록 이름을 이렇게 추출했습니다.
뒤에 붙은 속성은 곧바로 버려져서 adobe 목록 전체가 category-ads에 펼쳐졌습니다. 처음 작성할 때 업스트림에는 없던 문법이지만, 다시 작성할 당시에는 1566줄의 include 중 77줄에 속성이 붙어 있었습니다. 구버전 로직을 당시 업스트림 데이터로 테스트하니 category-ads는 8045개의 규칙을 출력했고, 여기에는 adobe.com, amazon.com, alibaba.com까지 들어 있었습니다. 올바른 결과는 638개뿐입니다. 이 목록에 REJECT를 적용하면 amazon.com도 차단됩니다.
이 문제들은 코드 몇 줄을 고쳐서 해결할 수 있는 것이 아니어서 프로젝트 전체를 다시 작성하기로 했습니다. 새 버전은 저장소의 next 브랜치에 있고, main과는 Git 이력이 완전히 독립적입니다. 기존 URL은 모두 그대로 사용할 수 있습니다.
전체 구조
새 버전은 pnpm 모노레포로, core, cli, worker, panel 네 개의 패키지로 나뉩니다.
core는 전부 순수 함수로 이루어져 있으며, Geosite 텍스트를 입력받아 Surge 규칙을 출력하고 파일을 읽거나 요청을 보내지 않습니다. 네 단계로 나뉩니다. parser는 각 목록을 타입과 줄 번호가 있는 항목으로 파싱하므로, 오류가 나면 어느 목록의 몇 번째 줄인지 알 수 있습니다. resolver는 include 확장, 속성 필터링, 중복 제거를 담당하며 순환 참조는 바로 오류로 처리합니다. regex는 정규식을 따로 처리합니다. 마지막으로 surge가 규칙 텍스트를 출력합니다.
위에서 말한 include 문제는 resolver에서는 함수 하나면 됩니다. include:xxx @a @-b는 parser에서 “속성 a 필수”와 “속성 b 금지”라는 두 종류의 조건으로 나뉘며, resolver가 각 규칙을 하나씩 확인합니다.
core는 변환 결과와 함께 보고서도 출력합니다. 각 정규식을 동등하게 변환했는지, 매칭 범위를 넓혔는지, 변환을 포기했는지와 그 이유를 기록합니다.
CLI는 core를 한 겹 감싼 것입니다. 로컬 domain-list-community의 data 디렉터리를 지정하면, 모든 목록을 각 모드별로 한 번씩 변환하고 규칙, include 확장 후 중간 결과, 각 목록의 통계를 출력합니다. 변환 로직을 수정한 뒤 로컬에서 한 번 실행하면 어떤 규칙이 바뀌었는지 확인할 수 있습니다.
변환 모드
정규식 변환에는 정답이 하나만 있는 것은 아닙니다. 완전한 동등성을 원해서 몇 개를 포기하더라도 괜찮은 사람도 있고, 최대한 많이 커버하고 어느 정도의 추가 매칭은 받아들일 수 있는 사람도 있으므로, 변환을 세 가지 모드로 나누었습니다.
strict는 동등함을 증명할 수 있는 변환만 출력합니다. 예를 들어 ^a\.b$는 DOMAIN, (^|\.)a\.b$는 DOMAIN-SUFFIX에 대응합니다. 그 외에는 출력하지 않습니다. balanced는 기본 모드로, 휴리스틱을 이용해 DOMAIN-WILDCARD로 변환합니다. 제로 폭 어서션이나 역참조처럼 와일드카드로 표현할 수 없는 구문, 또는 의미 없는 변환 결과는 건너뜁니다. full은 balanced를 기반으로 하며, 변환하지 못한 정규식에서 리터럴 도메인 접미사를 추출하려고 시도합니다. 그래도 안 되면 *를 출력합니다.
당시 업스트림 데이터에는 서로 다른 정규식이 총 181개 있었습니다. strict에서는 하나도 남지 않았습니다. Surge 규칙으로 완전히 동등하게 변환할 수 있는 정규식이 없었기 때문입니다. 따라서 이 데이터에서 strict는 정규식을 모두 버리는 것과 사실상 같습니다. balanced에서는 180개, full에서는 181개 모두를 변환할 수 있습니다. 예를 들어 .+\.dkr\.ecr\.[^\.]+\.amazonaws\.com$는 balanced에서 *.dkr.ecr.*.amazonaws.com로 변합니다. 반면 #3에 있던 ^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$는 balanced에서는 건너뛰고, full에서만 *로 변환됩니다.
Worker
구버전은 요청이 들어온 뒤에야 업스트림을 가져오고 압축을 풀고 변환했지만, 새 버전은 이 작업을 모두 예약 작업으로 옮겼고 요청은 R2만 읽으면 됩니다.
Cron은 5분마다 실행되며 업스트림 ZIP에 HEAD 요청을 보내 ETag를 비교하고, 변경이 없으면 아무것도 하지 않습니다. 변경이 있을 때만 한 번 다운로드해 data 디렉터리의 모든 목록을 압축 해제하고, core로 전체 파싱 및 확장을 수행합니다. 오류가 없으면 스냅샷과 인덱스를 R2에 기록한 뒤 마지막으로 state/latest.json을 전환합니다. 각 버전 디렉터리는 업스트림 ETag로 이름이 붙고, 새 스냅샷과 이전 스냅샷은 공존할 수 있습니다.
요청이 들어온 뒤의 흐름은 다음과 같습니다.
변환된 규칙 세트는 “목록·모드·속성” 조합마다 산출물로 저장합니다. 첫 요청이 들어올 때 생성해 R2에 기록하고, 이후에는 바로 읽습니다. 현재 버전에 산출물이 없고 이전 버전에는 있다면, 이전 것을 먼저 반환하면서 waitUntil로 백그라운드에서 새 산출물을 생성합니다. 요청은 기다릴 필요가 없습니다. 응답 헤더에는 ETag와 x-upstream-etag가 포함됩니다. 이전 산출물을 반환할 때는 x-stale: 1도 붙습니다. Cache-Control은 평소에는 public, max-age=300, s-maxage=1800, stale-while-revalidate=86400이고, 이전 산출물을 반환할 때는 max-age=60, s-maxage=120으로 줄어듭니다.
이렇게 하면 요청 경로에서 GitHub에 접근하지 않습니다. 업스트림이 느리거나 불안정해도 다음 갱신에만 영향을 주고, 이미 준비된 규칙 세트는 영향을 받지 않습니다.
기존 URL은 모두 유지되며, /geosite/apple@cn도 그대로 사용할 수 있고 기본값은 balanced입니다. 모드를 지정하려면 목록 이름 앞에 붙이면 됩니다. 예를 들어 /geosite/strict/apple@cn입니다. 또한 /geosite-srs/:name과 /geosite-mrs/:name 두 엔드포인트도 추가했습니다. 다른 클라이언트를 쓰는 사람을 위해 sing-geosite와 meta-rules-dat에서 이미 변환한 바이너리 규칙 세트를 캐시해 전달하는 용도입니다.
전체 도메인에서 /geosite*는 이 Worker가 처리하고, 나머지 경로는 패널이 처리합니다.
패널
구버전은 규칙 세트 변환과 간단한 목록만 지원했습니다. 사용 가능한 규칙 세트를 찾으려면 README에서 제가 주기적으로 갱신하는 아주 긴 목록을 보거나, 업스트림 저장소의 data 디렉터리를 뒤져야 했는데, 그 페이지는 GitHub에서 열면 브라우저가 바로 멈출 정도였습니다. Surge를 설정할 때 특정 목록을 쉽게 찾고, 그것이 어떻게 변환되는지, 모드를 바꾸면 어떻게 되는지, @cn을 붙이면 어떤 규칙이 남는지 쉽게 확인하려고 프런트엔드 패널을 추가하기로 했습니다.
프로젝트 전체가 Cloudflare에 배포되어 있지만, 설정 중에 패널을 여는 사람이 프록시를 사용하고 있다고 단정할 수는 없습니다. Surge는 프록시로 규칙을 가져올 수 있어도 브라우저는 직접 접속하고 있을 수 있습니다. 그래서 패널을 가볍게 만들고 프런트엔드 파일도 작게 유지해야 합니다. 이런 용도에는 Svelte가 제격입니다.
패널은 SvelteKit에 Tailwind 4와 shadcn-svelte를 더해 만들었습니다. 런타임 의존성은 아이콘 라이브러리 @lucide/svelte뿐입니다.
첫 화면을 빠르게 보여주려면 SSR이 필요합니다. 다만 업스트림에는 1.4k개가 넘는 목록이 있어, 전체 인덱스를 처음부터 브라우저에 보내면 gzip으로 압축해도 큽니다. 그래서 서버에서는 처음 40개의 목록 이름, 약 7 KB만 렌더링해 곧바로 볼 수 있게 했습니다. 나머지는 페이지 로딩이 끝난 뒤 브라우저에서 가져옵니다. 패널 서버는 최근 64개의 미리보기를 캐시하고, 20초마다 If-None-Match를 붙여 인덱스의 변경 여부를 확인합니다. Worker에는 service binding으로 접근해 공용 인터넷을 거치지 않습니다.
또한 인터페이스는 중국어와 영어 두 가지가 있으며, Accept-Language에 따라 자동으로 리디렉션합니다. 검색창에는 디바운스를 적용했고, 목록을 빠르게 전환할 때 늦게 도착한 이전 응답은 버립니다.
이렇게 해서 surge.bojin.co는 거의 즉시 열리고 바로 사용할 수 있게 되었습니다.