main
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
PORT=3000
|
||||
JWT_PRIVATE_KEY_PATH=./private.pem
|
||||
JWT_PUBLIC_KEY_PATH=./public.pem
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
skeet_server.db
|
||||
logs/
|
||||
.env
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
const argon2 = require('argon2');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// ─── Load RSA keys ────────────────────────────────────
|
||||
|
||||
function loadKeys() {
|
||||
const privatePath = process.env.JWT_PRIVATE_KEY_PATH || path.join(__dirname, 'private.pem');
|
||||
const publicPath = process.env.JWT_PUBLIC_KEY_PATH || path.join(__dirname, 'public.pem');
|
||||
|
||||
return {
|
||||
privateKey: fs.readFileSync(privatePath, 'utf8'),
|
||||
publicKey: fs.readFileSync(publicPath, 'utf8')
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Argon2id (with client-side SHA-256 for HTTP transport) ──
|
||||
|
||||
/**
|
||||
* Hash a raw password for storage.
|
||||
* Step 1: SHA-256(raw) → 64-char hex
|
||||
* Step 2: Argon2id(sha256_hex) → stored in DB
|
||||
*
|
||||
* This way the client sends SHA-256(password) over HTTP,
|
||||
* and the server never sees or stores the raw password.
|
||||
*/
|
||||
async function hashPassword(password) {
|
||||
const sha256 = crypto.createHash('sha256').update(password).digest('hex');
|
||||
return argon2.hash(sha256, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536, // 64 MB
|
||||
timeCost: 3, // 3 iterations
|
||||
parallelism: 4
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a client-submitted password hash against stored Argon2id hash.
|
||||
* @param {string} storedHash — Argon2id(SHA-256(raw_password))
|
||||
* @param {string} receivedSha256 — SHA-256(raw_password) from client (64-char hex)
|
||||
*/
|
||||
async function verifyPassword(storedHash, receivedSha256) {
|
||||
return argon2.verify(storedHash, receivedSha256);
|
||||
}
|
||||
|
||||
// ─── JWT ──────────────────────────────────────────────
|
||||
|
||||
function signAccessToken(user, deviceId) {
|
||||
const { privateKey } = loadKeys();
|
||||
return jwt.sign(
|
||||
{
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
device_id: deviceId
|
||||
},
|
||||
privateKey,
|
||||
{
|
||||
algorithm: 'RS256',
|
||||
expiresIn: '30m'
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function verifyAccessToken(token) {
|
||||
const { publicKey } = loadKeys();
|
||||
return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
|
||||
}
|
||||
|
||||
// ─── Refresh Token ────────────────────────────────────
|
||||
|
||||
function generateRefreshToken() {
|
||||
return crypto.randomBytes(32).toString('hex'); // 256-bit → 64 hex chars
|
||||
}
|
||||
|
||||
function hashToken(token) {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
// ─── HWID Matching ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dynamic HWID matching.
|
||||
* Rule 1: CPU + Board both match → immediate pass (core hardware).
|
||||
* Rule 2: Weighted score ≥ 80%.
|
||||
* CPU: 45%, Board: 45%, Disk: 8%, MAC: 2%
|
||||
*/
|
||||
function hwidMatchRate(device, incoming) {
|
||||
const cpuMatch = device.cpu_hash === incoming.cpu;
|
||||
const boardMatch = device.board_hash === incoming.board;
|
||||
const diskMatch = device.disk_hash === incoming.disk;
|
||||
const macMatch = device.mac_hash === incoming.mac;
|
||||
|
||||
// Rule 1: CPU + Board
|
||||
if (cpuMatch && boardMatch) return { match: true, rate: 0.80 };
|
||||
|
||||
// Rule 2: weighted
|
||||
let score = 0;
|
||||
if (cpuMatch) score += 0.45;
|
||||
if (boardMatch) score += 0.45;
|
||||
if (diskMatch) score += 0.08;
|
||||
if (macMatch) score += 0.02;
|
||||
|
||||
return { match: score >= 0.80, rate: score };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a combined HWID hash for quick lookup.
|
||||
*/
|
||||
function combinedHwidHash(components) {
|
||||
const combined = `${components.cpu}|${components.board}|${components.disk}|${components.mac}`;
|
||||
return crypto.createHash('sha256').update(combined).digest('hex');
|
||||
}
|
||||
|
||||
// ─── Admin RBAC ───────────────────────────────────────
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ success: false, error: 'UNAUTHORIZED' });
|
||||
}
|
||||
|
||||
try {
|
||||
const token = authHeader.slice(7);
|
||||
const payload = verifyAccessToken(token);
|
||||
if (payload.role !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: 'FORBIDDEN', message: 'Admin role required' });
|
||||
}
|
||||
req.user = payload;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ success: false, error: 'TOKEN_INVALID' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a pre-computed SHA-256 hex string (from client) with Argon2id.
|
||||
* Used for registration when the client sends SHA-256(password).
|
||||
*/
|
||||
async function hashPasswordFromHash(sha256hex) {
|
||||
return argon2.hash(sha256hex, {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 65536,
|
||||
timeCost: 3,
|
||||
parallelism: 4
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword, hashPasswordFromHash, verifyPassword,
|
||||
signAccessToken, verifyAccessToken,
|
||||
generateRefreshToken, hashToken,
|
||||
hwidMatchRate, combinedHwidHash,
|
||||
requireAdmin
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Batch user creation script.
|
||||
*
|
||||
* Usage:
|
||||
* node create_users.js user1 pass1 [user2 pass2 ...]
|
||||
* node create_users.js --file users.json
|
||||
* node create_users.js --interactive
|
||||
*
|
||||
* JSON format (users.json):
|
||||
* [
|
||||
* { "username": "user1", "password": "pass1", "role": "user", "expires_at": null },
|
||||
* { "username": "user2", "password": "pass2", "role": "user", "expires_at": "2026-12-31" }
|
||||
* ]
|
||||
*/
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
|
||||
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const db = require('./db');
|
||||
const auth = require('./auth');
|
||||
|
||||
db.initDB();
|
||||
|
||||
async function createOne(username, password, role = 'user', expiresAt = null) {
|
||||
const existing = db.getUserByUsername(username);
|
||||
if (existing) {
|
||||
console.log(` [SKIP] '${username}' already exists`);
|
||||
return false;
|
||||
}
|
||||
const hash = await auth.hashPassword(password);
|
||||
db.createUser(username, hash, role, expiresAt || null);
|
||||
console.log(` [OK] '${username}' created (role: ${role}${expiresAt ? ', expires: ' + expiresAt : ''})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let created = 0;
|
||||
|
||||
if (args.length === 0 || args.includes('--interactive') || args.includes('-i')) {
|
||||
// ── Interactive mode ──
|
||||
console.log('=== Interactive User Creation ===');
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
while (true) {
|
||||
const username = await new Promise(resolve => rl.question('\nUsername (empty to finish): ', resolve));
|
||||
if (!username) break;
|
||||
|
||||
const password = await new Promise(resolve => rl.question('Password: ', resolve));
|
||||
if (!password) { console.log(' Password required, skipping.'); continue; }
|
||||
|
||||
const role = await new Promise(resolve => rl.question('Role [user/admin] (default: user): ', resolve));
|
||||
const expires = await new Promise(resolve => rl.question('Expiry YYYY-MM-DD (default: never): ', resolve));
|
||||
|
||||
if (await createOne(username, password, role || 'user', expires || null)) created++;
|
||||
}
|
||||
rl.close();
|
||||
|
||||
} else if (args.includes('--file') || args.includes('-f')) {
|
||||
// ── JSON file mode ──
|
||||
const fileIdx = args.indexOf('--file');
|
||||
const fIdx = args.indexOf('-f');
|
||||
const filePath = args[fileIdx !== -1 ? fileIdx + 1 : fIdx + 1];
|
||||
|
||||
if (!filePath || !fs.existsSync(filePath)) {
|
||||
console.error('File not found:', filePath);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const users = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (!Array.isArray(users)) {
|
||||
console.error('JSON must be an array of user objects.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Creating ${users.length} users from ${filePath}...\n`);
|
||||
for (const u of users) {
|
||||
if (await createOne(u.username, u.password, u.role || 'user', u.expires_at || null)) created++;
|
||||
}
|
||||
|
||||
} else {
|
||||
// ── CLI args mode: pairs of username password ──
|
||||
if (args.length % 2 !== 0) {
|
||||
console.error('Arguments must be in pairs: username password [username password ...]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Creating ${args.length / 2} users...\n`);
|
||||
for (let i = 0; i < args.length; i += 2) {
|
||||
if (await createOne(args[i], args[i + 1])) created++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone. ${created} user(s) created.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => { console.error('Error:', err); process.exit(1); });
|
||||
+198
@@ -0,0 +1,198 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
let db;
|
||||
|
||||
function initDB(dbPath) {
|
||||
db = new Database(dbPath || path.join(__dirname, 'skeet_server.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
cpu_hash TEXT NOT NULL,
|
||||
board_hash TEXT NOT NULL,
|
||||
disk_hash TEXT NOT NULL,
|
||||
mac_hash TEXT NOT NULL,
|
||||
last_login TEXT,
|
||||
last_ip TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
token_hash TEXT UNIQUE NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
device_id INTEGER,
|
||||
hwid_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
device_id INTEGER,
|
||||
ip_address TEXT,
|
||||
user_agent TEXT,
|
||||
hwid_match_rate REAL,
|
||||
success INTEGER NOT NULL,
|
||||
reason TEXT,
|
||||
attempted_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
key TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 1,
|
||||
window_start TEXT NOT NULL,
|
||||
PRIMARY KEY (key, action)
|
||||
);
|
||||
`);
|
||||
|
||||
return db;
|
||||
}
|
||||
|
||||
function getDB() {
|
||||
if (!db) throw new Error('Database not initialized. Call initDB() first.');
|
||||
return db;
|
||||
}
|
||||
|
||||
// ─── Users ────────────────────────────────────────────
|
||||
|
||||
function getUserByUsername(username) {
|
||||
return getDB().prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
}
|
||||
|
||||
function getUserById(id) {
|
||||
return getDB().prepare('SELECT * FROM users WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function createUser(username, passwordHash, role = 'user', expiresAt = null) {
|
||||
return getDB().prepare(
|
||||
'INSERT INTO users (username, password_hash, role, expires_at) VALUES (?, ?, ?, ?)'
|
||||
).run(username, passwordHash, role, expiresAt);
|
||||
}
|
||||
|
||||
// ─── Devices ──────────────────────────────────────────
|
||||
|
||||
function findMatchingDevice(userId, cpuHash, boardHash, diskHash, macHash) {
|
||||
const devices = getDB().prepare(
|
||||
'SELECT * FROM devices WHERE user_id = ?'
|
||||
).all(userId);
|
||||
return devices;
|
||||
}
|
||||
|
||||
function createDevice(userId, displayName, cpuHash, boardHash, diskHash, macHash, ip) {
|
||||
return getDB().prepare(
|
||||
`INSERT INTO devices (user_id, display_name, cpu_hash, board_hash, disk_hash, mac_hash, last_login, last_ip)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?)`
|
||||
).run(userId, displayName, cpuHash, boardHash, diskHash, macHash, ip);
|
||||
}
|
||||
|
||||
function updateDeviceLogin(deviceId, ip) {
|
||||
getDB().prepare(
|
||||
'UPDATE devices SET last_login = datetime(\'now\'), last_ip = ? WHERE id = ?'
|
||||
).run(ip, deviceId);
|
||||
}
|
||||
|
||||
// ─── Refresh Tokens ───────────────────────────────────
|
||||
|
||||
function saveRefreshToken(tokenHash, userId, deviceId, hwidHash, expiresAt) {
|
||||
return getDB().prepare(
|
||||
'INSERT INTO refresh_tokens (token_hash, user_id, device_id, hwid_hash, expires_at) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(tokenHash, userId, deviceId, hwidHash, expiresAt);
|
||||
}
|
||||
|
||||
function findRefreshToken(tokenHash) {
|
||||
return getDB().prepare(
|
||||
'SELECT * FROM refresh_tokens WHERE token_hash = ?'
|
||||
).get(tokenHash);
|
||||
}
|
||||
|
||||
function revokeRefreshToken(tokenHash) {
|
||||
getDB().prepare(
|
||||
'UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?'
|
||||
).run(tokenHash);
|
||||
}
|
||||
|
||||
// ─── Login Log ────────────────────────────────────────
|
||||
|
||||
function logLogin(userId, deviceId, ip, userAgent, hwidMatchRate, success, reason) {
|
||||
getDB().prepare(
|
||||
`INSERT INTO login_log (user_id, device_id, ip_address, user_agent, hwid_match_rate, success, reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(userId, deviceId, ip, userAgent, hwidMatchRate, success ? 1 : 0, reason);
|
||||
}
|
||||
|
||||
function countRecentFailures(username, windowMinutes = 10) {
|
||||
const row = getDB().prepare(
|
||||
`SELECT COUNT(*) as cnt FROM login_log
|
||||
JOIN users ON login_log.user_id = users.id
|
||||
WHERE users.username = ? AND login_log.success = 0
|
||||
AND login_log.attempted_at > datetime('now', '-' || ? || ' minutes')`
|
||||
).get(username, windowMinutes);
|
||||
return row ? row.cnt : 0;
|
||||
}
|
||||
|
||||
// ─── Rate Limits ──────────────────────────────────────
|
||||
|
||||
function checkRateLimit(key, action, maxCount, windowSeconds) {
|
||||
const now = new Date().toISOString();
|
||||
const db = getDB();
|
||||
|
||||
// UPSERT — single row per (key, action)
|
||||
db.prepare(
|
||||
`INSERT INTO rate_limits (key, action, count, window_start)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(key, action) DO UPDATE SET
|
||||
count = CASE
|
||||
WHEN (unixepoch(?) - unixepoch(window_start)) > ? THEN 1
|
||||
ELSE count + 1
|
||||
END,
|
||||
window_start = CASE
|
||||
WHEN (unixepoch(?) - unixepoch(window_start)) > ? THEN ?
|
||||
ELSE window_start
|
||||
END`
|
||||
).run(key, action, now, now, windowSeconds, now, windowSeconds, now);
|
||||
|
||||
const row = db.prepare(
|
||||
'SELECT count, window_start FROM rate_limits WHERE key = ? AND action = ?'
|
||||
).get(key, action);
|
||||
|
||||
if (!row) return { allowed: true, remaining: maxCount };
|
||||
|
||||
const windowAge = (Date.now() - new Date(row.window_start + 'Z').getTime()) / 1000;
|
||||
if (windowAge > windowSeconds) return { allowed: true, remaining: maxCount };
|
||||
|
||||
return {
|
||||
allowed: row.count <= maxCount,
|
||||
remaining: Math.max(0, maxCount - row.count),
|
||||
retryAfter: row.count > maxCount ? Math.ceil(windowSeconds - windowAge) : 0
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initDB, getDB,
|
||||
getUserByUsername, getUserById, createUser,
|
||||
findMatchingDevice, createDevice, updateDeviceLogin,
|
||||
saveRefreshToken, findRefreshToken, revokeRefreshToken,
|
||||
logLogin, countRecentFailures,
|
||||
checkRateLimit
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title Skeet Loader Server
|
||||
|
||||
echo ==========================================
|
||||
echo Skeet Loader - Server Deployment
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
:: Check Node.js
|
||||
node --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Node.js is not installed!
|
||||
echo Download from: https://nodejs.org (LTS version)
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo Node.js: OK
|
||||
|
||||
:: Check npm
|
||||
npm --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] npm not found!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo npm: OK
|
||||
|
||||
:: Check if dependencies installed
|
||||
if not exist "node_modules\" (
|
||||
echo Installing dependencies...
|
||||
npm install
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] npm install failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
echo Dependencies: OK
|
||||
|
||||
:: Check RSA keys
|
||||
if not exist "private.pem" (
|
||||
echo [ERROR] RSA keys not found! Generate them first:
|
||||
echo openssl genrsa -out private.pem 2048
|
||||
echo openssl rsa -in private.pem -pubout -out public.pem
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo RSA Keys: OK
|
||||
|
||||
:: Check if admin user exists
|
||||
if not exist "skeet_server.db" (
|
||||
echo.
|
||||
echo No database found. Creating admin user...
|
||||
node setup.js
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Admin setup failed!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: Check PM2
|
||||
echo.
|
||||
where pm2 >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo PM2 found - starting with PM2...
|
||||
pm2 start ecosystem.config.js
|
||||
pm2 status
|
||||
echo.
|
||||
echo Server started with PM2! Commands:
|
||||
echo pm2 status - View status
|
||||
echo pm2 logs - View logs
|
||||
echo pm2 restart skeet-server
|
||||
echo pm2 stop skeet-server
|
||||
) else (
|
||||
echo PM2 not installed - starting directly...
|
||||
echo To install PM2: npm install -g pm2
|
||||
echo.
|
||||
echo Starting server directly (Ctrl+C to stop)...
|
||||
node server.js
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo Server should be running on port 3000
|
||||
echo Test: curl http://localhost:3000/api/status
|
||||
echo ==========================================
|
||||
pause
|
||||
@@ -0,0 +1,32 @@
|
||||
// PM2 process manager config
|
||||
// Install: npm install -g pm2
|
||||
// Start: pm2 start ecosystem.config.js
|
||||
// Status: pm2 status
|
||||
// Logs: pm2 logs skeet-server
|
||||
// Restart: pm2 restart skeet-server
|
||||
// Stop: pm2 stop skeet-server
|
||||
// Auto-start on boot: pm2 startup && pm2 save
|
||||
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'skeet-server',
|
||||
script: 'server.js',
|
||||
cwd: __dirname,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3000
|
||||
},
|
||||
// Restart if memory exceeds 200MB
|
||||
max_memory_restart: '200M',
|
||||
// Restart if it crashes
|
||||
autorestart: true,
|
||||
// Max 5 restarts in 60 seconds, then stop
|
||||
max_restarts: 5,
|
||||
min_uptime: '60s',
|
||||
// Log settings
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||
error_file: './logs/error.log',
|
||||
out_file: './logs/out.log',
|
||||
merge_logs: true
|
||||
}]
|
||||
};
|
||||
Generated
+1430
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "skeet-loader-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Cloud verification server for skeet_loader",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"setup": "node setup.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"argon2": "^0.41.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCfL9+eXaLxbwOf
|
||||
l42JstBlTgcPrfe5pqaq99bm+3cplEMCgfcX8rgJjae2vzjIdVA8CraS5dmPuV4R
|
||||
vxbLN+Drz0P7PrzjGEf6gb8QJiQNQUsnIKjLaJtvgXj2r/qCpQN43fGnPbAakVCC
|
||||
wYEgjsLTnwPFrOqPmjUSdhdqu+qe/umWc9ahqL4YyWyrMOEZsCOnJi8rrPlYd4uv
|
||||
H0XOt7T4giSJPCQIRF2d+KujFOMwHdmutxQWgkPGmUM7npVQE+iBBpAj2IBhXHVV
|
||||
2A5Dq4bznnT1Hh/ihCRWd1e6D1DRv6jL9BvL6tcASp+kaSiPwLaSjrWgj6kMnZDl
|
||||
OL1WQFH7AgMBAAECggEASmNjQsysA9nmXhJC1Imoo+aKGzQnMuzjLrrhhJAXd+ku
|
||||
RUI5l0nX4bL+IEKMxPQ5Wc2B0vSxLd4oTO+4tZ93roptUL/ZIeBrZm9yOxgvubf8
|
||||
6Bx1dJR+KmXdAp5q2NlG3ZkNv6LupAOrFWdasb204pKI/zuWYyPjUCy6Naj583Eb
|
||||
UmJxv8C/H95UL8BzGHkb4feFcJjv4Bm0dTLr45kt+GLaPSqTmMksXntCN7r64uNT
|
||||
mZSBKN2/kRxmNjeyFPJvTbj6ZgsYCgyE6oeFTAKYXaAhEyyUTZqNYTQiADngvNCL
|
||||
Us4RwRLY9rlw3RNFWATojyA6MgUWIWNuHFDxJst82QKBgQDfSoXThtv9cnUNMH72
|
||||
qkyJdo4GJkbyvJk6TyyVgu4hd8eI0oFrgRCrKgCRbcmoi1oyu8tqeaCM5Y3qHtri
|
||||
ggG8zUpmG5MqUv2LEd3cGAOd9ZP2oX5yoAg2QRoZTjoJJa9b8vg6y6JfYBNK4if6
|
||||
37tTPvfDf+N2/Vc7c/fBRX4TyQKBgQC2gW35uWRR3thyCSLdTtD7TOhC93GzD1O8
|
||||
psRQ7F9bcT25GZBp2YlK3LpTzVbPP2ek6x7ltyeq6PxXRbugE0ij7c+ywcMo91uZ
|
||||
3Rcsj7TL8EpXpZU5V+BI4aeyN1KWKXa337jhcnZu8cJqomFkBv6sznLRyvLAxQu/
|
||||
17f2oU5xowKBgDWjUjh42dmtJ/8OGkGosRAIYZ+KjFp9AZXnNP+JXyi8/DqazqoD
|
||||
a/yh71b/94Q8TWOIhxnBs9aEwi1uUgg9UKuI6QlUMGrnWq6QkSnwvtWwC2Ygbx6b
|
||||
4L0fsGRJzVkrK0+8MvL0vcGJc7j2UMJMAlTB+ISG8R5BRzYp6mcMCXg5AoGABhwb
|
||||
JGsScrM1J8wqgKIs2NzgQa2q/sWalgw3MkZXguYtnM7ASOrhb8In8rpDF7kVrS25
|
||||
4RLanxwhpoJNH7TFj8dcVq2p7OsrA+Gk7vb4pIMs2fZPIpZQieAUDyFPAHvu34T7
|
||||
2YBNerVZPtykygZ57CsVKPTUX6O5GvkLzZPk3mUCgYEA14/OzSOSfYgO8AeZloTZ
|
||||
IwBBMVTrK37vm/oP4nvnfhI34dpbPU6dT5PDR4WizUJsq5BqGfaxUgAJudMueAqb
|
||||
E9q8RLF7l3ZEoiSut62VEllulrtk/EIBcGFJU4X0TthUskYrYE4GfO5UAuwfbxO5
|
||||
O2Wkxljb2feNBBih8QDHJhY=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAny/fnl2i8W8Dn5eNibLQ
|
||||
ZU4HD633uaamqvfW5vt3KZRDAoH3F/K4CY2ntr84yHVQPAq2kuXZj7leEb8Wyzfg
|
||||
689D+z684xhH+oG/ECYkDUFLJyCoy2ibb4F49q/6gqUDeN3xpz2wGpFQgsGBII7C
|
||||
058Dxazqj5o1EnYXarvqnv7plnPWoai+GMlsqzDhGbAjpyYvK6z5WHeLrx9Fzre0
|
||||
+IIkiTwkCERdnfiroxTjMB3ZrrcUFoJDxplDO56VUBPogQaQI9iAYVx1VdgOQ6uG
|
||||
85509R4f4oQkVndXug9Q0b+oy/Qby+rXAEqfpGkoj8C2ko61oI+pDJ2Q5Ti9VkBR
|
||||
+wIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,365 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Skeet Loader — Admin</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:#0f1117;color:#e0e0e0;min-height:100vh}
|
||||
/* Login */
|
||||
#loginBox{max-width:380px;margin:100px auto;padding:40px 30px;background:#1a1d27;border-radius:12px;border:1px solid #2a2d37}
|
||||
#loginBox h1{text-align:center;font-size:22px;margin-bottom:8px;background:linear-gradient(90deg,#00b9ff,#ff01f7,#e8ff00);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
#loginBox .sub{text-align:center;font-size:12px;color:#666;margin-bottom:24px}
|
||||
#loginBox input{width:100%;padding:12px;margin-bottom:12px;background:#0f1117;border:1px solid #2a2d37;border-radius:6px;color:#fff;font-size:14px;outline:none}
|
||||
#loginBox input:focus{border-color:#00b9ff}
|
||||
#loginBox button{width:100%;padding:12px;background:linear-gradient(90deg,#00b9ff,#ff01f7);border:none;border-radius:6px;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
|
||||
#loginBox button:hover{opacity:0.9}
|
||||
#loginBox .err{color:#ff5050;font-size:13px;margin-top:8px;text-align:center}
|
||||
/* Dashboard */
|
||||
#dashboard{display:none;max-width:1200px;margin:0 auto;padding:20px}
|
||||
header{display:flex;justify-content:space-between;align-items:center;padding:16px 0;border-bottom:1px solid #2a2d37;margin-bottom:24px}
|
||||
header h1{font-size:20px;background:linear-gradient(90deg,#00b9ff,#ff01f7,#e8ff00);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
|
||||
header .user{font-size:13px;color:#888;display:flex;gap:16px;align-items:center}
|
||||
header .user button{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:12px}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:24px}
|
||||
.stat{background:#1a1d27;border:1px solid #2a2d37;border-radius:8px;padding:16px 20px}
|
||||
.stat .n{font-size:28px;font-weight:700}
|
||||
.stat .l{font-size:12px;color:#666;margin-top:4px}
|
||||
.tabs{display:flex;gap:8px;margin-bottom:16px;border-bottom:1px solid #2a2d37;padding-bottom:8px}
|
||||
.tab{padding:8px 16px;background:transparent;color:#888;border:none;cursor:pointer;font-size:13px;border-radius:4px 4px 0 0}
|
||||
.tab.active{background:#1a1d27;color:#fff;border:1px solid #2a2d37;border-bottom:1px solid #1a1d27}
|
||||
.toolbar{display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap}
|
||||
.toolbar button{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:7px 16px;border-radius:4px;cursor:pointer;font-size:12px}
|
||||
.toolbar button:hover{background:#3a3d47}
|
||||
.toolbar input{background:#1a1d27;border:1px solid #2a2d37;color:#fff;padding:7px 12px;border-radius:4px;font-size:12px;outline:none;width:180px}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px}
|
||||
th{text-align:left;padding:10px 12px;border-bottom:2px solid #2a2d37;color:#888;font-weight:600}
|
||||
td{padding:9px 12px;border-bottom:1px solid #1a1d27}
|
||||
tr:hover td{background:#1a1d2733}
|
||||
.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
|
||||
.badge-ok{background:#0a3;color:#fff}
|
||||
.badge-bad{background:#a00;color:#fff}
|
||||
.badge-warn{background:#a80;color:#fff}
|
||||
.badge-info{background:#049;color:#fff}
|
||||
.btn-sm{background:#2a2d37;color:#ccc;border:1px solid #3a3d47;padding:4px 10px;border-radius:3px;cursor:pointer;font-size:11px;margin-right:4px}
|
||||
.btn-sm:hover{background:#3a3d47}
|
||||
.btn-sm.danger{color:#ff5050;border-color:#a03030}
|
||||
.btn-sm.danger:hover{background:#3a1a1a}
|
||||
.modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:100;justify-content:center;align-items:center}
|
||||
.modal{background:#1a1d27;border:1px solid #2a2d37;border-radius:12px;padding:24px;min-width:350px}
|
||||
.modal h3{margin-bottom:16px}
|
||||
.modal input{width:100%;padding:10px;margin-bottom:10px;background:#0f1117;border:1px solid #2a2d37;border-radius:6px;color:#fff;font-size:13px}
|
||||
.modal .btns{display:flex;gap:8px;justify-content:flex-end;margin-top:16px}
|
||||
.modal .btns button{padding:8px 18px;border-radius:6px;border:none;cursor:pointer;font-size:13px}
|
||||
.modal .btn-primary{background:linear-gradient(90deg,#00b9ff,#ff01f7);color:#fff}
|
||||
.modal .btn-cancel{background:#2a2d37;color:#ccc}
|
||||
.toast{position:fixed;top:20px;right:20px;background:#0a3;color:#fff;padding:12px 20px;border-radius:6px;font-size:13px;z-index:200;animation:fadein 0.3s}
|
||||
@keyframes fadein{from{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login -->
|
||||
<div id="loginBox">
|
||||
<h1>Skeet Loader</h1>
|
||||
<div class="sub">Admin Panel</div>
|
||||
<input id="loginUser" placeholder="Username" autofocus>
|
||||
<input id="loginPwd" type="password" placeholder="Password">
|
||||
<button onclick="doLogin()">Login</button>
|
||||
<div class="err" id="loginErr"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard -->
|
||||
<div id="dashboard">
|
||||
<header>
|
||||
<h1>Skeet Admin</h1>
|
||||
<div class="user">
|
||||
<span id="adminUser"></span>
|
||||
<button onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stats" id="stats"></div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('users')">Users</button>
|
||||
<button class="tab" onclick="switchTab('logs')">Login Logs</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" id="userToolbar">
|
||||
<input id="searchInput" placeholder="Search username..." oninput="renderUsers()">
|
||||
<button onclick="showCreateModal()">+ Create User</button>
|
||||
<button onclick="loadUsers()">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<table id="userTable"><thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Status</th><th>HWID Bound</th><th>Created</th><th>Expires</th><th>Actions</th></tr></thead><tbody></tbody></table>
|
||||
|
||||
<table id="logTable" style="display:none"><thead><tr><th>Time</th><th>User</th><th>IP</th><th>Result</th><th>HWID Match</th><th>Reason</th></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
|
||||
<!-- Create User Modal -->
|
||||
<div class="modal-overlay" id="createModal"><div class="modal">
|
||||
<h3>Create User</h3>
|
||||
<input id="newUser" placeholder="Username">
|
||||
<input id="newPwd" type="password" placeholder="Password">
|
||||
<input id="newExp" placeholder="Expiry (YYYY-MM-DD, optional)">
|
||||
<div class="btns">
|
||||
<button class="btn-cancel" onclick="closeModal('createModal')">Cancel</button>
|
||||
<button class="btn-primary" onclick="createUser()">Create</button>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast" class="toast" style="display:none"></div>
|
||||
|
||||
<script>
|
||||
const API = ''; // same origin
|
||||
let token = localStorage.getItem('admin_token');
|
||||
let allUsers = [];
|
||||
let allLogs = [];
|
||||
let currentTab = 'users';
|
||||
|
||||
// ─── Init ──────────────────────────────
|
||||
if (token) {
|
||||
document.getElementById('loginBox').style.display = 'none';
|
||||
document.getElementById('dashboard').style.display = 'block';
|
||||
loadAll();
|
||||
}
|
||||
|
||||
// ─── Login ─────────────────────────────
|
||||
async function doLogin() {
|
||||
const u = document.getElementById('loginUser').value.trim();
|
||||
const p = document.getElementById('loginPwd').value;
|
||||
if (!u || !p) return;
|
||||
|
||||
const h = sha256(p);
|
||||
|
||||
try {
|
||||
const r = await fetch(API + '/api/login', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: u, password_hash: h,
|
||||
hwid_components: { cpu: 'admin', board: 'panel', disk: 'panel', mac: 'panel' }
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.success) { document.getElementById('loginErr').textContent = d.error || 'Login failed'; return; }
|
||||
|
||||
// Verify admin role
|
||||
const payload = JSON.parse(atob(d.access_token.split('.')[1]));
|
||||
if (payload.role !== 'admin') {
|
||||
document.getElementById('loginErr').textContent = 'Not an admin account';
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('admin_token', d.access_token);
|
||||
token = d.access_token;
|
||||
document.getElementById('loginBox').style.display = 'none';
|
||||
document.getElementById('dashboard').style.display = 'block';
|
||||
document.getElementById('adminUser').textContent = payload.username;
|
||||
loadAll();
|
||||
} catch(e) {
|
||||
document.getElementById('loginErr').textContent = 'Connection failed';
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('admin_token');
|
||||
token = null;
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ─── SHA-256 (pure JS — works without crypto.subtle, over HTTP) ──
|
||||
function sha256(msg) {
|
||||
function R(n,w){return((n>>>w)|(n<<(32-w)))>>>0}
|
||||
const K=[0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2];
|
||||
// UTF-8 encode
|
||||
const m=unescape(encodeURIComponent(msg));
|
||||
const bytes=[];for(let i=0;i<m.length;i++)bytes.push(m.charCodeAt(i)&0xFF);
|
||||
const l=bytes.length*8;
|
||||
bytes.push(0x80);while((bytes.length+8)%64!==0)bytes.push(0);
|
||||
// Big-endian 64-bit length (high 32 bits always 0 for <4GB messages)
|
||||
bytes.push(0);bytes.push(0);bytes.push(0);bytes.push(0);
|
||||
bytes.push((l>>>24)&0xFF);bytes.push((l>>>16)&0xFF);bytes.push((l>>>8)&0xFF);bytes.push(l&0xFF);
|
||||
const H=[0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19];
|
||||
for(let p=0;p<bytes.length;p+=64){
|
||||
const W=[];for(let t=0;t<16;t++)W[t]=(bytes[p+t*4]<<24)|(bytes[p+t*4+1]<<16)|(bytes[p+t*4+2]<<8)|bytes[p+t*4+3];
|
||||
for(let t=16;t<64;t++){const s0=R(W[t-15],7)^R(W[t-15],18)^(W[t-15]>>>3),s1=R(W[t-2],17)^R(W[t-2],19)^(W[t-2]>>>10);W[t]=(W[t-16]+s0+W[t-7]+s1)>>>0}
|
||||
let[a,b,c,d,e,f,g,h]=H;
|
||||
for(let t=0;t<64;t++){const S1=R(e,6)^R(e,11)^R(e,25),ch=(e&f)^(~e&g),t1=(h+S1+ch+K[t]+W[t])>>>0,S0=R(a,2)^R(a,13)^R(a,22),maj=(a&b)^(a&c)^(b&c),t2=(S0+maj)>>>0;h=g;g=f;f=e;e=(d+t1)>>>0;d=c;c=b;b=a;a=(t1+t2)>>>0}
|
||||
H[0]=(H[0]+a)>>>0;H[1]=(H[1]+b)>>>0;H[2]=(H[2]+c)>>>0;H[3]=(H[3]+d)>>>0;H[4]=(H[4]+e)>>>0;H[5]=(H[5]+f)>>>0;H[6]=(H[6]+g)>>>0;H[7]=(H[7]+h)>>>0;
|
||||
}
|
||||
return H.map(v=>('0000000'+v.toString(16)).slice(-8)).join('');
|
||||
}
|
||||
|
||||
// ─── Load Data ─────────────────────────
|
||||
async function loadAll() {
|
||||
await Promise.all([loadUsers(), loadStats()]);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/users', { headers: authHeader() });
|
||||
const d = await r.json();
|
||||
allUsers = d.users || [];
|
||||
renderUsers();
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/stats', { headers: authHeader() });
|
||||
const d = await r.json();
|
||||
document.getElementById('stats').innerHTML =
|
||||
`<div class="stat"><div class="n">${d.totalUsers}</div><div class="l">Total Users</div></div>
|
||||
<div class="stat"><div class="n" style="color:#0a3">${d.activeUsers}</div><div class="l">Active</div></div>
|
||||
<div class="stat"><div class="n" style="color:#0af">${d.logins24h}</div><div class="l">Logins (24h)</div></div>
|
||||
<div class="stat"><div class="n" style="color:#f55">${d.failures24h}</div><div class="l">Failures (24h)</div></div>`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/logs', { headers: authHeader() });
|
||||
const d = await r.json();
|
||||
allLogs = d.logs || [];
|
||||
renderLogs();
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return { 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────
|
||||
function renderUsers() {
|
||||
const q = (document.getElementById('searchInput')?.value || '').toLowerCase();
|
||||
let users = q ? allUsers.filter(u => u.username.toLowerCase().includes(q)) : allUsers;
|
||||
const tbody = document.querySelector('#userTable tbody');
|
||||
tbody.innerHTML = users.map(u => `
|
||||
<tr>
|
||||
<td>${u.id}</td>
|
||||
<td><b>${esc(u.username)}</b></td>
|
||||
<td><span class="badge ${u.role==='admin'?'badge-warn':'badge-info'}">${u.role}</span></td>
|
||||
<td><span class="badge ${u.is_active?'badge-ok':'badge-bad'}">${u.is_active?'Active':'Banned'}</span></td>
|
||||
<td>${u.hwid_bound_at || '<span style="color:#666">—</span>'}</td>
|
||||
<td>${fmtDate(u.created_at)}</td>
|
||||
<td>${u.expires_at ? fmtDate(u.expires_at) : '<span style="color:#666">Never</span>'}</td>
|
||||
<td>
|
||||
<button class="btn-sm" onclick="toggleUser(${u.id},${u.is_active})">${u.is_active?'Ban':'Unban'}</button>
|
||||
<button class="btn-sm danger" onclick="resetHwid(${u.id})">Reset HWID</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function renderLogs() {
|
||||
const tbody = document.querySelector('#logTable tbody');
|
||||
tbody.innerHTML = allLogs.map(l => `
|
||||
<tr>
|
||||
<td>${fmtDate(l.attempted_at)}</td>
|
||||
<td>${esc(l.username||'—')}</td>
|
||||
<td>${l.ip_address||'—'}</td>
|
||||
<td><span class="badge ${l.success?'badge-ok':'badge-bad'}">${l.success?'OK':'FAIL'}</span></td>
|
||||
<td>${l.hwid_match_rate != null ? (l.hwid_match_rate*100).toFixed(0)+'%' : '—'}</td>
|
||||
<td style="color:#888">${esc(l.reason||'')}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
// ─── Tabs ──────────────────────────────
|
||||
function switchTab(t) {
|
||||
currentTab = t;
|
||||
document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('userTable').style.display = t === 'users' ? '' : 'none';
|
||||
document.getElementById('userToolbar').style.display = t === 'users' ? '' : 'none';
|
||||
document.getElementById('logTable').style.display = t === 'logs' ? '' : 'none';
|
||||
if (t === 'logs') loadLogs();
|
||||
if (t === 'users') loadUsers();
|
||||
}
|
||||
|
||||
// ─── Actions ───────────────────────────
|
||||
function showCreateModal() {
|
||||
document.getElementById('createModal').style.display = 'flex';
|
||||
document.getElementById('newUser').focus();
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).style.display = 'none';
|
||||
document.getElementById('newUser').value = '';
|
||||
document.getElementById('newPwd').value = '';
|
||||
document.getElementById('newExp').value = '';
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
const u = document.getElementById('newUser').value.trim();
|
||||
const p = document.getElementById('newPwd').value;
|
||||
const e = document.getElementById('newExp').value;
|
||||
if (!u || !p) return;
|
||||
|
||||
// Admin creates user with raw password (server hashes internally)
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/create-user', {
|
||||
method: 'POST', headers: { ...authHeader(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: u, password: p, expires_at: e || null })
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) { toast('User created: ' + u); closeModal('createModal'); loadUsers(); }
|
||||
else toast(d.error || 'Failed', true);
|
||||
} catch(e) { toast('Connection error', true); }
|
||||
}
|
||||
|
||||
async function toggleUser(id, current) {
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/user/' + id + '/toggle', {
|
||||
method: 'POST', headers: authHeader()
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) { toast('User ' + (d.is_active ? 'activated' : 'banned')); loadUsers(); }
|
||||
} catch(e) { toast('Error', true); }
|
||||
}
|
||||
|
||||
async function resetHwid(id) {
|
||||
if (!confirm('Reset HWID for this user? They can bind to a new machine on next login.')) return;
|
||||
try {
|
||||
const r = await fetch(API + '/api/admin/user/' + id + '/reset-hwid', {
|
||||
method: 'POST', headers: authHeader()
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.success) toast('HWID reset — user can re-bind'); else toast(d.error||'Failed', true);
|
||||
} catch(e) { toast('Error', true); }
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '—';
|
||||
const d = new Date(s + (s.endsWith('Z')?'':'Z'));
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function toast(msg, isErr) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.style.background = isErr ? '#a00' : '#0a3';
|
||||
el.style.display = 'block';
|
||||
setTimeout(() => el.style.display = 'none', 3000);
|
||||
}
|
||||
|
||||
// Close modal on overlay click
|
||||
document.getElementById('createModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) closeModal('createModal');
|
||||
});
|
||||
|
||||
// Enter key on login
|
||||
document.getElementById('loginPwd').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') doLogin();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,42 @@
|
||||
const { checkRateLimit } = require('./db');
|
||||
|
||||
const LIMITS = {
|
||||
ip: { max: 20, window: 300 }, // 20 req / 5 min
|
||||
user: { max: 5, window: 600 }, // 5 failures / 10 min lock
|
||||
hwid: { max: 10, window: 900 } // 10 failures / 15 min lock
|
||||
};
|
||||
|
||||
/**
|
||||
* Check all three rate limit dimensions.
|
||||
* Returns { allowed, retryAfter } — retryAfter is the longest among triggered limits.
|
||||
*/
|
||||
function checkLoginRateLimit(ip, username, hwidCombined) {
|
||||
let maxRetryAfter = 0;
|
||||
|
||||
// IP-based
|
||||
const ipLimit = checkRateLimit(`ip:${ip}`, 'login', LIMITS.ip.max, LIMITS.ip.window);
|
||||
if (!ipLimit.allowed) {
|
||||
maxRetryAfter = Math.max(maxRetryAfter, ipLimit.retryAfter);
|
||||
}
|
||||
|
||||
// Username-based (only for failed attempts — counted in login handler)
|
||||
const userLimit = checkRateLimit(`user:${username}`, 'login', LIMITS.user.max, LIMITS.user.window);
|
||||
if (!userLimit.allowed) {
|
||||
maxRetryAfter = Math.max(maxRetryAfter, userLimit.retryAfter);
|
||||
}
|
||||
|
||||
// HWID-based
|
||||
if (hwidCombined) {
|
||||
const hwidLimit = checkRateLimit(`hwid:${hwidCombined}`, 'login', LIMITS.hwid.max, LIMITS.hwid.window);
|
||||
if (!hwidLimit.allowed) {
|
||||
maxRetryAfter = Math.max(maxRetryAfter, hwidLimit.retryAfter);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: maxRetryAfter === 0,
|
||||
retryAfter: maxRetryAfter
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { checkLoginRateLimit };
|
||||
@@ -0,0 +1,322 @@
|
||||
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
|
||||
|
||||
const express = require('express');
|
||||
const db = require('./db');
|
||||
const auth = require('./auth');
|
||||
const rateLimit = require('./rate_limit');
|
||||
|
||||
const path = require('path');
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Init DB
|
||||
db.initDB();
|
||||
|
||||
// GET /api/status
|
||||
app.get('/api/status', (req, res) => {
|
||||
res.json({ status: 'ok', server_time: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// POST /api/login
|
||||
app.post('/api/login', async (req, res) => {
|
||||
const { username, password_hash, hwid_components } = req.body;
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
const userAgent = req.headers['user-agent'] || '';
|
||||
|
||||
if (!username || !password_hash || !hwid_components) {
|
||||
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
|
||||
}
|
||||
const { cpu, board, disk, mac } = hwid_components;
|
||||
if (!cpu || !board || !disk || !mac) {
|
||||
return res.status(400).json({ success: false, error: 'MISSING_HWID_COMPONENTS' });
|
||||
}
|
||||
|
||||
const hwidCombined = auth.combinedHwidHash(hwid_components);
|
||||
|
||||
// Rate limit
|
||||
const rl = rateLimit.checkLoginRateLimit(ip, username, hwidCombined);
|
||||
if (!rl.allowed) {
|
||||
return res.status(429).json({
|
||||
success: false, error: 'RATE_LIMITED',
|
||||
message: 'Too many attempts', retry_after: rl.retryAfter
|
||||
});
|
||||
}
|
||||
|
||||
// User lookup
|
||||
const user = db.getUserByUsername(username);
|
||||
if (!user) {
|
||||
db.logLogin(null, null, ip, userAgent, 0, false, 'USER_NOT_FOUND');
|
||||
return res.json({ success: false, error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
if (!user.is_active) return res.json({ success: false, error: 'ACCOUNT_DISABLED' });
|
||||
if (user.expires_at && new Date(user.expires_at) < new Date())
|
||||
return res.json({ success: false, error: 'ACCOUNT_EXPIRED' });
|
||||
|
||||
// Verify password (Argon2id)
|
||||
let passwordValid = false;
|
||||
try {
|
||||
passwordValid = await auth.verifyPassword(user.password_hash, password_hash);
|
||||
} catch (err) {
|
||||
console.error('[login] argon2 error:', err.message);
|
||||
return res.status(500).json({ success: false, error: 'INTERNAL_ERROR' });
|
||||
}
|
||||
if (!passwordValid) {
|
||||
db.logLogin(user.id, null, ip, userAgent, 0, false, 'WRONG_PASSWORD');
|
||||
return res.json({ success: false, error: 'INVALID_CREDENTIALS' });
|
||||
}
|
||||
|
||||
// Admin accounts skip HWID check (for web panel access)
|
||||
if (user.role === 'admin') {
|
||||
const accessToken = auth.signAccessToken(user, 0);
|
||||
const refreshToken = auth.generateRefreshToken();
|
||||
const tokenHash = auth.hashToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
|
||||
db.saveRefreshToken(tokenHash, user.id, 0, 'admin', expiresAt);
|
||||
db.logLogin(user.id, 0, ip, userAgent, 1.0, true, null);
|
||||
return res.json({
|
||||
success: true,
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
username: user.username,
|
||||
match_rate: 1.0
|
||||
});
|
||||
}
|
||||
|
||||
// HWID matching
|
||||
const userDevices = db.findMatchingDevice(user.id, cpu, board, disk, mac);
|
||||
let matchedDevice = null;
|
||||
let bestRate = 0;
|
||||
|
||||
for (const device of userDevices) {
|
||||
const result = auth.hwidMatchRate(device, hwid_components);
|
||||
if (result.match && result.rate > bestRate) {
|
||||
matchedDevice = device;
|
||||
bestRate = result.rate;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedDevice) {
|
||||
if (userDevices.length === 0) {
|
||||
// First login — auto-create device
|
||||
const displayName = req.headers['x-device-name'] || 'Unknown Device';
|
||||
const result = db.createDevice(user.id, displayName, cpu, board, disk, mac, ip);
|
||||
matchedDevice = { id: result.lastInsertRowid, cpu_hash: cpu, board_hash: board, disk_hash: disk, mac_hash: mac };
|
||||
bestRate = 1.0;
|
||||
} else {
|
||||
// HWID threshold failed
|
||||
db.logLogin(user.id, null, ip, userAgent, bestRate, false, 'HWID_THRESHOLD');
|
||||
return res.json({
|
||||
success: false, error: 'HWID_THRESHOLD',
|
||||
message: 'Hardware does not match any bound device', match_rate: bestRate
|
||||
});
|
||||
}
|
||||
} else {
|
||||
db.updateDeviceLogin(matchedDevice.id, ip);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = auth.signAccessToken(user, matchedDevice.id);
|
||||
const refreshToken = auth.generateRefreshToken();
|
||||
const tokenHash = auth.hashToken(refreshToken);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
|
||||
db.saveRefreshToken(tokenHash, user.id, matchedDevice.id, hwidCombined, expiresAt);
|
||||
|
||||
db.logLogin(user.id, matchedDevice.id, ip, userAgent, bestRate, true, null);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
access_token: accessToken,
|
||||
refresh_token: refreshToken,
|
||||
username: user.username,
|
||||
match_rate: bestRate
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/refresh
|
||||
app.post('/api/refresh', (req, res) => {
|
||||
const { refresh_token, hwid_components } = req.body;
|
||||
if (!refresh_token || !hwid_components) {
|
||||
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
|
||||
}
|
||||
|
||||
const tokenHash = auth.hashToken(refresh_token);
|
||||
const stored = db.findRefreshToken(tokenHash);
|
||||
if (!stored || stored.revoked) return res.json({ success: false, error: 'TOKEN_EXPIRED' });
|
||||
if (new Date(stored.expires_at) < new Date()) return res.json({ success: false, error: 'TOKEN_EXPIRED' });
|
||||
|
||||
// Verify HWID
|
||||
const hwidCombined = auth.combinedHwidHash(hwid_components);
|
||||
if (stored.hwid_hash !== hwidCombined) {
|
||||
const user = db.getUserById(stored.user_id);
|
||||
if (!user) return res.json({ success: false, error: 'TOKEN_EXPIRED' });
|
||||
|
||||
const userDevices = db.findMatchingDevice(user.id,
|
||||
hwid_components.cpu, hwid_components.board,
|
||||
hwid_components.disk, hwid_components.mac);
|
||||
|
||||
let matched = false;
|
||||
for (const device of userDevices) {
|
||||
if (auth.hwidMatchRate(device, hwid_components).match) { matched = true; break; }
|
||||
}
|
||||
if (!matched) return res.json({ success: false, error: 'HWID_THRESHOLD' });
|
||||
}
|
||||
|
||||
// Rotate
|
||||
db.revokeRefreshToken(tokenHash);
|
||||
const user = db.getUserById(stored.user_id);
|
||||
const newAccessToken = auth.signAccessToken(user, stored.device_id);
|
||||
const newRefreshToken = auth.generateRefreshToken();
|
||||
const newTokenHash = auth.hashToken(newRefreshToken);
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString();
|
||||
db.saveRefreshToken(newTokenHash, user.id, stored.device_id,
|
||||
auth.combinedHwidHash(hwid_components), expiresAt);
|
||||
|
||||
if (stored.device_id) db.updateDeviceLogin(stored.device_id, req.ip || req.socket.remoteAddress);
|
||||
|
||||
res.json({ success: true, access_token: newAccessToken, refresh_token: newRefreshToken });
|
||||
});
|
||||
|
||||
// POST /api/logout
|
||||
app.post('/api/logout', (req, res) => {
|
||||
const { refresh_token } = req.body;
|
||||
if (refresh_token) db.revokeRefreshToken(auth.hashToken(refresh_token));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// POST /api/register (public self-registration)
|
||||
app.post('/api/register', async (req, res) => {
|
||||
const { username, password_hash } = req.body;
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
|
||||
if (!username || !password_hash) {
|
||||
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
|
||||
}
|
||||
|
||||
// Username rules
|
||||
if (username.length < 3 || username.length > 32) {
|
||||
return res.json({ success: false, error: 'INVALID_USERNAME', message: 'Username must be 3-32 characters' });
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
return res.json({ success: false, error: 'INVALID_USERNAME', message: 'Only letters, numbers, underscores' });
|
||||
}
|
||||
|
||||
// Rate limit registration per IP (5 per hour)
|
||||
const rl = db.checkRateLimit(`register:${ip}`, 'register', 5, 3600);
|
||||
if (!rl.allowed) {
|
||||
return res.status(429).json({
|
||||
success: false, error: 'RATE_LIMITED',
|
||||
message: 'Too many registrations. Try again later.', retry_after: rl.retryAfter
|
||||
});
|
||||
}
|
||||
|
||||
// Check if username taken
|
||||
if (db.getUserByUsername(username)) {
|
||||
return res.json({ success: false, error: 'USERNAME_TAKEN' });
|
||||
}
|
||||
|
||||
// password_hash comes as SHA-256 from client — verify it's 64-char hex
|
||||
if (password_hash.length !== 64 || !/^[a-f0-9]{64}$/.test(password_hash)) {
|
||||
return res.json({ success: false, error: 'INVALID_PASSWORD_HASH' });
|
||||
}
|
||||
|
||||
// password_hash is already SHA-256(password) — Argon2id it directly
|
||||
let storedHash;
|
||||
try {
|
||||
storedHash = await auth.hashPasswordFromHash(password_hash);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ success: false, error: 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
db.createUser(username, storedHash, 'user', null);
|
||||
|
||||
console.log(`[register] New user: ${username} (ip: ${ip})`);
|
||||
res.json({ success: true, message: 'Registration successful. You can now login.' });
|
||||
});
|
||||
|
||||
// Admin: POST /api/admin/create-user
|
||||
app.post('/api/admin/create-user', auth.requireAdmin, async (req, res) => {
|
||||
const { username, password, role, expires_at } = req.body;
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ success: false, error: 'MISSING_FIELDS' });
|
||||
}
|
||||
|
||||
if (db.getUserByUsername(username)) {
|
||||
return res.json({ success: false, error: 'USERNAME_TAKEN' });
|
||||
}
|
||||
|
||||
let passwordHash;
|
||||
try { passwordHash = await auth.hashPassword(password); }
|
||||
catch (err) { return res.status(500).json({ success: false, error: 'INTERNAL_ERROR' }); }
|
||||
|
||||
db.createUser(username, passwordHash, role || 'user', expires_at || null);
|
||||
res.json({ success: true, message: `User '${username}' created` });
|
||||
});
|
||||
|
||||
// Admin: GET /api/admin/users (list all users)
|
||||
app.get('/api/admin/users', auth.requireAdmin, (req, res) => {
|
||||
const db = require('./db');
|
||||
const users = db.getDB().prepare(
|
||||
`SELECT u.id, u.username, u.role, u.is_active, u.created_at, u.expires_at,
|
||||
(SELECT MIN(d.created_at) FROM devices d WHERE d.user_id = u.id) as hwid_bound_at
|
||||
FROM users u ORDER BY u.id DESC`
|
||||
).all();
|
||||
res.json({ users });
|
||||
});
|
||||
|
||||
// Admin: GET /api/admin/logs (recent login logs, last 100)
|
||||
app.get('/api/admin/logs', auth.requireAdmin, (req, res) => {
|
||||
const db = require('./db');
|
||||
const logs = db.getDB().prepare(
|
||||
`SELECT login_log.*, users.username FROM login_log
|
||||
LEFT JOIN users ON login_log.user_id = users.id
|
||||
ORDER BY login_log.id DESC LIMIT 100`
|
||||
).all();
|
||||
res.json({ logs });
|
||||
});
|
||||
|
||||
// Admin: GET /api/admin/stats
|
||||
app.get('/api/admin/stats', auth.requireAdmin, (req, res) => {
|
||||
const db = require('./db');
|
||||
const totalUsers = db.getDB().prepare('SELECT COUNT(*) as cnt FROM users').get().cnt;
|
||||
const activeUsers = db.getDB().prepare('SELECT COUNT(*) as cnt FROM users WHERE is_active = 1').get().cnt;
|
||||
const logins24h = db.getDB().prepare(
|
||||
"SELECT COUNT(*) as cnt FROM login_log WHERE success = 1 AND attempted_at > datetime('now', '-1 day')"
|
||||
).get().cnt;
|
||||
const failures24h = db.getDB().prepare(
|
||||
"SELECT COUNT(*) as cnt FROM login_log WHERE success = 0 AND attempted_at > datetime('now', '-1 day')"
|
||||
).get().cnt;
|
||||
res.json({ totalUsers, activeUsers, logins24h, failures24h });
|
||||
});
|
||||
|
||||
// Admin: POST /api/admin/user/:id/toggle (ban/unban)
|
||||
app.post('/api/admin/user/:id/toggle', auth.requireAdmin, (req, res) => {
|
||||
const db = require('./db');
|
||||
const user = db.getUserById(req.params.id);
|
||||
if (!user) return res.json({ success: false, error: 'NOT_FOUND' });
|
||||
const newStatus = user.is_active ? 0 : 1;
|
||||
db.getDB().prepare('UPDATE users SET is_active = ? WHERE id = ?').run(newStatus, req.params.id);
|
||||
res.json({ success: true, is_active: newStatus });
|
||||
});
|
||||
|
||||
// Admin: POST /api/admin/user/:id/reset-hwid
|
||||
app.post('/api/admin/user/:id/reset-hwid', auth.requireAdmin, (req, res) => {
|
||||
const db = require('./db');
|
||||
db.getDB().prepare('DELETE FROM devices WHERE user_id = ?').run(req.params.id);
|
||||
db.getDB().prepare(
|
||||
'UPDATE refresh_tokens SET revoked = 1 WHERE user_id = ?'
|
||||
).run(req.params.id);
|
||||
res.json({ success: true, message: 'HWID reset. User can bind to new machine on next login.' });
|
||||
});
|
||||
|
||||
// Start
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[server] running on port ${PORT}`);
|
||||
console.log(` GET /api/status`);
|
||||
console.log(` POST /api/login`);
|
||||
console.log(` POST /api/refresh`);
|
||||
console.log(` POST /api/logout`);
|
||||
console.log(` POST /api/admin/create-user (admin)`);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Setup script — create the first admin user.
|
||||
* Usage: node setup.js <admin_username> <admin_password>
|
||||
*/
|
||||
require('dotenv').config();
|
||||
|
||||
const readline = require('readline');
|
||||
const db = require('./db');
|
||||
const auth = require('./auth');
|
||||
|
||||
db.initDB();
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let username = args[0];
|
||||
let password = args[1];
|
||||
|
||||
if (!username || !password) {
|
||||
// Prompt interactively
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
username = await new Promise(resolve => rl.question('Admin username: ', resolve));
|
||||
password = await new Promise(resolve => rl.question('Admin password: ', resolve));
|
||||
rl.close();
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
console.error('Username and password are required.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const existing = db.getUserByUsername(username);
|
||||
if (existing) {
|
||||
console.error(`User '${username}' already exists.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const passwordHash = await auth.hashPassword(password);
|
||||
db.createUser(username, passwordHash, 'admin', null);
|
||||
|
||||
console.log(`Admin user '${username}' created successfully.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Setup failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user