마크 서버, 디스코드 봇으로 원격조작하기 (RCON 직접 구현)
어제 새로 판 마크 서버 얘기에서 이어진다. 서버 파고 나니까 다음 고민이 생겼다. 내가 자리에 없을 때 서버가 말썽이면?? 재시작 하나 하자고 나한테 연락이 와야 하나... 믿을 만한 친구한테 조작 권한을 넘겨줄 방법이 필요했음
제일 쉬운 답은 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분이고 이건 한나절 걸렸는데 열어놓은 포트는 잘 때도 열려있으니까...