feat: add Queue-based server provisioning with security fixes

- Add Cloudflare Queue for async server provisioning
  - Producer: callback-handler.ts sends to queue
  - Consumer: provision-consumer.ts processes orders
  - DLQ: provision-dlq.ts handles failed orders with refund

- Security improvements (from code review):
  - Store password hash instead of plaintext (SHA-256)
  - Exclude root_password from logs
  - Add retryable flag to prevent duplicate instance creation
  - Atomic balance deduction with db.batch()
  - Race condition prevention with UPDATE...WHERE status='pending'
  - Auto-refund on DLQ processing

- Validation improvements:
  - OS image whitelist validation
  - Session required fields validation
  - Queue handler refactoring

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-01-24 22:54:15 +09:00
parent 2494593b62
commit 1fead51eff
7 changed files with 488 additions and 87 deletions

View File

@@ -1,7 +1,6 @@
import { answerCallbackQuery, editMessageText, sendMessage, sendMessageWithKeyboard } from '../../telegram';
import { UserService } from '../../services/user-service';
import { executeDomainRegister } from '../../domain-register';
import { executeServerProvision } from '../../server-provision';
import {
getSessionForUser,
updateSession,
@@ -11,8 +10,16 @@ import {
} from '../../utils/session';
import { getServerSpec } from '../../services/cloud-spec-service';
import { getRegionDisplay, getOSDisplayName, NUM_EMOJIS } from '../../constants/server';
import { createLogger } from '../../utils/logger';
import type { Env, TelegramUpdate } from '../../types';
const logger = createLogger('callback-handler');
/**
* Allowed OS images for server provisioning
*/
const ALLOWED_OS_IMAGES = ['ubuntu-22.04', 'ubuntu-24.04', 'debian-12', 'centos-stream-9'];
/**
* Safely parse integer with range validation
* @param value - String to parse
@@ -263,6 +270,12 @@ ${result.error}
if (action === 'os') {
const osImage = parts[3];
// Validation: Check if OS image is allowed
if (!ALLOWED_OS_IMAGES.includes(osImage)) {
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '지원하지 않는 OS입니다.' });
return;
}
await updateSession<ServerOrderSessionData>(env.SESSION_KV, sessionId, {
step: 'final_confirm',
image: osImage
@@ -272,6 +285,14 @@ ${result.error}
const { plan, region, provider } = session.data;
// Validation: Check if required session data exists
if (!plan || !region || !provider) {
logger.error('세션 데이터 불완전', undefined, { sessionId, plan, region, provider });
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '세션이 만료되었습니다. 다시 시작해주세요.' });
await deleteSession(env.SESSION_KV, sessionId);
return;
}
// DB에서 사양 조회
const spec = await getServerSpec(
env.CLOUD_DB,
@@ -362,7 +383,7 @@ ${result.error}
return;
}
// confirm: 서버 생성 실행
// confirm: 서버 생성 요청 (Queue 전송)
if (action === 'confirm') {
const { orderId } = session.data;
if (!orderId) {
@@ -370,50 +391,35 @@ ${result.error}
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' });
await editMessageText(env.BOT_TOKEN, chatId, messageId,
'⏳ 서버를 생성하고 있습니다... (1-3분 소요)'
);
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 접수 중...' });
const result = await executeServerProvision(env, user.id, telegramUserId, orderId);
// Queue에 메시지 전송 (즉시 반환)
await env.SERVER_PROVISION_QUEUE.send({
order_id: orderId,
user_id: user.id,
telegram_user_id: telegramUserId,
chat_id: chatId,
timestamp: Date.now(),
});
// 사용자에게 즉시 응답
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`📋 <b>서버 생성 주문 접수 완료!</b>
주문번호: #${orderId}
⏳ 서버를 생성하고 있습니다. (1-3분 소요)
완료되면 알림을 보내드립니다.
💡 이 메시지를 닫아도 괜찮습니다.`,
{ parse_mode: 'HTML' }
);
// 세션 삭제
await deleteSession(env.SESSION_KV, sessionId);
if (result.success) {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`✅ <b>서버 생성 완료!</b>
• 사양: <code>${result.plan_label}</code>
• 리전: ${result.region}
• IP 주소: <code>${result.ip_address}</code>
• Root 비밀번호: <code>${result.root_password}</code>
📌 <b>접속 방법</b>
<code>ssh root@${result.ip_address}</code>
⚠️ <b>보안 권고</b>
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
2. SSH 키 인증 설정을 권장합니다.
3. 방화벽(UFW)을 활성화하세요.
🎉 서버가 성공적으로 생성되었습니다!`
);
} else {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`❌ <b>서버 생성 실패</b>
${result.error}
다시 시도하시려면 서버 주문을 요청해주세요.`
);
}
return;
}
@@ -650,7 +656,7 @@ ${result.error}
return;
}
// 서버 주문 확인
// 서버 주문 확인 (레거시 - Queue 기반으로 전환)
if (data.startsWith('server_order:')) {
const parts = data.split(':');
if (parts.length !== 2) {
@@ -665,50 +671,33 @@ ${result.error}
return;
}
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '서버 생성 중...' });
await answerCallbackQuery(env.BOT_TOKEN, queryId, { text: '주문 접수 중...' });
// Queue에 메시지 전송 (즉시 반환)
await env.SERVER_PROVISION_QUEUE.send({
order_id: orderId,
user_id: user.id,
telegram_user_id: telegramUserId,
chat_id: chatId,
timestamp: Date.now(),
});
// 사용자에게 즉시 응답
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
'⏳ 서버 생성하고 있습니다... (1-3분 소요)'
`📋 <b>서버 생성 주문 접수 완료!</b>
주문번호: #${orderId}
⏳ 서버를 생성하고 있습니다. (1-3분 소요)
완료되면 알림을 보내드립니다.
💡 이 메시지를 닫아도 괜찮습니다.`,
{ parse_mode: 'HTML' }
);
const result = await executeServerProvision(env, user.id, telegramUserId, orderId);
if (result.success) {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`✅ <b>서버 생성 완료!</b>
• 사양: <code>${result.plan_label}</code>
• 리전: ${result.region}
• IP 주소: <code>${result.ip_address}</code>
• Root 비밀번호: <code>${result.root_password}</code>
📌 <b>접속 방법</b>
<code>ssh root@${result.ip_address}</code>
⚠️ <b>보안 권고</b>
1. 즉시 비밀번호를 변경하세요: <code>passwd</code>
2. SSH 키 인증 설정을 권장합니다.
3. 방화벽(UFW)을 활성화하세요.
🎉 서버가 성공적으로 생성되었습니다!`
);
} else {
await editMessageText(
env.BOT_TOKEN,
chatId,
messageId,
`❌ <b>서버 생성 실패</b>
${result.error}
다시 시도하시려면 서버 주문을 요청해주세요.`
);
}
return;
}