지포스나우는 일본 계정을 쓴다. 이 계정은 앱을 켤 때, 로그인할 때, 게임을 시작할 때 일본 IP로 보여야 한다. 대신 게임이 시작된 뒤에는 VPN을 꺼도 스트리밍이 그대로 이어진다. 그래서 VPN은 처음 몇 분만 켜면 된다.

이 용도에는 미꾸라지(Mudfish)가 잘 맞는다. 월정액이 아니라 쓴 트래픽만큼 내는 종량제라, 잠깐 켜는 용도에는 부담이 적다. 스팀덱(SteamOS)도 공식 지원한다. 문제는 쓰는 방법이었다. 데스크탑 모드의 Konsole에서 미꾸라지를 띄운 채로 게임모드로 넘어가면 미꾸라지가 같이 꺼진다. 결국 지포스나우를 켤 때마다 데스크탑 모드를 오가야 했다.

그래서 게임모드의 데키(Decky Loader) 패널에서 미꾸라지 Full VPN을 켜고 끄는 플러그인을 만들었다. 저장소는 git.sejong.dev/ysj7705/mudfish-decky에 공개해 두었다.

쓰는 모습

  1. 게임모드에서 ... → 데키 → Mudfish VPN을 열고 토글을 켠다.
  2. 상태가 연결됨 · 🇯🇵 JP로 바뀌면 지포스나우를 실행해 로그인하고 게임을 시작한다.
  3. 게임이 시작되면 토글을 끈다.

노드는 기본이 자동이라, 켤 때마다 그 시점에 핑이 가장 낮은 일본 노드를 고른다. 직접 고르고 싶으면 고급 ▸을 펼친다. 연결된 뒤 지난 시간도 보여 줘서, 켜 둔 걸 잊지 않게 했다.

끄는 걸 잊으면 안 되는 이유가 있다. 미꾸라지는 1GB당 135원이고 노드를 거치는 트래픽은 두 번 집계된다. 스트리밍을 켜 둔 채로 두면 시간당 수천 원이 나간다. 그래서 이 플러그인의 첫 번째 규칙은 "절대 스스로 연결을 시작하지 않는다"였다. 부팅할 때도, 플러그인이 다시 로드될 때도, 절전에서 깨어날 때도 켜는 건 사람이 토글을 눌렀을 때뿐이다.

구조

구성역할
프론트엔드 (React, @decky/ui)빠른 메뉴 패널: 상태 줄, 토글, 노드 줄, 고급 메뉴
백엔드 (Python, root)미꾸라지 데몬 관리, 로컬 API 호출, 경로 정리. 표준 라이브러리만 쓴다
미꾸라지 데몬 mudrun-headless로컬 웹 UI와 API를 127.0.0.1:8282에서 연다. 터널(tap0)을 만든다

게임모드 전환 때 미꾸라지가 죽는 건 Konsole 세션이 닫히면서 같이 정리되기 때문이었다. 그래서 데몬을 세션과 무관한 transient systemd unit으로 띄운다. 이렇게 띄운 데몬은 데스크탑 모드와 게임모드를 오가도 그대로 살아 있는 것을 확인했다.

def ensure_daemon(bin_path, is_up, run=subprocess.run, sleep=time.sleep):
    """Start the launcher as a transient systemd unit so it outlives desktop/game mode switches."""
    if is_up():
        return
    run(["systemd-run", "--unit=mudfish-decky", "--collect", bin_path, "-B"], capture_output=True, timeout=10)
    for _ in range(20):
        sleep(0.5)
        if is_up():
            return

데몬은 플러그인이 로드될 때 띄워 두지만, 이때는 코어(터널)를 시작하지 않은 대기 상태다. 노드 핑만 측정한다.

웹 UI가 하는 일을 그대로 따라 하기

미꾸라지 데몬은 로컬 웹 UI로 조작한다. 웹 UI도 결국 데몬의 HTTP API를 부르는 것이라, 웹 UI가 보내는 요청을 하나씩 확인해 백엔드에서 똑같이 재현했다. 공개된 API는 아니다.

요청용도
GET /do/mudrun/status로그인 여부, 코어 상태(core_ready, client_ready) 등 전체 상태
POST /do/auth클라우드 로그인 결과를 로컬 데몬에 전달
POST /do/mudfish/start, /stop터널 시작·종료
POST /do/fullvpn/refresh연결 중에 Full VPN 노드 바꾸기
GET /do/rtt/staticnodes노드별 핑

자동 로그인. 데몬을 새로 띄우면 로그인이 풀린다. 데스크탑 모드에서 "로그인 기억하기"로 한 번 로그인해 두면 저장된 토큰이 남는데, 웹 UI는 이 토큰으로 미꾸라지 클라우드 API에 다시 로그인한 뒤 결과를 /do/auth로 데몬에 넘긴다. 플러그인도 같은 순서로 한다.

Full VPN 노드는 계정 설정이다. 어느 노드로 Full VPN을 탈지는 로컬이 아니라 미꾸라지 계정의 프로그램 설정에 저장된다. 플러그인은 이 설정을 읽어서 노드 번호 하나만 바꾸고 나머지는 읽은 값 그대로 돌려보낸다. 사용자 계정 설정을 묻지 않고 바꾸지 않기 위해서다.

직접 시험해 보니 몇 가지가 예상과 달랐다.

  • 연결 중에 노드를 바꾸면 응답에 재시작이 필요 없다고 나오고, /do/fullvpn/refresh를 부르면 재연결 없이 새 노드로 바뀐다. 그런데 로컬 상태의 노드 번호는 이전 값으로 남는다. 그래서 현재 노드는 상태 API가 아니라 플러그인이 저장한 값으로 판단한다.
  • /do/rtt/staticnodes는 핑 측정 라운드가 도는 동안에는 {"status": 300}만 준다. 측정값은 라운드가 끝난 순간에만 잠깐 나오고, 라운드 간격은 1분이 넘는다. 그래서 마지막으로 성공한 측정값을 저장해 두고 쓴다.

왜 Full VPN인가

윈도우판 미꾸라지는 프로그램 단위로 트래픽을 잡는 "아이템" 방식이 있어서 필요한 프로그램만 VPN을 태울 수 있다. 리눅스판에는 이 프로세스 단위 캡처가 없다. 아이템 방식으로 시험해 보니 지포스나우 로그인과 API 호스트(login.nvidia.com, *.geforcenow.com)가 미꾸라지를 거치지 않았다. 그래서 모든 트래픽을 보내는 Full VPN만 지원한다. 어차피 처음 몇 분만 켜니 크레딧 부담은 크지 않다.

Full VPN이 켜지면 미꾸라지는 기본 경로를 바꾸지 않고 0.0.0.0/1과 128.0.0.0/1 두 경로를 tap0로 보내는 분할 방식을 쓴다. 기존 default 경로보다 더 구체적인 경로라서 모든 트래픽이 터널로 간다. 일본 노드는 228개 중 135개였고, 도쿄·오사카·홋카이도에 있다.

끄기: 남는 경로 정리

끄기는 생각보다 손이 많이 갔다. 터널을 내리면 tap0와 분할 경로는 사라지지만, 노드 IP로 가는 /32 경로가 기존 게이트웨이 쪽으로 남는다. 처음 시험할 때 한 번에 349개가 남았다. 같은 와이파이에서는 문제가 없지만, 다른 네트워크로 옮기면 그 노드들로 가는 경로가 틀어질 수 있다.

그래서 끌 때마다 미꾸라지가 남긴 노드 경로를 지운다. 실제 덱에서 확인해 보니 끌 때마다 400줄 남짓이 정리되고, 라우팅 테이블에는 기본 2줄만 남았다.

상태 판단도 플러그인 내부 플래그를 믿지 않는다. 패널을 열 때마다 미꾸라지 상태 API와 tap0 존재 여부를 새로 확인한다. 상태 조회가 실패했는데 tap0가 살아 있으면 "꺼짐"이 아니라 오류로 보여 주고, 토글로 언제든 강제로 끌 수 있게 했다.

절전에서 깨어나면 인터넷이 끊긴다

실사용 테스트에서 가장 고약했던 문제다. VPN을 켠 채로 덱을 절전시켰다가 깨우면 인터넷이 통째로 안 된다. 그런데 미꾸라지는 여전히 client_ready: true, 즉 정상이라고 보고한다.

원인은 이랬다.

  1. 깨어날 때 NetworkManager가 wlan0 경로를 다시 만들면서, 미꾸라지가 넣어 둔 중계 서버로 가는 /32 경로를 지운다.
  2. 반면 tap0의 0/1, 128/1 경로는 남는다.
  3. 그러면 중계 서버로 가야 할 터널 패킷까지 터널 안으로 들어간다. 터널이 자기 자신을 감싸려다 아무 데도 못 가는 상태다.

중계 서버 /32 경로를 손으로 다시 넣으면 복구되는 것으로 원인을 확인했다. 경로를 복구하는 방법도 있었지만, 이 VPN은 어차피 처음 몇 분만 쓰는 용도다. 그래서 절전에서 깨어나면 VPN을 끈다로 정했다. 덱을 끌 때(플러그인 unload)도 마찬가지로 끈다.

깨어남은 시계 두 개의 차이로 감지한다. CLOCK_BOOTTIME은 절전 중에도 흐르고 CLOCK_MONOTONIC은 멈춘다. 둘의 차이가 늘어났다면 그사이 절전했다는 뜻이다. 이걸 2초마다 확인한다.

def sleep_offset(clock=time.clock_gettime, boot=getattr(time, "CLOCK_BOOTTIME", None),
                 mono=time.CLOCK_MONOTONIC) -> float:
    """Total time spent suspended since boot: BOOTTIME keeps counting during suspend, MONOTONIC doesn't."""
    return clock(boot) - clock(mono)

다시 시험해 보니 깨어난 직후 로그에 resume detected; turning VPN off가 남고, 인터넷은 정상이고, 패널은 꺼짐 상태였다.

게임모드에서 휴대폰으로 로그인하기

처음 설계에서는 게임모드 로그인을 범위 밖으로 뒀다. 저장된 로그인이 만료되면 "데스크탑 모드에서 다시 로그인하세요"라고 안내만 하는 식이었다. 그런데 막상 쓰다 보니 그 한 번을 위해 데스크탑 모드에 가는 게 번거로웠다.

그래서 휴대폰으로 QR을 찍어 로그인하게 했다. 미꾸라지 로그인 화면은 원래 덱 안의 127.0.0.1:8282에만 열려 있다. 로그인이 필요할 때만 플러그인이 덱의 LAN 주소에 임시 포트를 열고, 그 포트로 들어온 연결을 로그인 화면으로 그대로 중계한다. 패널에는 그 주소를 QR로 띄운다.

LAN에 포트를 여는 일이니 조건을 걸었다.

  • QR 주소에는 일회용 토큰(/_mfd/<token>)이 들어 있다. 이 주소를 처음 연 IP만 통과하고, 다른 기기는 403을 받는다.
  • 최대 5분만 열려 있다. 새 로그인이 감지되거나, 팝업을 닫거나, 시간이 지나거나, 플러그인이 내려가면 바로 닫힌다.

중계는 HTTP를 해석하지 않는 TCP 파이프다. 첫 요청만 MSG_PEEK로 들여다보고 토큰 경로인지 판단한다.

class Gate:
    def decide(self, ip: str, path: Optional[str]) -> str:
        is_token = path is not None and path.split("?", 1)[0] == TOKEN_PREFIX + self.token
        with self._lock:
            if self.client is None and is_token:
                self.client = ip          # 토큰을 처음 연 기기만 등록
            if ip != self.client:
                return "deny"
            return "authorize" if is_token else "pipe"

실제로 덱에서 로그아웃한 뒤 휴대폰으로 QR을 찍어 로그인하자, 10초 뒤 플러그인이 새 로그인을 감지하고 중계를 닫았다. 닫힌 뒤 LAN에 남은 포트는 없었다.

미꾸라지 설치·업데이트·제거까지

이것도 처음엔 범위 밖이었다. 미꾸라지 설치는 공식 가이드대로 데스크탑 모드 터미널에서 하면 된다고 봤다. 하지만 VPN을 게임모드에서 켜고 끄게 해 놓고 설치만 데스크탑 모드에 남겨 두니 어색해서, 이것도 패널 버튼으로 옮겼다.

설치. 미꾸라지 리눅스 패키지는 Makeself 형식이다. 셸 스크립트 헤더 뒤에 bzip2로 묶은 tar가 붙어 있고, 헤더에 MD5가 적혀 있다. 플러그인은 이 스크립트를 실행하지 않는다. MD5를 검증한 뒤 tar 부분만 직접 풀어 /opt/mudfish/<버전>에 넣는다. 이때 경로 조작과 링크는 거부한다(filter="data"). 실제 6.6.0을 이렇게 설치해 보니 데몬 바이너리가 공식 설치본과 같았다. 덱에서 /opt는 /home 쪽 파티션이라 SteamOS 업데이트에도 남는다.

업데이트. 하루에 한 번 최신 버전을 확인하고, 새 버전이 있으면 고급 메뉴에 버튼을 띄운다. 업데이트는 버튼으로만 하고, VPN이 켜져 있으면 거부한다. 새 버전에 로그인 설정을 옮겨 주므로 다시 로그인할 필요가 없고, 바로 전 버전 하나만 남긴다. 6.5.5에서 6.6.0으로 올려 보니 로그인이 그대로 유지됐다.

제거. 버튼으로도 지울 수 있고, 데키에서 플러그인을 삭제해도 같이 정리되게 했다. 그런데 후자가 말썽이었다. 데키 3.3은 플러그인을 내릴 때 SIGTERM을 보내는데, 이와 동시에 소켓이 닫히면 플러그인 쪽 리스너가 EOF에서 헛돌며 이벤트 루프를 붙잡는다. 그러면 정리 코드(_uninstall)가 돌지 못한 채 5초 뒤 SIGKILL을 맞는다. 이 덱에 깔린 다른 플러그인들도 꽤 높은 비율로 이렇게 종료되고 있었다.

그래서 정리를 플러그인 프로세스에만 맡기지 않았다. 플러그인이 일시적인 systemd path 유닛을 하나 띄워 plugins 폴더를 지켜보게 한다. 이 플러그인 폴더가 20초 동안 사라져 있으면 그때 미꾸라지를 정리한다. 재설치나 업데이트처럼 폴더가 곧바로 다시 생기는 경우는 건드리지 않는다.

script = "; ".join([
    "sleep 20",
    f'[ -e "{plugin_dir}/plugin.json" ] && exit 0',   # 다시 설치됐으면 그만
    "systemctl stop mudfish-decky",
    "nmcli connection delete mudfish-tap",
    "rm -rf /opt/mudfish",
    f'rm -rf "{settings_dir}"',
    "systemctl stop mudfish-decky-cleanup.path",
])
run(["systemd-run", "--unit=mudfish-decky-cleanup",
     f"--path-property=PathChanged={os.path.dirname(plugin_dir)}",
     "/bin/sh", "-c", script])

데키에서 플러그인을 삭제해 보니 _uninstall과 감시 유닛이 둘 다 동작했고, /opt/mudfish와 설정, 데몬이 모두 사라졌다. 미꾸라지 계정에 저장된 Full VPN 노드 설정만 남는다.

배포: Jenkins → Gitea 릴리스

데키 스토어에는 올리지 않았다. 대신 main에 푸시하면 Jenkins가 테스트 → 프론트엔드 빌드 → zip 패키징 → Gitea 릴리스 생성까지 한다. package.json의 버전을 올려서 푸시하면 새 릴리스가 생기고, 같은 버전이 이미 있으면 건너뛴다.

설치는 데키 설정 → 개발자 모드 → Install Plugin from URL에 릴리스 zip 주소를 붙여 넣으면 된다. 덱에서 이 주소를 치기 귀찮아서, 패널 고급 메뉴에 최신 플러그인 주소 복사 버튼을 넣었다. 누르고 붙여 넣으면 업데이트가 끝난다.

개발하는 동안에는 Claude가 SSH로 덱에 직접 배포하고 테스트했다. 미꾸라지 API를 확인하고, 실측 결과를 설계 문서에 남기고, 그 결과에 맞춰 코드를 고치는 식이었다. 패널을 눌러 보고 지포스나우로 실제 게임을 켜 보는 건 내가 했다.

결과

플러그인으로 VPN을 켠 상태에서 지포스나우에 로그인하고 게임을 시작했다. 지포스나우 로그에 일본 존(ap-japan)이 지연 40ms로 잡혔다. 전에는 한국 존이 잡혀 있었다. 게임이 시작된 뒤 토글을 끄면 스트리밍은 직접 연결로 이어진다.

정리

  • Konsole에서 띄운 미꾸라지는 게임모드 전환 때 죽는다. transient systemd unit으로 띄우면 산다.
  • 리눅스판은 프로세스 단위 캡처가 없어서 지포스나우 로그인에는 Full VPN이 필요하다. 노드는 계정 설정에 저장되니 노드 번호만 바꾼다.
  • 끌 때 남는 노드 /32 경로는 직접 정리한다. 절전에서 깨어나면 경로가 꼬여 인터넷이 끊기므로 깨어나면 VPN을 끈다.
  • 로그인이 만료되면 일회용 토큰이 든 QR로 휴대폰에서 로그인한다. 포트는 처음 연 기기에만, 최대 5분만 열린다.
  • 데키 3.3은 정리 코드가 돌기 전에 플러그인을 죽이는 일이 잦다. 제거 정리는 systemd path 유닛에 맡긴다.
  • 종량제 VPN이라 플러그인은 절대 스스로 연결하지 않는다. 켜고 끄는 건 사람이 한다.

최종 수정: 2026년 10월 25일

태그:

개발, Steam Deck, VPN