← 목록

마크 서버, 디스코드 봇으로 원격조작하기 (RCON 직접 구현)

#minecraft#discord#bot#rcon#python

어제 새로 판 마크 서버 얘기의 연장이다. 서버를 파고 나니 다음 고민이 생겼다. 내가 자리에 없을 때 서버가 말썽이면? 재시작 하나 하자고 나한테 연락이 와야 하나? 믿을 만한 친구한테 조작 권한을 넘겨줄 방법이 필요했다.

제일 쉬운 답은 RCON 포트 개방인데

마크 서버에는 RCON이라는게 있다. server.properties에 세 줄 넣으면 켜지는 원격 콘솔 프로토콜이다.

enable-rcon=true
rcon.port=<포트>
rcon.password=<비밀번호>

여기에 접속하면 서버 콘솔 명령을 뭐든 칠 수 있다. 그러니까 공유기에서 이 포트를 포워딩해주고 친구한테 비밀번호를 알려주면 끝... 인 것 같지만 하면 안되는 짓이다.

  • RCON은 암호화가 없다. 비밀번호도 명령도 평문으로 흘러간다
  • 로그인 시도 제한 같은것도 없다. 무차별 대입 하기 딱 좋음
  • 뚫리면 채팅이 아니라 콘솔이다. op 부여, 화이트리스트 해제, 월드 삭제까지 다 된다

포트 스캐너들이 24시간 인터넷을 훑고 다니는 세상에서 이건 콘솔을 통째로 내놓는 것이다. 한번 털려본 사람이라 이런건 이제 안 하게 됐다.

포트를 여는 대신 경로를 만들기

그래서 방향을 바꿨다. RCON 자체는 서버 안에서만 쓰고, 밖에서 들어오는 통로는 디스코드로 만든다.

디스코드 봇을 서버 호스트에서 돌리고, 봇이 localhost로 RCON에 붙는다. 친구는 디스코드 채널에서 명령어를 치고, 봇이 대신 콘솔에 넣어주는 구조다. 이러면

  • 열리는 포트가 0개다. RCON은 밖에서 안 보인다
  • 인증이 비밀번호 공유가 아니라 디스코드 계정 기반이다
  • 누가 언제 뭘 실행했는지 채널에 기록이 그대로 남는다

부재중 대비라는 목적에는 마지막 항목이 은근히 크다. 돌아와서 채널 스크롤만 올려보면 무슨 일이 있었는지 다 보인다.

마침 예전에 팰월드 서버용으로 만들어둔 봇이 있어서 (취준 시절에 만든 애착 코드다) 구조를 그대로 가져와 마크용 봇을 하나 더 만들었다. 팰월드 봇은 공식 REST API를 쓰는데 마크는 그 자리에 RCON이 들어간 것만 다르다.

RCON 프로토콜, 라이브러리 없이 40줄

파이썬 RCON 라이브러리가 몇 개 있긴 한데 프로토콜 명세를 보니 그냥 직접 짜도 되겠다 싶었다. 패킷 구조가 이게 전부다.

[길이 4바이트][요청ID 4바이트][타입 4바이트][본문][null null]

타입 3으로 비밀번호를 보내면 인증이고, 응답의 요청ID가 -1이면 비밀번호 틀림. 타입 2로 보내면 명령 실행. 끝이다. asyncio로 짜면 핵심은 이 정도

def _pack(req_id, ptype, body):
    payload = struct.pack("<ii", req_id, ptype) + body.encode() + b"\x00\x00"
    return struct.pack("<i", len(payload)) + payload

async def command(self, cmd):
    reader, writer = await asyncio.open_connection(self.host, self.port)
    writer.write(self._pack(1, 3, self.password))   # 인증
    ...
    writer.write(self._pack(2, 2, cmd))             # 명령

의존성 하나 줄이고 프로토콜도 이해하게 되니 남는 장사였음. 명령 하나당 연결 하나로 짧게 쓰고 끊는데, 이러면 서버가 재시작돼도 봇쪽에서 끊긴 연결 들고 헤맬 일이 없다.

재시작 명령의 꼼수

봇 명령어는 상태 확인, 접속자 목록, 공지, 저장, 재시작, 그리고 관리자 전용 콘솔 직결 정도를 만들었다. 이 중에 재시작이 재밌는데, 봇은 그냥 콘솔에 stop만 친다.

서버 프로세스가 systemd 유닛에 Restart=always로 걸려 있어서, stop으로 곱게 종료되면 10초 뒤 systemd가 알아서 되살린다. 봇한테 호스트 권한(systemctl)을 줄 필요가 없는 것이다. 봇이 가진 권한은 끝까지 RCON 하나뿐이고, 그걸로 충분하다.

접속자가 있으면 인게임에 1분 예고 방송을 하고 저장 치고 내리는 것까지가 봇 몫이다.

삽질 1: 봇이 시작하자마자 죽는다

discord.py로 주기 작업(tasks.loop)을 쓸 때의 지뢰. 접속/퇴장 알림용 워처를 Cog의 __init__에서 start() 했더니 서비스가 뜨자마자 죽었다.

RuntimeError: no running event loop

__init__이 돌 때는 아직 봇 이벤트 루프가 없다. 시작은 cog_load()에서 해야 한다. 루프가 준비된 뒤 불리는 훅이라 여기서는 안전하다.

async def cog_load(self):
    if self.notice_channel_id:
        self.watcher.start()

팰월드 봇에도 똑같은 코드가 잠복해 있었다. 걔는 여태 실행된 적이 없어서 안 터졌던 것뿐... 같이 고쳤다.

삽질 2: 새 개발자 포털의 비공개 봇 함정

디스코드 개발자 포털이 개편되면서 봇 초대가 Installation 탭에서 링크 하나 복사하는 방식으로 바뀌었다. 그런데 남이 초대 못하게 Public Bot을 꺼두면 이런 에러가 난다.

비공개 애플리케이션은 기본 승인 링크를 가질 수 없어요.

비공개 봇은 기본 설치 링크 자체를 못 가진다. 이럴 땐 Installation 탭의 Install Link를 None으로 두고, 옛날 방식대로 OAuth2 탭의 URL Generator에서 bot scope 체크해서 나온 링크로 초대하면 된다. 비공개여도 앱 소유자 본인은 이 링크로 초대 가능하다.

하나 더, Bot 탭의 Message Content Intent를 안 켜면 봇이 메시지 내용을 아예 못 읽어서 접두어 명령에 무반응이다. 제일 많이 빠뜨리는 스위치다.

이왕 하는 김에 팰월드 봇도 부활

토큰만 안 채워서 잠들어 있던 팰월드 봇도 이참에 같이 깨웠다. 기존 봇 계정을 재활용했는데, 켜자마자 로그에 403 Missing Access가 찍혀서 뜨끔했다. 알고보니 옛날에 봇을 초대할 때 bot scope만으로 초대해서 슬래시 명령 동기화 권한이 없는 것이었다. 이 봇은 접두어 명령만 쓰니까 실사용엔 지장이 없다. 에러 로그가 다 장애는 아니다.

그래서 지금은 봇 두 대 체제다. 팰월드 봇은 REST API + 도커 제어, 마크 봇은 RCON + systemd. 게임 서버 셋에 봇 둘이 도는 집이 됐다.

마치며

포트를 여는게 5분이고 이 짓이 한나절인데, 열어놓은 포트는 잘 때도 열려있다. 이쪽이 맞다.

← 목록으로
오늘 0·전체 0