feat: 实现 IP 白名单设置的管理界面及后端校验逻辑
This commit is contained in:
Generated
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
<data-source source="LOCAL" name="nexus-terminal" uuid="e7b75293-7bc6-44e2-bde1-518a65febbc1">
|
<data-source source="LOCAL" name="nexus-shell" uuid="e7b75293-7bc6-44e2-bde1-518a65febbc1">
|
||||||
<driver-ref>sqlite.xerial</driver-ref>
|
<driver-ref>sqlite.xerial</driver-ref>
|
||||||
<synchronize>true</synchronize>
|
<synchronize>true</synchronize>
|
||||||
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import ipaddr from 'ipaddr.js';
|
||||||
|
import { settingsService } from '../services/settings.service';
|
||||||
|
|
||||||
|
const IP_WHITELIST_SETTING_KEY = 'ipWhitelist';
|
||||||
|
|
||||||
|
// 本地开发环境的 IP 地址列表
|
||||||
|
const LOCAL_IPS = [
|
||||||
|
'127.0.0.1', // IPv4 本地回环
|
||||||
|
'::1', // IPv6 本地回环
|
||||||
|
'localhost' // 本地主机名
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP 白名单中间件
|
||||||
|
* 检查请求来源 IP 是否在设置中定义的白名单内。
|
||||||
|
* 白名单支持 IPv4, IPv6 地址以及 CIDR 范围。
|
||||||
|
* 如果白名单未设置或为空,则允许所有 IP。
|
||||||
|
* 本地开发环境的 IP 地址始终允许访问。
|
||||||
|
*/
|
||||||
|
export const ipWhitelistMiddleware = async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
// 获取请求 IP 地址
|
||||||
|
const requestIpString = req.ip || req.socket.remoteAddress;
|
||||||
|
|
||||||
|
if (!requestIpString) {
|
||||||
|
console.warn('无法获取请求 IP 地址,已拒绝访问。');
|
||||||
|
return res.status(403).json({ message: '禁止访问:无法识别来源 IP。' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是本地开发环境的 IP
|
||||||
|
if (LOCAL_IPS.includes(requestIpString)) {
|
||||||
|
console.log(`允许来自本地开发环境 (${requestIpString}) 的访问。`);
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
const whitelistString = await settingsService.getSetting(IP_WHITELIST_SETTING_KEY);
|
||||||
|
|
||||||
|
// 如果白名单未设置或为空字符串,则允许所有请求
|
||||||
|
if (!whitelistString || whitelistString.trim() === '') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析白名单字符串 (假设以换行符或逗号分隔)
|
||||||
|
const whitelistEntries = whitelistString
|
||||||
|
.split(/[\n,]+/) // 按换行符或逗号分割
|
||||||
|
.map(entry => entry.trim()) // 去除首尾空格
|
||||||
|
.filter(entry => entry.length > 0); // 过滤空条目
|
||||||
|
|
||||||
|
// 如果解析后白名单为空,也允许所有请求 (避免配置错误导致完全锁死)
|
||||||
|
if (whitelistEntries.length === 0) {
|
||||||
|
console.warn('IP 白名单设置非空但解析后为空,暂时允许所有 IP。请检查设置。');
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
let requestIp: ipaddr.IPv4 | ipaddr.IPv6 | null = null;
|
||||||
|
try {
|
||||||
|
requestIp = ipaddr.parse(requestIpString);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`无法解析请求 IP 地址 "${requestIpString}",已拒绝访问。`);
|
||||||
|
return res.status(403).json({ message: '禁止访问:无效的来源 IP 格式。' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 IP 是否匹配白名单中的任何条目
|
||||||
|
const isAllowed = whitelistEntries.some(entry => {
|
||||||
|
try {
|
||||||
|
// 尝试解析为 CIDR 范围
|
||||||
|
const range = ipaddr.parseCIDR(entry);
|
||||||
|
// 使用 match 方法检查 IP 是否在范围内
|
||||||
|
// 需要根据 IP 类型调用正确的 match 签名
|
||||||
|
if (requestIp!.kind() === 'ipv4' && range[0].kind() === 'ipv4') {
|
||||||
|
return (requestIp! as ipaddr.IPv4).match(range as [ipaddr.IPv4, number]);
|
||||||
|
} else if (requestIp!.kind() === 'ipv6' && range[0].kind() === 'ipv6') {
|
||||||
|
// 注意:IPv6 的 match 可能需要特殊处理,取决于 ipaddr.js 的具体实现和类型定义
|
||||||
|
// 这里假设 IPv6 的 match 签名与 IPv4 类似,但可能需要调整
|
||||||
|
return (requestIp! as ipaddr.IPv6).match(range as [ipaddr.IPv6, number]);
|
||||||
|
}
|
||||||
|
// 如果 IP 类型和范围类型不匹配,则认为不匹配
|
||||||
|
return false;
|
||||||
|
} catch (e1) {
|
||||||
|
// 如果解析 CIDR 失败,尝试解析为单个 IP 地址
|
||||||
|
try {
|
||||||
|
const allowedIp = ipaddr.parse(entry);
|
||||||
|
// 比较地址是否相同
|
||||||
|
return requestIp!.kind() === allowedIp.kind() && requestIp!.toString() === allowedIp.toString();
|
||||||
|
} catch (e2) {
|
||||||
|
// 如果单个 IP 也解析失败,忽略此条目并记录警告
|
||||||
|
console.warn(`无效的 IP 白名单条目: "${entry}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isAllowed) {
|
||||||
|
// IP 在白名单内,允许继续处理请求
|
||||||
|
return next();
|
||||||
|
} else {
|
||||||
|
// IP 不在白名单内,拒绝访问
|
||||||
|
console.warn(`已拒绝来自 IP ${requestIpString} 的访问 (不在白名单内)。`);
|
||||||
|
return res.status(403).json({ message: '禁止访问:您的 IP 地址不在允许列表中。' });
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('IP 白名单中间件执行出错:', error);
|
||||||
|
// 中间件出错时,为安全起见,默认拒绝访问
|
||||||
|
return res.status(500).json({ message: '服务器内部错误 (IP 校验失败)。' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import proxyRoutes from './proxies/proxies.routes'; // 导入代理路由
|
|||||||
import tagsRouter from './tags/tags.routes'; // 导入标签路由
|
import tagsRouter from './tags/tags.routes'; // 导入标签路由
|
||||||
import settingsRoutes from './settings/settings.routes'; // 导入设置路由
|
import settingsRoutes from './settings/settings.routes'; // 导入设置路由
|
||||||
import { initializeWebSocket } from './websocket';
|
import { initializeWebSocket } from './websocket';
|
||||||
|
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; // 导入 IP 白名单中间件
|
||||||
|
|
||||||
// 基础 Express 应用设置 (后续会扩展)
|
// 基础 Express 应用设置 (后续会扩展)
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -25,6 +26,9 @@ const SQLiteStore = connectSqlite3(session);
|
|||||||
const dbPath = path.resolve(__dirname, '../../data'); // 数据库目录路径
|
const dbPath = path.resolve(__dirname, '../../data'); // 数据库目录路径
|
||||||
|
|
||||||
// --- 中间件 ---
|
// --- 中间件 ---
|
||||||
|
// !! 重要:IP 白名单应尽可能早地应用,通常在其他中间件之前 !!
|
||||||
|
app.use(ipWhitelistMiddleware as RequestHandler); // 应用 IP 白名单中间件 (使用类型断言)
|
||||||
|
|
||||||
app.use(express.json()); // 添加此行以解析 JSON 请求体
|
app.use(express.json()); // 添加此行以解析 JSON 请求体
|
||||||
|
|
||||||
// 会话中间件配置
|
// 会话中间件配置
|
||||||
|
|||||||
@@ -1,224 +1,82 @@
|
|||||||
import { Database } from 'sqlite3';
|
import { Database } from 'sqlite3';
|
||||||
import { getDb } from './database';
|
import { getDb } from './database';
|
||||||
|
|
||||||
const createUsersTableSQL = `
|
const createSettingsTableSQL = `
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
key TEXT PRIMARY KEY NOT NULL,
|
||||||
username TEXT UNIQUE NOT NULL,
|
value TEXT NOT NULL,
|
||||||
hashed_password TEXT NOT NULL,
|
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
|
||||||
two_factor_secret TEXT NULL, -- 2FA 密钥占位符
|
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 更新后的 Schema,支持密码和密钥认证
|
const createAuditLogsTableSQL = `
|
||||||
const createConnectionsTableSQL = `
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
CREATE TABLE IF NOT EXISTS connections (
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
action_type TEXT NOT NULL,
|
||||||
|
details TEXT NULL
|
||||||
|
);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const createApiKeysTableSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
host TEXT NOT NULL,
|
hashed_key TEXT UNIQUE NOT NULL,
|
||||||
port INTEGER NOT NULL DEFAULT 22,
|
created_at INTEGER NOT NULL
|
||||||
username TEXT NOT NULL,
|
|
||||||
auth_method TEXT NOT NULL CHECK(auth_method IN ('password', 'key')), -- 更新 CHECK 约束
|
|
||||||
encrypted_password TEXT NULL,
|
|
||||||
encrypted_private_key TEXT NULL,
|
|
||||||
encrypted_passphrase TEXT NULL,
|
|
||||||
proxy_id INTEGER NULL, -- 新增:关联的代理 ID
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL,
|
|
||||||
last_connected_at INTEGER NULL,
|
|
||||||
FOREIGN KEY (proxy_id) REFERENCES proxies(id) ON DELETE SET NULL -- 设置外键约束,删除代理时将关联设为 NULL
|
|
||||||
);
|
);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 新增:创建 proxies 表的 SQL (与文档同步)
|
|
||||||
const createProxiesTableSQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS proxies (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
type TEXT NOT NULL CHECK(type IN ('SOCKS5', 'HTTP')), -- 代理类型,目前支持 SOCKS5 和 HTTP
|
|
||||||
host TEXT NOT NULL,
|
|
||||||
port INTEGER NOT NULL,
|
|
||||||
username TEXT NULL, -- 代理认证用户名 (可选)
|
|
||||||
auth_method TEXT NOT NULL DEFAULT 'none' CHECK(auth_method IN ('none', 'password', 'key')), -- 添加 auth_method
|
|
||||||
encrypted_password TEXT NULL, -- 加密存储的代理密码 (可选)
|
|
||||||
encrypted_private_key TEXT NULL, -- 添加 encrypted_private_key
|
|
||||||
encrypted_passphrase TEXT NULL, -- 添加 encrypted_passphrase
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 新增:创建 tags 表的 SQL
|
|
||||||
const createTagsTableSQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS tags (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL UNIQUE, -- 标签名称,唯一
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 新增:创建 connection_tags 关联表的 SQL
|
|
||||||
const createConnectionTagsTableSQL = `
|
|
||||||
CREATE TABLE IF NOT EXISTS connection_tags (
|
|
||||||
connection_id INTEGER NOT NULL,
|
|
||||||
tag_id INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (connection_id, tag_id),
|
|
||||||
FOREIGN KEY (connection_id) REFERENCES connections(id) ON DELETE CASCADE, -- 删除连接时,自动删除关联
|
|
||||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE -- 删除标签时,自动删除关联
|
|
||||||
);
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 未来可能需要的其他表 (根据项目文档)
|
|
||||||
// const createSettingsTableSQL = \`...\`; // 设置表
|
|
||||||
// const createAuditLogsTableSQL = \`...\`; // 审计日志表
|
|
||||||
// const createApiKeysTableSQL = \`...\`; // API 密钥表
|
|
||||||
|
|
||||||
// Interface for PRAGMA table_info result rows
|
|
||||||
interface TableInfoColumn {
|
|
||||||
cid: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
notnull: number;
|
|
||||||
dflt_value: any;
|
|
||||||
pk: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to add a column if it doesn't exist
|
|
||||||
const addColumnIfNotExists = (db: Database, tableName: string, columnName: string, columnDefinition: string): Promise<void> => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Check if the column exists using PRAGMA table_info
|
|
||||||
// Explicitly type the 'columns' parameter
|
|
||||||
db.all(`PRAGMA table_info(${tableName})`, (err, columns: TableInfoColumn[]) => {
|
|
||||||
if (err) {
|
|
||||||
console.error(`Error checking table info for ${tableName}:`, err.message);
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
// Now 'col' inside .some() will have the correct type
|
|
||||||
const columnExists = columns.some(col => col.name === columnName);
|
|
||||||
if (!columnExists) {
|
|
||||||
// Column doesn't exist, add it
|
|
||||||
const sql = `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`;
|
|
||||||
db.run(sql, (alterErr) => {
|
|
||||||
if (alterErr) {
|
|
||||||
console.error(`Error adding column ${columnName} to ${tableName}:`, alterErr.message);
|
|
||||||
// Don't reject immediately, maybe it's a harmless error (like constraint issue)
|
|
||||||
// Let subsequent migrations try. If it's critical, the app might fail later.
|
|
||||||
console.warn(`Potential harmless error adding column ${columnName}. Continuing migration.`);
|
|
||||||
resolve();
|
|
||||||
// return reject(alterErr);
|
|
||||||
} else {
|
|
||||||
console.log(`Column ${columnName} added to table ${tableName}.`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Column already exists
|
|
||||||
// console.log(`Column ${columnName} already exists in table ${tableName}.`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行数据库迁移 (创建表和添加列)
|
|
||||||
* @param db - 数据库实例
|
|
||||||
* @returns Promise,在所有迁移完成后 resolve
|
|
||||||
*/
|
|
||||||
export const runMigrations = async (db: Database): Promise<void> => {
|
export const runMigrations = async (db: Database): Promise<void> => {
|
||||||
// Use async/await for better readability with sequential operations
|
|
||||||
try {
|
try {
|
||||||
|
// 创建 settings 表 (如果不存在)
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
db.run(createUsersTableSQL, (err) => {
|
db.run(createSettingsTableSQL, (err: Error | null) => {
|
||||||
if (err) return reject(new Error(`创建 users 表时出错: ${err.message}`));
|
if (err) return reject(new Error(`创建 settings 表时出错: ${err.message}`));
|
||||||
console.log('Users 表已检查/创建。');
|
console.log('Settings 表已检查/创建。');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 插入默认的 IP 白名单设置
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
db.run("INSERT OR IGNORE INTO settings (key, value) VALUES ('ipWhitelistEnabled', 'false')", (err: Error | null) => {
|
||||||
|
if (err) return reject(new Error(`插入默认 ipWhitelistEnabled 设置时出错: ${err.message}`));
|
||||||
|
console.log('默认 ipWhitelistEnabled 设置已插入。');
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
db.run(createConnectionsTableSQL, (err) => {
|
db.run("INSERT OR IGNORE INTO settings (key, value) VALUES ('ipWhitelist', '')", (err: Error | null) => {
|
||||||
// Ignore "duplicate column name" error if table already exists partially
|
if (err) return reject(new Error(`插入默认 ipWhitelist 设置时出错: ${err.message}`));
|
||||||
if (err && !err.message.includes('duplicate column name')) {
|
console.log('默认 ipWhitelist 设置已插入。');
|
||||||
return reject(new Error(`创建 connections 表时出错: ${err.message}`));
|
|
||||||
}
|
|
||||||
if (err && err.message.includes('duplicate column name')) {
|
|
||||||
console.warn('创建 connections 表时遇到 "duplicate column name" 错误,可能表已部分存在,将尝试 ALTER TABLE。');
|
|
||||||
}
|
|
||||||
console.log('Connections 表已检查/尝试创建。');
|
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add columns to connections table if they don't exist
|
// 创建 audit_logs 表 (如果不存在)
|
||||||
// Add auth_method first in case it's missing from very old schema
|
|
||||||
await addColumnIfNotExists(db, 'connections', 'auth_method', "TEXT NOT NULL DEFAULT 'password'"); // Add default for existing rows
|
|
||||||
await addColumnIfNotExists(db, 'connections', 'encrypted_private_key', 'TEXT NULL');
|
|
||||||
await addColumnIfNotExists(db, 'connections', 'encrypted_passphrase', 'TEXT NULL');
|
|
||||||
// 新增:添加 proxy_id 列到 connections 表 (如果不存在)
|
|
||||||
// 注意:直接添加带 FOREIGN KEY 的列在旧版 SQLite 中可能有限制,但现代版本通常支持。
|
|
||||||
// 如果遇到问题,可能需要更复杂的迁移步骤(创建新表,复制数据,重命名)。
|
|
||||||
// 这里我们先尝试直接添加。ON DELETE SET NULL 意味着如果代理被删除,关联的连接不会被删除,只是 proxy_id 变为空。
|
|
||||||
await addColumnIfNotExists(db, 'connections', 'proxy_id', 'INTEGER NULL REFERENCES proxies(id) ON DELETE SET NULL');
|
|
||||||
|
|
||||||
// 创建 proxies 表 (如果不存在)
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
db.run(createProxiesTableSQL, (err) => {
|
db.run(createAuditLogsTableSQL, (err: Error | null) => {
|
||||||
if (err) return reject(new Error(`创建 proxies 表时出错: ${err.message}`));
|
if (err) return reject(new Error(`创建 audit_logs 表时出错: ${err.message}`));
|
||||||
console.log('Proxies 表已检查/创建。');
|
console.log('Audit_Logs 表已检查/创建。');
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add columns to proxies table if they don't exist (to match documentation)
|
// 创建 api_keys 表 (如果不存在)
|
||||||
await addColumnIfNotExists(db, 'proxies', 'auth_method', "TEXT NOT NULL DEFAULT 'none'");
|
|
||||||
await addColumnIfNotExists(db, 'proxies', 'encrypted_private_key', 'TEXT NULL');
|
|
||||||
await addColumnIfNotExists(db, 'proxies', 'encrypted_passphrase', 'TEXT NULL');
|
|
||||||
|
|
||||||
|
|
||||||
// 新增:创建 tags 表 (如果不存在)
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
db.run(createTagsTableSQL, (err) => {
|
db.run(createApiKeysTableSQL, (err: Error | null) => {
|
||||||
if (err) return reject(new Error(`创建 tags 表时出错: ${err.message}`));
|
if (err) return reject(new Error(`创建 api_keys 表时出错: ${err.message}`));
|
||||||
console.log('Tags 表已检查/创建。');
|
console.log('Api_Keys 表已检查/创建。');
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 新增:创建 connection_tags 表 (如果不存在)
|
console.log('所有数据库迁移已完成。');
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
db.run(createConnectionTagsTableSQL, (err) => {
|
|
||||||
if (err) return reject(new Error(`创建 connection_tags 表时出错: ${err.message}`));
|
|
||||||
console.log('Connection_Tags 表已检查/创建。');
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add other tables or columns here in the future
|
|
||||||
|
|
||||||
console.log('数据库迁移检查完成。');
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('数据库迁移过程中发生错误:', error);
|
console.error('数据库迁移过程中出错:', error);
|
||||||
throw error; // Re-throw the error to be caught by the caller
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 允许通过命令行直接运行此文件来执行迁移 (例如: node dist/migrations.js)
|
|
||||||
if (require.main === module) {
|
|
||||||
const db = getDb();
|
|
||||||
runMigrations(db)
|
|
||||||
.then(() => {
|
|
||||||
console.log('数据库迁移执行成功。');
|
|
||||||
// 如果是独立运行,可以选择关闭数据库连接,但在应用启动流程中通常不需要
|
|
||||||
// db.close();
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('数据库迁移执行失败:', err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,9 +36,14 @@ export const settingsRepository = {
|
|||||||
|
|
||||||
async setSetting(key: string, value: string): Promise<void> {
|
async setSetting(key: string, value: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const now = Math.floor(Date.now() / 1000); // 获取当前 Unix 时间戳
|
||||||
db.run(
|
db.run(
|
||||||
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value',
|
`INSERT INTO settings (key, value, created_at, updated_at)
|
||||||
[key, value],
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at`,
|
||||||
|
[key, value, now, now],
|
||||||
function (err: any) { // 添加 err 类型
|
function (err: any) { // 添加 err 类型
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(`设置设置项 ${key} 时出错:`, err); // 更新日志为中文
|
console.error(`设置设置项 ${key} 时出错:`, err); // 更新日志为中文
|
||||||
|
|||||||
@@ -47,4 +47,29 @@ export const settingsService = {
|
|||||||
async deleteSetting(key: string): Promise<void> {
|
async deleteSetting(key: string): Promise<void> {
|
||||||
await settingsRepository.deleteSetting(key);
|
await settingsRepository.deleteSetting(key);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 IP 白名单设置
|
||||||
|
* @returns 返回包含启用状态和白名单列表的对象
|
||||||
|
*/
|
||||||
|
async getIpWhitelistSettings(): Promise<{ enabled: boolean; whitelist: string }> {
|
||||||
|
const enabledStr = await settingsRepository.getSetting('ipWhitelistEnabled');
|
||||||
|
const whitelist = await settingsRepository.getSetting('ipWhitelist');
|
||||||
|
return {
|
||||||
|
enabled: enabledStr === 'true', // 将字符串 'true' 转换为布尔值
|
||||||
|
whitelist: whitelist ?? '', // 如果为 null 则返回空字符串
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新 IP 白名单设置
|
||||||
|
* @param enabled 是否启用 IP 白名单
|
||||||
|
* @param whitelist 允许的 IP 地址/CIDR 列表 (字符串形式)
|
||||||
|
*/
|
||||||
|
async updateIpWhitelistSettings(enabled: boolean, whitelist: string): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
settingsRepository.setSetting('ipWhitelistEnabled', String(enabled)), // 将布尔值转换为字符串
|
||||||
|
settingsRepository.setSetting('ipWhitelist', whitelist),
|
||||||
|
]);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -324,6 +324,20 @@
|
|||||||
"passwordRequiredForDisable": "Current password is required to disable.",
|
"passwordRequiredForDisable": "Current password is required to disable.",
|
||||||
"disableFailed": "Failed to disable two-factor authentication."
|
"disableFailed": "Failed to disable two-factor authentication."
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ipWhitelist": {
|
||||||
|
"title": "IP Whitelist",
|
||||||
|
"description": "Configure allowed IP addresses or ranges to access this application. Leave empty to allow all IPs.",
|
||||||
|
"label": "Allowed IP Addresses/Ranges (one per line or comma-separated):",
|
||||||
|
"hint": "Supports IPv4, IPv6, and CIDR (e.g., 192.168.1.100, 10.0.0.0/8, 2001:db8::/32).",
|
||||||
|
"saveButton": "Save Whitelist",
|
||||||
|
"success": {
|
||||||
|
"saved": "IP whitelist saved successfully."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"fetchFailed": "Failed to fetch IP whitelist settings.",
|
||||||
|
"saveFailed": "Failed to save IP whitelist."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|||||||
@@ -327,6 +327,20 @@
|
|||||||
"passwordRequiredForDisable": "需要输入当前密码才能禁用。",
|
"passwordRequiredForDisable": "需要输入当前密码才能禁用。",
|
||||||
"disableFailed": "禁用两步验证失败。"
|
"disableFailed": "禁用两步验证失败。"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"ipWhitelist": {
|
||||||
|
"title": "IP 白名单",
|
||||||
|
"description": "配置允许访问此应用的 IP 地址或范围。留空则允许所有 IP。",
|
||||||
|
"label": "允许的 IP 地址/范围 (每行一个或用逗号分隔):",
|
||||||
|
"hint": "支持 IPv4, IPv6 和 CIDR (例如 192.168.1.100, 10.0.0.0/8, 2001:db8::/32)。",
|
||||||
|
"saveButton": "保存白名单",
|
||||||
|
"success": {
|
||||||
|
"saved": "IP 白名单已成功保存。"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"fetchFailed": "获取 IP 白名单设置失败。",
|
||||||
|
"saveFailed": "保存 IP 白名单失败。"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
|
|||||||
@@ -69,6 +69,24 @@
|
|||||||
<p v-if="twoFactorMessage" :class="{ 'success-message': twoFactorSuccess, 'error-message': !twoFactorSuccess }">{{ twoFactorMessage }}</p>
|
<p v-if="twoFactorMessage" :class="{ 'success-message': twoFactorSuccess, 'error-message': !twoFactorSuccess }">{{ twoFactorMessage }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<h2>{{ $t('settings.ipWhitelist.title') }}</h2>
|
||||||
|
<p>{{ $t('settings.ipWhitelist.description') }}</p>
|
||||||
|
<form @submit.prevent="handleUpdateIpWhitelist">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="ipWhitelist">{{ $t('settings.ipWhitelist.label') }}</label>
|
||||||
|
<textarea id="ipWhitelist" v-model="ipWhitelistInput" rows="5"></textarea>
|
||||||
|
<small>{{ $t('settings.ipWhitelist.hint') }}</small>
|
||||||
|
</div>
|
||||||
|
<button type="submit" :disabled="ipWhitelistLoading">{{ ipWhitelistLoading ? $t('common.loading') : $t('settings.ipWhitelist.saveButton') }}</button>
|
||||||
|
<p v-if="ipWhitelistMessage" :class="{ 'success-message': ipWhitelistSuccess, 'error-message': !ipWhitelistSuccess }">{{ ipWhitelistMessage }}</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 其他设置项可以在这里添加 -->
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -98,6 +116,12 @@ const setupData = ref<{ secret: string; qrCodeUrl: string } | null>(null); //
|
|||||||
const verificationCode = ref(''); // 用户输入的验证码
|
const verificationCode = ref(''); // 用户输入的验证码
|
||||||
const disablePassword = ref(''); // 禁用时需要输入的密码
|
const disablePassword = ref(''); // 禁用时需要输入的密码
|
||||||
|
|
||||||
|
// --- IP 白名单状态 ---
|
||||||
|
const ipWhitelistInput = ref(''); // 用于编辑的文本区域内容
|
||||||
|
const ipWhitelistLoading = ref(false);
|
||||||
|
const ipWhitelistMessage = ref('');
|
||||||
|
const ipWhitelistSuccess = ref(false);
|
||||||
|
|
||||||
// 计算属性判断当前是否处于 2FA 设置流程中
|
// 计算属性判断当前是否处于 2FA 设置流程中
|
||||||
const isSettingUp2FA = computed(() => setupData.value !== null);
|
const isSettingUp2FA = computed(() => setupData.value !== null);
|
||||||
|
|
||||||
@@ -109,8 +133,27 @@ const checkTwoFactorStatus = async () => {
|
|||||||
twoFactorEnabled.value = authStore.user?.isTwoFactorEnabled ?? false;
|
twoFactorEnabled.value = authStore.user?.isTwoFactorEnabled ?? false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取当前的 IP 白名单设置
|
||||||
|
const fetchIpWhitelist = async () => {
|
||||||
|
ipWhitelistLoading.value = true;
|
||||||
|
ipWhitelistMessage.value = '';
|
||||||
|
try {
|
||||||
|
// 使用 settings API 获取所有设置
|
||||||
|
const response = await axios.get<Record<string, string>>('/api/v1/settings');
|
||||||
|
ipWhitelistInput.value = response.data['ipWhitelist'] || ''; // 从设置中获取,默认为空字符串
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('获取 IP 白名单设置失败:', error);
|
||||||
|
ipWhitelistMessage.value = t('settings.ipWhitelist.error.fetchFailed');
|
||||||
|
ipWhitelistSuccess.value = false;
|
||||||
|
} finally {
|
||||||
|
ipWhitelistLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
onMounted(async () => { // 使 onMounted 异步
|
onMounted(async () => { // 使 onMounted 异步
|
||||||
await checkTwoFactorStatus(); // 等待状态检查完成
|
await checkTwoFactorStatus(); // 等待状态检查完成
|
||||||
|
await fetchIpWhitelist(); // 获取 IP 白名单设置
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -222,6 +265,28 @@ const cancelSetup = () => {
|
|||||||
twoFactorMessage.value = '';
|
twoFactorMessage.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- IP 白名单相关方法 ---
|
||||||
|
const handleUpdateIpWhitelist = async () => {
|
||||||
|
ipWhitelistLoading.value = true;
|
||||||
|
ipWhitelistMessage.value = '';
|
||||||
|
ipWhitelistSuccess.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 settings API 更新设置
|
||||||
|
await axios.put('/api/v1/settings', {
|
||||||
|
ipWhitelist: ipWhitelistInput.value.trim() // 发送修剪后的值
|
||||||
|
});
|
||||||
|
ipWhitelistMessage.value = t('settings.ipWhitelist.success.saved');
|
||||||
|
ipWhitelistSuccess.value = true;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('更新 IP 白名单失败:', error);
|
||||||
|
ipWhitelistMessage.value = error.response?.data?.message || t('settings.ipWhitelist.error.saveFailed');
|
||||||
|
ipWhitelistSuccess.value = false;
|
||||||
|
} finally {
|
||||||
|
ipWhitelistLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -246,12 +311,30 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
input[type="password"],
|
input[type="password"],
|
||||||
input[type="text"] {
|
input[type="text"],
|
||||||
|
textarea { /* 添加 textarea 样式 */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #ccc; /* 确保 textarea 有边框 */
|
||||||
|
border-radius: 4px; /* 确保 textarea 有圆角 */
|
||||||
|
font-family: inherit; /* 继承字体 */
|
||||||
|
font-size: inherit; /* 继承字号 */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical; /* 允许垂直调整大小 */
|
||||||
|
min-height: 80px; /* 设置最小高度 */
|
||||||
|
}
|
||||||
|
|
||||||
|
small { /* 提示文字样式 */
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
Reference in New Issue
Block a user