feat: Enhanced security, UI improvements, and animations (#432)

* fix: Remove empty catch blocks and add error logging

* refactor: Modularize server stats widget collectors

* feat: Add i18n support for terminal customization and login stats

- Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages
- Add SSH login statistics translations
- Update HostManagerEditor to use i18n for all terminal customization UI elements
- Update LoginStatsWidget to use i18n for all UI text
- Add missing logger imports in backend files for improved debugging

* feat: Add keyboard shortcut enhancements with Kbd component

- Add shadcn kbd component for displaying keyboard shortcuts
- Enhance file manager context menu to display shortcuts with Kbd component
- Add 5 new keyboard shortcuts to file manager:
  - Ctrl+D: Download selected files
  - Ctrl+N: Create new file
  - Ctrl+Shift+N: Create new folder
  - Ctrl+U: Upload files
  - Enter: Open/run selected file
- Add keyboard shortcut hints to command palette footer
- Create helper function to parse and render keyboard shortcuts

* feat: Add i18n support for command palette

- Add commandPalette translation section with 22 keys to all 4 languages
- Update CommandPalette component to use i18n for all UI text
- Translate search placeholder, group headings, menu items, and shortcut hints
- Support multilingual command palette interface

* feat: Add smooth transitions and animations to UI

- Add fade-in/fade-out transition to command palette (200ms)
- Add scale animation to command palette on open/close
- Add smooth popup animation to context menu (150ms)
- Add visual feedback for file selection with ring effect
- Add hover scale effect to file grid items
- Add transition-all to list view items for consistent behavior
- Zero JavaScript overhead, pure CSS transitions
- All animations under 200ms for instant feel

* feat: Add button active state and dashboard card animations

- Add active:scale-95 to all buttons for tactile click feedback
- Add hover border effect to dashboard cards (150ms transition)
- Add pulse animation to dashboard loading states
- Pure CSS transitions with zero JavaScript overhead
- Improves enterprise-level feel of UI

* feat: Add smooth macOS-style page transitions

- Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in)
- Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile)
- Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json
- Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior

All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX

* fix: Add key prop to force animation re-trigger on tab switch

Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation

* revert: Remove page transition animations

Page switching animations were not noticeable enough and felt unnecessary.
Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes

* feat: Add ripple effect to login/logout transitions

Add three-layer expanding ripple animation during fadeOut phase:
- Ripples expand from screen center using primary theme color
- Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect
- Ripples fade out as they expand to create elegant visual feedback
- Uses pure CSS keyframe animation, no external libraries

Total animation: 800ms ripple + 300ms screen fade

* feat: Add smooth TERMIX logo animation to transitions

Changes:
- Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel
- Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety
- Slow down ripple animation from 0.8s to 2s with cubic-bezier easing
- Add centered TERMIX logo with monospace font and subtitle
- Logo fades in from 80% scale, holds, then fades out at 110% scale
- Total effect: 1.2s logo animation synced with 2s ripple waves

Creates a premium, branded transition experience

* feat: Enhance transition animation with premium details

Timing adjustments:
- Extend fadeOut from 800ms to 1200ms
- Extend fadeIn from 600ms to 800ms
- Slow background fade to 700ms for elegance

Visual enhancements:
- Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays
- Ripple animation extended to 2.5s with refined opacity curve
- Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px
- Logo glow effect: triple-layer text-shadow using primary theme color
- Increase logo size from text-6xl to text-7xl
- Subtitle delayed fade-in from bottom with smooth slide animation

Creates a cinematic, polished brand experience

* feat: Redesign login page with split-screen cinematic layout

Major redesign of authentication page:

Left Side (40% width):
- Full-height gradient background using primary theme color
- Large TERMIX logo with glow effect
- Subtitle and tagline
- Infinite animated ripple waves (3 layers)
- Hidden on mobile, shows brand identity

Right Side (60% width):
- Centered glassmorphism card with backdrop blur
- Refined tab switcher with pill-style active state
- Enlarged title with gradient text effect
- Added welcome subtitles for better UX
- Card slides in from bottom on load
- All existing functionality preserved

Visual enhancements:
- Tab navigation: segmented control style in muted container
- Active tab: white background with subtle shadow
- Smooth 200ms transitions on all interactions
- Card: rounded-2xl, shadow-xl, semi-transparent border

Creates premium, modern login experience matching transition animations

* feat: Update login page theme colors and add i18n support

- Changed login page gradient from blue to match dark theme colors
- Updated ripple effects to use theme primary color
- Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal)
- Updated all language files (en, zh, de, ru, pt-BR) with new translations
- Fixed TypeScript compilation issues by clearing build cache

* refactor: Use shadcn Tabs component and fix modal styling

- Replace custom tab navigation with shadcn Tabs component
- Restore border-2 border-dark-border for modal consistency
- Remove circular icon from login success message
- Simplify authentication success display

* refactor: Remove ripple effects and gradient from login page

- Remove animated ripple background effects
- Remove gradient background, use solid color (bg-dark-bg-darker)
- Remove text-shadow glow effect from logo
- Simplify brand showcase to clean, minimal design

* feat: Add decorative slash and remove subtitle from login page

- Add decorative slash divider with gradient lines below TERMIX logo
- Remove subtitle text (welcomeBack and createAccount)
- Simplify page title to show only the main heading

* feat: Add diagonal line pattern background to login page

- Replace decorative slash with subtle diagonal line pattern background
- Use repeating-linear-gradient at 45deg angle
- Set very low opacity (0.03) for subtle effect
- Pattern uses theme primary color

* fix: Display diagonal line pattern on login background

- Combine background color and pattern in single style attribute
- Use white semi-transparent lines (rgba 0.03 opacity)
- 45deg angle, 35px spacing, 2px width
- Remove separate overlay div to ensure pattern visibility

* security: Fix user enumeration vulnerability in login

- Unify error messages for invalid username and incorrect password
- Both return 401 status with 'Invalid username or password'
- Prevent attackers from enumerating valid usernames
- Maintain detailed logging for debugging purposes
- Changed from 404 'User not found' to generic auth failure message

* security: Add login rate limiting to prevent brute force attacks

- Implement LoginRateLimiter with IP and username-based tracking
- Block after 5 failed attempts within 15 minutes
- Lock account/IP for 15 minutes after threshold
- Automatic cleanup of expired entries every 5 minutes
- Track remaining attempts in logs for monitoring
- Return 429 status with remaining time on rate limit
- Reset counters on successful login
- Dual protection: both IP-based and username-based limits
This commit was merged in pull request #432.
This commit is contained in:
ZacharyZcR
2025-11-09 09:48:32 +08:00
committed by GitHub
parent b43e98073f
commit 5fc2ec3dc0
55 changed files with 2081 additions and 649 deletions

View File

@@ -1480,13 +1480,17 @@ app.get(
if (status.hasUnencryptedDb) {
try {
unencryptedSize = fs.statSync(dbPath).size;
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
}
if (status.hasEncryptedDb) {
try {
encryptedSize = fs.statSync(encryptedDbPath).size;
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
}
res.json({

View File

@@ -22,11 +22,12 @@ import { nanoid } from "nanoid";
import speakeasy from "speakeasy";
import QRCode from "qrcode";
import type { Request, Response } from "express";
import { authLogger } from "../../utils/logger.js";
import { authLogger, databaseLogger } from "../../utils/logger.js";
import { AuthManager } from "../../utils/auth-manager.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js";
import { parseUserAgent } from "../../utils/user-agent-parser.js";
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
const authManager = AuthManager.getInstance();
@@ -885,6 +886,7 @@ router.get("/oidc/callback", async (req, res) => {
// POST /users/login
router.post("/login", async (req, res) => {
const { username, password } = req.body;
const clientIp = req.ip || req.socket.remoteAddress || "unknown";
if (!isNonEmptyString(username) || !isNonEmptyString(password)) {
authLogger.warn("Invalid traditional login attempt", {
@@ -895,6 +897,21 @@ router.post("/login", async (req, res) => {
return res.status(400).json({ error: "Invalid username or password" });
}
// Check rate limiting
const lockStatus = loginRateLimiter.isLocked(clientIp, username);
if (lockStatus.locked) {
authLogger.warn("Login attempt blocked due to rate limiting", {
operation: "user_login_blocked",
username,
ip: clientIp,
remainingTime: lockStatus.remainingTime,
});
return res.status(429).json({
error: "Too many login attempts. Please try again later.",
remainingTime: lockStatus.remainingTime,
});
}
try {
const row = db.$client
.prepare("SELECT value FROM settings WHERE key = 'allow_password_login'")
@@ -919,11 +936,14 @@ router.post("/login", async (req, res) => {
.where(eq(users.username, username));
if (!user || user.length === 0) {
authLogger.warn(`User not found: ${username}`, {
loginRateLimiter.recordFailedAttempt(clientIp, username);
authLogger.warn(`Login failed: user not found`, {
operation: "user_login",
username,
ip: clientIp,
remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username),
});
return res.status(404).json({ error: "User not found" });
return res.status(401).json({ error: "Invalid username or password" });
}
const userRecord = user[0];
@@ -941,12 +961,15 @@ router.post("/login", async (req, res) => {
const isMatch = await bcrypt.compare(password, userRecord.password_hash);
if (!isMatch) {
authLogger.warn(`Incorrect password for user: ${username}`, {
loginRateLimiter.recordFailedAttempt(clientIp, username);
authLogger.warn(`Login failed: incorrect password`, {
operation: "user_login",
username,
userId: userRecord.id,
ip: clientIp,
remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username),
});
return res.status(401).json({ error: "Incorrect password" });
return res.status(401).json({ error: "Invalid username or password" });
}
try {
@@ -958,7 +981,9 @@ router.post("/login", async (req, res) => {
if (kekSalt.length === 0) {
await authManager.registerUser(userRecord.id, password);
}
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", { error });
}
const dataUnlocked = await authManager.authenticateUser(
userRecord.id,
@@ -986,6 +1011,9 @@ router.post("/login", async (req, res) => {
deviceInfo: deviceInfo.deviceInfo,
});
// Reset rate limiter on successful login
loginRateLimiter.resetAttempts(clientIp, username);
authLogger.success(`User logged in successfully: ${username}`, {
operation: "user_login_success",
username,
@@ -993,6 +1021,7 @@ router.post("/login", async (req, res) => {
dataUnlocked: true,
deviceType: deviceInfo.type,
deviceInfo: deviceInfo.deviceInfo,
ip: clientIp,
});
const response: Record<string, unknown> = {
@@ -1039,7 +1068,15 @@ router.post("/logout", authenticateJWT, async (req, res) => {
try {
const payload = await authManager.verifyJWTToken(token);
sessionId = payload?.sessionId;
} catch (error) {}
} catch (error) {
authLogger.debug(
"Token verification failed during logout (expected if token expired)",
{
operation: "logout_token_verify_failed",
userId,
},
);
}
}
await authManager.logoutUser(userId, sessionId);

View File

@@ -6,7 +6,7 @@ import { Client as SSHClient } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshCredentials, sshData } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { fileLogger } from "../utils/logger.js";
import { fileLogger, sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
@@ -120,7 +120,9 @@ function cleanupSession(sessionId: string) {
if (session) {
try {
session.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
clearTimeout(session.timeout);
delete sshSessions[sessionId];
}
@@ -663,7 +665,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
delete pendingTOTPSessions[sessionId];
try {
session.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
fileLogger.warn("TOTP session timeout before code submission", {
operation: "file_totp_verify",
sessionId,

View File

@@ -6,10 +6,18 @@ import { Client, type ConnectConfig } from "ssh2";
import { getDb } from "../database/db/index.js";
import { sshData, sshCredentials } from "../database/db/schema.js";
import { eq, and } from "drizzle-orm";
import { statsLogger } from "../utils/logger.js";
import { statsLogger, sshLogger } from "../utils/logger.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { AuthManager } from "../utils/auth-manager.js";
import type { AuthenticatedRequest } from "../../types/index.js";
import { collectCpuMetrics } from "./widgets/cpu-collector.js";
import { collectMemoryMetrics } from "./widgets/memory-collector.js";
import { collectDiskMetrics } from "./widgets/disk-collector.js";
import { collectNetworkMetrics } from "./widgets/network-collector.js";
import { collectUptimeMetrics } from "./widgets/uptime-collector.js";
import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
interface PooledConnection {
client: Client;
@@ -156,7 +164,9 @@ class SSHConnectionPool {
if (!conn.inUse && now - conn.lastUsed > maxAge) {
try {
conn.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
return false;
}
return true;
@@ -176,7 +186,9 @@ class SSHConnectionPool {
for (const conn of connections) {
try {
conn.client.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
}
}
this.connections.clear();
@@ -214,7 +226,9 @@ class RequestQueue {
if (request) {
try {
await request();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
}
}
@@ -518,7 +532,14 @@ class PollingManager {
data: metrics,
timestamp: Date.now(),
});
} catch (error) {}
} catch (error) {
statsLogger.warn("Failed to collect metrics for host", {
operation: "metrics_poll_failed",
hostId: host.id,
hostName: host.name,
error: error instanceof Error ? error.message : String(error),
});
}
}
stopPollingForHost(hostId: number, clearData = true): void {
@@ -932,59 +953,6 @@ async function withSshConnection<T>(
}
}
function execCommand(
client: Client,
command: string,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
exitCode = typeof code === "number" ? code : null;
resolve({ stdout, stderr, code: exitCode });
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
});
});
});
}
function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}
async function collectMetrics(host: SSHHostWithCredentials): Promise<{
cpu: {
percent: number | null;
@@ -1047,298 +1015,38 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
return requestQueue.queueRequest(host.id, async () => {
try {
return await withSshConnection(host, async (client) => {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
const cpu = await collectCpuMetrics(client);
const memory = await collectMemoryMetrics(client);
const disk = await collectDiskMetrics(client);
const network = await collectNetworkMetrics(client);
const uptime = await collectUptimeMetrics(client);
const processes = await collectProcessesMetrics(client);
const system = await collectSystemMetrics(client);
let login_stats = {
recentLogins: [],
failedLogins: [],
totalLogins: 0,
uniqueIPs: 0,
};
try {
const [stat1, loadAvgOut, coresOut] = await Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]);
await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
login_stats = await collectLoginStats(client);
} catch (e) {
cpuPercent = null;
cores = null;
loadTriplet = null;
statsLogger.debug("Failed to collect login stats", {
operation: "login_stats_failed",
error: e instanceof Error ? e.message : String(e),
});
}
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, "cat /proc/meminfo");
const lines = memInfo.stdout.split("\n");
const getVal = (key: string) => {
const line = lines.find((l) => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null;
};
const totalKb = getVal("MemTotal:");
const availKb = getVal("MemAvailable:");
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch (e) {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
let availableHuman: string | null = null;
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"),
execCommand(client, "df -B1 -P / | tail -n +2"),
]);
const humanLine =
diskOutHuman.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/);
if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
if (
Number.isFinite(totalBytes) &&
Number.isFinite(usedBytes) &&
totalBytes > 0
) {
diskPercent = Math.max(
0,
Math.min(100, (usedBytes / totalBytes) * 100),
);
}
}
} catch (e) {
diskPercent = null;
usedHuman = null;
totalHuman = null;
availableHuman = null;
}
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
}> = [];
try {
const ifconfigOut = await execCommand(
client,
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
);
const netStatOut = await execCommand(
client,
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
);
const addrs = ifconfigOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const states = netStatOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const ifMap = new Map<string, { ip: string; state: string }>();
for (const line of addrs) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
}
}
for (const line of states) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const state = parts[1];
const existing = ifMap.get(name);
if (existing) {
existing.state = state;
}
}
}
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
});
}
} catch (e) {}
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const uptimeOut = await execCommand(client, "cat /proc/uptime");
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
if (uptimeParts.length >= 1) {
uptimeSeconds = Number(uptimeParts[0]);
if (Number.isFinite(uptimeSeconds)) {
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
}
}
} catch (e) {}
let totalProcesses: number | null = null;
let runningProcesses: number | null = null;
const topProcesses: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}> = [];
try {
const psOut = await execCommand(
client,
"ps aux --sort=-%cpu | head -n 11",
);
const psLines = psOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (psLines.length > 1) {
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
const parts = psLines[i].split(/\s+/);
if (parts.length >= 11) {
topProcesses.push({
pid: parts[1],
user: parts[0],
cpu: parts[2],
mem: parts[3],
command: parts.slice(10).join(" ").substring(0, 50),
});
}
}
}
const procCount = await execCommand(client, "ps aux | wc -l");
const runningCount = await execCommand(
client,
"ps aux | grep -c ' R '",
);
totalProcesses = Number(procCount.stdout.trim()) - 1;
runningProcesses = Number(runningCount.stdout.trim());
} catch (e) {}
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const hostnameOut = await execCommand(client, "hostname");
const kernelOut = await execCommand(client, "uname -r");
const osOut = await execCommand(
client,
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
);
hostname = hostnameOut.stdout.trim() || null;
kernel = kernelOut.stdout.trim() || null;
os = osOut.stdout.trim() || null;
} catch (e) {}
const result = {
cpu: { percent: toFixedNum(cpuPercent, 0), cores, load: loadTriplet },
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
},
disk: {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman,
},
network: {
interfaces,
},
uptime: {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
},
processes: {
total: totalProcesses,
running: runningProcesses,
top: topProcesses,
},
system: {
hostname,
kernel,
os,
},
cpu,
memory,
disk,
network,
uptime,
processes,
system,
login_stats,
};
metricsCache.set(host.id, result);
@@ -1386,7 +1094,9 @@ function tcpPing(
settled = true;
try {
socket.destroy();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
resolve(result);
};

View File

@@ -15,7 +15,7 @@ import type {
ErrorType,
} from "../../types/index.js";
import { CONNECTION_STATES } from "../../types/index.js";
import { tunnelLogger } from "../utils/logger.js";
import { tunnelLogger, sshLogger } from "../utils/logger.js";
import { SystemCrypto } from "../utils/system-crypto.js";
import { SimpleDBOps } from "../utils/simple-db-ops.js";
import { DataCrypto } from "../utils/data-crypto.js";
@@ -217,7 +217,9 @@ function cleanupTunnelResources(
if (verification?.timeout) clearTimeout(verification.timeout);
try {
verification?.conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
tunnelVerifications.delete(tunnelName);
}
@@ -282,7 +284,9 @@ function handleDisconnect(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
tunnelVerifications.delete(tunnelName);
}
@@ -638,7 +642,9 @@ async function connectSSHTunnel(
try {
conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
activeTunnels.delete(tunnelName);
@@ -778,7 +784,9 @@ async function connectSSHTunnel(
const verification = tunnelVerifications.get(tunnelName);
if (verification?.timeout) clearTimeout(verification.timeout);
verification?.conn.end();
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", { error });
}
tunnelVerifications.delete(tunnelName);
}

View File

@@ -0,0 +1,39 @@
import type { Client } from "ssh2";
export function execCommand(
client: Client,
command: string,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
client.exec(command, { pty: false }, (err, stream) => {
if (err) return reject(err);
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
exitCode = typeof code === "number" ? code : null;
resolve({ stdout, stderr, code: exitCode });
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
});
});
});
}
export function toFixedNum(n: number | null | undefined, digits = 2): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
export function kibToGiB(kib: number): number {
return kib / (1024 * 1024);
}

View File

@@ -0,0 +1,83 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
function parseCpuLine(
cpuLine: string,
): { total: number; idle: number } | undefined {
const parts = cpuLine.trim().split(/\s+/);
if (parts[0] !== "cpu") return undefined;
const nums = parts
.slice(1)
.map((n) => Number(n))
.filter((n) => Number.isFinite(n));
if (nums.length < 4) return undefined;
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
const total = nums.reduce((a, b) => a + b, 0);
return { total, idle };
}
export async function collectCpuMetrics(client: Client): Promise<{
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
}> {
let cpuPercent: number | null = null;
let cores: number | null = null;
let loadTriplet: [number, number, number] | null = null;
try {
const [stat1, loadAvgOut, coresOut] = await Promise.all([
execCommand(client, "cat /proc/stat"),
execCommand(client, "cat /proc/loadavg"),
execCommand(
client,
"nproc 2>/dev/null || grep -c ^processor /proc/cpuinfo",
),
]);
await new Promise((r) => setTimeout(r, 500));
const stat2 = await execCommand(client, "cat /proc/stat");
const cpuLine1 = (
stat1.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const cpuLine2 = (
stat2.stdout.split("\n").find((l) => l.startsWith("cpu ")) || ""
).trim();
const a = parseCpuLine(cpuLine1);
const b = parseCpuLine(cpuLine2);
if (a && b) {
const totalDiff = b.total - a.total;
const idleDiff = b.idle - a.idle;
const used = totalDiff - idleDiff;
if (totalDiff > 0)
cpuPercent = Math.max(0, Math.min(100, (used / totalDiff) * 100));
}
const laParts = loadAvgOut.stdout.trim().split(/\s+/);
if (laParts.length >= 3) {
loadTriplet = [
Number(laParts[0]),
Number(laParts[1]),
Number(laParts[2]),
].map((v) => (Number.isFinite(v) ? Number(v) : 0)) as [
number,
number,
number,
];
}
const coresNum = Number((coresOut.stdout || "").trim());
cores = Number.isFinite(coresNum) && coresNum > 0 ? coresNum : null;
} catch (e) {
cpuPercent = null;
cores = null;
loadTriplet = null;
}
return {
percent: toFixedNum(cpuPercent, 0),
cores,
load: loadTriplet,
};
}

View File

@@ -0,0 +1,67 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
export async function collectDiskMetrics(client: Client): Promise<{
percent: number | null;
usedHuman: string | null;
totalHuman: string | null;
availableHuman: string | null;
}> {
let diskPercent: number | null = null;
let usedHuman: string | null = null;
let totalHuman: string | null = null;
let availableHuman: string | null = null;
try {
const [diskOutHuman, diskOutBytes] = await Promise.all([
execCommand(client, "df -h -P / | tail -n +2"),
execCommand(client, "df -B1 -P / | tail -n +2"),
]);
const humanLine =
diskOutHuman.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const bytesLine =
diskOutBytes.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)[0] || "";
const humanParts = humanLine.split(/\s+/);
const bytesParts = bytesLine.split(/\s+/);
if (humanParts.length >= 6 && bytesParts.length >= 6) {
totalHuman = humanParts[1] || null;
usedHuman = humanParts[2] || null;
availableHuman = humanParts[3] || null;
const totalBytes = Number(bytesParts[1]);
const usedBytes = Number(bytesParts[2]);
if (
Number.isFinite(totalBytes) &&
Number.isFinite(usedBytes) &&
totalBytes > 0
) {
diskPercent = Math.max(
0,
Math.min(100, (usedBytes / totalBytes) * 100),
);
}
}
} catch (e) {
diskPercent = null;
usedHuman = null;
totalHuman = null;
availableHuman = null;
}
return {
percent: toFixedNum(diskPercent, 0),
usedHuman,
totalHuman,
availableHuman,
};
}

View File

@@ -0,0 +1,117 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
export interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
export interface LoginStats {
recentLogins: LoginRecord[];
failedLogins: LoginRecord[];
totalLogins: number;
uniqueIPs: number;
}
export async function collectLoginStats(client: Client): Promise<LoginStats> {
const recentLogins: LoginRecord[] = [];
const failedLogins: LoginRecord[] = [];
const ipSet = new Set<string>();
try {
const lastOut = await execCommand(
client,
"last -n 20 -F -w | grep -v 'reboot' | grep -v 'wtmp' | head -20",
);
const lastLines = lastOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of lastLines) {
const parts = line.split(/\s+/);
if (parts.length >= 10) {
const user = parts[0];
const tty = parts[1];
const ip = parts[2] === ":" || parts[2].startsWith(":") ? "local" : parts[2];
const timeStart = parts.indexOf(parts.find(p => /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)/.test(p)) || "");
if (timeStart > 0 && parts.length > timeStart + 4) {
const timeStr = parts.slice(timeStart, timeStart + 5).join(" ");
if (user && user !== "wtmp" && tty !== "system") {
recentLogins.push({
user,
ip,
time: new Date(timeStr).toISOString(),
status: "success",
});
if (ip !== "local") {
ipSet.add(ip);
}
}
}
}
}
} catch (e) {
// Ignore errors
}
try {
const failedOut = await execCommand(
client,
"grep 'Failed password' /var/log/auth.log 2>/dev/null | tail -10 || grep 'authentication failure' /var/log/secure 2>/dev/null | tail -10 || echo ''",
);
const failedLines = failedOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
for (const line of failedLines) {
let user = "unknown";
let ip = "unknown";
let timeStr = "";
const userMatch = line.match(/for (?:invalid user )?(\S+)/);
if (userMatch) {
user = userMatch[1];
}
const ipMatch = line.match(/from (\d+\.\d+\.\d+\.\d+)/);
if (ipMatch) {
ip = ipMatch[1];
}
const dateMatch = line.match(/^(\w+\s+\d+\s+\d+:\d+:\d+)/);
if (dateMatch) {
const currentYear = new Date().getFullYear();
timeStr = `${currentYear} ${dateMatch[1]}`;
}
if (user && ip) {
failedLogins.push({
user,
ip,
time: timeStr ? new Date(timeStr).toISOString() : new Date().toISOString(),
status: "failed",
});
if (ip !== "unknown") {
ipSet.add(ip);
}
}
}
} catch (e) {
// Ignore errors
}
return {
recentLogins: recentLogins.slice(0, 10),
failedLogins: failedLogins.slice(0, 10),
totalLogins: recentLogins.length,
uniqueIPs: ipSet.size,
};
}

View File

@@ -0,0 +1,41 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum, kibToGiB } from "./common-utils.js";
export async function collectMemoryMetrics(client: Client): Promise<{
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
}> {
let memPercent: number | null = null;
let usedGiB: number | null = null;
let totalGiB: number | null = null;
try {
const memInfo = await execCommand(client, "cat /proc/meminfo");
const lines = memInfo.stdout.split("\n");
const getVal = (key: string) => {
const line = lines.find((l) => l.startsWith(key));
if (!line) return null;
const m = line.match(/\d+/);
return m ? Number(m[0]) : null;
};
const totalKb = getVal("MemTotal:");
const availKb = getVal("MemAvailable:");
if (totalKb && availKb && totalKb > 0) {
const usedKb = totalKb - availKb;
memPercent = Math.max(0, Math.min(100, (usedKb / totalKb) * 100));
usedGiB = kibToGiB(usedKb);
totalGiB = kibToGiB(totalKb);
}
} catch (e) {
memPercent = null;
usedGiB = null;
totalGiB = null;
}
return {
percent: toFixedNum(memPercent, 0),
usedGiB: usedGiB ? toFixedNum(usedGiB, 2) : null,
totalGiB: totalGiB ? toFixedNum(totalGiB, 2) : null,
};
}

View File

@@ -0,0 +1,79 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectNetworkMetrics(client: Client): Promise<{
interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
}>;
}> {
const interfaces: Array<{
name: string;
ip: string;
state: string;
rxBytes: string | null;
txBytes: string | null;
}> = [];
try {
const ifconfigOut = await execCommand(
client,
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
);
const netStatOut = await execCommand(
client,
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
);
const addrs = ifconfigOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const states = netStatOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const ifMap = new Map<string, { ip: string; state: string }>();
for (const line of addrs) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: "UNKNOWN" });
}
}
for (const line of states) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const state = parts[1];
const existing = ifMap.get(name);
if (existing) {
existing.state = state;
}
}
}
for (const [name, data] of ifMap.entries()) {
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: null,
txBytes: null,
});
}
} catch (e) {
statsLogger.debug("Failed to collect network interface stats", {
operation: "network_stats_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return { interfaces };
}

View File

@@ -0,0 +1,69 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectProcessesMetrics(client: Client): Promise<{
total: number | null;
running: number | null;
top: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}>;
}> {
let totalProcesses: number | null = null;
let runningProcesses: number | null = null;
const topProcesses: Array<{
pid: string;
user: string;
cpu: string;
mem: string;
command: string;
}> = [];
try {
const psOut = await execCommand(
client,
"ps aux --sort=-%cpu | head -n 11",
);
const psLines = psOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
if (psLines.length > 1) {
for (let i = 1; i < Math.min(psLines.length, 11); i++) {
const parts = psLines[i].split(/\s+/);
if (parts.length >= 11) {
topProcesses.push({
pid: parts[1],
user: parts[0],
cpu: parts[2],
mem: parts[3],
command: parts.slice(10).join(" ").substring(0, 50),
});
}
}
}
const procCount = await execCommand(client, "ps aux | wc -l");
const runningCount = await execCommand(
client,
"ps aux | grep -c ' R '",
);
totalProcesses = Number(procCount.stdout.trim()) - 1;
runningProcesses = Number(runningCount.stdout.trim());
} catch (e) {
statsLogger.debug("Failed to collect process stats", {
operation: "process_stats_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return {
total: totalProcesses,
running: runningProcesses,
top: topProcesses,
};
}

View File

@@ -0,0 +1,37 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectSystemMetrics(client: Client): Promise<{
hostname: string | null;
kernel: string | null;
os: string | null;
}> {
let hostname: string | null = null;
let kernel: string | null = null;
let os: string | null = null;
try {
const hostnameOut = await execCommand(client, "hostname");
const kernelOut = await execCommand(client, "uname -r");
const osOut = await execCommand(
client,
"cat /etc/os-release | grep '^PRETTY_NAME=' | cut -d'\"' -f2",
);
hostname = hostnameOut.stdout.trim() || null;
kernel = kernelOut.stdout.trim() || null;
os = osOut.stdout.trim() || null;
} catch (e) {
statsLogger.debug("Failed to collect system info", {
operation: "system_info_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return {
hostname,
kernel,
os,
};
}

View File

@@ -0,0 +1,35 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { statsLogger } from "../../utils/logger.js";
export async function collectUptimeMetrics(client: Client): Promise<{
seconds: number | null;
formatted: string | null;
}> {
let uptimeSeconds: number | null = null;
let uptimeFormatted: string | null = null;
try {
const uptimeOut = await execCommand(client, "cat /proc/uptime");
const uptimeParts = uptimeOut.stdout.trim().split(/\s+/);
if (uptimeParts.length >= 1) {
uptimeSeconds = Number(uptimeParts[0]);
if (Number.isFinite(uptimeSeconds)) {
const days = Math.floor(uptimeSeconds / 86400);
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
uptimeFormatted = `${days}d ${hours}h ${minutes}m`;
}
}
} catch (e) {
statsLogger.debug("Failed to collect uptime", {
operation: "uptime_failed",
error: e instanceof Error ? e.message : String(e),
});
}
return {
seconds: uptimeSeconds,
formatted: uptimeFormatted,
};
}

View File

@@ -21,7 +21,11 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
if (persistentConfig.parsed) {
Object.assign(process.env, persistentConfig.parsed);
}
} catch {}
} catch (error) {
systemLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
let version = "unknown";

View File

@@ -233,7 +233,11 @@ IP.3 = 0.0.0.0
let envContent = "";
try {
envContent = await fs.readFile(this.ENV_FILE, "utf8");
} catch {}
} catch (error) {
systemLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
let updatedContent = envContent;
let hasChanges = false;

View File

@@ -327,7 +327,11 @@ class DatabaseFileEncryption {
fs.accessSync(envPath, fs.constants.R_OK);
envFileReadable = true;
}
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
databaseLogger.error(
"Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write",
@@ -628,7 +632,11 @@ class DatabaseFileEncryption {
try {
fs.accessSync(envPath, fs.constants.R_OK);
result.environment.envFileReadable = true;
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
if (

View File

@@ -82,7 +82,11 @@ export class LazyFieldEncryption {
legacyFieldName,
);
return decrypted;
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
const sensitiveFields = [
@@ -174,7 +178,11 @@ export class LazyFieldEncryption {
wasPlaintext: false,
wasLegacyEncryption: true,
};
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
return {
encrypted: fieldValue,

View File

@@ -0,0 +1,146 @@
interface LoginAttempt {
count: number;
firstAttempt: number;
lockedUntil?: number;
}
class LoginRateLimiter {
private ipAttempts = new Map<string, LoginAttempt>();
private usernameAttempts = new Map<string, LoginAttempt>();
private readonly MAX_ATTEMPTS = 5;
private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes
private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes
// Clean up old entries periodically
constructor() {
setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes
}
private cleanup(): void {
const now = Date.now();
// Clean IP attempts
for (const [ip, attempt] of this.ipAttempts.entries()) {
if (attempt.lockedUntil && attempt.lockedUntil < now) {
this.ipAttempts.delete(ip);
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
this.ipAttempts.delete(ip);
}
}
// Clean username attempts
for (const [username, attempt] of this.usernameAttempts.entries()) {
if (attempt.lockedUntil && attempt.lockedUntil < now) {
this.usernameAttempts.delete(username);
} else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) {
this.usernameAttempts.delete(username);
}
}
}
recordFailedAttempt(ip: string, username?: string): void {
const now = Date.now();
// Record IP attempt
const ipAttempt = this.ipAttempts.get(ip);
if (!ipAttempt) {
this.ipAttempts.set(ip, {
count: 1,
firstAttempt: now,
});
} else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) {
// Reset if outside window
this.ipAttempts.set(ip, {
count: 1,
firstAttempt: now,
});
} else {
ipAttempt.count++;
if (ipAttempt.count >= this.MAX_ATTEMPTS) {
ipAttempt.lockedUntil = now + this.LOCKOUT_MS;
}
}
// Record username attempt if provided
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (!userAttempt) {
this.usernameAttempts.set(username, {
count: 1,
firstAttempt: now,
});
} else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) {
// Reset if outside window
this.usernameAttempts.set(username, {
count: 1,
firstAttempt: now,
});
} else {
userAttempt.count++;
if (userAttempt.count >= this.MAX_ATTEMPTS) {
userAttempt.lockedUntil = now + this.LOCKOUT_MS;
}
}
}
}
resetAttempts(ip: string, username?: string): void {
this.ipAttempts.delete(ip);
if (username) {
this.usernameAttempts.delete(username);
}
}
isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } {
const now = Date.now();
// Check IP lockout
const ipAttempt = this.ipAttempts.get(ip);
if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) {
return {
locked: true,
remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000),
};
}
// Check username lockout
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) {
return {
locked: true,
remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000),
};
}
}
return { locked: false };
}
getRemainingAttempts(ip: string, username?: string): number {
const now = Date.now();
let minRemaining = this.MAX_ATTEMPTS;
// Check IP attempts
const ipAttempt = this.ipAttempts.get(ip);
if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) {
const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count);
minRemaining = Math.min(minRemaining, ipRemaining);
}
// Check username attempts
if (username) {
const userAttempt = this.usernameAttempts.get(username);
if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) {
const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count);
minRemaining = Math.min(minRemaining, userRemaining);
}
}
return minRemaining;
}
}
// Export singleton instance
export const loginRateLimiter = new LoginRateLimiter();

View File

@@ -1,4 +1,5 @@
import ssh2Pkg from "ssh2";
import { sshLogger } from "./logger.js";
const ssh2Utils = ssh2Pkg.utils;
function detectKeyTypeFromContent(keyContent: string): string {
@@ -84,7 +85,11 @@ function detectKeyTypeFromContent(keyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
if (content.length < 800) {
return "ssh-ed25519";
@@ -140,7 +145,11 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
} else if (decodedString.includes("1.3.101.112")) {
return "ssh-ed25519";
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
if (content.length < 400) {
return "ssh-ed25519";
@@ -242,7 +251,11 @@ export function parseSSHKey(
useSSH2 = true;
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
}
if (!useSSH2) {
@@ -268,7 +281,11 @@ export function parseSSHKey(
success: true,
};
}
} catch {}
} catch (error) {
sshLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
return {
privateKey: privateKeyData,

View File

@@ -51,7 +51,15 @@ class SystemCrypto {
},
);
}
} catch (fileError) {}
} catch (fileError) {
// OK: .env file not found or unreadable, will generate new JWT secret
databaseLogger.debug(
".env file not accessible, will generate new JWT secret",
{
operation: "jwt_env_not_found",
},
);
}
await this.generateAndGuideUser();
} catch (error) {
@@ -102,7 +110,15 @@ class SystemCrypto {
return;
} else {
}
} catch (fileError) {}
} catch (fileError) {
// OK: .env file not found or unreadable, will generate new database key
databaseLogger.debug(
".env file not accessible, will generate new database key",
{
operation: "db_key_env_not_found",
},
);
}
await this.generateAndGuideDatabaseKey();
} catch (error) {
@@ -140,7 +156,11 @@ class SystemCrypto {
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
return;
}
} catch {}
} catch (error) {
databaseLogger.debug("Operation failed, continuing", {
error: error instanceof Error ? error.message : String(error),
});
}
await this.generateAndGuideInternalAuthToken();
} catch (error) {