First init

This commit is contained in:
Red 2025-09-27 02:16:54 +05:00
commit fa9d24a0c5
11 changed files with 651 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
db.sqlite
node_modules/
package-lock.json

54
README.md Normal file
View File

@ -0,0 +1,54 @@
# Сервер-менеджер для ключей.
### Этот сервер может понадобиться в разработке безопасного api, авторизации и аутентификации.
### Ключевые особенности:
- Авторотация ключей (с возможностью указать интервал обновления)
- Генерация ключей (пока нельзя свой вставить и навсегда)
- Идеален для JWT и задач криптографии.
- Плавная ротация поддерживается (старые ключи живут некоторое время после создания нового).
- Оформлено в виде удобного api, с понятными конечными маршрутами.
- Хранилище в виде базы данных(sqlite) с собственной оберткой.
- AES-шифрование из коробки. Обязательно нужно обновить ключ в /keys перед продакшн-деплоем.
- Дружелюбный язык и стэк - node js, express. Это вам не сложный java и Spring Boot.
# Краткое руководство
### Суть
Предполагается, что:
1. Сервис будет запускаться локально (извне обращения игнорироваться).
2. Использоваться для благих целей.
### Как пользоваться
Пользоваться им очень просто, как два пальца об асфальт.
#### Для создания ключа:
Нужно обратиться к /reg-key с такой структурой:
```
POST /reg-key
body: {
"name": "Example",
"update": 24
}
```
где:
- update - интервал обновления
- name - название ключа
#### Для получения ключа:
Тут тоже все просто, отличие лишь в способе передачи информации:
```
GET /get-key
query-параметры:
- name
- kid (опционально)
примеры:
- http://localhost/get-key?name=Example
- http://localhost/get-key?name=Example&kid=4adc0149-49e7-45e7-b0af-5e0c4da5fe3f
```
Все, это все что вам нужно знать, чтобы использовать этот сервис.

1
keys/aes-256.key Normal file
View File

@ -0,0 +1 @@
<EFBFBD>bؤU<EFBFBD>7خخعy<79><7F>H@<40>د<EFBFBD>uية<D98A><D8A9><EFBFBD>ل#<23>I

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"dependencies": {
"better-sqlite3": "^12.4.1",
"express": "^5.1.0",
"node-cron": "^4.2.1"
},
"type": "module"
}

View File

@ -0,0 +1,88 @@
import { Worker } from 'worker_threads'
import { writeFileSync } from 'fs'
import path from 'path'
import { unlink } from 'fs/promises'
import { getClassMethodNames } from '../util.js'
import { Table } from './Table.js'
export class AsyncTableWrapper {
static _sharedWorker = null
static _sharedCallbacks = new Map()
static _messageId = 0
static _initOnce(dbFile, tableConfigs) {
if (this._sharedWorker) return
const workerScript = `
import { parentPort, workerData } from 'worker_threads'
import { Database } from './lib/db/Database.js'
import { Table } from './lib/db/Table.js'
const db = new Database(workerData.dbName)
const tables = new Map()
for (const config of workerData.tableConfigs) {
tables.set(config.name, new Table(db, config))
}
parentPort.on('message', async ({ id, tableName, method, args }) => {
const table = tables.get(tableName)
if (!table) {
return parentPort.postMessage({ id, error: 'Table not found: ' + tableName })
}
try {
const result = await table[method](...args)
parentPort.postMessage({ id, result })
} catch (err) {
parentPort.postMessage({ id, error: err.message })
}
})
`
this._filename = path.resolve('./src/.tmp-worker-shared.mjs')
writeFileSync(this._filename, workerScript)
this._sharedWorker = new Worker(this._filename, {
type: 'module',
workerData: {
dbName: dbFile,
tableConfigs: tableConfigs
}
})
this._sharedWorker.on('message', ({ id, result, error }) => {
const cb = this._sharedCallbacks.get(id)
if (!cb) return
const { resolve, reject } = cb
this._sharedCallbacks.delete(id)
if (error) reject(new Error(error))
else resolve(result)
})
}
static async terminate() {
if (this._sharedWorker) {
await unlink(this._filename)
await this._sharedWorker.terminate()
this._sharedWorker = null
}
}
constructor(tableName) {
this.tableName = tableName
for (const methodName of getClassMethodNames(Table)) {
this[methodName] = async (...args) => {
return AsyncTableWrapper._call(this.tableName, methodName, args)
}
}
}
static _call(tableName, method, args) {
const id = ++this._messageId
return new Promise((resolve, reject) => {
this._sharedCallbacks.set(id, { resolve, reject })
this._sharedWorker.postMessage({ id, tableName, method, args })
})
}
}

48
src/lib/db/Database.js Normal file
View File

@ -0,0 +1,48 @@
import openDB from 'better-sqlite3'
export class Database {
/**
* @param {string} filename Имя файла базы данных
*/
constructor (filename) {
this.filename = filename
this.db = openDB(`db/${filename}.sqlite`)
}
/**
* @param {Object} tableConfig
* @returns {boolean}
*/
createTable (tableConfig) {
try {
this.db.exec(`
CREATE TABLE IF NOT EXISTS ${tableConfig.name} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
${Object.entries(tableConfig.columns).map(
([name, type]) => name + ' ' + type).join(', ')
}
)
`)
return true
} catch (e) {
console.error("Проблема при создании таблицы :", e)
throw e
}
}
/**
* @param {string} tableName
* @returns {boolean}
*/
deleteTable(tableName) {
try {
this.db.exec(`DROP TABLE IF EXISTS ${tableName}`)
return true
} catch (e) {
console.error("Проблема при удалении таблицы :", e)
throw e
}
}
}

248
src/lib/db/Table.js Normal file
View File

@ -0,0 +1,248 @@
import { Database } from "./Database.js"
function sanitizeValue(value) {
if (value === undefined) return null
if (typeof value === 'boolean') return value ? 1 : 0
if (typeof value === 'object' && value !== null) return JSON.stringify(value)
return value
}
export class Table {
/**
* @param {Database} db
* @param {Object} tableConfig
*/
constructor(db, tableConfig) {
this.database = db
this.tableName = tableConfig.name
this.columns = Object.entries(tableConfig.columns).map(([name, _]) => name)
db.createTable(tableConfig)
// Подготовленные запросы
this.getByIdStmt = db.db.prepare(`SELECT * FROM ${this.tableName} WHERE id = ?`)
this.getAllStmt = db.db.prepare(`SELECT * FROM ${this.tableName}`)
this.delByIdStmt = db.db.prepare(`DELETE FROM ${this.tableName} WHERE id = ?`)
this.addStmt = db.db.prepare(
`INSERT INTO ${this.tableName} (${this.columns.join(', ')}) VALUES (${Array(this.columns.length).fill('?').join(', ')})`
)
const setColumns = this.columns.filter(col => col !== 'id').map(col => `${col} = ?`).join(', ')
this.updateByIdStmt = db.db.prepare(`UPDATE ${this.tableName} SET ${setColumns} WHERE id = ?`)
// Для динамических запросов по полям
this.getByFieldStmts = new Map()
this.getAllByFieldStmts = new Map()
}
// Получение по id
getById(id) {
try {
return this.getByIdStmt.get(id)
} catch (e) {
console.error("Ошибка при getById:", e)
throw e
}
}
// Получение всех записей
getAll() {
try {
return this.getAllStmt.all()
} catch (e) {
console.error("Ошибка при getAll:", e)
throw e
}
}
// Добавление одной записи
add(data) {
try {
if (Array.isArray(data)) {
const sanitized = data.map(sanitizeValue)
this.addStmt.run(sanitized)
return true
} else if (typeof data === 'object' && data) {
const dataArr = this.columns.map(key => sanitizeValue(data[key]))
this.addStmt.run(dataArr)
return true
}
throw new Error("Некорректный аргумент для add")
} catch (e) {
console.error("Ошибка при вставке:", e)
throw e
}
}
// Добавление многих записей в транзакции
addMany(dataArr) {
this.database.db.exec('BEGIN TRANSACTION')
try {
for (const data of dataArr) {
if (Array.isArray(data)) {
const sanitized = data.map(sanitizeValue)
this.addStmt.run(sanitized)
} else if (typeof data === 'object' && data) {
const arr = this.columns.map(key => sanitizeValue(data[key]))
this.addStmt.run(arr)
} else {
throw new Error("Некорректный аргумент для addMany")
}
}
this.database.db.exec('COMMIT')
return true
} catch (e) {
this.database.db.exec('ROLLBACK')
console.error('Ошибка при множественной вставке:', e.message)
throw e
}
}
// Обновление по id
updateById(id, data) {
try {
if (typeof data !== 'object' || data === null) {
throw new Error("Некорректный аргумент для updateById")
}
const updateColumns = this.columns.filter(col => col !== 'id')
const values = updateColumns.map(col => sanitizeValue(data[col]))
values.push(id)
this.updateByIdStmt.run(values)
return true
} catch (e) {
console.error("Ошибка при updateById:", e)
throw e
}
}
// Множественное обновление по id в транзакции
updateManyById(dataArr) {
this.database.db.exec('BEGIN TRANSACTION')
try {
for (const data of dataArr) {
if (!data || typeof data !== 'object' || data.id === undefined) {
throw new Error("Каждый объект должен содержать id для updateManyById")
}
this.updateById(data.id, data)
}
this.database.db.exec('COMMIT')
return true
} catch (e) {
this.database.db.exec('ROLLBACK')
console.error("Ошибка при множественном обновлении:", e.message)
throw e
}
}
// Удаление по id
delById(id) {
try {
this.delByIdStmt.run(id)
return true
} catch (e) {
console.error("Ошибка при delById:", e)
throw e
}
}
// Множественное удаление по id в транзакции
delManyByIds(ids) {
this.database.db.exec('BEGIN TRANSACTION')
try {
for (const id of ids) {
try {
this.delByIdStmt.run(id)
} catch (e) {
console.error('Ошибка при удалении id:', id, e.message)
}
}
this.database.db.exec('COMMIT')
return true
} catch (e) {
this.database.db.exec('ROLLBACK')
console.error('Ошибка при множественном удалении:', e.message)
throw e
}
}
// Получение одной записи по полю
getBy(field, value) {
try {
if (!this.getByFieldStmts.has(field)) {
const stmt = this.database.db.prepare(
`SELECT * FROM ${this.tableName} WHERE ${field} = ? LIMIT 1`
)
this.getByFieldStmts.set(field, stmt)
}
const stmt = this.getByFieldStmts.get(field)
return stmt.get(value)
} catch (e) {
console.error(`Ошибка при getBy(${field}):`, e)
throw e
}
}
// Получение всех записей по полю
getAllBy(field, value) {
try {
if (!this.getAllByFieldStmts.has(field)) {
const stmt = this.database.db.prepare(
`SELECT * FROM ${this.tableName} WHERE ${field} = ?`
)
this.getAllByFieldStmts.set(field, stmt)
}
const stmt = this.getAllByFieldStmts.get(field)
return stmt.all(value)
} catch (e) {
console.error(`Ошибка при getAllBy(${field}):`, e)
throw e
}
}
// Получение по множеству условий (AND)
getByFields(fieldsObj) {
try {
const keys = Object.keys(fieldsObj)
if (keys.length === 0) throw new Error("Пустой объект условий")
const whereClause = keys.map(k => `${k} = ?`).join(' AND ')
const values = keys.map(k => fieldsObj[k])
const stmt = this.database.db.prepare(
`SELECT * FROM ${this.tableName} WHERE ${whereClause}`
)
return stmt.all(values)
} catch (e) {
console.error("Ошибка при getByFields:", e)
throw e
}
}
// Получить первую запись (по id ASC)
getFirst() {
try {
const stmt = this.database.db.prepare(
`SELECT * FROM ${this.tableName} ORDER BY id ASC LIMIT 1`
)
return stmt.get()
} catch (e) {
console.error("Ошибка при getFirst:", e)
throw e
}
}
// Получить последнюю запись (по id DESC)
getLast() {
try {
const stmt = this.database.db.prepare(
`SELECT * FROM ${this.tableName} ORDER BY id DESC LIMIT 1`
)
return stmt.get()
} catch (e) {
console.error("Ошибка при getLast:", e)
throw e
}
}
}

16
src/lib/genKey.js Normal file
View File

@ -0,0 +1,16 @@
import { generateKeyPairSync } from 'crypto';
export function genRsaKey(size = 2048) {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: size,
publicKeyEncoding: {
type: 'spki',
format: 'pem'
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem'
}
});
return {privateKey, publicKey}
}

24
src/lib/sec.js Normal file
View File

@ -0,0 +1,24 @@
import crypto from 'crypto';
export function encrypt(text, key) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return Buffer.concat([iv, tag, encrypted]).toString('base64');
}
export function decrypt(data, key) {
const bData = Buffer.from(data, 'base64');
const iv = bData.slice(0, 16);
const tag = bData.slice(16, 32);
const text = bData.slice(32);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
const decrypted = decipher.update(text) + decipher.final('utf8');
return decrypted;
}

4
src/lib/util.js Normal file
View File

@ -0,0 +1,4 @@
export function getClassMethodNames(klass) {
return Object.getOwnPropertyNames(klass.prototype)
.filter(name => typeof klass.prototype[name] === 'function' && name !== 'constructor')
}

157
src/server.js Normal file
View File

@ -0,0 +1,157 @@
import fs from 'fs';
import express from 'express'
import { AsyncTableWrapper } from "./lib/db/AsyncTableWrap.js"
import { encrypt, decrypt } from './lib/sec.js';
import { genRsaKey } from './lib/genKey.js';
import { randomUUID } from 'crypto';
import cron from 'node-cron'
const KEY = fs.readFileSync('./keys/aes-256.key')
const PORT = 39391
const TABLE_CONFIG = [
{
name: "rsa_keys",
columns: {
name: "varchar(64)",
kid: "varchar(64)",
publicKey: "text",
privateKey: "text",
actual: "boolean"
}
},
{
name: "keys",
columns: {
name: "varchar(64)",
updateInterval: "integer" //в часах
}
}
]
AsyncTableWrapper._initOnce('db', TABLE_CONFIG)
const app = express()
let keyTable, rsaTable
async function updateKey (name) {
const keys = await rsaTable.getAllBy("name", name)
if(keys.length >= 1) {
const key = keys.at(-1)
key.actual = false
await rsaTable.updateById(key.id, key)
}
const {privateKey, publicKey} = genRsaKey()
const kid = randomUUID()
console.log("Обновение ключа \"" + name + "\" c kid: " + kid)
await rsaTable.add({
name,
kid,
publicKey: encrypt(publicKey, KEY),
privateKey: encrypt(privateKey, KEY),
actual: true
})
}
function autoUpdate (name, update) {
cron.schedule(`0 */${update} * * *`, () => updateKey(name))
// cron.schedule(`*/1 * * * *`, () => updateKey(name))
}
app.use(express.json())
app.use(express.urlencoded({ extended: true }))
app.post("/reg-key", async (req, res) => {
const {name, update} = req.body
if (!name || !update || isNaN(Number(update))) {
return res.status(400).json({ ошибка: "Неверные параметры" });
}
const record = await keyTable.getBy("name", name)
if(record)
return res.status(409).json({ошибка: "Запись существует"})
await keyTable.add({name, updateInterval: update})
updateKey(name)
autoUpdate(name, update)
return res.json({статус: "Запись добавлена"})
})
app.get("/get-key", async (req, res) => {
const {name, kid = null} = req.query
if(!(await keyTable.getBy("name", name)))
return res.status(401).json({ошибка: "Запись не найдена"})
if(kid) {
const key = await rsaTable.getBy("kid", kid)
if(key)
return res.json({
publicKey: decrypt(key.publicKey, KEY),
privateKey: decrypt(key.privateKey, KEY)
})
return res.status(401).json({ошибка: "Запись не найдена по kid"})
}
const key = (await rsaTable.getAllBy("name", name)).at(-1)
return res.json({
publicKey: decrypt(key.publicKey, KEY),
privateKey: decrypt(key.privateKey, KEY)
})
})
async function recaveryCron () {
console.log("Восстановление расписаний")
const keys = await keyTable.getAll()
keys.forEach(key => {
autoUpdate(key.name, key.updateInterval)
console.log("Запись \"" + key.name + "\" возобновлена")
});
}
const server = app.listen(PORT, async error => {
rsaTable = new AsyncTableWrapper('rsa_keys')
keyTable = new AsyncTableWrapper('keys')
console.log("Сервер стартовал")
recaveryCron()
if (error) console.log("Error: " + error)
})
const shutdown = async () => {
console.log('Завершение работы...')
await AsyncTableWrapper.terminate()
.then(() => {
console.log("Закрытие соединения с бд")
})
server.close(() => {
console.log('Cервер остановлен')
process.exit(0)
})
setTimeout(() => {
console.error('Принудительное завершение...')
process.exit(1)
}, 2000)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
process.on('uncaughtException', err => {
console.error('Непойманное исключение:', err)
shutdown()
})