> ## Content Index
> Fetch the complete content index at: https://blog.sejong.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# 스팀덱 게임모드에서 미꾸라지 VPN 켜고 끄기: Decky 플러그인 만들기
- URL: https://blog.sejong.dev/mudfish-decky/
- Published: 2026-10-25T08:00:30.000Z
- Updated: 2026-10-25T08:05:00.000Z
- Description: 지포스나우 일본 계정에 로그인할 때만 잠깐 일본 IP가 필요해서, 스팀덱 게임모드 데키 패널에서 미꾸라지 Full VPN을 켜고 끄는 플러그인을 만든 기록. 남는 경로 정리, 절전 후 끊기는 인터넷, 휴대폰 QR 로그인, 데키 3.3의 강제 종료까지.
- Author: Sejong Yoo
- Tags: 개발, Steam Deck, VPN

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

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

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

## 쓰는 모습

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으로 띄운다. 이렇게 띄운 데몬은 데스크탑 모드와 게임모드를 오가도 그대로 살아 있는 것을 확인했다.

```python
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초마다 확인한다.

```python
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`로 들여다보고 토큰 경로인지 판단한다.

```python
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초 동안 사라져 있으면 그때 미꾸라지를 정리한다. 재설치나 업데이트처럼 폴더가 곧바로 다시 생기는 경우는 건드리지 않는다.

```python
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이라 플러그인은 **절대 스스로 연결하지 않는다**. 켜고 끄는 건 사람이 한다.