Files

228 lines
21 KiB
JavaScript
Raw Permalink Normal View History

/**
* 메일 사용량 정보 제공 API(1.0.9, 요구사항 1) — 웹메이커 같은 외부 관리 시스템이 디스크 사용량·발송량 등을 통합 관리할 수 있게 읽기 전용 통계를 내줍니다.
*
* 주소(어느 호스트로 와도 같음, nginx 뒤 HTTPS):
* GET /api/usage 모든 도메인의 요약(기본 도메인 먼저)
* GET /api/usage/<도메인> 그 도메인의 상세(기본으로 계정 목록·일별 추이 포함)
* 쿼리: days=30(일별 추이·계정별 기간 통수의 기간, 1~366) · accounts=0|1(계정 목록 포함, 요약은 기본 0·상세는 기본 1) · series=0|1(일별 추이, 상세는 기본 1)
*
* 보안(요구사항: webmaker 의 해당 기능에서만 접근할 수 있게)
* 1) 비밀 키: 전체 설정 화면의 "사용량 API" 카드에서 만든 키(USAGE_API_KEY, 기본 도메인의 설정 파일에만 저장, 32바이트 난수)를 `Authorization: Bearer <키>`(또는 X-Usage-Key)로
* 보내야 합니다. 키가 없으면 API 자체가 꺼진 상태(404)이고, 비교는 상수 시간(safeEqual)입니다. 키는 만들 때 한 번만 보여 주며 설정 화면에는 앞뒤 일부만 보입니다. 언제든 다시 만들거나(교체) 지울 수 있습니다.
* 2) 접속 IP 제한(선택): USAGE_API_ALLOW 에 웹메이커 서버의 IP 나 CIDR(쉼표 구분)을 적으면 그 밖의 주소는 키가 맞아도 403. nginx 가 넘긴 X-Forwarded-For 의 첫 주소(또는 CF-Connecting-IP)를 봅니다.
* 3) 시도 제한: 키가 틀린 요청은 IP 마다 15분에 10회(그 뒤 429), 성공 요청도 IP 마다 1분에 120회(무차별 조회 방지). 모두 메모리 제한(env.RATE)입니다.
* 4) 읽기 전용·비밀값 없음: 메일 내용·주소록·비밀번호·API 키는 절대 들어가지 않고, 수(통·바이트)·제한·계정 이름(로그인 ID·메일함 주소·역할·상태)만 나갑니다. 호출은 감사 기록(audit_log)에 남습니다.
*
* 응답(도메인 하나)
* domain, primary, enabled, hosts, authMode, mailbox, inbound, outbound, timezone, generatedAt
* accounts : { local, external, total, limit, percent } — 게이트웨이 계정 수 / 웹메이커 등 외부 계정 수 / 시스템 제한(0 = 없음)
* storage : { bytes, limit, percent, largeFiles:{count, bytes, sent} } — 모든 메일함의 사용량(MIME 기준, 원본 .eml 크기) / 대용량 첨부파일(별도 저장)
* messages : { total, inbound, outbound, drafts, failed } — 저장된 메일 수(폴더 무관, 휴지통 포함; 영구 삭제한 것은 없음)
* traffic : { day, week, month: { sent, received, total, sentBytes, receivedBytes, start, end }, monthlyLimit, monthlyPercent } — 통계 시간대 기준(오늘·이번 주(월~)·이달)
* daily : [ { date:'YYYY-MM-DD', sent, received, sentBytes, receivedBytes } ] (최근 days 일, 오래된 날부터)
* accountList : [ { login, name, mailbox, role, source:'local'|'www'|'webmaker', status, messages, bytes, limit, percent, lastLoginAt, sent, received, sentBytes, receivedBytes } ] (기간 = days)
* limits : { system:{…}, admin:{…} } — 설치 시 시스템 제한과 관리자 제한(안내용)
*/
import { json, bad, safeEqual, now } from './util.mjs';
import { systemLimits, readAdminLimits, mailboxUsage, mailboxQuota, totalUsage, accountCount, statsTimezone, periodBounds, countMail, percentOf, limitState, statsNow } from './limits.mjs';
import { listUsers, externalUsers, authMode, audit, mailboxOwner, standalone } from './auth.mjs';
import { largeFileStats } from './files.mjs';
import { inboundMode, outboundMode } from './providers/index.mjs';
import { mailDomain } from './config.mjs';
export const USAGE_FAIL_LIMIT = 10, USAGE_RATE_LIMIT = 120;
const DAY_MS = 86400000;
// ------------------------------------------------------------------ 접속 IP 허용 목록
/** "1.2.3.4, 10.0.0.0/8, 2001:db8::1" 같은 문자열을 항목 배열로. 빈 문자열이면 제한 없음(빈 배열). */
export function parseAllowList(text) {
return String(text || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
}
const ipv4ToInt = (ip) => { const m = String(ip).match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (!m) return null; const parts = m.slice(1).map(Number); if (parts.some((n) => n > 255)) return null; return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; };
/** IPv6 주소를 소문자 전개형(8그룹 16진수)으로. 잘못된 주소는 null. "::ffff:1.2.3.4" 꼴은 IPv4 로 비교하도록 호출자가 먼저 벗깁니다. */
function expandIpv6(ip) {
const s = String(ip).toLowerCase(); if (!/^[0-9a-f:]+$/.test(s) || s.split('::').length > 2) return null;
const [head, tail = ''] = s.split('::'); const h = head ? head.split(':') : [], t = tail ? tail.split(':') : [];
if (h.length + t.length > 8 || (s.indexOf('::') < 0 && h.length !== 8)) return null;
const groups = [...h, ...Array(8 - h.length - t.length).fill('0'), ...t];
if (groups.some((g) => !/^[0-9a-f]{1,4}$/.test(g))) return null;
return groups.map((g) => parseInt(g, 16));
}
/**
* 접속 IP 가 허용 목록 안인지. 목록이 비어 있으면 모두 허용. IPv4 는 CIDR(/8~/32) 지원, IPv6 는 정확히 같은 주소 또는 /n 접두사.
* IPv4 주소가 "::ffff:1.2.3.4" 로 들어오면(IPv6 소켓) IPv4 로 봅니다.
*/
export function ipAllowed(ip, list) {
if (!list || !list.length) return true;
let addr = String(ip || '').trim().toLowerCase(); if (addr.startsWith('::ffff:') && addr.includes('.')) addr = addr.slice(7);
const v4 = ipv4ToInt(addr), v6 = v4 === null ? expandIpv6(addr) : null;
if (v4 === null && !v6) return false;
for (const entry of list) {
const [net, bitsRaw] = entry.split('/'); const bits = bitsRaw === undefined ? null : Number(bitsRaw);
if (v4 !== null) {
const n = ipv4ToInt(net); if (n === null) continue;
const b = bits === null ? 32 : bits; if (!Number.isInteger(b) || b < 0 || b > 32) continue;
const mask = b === 0 ? 0 : (~0 << (32 - b)) >>> 0; if (((v4 & mask) >>> 0) === ((n & mask) >>> 0)) return true;
} else {
const n = expandIpv6(net); if (!n) continue;
const b = bits === null ? 128 : bits; if (!Number.isInteger(b) || b < 0 || b > 128) continue;
let ok = true; for (let i = 0; i < 8 && ok; i++) { const take = Math.max(0, Math.min(16, b - i * 16)); if (!take) break; const m = take === 16 ? 0xffff : (0xffff << (16 - take)) & 0xffff; if ((v6[i] & m) !== (n[i] & m)) ok = false; }
if (ok) return true;
}
}
return false;
}
/**
* 허용 목록 항목 검사: 각 항목이 IPv4[/0~32] 또는 IPv6[/0~128] 이어야 합니다. 잘못된 항목 목록을 돌려줍니다(비어 있으면 통과).
* 저장 때 검사하지 않으면 오타 하나로 모든 요청이 403 이 되고 아무 데서도 알 수 없습니다.
*/
export function invalidAllowEntries(text) {
const bad = [];
for (const entry of parseAllowList(text)) {
const [net, bitsRaw, extra] = entry.split('/'); if (extra !== undefined) { bad.push(entry); continue; }
const v4 = ipv4ToInt(net), v6 = v4 === null ? expandIpv6(net) : null; if (v4 === null && !v6) { bad.push(entry); continue; }
if (bitsRaw !== undefined) { const b = Number(bitsRaw); if (!/^\d{1,3}$/.test(bitsRaw) || b > (v4 !== null ? 32 : 128)) bad.push(entry); }
}
return bad;
}
/**
* 요청의 접속 IP(허용 목록·시도 제한용). 클라이언트가 마음대로 넣을 수 있는 헤더를 믿지 않습니다:
* 1) X-Real-IP(nginx 템플릿 1.0.9 부터 $remote_addr 로 넣음) → 2) X-Forwarded-For 의 **마지막** 주소(nginx 가 $proxy_add_x_forwarded_for 로 실제 접속 주소를 끝에 덧붙임;
* 첫 주소는 클라이언트가 보낸 값일 수 있음) → 3) 소켓 주소. CF-Connecting-IP 는 Cloudflare 뒤에서만 참이므로 USAGE_TRUST_CF=1 일 때만 봅니다.
*/
export function clientIp(headers, remote = '') {
const get = (k) => String(headers.get(k) || '').trim();
if (process.env.USAGE_TRUST_CF === '1' && get('cf-connecting-ip')) return get('cf-connecting-ip');
if (get('x-real-ip')) return get('x-real-ip');
const xff = get('x-forwarded-for').split(',').map((s) => s.trim()).filter(Boolean);
if (xff.length) return xff[xff.length - 1];
return String(remote || '').trim();
}
// ------------------------------------------------------------------ 인증
/**
* 사용량 API 인증. 키는 기본 도메인(primaryEnv)의 설정에서만 읽습니다(도메인별 키가 아니라 서버 하나에 한 키 — 웹메이커가 모든 도메인을 한 번에 봄).
* @returns {{ok:true}|{ok:false, status:number, error:string}}
*/
export function authorizeUsage(primaryEnv, request, ip) {
const key = String(primaryEnv.USAGE_API_KEY || '').trim();
if (!key) return { ok: false, status: 404, error: 'usage_api_disabled' };
const rate = primaryEnv.RATE;
if (rate?.blocked('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT })) return { ok: false, status: 429, error: 'rate_limited' };
const auth = request.headers.get('authorization') || '';
const given = (auth.match(/^Bearer\s+(.+)$/i)?.[1] || request.headers.get('x-usage-key') || '').trim();
if (!given || !safeEqual(given, key)) { rate?.hit('usage-fail:' + ip, { limit: USAGE_FAIL_LIMIT, windowSec: 900 }); return { ok: false, status: 401, error: 'unauthenticated' }; }
if (!ipAllowed(ip, parseAllowList(primaryEnv.USAGE_API_ALLOW))) return { ok: false, status: 403, error: 'forbidden_ip' };
// 성공 요청 빈도(IP 마다 1분 120회). hit() 가 false 를 돌려주면 한도를 넘은 것.
if (rate && !rate.hit('usage:' + ip, { limit: USAGE_RATE_LIMIT, windowSec: 60 })) return { ok: false, status: 429, error: 'rate_limited' };
return { ok: true };
}
// ------------------------------------------------------------------ 통계
/** 통계 시간대 기준의 날짜 문자열(YYYY-MM-DD). */
const dateKey = (iso, tz) => { try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso)); } catch { return String(iso).slice(0, 10); } };
/**
* 최근 days 일의 일별 수·발신 통수와 바이트, 메일함별 기간 합계. 한 번의 조회로 기간 안의 메일(발신은 발송 완료 시각, 수신은 받은 시각)을 읽어 JS 에서 날짜별로 묶습니다
* (시간대 변환을 SQL 로 하기 어렵고, 메일 수가 많지 않아 충분히 빠름). 기간 시작은 오늘 0시(통계 시간대)에서 days-1 일을 뺀 시각입니다.
*/
async function dailySeries(env, days, tz) {
// 시작 = (오늘 0시 - (days-1)일) 이 속한 날의 0시. 24시간 단위로 빼기만 하면 서머타임 경계에서 한 시간이 밀리므로 그 날의 정오를 잡아 다시 0시를 구합니다.
const today = periodBounds('day', tz, statsNow(env)); const approx = Date.parse(today.start) - (days - 1) * DAY_MS;
const start = periodBounds('day', tz, new Date(approx + DAY_MS / 2)).start, startMs = Date.parse(start), end = today.end;
const rows = (await env.DB.prepare(`SELECT direction, mailbox, raw_size, sent_at, received_at FROM messages WHERE (direction='inbound' AND received_at>=? AND received_at<?) OR (direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?)`).bind(start, end, start, end).all()).results;
const byDate = new Map(); for (let i = 0; i < days; i++) { const d = dateKey(new Date(startMs + i * DAY_MS + DAY_MS / 2).toISOString(), tz); byDate.set(d, { date: d, sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }); }
const byMailbox = new Map();
for (const r of rows) {
const at = r.direction === 'outbound' ? r.sent_at : r.received_at; const d = dateKey(at, tz); const bucket = byDate.get(d); const size = Number(r.raw_size || 0);
const mb = byMailbox.get(r.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; byMailbox.set(r.mailbox, mb);
if (r.direction === 'outbound') { if (bucket) { bucket.sent++; bucket.sentBytes += size; } mb.sent++; mb.sentBytes += size; }
else { if (bucket) { bucket.received++; bucket.receivedBytes += size; } mb.received++; mb.receivedBytes += size; }
}
return { start, end, daily: [...byDate.values()], byMailbox };
}
/** 기간 통계에 바이트 합계를 더한 countMail. */
async function periodTraffic(env, bounds) {
const c = await countMail(env, bounds);
const s = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='outbound' AND state='sent' AND sent_at>=? AND sent_at<?").bind(bounds.start, bounds.end).first();
const r = await env.DB.prepare("SELECT COALESCE(SUM(raw_size),0) AS b FROM messages WHERE direction='inbound' AND received_at>=? AND received_at<?").bind(bounds.start, bounds.end).first();
return { ...c, sentBytes: Number(s?.b || 0), receivedBytes: Number(r?.b || 0) };
}
/**
* 도메인(테넌트 env) 하나의 사용량 보고서.
* @param {object} o days: 일별 추이·계정별 기간의 길이, accounts: 계정 목록 포함 여부, series: 일별 추이 포함 여부, entry: 등록부 항목(hosts·enabled·primary)
*/
export async function domainUsage(env, { days = 30, accounts = true, series = true, entry = null } = {}) {
const tz = statsTimezone(env), sys = systemLimits(env), admin = await readAdminLimits(env);
const counts = await env.DB.prepare("SELECT COUNT(*) total, SUM(CASE WHEN direction='inbound' THEN 1 ELSE 0 END) inbound, SUM(CASE WHEN direction='outbound' AND state='sent' THEN 1 ELSE 0 END) outbound, SUM(CASE WHEN state IN ('draft','sending') THEN 1 ELSE 0 END) drafts, SUM(CASE WHEN state='failed' THEN 1 ELSE 0 END) failed FROM messages").first();
const total = await totalUsage(env), local = await accountCount(env), ext = await externalUsers(env);
const traffic = {}; for (const kind of ['day', 'week', 'month']) traffic[kind] = await periodTraffic(env, periodBounds(kind, tz, statsNow(env)));
const limits = await limitState(env); // [Ariweb Suite 변경] 경고 구간·제한 사유
const out = {
domain: mailDomain(env), primary: !env.TENANTS || env.TENANTS.primary.env === env, enabled: entry ? entry.enabled !== false : true, hosts: entry?.hosts || [env.PUBLIC_HOST].filter(Boolean),
authMode: authMode(env), mailbox: String(env.MAILBOX || ''), inbound: inboundMode(env), outbound: outboundMode(env), timezone: tz, generatedAt: now(),
accounts: { local, external: ext.accounts.length, total: local + ext.accounts.length, limit: sys.accounts, percent: percentOf(local, sys.accounts), externalSource: ext.accounts.length || authMode(env) === 'webmaker' ? { kind: authMode(env), ok: ext.ok !== false, error: ext.error || null } : null },
storage: { bytes: total, limit: sys.totalBytes, percent: percentOf(total, sys.totalBytes), largeFiles: await largeFileStats(env) },
messages: { total: Number(counts?.total || 0), inbound: Number(counts?.inbound || 0), outbound: Number(counts?.outbound || 0), drafts: Number(counts?.drafts || 0), failed: Number(counts?.failed || 0) },
traffic: { ...traffic, monthlyLimit: sys.monthlyMessages, monthlyPercent: percentOf(traffic.month.total, sys.monthlyMessages), countingRule: 'per_final_recipient', monthReset: limits.monthReset },
limits: { system: sys, admin },
// [Ariweb Suite 변경] 포털 표시용: 사이트 대표/독립 구분, 운영 상태, 경고 구간(0/70/80/90/95/100), 제한 사유.
ariweb: entry?.ariweb || env.TENANT?.ariweb || null, operating: limits.operating, warnLevel: limits.warnLevel, warnLevels: limits.levels, limitedReasons: limits.limitedReasons, measuredAt: limits.measuredAt,
};
if (series || accounts) {
const d = await dailySeries(env, days, tz);
out.period = { days, start: d.start, end: d.end };
if (series) out.daily = d.daily;
if (accounts) {
const rows = [];
const push = async (u) => { const bytes = await mailboxUsage(env, u.mailbox), limit = await mailboxQuota(env, u.mailbox), n = await env.DB.prepare('SELECT COUNT(*) AS n FROM messages WHERE mailbox=?').bind(u.mailbox).first(); const t = d.byMailbox.get(u.mailbox) || { sent: 0, received: 0, sentBytes: 0, receivedBytes: 0 }; rows.push({ login: u.login, name: u.name || '', mailbox: u.mailbox, role: u.role, source: u.source, status: u.status, messages: Number(n?.n || 0), bytes, limit, percent: percentOf(bytes, limit), lastLoginAt: u.lastLoginAt || null, ...t }); };
for (const u of await listUsers(env)) await push({ ...u, source: 'local', lastLoginAt: u.last_login_at });
for (const a of ext.accounts) await push({ ...a, source: 'webmaker' });
// www 연동의 기본 메일함(관리자 계정 행이 없음): 메일함 주인(www 관리자) 이름으로 한 행.
if (!standalone(env) && !rows.some((r) => r.mailbox === String(env.MAILBOX || '').toLowerCase())) { let owner = null; try { owner = mailboxOwner(env); } catch { owner = null; } await push({ login: owner?.login_id || owner?.email || 'admin', name: owner?.name || '', mailbox: String(env.MAILBOX || '').toLowerCase(), role: 'admin', source: 'www', status: owner?.status || 'active', lastLoginAt: null }); }
// 계정 행이 없는데 메일이 있는 메일함(지운 계정): 주소만으로 한 행(사용량이 사라지지 않게). 메일이 있는 메일함 목록은 messages 표에서 직접 뽑습니다.
const withMail = (await env.DB.prepare('SELECT mailbox, COUNT(*) AS n FROM messages GROUP BY mailbox').all()).results;
for (const m of withMail) if (m.n && !rows.some((r) => r.mailbox === m.mailbox)) await push({ login: null, name: '', mailbox: m.mailbox, role: null, source: 'orphan', status: 'deleted', lastLoginAt: null });
out.accountList = rows;
}
}
return out;
}
// ------------------------------------------------------------------ HTTP 처리(main.mjs 가 /api/usage 로 부름)
const clampDays = (v) => { const n = Number(v); return Number.isInteger(n) && n >= 1 && n <= 366 ? n : 30; };
const flag = (v, d) => (v === undefined || v === null || v === '' ? d : !(v === '0' || v === 'false' || v === 'no'));
/**
* GET /api/usage[/<도메인>]. 인증·IP·빈도 검사 뒤 보고서를 JSON 으로 돌려줍니다. 감사 기록은 기본 도메인 DB 에 남깁니다.
* @param {object} tenants domains.mjs 의 테넌트 관리자
* @param {Request} request
* @param {{path:string[], ip:string, query:URLSearchParams}} o path: 'usage' 뒤의 조각
*/
export async function usageApi(tenants, request, { path = [], ip = '', query = new URLSearchParams() } = {}) {
const primary = tenants.primary.env;
if (request.method !== 'GET') return bad('method_not_allowed', 405);
const auth = authorizeUsage(primary, request, ip);
// 키가 없어 꺼진 상태(404)만 빼고 모든 거부(틀린 키·허용 밖 IP·빈도 초과)를 기록합니다 — 맞는 키로 허용 밖에서 부르는 것이 가장 중요한 신호.
if (!auth.ok) { if (auth.status !== 404) console.warn(`사용량 API 거부 ${ip}: ${auth.error}(${auth.status})`); return bad(auth.error, auth.status); }
const days = clampDays(query.get('days'));
const headers = { 'Cache-Control': 'no-store' };
let domain = ''; try { domain = path[0] ? decodeURIComponent(path[0]).trim().toLowerCase() : ''; } catch { return bad('domain_not_found', 404); }
try {
if (domain) {
const t = tenants.byDomain(domain); if (!t) return bad('domain_not_found', 404);
const report = await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), true), series: flag(query.get('series'), true), entry: t.entry });
await audit(primary, 'usage-api', 'usage_read', domain, { ip, days });
return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), ...report }, 200, headers);
}
// 도메인 하나가 깨져도(DB 오류 등) 나머지 도메인의 수치는 돌려줍니다. 깨진 도메인은 { domain, error } 로 표시하고 합계에서는 뺍니다.
const domains = [];
for (const t of tenants.all()) {
try { domains.push(await domainUsage(t.env, { days, accounts: flag(query.get('accounts'), false), series: flag(query.get('series'), false), entry: t.entry })); }
catch (e) { console.error(`사용량 API 도메인 오류(${t.entry.domain})`, e.message); domains.push({ domain: t.entry.domain, primary: t.entry.domain === tenants.registry.primary, enabled: t.entry.enabled !== false, error: 'server_error' }); }
}
await audit(primary, 'usage-api', 'usage_read', null, { ip, days, domains: domains.length });
const sum = (k, f) => domains.reduce((n, d) => n + (d.error ? 0 : f(d)), 0);
return json({ ok: true, version: primary.APP_VERSION || null, generatedAt: now(), primary: tenants.registry.primary, totals: { domains: domains.length, accounts: sum('a', (d) => d.accounts.total), storageBytes: sum('s', (d) => d.storage.bytes), largeFileBytes: sum('l', (d) => d.storage.largeFiles.bytes), messages: sum('m', (d) => d.messages.total), sentMonth: sum('sm', (d) => d.traffic.month.sent), receivedMonth: sum('rm', (d) => d.traffic.month.received) }, domains }, 200, headers);
} catch (e) { console.error('사용량 API 오류', e.message); return bad('server_error', 500); }
}