huge update.

fix issues with failing tests (I'm just gonna fucking assume directx isn't installed on the github actions machines).

Added MUCH better RBAC support and control

Fixed an issue when you have over a million clients RBAC would be slow asf

Add settings page with some basic settings and configs you can change.
This commit is contained in:
KingKDot
2026-03-06 20:50:02 -06:00
parent 0b4e059f2b
commit d26668df78
26 changed files with 2092 additions and 50 deletions
+11 -2
View File
@@ -10,7 +10,6 @@ import {
const JWT_ISSUER = "overlord-server";
const JWT_AUDIENCE = "overlord-client";
const JWT_EXPIRATION = "7d";
const tokenBlacklist = new Set<string>();
@@ -25,6 +24,15 @@ function getSecretKey(): Uint8Array {
return new TextEncoder().encode(config.auth.jwtSecret);
}
export function getSessionTtlHours(): number {
const configured = Number(getConfig().security.sessionTtlHours) || 168;
return Math.min(24 * 30, Math.max(1, configured));
}
export function getSessionTtlSeconds(): number {
return getSessionTtlHours() * 60 * 60;
}
export interface JWTPayload {
sub: string;
userId: number;
@@ -42,6 +50,7 @@ export interface AuthenticatedUser {
}
export async function generateToken(user: User): Promise<string> {
const expiration = `${getSessionTtlHours()}h`;
const token = await new SignJWT({
sub: user.username,
userId: user.id,
@@ -51,7 +60,7 @@ export async function generateToken(user: User): Promise<string> {
.setIssuedAt()
.setIssuer(JWT_ISSUER)
.setAudience(JWT_AUDIENCE)
.setExpirationTime(JWT_EXPIRATION)
.setExpirationTime(expiration)
.sign(getSecretKey());
return token;
+111
View File
@@ -32,6 +32,17 @@ export interface Config {
telegramBotToken: string;
telegramChatId: string;
};
security: {
sessionTtlHours: number;
loginMaxAttempts: number;
loginWindowMinutes: number;
loginLockoutMinutes: number;
passwordMinLength: number;
passwordRequireUppercase: boolean;
passwordRequireLowercase: boolean;
passwordRequireNumber: boolean;
passwordRequireSymbol: boolean;
};
}
const DEFAULT_CONFIG: Config = {
@@ -62,6 +73,17 @@ const DEFAULT_CONFIG: Config = {
telegramBotToken: "",
telegramChatId: "",
},
security: {
sessionTtlHours: 168,
loginMaxAttempts: 5,
loginWindowMinutes: 15,
loginLockoutMinutes: 30,
passwordMinLength: 6,
passwordRequireUppercase: false,
passwordRequireLowercase: false,
passwordRequireNumber: false,
passwordRequireSymbol: false,
},
};
type SaveSecrets = {
@@ -256,6 +278,44 @@ export function loadConfig(): Config {
fileConfig.notifications?.telegramChatId ||
DEFAULT_CONFIG.notifications.telegramChatId,
},
security: {
sessionTtlHours:
Number(process.env.OVERLORD_SESSION_TTL_HOURS) ||
fileConfig.security?.sessionTtlHours ||
DEFAULT_CONFIG.security.sessionTtlHours,
loginMaxAttempts:
Number(process.env.OVERLORD_LOGIN_MAX_ATTEMPTS) ||
fileConfig.security?.loginMaxAttempts ||
DEFAULT_CONFIG.security.loginMaxAttempts,
loginWindowMinutes:
Number(process.env.OVERLORD_LOGIN_WINDOW_MINUTES) ||
fileConfig.security?.loginWindowMinutes ||
DEFAULT_CONFIG.security.loginWindowMinutes,
loginLockoutMinutes:
Number(process.env.OVERLORD_LOGIN_LOCKOUT_MINUTES) ||
fileConfig.security?.loginLockoutMinutes ||
DEFAULT_CONFIG.security.loginLockoutMinutes,
passwordMinLength:
Number(process.env.OVERLORD_PASSWORD_MIN_LENGTH) ||
fileConfig.security?.passwordMinLength ||
DEFAULT_CONFIG.security.passwordMinLength,
passwordRequireUppercase:
String(process.env.OVERLORD_PASSWORD_REQUIRE_UPPERCASE || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireUppercase ||
DEFAULT_CONFIG.security.passwordRequireUppercase,
passwordRequireLowercase:
String(process.env.OVERLORD_PASSWORD_REQUIRE_LOWERCASE || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireLowercase ||
DEFAULT_CONFIG.security.passwordRequireLowercase,
passwordRequireNumber:
String(process.env.OVERLORD_PASSWORD_REQUIRE_NUMBER || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireNumber ||
DEFAULT_CONFIG.security.passwordRequireNumber,
passwordRequireSymbol:
String(process.env.OVERLORD_PASSWORD_REQUIRE_SYMBOL || "").toLowerCase() === "true" ||
fileConfig.security?.passwordRequireSymbol ||
DEFAULT_CONFIG.security.passwordRequireSymbol,
},
};
if (saveChanged) {
@@ -332,3 +392,54 @@ export async function updateNotificationsConfig(
await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
return next;
}
export async function updateSecurityConfig(
updates: Partial<Config["security"]>,
): Promise<Config["security"]> {
const current = getConfig();
const next = {
...current.security,
...updates,
};
const toNumberOr = (value: unknown, fallback: number) => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
next.sessionTtlHours = Math.min(24 * 30, Math.max(1, toNumberOr(next.sessionTtlHours, 168)));
next.loginMaxAttempts = Math.min(50, Math.max(1, toNumberOr(next.loginMaxAttempts, 5)));
next.loginWindowMinutes = Math.min(24 * 24, Math.max(1, toNumberOr(next.loginWindowMinutes, 15)));
next.loginLockoutMinutes = Math.min(24 * 24, Math.max(1, toNumberOr(next.loginLockoutMinutes, 30)));
next.passwordMinLength = Math.min(128, Math.max(6, toNumberOr(next.passwordMinLength, 6)));
next.passwordRequireUppercase = Boolean(next.passwordRequireUppercase);
next.passwordRequireLowercase = Boolean(next.passwordRequireLowercase);
next.passwordRequireNumber = Boolean(next.passwordRequireNumber);
next.passwordRequireSymbol = Boolean(next.passwordRequireSymbol);
configCache = {
...current,
security: next,
};
const configPath = resolve(process.cwd(), "config.json");
let fileConfig: any = {};
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, "utf-8");
fileConfig = JSON.parse(content) || {};
} catch {
fileConfig = {};
}
}
fileConfig.security = next;
try {
await mkdir(resolve(process.cwd()), { recursive: true });
} catch {}
await writeFile(configPath, JSON.stringify(fileConfig, null, 2));
return next;
}
+48 -3
View File
@@ -187,6 +187,26 @@ export function unbanIp(ip: string) {
db.run(`DELETE FROM banned_ips WHERE ip=?`, ip);
}
export type BannedIpEntry = {
ip: string;
reason: string | null;
createdAt: number;
};
export function listBannedIps(): BannedIpEntry[] {
const rows = db
.query<{ ip: string; reason: string | null; createdAt: number }>(
`SELECT ip, reason, created_at as createdAt FROM banned_ips ORDER BY created_at DESC`,
)
.all();
return rows.map((row) => ({
ip: row.ip,
reason: row.reason,
createdAt: Number(row.createdAt) || 0,
}));
}
export function isIpBanned(ip: string): boolean {
const row = db.query<{ ip: string }>(`SELECT ip FROM banned_ips WHERE ip=?`).get(ip);
return !!row?.ip;
@@ -198,7 +218,16 @@ export function markAllClientsOffline() {
}
export function listClients(filters: ListFilters): ListResult {
const { page, pageSize, search, sort, statusFilter, osFilter } = filters;
const {
page,
pageSize,
search,
sort,
statusFilter,
osFilter,
allowedClientIds,
deniedClientIds,
} = filters;
const where: string[] = [];
const params: any[] = [];
@@ -221,6 +250,20 @@ export function listClients(filters: ListFilters): ListResult {
params.push(osFilter);
}
if (Array.isArray(allowedClientIds)) {
if (allowedClientIds.length === 0) {
where.push("1=0");
} else {
where.push(`id IN (${allowedClientIds.map(() => "?").join(",")})`);
params.push(...allowedClientIds);
}
}
if (Array.isArray(deniedClientIds) && deniedClientIds.length > 0) {
where.push(`id NOT IN (${deniedClientIds.map(() => "?").join(",")})`);
params.push(...deniedClientIds);
}
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
const orderBy = (() => {
@@ -242,8 +285,10 @@ export function listClients(filters: ListFilters): ListResult {
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients ${whereSql}`)
.get(...params) ?? { c: 0 };
const onlineRow = db
.query<{ c: number }>(`SELECT COUNT(*) as c FROM clients WHERE online=1`)
.get() ?? { c: 0 };
.query<{ c: number }>(
`SELECT COUNT(*) as c FROM clients ${whereSql ? `${whereSql} AND online=1` : "WHERE online=1"}`,
)
.get(...params) ?? { c: 0 };
const offset = (page - 1) * pageSize;
const rows = db
+5 -2
View File
@@ -1,10 +1,12 @@
import { describe, expect, test } from "bun:test";
import { getConfig } from "./config";
import { isRateLimited, recordFailedAttempt, recordSuccessfulAttempt } from "./rateLimit";
describe("rateLimit", () => {
test("locks out after repeated failures", () => {
const maxAttempts = Math.max(1, Number(getConfig().security.loginMaxAttempts) || 5);
const ip = `10.0.0.${Date.now()}`;
for (let i = 0; i < 5; i += 1) {
for (let i = 0; i < maxAttempts; i += 1) {
recordFailedAttempt(ip);
}
@@ -14,8 +16,9 @@ describe("rateLimit", () => {
});
test("successful attempt clears lock state", () => {
const maxAttempts = Math.max(1, Number(getConfig().security.loginMaxAttempts) || 5);
const ip = `10.0.1.${Date.now()}`;
for (let i = 0; i < 5; i += 1) {
for (let i = 0; i < maxAttempts; i += 1) {
recordFailedAttempt(ip);
}
expect(isRateLimited(ip).limited).toBe(true);
+21 -11
View File
@@ -1,4 +1,5 @@
import { logger } from "./logger";
import { getConfig } from "./config";
interface RateLimitEntry {
attempts: number;
@@ -8,15 +9,21 @@ interface RateLimitEntry {
const rateLimitStore = new Map<string, RateLimitEntry>();
const MAX_ATTEMPTS = 5;
const WINDOW_MS = 15 * 60 * 1000;
const LOCKOUT_MS = 30 * 60 * 1000;
const CLEANUP_INTERVAL = 60 * 1000;
function getRateLimitPolicy() {
const security = getConfig().security;
const maxAttempts = Math.min(50, Math.max(1, Number(security.loginMaxAttempts) || 5));
const windowMs = Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, (Number(security.loginWindowMinutes) || 15) * 60 * 1000));
const lockoutMs = Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, (Number(security.loginLockoutMinutes) || 30) * 60 * 1000));
return { maxAttempts, windowMs, lockoutMs };
}
export function isRateLimited(ip: string): {
limited: boolean;
retryAfter?: number;
} {
const policy = getRateLimitPolicy();
const entry = rateLimitStore.get(ip);
if (!entry) {
@@ -30,14 +37,14 @@ export function isRateLimited(ip: string): {
return { limited: true, retryAfter };
}
if (now - entry.firstAttempt > WINDOW_MS) {
if (now - entry.firstAttempt > policy.windowMs) {
rateLimitStore.delete(ip);
return { limited: false };
}
if (entry.attempts >= MAX_ATTEMPTS) {
entry.lockedUntil = now + LOCKOUT_MS;
const retryAfter = Math.ceil(LOCKOUT_MS / 1000);
if (entry.attempts >= policy.maxAttempts) {
entry.lockedUntil = now + policy.lockoutMs;
const retryAfter = Math.ceil(policy.lockoutMs / 1000);
logger.warn(
`[rate-limit] IP ${ip} locked out for ${retryAfter}s after ${entry.attempts} failed attempts`,
);
@@ -48,6 +55,7 @@ export function isRateLimited(ip: string): {
}
export function recordFailedAttempt(ip: string): void {
const policy = getRateLimitPolicy();
const now = Date.now();
const entry = rateLimitStore.get(ip);
@@ -59,7 +67,7 @@ export function recordFailedAttempt(ip: string): void {
return;
}
if (now - entry.firstAttempt > WINDOW_MS) {
if (now - entry.firstAttempt > policy.windowMs) {
rateLimitStore.set(ip, {
attempts: 1,
firstAttempt: now,
@@ -69,7 +77,7 @@ export function recordFailedAttempt(ip: string): void {
entry.attempts++;
logger.debug(
`[rate-limit] IP ${ip} failed attempt ${entry.attempts}/${MAX_ATTEMPTS}`,
`[rate-limit] IP ${ip} failed attempt ${entry.attempts}/${policy.maxAttempts}`,
);
}
@@ -78,11 +86,12 @@ export function recordSuccessfulAttempt(ip: string): void {
}
function cleanupExpired(): void {
const policy = getRateLimitPolicy();
const now = Date.now();
let cleaned = 0;
for (const [ip, entry] of rateLimitStore.entries()) {
const windowExpired = now - entry.firstAttempt > WINDOW_MS;
const windowExpired = now - entry.firstAttempt > policy.windowMs;
const lockoutExpired = entry.lockedUntil && entry.lockedUntil < now;
if ((windowExpired && !entry.lockedUntil) || lockoutExpired) {
@@ -114,6 +123,7 @@ export function getRateLimitStats(): { total: number; locked: number } {
setInterval(cleanupExpired, CLEANUP_INTERVAL);
const initialPolicy = getRateLimitPolicy();
logger.info(
`[rate-limit] Initialized: ${MAX_ATTEMPTS} attempts per ${WINDOW_MS / 60000} minutes, ${LOCKOUT_MS / 60000} minute lockout`,
`[rate-limit] Initialized: ${initialPolicy.maxAttempts} attempts per ${Math.round(initialPolicy.windowMs / 60000)} minutes, ${Math.round(initialPolicy.lockoutMs / 60000)} minute lockout`,
);
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { getSessionTtlHours, getSessionTtlSeconds } from "./auth";
import { getConfig, updateSecurityConfig, type Config } from "./config";
import { isRateLimited, recordFailedAttempt, recordSuccessfulAttempt } from "./rateLimit";
let originalSecurity: Config["security"];
beforeAll(() => {
originalSecurity = { ...getConfig().security };
});
afterAll(async () => {
await updateSecurityConfig({ ...originalSecurity });
});
describe("security settings", () => {
test("session TTL helpers reflect configured policy", async () => {
await updateSecurityConfig({ sessionTtlHours: 3 });
expect(getSessionTtlHours()).toBe(3);
expect(getSessionTtlSeconds()).toBe(3 * 60 * 60);
});
test("rate limiting honors configured max attempts", async () => {
await updateSecurityConfig({
loginMaxAttempts: 2,
loginWindowMinutes: 5,
loginLockoutMinutes: 5,
});
const ip = `10.9.0.${Date.now()}`;
recordFailedAttempt(ip);
expect(isRateLimited(ip).limited).toBe(false);
recordFailedAttempt(ip);
const limited = isRateLimited(ip);
expect(limited.limited).toBe(true);
expect(limited.retryAfter).toBeGreaterThan(0);
recordSuccessfulAttempt(ip);
expect(isRateLimited(ip).limited).toBe(false);
});
test("security bounds are clamped", async () => {
const updated = await updateSecurityConfig({
sessionTtlHours: 100000,
loginMaxAttempts: 0,
loginWindowMinutes: 0,
loginLockoutMinutes: 0,
passwordMinLength: 2,
});
expect(updated.sessionTtlHours).toBe(24 * 30);
expect(updated.loginMaxAttempts).toBe(1);
expect(updated.loginWindowMinutes).toBe(1);
expect(updated.loginLockoutMinutes).toBe(1);
expect(updated.passwordMinLength).toBe(6);
});
});
@@ -3,6 +3,7 @@ import {
authenticateUser,
extractTokenFromRequest,
generateToken,
getSessionTtlSeconds,
getUserFromRequest,
revokeToken,
} from "../../auth";
@@ -62,6 +63,7 @@ export async function handleAuthRoutes(
if (user) {
const token = await generateToken(user);
const sessionTtlSeconds = getSessionTtlSeconds();
logger.info(
`[auth] User ${user.username} logged in. must_change_password =`,
@@ -93,7 +95,7 @@ export async function handleAuthRoutes(
{
headers: {
"Content-Type": "application/json",
"Set-Cookie": `overlord_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
"Set-Cookie": `overlord_token=${token}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${sessionTtlSeconds}`,
},
},
);
@@ -7,12 +7,20 @@ import {
clientExists,
deleteClientRow,
getClientIp,
isIpBanned,
listBannedIps,
listClients,
setOnlineState,
unbanIp,
} from "../../db";
import { metrics } from "../../metrics";
import { encodeMessage } from "../../protocol";
import { requirePermission } from "../../rbac";
import {
canUserAccessClient,
getUserClientAccessScope,
listUserClientRuleIdsByAccess,
} from "../../users";
type RequestIpProvider = {
requestIP: (req: Request) => { address?: string } | null | undefined;
@@ -43,7 +51,8 @@ export async function handleClientRoutes(
}
if (url.pathname === "/api/clients") {
if (!(await authenticateRequest(req))) {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const page = Math.max(1, Number(url.searchParams.get("page") || 1));
@@ -52,10 +61,86 @@ export async function handleClientRoutes(
const sort = url.searchParams.get("sort") || "last_seen_desc";
const statusFilter = url.searchParams.get("status") || "all";
const osFilter = url.searchParams.get("os") || "all";
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
if (user.role === "admin") {
const result = listClients({ page, pageSize, search, sort, statusFilter, osFilter });
return Response.json(result, { headers: deps.CORS_HEADERS });
}
const scope = getUserClientAccessScope(user.userId);
if (scope === "none") {
return Response.json(
{ page, pageSize, total: 0, online: 0, items: [] },
{ headers: deps.CORS_HEADERS },
);
}
const allowedClientIds =
scope === "allowlist"
? listUserClientRuleIdsByAccess(user.userId, "allow")
: undefined;
const deniedClientIds =
scope === "denylist"
? listUserClientRuleIdsByAccess(user.userId, "deny")
: undefined;
const result = listClients({
page,
pageSize,
search,
sort,
statusFilter,
osFilter,
allowedClientIds,
deniedClientIds,
});
return Response.json(result, { headers: deps.CORS_HEADERS });
}
if (url.pathname === "/api/clients/banned-ips") {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requirePermission(user, "clients:control");
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
if (req.method === "GET") {
return Response.json({ items: listBannedIps() }, { headers: deps.CORS_HEADERS });
}
if (req.method === "DELETE") {
const ipToUnban = (url.searchParams.get("ip") || "").trim();
if (!ipToUnban) {
return Response.json({ error: "Missing ip query parameter" }, { status: 400 });
}
if (!/^[0-9a-fA-F:.]{3,64}$/.test(ipToUnban)) {
return Response.json({ error: "Invalid IP format" }, { status: 400 });
}
if (!isIpBanned(ipToUnban)) {
return Response.json({ error: "IP is not banned" }, { status: 404 });
}
unbanIp(ipToUnban);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: user.username,
ip,
action: AuditAction.COMMAND,
details: `Unbanned IP ${ipToUnban}`,
success: true,
});
return Response.json({ ok: true }, { headers: deps.CORS_HEADERS });
}
}
const banMatch = url.pathname.match(/^\/api\/clients\/(.+)\/ban$/);
if (req.method === "POST" && banMatch) {
const user = await authenticateRequest(req);
@@ -68,6 +153,9 @@ export async function handleClientRoutes(
}
const targetId = banMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const target = clientManager.getClient(targetId);
const targetIp = target?.ip || getClientIp(targetId);
if (!targetIp) {
@@ -99,10 +187,14 @@ export async function handleClientRoutes(
const thumbnailMatch = url.pathname.match(/^\/api\/clients\/(.+)\/thumbnail$/);
if (req.method === "POST" && thumbnailMatch) {
if (!(await authenticateRequest(req))) {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
const clientId = thumbnailMatch[1];
if (!canUserAccessClient(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const { generateThumbnail, markThumbnailRequested } = await import("../../thumbnails");
markThumbnailRequested(clientId);
const target = clientManager.getClient(clientId);
@@ -135,6 +227,9 @@ export async function handleClientRoutes(
}
const targetId = clientDeleteMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const target = clientManager.getClient(targetId);
const existsInDb = clientExists(targetId);
if (!target && !existsInDb) {
@@ -181,6 +276,9 @@ export async function handleClientRoutes(
}
const targetId = cmdMatch[1];
if (!canUserAccessClient(user.userId, user.role, targetId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const target = clientManager.getClient(targetId);
const ip = server.requestIP(req)?.address || "unknown";
@@ -1,6 +1,7 @@
import { authenticateRequest } from "../../auth";
import * as clientManager from "../../clientManager";
import { getAuditLogs } from "../../auditLog";
import { AuditAction, getAuditLogs, logAudit } from "../../auditLog";
import { getConfig, updateSecurityConfig } from "../../config";
import { listClients } from "../../db";
import { logger } from "../../logger";
import { metrics } from "../../metrics";
@@ -9,6 +10,7 @@ import { requirePermission } from "../../rbac";
type MiscRouteDeps = {
CORS_HEADERS: Record<string, string>;
SERVER_VERSION: string;
requestIP?: (req: Request) => { address?: string } | null | undefined;
getConsoleSessionCount: () => number;
getRdSessionCount: () => number;
getFileBrowserSessionCount: () => number;
@@ -93,6 +95,53 @@ export async function handleMiscRoutes(
});
}
if (url.pathname === "/api/settings/security") {
const user = await authenticateRequest(req);
if (!user) {
return new Response("Unauthorized", { status: 401 });
}
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
if (req.method === "GET") {
return Response.json({ security: getConfig().security }, { headers: deps.CORS_HEADERS });
}
if (req.method === "PUT") {
let body: any = {};
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
const updated = await updateSecurityConfig({
sessionTtlHours: Number(body?.sessionTtlHours),
loginMaxAttempts: Number(body?.loginMaxAttempts),
loginWindowMinutes: Number(body?.loginWindowMinutes),
loginLockoutMinutes: Number(body?.loginLockoutMinutes),
passwordMinLength: Number(body?.passwordMinLength),
passwordRequireUppercase: Boolean(body?.passwordRequireUppercase),
passwordRequireLowercase: Boolean(body?.passwordRequireLowercase),
passwordRequireNumber: Boolean(body?.passwordRequireNumber),
passwordRequireSymbol: Boolean(body?.passwordRequireSymbol),
});
logAudit({
timestamp: Date.now(),
username: user.username,
ip: deps.requestIP?.(req)?.address || "unknown",
action: AuditAction.COMMAND,
details: "Updated security settings",
success: true,
});
return Response.json({ ok: true, security: updated }, { headers: deps.CORS_HEADERS });
}
}
if (req.method === "GET" && url.pathname === "/api/audit-logs") {
const user = await authenticateRequest(req);
if (!user) {
@@ -1,6 +1,6 @@
import { authenticateRequest } from "../../auth";
import { requirePermission } from "../../rbac";
import { getUserById } from "../../users";
import { canUserAccessClient, getUserById, type UserRole } from "../../users";
type PageRouteDeps = {
PUBLIC_ROOT: string;
@@ -37,6 +37,11 @@ export async function handlePageRoutes(
url: URL,
deps: PageRouteDeps,
): Promise<Response | null> {
const canAccessClientPage = (userId: number, role: UserRole, clientId: string): boolean => {
if (!clientId) return false;
return canUserAccessClient(userId, role, clientId);
};
if (req.method === "GET" && (url.pathname === "/" || url.pathname === "/index.html")) {
const authed = await authenticateRequest(req);
@@ -71,6 +76,12 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
if (!canAccessClientPage(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/remotedesktop.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("remotedesktop.html")) });
@@ -89,6 +100,12 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
const clientId = (url.searchParams.get("clientId") || "").trim();
if (!canAccessClientPage(user.userId, user.role, clientId)) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/hvnc.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("hvnc.html")) });
@@ -168,6 +185,42 @@ export async function handlePageRoutes(
}
}
if (req.method === "GET" && url.pathname === "/user-client-access") {
const user = await authenticateRequest(req);
if (!user) {
return serveLoginOrUnauthorized(deps);
}
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
if (user.role !== "admin") {
return new Response("Forbidden: Admin access required", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/user-client-access.html`);
if (await file.exists()) {
return new Response(file, {
headers: deps.secureHeaders(deps.mimeType("user-client-access.html")),
});
}
}
if (req.method === "GET" && url.pathname === "/settings") {
const user = await authenticateRequest(req);
if (!user) {
return serveLoginOrUnauthorized(deps);
}
const maybeChange = await serveChangePasswordIfRequired(deps, user.userId);
if (maybeChange) return maybeChange;
const file = Bun.file(`${deps.PUBLIC_ROOT}/settings.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("settings.html")) });
}
}
if (req.method === "GET" && url.pathname === "/build") {
const user = await authenticateRequest(req);
if (!user) {
@@ -210,6 +263,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, consolePageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/console.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("console.html")) });
@@ -226,6 +282,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, filesPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/filebrowser.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("filebrowser.html")) });
@@ -242,6 +301,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, processesPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/processes.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("processes.html")) });
@@ -258,6 +320,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, keyloggerPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/keylogger.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("keylogger.html")) });
@@ -274,6 +339,9 @@ export async function handlePageRoutes(
if (user.role === "viewer") {
return new Response("Forbidden: Viewers cannot access interactive features", { status: 403 });
}
if (!canAccessClientPage(user.userId, user.role, proxyPageMatch[1])) {
return new Response("Forbidden: Client access denied", { status: 403 });
}
const file = Bun.file(`${deps.PUBLIC_ROOT}/proxy.html`);
if (await file.exists()) {
return new Response(file, { headers: deps.secureHeaders(deps.mimeType("proxy.html")) });
@@ -1,12 +1,19 @@
import { generateToken, authenticateRequest } from "../../auth";
import { generateToken, authenticateRequest, getSessionTtlSeconds } from "../../auth";
import { AuditAction, logAudit } from "../../auditLog";
import { logger } from "../../logger";
import { requirePermission } from "../../rbac";
import {
type ClientAccessRuleKind,
type ClientAccessScope,
createUser,
deleteUser,
getUserById,
getUserClientAccessScope,
listUsers,
listUserClientAccessRules,
removeUserClientAccessRule,
setUserClientAccessRule,
setUserClientAccessScope,
updateUserPassword,
updateUserRole,
} from "../../users";
@@ -114,6 +121,7 @@ export async function handleUsersRoutes(
if (user.userId === userId && targetUser) {
const newToken = await generateToken(targetUser);
const sessionTtlSeconds = getSessionTtlSeconds();
return new Response(
JSON.stringify({
success: true,
@@ -122,7 +130,7 @@ export async function handleUsersRoutes(
{
headers: {
"Content-Type": "application/json",
"Set-Cookie": `overlord_token=${newToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=604800`,
"Set-Cookie": `overlord_token=${newToken}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${sessionTtlSeconds}`,
},
},
);
@@ -166,6 +174,118 @@ export async function handleUsersRoutes(
return Response.json({ error: result.error }, { status: 400 });
}
if (req.method === "GET" && url.pathname.match(/^\/api\/users\/\d+\/client-access$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const scope = getUserClientAccessScope(userId);
const rules = listUserClientAccessRules(userId);
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Viewed client access policy for user: ${targetUser.username}`,
success: true,
});
return Response.json({ scope, rules });
}
if (req.method === "PUT" && url.pathname.match(/^\/api\/users\/\d+\/client-access$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const body = await req.json();
const scope = body?.scope as ClientAccessScope;
const result = setUserClientAccessScope(userId, scope);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Updated client access scope for ${targetUser.username} to ${scope}`,
success: true,
});
return Response.json({ success: true, scope });
}
if (req.method === "POST" && url.pathname.match(/^\/api\/users\/\d+\/client-access\/rules$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const body = await req.json();
const clientId = String(body?.clientId || "").trim();
const access = body?.access as ClientAccessRuleKind;
const result = setUserClientAccessRule(userId, clientId, access);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Set client access rule for ${targetUser.username}: ${access} ${clientId}`,
success: true,
});
return Response.json({ success: true });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+\/client-access\/rules$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
const targetUser = getUserById(userId);
if (!targetUser) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const clientId = String(url.searchParams.get("clientId") || "").trim();
if (!clientId) {
return Response.json({ error: "clientId is required" }, { status: 400 });
}
const result = removeUserClientAccessRule(userId, clientId);
if (!result.success) {
return Response.json({ error: result.error }, { status: 400 });
}
const ip = server.requestIP(req)?.address || "unknown";
logAudit({
timestamp: Date.now(),
username: authedUser.username,
ip,
action: AuditAction.COMMAND,
details: `Removed client access rule for ${targetUser.username}: ${clientId}`,
success: true,
});
return Response.json({ success: true });
}
if (req.method === "DELETE" && url.pathname.match(/^\/api\/users\/\d+$/)) {
const authedUser = requirePermission(user, "users:manage");
const userId = parseInt(url.pathname.split("/")[3]);
+2
View File
@@ -28,6 +28,8 @@ export type ListFilters = {
sort: string;
statusFilter?: string;
osFilter?: string;
allowedClientIds?: string[];
deniedClientIds?: string[];
};
export type ListItem = ClientInfo & {
@@ -0,0 +1,107 @@
import { describe, expect, test } from "bun:test";
import {
canUserAccessClient,
canViewClients,
createUser,
deleteUser,
getUserByUsername,
getUserClientAccessScope,
listUserClientAccessRules,
setUserClientAccessRule,
setUserClientAccessScope,
updateUserRole,
} from "./users";
const createdUserIds: number[] = [];
async function createTempUser(role: "viewer" | "operator" | "admin") {
const username = `r_${role.slice(0, 3)}_${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
const result = await createUser(username, "Aa1!VeryLongTestPassword_2026", role, "test");
expect(result.success).toBe(true);
const user = getUserByUsername(username);
expect(user).not.toBeNull();
createdUserIds.push(user!.id);
return user!;
}
function cleanupCreatedUsers() {
while (createdUserIds.length > 0) {
const id = createdUserIds.pop();
if (typeof id === "number") {
deleteUser(id);
}
}
}
describe("users client access RBAC", () => {
test("clients:view permission is admin-only by default", () => {
expect(canViewClients("admin")).toBe(true);
expect(canViewClients("operator")).toBe(false);
expect(canViewClients("viewer")).toBe(false);
});
test("new non-admin users default to no client visibility", async () => {
try {
const viewer = await createTempUser("viewer");
expect(getUserClientAccessScope(viewer.id)).toBe("none");
expect(canUserAccessClient(viewer.id, viewer.role, "client-a")).toBe(false);
} finally {
cleanupCreatedUsers();
}
});
test("allowlist scope only allows explicitly approved clients", async () => {
try {
const operator = await createTempUser("operator");
const scopeResult = setUserClientAccessScope(operator.id, "allowlist");
expect(scopeResult.success).toBe(true);
const ruleResult = setUserClientAccessRule(operator.id, "client-allowed", "allow");
expect(ruleResult.success).toBe(true);
const rules = listUserClientAccessRules(operator.id);
expect(rules.some((rule) => rule.clientId === "client-allowed" && rule.access === "allow")).toBe(true);
expect(canUserAccessClient(operator.id, operator.role, "client-allowed")).toBe(true);
expect(canUserAccessClient(operator.id, operator.role, "client-other")).toBe(false);
} finally {
cleanupCreatedUsers();
}
});
test("denylist scope blocks denied clients and allows others", async () => {
try {
const operator = await createTempUser("operator");
const scopeResult = setUserClientAccessScope(operator.id, "denylist");
expect(scopeResult.success).toBe(true);
const ruleResult = setUserClientAccessRule(operator.id, "client-denied", "deny");
expect(ruleResult.success).toBe(true);
expect(canUserAccessClient(operator.id, operator.role, "client-denied")).toBe(false);
expect(canUserAccessClient(operator.id, operator.role, "client-safe")).toBe(true);
} finally {
cleanupCreatedUsers();
}
});
test("role changes reset scope between admin and non-admin", async () => {
try {
const operator = await createTempUser("operator");
expect(getUserClientAccessScope(operator.id)).toBe("none");
const toAdmin = updateUserRole(operator.id, "admin");
expect(toAdmin.success).toBe(true);
expect(getUserClientAccessScope(operator.id)).toBe("all");
const backToOperator = updateUserRole(operator.id, "operator");
expect(backToOperator.success).toBe(true);
expect(getUserClientAccessScope(operator.id)).toBe("none");
} finally {
cleanupCreatedUsers();
}
});
});
+196 -12
View File
@@ -9,6 +9,8 @@ const dbPath = resolve(dataDir, "overlord.db");
const db = new Database(dbPath);
export type UserRole = "admin" | "operator" | "viewer";
export type ClientAccessScope = "none" | "allowlist" | "denylist" | "all";
export type ClientAccessRuleKind = "allow" | "deny";
export interface User {
id: number;
@@ -19,6 +21,7 @@ export interface User {
last_login: number | null;
created_by: string | null;
must_change_password: number;
client_scope: ClientAccessScope;
}
export interface UserInfo {
@@ -28,6 +31,13 @@ export interface UserInfo {
created_at: number;
last_login: number | null;
created_by: string | null;
client_scope: ClientAccessScope;
}
export interface UserClientAccessRule {
userId: number;
clientId: string;
access: ClientAccessRuleKind;
}
db.exec(`
@@ -39,10 +49,26 @@ db.exec(`
created_at INTEGER NOT NULL,
last_login INTEGER,
created_by TEXT,
must_change_password INTEGER DEFAULT 0
must_change_password INTEGER DEFAULT 0,
client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS user_client_access_rules (
user_id INTEGER NOT NULL,
client_id TEXT NOT NULL,
access TEXT NOT NULL CHECK(access IN ('allow', 'deny')),
created_at INTEGER NOT NULL,
PRIMARY KEY (user_id, client_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_user_client_access_rules_user ON user_client_access_rules(user_id)`,
);
try {
db.exec(
`ALTER TABLE users ADD COLUMN must_change_password INTEGER DEFAULT 0`,
@@ -54,6 +80,23 @@ try {
}
}
try {
db.exec(
`ALTER TABLE users ADD COLUMN client_scope TEXT NOT NULL DEFAULT 'none' CHECK(client_scope IN ('none', 'allowlist', 'denylist', 'all'))`,
);
logger.info("[users] Added client_scope column to existing database");
} catch (err: any) {
if (!err.message?.includes("duplicate column name")) {
logger.error("[users] Migration error:", err);
}
}
try {
db.exec(`UPDATE users SET client_scope='all' WHERE role='admin'`);
} catch (err: any) {
logger.error("[users] Failed to normalize admin client_scope:", err);
}
const userCount = db.prepare("SELECT COUNT(*) as count FROM users").get() as {
count: number;
};
@@ -69,8 +112,8 @@ if (userCount.count === 0) {
});
db.prepare(
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password) VALUES (?, ?, ?, ?, ?, ?)",
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1);
"INSERT INTO users (username, password_hash, role, created_at, created_by, must_change_password, client_scope) VALUES (?, ?, ?, ?, ?, ?, ?)",
).run(initialUsername, defaultPassword, "admin", Date.now(), "system", 1, "all");
const createdUser = db
.prepare("SELECT * FROM users WHERE username = ?")
@@ -106,12 +149,146 @@ export function getUserByUsername(username: string): User | null {
export function listUsers(): UserInfo[] {
const users = db
.prepare(
"SELECT id, username, role, created_at, last_login, created_by FROM users ORDER BY created_at DESC",
"SELECT id, username, role, created_at, last_login, created_by, client_scope FROM users ORDER BY created_at DESC",
)
.all() as UserInfo[];
return users;
}
export function getUserClientAccessScope(userId: number): ClientAccessScope {
const row = db
.prepare("SELECT client_scope FROM users WHERE id = ?")
.get(userId) as { client_scope?: ClientAccessScope } | undefined;
return row?.client_scope || "none";
}
export function listUserClientAccessRules(userId: number): UserClientAccessRule[] {
return db
.prepare(
"SELECT user_id as userId, client_id as clientId, access FROM user_client_access_rules WHERE user_id = ? ORDER BY client_id ASC",
)
.all(userId) as UserClientAccessRule[];
}
export function listUserClientRuleIdsByAccess(
userId: number,
access: ClientAccessRuleKind,
): string[] {
return db
.prepare(
"SELECT client_id as clientId FROM user_client_access_rules WHERE user_id = ? AND access = ? ORDER BY client_id ASC",
)
.all(userId, access)
.map((row: { clientId: string }) => row.clientId);
}
export function setUserClientAccessScope(
userId: number,
scope: ClientAccessScope,
): { success: boolean; error?: string } {
if (!["none", "allowlist", "denylist", "all"].includes(scope)) {
return { success: false, error: "Invalid client access scope" };
}
try {
db.prepare("UPDATE users SET client_scope = ? WHERE id = ?").run(scope, userId);
return { success: true };
} catch (err: any) {
logger.error("[users] setUserClientAccessScope error:", err);
return { success: false, error: err.message || "Failed to update client access scope" };
}
}
export function setUserClientAccessRule(
userId: number,
clientId: string,
access: ClientAccessRuleKind,
): { success: boolean; error?: string } {
const normalizedClientId = (clientId || "").trim();
if (!normalizedClientId) {
return { success: false, error: "clientId is required" };
}
if (!["allow", "deny"].includes(access)) {
return { success: false, error: "Invalid client access rule" };
}
try {
db.prepare(
"INSERT OR REPLACE INTO user_client_access_rules (user_id, client_id, access, created_at) VALUES (?, ?, ?, ?)",
).run(userId, normalizedClientId, access, Date.now());
return { success: true };
} catch (err: any) {
logger.error("[users] setUserClientAccessRule error:", err);
return { success: false, error: err.message || "Failed to update client access rule" };
}
}
export function removeUserClientAccessRule(
userId: number,
clientId: string,
): { success: boolean; error?: string } {
try {
db.prepare("DELETE FROM user_client_access_rules WHERE user_id = ? AND client_id = ?").run(
userId,
clientId,
);
return { success: true };
} catch (err: any) {
logger.error("[users] removeUserClientAccessRule error:", err);
return { success: false, error: err.message || "Failed to remove client access rule" };
}
}
export function canUserAccessClient(
userId: number,
role: UserRole,
clientId: string,
): boolean {
if (role === "admin") return true;
const scope = getUserClientAccessScope(userId);
if (scope === "none") return false;
if (scope === "all") return true;
const row = db
.prepare(
"SELECT access FROM user_client_access_rules WHERE user_id = ? AND client_id = ?",
)
.get(userId, clientId) as { access?: ClientAccessRuleKind } | undefined;
if (scope === "allowlist") {
return row?.access === "allow";
}
if (scope === "denylist") {
return row?.access !== "deny";
}
return false;
}
function validatePasswordPolicy(password: string): string | null {
const security = getConfig().security;
const minLength = Math.min(128, Math.max(6, Number(security.passwordMinLength) || 6));
if (!password || password.length < minLength) {
return `Password must be at least ${minLength} characters`;
}
if (security.passwordRequireUppercase && !/[A-Z]/.test(password)) {
return "Password must include at least one uppercase letter";
}
if (security.passwordRequireLowercase && !/[a-z]/.test(password)) {
return "Password must include at least one lowercase letter";
}
if (security.passwordRequireNumber && !/[0-9]/.test(password)) {
return "Password must include at least one number";
}
if (security.passwordRequireSymbol && !/[^A-Za-z0-9]/.test(password)) {
return "Password must include at least one symbol";
}
return null;
}
export async function createUser(
username: string,
password: string,
@@ -133,8 +310,9 @@ export async function createUser(
};
}
if (!password || password.length < 6) {
return { success: false, error: "Password must be at least 6 characters" };
const policyError = validatePasswordPolicy(password);
if (policyError) {
return { success: false, error: policyError };
}
const existing = getUserByUsername(username);
@@ -150,9 +328,9 @@ export async function createUser(
const result = db
.prepare(
"INSERT INTO users (username, password_hash, role, created_at, created_by) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO users (username, password_hash, role, created_at, created_by, client_scope) VALUES (?, ?, ?, ?, ?, ?)",
)
.run(username, passwordHash, role, Date.now(), createdBy);
.run(username, passwordHash, role, Date.now(), createdBy, role === "admin" ? "all" : "none");
return { success: true, userId: result.lastInsertRowid as number };
} catch (err: any) {
@@ -165,8 +343,9 @@ export async function updateUserPassword(
userId: number,
newPassword: string,
): Promise<{ success: boolean; error?: string }> {
if (!newPassword || newPassword.length < 6) {
return { success: false, error: "Password must be at least 6 characters" };
const policyError = validatePasswordPolicy(newPassword);
if (policyError) {
return { success: false, error: policyError };
}
try {
@@ -193,7 +372,12 @@ export function updateUserRole(
newRole: UserRole,
): { success: boolean; error?: string } {
try {
db.prepare("UPDATE users SET role = ? WHERE id = ?").run(newRole, userId);
const nextScope: ClientAccessScope = newRole === "admin" ? "all" : "none";
db.prepare("UPDATE users SET role = ?, client_scope = ? WHERE id = ?").run(
newRole,
nextScope,
userId,
);
return { success: true };
} catch (err: any) {
console.error("[users] Update role error:", err);
@@ -253,7 +437,7 @@ export function canControlClients(role: UserRole): boolean {
}
export function canViewClients(role: UserRole): boolean {
return true;
return role === "admin";
}
export function canViewAuditLogs(role: UserRole): boolean {
+1 -1
View File
@@ -1 +1 @@
export const SERVER_VERSION = "1.1.0";
export const SERVER_VERSION = "1.2.1";