External Controller API와 자동 전환의 범위

Clash 계열 클라이언트에서 노드 선택과 연결 상태 확인은 서로 다른 기능입니다. 프록시 그룹 화면에서 다른 노드를 누르면 코어의 선택 상태가 바뀌지만, 노드가 실제로 살아 있는지 주기적으로 검사하고 장애 때 자동으로 대체 노드를 고르는 기능까지 항상 제공되는 것은 아닙니다. mihomo 계열 코어의 External Controller API를 사용하면 그룹 구성과 현재 선택 노드를 조회하고, 지연 검사 결과를 확인한 뒤, 필요할 때 선택을 바꾸는 외부 스크립트를 만들 수 있습니다.

이 방식은 코어의 규칙이나 구독 내용을 자동으로 다시 작성하지 않습니다. 코어가 실행 중이고 API에 접근할 수 있으며, 대상 그룹이 선택 가능한 프록시를 포함하고 있어야 합니다. 클라이언트 화면의 이름이 같더라도 사용 중인 코어와 버전에 따라 API 지원 범위가 다를 수 있으므로 먼저 실제 코어 정보를 확인하세요. 아래 예시는 mihomo 계열 API 동작을 기준으로 하며, 모든 구형 Clash 코어에 그대로 적용되는 설정은 아닙니다.

  • 그룹 조회: GET /proxies 또는 GET /proxies/{그룹명}으로 그룹 멤버와 현재 선택값을 확인합니다.
  • 연결 검사: GET /proxies/{노드명}/delay에 검사 URL과 제한 시간을 전달해 해당 프록시를 통한 요청 결과를 확인합니다.
  • 선택 변경: PUT /proxies/{그룹명}에 선택할 프록시 이름을 JSON으로 전달합니다.

지연 검사 성공은 지정한 URL에 대한 요청이 제한 시간 안에 성공했다는 뜻입니다. 모든 웹사이트, UDP 연결, 특정 앱의 트래픽까지 정상이라는 보장은 아닙니다. 따라서 검사 결과만 보고 노드 전체의 품질을 단정하지 말고, 자동 전환 후 실제로 필요한 서비스가 복구됐는지도 별도로 확인하세요.

API 접근 설정과 보안

External Controller는 코어의 관리 인터페이스입니다. 설정에 따라 로컬 포트에서 동작하며, 예를 들어 127.0.0.1:9090은 같은 기기에서만 접근할 수 있도록 묶은 주소입니다. 포트 번호는 예시일 뿐이므로 실제 적용 설정과 클라이언트의 컨트롤러 주소를 확인해야 합니다. 컨트롤러를 0.0.0.0에 공개하면 네트워크의 다른 기기에서도 접근할 수 있어 공격 표면이 커집니다. 원격 관리가 꼭 필요하지 않다면 루프백 주소를 사용하세요.

external-controller: 127.0.0.1:9090
secret: "여기에 충분히 긴 임의의 비밀값"

클라이언트가 코어 설정을 생성하거나 덮어쓰는 경우, 직접 수정한 파일이 다음 업데이트에서 사라질 수 있습니다. 클라이언트의 설정 편집·오버라이드 기능을 이용하고, 실제 실행 설정 또는 시작 로그에서 값이 적용됐는지 확인하세요. API 요청에는 인증 헤더를 포함해야 합니다. 위 비밀값은 문서용 자리표시자이며, 그대로 운영에 사용하지 마세요.

Authorization: Bearer 여기에_설정한_비밀값

Python으로 검사하고 장애 시 그룹 전환하기

다음 예시는 로컬 컨트롤러 API를 10초마다 확인하는 간단한 감시 스크립트입니다. 먼저 그룹 정보를 읽어 현재 선택된 노드가 그룹 멤버인지 확인하고, 현재 노드의 지연 검사에 실패하는 상황이 연속 두 번 발생하면 다른 멤버를 순서대로 검사합니다. 응답이 정상인 첫 후보를 그룹에 선택합니다. 연속 실패 횟수는 일시적인 네트워크 흔들림에 즉시 반응해 노드를 계속 바꾸는 현상을 줄이기 위한 값입니다.

스크립트는 Python 3와 requests 패키지를 사용합니다. 터미널에서 python -m pip install requests로 준비한 뒤, 코드를 파일로 저장해 실행하세요. 그룹명은 실제 설정의 이름과 정확히 일치해야 합니다. 검사 주소는 사용하는 환경에서 안정적으로 접근 가능한 HTTPS 주소로 바꾸고, 검사 제한 시간과 주기는 네트워크 특성에 맞춰 조정하세요.

import os
import time
from urllib.parse import quote

import requests

API = os.environ.get("CLASH_API", "http://127.0.0.1:9090")
SECRET = os.environ["CLASH_SECRET"]
GROUP = "자동 전환"
TEST_URL = "https://www.gstatic.com/generate_204"
TIMEOUT_MS = 5000
INTERVAL_SECONDS = 10
FAILURES_BEFORE_SWITCH = 2

session = requests.Session()
session.headers.update({"Authorization": f"Bearer {SECRET}"})


def api_url(path):
    return API.rstrip("/") + path


def get_group():
    name = quote(GROUP, safe="")
    response = session.get(api_url(f"/proxies/{name}"), timeout=5)
    response.raise_for_status()
    return response.json()


def check_delay(proxy_name):
    name = quote(proxy_name, safe="")
    response = session.get(
        api_url(f"/proxies/{name}/delay"),
        params={"timeout": TIMEOUT_MS, "url": TEST_URL},
        timeout=(3, TIMEOUT_MS / 1000 + 2),
    )
    response.raise_for_status()
    result = response.json()
    return int(result["delay"])


def select_proxy(proxy_name):
    name = quote(GROUP, safe="")
    response = session.put(
        api_url(f"/proxies/{name}"),
        json={"name": proxy_name},
        timeout=5,
    )
    response.raise_for_status()


failures = 0

while True:
    try:
        group = get_group()
        current = group.get("now")
        members = group.get("all", [])

        if not current or current not in members:
            raise RuntimeError("현재 선택 노드 또는 그룹 멤버 목록을 확인할 수 없습니다.")

        try:
            delay = check_delay(current)
            print(f"현재 노드 정상: {current}, {delay} ms")
            failures = 0
        except requests.RequestException as error:
            failures += 1
            print(f"현재 노드 검사 실패 ({failures}/{FAILURES_BEFORE_SWITCH}): {error}")

            if failures >= FAILURES_BEFORE_SWITCH:
                switched = False
                for candidate in members:
                    if candidate == current:
                        continue
                    try:
                        delay = check_delay(candidate)
                        select_proxy(candidate)
                        print(f"전환 완료: {current} -> {candidate}, {delay} ms")
                        failures = 0
                        switched = True
                        break
                    except (requests.RequestException, KeyError, ValueError) as error:
                        print(f"후보 노드 검사 실패: {candidate}: {error}")

                if not switched:
                    print("정상 후보가 없어 현재 선택을 유지합니다.")

    except (requests.RequestException, RuntimeError, ValueError) as error:
        print(f"그룹 조회 또는 API 처리 실패: {error}")

    time.sleep(INTERVAL_SECONDS)

코드의 CLASH_SECRET은 환경 변수로 전달해 코드 파일에 비밀값을 남기지 않도록 했습니다. 예를 들어 셸에서 환경 변수를 설정한 뒤 같은 터미널에서 스크립트를 실행할 수 있습니다. Windows와 macOS·Linux의 환경 변수 설정 방법은 서로 다르므로 사용하는 셸의 방식에 맞추세요. 실행 전에는 그룹의 실제 응답에 now와 all 값이 있는지 확인해야 합니다. 그룹 유형과 코어 버전에 따라 반환 구조가 다를 수 있습니다.

export CLASH_SECRET="실제 컨트롤러 비밀값"
python monitor.py

예시 코드는 현재 노드 검사 실패와 API 접근 실패를 구분해 일부 오류를 출력하지만, 운영 환경에서는 로그 파일 회전, 종료 신호 처리, 재시작 정책도 고려하세요. 특히 401 또는 403 응답은 비밀값이나 권한 문제일 가능성이 있고, 연결 거부는 컨트롤러 주소·포트 또는 코어 실행 상태를 확인해야 합니다. API 오류를 노드 장애로 오인해 대체 노드를 계속 선택하지 않도록 실패 유형을 나누는 것이 중요합니다.

그룹 구성과 후보 선택 정책

검사 대상은 그룹 멤버 중에서 고르며, 실제 전환 대상도 해당 그룹에서 선택 가능한 항목이어야 합니다. 그룹에 다른 그룹이나 특수 정책이 들어 있다면 중첩 구조를 고려해야 합니다. 위의 간단한 스크립트는 멤버 이름을 그대로 검사하므로, 멤버가 또 다른 그룹인 구성을 쓰는 경우에는 그룹 내부에서 노드를 선택하는 로직을 별도로 구현해야 합니다. 또한 현재 노드 외에 첫 번째로 응답한 노드를 선택하므로, 지연 시간 기준 최적 경로 선택이나 지역 우선순위는 포함하지 않습니다.

  • 기본 전환: 연결 성공 여부만 확인하고 첫 번째 정상 후보를 선택합니다. 이해와 테스트가 쉽습니다.
  • 최저 지연 선택: 모든 후보를 검사해 가장 낮은 지연을 선택할 수 있지만, 매 주기마다 후보 전체에 요청하므로 검사 트래픽과 전환 변동이 늘어납니다.
  • 고정 우선순위: 중요한 서비스에서 선호 노드 순서를 정할 수 있습니다. 지연이 조금 높더라도 운영 정책상 특정 노드를 우선할 수 있습니다.

상태가 간헐적으로 흔들리는 환경에서는 검사 간격을 지나치게 짧게 잡지 마세요. 10초마다 모든 후보를 검사하면 후보 수와 요청 수가 비례해 늘어납니다. 두 번의 실패 후 전환, 전환 뒤 일정 시간 재검사 제한, 정상 상태가 일정 횟수 유지된 뒤에만 원래 노드로 복귀하는 방식이 불필요한 왕복 전환을 줄이는 데 도움이 됩니다.

검증, 로그 확인과 복구 계획

운영 전에 실제 장애를 만들기보다 테스트용 그룹이나 복구 가능한 환경에서 먼저 확인하세요. 먼저 GET /proxies로 그룹 이름을 확인하고, 해당 그룹을 조회해 현재 선택과 멤버 목록을 기록합니다. 다음으로 단일 노드의 지연 검사 API를 호출해 성공 응답의 형태와 실패 응답을 확인한 뒤, 그룹 전환 요청을 실행하고 조회 결과의 현재 선택값이 바뀌었는지 확인합니다. 테스트가 끝나면 원래 노드로 되돌려 두세요.

  1. 코어가 실행 중인지, API가 설정한 주소와 포트에서 응답하는지 확인합니다.
  2. 인증 헤더가 포함된 요청으로 그룹 정보와 멤버 이름을 확인합니다. 이름에 공백이나 특수 문자가 있으면 URL 인코딩이 필요합니다.
  3. 현재 노드와 대체 후보 각각에 같은 검사 URL을 사용해 결과를 비교합니다.
  4. 전환 요청 후 그룹 정보를 다시 조회하고, 클라이언트 UI의 선택 상태도 함께 확인합니다.
  5. 스크립트를 중지한 뒤에도 선택 상태가 유지되는지, 재시작 시 기대한 정책으로 돌아오는지 확인합니다.

검사 URL이 차단되거나 특정 지역에서 응답하지 않으면 정상 노드도 실패로 판정할 수 있습니다. 검사 주소를 바꾸기 전에 브라우저 등 별도 경로에서 해당 주소가 실제로 접근 가능한지 확인하세요. DNS 오류, TLS 인증서 문제, 연결 제한 시간 초과는 서로 다른 실패 원인일 수 있습니다. 지연 검사 결과 하나만으로 노드 서버 장애라고 판단하지 말고, 코어 로그와 시스템 네트워크 상태를 함께 살펴보세요.

자동 전환은 실패한 현재 노드에서 새 연결을 시작할 때 적용할 출구를 바꾸는 동작입니다. 이미 열려 있는 TCP 연결이 새 노드로 그대로 이동하는 것은 아닙니다. 기존 연결은 끊기거나 시간 초과될 수 있으며, 앱이 다시 연결해야 새 출구를 사용합니다. 자동 전환 후에도 특정 연결이 복구되지 않는다면 연결 목록을 갱신하거나 앱에서 해당 요청을 다시 시도해 보세요.

자주 묻는 질문

Clash Verge 계열 클라이언트에서 API 주소는 어디서 확인하나요?

클라이언트마다 설정 화면과 명칭이 다르므로 컨트롤러 주소를 일률적으로 단정할 수 없습니다. 설정 편집 또는 코어 시작 정보에서 external-controller와 인증 비밀값 적용 여부를 확인하세요. UI에서 관리 기능을 제공한다면 그 설정을 우선 사용하고, 실제 실행 중인 코어에 값이 반영됐는지 확인해야 합니다.

지연 검사에 실패하면 노드가 완전히 사용할 수 없는 상태인가요?

아닙니다. 검사 URL에 접근하지 못했거나 DNS·TLS·네트워크 경로에 일시적인 문제가 있을 수 있습니다. 다른 검사 주소와 코어 로그를 함께 확인하고, 연속 실패 횟수를 두어 한 번의 요청 실패가 즉시 전환으로 이어지지 않게 하세요.

자동 전환으로 기존 연결도 끊김 없이 유지되나요?

보장되지 않습니다. 그룹의 선택을 바꾸더라도 이미 설정된 연결의 출구가 소급해 변경되지는 않습니다. 새 연결부터 새 선택을 사용할 수 있으며, 기존 연결은 앱이 다시 연결해야 할 수 있습니다.

컨트롤러를 다른 기기에서 호출해도 되나요?

가능한 구성도 있지만, 로컬 루프백보다 노출 위험이 큽니다. 필요한 경우에만 접근 가능한 인터페이스와 방화벽을 제한하고 강한 비밀값을 사용하세요. 포트 공개만으로 인증과 네트워크 접근 제어를 대신할 수는 없습니다.