ma7engine: ядро, шаблон панели, скаффолдер и доктор

Первый коммит существующего состояния. Ядро кладётся под git ради того,
чтобы половины разъезжались в продуктовые репозитории через git subtree,
а не копированием: subtree делает настоящий merge, хранит историю и умеет
отдавать правки обратно.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LeBkZLiQrvdECf5nfgiMz
This commit is contained in:
2026-08-26 20:39:37 +03:00
co-authored by Claude Opus 5
commit 18bd05bcbb
50 changed files with 8152 additions and 0 deletions
+82
View File
@@ -0,0 +1,82 @@
// Проверка пароля единственной учётки панели.
//
// Две вещи, которые в панелях были сделаны по-разному, здесь сведены к одному
// поведению:
//
// 1. Сравнение. Везде было плоское `pass !== ADMIN_PASS` — время сравнения
// строк зависит от длины общего префикса. Здесь сравниваются SHA-256
// дайджесты через timingSafeEqual: одинаковая длина (иначе timingSafeEqual
// бросает) и постоянное время.
//
// 2. Ненастроенный пароль. Одна панель падала в открытую (дефолт "admin" и
// предупреждение в консоль), другая — в закрытую (вход запрещён). Ядро
// падает в закрытую и максимально рано: не на первом логине, а при старте
// процесса. Тихо работающая панель с паролем по умолчанию — худший из
// возможных исходов.
import crypto from 'node:crypto';
/** Сравнение за постоянное время. Строки произвольной длины. */
export function timingSafeEquals(a: string, b: string): boolean {
const ha = crypto.createHash('sha256').update(a).digest();
const hb = crypto.createHash('sha256').update(b).digest();
return crypto.timingSafeEqual(ha, hb);
}
export interface PasswordOptions {
/** Пароль открытым текстом (обычно из ADMIN_PASS). */
password?: string;
/**
* Либо хэш вида `scrypt:<salt-hex>:<key-hex>` (обычно из ADMIN_PASS_HASH) —
* тогда открытый пароль не хранится в окружении вовсе.
* Сгенерировать: `node -e "…"` — см. hashPassword ниже.
*/
passwordHash?: string;
}
export interface PasswordCheck {
verify: (input: string) => boolean;
/** Как настроен пароль — для строки в логе при старте. */
kind: 'plain' | 'scrypt';
}
const SCRYPT_PREFIX = 'scrypt:';
/** Готовый ADMIN_PASS_HASH для указанного пароля. */
export function hashPassword(password: string): string {
const salt = crypto.randomBytes(16);
const key = crypto.scryptSync(password, salt, 32);
return `${SCRYPT_PREFIX}${salt.toString('hex')}:${key.toString('hex')}`;
}
export function createPasswordCheck(options: PasswordOptions): PasswordCheck {
const { password, passwordHash } = options;
if (passwordHash) {
if (!passwordHash.startsWith(SCRYPT_PREFIX)) {
throw new Error('panel-server: passwordHash должен начинаться с "scrypt:" — см. hashPassword()');
}
const [, saltHex, keyHex] = passwordHash.split(':');
if (!saltHex || !keyHex) {
throw new Error('panel-server: passwordHash повреждён, ожидается scrypt:<salt-hex>:<key-hex>');
}
const salt = Buffer.from(saltHex, 'hex');
const expected = Buffer.from(keyHex, 'hex');
return {
kind: 'scrypt',
verify(input) {
const actual = crypto.scryptSync(input, salt, expected.length);
return crypto.timingSafeEqual(actual, expected);
},
};
}
if (!password) {
throw new Error(
'panel-server: пароль панели не задан.\n' +
' Укажи ADMIN_PASS (или ADMIN_PASS_HASH — тогда открытого пароля в окружении не будет).\n' +
' Значения по умолчанию здесь намеренно нет: панель с паролем "admin" опаснее, чем не поднявшаяся панель.',
);
}
return { kind: 'plain', verify: (input) => timingSafeEquals(input, password) };
}