X4G는 검열 우회용 프록시 소프트웨어다. 프록시·VPN 운영의 합법성은 국가와 상황에 따라 크게 다르고, 무료 PaaS(Railway 등)에서 이런 트래픽을 중계하는 건 대부분 해당 서비스 약관 위반이다. 이 문서는 실행·배포를 권하는 글이 아니라 asyncio 네트워크 프로그래밍 교재로서 코드를 읽는 글이다. 8장 실습 과제도 전부 로컬에서 자기 자신에게 연결하는 형태로 설계했다.
또한 저장소에 LICENSE 파일이 없다. 법적으로 "모든 권리 유보" 상태이므로 코드를 복사해 재배포·상용화할 권리가 없다. 읽고 배우는 것과 가져다 쓰는 것은 다르다.
클라이언트(v2rayNG, NekoBox 같은 앱)가 wss://내도메인/ws/{uuid} 로 접속하면, X4G가 그 안에 실린 VLESS 헤더를 읽어 "어느 사이트로 가고 싶은지"를 알아내고, 대신 그 사이트에 TCP 연결을 열어 바이트를 양방향으로 실어 나른다. 여기에 링크별 트래픽 상한·속도 제한·동시 IP 제한·만료일, 그리고 페르시아어 관리 대시보드와 텔레그램 봇이 얹혀 있다.
국제 우편 대행소를 생각하면 된다. 당신은 편지 겉봉에 "이 편지를 실제로는 도쿄의 A씨에게 보내주세요"라고 적은 속지를 넣고, 겉봉에는 그냥 대행소 주소만 적어서 보낸다. 대행소(X4G)는 겉봉을 뜯어 속지를 읽고, 진짜 목적지로 다시 부친다. 바깥에서 보면 당신은 그냥 대행소에 편지를 보낸 것뿐이다.
여기서 "겉봉"이 HTTPS/WebSocket이고, "속지"가 VLESS 헤더다. 겉봉이 평범한 443 포트 HTTPS라서 겉만 봐서는 일반 웹 접속과 구별하기 어렵다는 게 이 구조의 핵심이다.
먼저 숫자부터 이상하다. 보통 오픈소스는 별이 포크의 5~20배다. X4G는 포크가 별의 1.8배다. 이건 저장소의 성격을 그대로 드러낸다 — 읽으려고 오는 곳이 아니라, 포크해서 배포하려고 오는 곳이다. README의 설치 방법 1단계가 말 그대로 "Fork"다.
| 단계 | 전통적인 Xray 서버 | X4G |
|---|---|---|
| 서버 | VPS 임대 (월 $3~5, 신용카드 필요) | Railway 무료 티어 |
| 도메인 | 도메인 구입 + DNS 설정 | *.up.railway.app 자동 제공 |
| TLS 인증서 | certbot / acme.sh 로 발급·갱신 | 플랫폼이 자동 처리 |
| 프록시 코어 | Xray-core 설치, JSON 설정 작성 | 포크 후 자동 배포 |
| 리버스 프록시 | nginx 설정으로 WS 경로 매핑 | 불필요 (앱이 직접 처리) |
| 사용자 관리 | 별도 패널(3x-ui 등) 추가 설치 | 내장 대시보드 + 텔레그램 봇 |
즉 X4G가 판 건 기술이 아니라 절차의 삭제다. 리눅스 명령어를 한 줄도 몰라도, 브라우저에서 버튼 몇 번으로 끝난다. 이게 포크 1만 개의 정체다.
기술적으로 가장 흥미로운 차별점은 XHTTP 지원이다. WebSocket 프록시는 널리 알려진 만큼 널리 차단된다. XHTTP는 WebSocket 업그레이드를 아예 포기하고, 평범한 HTTP 요청/응답 쌍만으로 양방향 통신을 흉내 낸다.
WebSocket이 전화라면, XHTTP는 편지 두 통이다. 하나는 "답장을 계속 부쳐주세요"라며 무한정 열어두는 편지(다운링크 GET), 다른 하나는 내가 할 말을 계속 부치는 편지(업링크 POST). 전화선이 끊겨 있어도 우체국만 살아있으면 대화가 된다.
X4G는 업링크를 두 가지 모드로 제공한다.
seq)이 박혀 있고, 서버가 순서를 다시 맞춘다.이 둘의 구현 차이가 4장의 핵심이다. 특히 packet-up의 재정렬 버퍼는 "상태 없는 HTTP 위에 순서 있는 스트림을 얹는" 문제를 아주 작은 코드로 보여준다.
| Xray-core | 3x-ui / Marzban | X4G | |
|---|---|---|---|
| 언어 | Go | Go·Python + Xray-core | 순수 Python |
| 프로토콜 구현 | 직접 (레퍼런스) | Xray-core에 위임 | 직접 (부분 구현) |
| 처리량 | 기가비트급 | 기가비트급 | 수십 Mbps 수준(추정) |
| UDP 지원 | 있음 | 있음 | 없음(명령 바이트 무시) |
| 수평 확장 | 가능 | 가능 | 불가(workers=1 고정) |
| 테스트 | 있음 | 있음 | 0개 |
| 배포 난이도 | 높음 | 중간 | 매우 낮음 |
X4G는 VLESS 프로토콜의 일부만 구현했다. 8장에서 확인하겠지만 헤더의 UUID를 검증하지 않고, TCP/UDP를 구분하는 command 바이트를 읽고도 무시한다. 파이썬 이벤트 루프 하나로 모든 바이트를 복사하므로 성능도 Go 구현과 비교 대상이 아니다.
그럼에도 읽을 가치가 있는 이유는 정확히 그 축소 때문이다. Xray-core는 수십만 줄이라 초보자가 진입할 수 없다. X4G의 relay_vless.py는 214줄이고, 그 안에 프로토콜 파싱·양방향 중계·역압·쿼터 회계가 전부 들어있다. 프록시가 어떻게 동작하는지 이해하기에 이보다 좋은 크기는 드물다.
저장소를 클론해서 git log를 보면 커밋이 6개뿐이고, 그중 4개가 Add files via upload다. 이건 GitHub 웹 UI에 파일을 드래그해서 올렸다는 뜻이다. 브랜치도, PR도, 개발 이력도 없다.
eval·exec·os.system·base64.b64decode 사용이 0건이고, 외부 통신 대상도 텔레그램 API·QR 서비스·CDN뿐이라 백도어는 발견되지 않았다), 개발 과정을 검증할 수 없다는 뜻이다. 별 5,700개짜리 저장소에 이력이 없다면 그 자체가 읽기 전 알아야 할 정보다.fastapi==0.104.1
uvicorn[standard]==0.24.0
httpx==0.25.1
websockets==12.0
aiofiles>=23.2.1
httpx[http2] # ← 위에서 이미 핀 고정했는데 또 씀 (중복)
여섯 줄이 전부다. ORM도, 데이터베이스 드라이버도, 템플릿 엔진도, 프론트엔드 빌드 도구도 없다. 그리고 이 중 하나는 프로젝트 코드에서 한 번도 import되지 않는다.
websockets==12.0의 정체WebSocket 추상화로 다룬다. 그럼 왜 있냐 — uvicorn[standard]가 내부적으로 WS 프로토콜 구현체로 쓰기 때문이다. 즉 간접 의존성을 직접 의존성처럼 적어둔 것이다. 흔한 실수이고, 나중에 uvicorn이 구현체를 바꾸면 유령 의존성으로 남는다.FastAPI를 쓴다고 다 같은 게 아니다. X4G가 실제로 사용하는 기능만 추리면 이렇다.
| 기능 | 사용 여부 | 위치·비고 |
|---|---|---|
FastAPI() | ✅ | main.py:26 — docs_url=None, redoc_url=None으로 자동 문서 비활성화 |
APIRouter | △ 1개만 | xhttp_siz10.py:33. 나머지 라우트는 전부 @app에 직접 |
Depends | ✅ 인증 전용 | _=Depends(require_auth)가 15개 엔드포인트에 |
| 미들웨어 | △ CORS만 | main.py:63-69 — 설정이 심각하다(8장 참조) |
| WebSocket | ✅ | main.py:1012 — 데코레이터가 아니라 app.add_api_websocket_route()로 명령형 등록 |
StreamingResponse | ✅ | xhttp_siz10.py:355 — XHTTP 다운링크의 심장 |
request.stream() | ✅ | xhttp_siz10.py:441 — stream-up 업링크 |
BackgroundTasks | ❌ | 대신 asyncio.create_task(save_state()) 를 날것으로 사용 |
| lifespan | ❌ | 구식 @app.on_event("startup") 사용 (FastAPI에서 deprecated) |
| Pydantic 모델 | ❌ | 요청 본문을 전부 dict로 받음. 스키마 검증 없음 |
uvicorn 설정은 딱 한 줄이다. 그리고 이 한 줄이 아키텍처 전체를 결정한다.
uvicorn.run("main:app", host="0.0.0.0", port=CONFIG["port"],
log_level="info", workers=1)
workers=1은 실수가 아니라 필수다모든 상태 — LINKS(설정 목록), connections(활성 연결), SESSIONS(로그인 세션), xhttp_sessions — 가 프로세스 메모리의 전역 딕셔너리다. 워커를 2개로 늘리는 순간 워커마다 다른 LINKS를 보게 되고, 트래픽 쿼터 회계가 조용히 망가진다.
즉 X4G는 구조적으로 수평 확장이 불가능하다. 이건 "나중에 고치면 되는 것"이 아니라 상태 저장소를 Redis 같은 외부로 빼야만 풀리는 문제다. 6장 학습 포인트에서 이 지점이 왜 중요한지 다시 다룬다.
여기가 이 프로젝트에서 가장 논쟁적인 부분이다. HTML이 전부 파이썬 문자열 리터럴로 pages.py 안에 들어있다.
# pages.py 구조
LOGO_B64 = "iVBORw0KGgo..." # 5행: base64 PNG 로고 (한 줄)
LOGIN_HTML = r"""<!DOCTYPE html>...""" # 7행부터 ~98줄
DASHBOARD_HTML = r"""<!DOCTYPE html>...""" # 105행부터 1966행까지 — 1,860줄
# 로고 주입은 그냥 문자열 치환
LOGIN_HTML = LOGIN_HTML.replace("__LOGO_B64__", LOGO_B64)
DASHBOARD_HTML = DASHBOARD_HTML.replace("__LOGO_B64__", LOGO_B64)
# 공개 페이지만 f-string — 그래서 CSS 중괄호를 전부 두 번 써야 함
def get_public_page_html(uuid_key): # 1970행
return f"""... *{{margin:0;padding:0}} ..."""
구문 강조 없음. 린터 없음. 프론트엔드 테스트 불가능. 미니파이 없음. 소스맵 없음. CSS를 한 줄 고치려면 1,800줄짜리 문자열 안에서 찾아야 한다.
그리고 get_public_page_html()은 f-string이라 CSS 중괄호를 전부 {{ }}로 이스케이프해야 한다. 400줄짜리 문자열에서 중괄호 하나를 빼먹으면 런타임 크래시다. 정적 분석으로 잡을 수 없는 종류의 버그다.
프론트엔드가 실제로 쓰는 것들:
| 항목 | 내용 |
|---|---|
| 차트 | Chart.js 4.4.1 (cdnjs) — 시간별 MB 라인차트, 평균 오버레이, 도넛 |
| CSS | 수제. :root 커스텀 프로퍼티 + [data-theme="light"] 오버라이드 |
| 디자인 | 다크 네이비 글래스모피즘 — backdrop-filter:blur(24px), 라디얼 그라디언트 |
| 방향 | <html lang="fa" dir="rtl"> — 전체 우측 정렬(페르시아어) |
| 폰트 | Vazirmatn (Google Fonts) + Tabler Icons 3.19.0 (jsDelivr) |
| JS | 바닐라. 프레임워크·번들러 없음. setInterval(fetchStats, 4000) 폴링 |
| QR 코드 | 외부 서비스에 위탁 — api.qrserver.com (심각한 문제, 8장) |
DATA_FILE = DATA_DIR / "x4g_state.json" # main.py:30
JSON 파일 하나가 전부다. 저장되는 것과 안 되는 것이 명확히 갈린다.
| 디스크에 저장 (재시작 후 유지) | 메모리만 (재시작 시 소멸) |
|---|---|
links — 프록시 설정 목록subs — 구독 그룹password_hashsaved_at | 통계 · 활성 연결 목록 · 시간별 트래픽 로그인 세션 · 에러/활동 로그 XHTTP 세션 |
쓰기는 임시 파일 + rename 방식이라 원자적이다. 이건 잘 짠 부분이다.
# main.py:97-100 (요지)
async with SAVE_LOCK:
tmp = DATA_FILE.with_suffix(".tmp")
# ... tmp에 전부 쓴 뒤 ...
tmp.replace(DATA_FILE) # POSIX에서 원자적 연산
파일에 직접 쓰다가 프로세스가 죽으면 반쪽짜리 JSON이 남아 다음 부팅에서 전부 날아간다. 임시 파일에 완전히 쓴 다음 rename하면, 파일시스템 레벨에서 "옛날 것" 또는 "새 것" 둘 중 하나만 존재한다. 중간 상태가 없다. 설정 파일을 다루는 모든 프로그램이 이렇게 해야 한다.
requirements.txt와 main.py를 자동 감지해 python main.py를 실행하는 데 의존한다. 이 암묵적 규약 덕에 설정이 0줄이지만, 동시에 Railway가 아닌 곳에 올리려면 스스로 알아내야 한다.| 환경변수 | 역할 | 기본값 |
|---|---|---|
ADMIN_PASSWORD | 대시보드 비밀번호 | X4GKING ← 위험 |
SECRET_KEY | 세션·비밀번호 서명 키 | 없으면 자동 생성 후 파일 저장 |
DATA_DIR | 상태 저장 경로 | /data (Volume 필수) |
PORT | 리스닝 포트 | 플랫폼이 주입 |
RAILWAY_PUBLIC_DOMAIN | 링크 생성용 호스트명 | localhost |
TELEGRAM_BOT_TOKEN | 봇 토큰 (선택) | — |
TELEGRAM_ADMIN_IDS | 관리자 숫자 ID, 쉼표 구분 | — |
relay_vless.websocket_tunnel() (119~214행)이 하는 일을 순서대로 따라가 보자.
프록시가 "어디로 보낼지"를 알아내는 부분이다. 전문을 그대로 옮긴다.
async def parse_vless_header(chunk: bytes):
if len(chunk) < 24:
raise ValueError("chunk too small")
pos = 1 # ① 버전 바이트 건너뜀
pos += 16 # ② UUID 16바이트 건너뜀 ← 검증 안 함!
addon_len = chunk[pos]; pos += 1 + addon_len
command = chunk[pos]; pos += 1 # ③ 읽지만 안 씀
port = int.from_bytes(chunk[pos:pos+2], "big"); pos += 2
addr_type = chunk[pos]; pos += 1
if addr_type == 1: # IPv4
address = ".".join(str(b) for b in chunk[pos:pos+4]); pos += 4
elif addr_type == 2: # 도메인 (길이 선행)
dlen = chunk[pos]; pos += 1
address = chunk[pos:pos+dlen].decode("utf-8", errors="ignore"); pos += dlen
elif addr_type == 3: # IPv6
ab = chunk[pos:pos+16]; pos += 16
address = ":".join(f"{ab[i]:02x}{ab[i+1]:02x}" for i in range(0, 16, 2))
else:
raise ValueError(f"unknown addr type: {addr_type}")
return command, address, port, chunk[pos:] # 남은 건 실제 데이터
초보자가 여기서 배울 것이 세 가지 있다.
pos 하나로 바이트 배열을 훑는다. 필드를 읽을 때마다 pos를 그 길이만큼 밀고, 마지막에 chunk[pos:]로 남은 부분을 돌려준다. 이 "소비하지 않은 나머지를 반환한다"는 패턴은 모든 스트림 파서의 기본형이다.
int.from_bytes(chunk[pos:pos+2], "big"). 포트 443은 0x01BB인데, 네트워크에서는 항상 큰 자릿수부터 보낸다(big-endian). x86 CPU는 반대(little-endian)라서 명시하지 않으면 443이 47873으로 읽힌다. 네트워크 프로토콜에서 "big"은 거의 항상 정답이다.
addr_type == 2 분기를 보라. 도메인은 길이가 가변이라 "길이 1바이트 → 그 길이만큼의 내용" 순서로 담는다. 종료 문자(\0)를 쓰지 않는 이유는, 내용 안에 그 문자가 나올 수 있고 파싱 전에 전체 길이를 알 수 없기 때문이다. 바이너리 프로토콜의 사실상 표준이다.
pos += 16 — 헤더 안의 UUID를 읽지도, 비교하지도 않고 그냥 건너뛴다. 인증은 오로지 URL 경로의 /ws/{uuid}로만 이루어진다.
결과: 비밀 UUID가 URL에 실려 다닌다. URL은 중간의 모든 프록시·CDN·리버스 프록시의 액세스 로그에 평문으로 남는다. 진짜 Xray는 이걸 헤더 안(TLS로 암호화된 본문)에 넣고 검증하기 때문에 로그에 남지 않는다. 프로토콜 설계 의도를 정확히 반대로 뒤집은 셈이다.
VLESS의 command는 0x01=TCP, 0x02=UDP, 0x03=MUX다. X4G는 이걸 return은 하지만 호출부에서 한 번도 검사하지 않고 전부 TCP로 처리한다.
실질적 영향: UDP가 동작하지 않는다. DNS 조회, QUIC(HTTP/3), 온라인 게임, 화상통화가 전부 깨지거나 이상하게 느려진다. 2장의 비교표에서 "UDP 지원 없음"이라고 쓴 근거가 이 한 줄이다.
프록시의 본질은 "A에서 읽어 B로 쓰고, 동시에 B에서 읽어 A로 쓴다"이다. 순서대로 하면 한쪽이 멈출 때 전체가 멈추므로, 반드시 동시에 돌려야 한다.
# relay_vless.py:181-193 (요지)
t1 = asyncio.create_task(relay_ws_to_tcp(ws, writer, conn_id, uid))
t2 = asyncio.create_task(relay_tcp_to_ws(ws, reader, conn_id, uid))
done, pending = await asyncio.wait({t1, t2},
return_when=asyncio.FIRST_COMPLETED)
for t in pending:
t.cancel()
try:
await t # ← 초보자가 빼먹는 부분
except asyncio.CancelledError:
pass
통역사 두 명이 마주 앉아 있다. 한 명은 한국어→영어, 다른 한 명은 영어→한국어만 담당한다. 둘 중 한 명이 "대화 끝났습니다"라고 하면, 나머지 한 명도 즉시 자리에서 일으켜 세워야 한다. 안 그러면 아무도 말하지 않는 방에 통역사 한 명이 영원히 앉아있게 된다 — 이게 코루틴 누수다.
cancel() 후에 await해야 하는 이유task.cancel()은 "취소해달라고 요청"만 하고 즉시 리턴한다. 실제 취소는 그 코루틴이 다음에 await 지점에 도달할 때 일어난다. await task로 기다려주지 않으면 정리 코드(finally 블록)가 실행되기 전에 프로그램이 넘어간다. 그리고 await하면 CancelledError가 올라오므로 반드시 잡아야 한다. 이 4줄 패턴은 asyncio를 쓰는 한 평생 쓴다.
① 고정 임계값 — 가장 단순한 형태. relay_vless.py:91-92:
writer.write(data)
if writer.transport.get_write_buffer_size() > RELAY_BUF: # 256KB
await writer.drain()
② 크기 제한 큐 — 큐 자체를 역압 장치로 쓴다. xhttp_siz10.py:225:
down_q = asyncio.Queue(maxsize=DOWNLINK_QUEUE_MAX) # 512
큐가 꽉 차면 await down_q.put()이 블록되고 → TCP 리더가 멈추고 → 커널 수신 버퍼가 차고 → TCP 윈도우가 줄어들어 원본 서버가 알아서 느려진다. 압력이 체인을 따라 거꾸로 전파된다.
③ AIMD 적응형 버퍼 — 이 저장소에서 가장 배울 게 많은 30줄이다.
# xhttp_siz10.py:157-168
def should_drain(self, buf_size: int) -> bool:
return buf_size > self.high_water
async def drain(self, writer: asyncio.StreamWriter):
t0 = time.monotonic()
await writer.drain()
elapsed_ms = (time.monotonic() - t0) * 1000
self.last_drain_ms = elapsed_ms
if elapsed_ms < FLOW_FAST_DRAIN_MS: # 2ms 미만 = 여유 있음
self.high_water = min(FLOW_MAX_HW, int(self.high_water * 1.5) + 65536)
elif elapsed_ms > FLOW_SLOW_DRAIN_MS: # 25ms 초과 = 막힘
self.high_water = max(FLOW_MIN_HW, self.high_water // 2)
수도꼭지를 조절하는 법이다. 물이 잘 빠지면(drain이 2ms 미만) 꼭지를 1.5배 더 연다. 물이 안 빠지면(25ms 초과) 절반으로 확 잠근다. 늘릴 때는 조금씩, 줄일 때는 과감하게 — 이게 AIMD다.
왜 비대칭인가? 늘리는 건 실수해도 다음 측정에서 되돌리면 되지만, 줄이는 게 늦으면 이미 시스템이 막혀버리기 때문이다. 인터넷 전체를 굴리는 TCP 혼잡 제어가 정확히 이 원리로 동작한다. X4G는 그걸 유저스페이스 버퍼에 그대로 적용했다.
packet-up의 핵심 난제는 HTTP 요청이 순서대로 도착한다는 보장이 없다는 점이다. 3번 POST가 2번보다 먼저 올 수 있다. 그래서 재정렬 버퍼가 필요하다.
# xhttp_siz10.py:396-407
if seq == sess["next_seq"]: # 기다리던 순번이 왔다
sess["writer"].write(body)
sess["next_seq"] += 1
while sess["next_seq"] in sess["seq_buf"]: # 미리 와서 대기 중인 것들을
pending = sess["seq_buf"].pop(sess["next_seq"])
sess["writer"].write(pending) # 연달아 흘려보낸다
sess["next_seq"] += 1
else:
sess["seq_buf"][seq] = body # 아직 차례 아님 → 보관
번호표 창구다. 지금 부를 번호는 5번인데 7번, 6번이 먼저 와서 대기실에 앉아있다. 5번이 도착하는 순간 5번을 처리하고, 대기실을 뒤져 6번이 있으면 바로 처리, 7번도 있으면 또 처리 — 끊길 때까지 연달아 밀어낸다. TCP가 커널에서 하는 일을 애플리케이션 레벨에서 재현한 것이다.
응답 헤더는 일부러 평범한 CDN 스트림처럼 위장한다 (FINGERPRINTS, 62~74행).
content-type: application/grpc
server: cloudflare
x-accel-buffering: no # ← 이건 위장이 아니라 기능상 필수
x-accel-buffering: no# relay_vless.py:63-73 — 전문
async def check_and_use(uid: str, n: int) -> bool:
async with LINKS_LOCK:
link = LINKS.get(uid)
if link is None:
return False
if not is_link_allowed(link): # 비활성/만료/한도초과?
return False
link["used_bytes"] += n
stats["total_bytes"] += n
hourly_traffic[now_ir().strftime("%H:00")] += n
return True
WebSocket 경로와 XHTTP 경로가 둘 다 이 함수 하나를 통과한다. 단일 진입점 설계라 회계 로직이 흩어지지 않는다 — 잘 짠 부분이다.
다만 두 가지 부작용이 있다.
먼저 검사하고 나중에 더하는 순서라, 마지막 청크 하나만큼은 무조건 초과한다. WebSocket에서는 최대 256KB, XHTTP에서는 _QuotaGate의 배치 때문에 최대 1MB까지 넘어간다. 코드 주석에 명시된 의도적 트레이드오프지만, 정확한 과금이 필요한 서비스라면 그대로 쓰면 안 된다.
그리고 청크마다 전역 락을 잡는 건 성능상 재앙이라, XHTTP 경로에는 배치 처리기가 별도로 있다.
# xhttp_siz10.py:115-131 — EWMA 기반 적응형 배치
async def add(self, nbytes: int) -> bool:
self.pending += nbytes
elapsed = time.monotonic() - self.last_check
if self.pending >= self.batch_bytes or elapsed >= QUOTA_CHECK_INTERVAL:
flush, self.pending = self.pending, 0
inst_rate = flush / elapsed
self.rate_ewma = (0.7 * self.rate_ewma + 0.3 * inst_rate) # 평활화
target = int(self.rate_ewma * QUOTA_CHECK_INTERVAL)
self.batch_bytes = max(QUOTA_MIN_BATCH, min(QUOTA_MAX_BATCH, target))
self.ok = await check_and_use(self.uuid, flush) # 락은 여기서만
return self.ok
return True # 대부분은 락 없이 통과
새값 = 0.7×이전값 + 0.3×측정값. 과거 기록을 하나도 저장하지 않으면서 노이즈를 부드럽게 깎는 방법이다. 변수 하나면 되고 연산도 곱셈 두 번. 가중치 0.7이 크다는 건 천천히 반응한다는 뜻 — 순간적인 튐에 흔들리지 않게 하려는 선택이다.# speed_limit.py — 핵심부 전문
class _Bucket:
__slots__ = ("rate", "capacity", "tokens", "last")
def __init__(self, rate_bytes_per_sec: float):
self.rate = max(rate_bytes_per_sec, MIN_RATE)
self.capacity = max(self.rate, MIN_BURST) # 1초치 버스트 허용
self.tokens = self.capacity
self.last = time.monotonic()
def _refill(self):
now = time.monotonic()
elapsed = now - self.last
if elapsed > 0:
self.last = now
self.tokens = min(self.capacity, self.tokens + elapsed * self.rate)
async def consume(self, n: int):
while True:
self._refill()
if self.tokens >= n:
self.tokens -= n
return
deficit = n - self.tokens
wait = deficit / self.rate
await asyncio.sleep(min(max(wait, 0.004), 0.5))
물통에 수도꼭지가 달려 있다. 물은 초당 R리터씩 일정하게 채워지고, 통 용량은 정해져 있다(넘치면 버림). 데이터를 N바이트 보내려면 물 N리터를 퍼내야 한다. 물이 모자라면 찰 때까지 기다린다.
이 구조의 장점: 잠깐 쉬었다가 몰아서 보내는 게 허용된다. 통에 물이 가득 차 있으면 한 번에 다 퍼낼 수 있으니까. 평균 속도는 지키되 순간 버스트는 봐주는 것 — 웹 브라우징처럼 들쭉날쭉한 트래픽에 잘 맞는다.
time.monotonic()을 쓴 이유time.time()은 시스템 시계라서 NTP 동기화나 서머타임 때문에 뒤로 갈 수 있다. 그러면 elapsed가 음수가 되어 토큰 계산이 망가진다. monotonic()은 절대 뒤로 가지 않는 단조 증가 시계다. "두 시점 사이의 경과 시간"을 잴 때는 예외 없이 monotonic()을 써야 한다.
min(max(wait, 0.004), 0.5) — 상하한 클램핑계산된 대기 시간을 4ms~500ms로 강제한다. 하한이 있는 이유는 너무 짧게 자면 이벤트 루프가 컨텍스트 스위칭만 하다 CPU를 태우기 때문이고, 상한이 있는 이유는 관리자가 대시보드에서 속도 제한을 바꿨을 때 0.5초 안에 반영되게 하기 위해서다. 두 상수 모두 실전에서 나온 값이다.
SESSION_COOKIE = "x4g_session"
SESSION_TTL = 60*60*24*365 # 1년 (!)
def hash_password(pw):
return hashlib.sha256(f"{pw}{CONFIG['secret']}".encode()).hexdigest()
로그인하면 secrets.token_urlsafe(32) 토큰이 메모리 SESSIONS 딕셔너리에 들어가고 httponly·samesite=lax 쿠키로 나간다. 여기까지는 정상이다. 문제는 해싱 방식인데, 8장에서 자세히 다룬다.
speed_limit.py(71줄) → relay_vless.py(214줄) → xhttp_siz10.py(472줄) 순서로 읽으면 난이도가 자연스럽게 올라간다. main.py는 CRUD 보일러플레이트가 대부분이고, pages.py는 읽을 게 없다. 전체 5,146줄 중 실제로 배울 것은 15%에 몰려 있다.
보통 개발자는 "import는 파일 맨 위에" 라고 배운다(PEP 8). 여기서 그걸 따르면 ImportError: cannot import name 'LINKS' from partially initialized module로 앱이 즉사한다. 코드에 이 사실을 알리는 주석조차 없다.
올바른 해법은 공유 상태를 state.py 같은 별도 모듈로 빼고, 세 파일이 모두 그걸 임포트하게 만드는 것이다(의존성 방향을 한쪽으로). 6장 실습 과제에 넣어두었다.
| # | 주제 | 위치 | 왜 가치 있나 |
|---|---|---|---|
| 1 | 바이너리 프로토콜 파싱 | relay_vless.py:42-61 | 커서 이동, big-endian, 길이 선행 문자열이 20줄에 전부 |
| 2 | 데드락 없는 양방향 중계 | relay_vless.py:181-193 | FIRST_COMPLETED + cancel + await 관용구 |
| 3 | 역압 3종 비교 | 세 파일에 걸쳐 | 같은 문제의 서로 다른 해법을 한 저장소에서 대조 가능 |
| 4 | AIMD 적응 제어 | xhttp_siz10.py:140-168 | TCP 혼잡 제어를 유저스페이스에 이식한 실물 예제 |
| 5 | 락 경합 완화 | xhttp_siz10.py:95-137 | 정확도를 조금 포기하고 처리량을 사는 배치 전략 |
| 6 | 토큰 버킷 | speed_limit.py 전체 | 71줄짜리 완결된 레이트 리미터. 그대로 이식 가능 |
| 7 | 순서 없는 전송 위 재조립 | xhttp_siz10.py:396-407 | 슬라이딩 윈도우 재정렬의 최소 구현 |
| 8 | 소켓 튜닝 | xhttp_siz10.py:82-92 | TCP_NODELAY, SO_SNDBUF, 그리고 try/except OSError로 감싸는 이유 |
| 9 | 원자적 파일 쓰기 | main.py:97-100 | temp + rename. 설정 파일 다루는 모든 프로그램의 기본 |
| 10 | 롱폴링 봇 직접 구현 | telegram_bot.py:806-829 | offset = update_id + 1, 타임아웃 중첩, 업데이트별 예외 격리 |
# xhttp_siz10.py:82-92
def _tune_socket(sock):
try:
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 2*1024*1024)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 2*1024*1024)
except OSError:
pass # 플랫폼에 따라 실패할 수 있음
TCP_NODELAY=1이 이걸 끈다.이 저장소는 나쁜 예제로도 훌륭하다. 아래는 전부 실제 코드에서 확인된 것이다.
@app.api_route("/proxy/{target_url:path}", methods=["GET","POST",…])
async def http_proxy(target_url: str, request: Request):
if not target_url.startswith("http"):
target_url = "https://" + target_url
# ← Depends(require_auth) 없음. 허용 목록 없음. SSRF 방어 없음.
인터넷의 누구나 https://당신앱.up.railway.app/proxy/아무주소로 요청을 보낼 수 있다. 클라우드 메타데이터 엔드포인트(169.254.169.254)를 찔러 자격증명을 훔치는 SSRF도, 익명 릴레이로 악용하는 것도 막을 게 없다. 요금은 배포한 사람이 낸다. 이 저장소에서 가장 심각한 결함이다.
ADMIN_PASSWORD의 기본값은 X4GKING이고, 도메인은 예측 가능한 *.up.railway.app이다. 포크가 1만 개인데 그중 상당수가 이걸 안 바꿨을 것이다. 게다가 로그인 시도 제한이 전혀 없어서 무한 브루트포스가 가능하다.
sha256(비밀번호 + 전역_pepper). 솔트 없음, 스트레칭 없음, bcrypt/argon2/PBKDF2 아님. 상태 파일이 유출되면 GPU로 몇 초 만에 역산된다. 비밀번호 해싱은 느려야 하는데 SHA-256은 빠르라고 만든 함수다. 용도가 정반대다.
관리자 비밀번호(main.py:597)와 그룹 비밀번호(main.py:1068) 모두 !=로 비교한다. 파이썬 문자열 비교는 첫 다른 글자에서 즉시 중단되므로, 응답 시간 차이로 한 글자씩 알아낼 수 있다. 정답은 secrets.compare_digest()다.
window.open('https://api.qrserver.com/v1/create-qr-code/?size=300x300&data='
+ encodeURIComponent(link), '_blank')
비밀 UUID가 들어간 vless:// 링크 전체가 제3자 서버의 URL 쿼리스트링으로 전송된다. 그 서버의 액세스 로그에 모든 사용자의 접속 자격증명이 평문으로 쌓인다. QR 생성은 클라이언트에서 라이브러리로 하면 되는 일이다.
allow_origins=["*"], allow_credentials=True,
allow_methods=["*"], allow_headers=["*"]
사실 브라우저가 "*" + credentials=True 조합을 거부하기 때문에 실제 취약점으로 이어지진 않는다. 하지만 아무도 이 설정을 생각해보지 않았다는 신호다. 습관적으로 복붙한 CORS 설정의 전형이다.
| 문제 | 구체적 근거 |
|---|---|
| 테스트 0개 | grep -rn 'assert\|pytest\|unittest' *.py → 결과 없음. CI도 린터 설정도 없음 |
| 예외 삼키기 16곳 | except (WebSocketDisconnect, Exception): pass — 두 번째가 첫 번째를 이미 포함한다. 예외 계층을 이해 못한 흔적 |
CancelledError 잡기 | xhttp_siz10.py:249, 309 — except (asyncio.CancelledError, Exception). 취소를 삼키면 태스크 종료가 안 되는 asyncio 안티패턴 |
| 중복 코드 | 클라이언트 IP 추출 함수가 3벌. FINGERPRINTS라는 이름이 두 파일에서 완전히 다른 의미로 정의됨 |
| 죽은 기능 | "xhttp-stream-one"이 PROTOCOLS에 있고 텔레그램 봇 마법사가 선택지로 제공하지만, xhttp_siz10.py:346이 404로 거부한다. 봇으로 만들면 반드시 안 되는 설정이 나온다 |
| 가짜 UI 데이터 | pages.py:1928 — 도넛 차트가 data:[55,35,10] 하드코딩. 갱신 코드가 없다. 텔레메트리인 척하는 장식 |
| 구식 API | @app.on_event() — FastAPI가 lifespan으로 대체한 지 오래 |
| 타입 힌트 부분적 | 시그니처는 꽤 달려 있지만 컨테이너는 전부 맨 dict. 정작 중요한 link/session 구조에 TypedDict가 없음 |
원자적 파일 쓰기, 단일 쿼터 진입점, 토큰 버킷 구현, AIMD 흐름 제어, 세션 청소기(reaper) 패턴, __slots__로 메모리 절약 — 이 부분들은 경험 있는 사람이 쓴 코드처럼 보인다. 코드 품질이 파일마다 극단적으로 다르다는 게 이 저장소의 특징이다. speed_limit.py는 교재에 실어도 되고, pages.py는 그 반대다.
| 항목 | 요구사항 | 근거 |
|---|---|---|
| Python | 3.10 이상 | 런타임 평가되는 어노테이션에 PEP 604 유니온(httpx.AsyncClient | None) 사용. zoneinfo도 3.9+ |
| OS | 리눅스 전제 | TCP_NODELAY/SO_SNDBUF가 try/except OSError로 감싸져 있어 다른 OS에서도 죽진 않음 |
| RAM | 최소 512MB, 권장 1GB | 이론상 XHTTP 세션 하나가 512청크 × 512KB = 256MB까지 큐잉 가능(실제로는 큐가 계속 빠져서 거의 안 참). WS는 방향당 256KB |
| CPU | 1코어면 충분 (더 줘도 못 씀) | workers=1 + GIL + 이벤트 루프 1개. 바이트 복사가 전부 파이썬 레벨 |
| 처리량 | 수십 Mbps 추정 | 벤치마크가 저장소에 없어 측정값이 아님. XHTTP의 배치·AIMD 장치가 존재한다는 것 자체가 파이썬 오버헤드가 병목이었다는 증거 |
| 디스크 | 무시할 수준. 단 Volume 필수 | JSON 파일 하나. 하지만 DATA_DIR에 영구 볼륨을 안 붙이면 재배포마다 모든 설정과 비밀번호가 초기화됨 |
| 네트워크 | 유효한 TLS 인증서가 붙은 HTTPS 도메인 | 클라이언트가 security=tls로 접속. 443 외 포트를 쓰려면 그 포트가 실제로 열려 있어야 한다고 README가 경고 |
DATA_DIR(/data)에 영구 볼륨을 마운트하지 않으면 컨테이너 파일시스템이 휘발성이라 모든 링크·구독 그룹·변경한 비밀번호가 사라지고 기본값 X4GKING으로 돌아간다. README에 적혀 있지만 눈에 잘 안 띄는 위치다.
아래 과제는 X4G를 배포하지 않고, 배울 가치가 있는 기술만 떼어내 직접 구현하는 방식이다. 프록시를 운영하려는 게 아니라 asyncio 네트워크 프로그래밍을 익히는 게 목표다. 4번 과제부터는 X4G 코드를 참고하되 자기 코드로 다시 쓰는 형태다.
parse_vless_header()의 역함수 build_vless_header(uuid, command, address, port)를 작성하고, 파서에 넣었을 때 원래 값이 그대로 나오는지 확인한다(라운드트립 테스트).
확인 사항: IPv4·도메인·IPv6 세 주소 타입 모두. 길이 0인 도메인, 255바이트 도메인 같은 경계값. int.from_bytes(..., "little")로 바꾸면 포트가 어떻게 깨지는지도 직접 봐라.
얻는 것: 바이트 조립/해체가 대칭이라는 감각. struct.pack과 수동 to_bytes의 차이.
speed_limit.py의 _Bucket을 그대로 가져와 "1MB/s 제한으로 10MB를 보내면 정말 10초가 걸리는가"를 측정한다.
실험: ① 청크 크기를 1KB / 64KB / 1MB로 바꿔가며 실제 소요 시간과 오차 측정. ② MIN_BURST를 0으로 바꾸면 무슨 일이 일어나는가. ③ min(max(wait, 0.004), 0.5)의 하한 0.004를 0으로 바꾸고 CPU 사용률을 보라.
얻는 것: 레이트 리미터의 정확도가 청크 크기에 어떻게 의존하는지. 상수 두 개가 왜 그 값인지가 몸으로 이해된다.
asyncio.start_server()로 로컬 포트 9000을 열고, 들어온 연결을 127.0.0.1:9001(별도로 띄운 에코 서버)로 그대로 중계한다. 4-4절의 FIRST_COMPLETED 패턴을 직접 써라.
실험: ① t.cancel() 뒤의 await t를 지우고 finally 블록에 로그를 찍어보라 — 실행이 안 되는 걸 확인. ② 두 코루틴을 동시가 아니라 await t1; await t2 순차로 바꾸면 어떻게 멈추는지 확인.
얻는 것: 프록시의 골격. 이후 모든 중계 코드의 기반이 되는 30줄.
과제 3의 프록시에서 목적지를 일부러 느리게 만든다(에코 서버가 읽기 전에 await asyncio.sleep(0.1)). 그리고 writer.transport.get_write_buffer_size()를 0.1초마다 출력한다.
실험: ① drain() 호출을 통째로 제거하고 100MB를 밀어넣어 메모리 사용량을 관찰(psutil). ② 고정 임계값 방식으로 바꿔 메모리가 어디서 안정되는지 확인. ③ 4-5절의 _AdaptiveFlow를 구현해 high_water가 시간에 따라 어떻게 오르내리는지 그래프로 그려라.
얻는 것: "역압을 안 하면 메모리가 터진다"를 글이 아니라 숫자로. AIMD 곡선의 톱니 모양을 직접 보는 경험.
FastAPI로 POST /up/{session}/{seq}와 GET /down/{session}을 만들고, 일부러 순서를 섞어 POST를 보내는 클라이언트를 작성한다. 다운링크로 원본 순서대로 나오는지 검증.
고려할 것: ① seq가 영원히 안 오면 어떻게 되는가(seq_buf가 무한 성장) → 윈도우 크기 상한을 둬라. ② seq 중복 도착 처리. ③ 세션이 방치되면 정리하는 reaper 태스크(X4G는 30초 idle / 10초 주기).
얻는 것: TCP가 커널에서 대신 해주던 일을 직접 해보는 경험. X4G가 구현하지 않은 부분(윈도우 상한)까지 스스로 발견하게 된다.
6-3절의 결함 ①~⑥에 대해 포크에 패치를 작성한다. 배포하지 말고 코드만.
| 결함 | 수정 방향 |
|---|---|
| 오픈 프록시 | Depends(require_auth) 추가 + 사설 IP 대역(169.254.0.0/16, 10/8, 127/8…) 차단 |
| 기본 비밀번호 | 미설정 시 랜덤 생성 후 로그 출력, 또는 기동 거부 |
| SHA-256 | hashlib.scrypt(표준 라이브러리) 또는 argon2 |
| 타이밍 공격 | secrets.compare_digest() |
| QR 외부 위탁 | 클라이언트에서 qrcode.js로 로컬 생성 |
| 브루트포스 | IP당 로그인 시도 제한 — 과제 2의 토큰 버킷을 재사용하면 된다 |
얻는 것: 보안 리뷰를 지적으로 끝내지 않고 패치까지 가는 훈련. 마지막 항목은 앞 과제와 연결된다.
5-1절의 순환 의존을 없앤다. state.py를 새로 만들어 LINKS, LINKS_LOCK, stats, connections 등 공유 상태를 옮기고, 네 모듈이 모두 state만 임포트하도록 바꾼다. 그다음 main.py의 import 문을 파일 맨 위로 올려도 동작하는지 확인.
보너스: 여기까지 왔으면 state.py를 Redis 백엔드로 교체하는 게 얼마나 쉬워졌는지 보라. 그게 workers=1 족쇄를 푸는 유일한 길이다.
얻는 것: "의존성 방향을 한쪽으로 정렬한다"는 원칙이 왜 아키텍처 확장성과 직결되는지에 대한 실감.
| 주차 | 주제 | 할 일 | 연결되는 과제 |
|---|---|---|---|
| 1주 | asyncio 기초 | 코루틴·태스크·이벤트 루프. create_task와 gather와 wait의 차이. 취소 전파. 공식 문서 asyncio 챕터 정독 | — |
| 2주 | asyncio 스트림 | open_connection, start_server, StreamReader/Writer, drain()의 정확한 의미. relay_vless.py 214줄 완독 | 과제 3 |
| 3주 | 바이너리 프로토콜 | 바이트 오더, struct 모듈, 길이 선행 vs 구분자. VLESS 스펙 원문과 X4G 구현을 대조 | 과제 1 |
| 4주 | 흐름 제어·역압 | TCP 슬라이딩 윈도우, 혼잡 제어(AIMD), 버퍼블로트. xhttp_siz10.py의 _AdaptiveFlow 완독 | 과제 4 |
| 5주 | 레이트 리미팅 | 토큰 버킷 vs 리키 버킷 vs 슬라이딩 윈도우 카운터. 분산 환경에서의 레이트 리미팅(Redis) | 과제 2 |
| 6주 | HTTP 스트리밍 | chunked transfer, SSE, StreamingResponse, request.stream(). 프록시·CDN이 스트리밍을 죽이는 지점들 | 과제 5 |
| 7주 | 웹 보안 | SSRF, 타이밍 공격, 비밀번호 해싱(bcrypt/scrypt/argon2), CORS의 실제 동작, 세션 관리 | 과제 6 |
| 8주 | 상태와 확장성 | 공유 상태를 외부화하기. workers=1 문제를 Redis로 푸는 설계. 멱등성·경합 조건 | 과제 7 |
더 쉬운 것: 파이썬 표준 라이브러리 asyncio/streams.py — drain()이 실제로 어떻게 구현됐는지 보면 4장이 다시 읽힌다.
같은 난이도: mitmproxy의 코어 — 같은 "중계" 문제를 훨씬 성숙하게 푼 파이썬 코드베이스.
더 어려운 것: Xray-core(Go) — X4G가 생략한 부분(UDP, MUX, 실제 UUID 검증, 다중화)이 어떻게 구현되는지.
Upgrade: websocket으로 승격시켜 얻는 지속적 양방향 바이트 파이프. 평범한 443 HTTPS로 시작하기 때문에 겉보기에 채팅 앱과 구별하기 어렵다.vless:// 목록을 줄바꿈으로 이어 반환하면 클라이언트가 서버 목록을 자동 갱신한다. profile-title, profile-update-interval 같은 헤더는 HTTP 표준이 아니라 클라이언트들 사이의 관행이다./ws/{uuid})에 실리고 프로토콜 헤더 안의 UUID는 검증되지 않는다 — 그 결과 중간 프록시들의 액세스 로그에 평문으로 남는다.fp=chrome 같은 값. 클라이언트에게 "진짜 크롬처럼 TLS ClientHello를 흉내 내라"고 지시한다(암호 스위트 순서, 확장 필드 배열). DPI가 "이건 프록시 도구다"라고 판정하지 못하게 하는 장치. X4G 서버는 이 문자열을 저장했다가 공유 링크에 넣어줄 뿐이다.h2, http/1.1)을 협상하는 확장. 불일치하면 "연결은 되는데 트래픽이 안 흐르는" 전형적 증상이 난다. SNI는 핸드셰이크에 평문으로 실리는 호스트명이다.await writer.drain()과 크기 제한 asyncio.Queue 두 가지다. 안 하면 버퍼가 무한 성장한다._AdaptiveFlow가 "느리면 절반으로" 줄이는 분기가 정확히 이걸 막기 위해 있다.새값 = α×이전값 + (1−α)×측정값. 과거 데이터를 저장하지 않고 노이즈를 평활화하는 한 줄짜리 기법. X4G는 α=0.7로 천천히 반응하도록 설정했다.TCP_NODELAY=1로 끈다.169.254.169.254(메타데이터 엔드포인트)를 찔러 자격증명을 빼내는 게 대표적이다. X4G의 /proxy/{url}이 여기에 무방비다.==로 비밀값을 비교하면 첫 불일치 지점에서 즉시 반환되므로 응답 시간이 일치한 글자 수에 비례한다. 이걸 반복 측정해 한 글자씩 알아낸다. 방어는 항상 전체를 비교하는 secrets.compare_digest().rename. POSIX에서 rename은 원자적이라, 중간에 죽어도 "옛날 파일" 또는 "새 파일" 둘 중 하나만 존재하고 반쪽짜리는 없다.open_connection, drain()의 정확한 의미