245 lines
20 KiB
JavaScript
245 lines
20 KiB
JavaScript
/**
|
|||
|
|
* POP3 서버 (RFC 1939 + RFC 2449 CAPA + RFC 2595 STLS).
|
||
|
|
*
|
||
|
|
* - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음.
|
||
|
|
* - 메일드롭: 받은 메일함(inbox)의 수신 메일 중 이 계정에 아직 전달하지 않은 것.
|
||
|
|
* - 중복 방지: RETR 또는 DELE 한 메일은 QUIT 시점에 pop_deliveries 에 기록되어 같은 계정에 다시 내려가지 않습니다.
|
||
|
|
* 서버의 저장본은 지우지 않으며, 웹 메일함에서 영구 삭제하기 전까지 기한 없이 계속 볼 수 있습니다.
|
||
|
|
* - 평문 포트(110): 995 는 즉시 TLS 이고, 110 은 STLS 뒤에 로그인하는 것이 원칙입니다. 다만 Gmail 의 "다른 계정에서 메일 확인" 처럼
|
||
|
|
* STLS 를 지원하지 않는 클라이언트가 110 으로 USER/PASS 를 바로 보내므로, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이
|
||
|
|
* 켜져 있으면 TLS 없이도 로그인을 받습니다(0.9.7). 꺼져 있으면 예전처럼 `-ERR [SYS/PERM] use STLS or port 995 before login` 으로 거절합니다.
|
||
|
|
* 접속마다 설정을 한 번 읽어(plainOk) CAPA 광고와 로그인 허용에 같은 값을 씁니다.
|
||
|
|
* - 세션 로그: 접속·인증·거부 외에 세션이 끝날 때 한 줄(내려준 통수·삭제 표시·걸린 시간·끝난 이유)을 남겨 "되다말다" 하는 증상을 추적할 수 있게 합니다(0.9.7).
|
||
|
|
*/
|
||
|
|
import net from 'node:net';
|
||
|
|
import tls from 'node:tls';
|
||
|
|
import { verifyAdmin, serviceAllowed } from './auth.mjs';
|
||
|
|
import { serviceEnabled, plainLoginAllowed } from './limits.mjs';
|
||
|
|
import { normalizeCrlf, dotStuff, splitHeader } from './mime.mjs';
|
||
|
|
import { now, memoryRateStore } from './util.mjs';
|
||
|
|
|
||
|
|
const CRLF = '\r\n';
|
||
|
|
/** latin1 로 읽은 줄(문자 = 바이트)을 UTF-8 문자열로 되돌립니다. */
|
||
|
|
const utf8 = (s) => Buffer.from(s, 'latin1').toString('utf8');
|
||
|
|
// user_id -> 그 메일함을 잠근 세션. RFC 1939 의 메일드롭 잠금: 같은 계정이 동시에 두 세션을 열면 두 번째는 [IN-USE] 로 거절합니다.
|
||
|
|
// 잠금은 세션이 끝날 때(QUIT·끊김·오류·시간 초과) 풉니다. 상대가 FIN 없이 사라진 소켓은 10분 유휴 시간 초과로 정리됩니다.
|
||
|
|
const locks = new Map();
|
||
|
|
const rate = memoryRateStore();
|
||
|
|
/** 잠금을 든 세션이 아직 살아 있는지. closed 표시가 없어도 소켓이 이미 파기됐으면(오류 뒤 close 이벤트 지연 등) 잠금을 비어 있는 것으로 봅니다. */
|
||
|
|
const lockHeld = (userId) => { const s = locks.get(userId); return !!s && !s.closed && !s.socket?.destroyed; };
|
||
|
|
|
||
|
|
export async function maildrop(env, userId, mailbox = null) {
|
||
|
|
mailbox = mailbox || String(env.MAILBOX || 'admin@arihakdang.com').trim().toLowerCase();
|
||
|
|
const rows = (await env.DB.prepare(`SELECT m.id, m.raw_size FROM messages m WHERE m.mailbox=? AND m.direction='inbound' AND m.folder='inbox' AND m.raw_status IN ('stored','built')
|
||
|
|
AND NOT EXISTS (SELECT 1 FROM pop_deliveries d WHERE d.message_id=m.id AND d.user_id=?) ORDER BY m.received_at ASC, m.id ASC LIMIT 5000`).bind(mailbox, userId).all()).results;
|
||
|
|
return rows.map((r) => ({ id: r.id, size: Number(r.raw_size) || 0 }));
|
||
|
|
}
|
||
|
|
async function loadEml(env, id) {
|
||
|
|
const row = await env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(id).first();
|
||
|
|
return row ? normalizeCrlf(Buffer.from(row.eml)) : null;
|
||
|
|
}
|
||
|
|
/**
|
||
|
|
* QUIT 시점에 이 세션이 내려준(RETR)·삭제 표시한(DELE) 메일을 pop_deliveries 에 기록합니다.
|
||
|
|
* 세션 중에 웹(영구 삭제)이나 IMAP(휴지통 비우기)으로 이미 지워진 메일은 건너뜁니다(WHERE EXISTS). 예전에는 그런 행 하나가 외래 키 위반을 일으켜
|
||
|
|
* 묶음 전체가 되돌아갔고, QUIT 이 -ERR 로 끝나며 그 세션의 다른 메일도 전부 기록되지 않아 다음 접속에 다시 내려갔습니다(0.9.7).
|
||
|
|
*/
|
||
|
|
export async function commitDeliveries(env, userId, entries) {
|
||
|
|
if (!entries.length) return;
|
||
|
|
const stamp = now();
|
||
|
|
await env.DB.batch(entries.map(([id, via]) => env.DB.prepare('INSERT OR REPLACE INTO pop_deliveries(user_id,message_id,delivered_at,via) SELECT ?,?,?,? WHERE EXISTS(SELECT 1 FROM messages WHERE id=?)').bind(userId, id, stamp, via, id)));
|
||
|
|
}
|
||
|
|
|
||
|
|
class Session {
|
||
|
|
constructor(server, socket, { secure }) {
|
||
|
|
this.server = server; this.env = server.env; this.secure = secure; this.state = 'auth'; this.user = null; this.pendingUser = '';
|
||
|
|
this.messages = []; this.deleted = new Set(); this.retrieved = new Set(); this.failures = 0; this.buffer = ''; this.closed = false;
|
||
|
|
this.startedAt = Date.now(); this.endReason = 'close'; this.summarized = false;
|
||
|
|
// plainOk: 이 접속에서 TLS 없이 로그인을 받을지. TLS 접속이면 항상 참. 평문 접속은 서버 옵션(allowPlaintext 가 true/false 로 고정) 또는
|
||
|
|
// 관리자 설정(plainLoginAllowed)을 따릅니다. 설정 읽기는 비동기라 명령 큐의 첫 작업으로 두어, 첫 명령(CAPA/USER)이 처리될 때는 값이 정해져 있습니다.
|
||
|
|
this.plainOk = secure || server.allowPlaintext === true;
|
||
|
|
this.queue = this.loadPlainLogin();
|
||
|
|
this.ip = socket.remoteAddress || ''; this.attach(socket);
|
||
|
|
console.log(`POP3 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`);
|
||
|
|
this.write(`+OK ${server.hostname} POP3 ready`);
|
||
|
|
}
|
||
|
|
async loadPlainLogin() {
|
||
|
|
if (this.secure || typeof this.server.allowPlaintext === 'boolean') return;
|
||
|
|
try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('POP3 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; }
|
||
|
|
}
|
||
|
|
attach(socket) {
|
||
|
|
this.socket = socket; socket.setTimeout(10 * 60 * 1000);
|
||
|
|
socket.on('data', (chunk) => this.onData(chunk));
|
||
|
|
socket.on('timeout', () => { this.endReason = 'timeout'; this.close('-ERR timeout'); });
|
||
|
|
socket.on('error', (e) => { if (this.endReason === 'close') this.endReason = 'error:' + (e?.code || e?.message || 'unknown'); this.finish(); });
|
||
|
|
socket.on('close', () => this.finish());
|
||
|
|
}
|
||
|
|
write(line) { if (!this.closed) this.socket.write(line + CRLF); }
|
||
|
|
close(line) { if (line) this.write(line); this.closed = true; try { this.socket.end(); } catch {} this.finish(); }
|
||
|
|
/** 세션 종료 공통 처리: 잠금 해제 + 요약 로그 한 줄(로그인한 세션만, 한 번만). close·error·close 이벤트가 겹쳐 불려도 안전합니다. */
|
||
|
|
finish() {
|
||
|
|
if (this.user && locks.get(this.user.id) === this) locks.delete(this.user.id);
|
||
|
|
this.closed = true;
|
||
|
|
if (this.user && !this.summarized) {
|
||
|
|
this.summarized = true;
|
||
|
|
const secs = ((Date.now() - this.startedAt) / 1000).toFixed(1);
|
||
|
|
console.log(`POP3 종료 ${this.ip} user=${this.user.login} 메일 ${this.messages.length}통 중 RETR ${this.retrieved.size}·DELE ${this.deleted.size} ${secs}s (${this.endReason})`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
onData(chunk) {
|
||
|
|
// STLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(아래 설명).
|
||
|
|
if (this.upgrading) return;
|
||
|
|
this.buffer += chunk.toString('latin1');
|
||
|
|
let i;
|
||
|
|
while ((i = this.buffer.indexOf('\n')) >= 0) {
|
||
|
|
const line = this.buffer.slice(0, i).replace(/\r$/, ''); this.buffer = this.buffer.slice(i + 1);
|
||
|
|
if (line.length > 4096) { this.close('-ERR line too long'); return; }
|
||
|
|
this.queue = this.queue.then(() => this.handle(line)).catch((e) => { console.error('POP3 오류', e?.message); this.write('-ERR internal error'); });
|
||
|
|
// STLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STLS 뒤에 붙어 온 명령은 TLS 전환 뒤에 암호화된 명령처럼
|
||
|
|
// 실행될 수 있으므로 버립니다. 클라이언트는 STLS 응답을 받은 뒤에만 다음 명령을 보내야 합니다(RFC 2595).
|
||
|
|
if (/^\s*STLS\s*$/i.test(line)) { this.upgrading = true; this.buffer = ''; return; }
|
||
|
|
}
|
||
|
|
// 줄 길이 검사는 줄 단위로 합니다. 예전에는 쌓인 버퍼 전체가 4KB 를 넘으면 끊어, 파이프라인으로 여러 명령을 보내는 클라이언트가 끊겼습니다.
|
||
|
|
if (this.buffer.length > 4096) this.close('-ERR line too long');
|
||
|
|
}
|
||
|
|
async handle(line) {
|
||
|
|
if (this.closed) return;
|
||
|
|
const [cmd, ...rest] = line.trim().split(/\s+/); const command = (cmd || '').toUpperCase(); const args = rest;
|
||
|
|
if (command === 'QUIT') return this.quit();
|
||
|
|
if (command === 'CAPA') return this.capa();
|
||
|
|
if (command === 'NOOP') return this.write('+OK');
|
||
|
|
if (this.state === 'auth') return this.authCommand(command, args, line);
|
||
|
|
return this.transactionCommand(command, args);
|
||
|
|
}
|
||
|
|
capa() {
|
||
|
|
const caps = ['TOP', 'UIDL', 'RESP-CODES', 'PIPELINING', 'IMPLEMENTATION arihakdang-mail-gateway'];
|
||
|
|
if (this.plainOk) caps.push('USER', 'SASL PLAIN'); // 평문 접속에서 로그인을 안 받으면 USER 를 광고하지 않습니다(RFC 2449)
|
||
|
|
if (!this.secure && this.server.secureContext) caps.push('STLS');
|
||
|
|
this.write('+OK Capability list follows'); for (const c of caps) this.write(c); this.write('.');
|
||
|
|
}
|
||
|
|
async authCommand(command, args, line) {
|
||
|
|
const loginAllowed = this.plainOk; // TLS 접속이면 항상 참, 평문 접속이면 관리자 설정(또는 서버 옵션)
|
||
|
|
if (command === 'STLS') {
|
||
|
|
this.upgrading = false; // 아래에서 거절하면 평문 입력을 다시 받습니다. 전환에 성공하면 새 TLS 소켓으로 받습니다.
|
||
|
|
if (this.secure) return this.write('-ERR already using TLS');
|
||
|
|
if (!this.server.secureContext) return this.write('-ERR TLS not available');
|
||
|
|
this.write('+OK Begin TLS negotiation');
|
||
|
|
const plain = this.socket; plain.removeAllListeners('data'); plain.removeAllListeners('timeout'); plain.removeAllListeners('error'); plain.removeAllListeners('close');
|
||
|
|
const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) });
|
||
|
|
this.secure = true; this.plainOk = true; this.buffer = ''; this.attach(secureSocket); return;
|
||
|
|
}
|
||
|
|
if (!loginAllowed && ['USER', 'PASS', 'AUTH', 'APOP'].includes(command)) { console.log(`POP3 평문 로그인 거부 ${this.ip} (STLS 필요)`); return this.write('-ERR [SYS/PERM] use STLS or port 995 before login'); }
|
||
|
|
// USER/PASS 인자는 줄을 latin1 로 읽었으므로 바이트 그대로입니다. 한글 등 비 ASCII 비밀번호는 UTF-8 로 되돌려야 웹·IMAP·AUTH PLAIN 과 같은 값이 됩니다(0.9.7).
|
||
|
|
// 예전에는 latin1 문자열을 그대로 검사해 비 ASCII 비밀번호가 POP USER/PASS 에서만 항상 틀렸습니다.
|
||
|
|
if (command === 'USER') { this.pendingUser = utf8(args.join(' ')); return this.write('+OK name accepted'); }
|
||
|
|
if (command === 'PASS') { const password = utf8(line.trim().slice(5)); return this.login(this.pendingUser, password); }
|
||
|
|
if (command === 'AUTH') {
|
||
|
|
if ((args[0] || '').toUpperCase() !== 'PLAIN') return this.write('-ERR unsupported mechanism');
|
||
|
|
let payload = args[1];
|
||
|
|
if (!payload) { this.write('+ '); this.awaitingSasl = true; return; }
|
||
|
|
return this.saslPlain(payload);
|
||
|
|
}
|
||
|
|
if (this.awaitingSasl) { this.awaitingSasl = false; if (line.trim() === '*') return this.write('-ERR cancelled'); return this.saslPlain(line.trim()); }
|
||
|
|
return this.write('-ERR unknown command');
|
||
|
|
}
|
||
|
|
saslPlain(payload) {
|
||
|
|
let parts; try { parts = Buffer.from(payload, 'base64').toString('utf8').split('\0'); } catch { return this.write('-ERR invalid'); }
|
||
|
|
if (parts.length < 3) return this.write('-ERR invalid');
|
||
|
|
return this.login(parts[1], parts[2]);
|
||
|
|
}
|
||
|
|
async login(login, password) {
|
||
|
|
if (rate.blocked('pop:' + this.ip, { limit: 20 })) return this.close('-ERR [AUTH] too many attempts');
|
||
|
|
// 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다.
|
||
|
|
const env = this.server.resolveEnv ? this.server.resolveEnv(login, sniName(this.socket)) : this.env;
|
||
|
|
const user = env ? await verifyAdmin(env, login, password) : null;
|
||
|
|
if (user) this.env = env;
|
||
|
|
if (!user) rate.hit('pop:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다(성공한 접속은 제한에 들지 않음)
|
||
|
|
console.log(`POP3 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login).slice(0, 80)}${this.secure ? '' : ' (plain)'}`);
|
||
|
|
if (!user) {
|
||
|
|
this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500));
|
||
|
|
if (this.failures >= 3) return this.close('-ERR [AUTH] authentication failed');
|
||
|
|
return this.write('-ERR [AUTH] authentication failed');
|
||
|
|
}
|
||
|
|
// 관리자가 POP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다.
|
||
|
|
if (!(await serviceEnabled(this.env, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (관리자가 POP 서비스를 껐음)`); return this.close('-ERR [SYS/PERM] POP service is disabled by the administrator'); }
|
||
|
|
// 계정 설정에서 POP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~). 비밀번호 오류와 구분되는 문구를 돌려줍니다.
|
||
|
|
if (!(await serviceAllowed(this.env, user, 'pop'))) { console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 POP 사용 안 함)`); return this.close('-ERR [SYS/PERM] POP access is disabled for this account'); }
|
||
|
|
// 같은 계정의 다른 세션이 아직 열려 있으면 거절합니다. 예전에는 로그가 없어 "비밀번호는 맞는데 가끔 실패" 로 보였습니다(0.9.7 부터 기록).
|
||
|
|
if (lockHeld(user.id)) { const other = locks.get(user.id); console.log(`POP3 접속 거부 ${this.ip} user=${user.login} (다른 세션 ${other.ip} 이 ${Math.round((Date.now() - other.startedAt) / 1000)}s 전부터 메일함 사용 중)`); return this.write('-ERR [IN-USE] mailbox is locked by another session'); }
|
||
|
|
locks.set(user.id, this); this.user = user; this.state = 'transaction';
|
||
|
|
this.messages = await maildrop(this.env, user.id, user.mailbox);
|
||
|
|
this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`);
|
||
|
|
}
|
||
|
|
total() { return this.messages.reduce((n, m, i) => this.deleted.has(i) ? n : n + m.size, 0); }
|
||
|
|
index(arg) { const n = Number(arg); if (!Number.isInteger(n) || n < 1 || n > this.messages.length || this.deleted.has(n - 1)) return -1; return n - 1; }
|
||
|
|
async transactionCommand(command, args) {
|
||
|
|
const live = () => this.messages.length - this.deleted.size;
|
||
|
|
if (command === 'STAT') return this.write(`+OK ${live()} ${this.total()}`);
|
||
|
|
if (command === 'LIST' || command === 'UIDL') {
|
||
|
|
const value = (m) => command === 'LIST' ? m.size : m.id;
|
||
|
|
if (args[0] !== undefined) { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); return this.write(`+OK ${i + 1} ${value(this.messages[i])}`); }
|
||
|
|
this.write(`+OK ${live()} messages (${this.total()} octets)`);
|
||
|
|
this.messages.forEach((m, i) => { if (!this.deleted.has(i)) this.write(`${i + 1} ${value(m)}`); }); return this.write('.');
|
||
|
|
}
|
||
|
|
if (command === 'RETR' || command === 'TOP') {
|
||
|
|
const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message');
|
||
|
|
const eml = await loadEml(this.env, this.messages[i].id); if (!eml) return this.write('-ERR message unavailable');
|
||
|
|
let out = eml;
|
||
|
|
if (command === 'TOP') { const lines = Number(args[1]); if (!Number.isInteger(lines) || lines < 0) return this.write('-ERR bad argument'); const { header, body } = splitHeader(eml); const bodyLines = body.toString('latin1').split(CRLF).slice(0, lines).join(CRLF); out = Buffer.concat([header, Buffer.from(CRLF + bodyLines, 'latin1')]); }
|
||
|
|
const data = dotStuff(out);
|
||
|
|
this.write(`+OK ${data.length} octets`);
|
||
|
|
if (!this.closed) this.socket.write(data);
|
||
|
|
if (!data.length || !data.subarray(-2).equals(Buffer.from(CRLF))) this.write('');
|
||
|
|
this.write('.');
|
||
|
|
if (command === 'RETR') this.retrieved.add(i);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (command === 'DELE') { const i = this.index(args[0]); if (i < 0) return this.write('-ERR no such message'); this.deleted.add(i); return this.write(`+OK message ${i + 1} deleted`); }
|
||
|
|
if (command === 'RSET') { this.deleted.clear(); return this.write(`+OK maildrop has ${this.messages.length} messages (${this.total()} octets)`); }
|
||
|
|
if (command === 'LAST') { return this.write(`+OK ${Math.max(0, ...[...this.retrieved].map((i) => i + 1))}`); }
|
||
|
|
return this.write('-ERR unknown command');
|
||
|
|
}
|
||
|
|
async quit() {
|
||
|
|
this.endReason = 'quit';
|
||
|
|
if (this.state === 'transaction' && this.user) {
|
||
|
|
const entries = [];
|
||
|
|
for (const i of new Set([...this.retrieved, ...this.deleted])) entries.push([this.messages[i].id, this.deleted.has(i) ? 'dele' : 'retr']);
|
||
|
|
try { await commitDeliveries(this.env, this.user.id, entries); } catch (e) { console.error('POP 전달 기록 실패', e.message); return this.close('-ERR some deleted messages not removed'); }
|
||
|
|
}
|
||
|
|
this.close(`+OK ${this.server.hostname} signing off`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions 인증서 PEM. 갱신 시 setTls 로 교체합니다.
|
||
|
|
* @param {boolean|null} allowPlaintext 평문 포트의 TLS 없는 로그인: true 면 항상 허용, false 면 항상 거부(테스트·진단), null(기본)이면
|
||
|
|
* 접속마다 관리자 설정 "평문 로그인 허용" 을 읽습니다(0.9.7). index.mjs 는 POP_ALLOW_PLAINTEXT=1 일 때만 true 를, 그 밖에는 null 을 넘깁니다.
|
||
|
|
*/
|
||
|
|
/** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 도메인 없이 로그인한 사용자가 어느 도메인의 사용자인지 짐작하는 데 씁니다(1.0.7). */
|
||
|
|
const sniName = (socket) => String(socket?.servername || '');
|
||
|
|
/** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작(단일 도메인). */
|
||
|
|
export function createPop3(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) {
|
||
|
|
const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv };
|
||
|
|
const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); });
|
||
|
|
// secure 서버는 인증서가 없어도 만들어 둡니다(핸드셰이크는 setSecureContext 뒤에만 성공). 인증서가 없으면 포트를 열지 않고 기억만 합니다(아래 pendingSecure).
|
||
|
|
const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); });
|
||
|
|
secure.on('tlsClientError', () => {});
|
||
|
|
let pendingSecure = null;
|
||
|
|
// 0.9.7: 인증서가 시작 때 없거나 읽히지 않았으면 TLS 포트를 바로 열 수 없으므로 포트를 기억해 두고(pendingSecure), 시간마다 돌아오는 setTls 가
|
||
|
|
// 인증서를 넣을 때 그 포트를 엽니다. 예전에는 secure 서버를 만들지 않아 setTls 가 아무 일도 하지 않았고, 재시작 전까지 TLS 포트가 닫힌 채였습니다.
|
||
|
|
const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); }));
|
||
|
|
return {
|
||
|
|
plain, secure,
|
||
|
|
setTls(options) {
|
||
|
|
shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options);
|
||
|
|
if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'pop3s', []).then(() => console.log(` POP3 TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' POP3 TLS 포트 열기 실패:', e.message)); }
|
||
|
|
},
|
||
|
|
async listen({ plainPort, securePort, host = '0.0.0.0' }) {
|
||
|
|
const out = [];
|
||
|
|
if (plainPort) await listenOn(plain, plainPort, host, 'pop3', out);
|
||
|
|
if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'pop3s', out); else pendingSecure = { port: securePort, host }; }
|
||
|
|
return out;
|
||
|
|
},
|
||
|
|
close() { plain.close(); if (secure.listening) secure.close(); },
|
||
|
|
};
|
||
|
|
}
|