/** * 게이트웨이 계정 명령줄 도구(0.8.18~). * * 독립 실행(www 사이트 DB 없이 설치)에서는 관리자 페이지에 들어갈 첫 관리자 계정을 화면에서 만들 수 없습니다 * (관리자 페이지는 관리자 로그인이 있어야 열리므로). 이 도구가 그 첫 계정을 만들고, 이후에도 비밀번호 재설정 같은 * 운영 작업을 서버 셸에서 할 수 있게 합니다. 서비스가 돌고 있는 중에도 쓸 수 있습니다(SQLite WAL, 짧은 쓰기). * * 사용법(서비스 계정으로, 환경 파일을 읽어서): * DB_FILE=... MAILBOX=admin@example.com node server/user-cli.mjs add <로그인ID> [--admin] [--name "이름"] * DB_FILE=... node server/user-cli.mjs passwd <로그인ID> * DB_FILE=... node server/user-cli.mjs list * * 비밀번호는 (1) 환경 변수 PASSWORD, (2) --password-stdin(표준 입력 한 줄), (3) 단말 입력(글자 숨김) 순으로 받습니다. * 비밀번호 규칙은 관리자 페이지의 비밀번호 정책(기본 6자 이상)을 따릅니다(0.8.24~). 독립 실행의 첫 관리자는 서버가 시작할 때 admin / adminpasswd 로 자동 생성되므로 이 도구는 추가 계정·비밀번호 재설정에 씁니다. * 계정 수 제한(LIMIT_ACCOUNTS, 0.9.0)은 환경 변수로 주면 여기서도 적용됩니다(서비스의 환경 파일을 그대로 넘기면 됨). * 명령줄 인수로는 받지 않습니다(ps·셸 이력에 남기 때문). * MAILBOX 는 계정의 메일함 주소(로그인ID@도메인)를 정하는 데 필요하므로 add 때는 꼭 서비스와 같은 값을 주세요. */ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { createInterface } from 'node:readline'; import { openDb, d1, migrate } from './db.mjs'; import { createUser, updateUser, listUsers, mailDomain, readPasswordPolicy } from './auth.mjs'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const [command, login, ...rest] = process.argv.slice(2); const flag = (name) => rest.includes(name); const option = (name) => { const i = rest.indexOf(name); return i >= 0 ? rest[i + 1] : undefined; }; function usage(code = 1) { console.error('사용법: node server/user-cli.mjs add [--admin] [--name 이름] | passwd | list\n비밀번호: 환경 변수 PASSWORD, --password-stdin, 또는 단말 입력'); process.exit(code); } /** 표준 입력 전체(첫 줄)를 비밀번호로 읽습니다 — 스크립트에서 `printf '%s' "$PW" | node ... --password-stdin` 형태로 씁니다. */ async function readStdinLine() { let data = ''; for await (const chunk of process.stdin) data += chunk; return data.split(/\r?\n/)[0]; } /** 단말에서 글자를 숨기고 비밀번호를 받습니다. 단말이 아니면(파이프) 표준 입력 한 줄을 읽습니다. */ async function prompt(label) { if (!process.stdin.isTTY) return readStdinLine(); const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true }); // 입력 글자를 화면에 쓰지 않도록 출력 함수를 가로챕니다. 질문 글은 먼저 직접 써 둡니다. process.stdout.write(label); rl._writeToOutput = () => {}; const answer = await new Promise((resolve) => rl.question('', resolve)); rl.close(); process.stdout.write('\n'); return answer; } /** 정책을 한 줄 안내문으로: "6자 이상, 대·소문자 혼용, 숫자 포함, 특수문자 포함" */ function policyText(p) { return [`${p.minLength}자 이상`, p.mixedCase && '대·소문자 혼용', p.digit && '숫자 포함', p.special && '특수문자 포함'].filter(Boolean).join(', '); } async function password() { if (process.env.PASSWORD) return process.env.PASSWORD; if (flag('--password-stdin')) return readStdinLine(); const a = await prompt(`비밀번호(${policyText(await readPasswordPolicy(env))}): `); if (process.stdin.isTTY) { const b = await prompt('비밀번호 확인: '); if (a !== b) { console.error('두 입력이 다릅니다'); process.exit(1); } } return a; } if (!command || !['add', 'passwd', 'list'].includes(command) || (command !== 'list' && !login)) usage(); const file = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite'); const db = openDb(file); migrate(db, join(ROOT, 'migrations')); const env = { ...process.env, DB: d1(db) }; try { if (command === 'list') { for (const u of await listUsers(env)) console.log(`${u.login}\t${u.mailbox}\t${u.role}\t${u.status}\t${u.name || ''}`); } else if (command === 'add') { const id = await createUser(env, { login, name: option('--name') || '', password: await password(), role: flag('--admin') ? 'admin' : 'user' }, 'user-cli'); console.log(`계정을 만들었습니다: ${login} → ${login}@${mailDomain(env)} (${flag('--admin') ? '관리자' : '사용자'}, id ${id})`); } else { const row = db.prepare('SELECT id FROM mail_users WHERE login = ?').get(String(login).toLowerCase()); if (!row) { console.error(`계정이 없습니다: ${login}`); process.exit(1); } await updateUser(env, row.id, { password: await password() }); console.log(`비밀번호를 바꿨습니다: ${login} (웹 세션은 모두 끝났습니다)`); } } catch (e) { // createUser/updateUser 의 오류 코드를 사람이 읽을 말로 바꿉니다. const messages = { invalid_login: '로그인 ID 는 영문 소문자·숫자·._- 2~64자여야 하고(기본 메일함 별칭은 독립 실행에서만 가능)', weak_password: `비밀번호가 정책에 맞지 않습니다(${e.policy ? policyText(e.policy) : '글자 수 부족'})`, login_taken: '이미 있는 로그인 ID 입니다', account_limit_reached: `계정 수가 시스템 제한(LIMIT_ACCOUNTS=${e.limit})에 이르렀습니다` }; console.error('실패:', messages[e.message] || e.message); process.exitCode = 1; } finally { db.close(); }