🎉 init: 小龙的工作空间
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@network-tool/control-server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"start": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"db:init": "tsx src/db/init.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@network-tool/shared-types": "*",
|
||||
"@network-tool/shared-crypto": "*",
|
||||
"fastify": "^4.26.0",
|
||||
"pino-pretty": "^11.0.0",
|
||||
"sql.js": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.0",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import initSqlJs, { Database as SqlJsDatabase, Statement } from 'sql.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'network.db');
|
||||
|
||||
let db: SqlJsDatabase | null = null;
|
||||
|
||||
export async function getDb(): Promise<SqlJsDatabase> {
|
||||
if (!db) {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
// 确保 data 目录存在
|
||||
const dir = path.dirname(DB_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
// 尝试从文件加载,否则创建新数据库
|
||||
if (fs.existsSync(DB_PATH)) {
|
||||
const buffer = fs.readFileSync(DB_PATH);
|
||||
db = new SQL.Database(buffer);
|
||||
} else {
|
||||
db = new SQL.Database();
|
||||
}
|
||||
|
||||
db.run('PRAGMA journal_mode = WAL');
|
||||
db.run('PRAGMA foreign_keys = ON');
|
||||
initSchema(db);
|
||||
console.log('[DB] 数据库已初始化');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动保存数据库到磁盘
|
||||
*/
|
||||
export function saveDb(): void {
|
||||
if (!db) return;
|
||||
const data = db.export();
|
||||
const buffer = Buffer.from(data);
|
||||
fs.writeFileSync(DB_PATH, buffer);
|
||||
console.log('[DB] 数据库已保存');
|
||||
}
|
||||
|
||||
function initSchema(database: SqlJsDatabase): void {
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user', 'admin')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
device_name TEXT NOT NULL,
|
||||
device_fingerprint TEXT UNIQUE NOT NULL,
|
||||
public_key TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('online', 'offline')),
|
||||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS relay_nodes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('online', 'offline', 'busy')),
|
||||
load INTEGER NOT NULL DEFAULT 0,
|
||||
last_heartbeat_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS connection_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
device_id INTEGER NOT NULL,
|
||||
node_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
used_at TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (node_id) REFERENCES relay_nodes(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
database.run(`
|
||||
CREATE TABLE IF NOT EXISTS connection_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
device_id INTEGER,
|
||||
node_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK(status IN ('success', 'failed')),
|
||||
message TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// 索引
|
||||
database.run('CREATE INDEX IF NOT EXISTS idx_devices_user ON devices(user_id)');
|
||||
database.run('CREATE INDEX IF NOT EXISTS idx_devices_fingerprint ON devices(device_fingerprint)');
|
||||
database.run('CREATE INDEX IF NOT EXISTS idx_nodes_status ON relay_nodes(status)');
|
||||
database.run('CREATE INDEX IF NOT EXISTS idx_tokens_hash ON connection_tokens(token_hash)');
|
||||
database.run('CREATE INDEX IF NOT EXISTS idx_logs_created ON connection_logs(created_at)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:查询单行
|
||||
*/
|
||||
export function queryOne<T = Record<string, any>>(
|
||||
database: SqlJsDatabase,
|
||||
sql: string,
|
||||
params: any[] = []
|
||||
): T | null {
|
||||
try {
|
||||
const stmt = database.prepare(sql);
|
||||
if (params.length > 0) stmt.bind(params);
|
||||
if (stmt.step()) {
|
||||
const row = stmt.getAsObject() as T;
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
stmt.free();
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error('[DB] queryOne error:', sql, err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:查询多行
|
||||
*/
|
||||
export function queryAll<T = Record<string, any>>(
|
||||
database: SqlJsDatabase,
|
||||
sql: string,
|
||||
params: any[] = []
|
||||
): T[] {
|
||||
try {
|
||||
const stmt = database.prepare(sql);
|
||||
if (params.length > 0) stmt.bind(params);
|
||||
const results: T[] = [];
|
||||
while (stmt.step()) {
|
||||
results.push(stmt.getAsObject() as T);
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
} catch (err) {
|
||||
console.error('[DB] queryAll error:', sql, err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 辅助函数:执行 INSERT/UPDATE/DELETE
|
||||
*/
|
||||
export function execute(
|
||||
database: SqlJsDatabase,
|
||||
sql: string,
|
||||
params: any[] = []
|
||||
): { changes: number; lastInsertRowid: number } {
|
||||
try {
|
||||
database.run(sql, params);
|
||||
const lastId = database.exec("SELECT last_insert_rowid() as id");
|
||||
const lastInsertRowid = lastId.length > 0 ? (lastId[0].values[0][0] as number) : 0;
|
||||
return {
|
||||
changes: database.getRowsModified(),
|
||||
lastInsertRowid,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[DB] execute error:', sql, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立的数据库初始化脚本(用于 npm run db:init)
|
||||
*/
|
||||
if (require.main === module) {
|
||||
getDb().then(() => {
|
||||
saveDb();
|
||||
console.log('[DB] 数据库初始化完成:', DB_PATH);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getDb, saveDb } from './database';
|
||||
|
||||
getDb().then(() => {
|
||||
saveDb();
|
||||
console.log('[DB Init] 数据库初始化完成');
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error('[DB Init] 失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import websocket from '@fastify/websocket';
|
||||
import { getDb, saveDb } from './db/database';
|
||||
import { authRoutes } from './routes/auth.routes';
|
||||
import { deviceRoutes } from './routes/device.routes';
|
||||
import { nodeRoutes } from './routes/node.routes';
|
||||
import { connectionRoutes } from './routes/connection.routes';
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
|
||||
async function main() {
|
||||
// 异步初始化数据库
|
||||
await getDb();
|
||||
console.log('[Server] 数据库已初始化');
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: 'info',
|
||||
transport: {
|
||||
target: 'pino-pretty',
|
||||
options: { colorize: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 插件
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(websocket);
|
||||
|
||||
// WebSocket 端点
|
||||
const connectedClients = new Set<any>();
|
||||
|
||||
app.get('/ws', { websocket: true }, (socket, _req) => {
|
||||
connectedClients.add(socket);
|
||||
console.log(`[WS] 客户端连接,当前连接数: ${connectedClients.size}`);
|
||||
|
||||
socket.on('message', (data: Buffer) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
for (const client of connectedClients) {
|
||||
if (client !== socket && client.readyState === 1) {
|
||||
client.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
connectedClients.delete(socket);
|
||||
console.log(`[WS] 客户端断开,当前连接数: ${connectedClients.size}`);
|
||||
});
|
||||
});
|
||||
|
||||
// 注册路由
|
||||
await app.register(authRoutes);
|
||||
await app.register(deviceRoutes);
|
||||
await app.register(nodeRoutes);
|
||||
await app.register(connectionRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get('/api/health', async () => ({
|
||||
success: true,
|
||||
message: 'Control Server is running',
|
||||
timestamp: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
// 全局错误处理
|
||||
app.setErrorHandler((error, _request, reply) => {
|
||||
console.error('[Server Error]', error);
|
||||
reply.status(error.statusCode || 500).send({
|
||||
success: false,
|
||||
error: 'INTERNAL_ERROR',
|
||||
message: error.message || 'Internal server error',
|
||||
});
|
||||
});
|
||||
|
||||
// 定时保存数据库(sql.js 是内存型,需要定期持久化)
|
||||
setInterval(() => {
|
||||
try {
|
||||
saveDb();
|
||||
} catch { /* ignore */ }
|
||||
}, 30000);
|
||||
|
||||
// 定时清理过期数据
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const db = await getDb();
|
||||
const { execute } = require('./db/database');
|
||||
execute(db, "UPDATE relay_nodes SET status = 'offline' WHERE status != 'offline' AND last_heartbeat_at < datetime('now', '-5 minutes')");
|
||||
execute(db, "DELETE FROM connection_tokens WHERE expires_at < datetime('now') AND used_at IS NULL");
|
||||
saveDb();
|
||||
} catch { /* ignore */ }
|
||||
}, 60000);
|
||||
|
||||
// 启动服务
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
console.log(`[Server] Control Server 已启动: http://${HOST}:${PORT}`);
|
||||
console.log(`[Server] WebSocket: ws://${HOST}:${PORT}/ws`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[Server] 启动失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { verifyToken, JwtPayload } from '@network-tool/shared-crypto';
|
||||
import { JWT_SECRET } from '../services/auth.service';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: JwtPayload;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT 认证中间件
|
||||
*/
|
||||
export async function authMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
reply.status(401).send({ success: false, error: 'UNAUTHORIZED', message: 'Missing or invalid token' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const payload = verifyToken(token, JWT_SECRET);
|
||||
|
||||
if (!payload) {
|
||||
reply.status(401).send({ success: false, error: 'TOKEN_EXPIRED', message: 'Token expired or invalid' });
|
||||
return;
|
||||
}
|
||||
|
||||
request.user = payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin 权限中间件(需在 authMiddleware 之后使用)
|
||||
*/
|
||||
export async function adminMiddleware(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
): Promise<void> {
|
||||
if (!request.user || request.user.role !== 'admin') {
|
||||
reply.status(403).send({ success: false, error: 'FORBIDDEN', message: 'Admin access required' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { AuthService } from '../services/auth.service';
|
||||
import { authMiddleware, adminMiddleware } from '../middleware/auth';
|
||||
|
||||
const authService = new AuthService();
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
/**
|
||||
* POST /api/auth/register - 用户注册
|
||||
*/
|
||||
app.post('/api/auth/register', async (request, reply) => {
|
||||
try {
|
||||
const { email, password } = request.body as { email: string; password: string };
|
||||
|
||||
if (!email || !password) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Email and password are required',
|
||||
});
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Password must be at least 6 characters',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await authService.register({ email, password });
|
||||
return reply.send({ success: true, data: result });
|
||||
} catch (err: any) {
|
||||
if (err.message === 'EMAIL_EXISTS') {
|
||||
return reply.status(409).send({
|
||||
success: false,
|
||||
error: 'EMAIL_EXISTS',
|
||||
message: 'Email already registered',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login - 用户登录
|
||||
*/
|
||||
app.post('/api/auth/login', async (request, reply) => {
|
||||
try {
|
||||
const { email, password } = request.body as { email: string; password: string };
|
||||
|
||||
if (!email || !password) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: 'VALIDATION_ERROR',
|
||||
message: 'Email and password are required',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await authService.login({ email, password });
|
||||
return reply.send({ success: true, data: result });
|
||||
} catch (err: any) {
|
||||
if (err.message === 'INVALID_CREDENTIALS') {
|
||||
return reply.status(401).send({
|
||||
success: false,
|
||||
error: 'INVALID_CREDENTIALS',
|
||||
message: 'Invalid email or password',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me - 获取当前用户信息
|
||||
*/
|
||||
app.get('/api/auth/me', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
const user = await authService.getMe(request.user!.user_id);
|
||||
if (!user) {
|
||||
return reply.status(404).send({
|
||||
success: false,
|
||||
error: 'USER_NOT_FOUND',
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
return reply.send({ success: true, data: user });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/users - 管理所有用户
|
||||
*/
|
||||
app.get('/api/admin/users', { preHandler: [authMiddleware, adminMiddleware] }, async (request, reply) => {
|
||||
const users = await authService.listUsers();
|
||||
return reply.send({ success: true, data: users });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { ConnectionService } from '../services/connection.service';
|
||||
import { authMiddleware, adminMiddleware } from '../middleware/auth';
|
||||
|
||||
const connectionService = new ConnectionService();
|
||||
|
||||
export async function connectionRoutes(app: FastifyInstance): Promise<void> {
|
||||
// 需要认证的路由
|
||||
app.post('/api/connections/request', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
try {
|
||||
const { device_id, node_id } = request.body as { device_id: number; node_id: number };
|
||||
if (!device_id || !node_id) {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'VALIDATION_ERROR',
|
||||
message: 'device_id and node_id are required',
|
||||
});
|
||||
}
|
||||
const result = await connectionService.requestConnection(request.user!.user_id, { device_id, node_id });
|
||||
return reply.send({ success: true, data: result });
|
||||
} catch (err: any) {
|
||||
if (err.message === 'DEVICE_NOT_FOUND') {
|
||||
return reply.status(404).send({
|
||||
success: false, error: 'DEVICE_NOT_FOUND',
|
||||
message: 'Device not found or not owned by user',
|
||||
});
|
||||
}
|
||||
if (err.message === 'NODE_NOT_AVAILABLE') {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'NODE_NOT_AVAILABLE',
|
||||
message: 'Node not available',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// verify-token 不需要用户认证(由 Relay Node 调用)
|
||||
app.post('/api/connections/verify-token', async (request, reply) => {
|
||||
const { token, device_id } = request.body as { token: string; device_id: number };
|
||||
if (!token || !device_id) {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'VALIDATION_ERROR',
|
||||
message: 'token and device_id are required',
|
||||
});
|
||||
}
|
||||
const result = await connectionService.verifyToken({ token, device_id });
|
||||
return reply.send({ success: true, data: result });
|
||||
});
|
||||
|
||||
app.post('/api/connections/disconnect', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
const { device_id } = request.body as { device_id: number };
|
||||
if (!device_id) {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'VALIDATION_ERROR',
|
||||
message: 'device_id is required',
|
||||
});
|
||||
}
|
||||
await connectionService.disconnect(request.user!.user_id, device_id);
|
||||
return reply.send({ success: true, message: 'Disconnected' });
|
||||
});
|
||||
|
||||
app.get('/api/connections/logs', { preHandler: [authMiddleware] }, async (request, reply) => {
|
||||
const logs = await connectionService.getUserLogs(request.user!.user_id);
|
||||
return reply.send({ success: true, data: logs });
|
||||
});
|
||||
|
||||
app.get('/api/admin/connections/logs', { preHandler: [authMiddleware, adminMiddleware] }, async (_request, reply) => {
|
||||
const logs = await connectionService.getLogs();
|
||||
return reply.send({ success: true, data: logs });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { DeviceService } from '../services/device.service';
|
||||
import { authMiddleware, adminMiddleware } from '../middleware/auth';
|
||||
|
||||
const deviceService = new DeviceService();
|
||||
|
||||
export async function deviceRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.addHook('preHandler', authMiddleware);
|
||||
|
||||
app.post('/api/devices/register', async (request, reply) => {
|
||||
try {
|
||||
const { device_name, device_fingerprint, public_key } = request.body as {
|
||||
device_name: string;
|
||||
device_fingerprint: string;
|
||||
public_key: string;
|
||||
};
|
||||
|
||||
if (!device_name || !device_fingerprint) {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'VALIDATION_ERROR',
|
||||
message: 'device_name and device_fingerprint are required',
|
||||
});
|
||||
}
|
||||
|
||||
const device = await deviceService.registerDevice(request.user!.user_id, {
|
||||
device_name, device_fingerprint,
|
||||
public_key: public_key || '',
|
||||
});
|
||||
|
||||
return reply.send({ success: true, data: device });
|
||||
} catch (err: any) {
|
||||
if (err.message === 'DEVICE_ALREADY_EXISTS') {
|
||||
return reply.status(409).send({
|
||||
success: false, error: 'DEVICE_ALREADY_EXISTS',
|
||||
message: 'Device already registered',
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/devices', async (request, reply) => {
|
||||
const devices = await deviceService.getUserDevices(request.user!.user_id);
|
||||
return reply.send({ success: true, data: devices });
|
||||
});
|
||||
|
||||
app.patch('/api/devices/:id/status', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const { status } = request.body as { status: 'online' | 'offline' };
|
||||
|
||||
if (!status || !['online', 'offline'].includes(status)) {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'VALIDATION_ERROR',
|
||||
message: 'Status must be "online" or "offline"',
|
||||
});
|
||||
}
|
||||
|
||||
const device = await deviceService.updateDeviceStatus(parseInt(id), status);
|
||||
if (!device) {
|
||||
return reply.status(404).send({
|
||||
success: false, error: 'DEVICE_NOT_FOUND',
|
||||
message: 'Device not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({ success: true, data: device });
|
||||
});
|
||||
|
||||
app.delete('/api/devices/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const deleted = await deviceService.deleteDevice(parseInt(id), request.user!.user_id);
|
||||
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({
|
||||
success: false, error: 'DEVICE_NOT_FOUND',
|
||||
message: 'Device not found',
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({ success: true, message: 'Device deleted' });
|
||||
});
|
||||
|
||||
app.get('/api/admin/devices', { preHandler: [adminMiddleware] }, async (_request, reply) => {
|
||||
const devices = await deviceService.getAllDevices();
|
||||
return reply.send({ success: true, data: devices });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { NodeService } from '../services/node.service';
|
||||
import { authMiddleware, adminMiddleware } from '../middleware/auth';
|
||||
|
||||
const nodeService = new NodeService();
|
||||
|
||||
export async function nodeRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/api/nodes/register', async (request, reply) => {
|
||||
try {
|
||||
const { name, host, port } = request.body as { name: string; host: string; port: number };
|
||||
if (!name || !host || !port) {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'VALIDATION_ERROR',
|
||||
message: 'name, host, and port are required',
|
||||
});
|
||||
}
|
||||
const node = await nodeService.registerNode({ name, host, port });
|
||||
return reply.send({ success: true, data: node });
|
||||
} catch (err) { throw err; }
|
||||
});
|
||||
|
||||
app.post('/api/nodes/heartbeat', async (request, reply) => {
|
||||
const { node_id, load, status } = request.body as {
|
||||
node_id: number; load: number; status: 'online' | 'offline' | 'busy';
|
||||
};
|
||||
if (!node_id) {
|
||||
return reply.status(400).send({
|
||||
success: false, error: 'VALIDATION_ERROR', message: 'node_id is required',
|
||||
});
|
||||
}
|
||||
const node = await nodeService.heartbeat(node_id, { load: load ?? 0, status: status ?? 'online' });
|
||||
if (!node) {
|
||||
return reply.status(404).send({
|
||||
success: false, error: 'NODE_NOT_FOUND', message: 'Node not found',
|
||||
});
|
||||
}
|
||||
return reply.send({ success: true, data: node });
|
||||
});
|
||||
|
||||
app.get('/api/nodes', { preHandler: [authMiddleware] }, async (_request, reply) => {
|
||||
const nodes = await nodeService.getAllNodes();
|
||||
return reply.send({ success: true, data: nodes });
|
||||
});
|
||||
|
||||
app.get('/api/nodes/available', { preHandler: [authMiddleware] }, async (_request, reply) => {
|
||||
const nodes = await nodeService.getAvailableNodes();
|
||||
return reply.send({ success: true, data: nodes });
|
||||
});
|
||||
|
||||
app.get('/api/admin/nodes', { preHandler: [authMiddleware, adminMiddleware] }, async (_request, reply) => {
|
||||
const nodes = await nodeService.getAllNodes();
|
||||
return reply.send({ success: true, data: nodes });
|
||||
});
|
||||
|
||||
app.delete('/api/admin/nodes/:id', { preHandler: [authMiddleware, adminMiddleware] }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const deleted = await nodeService.deleteNode(parseInt(id));
|
||||
if (!deleted) {
|
||||
return reply.status(404).send({
|
||||
success: false, error: 'NODE_NOT_FOUND', message: 'Node not found',
|
||||
});
|
||||
}
|
||||
return reply.send({ success: true, message: 'Node deleted' });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getDb, queryOne, queryAll, execute, saveDb } from '../db/database';
|
||||
import type { User, UserPublic } from '@network-tool/shared-types';
|
||||
import { hashPassword, verifyPassword, generateToken } from '@network-tool/shared-crypto';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'network-tool-dev-secret-change-in-production';
|
||||
export { JWT_SECRET };
|
||||
|
||||
export class AuthService {
|
||||
async register(data: { email: string; password: string }) {
|
||||
const db = await getDb();
|
||||
if (queryOne(db, 'SELECT id FROM users WHERE email = ?', [data.email])) {
|
||||
throw new Error('EMAIL_EXISTS');
|
||||
}
|
||||
const hash = await hashPassword(data.password);
|
||||
const result = execute(db, 'INSERT INTO users (email, password_hash) VALUES (?, ?)', [data.email, hash]);
|
||||
const user = queryOne<User>(db, 'SELECT * FROM users WHERE id = ?', [result.lastInsertRowid])!;
|
||||
saveDb();
|
||||
const token = generateToken({ user_id: user.id, email: user.email, role: user.role }, JWT_SECRET);
|
||||
return { user: this.public(user), token };
|
||||
}
|
||||
|
||||
async login(data: { email: string; password: string }) {
|
||||
const db = await getDb();
|
||||
const user = queryOne<User>(db, 'SELECT * FROM users WHERE email = ?', [data.email]);
|
||||
if (!user) throw new Error('INVALID_CREDENTIALS');
|
||||
if (!(await verifyPassword(data.password, user.password_hash))) throw new Error('INVALID_CREDENTIALS');
|
||||
const token = generateToken({ user_id: user.id, email: user.email, role: user.role }, JWT_SECRET);
|
||||
return { user: this.public(user), token };
|
||||
}
|
||||
|
||||
async getMe(userId: number): Promise<UserPublic | null> {
|
||||
const db = await getDb();
|
||||
const user = queryOne<User>(db, 'SELECT * FROM users WHERE id = ?', [userId]);
|
||||
return user ? this.public(user) : null;
|
||||
}
|
||||
|
||||
async listUsers(): Promise<UserPublic[]> {
|
||||
const db = await getDb();
|
||||
return queryAll<User>(db, 'SELECT * FROM users ORDER BY created_at DESC').map(u => this.public(u));
|
||||
}
|
||||
|
||||
private public(u: User): UserPublic {
|
||||
return { id: u.id, email: u.email, role: u.role, created_at: u.created_at };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { getDb, queryOne, queryAll, execute, saveDb } from '../db/database';
|
||||
import type { ConnectionTokenResponse, TokenVerifyResponse, ConnectionLog } from '@network-tool/shared-types';
|
||||
import { generateConnectionToken, sha256, generateNonce } from '@network-tool/shared-crypto';
|
||||
import { JWT_SECRET } from './auth.service';
|
||||
|
||||
export class ConnectionService {
|
||||
async requestConnection(userId: number, data: {
|
||||
device_id: number;
|
||||
node_id: number;
|
||||
}): Promise<ConnectionTokenResponse> {
|
||||
const db = await getDb();
|
||||
|
||||
const device = queryOne(db, 'SELECT * FROM devices WHERE id = ? AND user_id = ?', [data.device_id, userId]);
|
||||
if (!device) throw new Error('DEVICE_NOT_FOUND');
|
||||
|
||||
const node = queryOne(db, "SELECT * FROM relay_nodes WHERE id = ? AND status = 'online'", [data.node_id]);
|
||||
if (!node) throw new Error('NODE_NOT_AVAILABLE');
|
||||
|
||||
execute(db, "UPDATE devices SET status = 'online', last_seen_at = datetime('now') WHERE id = ?", [data.device_id]);
|
||||
|
||||
const nonce = generateNonce();
|
||||
const token = generateConnectionToken({
|
||||
user_id: userId,
|
||||
device_id: data.device_id,
|
||||
node_id: data.node_id,
|
||||
nonce,
|
||||
}, JWT_SECRET, '5m');
|
||||
|
||||
const tokenHash = sha256(token);
|
||||
const expiresAt = new Date(Date.now() + 5 * 60 * 1000).toISOString();
|
||||
|
||||
execute(db,
|
||||
'INSERT INTO connection_tokens (user_id, device_id, node_id, token_hash, expires_at) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, data.device_id, data.node_id, tokenHash, expiresAt]
|
||||
);
|
||||
|
||||
this._log(db, userId, data.device_id, data.node_id, 'token_issued', 'success', 'Token issued');
|
||||
|
||||
saveDb();
|
||||
|
||||
return {
|
||||
token,
|
||||
node: node as any,
|
||||
expires_at: expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyToken(data: { token: string; device_id: number }): Promise<TokenVerifyResponse> {
|
||||
const db = await getDb();
|
||||
|
||||
const tokenHash = sha256(data.token);
|
||||
const record = queryOne<any>(db,
|
||||
`SELECT * FROM connection_tokens
|
||||
WHERE token_hash = ? AND device_id = ? AND used_at IS NULL
|
||||
AND expires_at > datetime('now')`,
|
||||
[tokenHash, data.device_id]
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
this._log(db, null, data.device_id, null, 'verify_token', 'failed', 'Invalid or expired token');
|
||||
return { valid: false, user_id: 0, device_id: 0, message: 'Invalid or expired token' };
|
||||
}
|
||||
|
||||
execute(db, "UPDATE connection_tokens SET used_at = datetime('now') WHERE id = ?", [record.id]);
|
||||
execute(db, "UPDATE devices SET status = 'online', last_seen_at = datetime('now') WHERE id = ?", [data.device_id]);
|
||||
this._log(db, record.user_id, record.device_id, record.node_id, 'verify_token', 'success', 'Token verified');
|
||||
saveDb();
|
||||
|
||||
return { valid: true, user_id: record.user_id, device_id: record.device_id, message: 'Token verified' };
|
||||
}
|
||||
|
||||
async disconnect(userId: number, deviceId: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
execute(db, "UPDATE devices SET status = 'offline', last_seen_at = datetime('now') WHERE id = ? AND user_id = ?",
|
||||
[deviceId, userId]);
|
||||
this._log(db, userId, deviceId, null, 'disconnect', 'success', 'Disconnected');
|
||||
saveDb();
|
||||
}
|
||||
|
||||
async getLogs(limit = 100): Promise<ConnectionLog[]> {
|
||||
const db = await getDb();
|
||||
return queryAll<ConnectionLog>(db, 'SELECT * FROM connection_logs ORDER BY created_at DESC LIMIT ?', [limit]);
|
||||
}
|
||||
|
||||
async getUserLogs(userId: number, limit = 50): Promise<ConnectionLog[]> {
|
||||
const db = await getDb();
|
||||
return queryAll<ConnectionLog>(db,
|
||||
'SELECT * FROM connection_logs WHERE user_id = ? ORDER BY created_at DESC LIMIT ?',
|
||||
[userId, limit]
|
||||
);
|
||||
}
|
||||
|
||||
async cleanupExpiredTokens(): Promise<void> {
|
||||
const db = await getDb();
|
||||
execute(db, "DELETE FROM connection_tokens WHERE expires_at < datetime('now') AND used_at IS NULL");
|
||||
saveDb();
|
||||
}
|
||||
|
||||
private _log(db: any, userId: number | null, deviceId: number | null, nodeId: number | null,
|
||||
action: string, status: 'success' | 'failed', message: string): void {
|
||||
execute(db,
|
||||
'INSERT INTO connection_logs (user_id, device_id, node_id, action, status, message) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[userId, deviceId, nodeId, action, status, message]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getDb, queryOne, queryAll, execute } from '../db/database';
|
||||
import type { Device } from '@network-tool/shared-types';
|
||||
|
||||
export class DeviceService {
|
||||
async registerDevice(userId: number, data: { device_name: string; device_fingerprint: string; public_key: string }): Promise<Device> {
|
||||
const db = await getDb();
|
||||
if (queryOne(db, 'SELECT id FROM devices WHERE device_fingerprint = ?', [data.device_fingerprint])) {
|
||||
throw new Error('DEVICE_ALREADY_EXISTS');
|
||||
}
|
||||
const result = execute(db,
|
||||
'INSERT INTO devices (user_id, device_name, device_fingerprint, public_key, status) VALUES (?, ?, ?, ?, ?)',
|
||||
[userId, data.device_name, data.device_fingerprint, data.public_key, 'offline']
|
||||
);
|
||||
return queryOne<Device>(db, 'SELECT * FROM devices WHERE id = ?', [result.lastInsertRowid])!;
|
||||
}
|
||||
|
||||
async getUserDevices(userId: number): Promise<Device[]> {
|
||||
const db = await getDb();
|
||||
return queryAll<Device>(db, 'SELECT * FROM devices WHERE user_id = ? ORDER BY last_seen_at DESC', [userId]);
|
||||
}
|
||||
|
||||
async getAllDevices(): Promise<Device[]> {
|
||||
const db = await getDb();
|
||||
return queryAll<Device>(db, 'SELECT * FROM devices ORDER BY last_seen_at DESC');
|
||||
}
|
||||
|
||||
async updateDeviceStatus(deviceId: number, status: 'online' | 'offline'): Promise<Device | null> {
|
||||
const db = await getDb();
|
||||
const result = execute(db, "UPDATE devices SET status = ?, last_seen_at = datetime('now') WHERE id = ?", [status, deviceId]);
|
||||
if (result.changes === 0) return null;
|
||||
return queryOne<Device>(db, 'SELECT * FROM devices WHERE id = ?', [deviceId]);
|
||||
}
|
||||
|
||||
async deleteDevice(deviceId: number, userId: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = execute(db, 'DELETE FROM devices WHERE id = ? AND user_id = ?', [deviceId, userId]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async adminDeleteDevice(deviceId: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = execute(db, 'DELETE FROM devices WHERE id = ?', [deviceId]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async getDeviceById(deviceId: number): Promise<Device | null> {
|
||||
const db = await getDb();
|
||||
return queryOne<Device>(db, 'SELECT * FROM devices WHERE id = ?', [deviceId]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { getDb, queryOne, queryAll, execute } from '../db/database';
|
||||
import type { RelayNode } from '@network-tool/shared-types';
|
||||
|
||||
export class NodeService {
|
||||
async registerNode(data: { name: string; host: string; port: number }): Promise<RelayNode> {
|
||||
const db = await getDb();
|
||||
const result = execute(db,
|
||||
"INSERT INTO relay_nodes (name, host, port, status, load) VALUES (?, ?, ?, 'offline', 0)",
|
||||
[data.name, data.host, data.port]
|
||||
);
|
||||
return queryOne<RelayNode>(db, 'SELECT * FROM relay_nodes WHERE id = ?', [result.lastInsertRowid])!;
|
||||
}
|
||||
|
||||
async heartbeat(nodeId: number, data: { load: number; status: 'online' | 'offline' | 'busy' }): Promise<RelayNode | null> {
|
||||
const db = await getDb();
|
||||
const result = execute(db,
|
||||
"UPDATE relay_nodes SET status = ?, load = ?, last_heartbeat_at = datetime('now') WHERE id = ?",
|
||||
[data.status, data.load, nodeId]
|
||||
);
|
||||
if (result.changes === 0) return null;
|
||||
return queryOne<RelayNode>(db, 'SELECT * FROM relay_nodes WHERE id = ?', [nodeId]);
|
||||
}
|
||||
|
||||
async getAllNodes(): Promise<RelayNode[]> {
|
||||
const db = await getDb();
|
||||
return queryAll<RelayNode>(db, 'SELECT * FROM relay_nodes ORDER BY last_heartbeat_at DESC');
|
||||
}
|
||||
|
||||
async getAvailableNodes(): Promise<RelayNode[]> {
|
||||
const db = await getDb();
|
||||
return queryAll<RelayNode>(db,
|
||||
"SELECT * FROM relay_nodes WHERE status = 'online' AND load < 100 ORDER BY load ASC, last_heartbeat_at DESC"
|
||||
);
|
||||
}
|
||||
|
||||
async getNodeById(nodeId: number): Promise<RelayNode | null> {
|
||||
const db = await getDb();
|
||||
return queryOne<RelayNode>(db, 'SELECT * FROM relay_nodes WHERE id = ?', [nodeId]);
|
||||
}
|
||||
|
||||
async deleteNode(nodeId: number): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = execute(db, 'DELETE FROM relay_nodes WHERE id = ?', [nodeId]);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async cleanupStaleNodes(timeoutMinutes = 5): Promise<void> {
|
||||
const db = await getDb();
|
||||
execute(db,
|
||||
"UPDATE relay_nodes SET status = 'offline' WHERE status != 'offline' AND last_heartbeat_at < datetime('now', '-' || ? || ' minutes')",
|
||||
[timeoutMinutes]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
declare module 'sql.js' {
|
||||
interface SqlValue {
|
||||
[columnName: string]: number | string | Uint8Array | null;
|
||||
}
|
||||
|
||||
interface QueryExecResult {
|
||||
columns: string[];
|
||||
values: any[][];
|
||||
}
|
||||
|
||||
interface Statement {
|
||||
bind(params?: any[]): boolean;
|
||||
step(): boolean;
|
||||
getAsObject(params?: any): SqlValue;
|
||||
free(): boolean;
|
||||
}
|
||||
|
||||
interface Database {
|
||||
run(sql: string, params?: any[]): Database;
|
||||
exec(sql: string): QueryExecResult[];
|
||||
prepare(sql: string): Statement;
|
||||
export(): Uint8Array;
|
||||
close(): void;
|
||||
getRowsModified(): number;
|
||||
}
|
||||
|
||||
interface SqlJsStatic {
|
||||
Database: new (data?: ArrayLike<number> | Buffer | null) => Database;
|
||||
}
|
||||
|
||||
export default function initSqlJs(config?: any): Promise<SqlJsStatic>;
|
||||
export { Database, Statement, SqlJsStatic };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user