개발자 문서
한국어

서버 운영

서버 설치

파발 서버를 인터넷에 공개된 서버 한 대에 도커 컴포즈로 설치하고 운영하는 방법.

파발 서버를 인터넷에 공개된 서버 한 대에 설치하는 방법입니다. 도커 컴포즈로 파발 서버 · PostgreSQL · Caddy(HTTPS) 세 컨테이너를 올리고, 지워지면 안 되는 데이터는 모두 호스트 디렉터리에 바인드합니다. 위에서부터 순서대로 따라 하면 됩니다.

이 문서의 예시 값

도메인 pabal.me, 서버 공인 IP 203.0.113.10(설명용 주소 — 실제 IP 로 바꿔 읽으세요), 소스 위치 /opt/pabal, 데이터 위치 /srv/pabal, 운영체제 Ubuntu 24.04 LTS. 필요한 파일은 저장소의 deploy/ 폴더에 있습니다.

한눈에 보기

인터넷 docker compose · ssemiya-net /srv/pabal (바인드 마운트) 파발 앱Pabal.app 브라우저 · 봇사이트 · Bot API 운영자SSH pabal-server MTProto :8443 웹 · Bot API · 관리 :8080 uid 1000 · JRE 21 웹훅 → 봇 서버 (HTTPS) pabal-caddy:80 · :443 인증서 자동 pabal-postgres 16내부망 전용 · 포트 없음 pabal_server/keys · RSA 키 pabal_server/data · 사진·설정 pabal_server/logs postgres_data/ · 모든 대화 caddy/ · 인증서 backups/ · backup.sh MTProto :8443 (자체 암호화) HTTPS :443 SSH 터널 → 127.0.0.1:8080/admin/
서버 한 대, 컨테이너 셋, 남아야 하는 것은 모두 /srv/pabal 에

다이어그램 설명

  • 세 영역: 인터넷의 사용자 → 운영 서버의 컨테이너 세 개 → 호스트 디스크의 바인드 디렉터리. 컨테이너는 언제든 지우고 다시 만들 수 있고, 남아야 하는 것은 전부 /srv/pabal 에 있습니다.
  • 입구는 둘뿐입니다: 앱은 MTProto 포트로 서버에 바로 붙고(자체 암호화라 HTTPS 불필요), 웹사이트·문서·Bot API 는 Caddy 의 443 을 거칩니다. Caddy 는 관리용 경로(/admin, /health …)를 막고 나머지만 넘깁니다.
  • 빨간 점선은 운영자만의 길입니다. 관리 화면은 서버의 127.0.0.1:8080 에만 열려 있어 SSH 터널로만 들어갑니다. PostgreSQL 은 바깥으로 여는 포트가 아예 없습니다.
  • 빨간 칸 두 개가 가장 중요한 데이터입니다. pabal_server/keys 의 RSA 키는 앱이 몸속에 넣고 빌드되므로 잃으면 모든 앱을 다시 배포해야 하고, postgres_data/ 에는 모든 계정과 대화가 있습니다. 백업 1순위입니다.
  • 회색 화살표는 저장입니다: 서버는 키·사진·설정·로그를, PostgreSQL 은 이벤트를, Caddy 는 인증서를 각자의 디렉터리에 씁니다.

준비물

항목내용
서버Ubuntu 24.04 LTS, 시작 기준 2 vCPU · 4 GB RAM · 40 GB SSD. 사용자·메시지가 늘면 메모리부터 늘리세요
공인 IPv4고정 IP. 앱이 서버 주소를 IP 로 가지고 빌드되고, 서버도 앱에 IP 로 알려 줍니다
도메인DNS 를 바꿀 수 있는 도메인 하나 (A 레코드)
가입 코드 전달 수단SMS(Twilio · Solapi · 웹훅) 또는 이메일(SMTP) 계정 하나
맥앱을 이 서버용으로 빌드할 맥 (윈도우 앱까지 만들려면 윈도우 PC 도 한 대)
Firebase 프로젝트 (선택)안드로이드 앱에 푸시 알림을 보낼 때. Google 계정으로 무료로 만듭니다(10단계)

포트

포트누가열기설명
22/tcp운영자운영자 IP 만 권장SSH
80/tcpCaddy모두인증서 발급 · HTTPS 로 옮기기
443/tcp, 443/udpCaddy모두웹사이트 · 문서 · Bot API (udp 는 HTTP/3)
8443/tcp서버모두앱 접속 (MTProto)
8080/tcp서버열지 않음관리 화면 · 헬스 체크 — 서버의 127.0.0.1 에만
5432/tcpPostgreSQL열지 않음컨테이너 내부망에만
왜 앱 포트가 443 이 아닌가

443 은 웹사이트(HTTPS)가 씁니다. MTProto 는 HTTP 도 TLS 도 아니어서 한 포트로 나눠 받을 수 없습니다. 흔치 않은 포트를 막는 회사·학교 망 때문에 앱도 443 으로 받고 싶다면 IP 를 하나 더 받아 그 IP 의 443 을 서버에 주세요(.env 에서 PUBLIC_IP 를 두 번째 IP, MTPROTO_PORT=443, 컴포즈 파일의 포트에 IP 를 붙여 나눔). 서버는 앱에 "이 IP 의 이 포트로 붙으라"고 알려 주므로 컨테이너 안팎의 앱 포트를 같게 둡니다.

1. 서버 준비

sudo apt update && sudo apt -y upgrade
timedatectl                      # "System clock synchronized: yes" 인지 확인

시간 동기화는 필수입니다. MTProto 메시지 번호가 시각에서 나오므로 서버 시계가 틀어지면 앱이 연결을 되풀이합니다. no 면 sudo timedatectl set-ntp true.

# 방화벽
sudo ufw allow OpenSSH           # 가능하면: sudo ufw allow from <운영자 IP> to any port 22
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw allow 8443/tcp
sudo ufw enable

# 도커 (공식 설치 스크립트)
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh
sudo usermod -aG docker $USER     # 다시 로그인하면 sudo 없이 docker
docker compose version            # v2 이상
도커와 ufw

도커가 여는(ports:) 포트는 ufw 규칙과 상관없이 열립니다. 그래서 컴포즈 파일은 공개할 포트(80·443·8443)만 열고, 관리 포트는 127.0.0.1:8080 으로만 열며, PostgreSQL 은 열지 않습니다. ports: 를 고칠 때 기억하세요.

2. 소스 가져오기

sudo mkdir -p /opt/pabal && sudo chown $USER: /opt/pabal
git clone <저장소 주소> /opt/pabal

개발 컴퓨터에서 복사한다면 키·데이터·로그는 보내지 마세요(rsync --exclude 'keys/' --exclude 'data/' --exclude 'logs/' --exclude '**/target/'). 운영 서버의 RSA 키는 운영 서버가 처음 켜질 때 새로 만듭니다 — 개발용 키를 운영에 쓰면 그 개발 컴퓨터가 운영 트래픽을 풀 수 있는 곳이 됩니다.

3. 데이터 디렉터리 (바인드 마운트)

sudo mkdir -p /srv/pabal/{postgres_data,pabal_server/keys,pabal_server/data,pabal_server/logs,caddy/data,caddy/config,backups}
sudo chown -R 1000:1000 /srv/pabal/pabal_server        # 서버 컨테이너는 uid 1000 으로 돕니다
sudo chown -R $USER: /srv/pabal/backups
sudo chmod 700 /srv/pabal/pabal_server/keys /srv/pabal/backups
디렉터리컨테이너 안들어 있는 것잃으면
pabal_server/keys/app/keysprivate.pem(서버 RSA 비밀키), private.pem.pub, (선택) firebase-service-account.json(안드로이드 푸시용 Firebase 비밀 키)모든 앱을 다시 빌드·배포 (Firebase 키는 콘솔에서 새로 받으면 됨)
pabal_server/data/app/data사진(media/), admin-token, operations.json(가입 코드·SMS·SMTP 설정, 비밀값 포함)사진·운영 설정
pabal_server/logs/app/logs서버 로그 (날마다 압축)기록만
postgres_data/var/lib/postgresql/data계정·대화·메시지·로그인·봇·웹훅 설정 전부서비스 전체
caddy/data, caddy/config/data, /configHTTPS 인증서다시 발급됨
backups(호스트 전용)backup.sh 결과—

4. 설정 파일 .env

cd /opt/pabal/deploy
cp .env.example .env
chmod 600 .env
openssl rand -base64 30 | tr -d '/+=' | cut -c1-32     # 나온 값을 POSTGRES_PASSWORD 에
nano .env
DOMAIN=pabal.me
PUBLIC_IP=203.0.113.10
POSTGRES_PASSWORD=(위에서 만든 값)
PABAL_HOME=/srv/pabal
PABAL_SOURCE=..
PABAL_NETWORK=ssemiya-net
MTPROTO_PORT=8443
HTTP_PORT=80
HTTPS_PORT=443
ADMIN_PORT=8080
ADMIN_TOKEN=
JAVA_OPTS="-Xms512m -Xmx2g -XX:+UseG1GC -XX:MaxGCPauseMillis=100"
FCM_CREDENTIALS=
FCM_PROJECT_ID=
PUSH_PREVIEWS=true
값넣을 것비고
DOMAIN웹사이트 도메인앱이 만드는 링크(pabal.me/사용자명), 문서의 주소, 링크 미리보기도 이 주소
PUBLIC_IP서버의 공인 IPv4클라우드라면 콘솔에 보이는 공인 IP (서버 안의 사설 IP 가 아님)
POSTGRES_PASSWORD무작위 값처음 켜기 전에 정하세요. PostgreSQL 은 데이터 디렉터리를 처음 만들 때만 이 값을 씁니다
PABAL_HOME/srv/pabal3단계의 디렉터리
PABAL_SOURCE..서버 이미지를 빌드할 저장소. 컴포즈 파일이 저장소의 deploy/ 안에 있으면 .., 밖에 두면 그 소스 경로
PABAL_NETWORKssemiya-net세 컨테이너가 함께 붙는 도커 네트워크. 미리 만들어 둡니다(6단계)
MTPROTO_PORT8443바꾸면 방화벽과 앱 빌드도 같이
ADMIN_TOKEN비워 둠비우면 처음 켤 때 pabal_server/data/admin-token 에 만들어짐. 직접 정하려면 16자 이상
JAVA_OPTS기본값메모리가 모자라면 -Xmx 를 올리세요 (서버 메모리의 절반 정도까지)
FCM_CREDENTIALS비워 둠안드로이드 푸시 알림. 켤 때 /app/keys/firebase-service-account.json(10단계). 비우면 푸시 없이 돕니다
FCM_PROJECT_ID비워 둠키 파일의 project_id 가 아닌 Firebase 프로젝트로 보낼 때만
PUSH_PREVIEWStruefalse 면 알림에 메시지 내용 없이 보낸 사람만 보입니다

운영용 컴포즈 파일은 테스트 번호를 끈 채(TELEGRAM_TEST_NUMBERS=false) 서버를 켭니다. 서버가 읽는 설정 값 전체는 관리와 설정 — 환경 변수에 있습니다.

5. DNS

이름종류값
pabal.meA203.0.113.10
www.pabal.meA203.0.113.10
dig +short pabal.me        # 203.0.113.10 이 나와야 함 (반영에 몇 분~몇 시간)

DNS 가 서버를 가리키기 전에 켜도 됩니다. Caddy 가 인증서를 받을 때까지 알아서 다시 시도합니다.

6. 빌드하고 켜기

cd /opt/pabal/deploy
docker network inspect ssemiya-net >/dev/null 2>&1 || docker network create ssemiya-net   # 한 번만
docker compose up -d --build       # 처음엔 몇 분 (Maven 이 라이브러리를 받음)
docker compose ps                  # pabal-server, pabal-postgres, pabal-caddy 가 running (healthy)
docker compose logs -f pabal-server      # Ctrl+C 로 빠져나옴

처음 켤 때 로그에 이런 줄이 나옵니다.

WARN  ServerKeys - Generated a new RSA key at /app/keys/private.pem (fingerprint -3898654385185406269). Clients must embed /app/keys/private.pem.pub
INFO  TelegramServer - JDBC persistence active (jdbc:postgresql://pabal-postgres:5432/pabal)
INFO  BotFather - BotFather is user 100000
INFO  AdminToken - Created the admin token in /app/data/admin-token
INFO  TelegramServer - Website: http://0.0.0.0:8080/ (published at https://pabal.me/)
  • 첫 줄은 WARN 이지만 정상입니다(처음 한 번만 키를 만든다는 알림). 다음부터는 Loaded RSA key … (fingerprint …) 가 나옵니다. fingerprint 는 서버마다 다릅니다.
  • 테이블은 서버가 켜질 때 스스로 만듭니다.

7. 확인

# 바깥(내 컴퓨터)에서
curl -sI https://pabal.me/ | head -1                              # HTTP/2 200
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/docs/    # 200 — 이 문서
curl -s -o /dev/null -w '%{http_code}\n' https://pabal.me/admin/   # 404 — 관리 화면은 공개되지 않음
curl -s https://pabal.me/docs/server-key.pem | head -1             # -----BEGIN RSA PUBLIC KEY-----
nc -vz 203.0.113.10 8443                                          # succeeded — 앱 포트

# 서버에서
curl -s http://127.0.0.1:8080/health          # {"status":"UP",…}
ls -l /srv/pabal/pabal_server/keys                  # private.pem(600), private.pem.pub

8. 관리 화면과 첫 설정

# 내 컴퓨터에서 (켜 둔 채로)
ssh -N -L 8080:127.0.0.1:8080 <사용자>@203.0.113.10

# 토큰은 서버에서
sudo cat /srv/pabal/pabal_server/data/admin-token

브라우저로 http://localhost:8080/admin/ 을 열고 토큰을 넣습니다. 운영 전에 꼭 할 설정(자세한 것은 관리와 설정):

  1. 코드 전달 방식을 SMS 또는 이메일로. "관리 화면" 방식은 운영자가 코드를 하나하나 알려 줘야 합니다.
  2. SMS 또는 이메일(SMTP) 값을 넣고 저장 → 테스트 발송으로 실제로 오는지 확인.
  3. 테스트 번호가 꺼져 있는지 확인. 켜면 누구나 +99966… 번호로 로그인할 수 있습니다.
  4. 필요하면 새 가입 허용을 끄고 시작해 초대할 사람만 먼저 받습니다.

9. 앱을 이 서버에 연결

앱은 서버 IP · 포트 · 공개키를 몸속에 넣고 빌드됩니다. 이 서버의 공개키로 다시 빌드해야 이 서버에 붙습니다. 맥에서:

# 1. 이 서버의 공개키 (비밀키가 아니라 공개키 — 문서 사이트에서 받아도 됩니다)
curl -s -o keys/production.pem.pub https://pabal.me/docs/server-key.pem

# 2. 앱 소스를 이 서버로 향하게 + 브랜드 적용
python3 scripts/tdesktop/point_to_server.py ~/Developer/tdesktop \
    --host 203.0.113.10 --port 8443 --key keys/production.pem.pub
python3 scripts/tdesktop/apply_branding.py ~/Developer/tdesktop

그다음 저장소의 docs/tdesktop-build-guide.md 5단계(configure)와 6단계(빌드)를 합니다. 결과 out/Debug/Pabal.app 은 다른 이름으로 복사해 두세요 — 같은 소스를 다른 서버용으로 다시 빌드하면 덮어써집니다.

앱을 다른 사람에게 나눠 주기 전에

지금 빌드는 서명·공증이 없는 디버그 빌드라 받은 사람이 처음 열 때 우클릭 → 열기를 해야 합니다. 디버그 빌드는 앱 옆 폴더(tdata/)에 데이터를 두고, 그곳에 쓸 수 없으면 진짜 텔레그램 데스크톱과 같은 데이터 폴더를 씁니다. 앱은 사용자 폴더(예: ~/Applications/Pabal/)에 두게 안내하세요. 일반 배포용 릴리스 빌드(데이터 폴더 분리, Apple 서명·공증)는 별도 작업입니다.

안드로이드·아이폰·윈도우 앱은 저장소의 docs/mobile-apps-guide.md 에 같은 방식(서버 주소·공개키를 넣는 스크립트 → 빌드)이 있습니다. 안드로이드 앱에 푸시 알림을 넣으려면 빌드하기 전에 10단계의 Firebase 앱 설정이 있어야 합니다.

10. 안드로이드 푸시 알림 (선택)

안드로이드는 화면에서 벗어난 앱을 곧 재웁니다. 푸시를 켜지 않으면 앱을 열어 둔 동안에만 메시지가 뜨고 전화가 울립니다. 푸시는 Google 의 Firebase 클라우드 메시징(FCM)으로 보내고(무료), 알림 내용(보낸 사람 이름, 메시지 미리보기)은 서버가 그 휴대폰만 가진 키로 암호화하므로 Google 은 읽지 못합니다.

Firebase 프로젝트 (운영자 것) 만들기 · 설치 휴대폰 · Google google-services.json 앱에 넣는 설정 APK 안에 들어감 · 비밀 아님 서비스 계정 키 firebase-service-account.json 비밀 · 서버에만 안드로이드 앱 빌드 apply_branding.py → APK pabal-server keys/ · chmod 600 FCM_CREDENTIALS 휴대폰의 키로 알림을 암호화 휴대폰의 파발 앱 처음 열 때 이 휴대폰을 등록 Google FCM 암호문만 전달 · 내용은 못 읽음 ① 휴대폰 등록 ② 암호화한 알림 ③ 깨움 → 알림
파일 두 개는 서로 다른 곳으로, 알림은 암호문으로

다이어그램 설명

  • 세 영역: 운영자가 만드는 Firebase 프로젝트 → 앱을 빌드하고 서버를 켜는 곳 → 휴대폰과 Google. Firebase 프로젝트 하나에서 파일 두 개를 받습니다.
  • 회색 화살표는 한 번만 하는 준비입니다. 초록 칸 google-services.json 은 앱 안에 들어가 모든 APK 에 실리므로 비밀이 아니고, 빨간 칸 서비스 계정 키는 서버의 keys/ 에만 두는 비밀입니다. 두 파일은 같은 프로젝트에서 받아야 합니다.
  • 파란 화살표는 알림이 가는 길입니다: ① 앱을 처음 열면 휴대폰이 FCM 토큰과 그 휴대폰만 가진 키를 서버에 등록하고, ② 새 메시지나 전화가 생기면 서버가 알림을 그 키로 암호화해 FCM 에 넘기고, ③ FCM 이 휴대폰을 깨우면 앱이 풀어서 알림을 띄우고 서버에 다시 붙어 놓친 것을 받습니다.
  • Google 이 보는 것은 기기 토큰과 보낸 시각·우선순위 같은 전달 정보뿐입니다. 보낸 사람 이름과 메시지는 암호문이라 읽지 못합니다.
  • 앱을 보고 있는 휴대폰에는 푸시를 보내지 않습니다 — 이미 연결로 받고 있기 때문입니다. 알림을 끈 대화방의 푸시는 급하지 않은 우선순위로 보내고, 앱이 스스로 걸러 냅니다.
Firebase 프로젝트는 앱과 짝입니다

서버의 키로는 같은 Firebase 프로젝트로 빌드된 앱에만 푸시를 보낼 수 있습니다. 다른 사람이 빌드한 앱(예: Pabal.me 에서 받은 앱)은 그 사람의 Firebase 프로젝트에 묶여 있고, 애초에 그 사람 서버의 주소와 공개키로 빌드되어 있습니다. 여러분 서버의 앱은 여러분이 빌드하고, Firebase 프로젝트도 여러분이 만듭니다.

Firebase 프로젝트 만들기

  1. Firebase 콘솔에서 프로젝트 추가. 이름은 아무것이나 괜찮고, Google 애널리틱스는 필요 없습니다.
  2. 프로젝트 설정 → 일반 → 내 앱 → Android 앱 추가. 패키지 이름에 me.pabal.android(앱 빌드 스크립트가 앱에 붙이는 이름)를 넣습니다. 인증서 지문(SHA-1)은 푸시에 필요하지 않습니다.
  3. 그 앱의 google-services.json 을 내려받습니다. 앱에 넣는 설정이며 비밀이 아닙니다.
  4. 프로젝트 설정 → 서비스 계정 → 새 비공개 키 생성으로 JSON 파일을 받아 이름을 firebase-service-account.json 으로 바꿉니다. 이 파일은 비밀입니다 — 저장소·채팅·.env 에 내용을 넣지 마세요.
  5. 프로젝트 설정 → 클라우드 메시징에서 Firebase Cloud Messaging API(V1) 가 사용 설정되어 있는지 봅니다(새 프로젝트는 보통 켜져 있습니다).

앱에 앱 설정 넣기

안드로이드 앱을 빌드하기 전에 google-services.json 을 빌드 스크립트가 찾는 곳에 둡니다. 스크립트는 파일을 검사한 뒤(me.pabal.android 가 등록된 프로젝트인지, 비밀 키를 잘못 준 것은 아닌지) 앱 소스에 넣습니다.

mkdir -p ~/.pabal/android
cp ~/Downloads/google-services.json ~/.pabal/android/     # 다른 곳에 두려면 apply_branding.py 에 --google-services 경로
python3 scripts/android/point_to_server.py <안드로이드 소스> \
    --host 203.0.113.10 --port 8443 --key keys/production.pem.pub
python3 scripts/android/apply_branding.py <안드로이드 소스>
# 끝에 "Firebase: project <프로젝트 ID>" 가 나와야 합니다. pabal-placeholder 가 보이면 푸시 없는 앱이 됩니다

그다음 빌드는 저장소의 docs/mobile-apps-guide.md "Android 앱" 절을 따릅니다. 푸시가 들어가기 전의 앱을 쓰던 휴대폰도 새로 빌드한 앱으로 업데이트해야 알림이 옵니다.

서버에 비밀 키 두기

sudo cp firebase-service-account.json /srv/pabal/pabal_server/keys/
sudo chown 1000:1000 /srv/pabal/pabal_server/keys/firebase-service-account.json    # 서버 컨테이너의 사용자
sudo chmod 600 /srv/pabal/pabal_server/keys/firebase-service-account.json

.env 에 한 줄을 넣습니다. 컨테이너 안에서 pabal_server/keys 는 /app/keys 입니다.

FCM_CREDENTIALS=/app/keys/firebase-service-account.json
docker compose up -d pabal-server
docker compose logs pabal-server | grep -i push
INFO  TelegramServer - Push notifications on: Firebase project <프로젝트 ID>, 0 device registrations, message text included (encrypted per device)
  • 이 줄이 보이면 켜진 것입니다. 파일을 못 읽거나 형식이 틀리면 Push notifications are off: … 에 이유가 나옵니다(파일 내용은 로그에 나오지 않습니다).
  • PUSH_PREVIEWS=false 면 줄 끝이 message text left out 이 되고, 알림에 메시지 내용 없이 보낸 사람만 보입니다.
  • 컨테이너에서 oauth2.googleapis.com, fcm.googleapis.com(443)으로 나가는 연결이 열려 있어야 합니다.

확인

  1. 휴대폰에 새로 빌드한 앱을 설치(또는 업데이트)하고 한 번 엽니다. 서버 로그에 Push device registered: user … type 2 token … (push key 256 bytes) 가 찍힙니다. 토큰은 지문으로만 남습니다.
  2. 최근 앱 목록에서 앱을 밀어 닫습니다. 설정의 "강제 종료"는 쓰지 마세요 — 안드로이드가 앱을 다시 직접 열 때까지 푸시를 전하지 않습니다.
  3. 다른 계정으로 메시지를 보내면 로그에 Push MESSAGE_TEXT to user …: sent 가 찍히고 휴대폰에 알림이 뜹니다.
  • 개인정보 처리방침: 관리 화면 → 설정 → 운영자 정보의 처리 위탁과 국외 이전에 Google LLC(Firebase 클라우드 메시징, 미국)를 적어 넣으세요. 방침 본문에는 FCM 을 거친다는 문장이 이미 있습니다. Google Play 의 "데이터 보안" 양식에도 같은 내용을 적습니다.
  • 키가 새어 나갔다면 Firebase 콘솔에서 그 키를 삭제하고 새로 받아 파일을 바꾼 뒤 서버를 다시 켭니다. 앱은 다시 빌드하지 않아도 됩니다.
아이폰과 데스크톱

아이폰은 Firebase 가 아니라 Apple 개발자 계정의 APNs 키(.p8)로 따로 켭니다 — 바로 아래 11단계. 맥·윈도우 앱은 푸시 없이, 켜져 있는 동안 받습니다.

11. 아이폰 푸시 알림 (선택)

아이폰도 화면에서 벗어난 앱을 곧 멈춥니다. 푸시를 켜지 않으면 앱을 열어 둔 동안에만 메시지와 전화를 받습니다. 푸시는 Apple 푸시 알림 서비스(APNs)로 보내고, 안드로이드와 같은 방식으로 알림 내용은 그 아이폰만 가진 키로 암호화합니다. Apple 이 볼 수 있는 알림 문구는 앱 이름과 "새 알림이 있습니다" 같은 내용 없는 말뿐이고, 아이폰 앱 속 알림 확장(Notification Service Extension)이 암호문을 풀어 보낸 사람과 메시지로 바꿔 보여 줍니다.

  • 등록: 앱은 켜질 때마다 계정마다 두 가지를 등록합니다 — 알림용(type 1)과 전화용(VoIP, type 9). 서버는 등록과 함께 앱의 언어(겉 문구를 쓸 말)와 빌드 번호를 기억합니다.
  • 메시지는 알림으로 갑니다. 알림을 끈 대화방에는 아무것도 보내지 않습니다. 다른 기기에서 읽거나 지운 것은 소리 없는 백그라운드 푸시로 알려, 떠 있던 알림을 앱이 치웁니다.
  • 전화는 VoIP 로 보내면 잠긴 화면에서도 CallKit 전화 화면이 뜹니다. 다만 VoIP 를 받은 앱은 곧바로 CallKit 에 전화를 알려야 하고, 못 하면 iOS 가 앱을 죽이고 되풀이되면 VoIP 를 더 보내 주지 않습니다. 그래서 그렇게 만든 빌드부터만 APNS_VOIP_MIN_BUILD 로 켜고, 그 전에는 전화도 알림으로 갑니다.
APNs 키는 앱에 서명한 팀의 것입니다

Firebase 와 달리 앱에 넣을 설정 파일은 없습니다 — 앱은 이미 APNs 를 쓸 수 있게 빌드되어 있고(알림 권한·App Group·알림 확장), 서버에 키만 두면 됩니다. 다만 서버의 키로는 그 키를 만든 팀이 서명하고 번들 ID 가 APNS_TOPIC 과 같은 앱에만 푸시를 보낼 수 있습니다. 여러분 서버의 아이폰 앱을 여러분의 Apple 개발자 계정으로 서명했다면, APNs 키도 그 계정에서 만듭니다.

APNs 키 만들기

  1. Apple Developer → Certificates, IDs & Profiles → Keys → +. 앱에 서명한 팀의 계정이어야 합니다.
  2. 이름을 적고 Apple Push Notifications service (APNs) 에 체크합니다. 환경은 Sandbox & Production, 범위는 팀 전체(Team Scoped)로 둡니다 — 알림과 VoIP 두 곳에 보내기 때문입니다.
  3. Register → Download. AuthKey_<키 ID>.p8 은 한 번만 내려받을 수 있습니다. 이 파일은 비밀입니다 — 저장소·채팅·.env 에 내용을 넣지 마세요.

서버에 키 두기

sudo cp AuthKey_<키 ID>.p8 /srv/pabal/pabal_server/keys/
sudo chown 1000:1000 /srv/pabal/pabal_server/keys/AuthKey_<키 ID>.p8    # 서버 컨테이너의 사용자
sudo chmod 600 /srv/pabal/pabal_server/keys/AuthKey_<키 ID>.p8

.env 에 넣습니다. 컨테이너 안에서 pabal_server/keys 는 /app/keys 입니다.

APNS_KEY=/app/keys/AuthKey_<키 ID>.p8
APNS_TEAM_ID=4Z8R468M2D
APNS_TOPIC=me.pabal.ios
APNS_MIN_BUILD=4
APNS_VOIP_MIN_BUILD=
값넣을 것비고
APNS_KEY키 파일 경로키 ID 는 파일 이름에서 읽습니다. 다른 이름으로 두었다면 APNS_KEY_ID 에 키 ID 를
APNS_TEAM_ID앱에 서명한 팀 ID파발의 앱은 4Z8R468M2D. 직접 빌드·서명한 앱이면 그 팀
APNS_TOPIC앱의 번들 ID파발의 앱은 me.pabal.ios. 전화(VoIP)는 여기에 .voip 를 붙인 곳으로 갑니다
APNS_MIN_BUILD4이보다 낮은 빌드에는 아무것도 보내지 않습니다(0 이면 모두). 4 는 푸시를 시험하기 전에 만든 파발 1.0 (3) 을 빼는 값
APNS_VOIP_MIN_BUILD비워 둠비우면 전화도 알림으로 갑니다. 전화를 곧바로 CallKit 에 알리는 첫 빌드 번호를 적으면 그 빌드부터 CallKit 전화 화면
docker compose up -d pabal-server
docker compose logs pabal-server | grep -i push
INFO  TelegramServer - iPhone push notifications on: APNs key <키 ID> of team 4Z8R468M2D, topic me.pabal.ios, builds from 4, calls as alerts (TELEGRAM_APNS_VOIP_MIN_BUILD not set)
  • 이 줄이 보이면 켜진 것입니다. 키 파일을 못 읽거나 형식이 틀리면 iPhone push notifications are off: … 에 이유가 나옵니다(파일 내용은 로그에 나오지 않습니다). 안드로이드(FCM)와는 따로 켜고 끕니다 — 한쪽만 켜도 됩니다.
  • 컨테이너에서 api.push.apple.com(443, HTTP/2)으로 나가는 연결이 열려 있어야 합니다. Xcode 로 기기에 직접 설치한 디버그 빌드는 api.sandbox.push.apple.com 으로 갑니다(앱이 등록할 때 알려 줍니다).

확인

  1. 실제 아이폰에서 앱을 한 번 열고 알림을 허용합니다. 서버 로그에 Push device registered: user … type 1 token … (push key 256 bytes, build …) 와 type 9 줄이 찍힙니다. 시뮬레이터로는 확인할 수 없습니다 — APNs 토큰도 CallKit 도 없습니다.
  2. 앱을 위로 밀어 닫습니다.
  3. 다른 계정으로 메시지를 보내면 로그에 Push MESSAGE_TEXT to user …: sent 가 찍히고 잠금 화면에 보낸 사람과 내용이 뜹니다.
  • 개인정보 처리방침: 관리 화면 → 설정 → 운영자 정보의 처리 위탁과 국외 이전에 Apple Inc.(Apple 푸시 알림 서비스, 미국)를 적어 넣으세요. 방침 본문에는 APNs 를 거친다는 문장이 이미 있습니다. App Store Connect 의 앱 개인정보 설문에서 기기 ID(푸시 토큰) 항목도 확인합니다.
  • 키가 새어 나갔다면 개발자 계정에서 그 키를 Revoke 하고 새로 만들어 파일을 바꾼 뒤 서버를 다시 켭니다. 앱은 다시 빌드하지 않아도 됩니다.

12. 음성 봇 (선택)

음성 봇은 사람이 앱에서 봇에게 전화를 걸면 AI 음성 모델이 받는 기능입니다(봇 주인을 위한 설명은 음성 봇). 서버에 부품 두 가지가 더 붙어야 하고, 법률 문서와 앱 빌드가 갖춰져야 켜집니다. 기본은 꺼져 있습니다.

음성은 덧붙이는 컴포즈 파일로 켭니다

음성 부품은 deploy/docker-compose.yml 이 아니라 덧붙이는 파일 deploy/docker-compose.voice.yml 에 있습니다 — 서버의 음성 설정과 링크 전용 망, 게이트웨이 pabal-voice(프로파일 voice), 워치 소리 입구 pabal-voice-edge(프로파일 voice-watch). .env 가 이 파일을 부르지 않으면 음성 설정 자체가 없습니다. 이 구성은 에코 엔진으로 돌려 보았지만 운영 서버에서 음성을 켠 적은 아직 없고, 음성 봇 통화를 운영 중계(TURN)로 끝까지 해 본 적도 없습니다 — 전 구간 시험은 도커 망 안의 시험용 중계로 했습니다(scripts/voice-e2e/run.sh). 켜기 전에 저장소의 deploy/voice_readiness.py 로 빠진 것을 점검할 수 있습니다. 공개하기 전의 조건(법률 검토, 제공자의 서면 답변, 앱 배포)은 운영자가 판단할 일이고, 코드는 그중 일부만 막습니다.

부품하는 일없으면
pabal-voice (게이트웨이, 이미지 0.11.0)봇 대신 통화의 한쪽 끝이 되어 키 교환을 하고, 소리를 AI 모델과 주고받습니다. 파이썬 3.12 + ntgcalls. 서버와는 링크(WebSocket, 인증은 되지만 암호화는 안 됨)로 이야기합니다모든 봇 통화가 통화 중(no_gateway)
coturn (통화 중계)봇 통화는 중계만 씁니다: 앱과 게이트웨이 모두 직접 연결을 끄고 중계로만 소리를 보냅니다. 사람끼리 통화의 중계와 같은 것(TURN_SECRET)모든 봇 통화가 통화 중(no_relay), 게이트웨이도 연결 거절(4008)
Vertex AI 키Google Cloud 서비스 계정 JSON 키. 게이트웨이의 엔진 작업 프로세스만 엽니다모델 없이 에코만(VOICE_ENGINE=echo, 소리가 기계 밖으로 나가지 않음)
(선택) 마스터 키봇 주인의 자기 키와 원음 모드의 비밀을 감싸는 키자기 키·원음 모드 꺼짐
(선택) MCP 부품pabal-mcp · 서명기 · 송신 프록시(아래)MCP 서버 꺼짐

켜지는 조건

서버가 보는 것없으면
링크 비밀 파일(TELEGRAM_VOICE_LINK_SECRET_FILE, 32바이트)음성 꺼짐 — 링크 포트도 열지 않음
통화 중계(TELEGRAM_TURN_HOST·TELEGRAM_TURN_SECRET)모든 봇 통화가 통화 중
사이트 개인정보처리방침(ko·en 둘 다)의 id="bot-calls" 절 — 앱의 확인 창이 이곳을 가리킴어떤 봇도 받지 않음(ERROR 로그). 초안은 docs/voice-bots/p0/privacy-*.draft.html
이용약관의 id="voice-bot-owners" 절봇 주인 약관에 아무도 동의할 수 없음 → 대화 기록·자기 키·원음 모드·MCP 모두 못 씀
방침의 id="bot-calls-stream" · id="bot-calls-mcp" 절원음 모드 · MCP 꺼짐
관리 화면의 봇 허용(기본은 허용한 봇만)과 공개(기본 시험자만, 공개하려면 확인 문구 입력)허용하지 않은 봇은 받지 않고, 공개 전에는 시험자만 걸 수 있음
발신자의 앱 빌드: 데스크톱 pabal-bc2(확인 창이 한국어·영어뿐인 동안은 pabal-bc1 도) · 안드로이드 62151 · 아이폰 5 이상 · 애플 워치 1 이상(TELEGRAM_VOICE_MIN_BUILD_*)그 사람에게 통화 버튼이 없고, 걸어도 통화 중
(선택) 애플 워치: 서버의 워치 API(TELEGRAM_WATCH_API_PORT)와 소리 주소(TELEGRAM_WATCH_AUDIO_URL), 소리 입구 pabal-voice-edge, direct 를 밝힌 게이트웨이(아래)워치로 걸 수 없음(휴대폰·컴퓨터 앱은 그대로)
(선택) 대화 남기기: 방침의 id="bot-calls-keep-text" 절, keep_text 를 밝힌 게이트웨이(VOICE_KEEP_TEXT, 아래)체크 상자를 골라도 대화가 남지 않음
(선택) 봇이 거는 전화: 방침의 id="bot-calls-outbound" 절과 약관의 id="voice-bot-owners-outbound" 절, 증거 키 폴더, 발신 최소 빌드, outbound 를 밝힌 게이트웨이, 관리 화면의 전역 스위치(아래)어느 봇도 사람에게 전화를 걸 수 없음

시험 서버(TELEGRAM_TEST_NUMBERS=true)에서만 TELEGRAM_VOICE_NOTICE_CHECK=off 로 법률 절 확인을 건너뛸 수 있고, TELEGRAM_VOICE_BOTS(모든 봇 *)·TELEGRAM_VOICE_TESTERS=* 로 누구나 시험할 수 있습니다. 운영에서는 쓰지 마세요 — 운영 서버는 TELEGRAM_VOICE_BOTS 를 경고와 함께 무시합니다.

통화 중계 (coturn)

  • 리눅스 서버라면 컴포즈의 pabal-turn(COMPOSE_PROFILES=turn)을 씁니다. .env 의 TURN_SECRET(openssl rand -hex 32)을 정하고, 공유기·방화벽에서 TURN_PORT(3478, udp·tcp)와 TURN_MIN_PORT~TURN_MAX_PORT(49160~49200, udp)를 엽니다. 범위를 늘리면 공유기·.env 를 함께 바꿉니다.
  • macOS 의 Docker Desktop 에서는 coturn 을 컨테이너로 돌리지 마세요. 모든 발신자가 192.168.65.1 로 보여 중계 허락이 맞지 않고, 겉으로는 멀쩡한데 소리가 모두 버려집니다. 맥 서버에서는 coturn 을 직접 설치합니다(deploy/turnserver.conf.example, docs/turn-relay-guide.md).
  • 게이트웨이에게 줄 중계 주소는 서버의 LAN 주소입니다: 서버의 TELEGRAM_VOICE_TURN_HOST 와 게이트웨이의 VOICE_TURN_HOST 에 같은 값을 둡니다. host.docker.internal 로는 닿지 않았고, 127.0.0.1 로는 할당이 끝나지 않았습니다. 앱에게는 공개 주소(TURN_HOST)가 그대로 갑니다.
  • 바깥망에서 deploy/check_relay.py --data 로 소리가 실제로 건너가는지 확인합니다. STUN 이 답하고 할당이 돼도 소리가 안 가는 경우가 있습니다.

Vertex AI 키

  1. Google Cloud 프로젝트에서 Agent Platform API(옛 이름 Vertex AI API, aiplatform.googleapis.com)를 켭니다. 결제 계정이 연결돼 있어야 합니다. Gemini Developer API(AI Studio 키)는 쓰지 않습니다.
  2. 서비스 계정을 하나 만들고 역할은 roles/aiplatform.user 하나만 줍니다(더 좁히려면 aiplatform.endpoints.predict 권한 하나짜리 커스텀 역할). 운영용 키는 개발용과 따로 만듭니다.
  3. JSON 키를 받아 저장소·.env 밖, 예를 들어 /srv/pabal/pabal_voice/secrets/vertex-sa.json 에 두고, 게이트웨이의 사용자만 읽게 합니다(리눅스: chown 10001:10001, chmod 400). 누구나 읽을 수 있으면 게이트웨이가 시작을 거부합니다.
  4. 실시간 모델은 us-central1(미국 아이오와)에만 있습니다(2026-09-26 확인). 통화 소리가 미국으로 간다는 것을 개인정보처리방침과 확인 창이 말해야 하고, 앱의 확인 창에 적힌 지역과 게이트웨이의 지역(VOICE_VERTEX_REGION)이 같아야 서버가 그 게이트웨이에 통화를 줍니다.
  5. 할 일: 예산 알림, 동시 세션 할당량(게이트웨이 용량의 두 배), 그리고 Vertex 의 데이터 캐시(켜져 있으면 입출력을 메모리에 최대 24시간 둡니다) — 확인 창은 "Google 은 대답을 만든 뒤 소리와 글을 보관하지 않는다"고 말하므로, 캐시를 끄기 전에는 그 말과 맞지 않습니다. 권장: 공개 전에 데이터 캐시를 끄고(저장소 docs/voice-bots/vertex-setup.md 9절), 키의 역할을 예측 권한 하나로 좁히세요(같은 문서 10절).

링크 비밀은 서버와 게이트웨이가 같은 값을 파일로 읽습니다. 링크는 암호화되지 않으므로 두 컨테이너만 붙는 전용 망에만 둡니다 — 다른 스택과 함께 쓰는 ssemiya-net 이나 LAN 에 두지 마세요. 포트는 공개하지 않습니다.

(umask 077; openssl rand -hex 32 > /tmp/voice-link.secret)
sudo install -m 400 -o 1000 /tmp/voice-link.secret /srv/pabal/pabal_server/keys/voice-link.secret   # 서버(uid 1000)의 것
sudo install -d -m 700 -o 10001 /srv/pabal/pabal_voice/secrets
sudo install -m 400 -o 10001 /tmp/voice-link.secret /srv/pabal/pabal_voice/secrets/voice-link.secret # 게이트웨이(uid 10001)의 것
shred -u /tmp/voice-link.secret

.env 에 넣는 값입니다. 덧붙이는 파일이 이 값으로 pabal-server 에 음성 설정(TELEGRAM_VOICE_*)과 링크 망을 더합니다.

COMPOSE_FILE=docker-compose.yml:docker-compose.voice.yml
COMPOSE_PROFILES=voice                              # 다른 프로파일(turn 등)이 있으면 쉼표로 함께
VOICE_LINK_SECRET_FILE=/app/keys/voice-link.secret  # 컨테이너 안 경로. 비우면 음성 꺼짐
VOICE_TURN_HOST=192.168.1.64                        # 서버의 LAN 주소 (coturn 이 듣는 곳)
VOICE_TESTERS=100001                                # 공개 전에 걸 수 있는 사람의 id (관리 화면에서도 넣음)
  • 덧붙이는 파일은 서버를 내부 전용 망 pabal-voice-link(10.231.53.0/29 — 서버 .2, 게이트웨이 .3)에 붙이고, 서버는 그 망의 자기 주소에서만 듣습니다(0.0.0.0 이면 함께 붙은 공유 망에서도 열립니다). 이 범위가 LAN·VPN·다른 도커 망과 겹치면 VOICE_LINK_SUBNET·VOICE_LINK_SERVER_IP·VOICE_LINK_GATEWAY_IP 셋을 함께 바꿉니다.
  • 루프백이 아닌 주소에 묶으려면 TELEGRAM_VOICE_LINK_ANY_ADDRESS=true 가 있어야 하고, 공인 주소에는 절대 묶이지 않습니다. 서버 로그가 링크가 보는 망을 알려 줍니다.
  • 인증 전 연결은 업그레이드 3초·hello 3초, 한 주소에서 2개, 모두 16연결까지만 받습니다.
  • 음성 관련 서버 설정 전체는 관리와 설정 — 음성 봇 환경 변수에 있습니다.

게이트웨이 (pabal-voice)

cd /opt/pabal
docker build -t pabal-voice:latest voice/                                       # 이 컴퓨터의 CPU 용
docker buildx build --platform linux/amd64 -t pabal-voice:latest --load voice/  # 인텔 서버용
cd deploy && docker compose up -d                                         # .env 의 COMPOSE_FILE·COMPOSE_PROFILES 대로

이미지는 해시로 고정한 파이썬 3.12, 컴파일러·pip 없음, uid 10001 로 돕니다. ntgcalls 는 리눅스 x86_64/aarch64 와 macOS arm64 만 있어 인텔 맥에서는 도커로만 돌립니다. 덧붙이는 파일의 pabal-voice 는 이 이미지를 빌드하지 않고 씁니다(image: pabal-voice:latest). 다른 컴퓨터에서 서버 CPU 용으로 만들어 보내려면 deploy/ship.sh <user@host> <폴더> --voice 를 씁니다(바로 전 이미지는 pabal-voice:previous 로 남습니다). 아래는 그 파일의 게이트웨이 서비스를 줄인 것입니다.

  pabal-voice:                             # deploy/docker-compose.voice.yml 에서 (줄임)
    image: pabal-voice:latest
    profiles: [voice]
    init: true
    stop_grace_period: 40s                 # VOICE_DRAIN_SECONDS(20) 보다 길게
    security_opt: ["no-new-privileges:true"]
    cap_drop: [ALL]
    mem_limit: ${VOICE_MEMORY:-1g}
    ulimits:
      core: 0
    environment:
      VOICE_LINK_URL: ws://${VOICE_LINK_SERVER_IP:-10.231.53.2}:8095/voice/link
      VOICE_LINK_SECRET_FILE: /run/secrets/voice-link.secret
      VOICE_ENGINE: gemini
      VOICE_VERTEX_KEY_FILE: /run/secrets/vertex-sa.json
      VOICE_VERTEX_REGION: us-central1
      VOICE_TURN_HOST: ${VOICE_TURN_HOST:-}  # 서버의 TELEGRAM_VOICE_TURN_HOST 와 같은 값
      VOICE_CAPACITY: ${VOICE_CAPACITY:-2}
      VOICE_DAILY_MODEL_MINUTES: ${VOICE_DAILY_MODEL_MINUTES:-60}
      VOICE_SAFETY_THRESHOLD: ${VOICE_SAFETY_THRESHOLD:-MEDIUM}   # 시험자 통화: 운영자가 확인할 가정(아래)
      VOICE_BYOK: "false"
      VOICE_STREAM: "false"
    volumes:
      - ${PABAL_HOME:-/srv/pabal}/pabal_voice/secrets:/run/secrets:ro   # 링크 비밀과 Vertex 키 — backup.sh 에 들어가지 않음
      - pabal_voice_state:/var/lib/pabal-voice
    networks:
      pabal-voice-link:                    # 서버와만 쓰는 링크 망 (internal)
        ipv4_address: ${VOICE_LINK_GATEWAY_IP:-10.231.53.3}
      pabal-voice-out: {}                  # 바깥(Google·중계)으로 나가는 자기 망
  • 게이트웨이는 링크 말고도 바깥으로 나가는 길이 있어야 합니다: Google(us-central1-aiplatform.googleapis.com:443)과 통화 중계(UDP). 링크 망은 internal: true 라 바깥으로 나갈 수 없으므로, 위처럼 pabal-voice-out 망을 하나 더 붙입니다.
  • Landlock: 모델이나 원음 모드를 쓰는 게이트웨이는 리눅스 커널의 Landlock(5.13 이상, 도커 기본 seccomp 에서 됨)이 없으면 시작을 거부합니다(종료 코드 2). 발신자가 보내는 것을 네이티브 코드로 다루는 미디어 작업 프로세스가 키 파일을 읽지 못하게 스스로 잠그기 때문입니다. 컨테이너 안에서 비밀 파일을 /app·/opt·/usr·/lib·/etc 아래로 마운트하지 마세요(/run/secrets 는 괜찮음 — 위 예처럼). VOICE_ALLOW_NO_LANDLOCK=true 는 개발용 맥 전용입니다. macOS Docker Desktop 에서 Landlock 이 되는지는 확인하지 않았습니다.
  • 메모리: 통화 하나에 미디어 60~100 MB + 엔진 30~40 MB. 용량 8(VOICE_CAPACITY)과 예비 한 쌍이면 약 1.3 GB. 덧붙이는 파일의 기본은 용량 2·메모리 1 GB(VOICE_MEMORY)입니다.
  • 안전 필터 VOICE_SAFETY_THRESHOLD 의 게이트웨이 기본은 LOW(가장 엄격)인데, 측정에서 멀쩡한 한국어 말 172개 중 13개를 영어 거절로 바꿨습니다(MEDIUM 은 69개 중 0개). 시험자 통화는 MEDIUM 을 명시하는 것이 저장소의 가정이며, 기본값을 바꾸기 전에는 레드팀 시험이 필요합니다.
  • 한도: 게이트웨이의 기본은 한 통화 600초·40만 토큰·분당 30턴, 하루(UTC) 모델 300분·800만 토큰이고, 덧붙이는 파일은 하루를 60분·160만 토큰으로 정합니다(VOICE_DAILY_MODEL_*, 넘으면 새 통화는 통화 중). OpenAI 엔진은 가짜 서버로만 시험했고 꺼져 있으며, 엔진 사이의 자동 전환은 없습니다.
  • 언어: 게이트웨이는 서버에 붙을 때 자기가 통화할 수 있는 봇 언어를 밝히고(0.7.0 부터 9개 모두), 서버는 봇의 언어로 통화하는 게이트웨이에만 그 봇의 통화를 줍니다 — 언어를 밝히지 않는 옛 게이트웨이(0.6.0)에는 한국어·영어 봇만 갑니다. 안내 녹음은 이미지의 assets/ 에 있고, 어느 녹음이 검토를 마쳤는지는 assets/assets.json 이 정합니다. 이 파일이 없으면 AI 안내를 싣지 못해 통화를 받지 못합니다(시작 로그에 ERROR). 검토를 마치지 않은 녹음(지금은 한국어·영어 밖의 모든 언어)은 틀지 않고, 그 언어의 발신자는 영어 안내를 듣습니다. VOICE_DRAFT_NOTICES=true 는 초안 녹음까지 트는 시험 서버 전용 스위치이고, VOICE_ALLOW_TEST_URLS=true 가 함께 없으면 게이트웨이가 시작하지 않습니다. VOICE_NOTICE_LANG(서버가 발신자의 언어를 알려 주지 않을 때의 안내 언어, 기본 ko)는 녹음 언어 10개(ko en ja zh fr es id vi km ar) 중 하나, VOICE_LANGUAGE(봇 주인 설정이 없는 통화의 봇 언어, 기본 ko-KR)는 봇 언어 목록의 id(음성 봇 — 목소리와 언어) 중 하나여야 합니다.
  • 로그에는 링크 비밀·키·소리·대화 글·지시문이 남지 않습니다. SIGTERM 이면 새 통화를 받지 않고 진행 중 통화에 VOICE_DRAIN_SECONDS(기본 20초)를 준 뒤 끝냅니다.

확인

docker compose logs pabal-server | grep -E "Voice|voice"
docker compose logs pabal-voice | head -20
INFO  TelegramServer - Voice bots on: gateways connect to ws://10.231.53.2:8095/voice/link; bots 0 listed, 2 calls each at most
INFO  VoiceGatewayLink - Voice gateway link listening on ws://10.231.53.2:8095/voice/link (망)
INFO  TelegramServer - Voice bot callers: patched apps only (desktop pabal-bc1+, Android 62151+, iOS 5+), recipients google.vertex.gemini-live@us-central1, testers 0 listed + the admin page's
INFO  VoiceGatewayLink - Voice gateway pabal-voice-1 (boot 823c9600d05e811847ce95a8f1ba9efa) connected: new session, capacity 2, features […], audio to google.vertex.gemini-live@us-central1
  • Voice bots are off: … 는 링크 비밀이 없거나 읽을 수 없거나 32바이트가 아닐 때, 줄 끝의 — but without a TURN relay every bot call is busy 는 중계가 없을 때입니다.
  • ERROR Voice bots are on, but no bot is callable: the privacy policy … has no id="bot-calls" section 가 보이면 사이트의 방침에 봇 통화 절을 먼저 게시해야 합니다.
  • 게이트웨이가 붙으면 서버 로그의 features 에 bot_settings·end_call·context·tools 와, 주인 키를 돌리는 게이트웨이면 byok, 대화 기록 안내 음성이 있으면 transcripts 가 보입니다(순서는 그때마다 다름). audio to 는 앱의 확인 창이 말하는 받는 곳과 같아야 합니다.
  • 게이트웨이 쪽 첫 줄에 starting as pabal-voice-1 (capacity 2, … engine gemini, audio to google.vertex.gemini-live@us-central1), 모델 줄에 session resumption off; no failover, 그리고 media workers sandbox themselves with Landlock (ABI N) 이 나와야 합니다. recordings for the callers' notices in: Korean, English 는 발신자 안내를 틀 수 있는 언어입니다(초안까지 트는 시험 서버면 (drafts included: …) 가 붙음).
  • 그다음 관리 화면의 음성 탭(관리와 설정 — 음성 탭)에서 게이트웨이가 붙었는지 보고, 시험자를 넣고, 봇을 허용합니다.

마스터 키 (자기 키 · 원음 모드)

봇 주인의 모델 키와 원음 모드의 비밀은 이 키로 감싸 저장합니다. 백업과 함께 복사되지 않는 곳에 둬야 합니다.

sudo install -d -m 700 -o 1000 -g 1000 /etc/pabal/voice                 # /srv/pabal 밖, 700
sudo sh -c 'umask 077; openssl rand -hex 32 > /etc/pabal/voice/voice-master.key'
sudo chown 1000:1000 /etc/pabal/voice/voice-master.key
      TELEGRAM_VOICE_KEYS_MASTER_FILE: /app/secrets/voice-master.key
    volumes:
      - /etc/pabal/voice:/app/secrets:ro
  • 데이터 폴더·RSA 키 폴더 안이나 그 옆이면, 폴더가 다른 사용자에게 조금이라도 열려 있으면(700 이 아니면) 서버가 쓰지 않습니다(Voice keys off: …). 파일이 없으면 첫 키를 받을 때 서버가 만듭니다(읽기 전용 마운트면 위처럼 직접 만드세요).
  • 백업에 넣지 말고, 오프라인 사본을 하나 따로 둡니다. 잃으면 모든 봇 주인이 키를 다시 정해야 합니다. 교체할 때는 TELEGRAM_VOICE_KEYS_PREVIOUS_MASTER_FILE 에 옛 키를 주고 켠 뒤 로그를 확인하고 옛 파일을 지웁니다.
  • 마스터 키가 있어도 자기 키는 앱이 자기 키 봇의 확인 창(2판)을 보여 준 뒤 TELEGRAM_VOICE_OWN_KEYS_APPS_READY=true 를 줄 때까지 받지 않습니다. 원음 모드는 원음 확인 창이 든 앱 빌드를 TELEGRAM_VOICE_STREAM_MIN_BUILD_DESKTOP·_ANDROID·_IOS 로 알려야 걸 수 있고(기본 0 = 아무도 못 검), 게이트웨이에 VOICE_STREAM=true 와 이 배포의 공인 IP(VOICE_STREAM_DENY_ADDRESSES)가 있어야 합니다.

MCP 부품 (선택)

봇 주인이 등록한 MCP 서버를 통화 중에 부르는 부품입니다. 기본은 꺼져 있고, deploy/docker-compose.mcp.yml(프로파일 mcp)로 세 컨테이너를 더합니다.

컨테이너하는 일
pabal-mcp (uid 1101)MCP 클라이언트. 서버와는 소켓 파일로만 잇고, 내부 망 하나뿐이라 송신 프록시로만 바깥에 나갑니다. 읽기 전용, 권한 없음, 512 MB
pabal-mcp-signer (uid 1102)서명기. 네트워크가 전혀 없고 소켓으로만 답하며, 공개 발급자 문서를 씁니다
pabal-mcp-egress (uid 1103)송신 프록시. 443 으로만, 파발 자신의 주소로는 가지 않습니다
docker compose -f docker-compose.yml -f docker-compose.mcp.yml --profile mcp up -d
  • 켜려면 TELEGRAM_MCP_EGRESS_OWN_ADDRESSES(파발의 공인 주소들, 필수)와 키 파일들(링크 비밀, K_grant, 설치 키 — 모두 /srv/pabal 밖 700 폴더)이 있어야 하고, 사이트에 방침의 bot-calls-mcp 절과 약관의 봇 주인 절이 있어야 합니다. 한 번 할 설정은 docker-compose.mcp.yml 맨 위 주석에 순서대로 있습니다. 볼트와 서명 키는 백업에 넣지 않습니다.
  • 앱이 확인 창에 봇의 MCP 받는 곳을 보여 주게 된 뒤 TELEGRAM_MCP_APPS_READY=true 를 줍니다. 그 전에는 등록만 되고 어떤 통화도 MCP 도구를 싣지 않습니다.
  • MCP 요청이 나갈 전용 송신 주소를 따로 두는 것은 코드의 조건이 아니라 운영 결정입니다(발신자의 말이 제3자 서버로 가는 출구이므로).
  • 켜지면 로그에 MCP tools on: pabal-mcp dials …; issuer …; ports …; own addresses N; clock by NTP …. deploy/ship.sh <서버> <폴더> --mcp 는 세 이미지를 만들어 싣고 override 파일을 복사할 뿐 아무것도 켜지 않습니다(리눅스 도커에서 돌려 본 적은 없음).

애플 워치 입구 (선택, 준비 중)

애플 워치 앱은 MTProto 대신 서버의 워치 API(HTTPS JSON, /api/watch/v1/)로 로그인하고 전화를 걸며, 통화 소리는 소리 입구(pabal-voice-edge, WSS)를 거쳐 게이트웨이에 닿습니다. 통화 중계(coturn)는 쓰지 않습니다. 기본은 꺼져 있습니다.

워치 소리 입구는 아직 바깥에서 닿지 않습니다

deploy/docker-compose.voice.yml 에 소리 입구 pabal-voice-edge(프로파일 voice-watch, 비밀 없음, 내부 전용 망)는 있지만, 서버의 워치 API 설정(TELEGRAM_WATCH_*)과 리버스 프록시의 워치 경로는 아직 없어 바깥의 워치가 닿지 못합니다. 아래는 설정 값의 안내이고, 운영 구성으로 돌려 본 적은 없습니다(개발 서버에서만 시험).

설정기본뜻
TELEGRAM_WATCH_API_PORT—워치 API 포트. 없으면 워치 API 꺼짐
TELEGRAM_WATCH_API_HOST, TELEGRAM_WATCH_API_ANY_ADDRESS127.0.0.1, —듣는 주소. 루프백이 아니면 ANY_ADDRESS=true 가 있어야 하고, 앞에 TLS 프록시를 둡니다
TELEGRAM_WATCH_AUDIO_URL—워치에게 알려 줄 소리 입구 주소. 경로는 정확히 wss://<호스트>/api/watch/v1/audio. 없거나 틀리면 워치 통화 꺼짐
TELEGRAM_WATCH_ALLOW_PLAIN—ws:// 주소를 허용. 개발 전용(집 안 시험)
TELEGRAM_WATCH_SESSION_DAYS7워치 로그인이 유지되는 날(1~365)
TELEGRAM_WATCH_AUTH_PER_MINUTE30서버 전체의 로그인 코드 요청 한도(분당). 주소마다의 한도는 따로 있음
TELEGRAM_VOICE_MIN_BUILD_WATCHOS1확인 창 전체를 보여 주는 첫 워치 앱 빌드
  • 게이트웨이: VOICE_DIRECT=true 와 VOICE_EDGE_DIR(입구와 함께 쓰는 소켓 폴더, 기본 /run/pabal-voice-edge). 워치 통화는 16 kHz 엔진(Gemini)이나 에코로만 됩니다.
  • 소리 입구: 같은 이미지의 두 번째 서비스로 python -m pabal_voice.edge 를 돌립니다(VOICE_EDGE_HOST·VOICE_EDGE_PORT, 기본 127.0.0.1·8096, VOICE_EDGE_DIR). 입구는 인터넷에서 온 것을 처음 푸는 곳이라 비밀을 하나도 붙이지 말고, 프록시와 게이트웨이 말고는 닿지 않는 내부 망에 둡니다.
  • 리버스 프록시는 워치 두 경로만 넘깁니다 — /api/watch/v1/audio(WebSocket, 입구로)와 /api/watch/v1/(워치 API로). 워치 API 쪽은 접속자 주소를 X-Forwarded-For 의 마지막에 붙이게 둡니다(서버가 그 값으로 주소마다의 로그인 한도를 셉니다). 입구 쪽에서는 X-Forwarded-For·X-Real-IP 를 지웁니다.
  • 워치에서는 가입할 수 없고, 도구·MCP·자기 키·원음 모드·봇 주인의 대화 기록을 쓰는 봇에는 걸 수 없습니다(WATCH_NOT_YET).

대화 남기기 (선택, 준비 중)

전화를 거는 사람이 확인 창에서 고르면 통화의 대화를 그 사람의 대화방에만 글로 남기는 기능입니다(앱 사용하기 — 음성 봇). 운영자 허용 스위치는 없고, 아래가 모두 있으면 켜집니다.

  • 개인정보처리방침에 id="bot-calls-keep-text" 절(초안: 저장소의 docs/voice-bots/p0/privacy-*.draft.html). 이 절이 곧 켜는 스위치입니다. 앱은 help.getAppConfig 의 pabal_voice_keep_text 로 체크 상자를 보일지 압니다.
  • 게이트웨이 0.9.0 의 VOICE_KEEP_TEXT: 모델 엔진이면 기본 true. 에코 엔진(원음 전용 게이트웨이 포함)에서 true 면 설정 오류로 시작하지 않습니다.
  • 체크 상자가 든 앱 빌드. 체크 상자 문구는 법률 검토를 마쳐야 출시 빌드에 실립니다. 워치에는 검토 전 문구를 보이지 않고, TELEGRAM_VOICE_KEEP_TEXT_UNREVIEWED=true 는 검토 전 문구를 보이는 내부 시험 서버 전용 스위치입니다.
  • 켜진 서버에서는 원음 모드가 아닌 모든 봇 통화의 voice_call ended 가 최대 10초 늦고, 통화 기록 뒤 메시지 번호 30개를 미리 잡습니다(발신자의 선택을 봇 주인이 알 수 없게).
  • 통화가 끝난 뒤 쓰기를 기다리는 대화 글은 메모리에만 있어, 그사이 서버를 다시 켜면 그 통화의 글은 남지 않습니다.

봇이 거는 전화 (선택, 법률 검토 대기)

음성 봇이 그 봇에게 전화를 허락한 사람에게 먼저 전화를 거는 기능입니다(음성 봇 — 봇이 거는 전화). 받는 전화보다 조건이 많고, 법률 검토를 마치기 전에는 켜지 마세요. 켜는 순서:

  1. 서버: 증거 키 폴더(TELEGRAM_VOICE_EVIDENCE_KEY_DIR — 700, 데이터·키 폴더처럼 백업되는 폴더 밖)를 scripts/voice-evidence-key.py 의 new 로 만들고(바꿀 때는 rotate, 점검은 check), 허락 창이 든 첫 앱 빌드를 TELEGRAM_VOICE_OUTBOUND_MIN_BUILD_ANDROID 같은 발신 최소 빌드로 정합니다(환경 변수). .env 에만 적은 TELEGRAM_VOICE_EVIDENCE_KEY_DIR 는 서버에 닿지 않습니다 — deploy/docker-compose.voice.yml 의 pabal-server 에 environment(컨테이너 안 경로)와 volumes(호스트의 그 폴더)로 더하세요.
  2. 앱: 허락 창이 든 앱 빌드를 냅니다. 앱의 허락 링크 규칙은 pabal.me 만 알기 때문에, 다른 서버라면 앱 빌드의 링크 규칙에 그 서버의 호스트를 넣어야 봇이 보낸 링크로 창이 열립니다(메뉴와 설정 목록은 그대로 됩니다).
  3. 법률: 개인정보처리방침의 id="bot-calls-outbound" 절과 이용약관의 id="voice-bot-owners-outbound" 절(봇 주인이 약관에 동의하려면 voice-bot-owners 절도)을 게시합니다. 초안은 저장소의 docs/voice-bots/p0/ 에 있습니다. 허락 창 글과 발신 안내 녹음(notice-out·apology-rule)의 검토와 승인도 이때입니다.
  4. 스위치: 관리 화면 음성 탭의 봇이 거는 전화에서 나라와 한도를 정하고 확인 문구로 켭니다(관리 화면 — 음성 탭). 봇마다 운영자의 발신 검토가 따로 있습니다.
  • 게이트웨이: VOICE_OUTBOUND=true 인 게이트웨이만 봇의 전화를 겁니다. 모델 엔진이면 발신 안내 녹음 넷이 승인되어 있어야 시작하고, 초안 녹음(VOICE_OUTBOUND_DRAFT_NOTICES)은 시험 서버 전용입니다.
  • 운영 .env 에는 TELEGRAM_VOICE_CALLME_UNREVIEWED·TELEGRAM_VOICE_TEST_DIAL 같은 시험·개발 전용 이름을 두지 마세요.
  • 봇이 거는 전화도 종단 간 암호화가 아닙니다 — 소리가 이 서버의 게이트웨이와 AI 제공자를 거칩니다.

13. 위치 메시지의 지도 그림 (선택)

위치를 보내면 안드로이드 앱과 데스크톱(맥·윈도우) 앱은 말풍선 속 지도를 스스로 그리지 않고 서버에 그 지점의 지도 그림을 달라고 합니다(upload.getWebFile). 서버는 그 그림을 Google 의 Maps Static API 에서 받아 전해 줍니다. 켜지 않으면 안드로이드는 자리표시 그림을, 데스크톱은 빈 칸에 핀만 보여 줍니다. 아이폰은 휴대폰에서 직접 그리므로 이 설정과 상관없고, 위치 보내기 자체와 위치를 누르면 열리는 큰 지도는 그대로 됩니다.

  1. Google Cloud 콘솔에서 결제 계정이 연결된 프로젝트를 고르고 Maps Static API 를 사용 설정합니다.
  2. API 키를 만들고, 애플리케이션 제한은 IP 주소(서버가 인터넷으로 나갈 때 쓰는 공인 IP), API 제한은 Maps Static API 하나로 둡니다. 이 키는 서버에만 있고 앱에는 들어가지 않습니다.
  3. .env 에 넣고 docker compose up -d pabal-server 로 서버를 다시 켭니다.
  4. 로그에서 확인합니다. 꺼져 있으면 Maps in location messages are off: … 와 그 이유가 나옵니다(키 값은 로그에 나오지 않습니다).
STATIC_MAPS_KEY=AIza…               # 키 값은 채팅·로그·저장소에 남기지 마세요
STATIC_MAPS_PER_DAY=300             # 하루에 새로 받는 그림 수 (0 = 제한 없음)
STATIC_MAPS_PER_USER_HOUR=120       # 한 사람이 한 시간에 새로 받게 할 수 있는 그림 수
INFO  TelegramServer - Maps in location messages from Google Static Maps (120 new per person an hour, 300 a day)
  • 비용: Maps Static API 는 한 달 1만 건까지 무료입니다. 서버는 받은 그림을 하루 동안 기억해 같은 지도를 달라는 기기에 다시 주고, 다시 준 그림은 한도에 세지 않습니다. 기본 한도(하루 300건)면 한 달 약 9천 건이라 무료 범위 안입니다. 한도를 넘은 요청은 FLOOD_WAIT 로 거절되고, 앱은 그동안 자리표시 그림을 보여 줍니다.
  • 개인정보 처리방침: 공유된 위치의 좌표(위도·경도)가 Google 로 갑니다 — 누가 보냈는지는 보내지 않습니다. 관리 화면 → 설정 → 운영자 정보의 처리 위탁과 국외 이전에 Google LLC(Maps Static API, 미국)를 적어 넣으세요.
  • 로그에 Map picture not made: Google Static Maps answered 403: … 가 보이면 키의 제한(IP·API)이나 결제 계정을 확인합니다.

운영

상태·로그

cd /opt/pabal/deploy
docker compose ps
docker compose logs --since 1h pabal-server
tail -f /srv/pabal/pabal_server/logs/telegram-server.log

업데이트

cd /opt/pabal && git pull
cd deploy
./backup.sh                                     # 먼저 백업
docker compose up -d --build pabal-server             # 새 이미지로 서버만 교체
docker compose logs --since 5m pabal-server | grep -E "Website|ERROR"

서버가 다시 켜지는 수십 초 동안 앱은 끊겼다가 다시 로그인하지 않고 스스로 다시 붙습니다. 테이블 변경은 서버가 켜질 때 알아서 적용합니다. 바인드 디렉터리는 이미지와 상관없으므로 docker compose down 이나 이미지 삭제로 데이터가 지워지지 않습니다 — /srv/pabal 을 지우지만 마세요.

되돌리기: 바로 전 이미지로 돌아가도 새 판이 데이터베이스에 이미 쓴 것은 그대로입니다. 새 판의 기록을 옛 판이 읽지 못할 수 있으니(예: 음성 봇 코드가 든 판은 음성을 켜지 않아도 대화방의 통화 기록 같은 새 이벤트를 씁니다), 되돌릴 때는 업데이트 직전 백업의 DB 를 함께 되살립니다(복구).

백업

./backup.sh            # → /srv/pabal/backups/<날짜-시각>/{pabal.dump, server-keys-data.tar.gz}
crontab -e             # 매일 03:00:
# 0 3 * * * /opt/pabal/deploy/backup.sh >> /srv/pabal/backups/backup.log 2>&1
  • 14일이 지난 백업은 스스로 지웁니다(KEEP_DAYS=30 ./backup.sh 처럼 바꿀 수 있음).
  • 다른 곳에도 복사하세요. 같은 디스크의 백업은 디스크 고장을 이기지 못합니다.
  • 백업에는 RSA 비밀키와 SMS·SMTP 비밀값이 들어 있습니다. 보관 장소도 서버만큼 지키세요.

복구

cd /opt/pabal/deploy
B=/srv/pabal/backups/20260920-030000            # 되살릴 백업
docker compose stop pabal-server pabal-caddy
docker compose exec -T pabal-postgres dropdb -U pabal pabal
docker compose exec -T pabal-postgres createdb -U pabal pabal
docker compose exec -T pabal-postgres pg_restore -U pabal -d pabal --no-owner < $B/pabal.dump
docker compose exec -T pabal-postgres psql -U pabal -d pabal -c "ALTER DATABASE pabal SET search_path = public"   # 데이터베이스의 기본 경로 — 복구로는 돌아오지 않아 다시 건다
sudo tar -C /srv/pabal/pabal_server -xzf $B/server-keys-data.tar.gz
sudo chown -R 1000:1000 /srv/pabal/pabal_server
docker compose up -d

새 서버로 옮길 때도 같습니다: 1~5단계를 한 뒤, 6단계 대신 위 복구를 하고 켭니다. 같은 키를 되살리면 앱을 다시 빌드할 필요가 없습니다(서버 IP 가 바뀌었다면 다시 빌드).

켜기·끄기

docker compose restart pabal-server       # 서버만 다시 켜기
docker compose stop                 # 모두 멈춤 (데이터 그대로)
docker compose up -d                # 다시 켬

서버를 재부팅해도 컨테이너는 restart: unless-stopped 라 스스로 다시 켜집니다.

보안 점검표

  • .env 권한 600, POSTGRES_PASSWORD 는 무작위 값
  • /srv/pabal/pabal_server/keys 권한 700, 비밀키가 백업 외 다른 곳에 없음
  • (푸시를 켰다면) firebase-service-account.json 권한 600, 저장소·.env 에 내용이 들어가 있지 않음
  • (아이폰 푸시를 켰다면) AuthKey_<키 ID>.p8 권한 600, 저장소·.env 에 내용이 들어가 있지 않음
  • 방화벽: 22(가능하면 운영자 IP 만)·80·443·8443 만
  • 바깥에서 https://도메인/admin/ 이 404
  • 관리 화면: 테스트 번호 꺼짐, 코드 전달 방식 SMS/이메일, 테스트 발송 성공
  • TELEGRAM_WEBHOOK_ALLOW_LOCAL 을 켜지 않음 (봇 웹훅이 내부망으로 가지 못하게)
  • (음성 봇을 켰다면) 링크 비밀·Vertex 키·마스터 키가 각자 읽는 사용자만 읽을 수 있고(400/600), 마스터 키가 /srv/pabal 밖에 있어 백업에 들어가지 않음. 링크 포트는 공개하지 않았고 링크 망은 서버와 게이트웨이만 씀. 운영 서버에 TELEGRAM_VOICE_NOTICE_CHECK·TELEGRAM_VOICE_TESTERS=* 가 없음
  • SSH: 비밀번호 로그인 끄기, 키로만
  • 백업 cron 과 외부 복사 확인, 복구를 한 번 연습
  • 서버 OS 자동 보안 업데이트(unattended-upgrades)

문제 해결

증상원인 → 해결
앱이 "연결 중…"에서 넘어가지 않음① 8443 이 막힘 → nc -vz IP 8443, ufw·클라우드 보안 그룹 확인 ② 앱을 다른 공개키로 빌드함 → 9단계를 이 서버의 키로 다시 ③ PUBLIC_IP 가 틀림 → .env 고치고 docker compose up -d pabal-server
처음엔 붙는데 곧 끊김앱이 서버가 알려 준 주소(PUBLIC_IP:MTPROTO_PORT)로 옮겨 가는데 그 주소가 틀림 → PUBLIC_IP 확인
HTTPS 인증서 오류DNS 가 아직 이 서버를 가리키지 않거나 80 포트가 막힘 → dig +short 도메인, docker compose logs pabal-caddy
server 가 계속 다시 켜짐, password authentication failedPOSTGRES_PASSWORD 를 데이터를 만든 뒤에 바꿈 → 원래 값으로 되돌리거나 DB 안에서 ALTER USER pabal PASSWORD '…'
AccessDeniedException: /app/keys/…바인드 디렉터리 소유자 → sudo chown -R 1000:1000 /srv/pabal/pabal_server
OutOfMemoryError, 느려짐JAVA_OPTS 의 -Xmx 를 올리고 docker compose up -d pabal-server
가입 코드가 안 옴관리 화면 가입 코드 탭의 발송 상태·실패 이유 → 설정의 SMS·SMTP 값, 테스트 발송
안드로이드에 알림이 안 옴① 로그에 Push notifications are off → FCM_CREDENTIALS 경로·파일 권한 ② Push device registered 가 없음 → 앱이 Firebase 설정 없이(pabal-placeholder) 빌드됐거나 새 앱을 아직 열지 않음 ③ sent 인데 안 뜸 → 휴대폰의 알림 권한·배터리 설정, 앱을 "강제 종료"했는지 ④ SENDER_ID_MISMATCH … Registrations are kept → 서버의 키와 앱의 google-services.json 이 서로 다른 프로젝트 ⑤ answered 404 without naming the token → FCM_PROJECT_ID 가 틀림 ⑥ refused an access token → 키가 폐기됐거나 다른 프로젝트의 키(5분 동안은 다시 묻지 않음)
아이폰에 알림이 안 옴① 로그에 iPhone push notifications are off → APNS_KEY 경로·파일 권한·이름(AuthKey_<키 ID>.p8) ② type 1 등록이 없음 → 앱에서 알림을 허용하지 않았거나 시뮬레이터 ③ 등록은 있는데 아무것도 안 나감 → 그 빌드가 APNS_MIN_BUILD 보다 낮음(등록 줄의 build), 또는 알림을 끈 대화방 ④ APNs refused the provider token → 키가 폐기됐거나 APNS_TEAM_ID 가 키의 팀과 다름(5분 동안은 다시 보내지 않음) ⑤ DeviceTokenNotForTopic … Registrations are kept → APNS_TOPIC 이 앱의 번들 ID 와 다름 ⑥ sent 인데 "새 알림이 있습니다"만 보임 → 알림 확장이 풀지 못함(앱을 다시 설치했다면 한 번 열어 다시 등록) ⑦ 전화가 알림으로만 옴 → APNS_VOIP_MIN_BUILD 가 비어 있거나 그 빌드보다 낮음(의도된 동작)
관리 화면에 안 들어가짐SSH 터널이 켜져 있는지, 내 컴퓨터의 8080 을 다른 프로그램이 쓰는지(-L 18080:127.0.0.1:8080 으로 바꿔 localhost:18080)
network ssemiya-net declared as external, but could not be found네트워크를 아직 만들지 않았습니다 → docker network create ssemiya-net (다른 이름이면 .env 의 PABAL_NETWORK)

알려진 한계

  • 서버 한 대 구성입니다. 켜질 때 모든 계정·대화·메시지를 메모리에 올려 둡니다. 여러 대로 나누는 수평 확장은 아직 안 되고, 데이터가 늘면 메모리를 늘려야 합니다.
  • 기다리는 가입 코드, 봇의 가져가지 않은 업데이트, 쓰기를 기다리는 대화 남기기의 글은 메모리에 있어 서버를 다시 켜면 사라집니다.
  • 가입 코드 요청에는 간격·하루 한도가 있지만, 그 밖의 요청 전체에 대한 속도 제한은 아직 없습니다. 공개 직후에는 로그와 대시보드를 자주 보세요.
  • 푸시 알림은 휴대폰 앱에만 갑니다 — 안드로이드(10단계)와 아이폰(11단계). 아이폰은 APNS_MIN_BUILD 이상 빌드에만 가고(파발 1.0 (3) 은 빠짐), 전화의 CallKit 화면은 APNS_VOIP_MIN_BUILD 를 정한 뒤에야 켜집니다. 맥·윈도우 앱은 켜져 있는 동안 받습니다.
  • 대화방별 "알림 끄기"는 서버 메모리에만 있습니다. 알림을 끈 대화방의 푸시는 안드로이드에는 급하지 않은 우선순위로 나가고(휴대폰이 스스로 걸러 냄) 아이폰에는 나가지 않다가, 서버를 다시 켜면 둘 다 평소처럼 나갑니다(앱은 설정을 바꿀 때만 서버에 다시 알려 줍니다). 꺼져 있던 앱이 깨어나 서버에 붙기까지 20초가 넘게 걸리면 전화는 거는 쪽에서 받지 않은 전화로 끝날 수 있습니다.
  • 메시지 첨부는 사진과 파일이 되고, 앱 안에서 재생되는 동영상·음성 메시지는 아직입니다. 채널·슈퍼그룹, 그룹 통화, 2단계 인증도 아직입니다.
  • 인증 키가 DB 에 평문으로 있습니다(서버가 복호화하려면 필요). DB 와 백업을 RSA 키만큼 지키세요.

파일

파일역할
deploy/docker-compose.yml운영 구성 (pabal-server · pabal-postgres · pabal-caddy, 바인드 마운트)
deploy/CaddyfileHTTPS, 관리 경로 차단, 사이트·문서·Bot API 전달
deploy/.env.example설정 견본 → deploy/.env
deploy/backup.shDB 덤프 + 키·데이터 보관, 오래된 백업 정리
Dockerfile서버 이미지 (빌드 → JRE 21, uid 1000)
voice/음성 게이트웨이 pabal-voice (소스, Dockerfile, README.md, 링크 규격 PROTOCOL.md)
deploy/docker-compose.mcp.yml음성 봇의 MCP 부품 세 컨테이너 (프로파일 mcp)
deploy/docker-compose.voice.yml음성 봇 부품을 더하는 파일 — 서버의 음성 설정과 링크 망, 게이트웨이 pabal-voice (프로파일 voice), 워치 소리 입구 pabal-voice-edge (프로파일 voice-watch)
deploy/ship.sh다른 컴퓨터에서 서버 이미지(--voice 면 게이트웨이 이미지도)를 서버 CPU 용으로 만들어 SSH 로 싣고 배포 파일을 복사
deploy/voice_readiness.py음성을 켜기 전의 점검 (Vertex 키·법률 절·앱 빌드·중계 …) — 비밀은 출력하지 않음
deploy/turnserver.conf.example, deploy/check_relay.py맥 서버에 직접 까는 coturn 설정 견본, 중계 확인 도구