fix: File cleanup
This commit is contained in:
@@ -12,10 +12,8 @@ import type { AuthenticatedRequest } from "../types/index.js";
|
||||
const app = express();
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
// Track server start time
|
||||
const serverStartTime = Date.now();
|
||||
|
||||
// In-memory rate limiter for activity logging
|
||||
const activityRateLimiter = new Map<string, number>();
|
||||
const RATE_LIMIT_MS = 1000; // 1 second window
|
||||
|
||||
@@ -60,7 +58,6 @@ app.use(express.json({ limit: "1mb" }));
|
||||
|
||||
app.use(authManager.createAuthMiddleware());
|
||||
|
||||
// Get server uptime
|
||||
app.get("/uptime", async (req, res) => {
|
||||
try {
|
||||
const uptimeMs = Date.now() - serverStartTime;
|
||||
@@ -80,7 +77,6 @@ app.get("/uptime", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent activity for current user
|
||||
app.get("/activity/recent", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -112,7 +108,6 @@ app.get("/activity/recent", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Log new activity
|
||||
app.post("/activity/log", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -138,22 +133,18 @@ app.post("/activity/log", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// In-memory rate limiting to prevent duplicate requests
|
||||
const rateLimitKey = `${userId}:${hostId}:${type}`;
|
||||
const now = Date.now();
|
||||
const lastLogged = activityRateLimiter.get(rateLimitKey);
|
||||
|
||||
if (lastLogged && now - lastLogged < RATE_LIMIT_MS) {
|
||||
// Too soon after last request, reject as duplicate
|
||||
return res.json({
|
||||
message: "Activity already logged recently (rate limited)",
|
||||
});
|
||||
}
|
||||
|
||||
// Update rate limiter
|
||||
activityRateLimiter.set(rateLimitKey, now);
|
||||
|
||||
// Clean up old entries from rate limiter (keep it from growing indefinitely)
|
||||
if (activityRateLimiter.size > 10000) {
|
||||
const entriesToDelete: string[] = [];
|
||||
for (const [key, timestamp] of activityRateLimiter.entries()) {
|
||||
@@ -164,7 +155,6 @@ app.post("/activity/log", async (req, res) => {
|
||||
entriesToDelete.forEach((key) => activityRateLimiter.delete(key));
|
||||
}
|
||||
|
||||
// Verify the host belongs to the user
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
@@ -178,7 +168,6 @@ app.post("/activity/log", async (req, res) => {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
|
||||
// Insert new activity
|
||||
const result = (await SimpleDBOps.insert(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
@@ -191,7 +180,6 @@ app.post("/activity/log", async (req, res) => {
|
||||
userId,
|
||||
)) as unknown as { id: number };
|
||||
|
||||
// Keep only the last 100 activities per user to prevent bloat
|
||||
const allActivities = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
@@ -216,7 +204,6 @@ app.post("/activity/log", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Reset recent activity for current user
|
||||
app.delete("/activity/reset", async (req, res) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
@@ -228,7 +215,6 @@ app.delete("/activity/reset", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Delete all activities for the user
|
||||
await SimpleDBOps.delete(
|
||||
recentActivity,
|
||||
"recent_activity",
|
||||
|
||||
@@ -915,7 +915,6 @@ app.post(
|
||||
const isOidcUser = !!userRecords[0].is_oidc;
|
||||
|
||||
if (!isOidcUser) {
|
||||
// Local accounts still prove knowledge of the password so their DEK can be derived again.
|
||||
if (!password) {
|
||||
return res.status(400).json({
|
||||
error: "Password required for import",
|
||||
@@ -928,7 +927,6 @@ app.post(
|
||||
return res.status(401).json({ error: "Invalid password" });
|
||||
}
|
||||
} else if (!DataCrypto.getUserDataKey(userId)) {
|
||||
// OIDC users skip the password prompt; make sure their DEK is unlocked via the OIDC session.
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||
if (!oidcUnlocked) {
|
||||
return res.status(403).json({
|
||||
@@ -947,7 +945,6 @@ app.post(
|
||||
|
||||
let userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey && isOidcUser) {
|
||||
// authenticateOIDCUser lazily provisions the session key; retry the fetch when it succeeds.
|
||||
const oidcUnlocked = await authManager.authenticateOIDCUser(userId);
|
||||
if (oidcUnlocked) {
|
||||
userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
@@ -1425,7 +1422,6 @@ app.use(
|
||||
err: unknown,
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_next: express.NextFunction,
|
||||
) => {
|
||||
apiLogger.error("Unhandled error in request", err, {
|
||||
@@ -1482,17 +1478,13 @@ app.get(
|
||||
if (status.hasUnencryptedDb) {
|
||||
try {
|
||||
unencryptedSize = fs.statSync(dbPath).size;
|
||||
} catch {
|
||||
// Ignore file access errors
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (status.hasEncryptedDb) {
|
||||
try {
|
||||
encryptedSize = fs.statSync(encryptedDbPath).size;
|
||||
} catch {
|
||||
// Ignore file access errors
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
||||
@@ -12,10 +12,6 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
const dataDir = process.env.DATA_DIR || "./db/data";
|
||||
const dbDir = path.resolve(dataDir);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
databaseLogger.info(`Creating database directory`, {
|
||||
operation: "db_init",
|
||||
path: dbDir,
|
||||
});
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
@@ -31,7 +27,6 @@ let sqlite: Database.Database;
|
||||
async function initializeDatabaseAsync(): Promise<void> {
|
||||
const systemCrypto = SystemCrypto.getInstance();
|
||||
|
||||
// Ensure database key is initialized
|
||||
await systemCrypto.getDatabaseKey();
|
||||
if (enableFileEncryption) {
|
||||
try {
|
||||
@@ -41,18 +36,11 @@ async function initializeDatabaseAsync(): Promise<void> {
|
||||
|
||||
memoryDatabase = new Database(decryptedBuffer);
|
||||
|
||||
// Count sessions after loading
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
databaseLogger.info("Database loaded from encrypted file", {
|
||||
operation: "db_load",
|
||||
sessionCount: sessionCount.count,
|
||||
bufferSize: decryptedBuffer.length,
|
||||
});
|
||||
} catch (countError) {
|
||||
// Ignore count errors
|
||||
}
|
||||
} else {
|
||||
const migration = new DatabaseMigration(dataDir);
|
||||
@@ -297,9 +285,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
|
||||
try {
|
||||
sqlite.prepare("DELETE FROM sessions").run();
|
||||
databaseLogger.info("All sessions cleared on startup", {
|
||||
operation: "db_init_session_cleanup",
|
||||
});
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Could not clear sessions on startup", {
|
||||
operation: "db_init_session_cleanup_failed",
|
||||
@@ -453,7 +438,6 @@ const migrateSchema = () => {
|
||||
addColumnIfNotExists("file_manager_pinned", "host_id", "INTEGER NOT NULL");
|
||||
addColumnIfNotExists("file_manager_shortcuts", "host_id", "INTEGER NOT NULL");
|
||||
|
||||
// Create sessions table if it doesn't exist (for existing databases)
|
||||
try {
|
||||
sqlite
|
||||
.prepare("SELECT id FROM sessions LIMIT 1")
|
||||
@@ -473,9 +457,6 @@ const migrateSchema = () => {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
`);
|
||||
databaseLogger.info("Sessions table created via migration", {
|
||||
operation: "schema_migration",
|
||||
});
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create sessions table", {
|
||||
operation: "schema_migration",
|
||||
@@ -499,18 +480,11 @@ async function saveMemoryDatabaseToFile() {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Count sessions before saving
|
||||
try {
|
||||
const sessionCount = memoryDatabase
|
||||
.prepare("SELECT COUNT(*) as count FROM sessions")
|
||||
.get() as { count: number };
|
||||
databaseLogger.info("Saving database to file", {
|
||||
operation: "db_save",
|
||||
sessionCount: sessionCount.count,
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
} catch (countError) {
|
||||
// Ignore count errors
|
||||
}
|
||||
|
||||
if (enableFileEncryption) {
|
||||
@@ -605,18 +579,15 @@ async function cleanupDatabase() {
|
||||
try {
|
||||
fs.unlinkSync(path.join(tempDir, file));
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmdirSync(tempDir);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,7 +596,6 @@ process.on("exit", () => {
|
||||
try {
|
||||
sqlite.close();
|
||||
} catch {
|
||||
// Ignore close errors on exit
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -336,14 +336,10 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
userId,
|
||||
adminDataKey,
|
||||
);
|
||||
authLogger.info("OIDC configuration encrypted with admin data key", {
|
||||
operation: "oidc_config_encrypt",
|
||||
userId,
|
||||
});
|
||||
} else {
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`, // Simple base64 encoding
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`,
|
||||
};
|
||||
authLogger.warn(
|
||||
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
||||
@@ -421,7 +417,6 @@ router.get("/oidc-config", async (req, res) => {
|
||||
|
||||
const config = JSON.parse((row as Record<string, unknown>).value as string);
|
||||
|
||||
// Only return public fields needed for login page
|
||||
const publicConfig = {
|
||||
client_id: config.client_id,
|
||||
issuer_url: config.issuer_url,
|
||||
@@ -661,7 +656,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
config.client_id,
|
||||
);
|
||||
} catch {
|
||||
// Fallback to manual decoding
|
||||
try {
|
||||
const parts = (tokenData.id_token as string).split(".");
|
||||
if (parts.length === 3) {
|
||||
@@ -812,7 +806,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Detect platform and device info
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
@@ -838,7 +831,6 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const redirectUrl = new URL(frontendUrl);
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
// Calculate max age based on device type
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -965,7 +957,6 @@ router.post("/login", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Detect platform and device info
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
@@ -995,7 +986,6 @@ router.post("/login", async (req, res) => {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
// Calculate max age based on device type
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -1018,7 +1008,6 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
const userId = authReq.userId;
|
||||
|
||||
if (userId) {
|
||||
// Get sessionId from JWT if available
|
||||
const token =
|
||||
req.cookies?.jwt || req.headers["authorization"]?.split(" ")[1];
|
||||
let sessionId: string | undefined;
|
||||
@@ -1027,9 +1016,7 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
try {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
sessionId = payload?.sessionId;
|
||||
} catch (error) {
|
||||
// Ignore token verification errors during logout
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
await authManager.logoutUser(userId, sessionId);
|
||||
@@ -1435,7 +1422,6 @@ router.post("/complete-reset", async (req, res) => {
|
||||
const saltRounds = parseInt(process.env.SALT || "10", 10);
|
||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
// Check if user is logged in and data is unlocked
|
||||
let userIdFromJwt: string | null = null;
|
||||
const cookie = req.cookies?.jwt;
|
||||
let header: string | undefined;
|
||||
@@ -1452,7 +1438,6 @@ router.post("/complete-reset", async (req, res) => {
|
||||
}
|
||||
|
||||
if (userIdFromJwt === userId) {
|
||||
// Logged-in user: preserve data
|
||||
try {
|
||||
const success = await authManager.resetUserPasswordWithPreservedDEK(
|
||||
userId,
|
||||
@@ -1491,15 +1476,12 @@ router.post("/complete-reset", async (req, res) => {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Logged-out user: data is lost
|
||||
await db
|
||||
.update(users)
|
||||
.set({ password_hash })
|
||||
.where(eq(users.username, username));
|
||||
|
||||
try {
|
||||
// Delete all encrypted data since we're creating a new DEK
|
||||
// The old DEK is lost, so old encrypted data becomes unreadable
|
||||
await db
|
||||
.delete(sshCredentialUsage)
|
||||
.where(eq(sshCredentialUsage.userId, userId));
|
||||
@@ -1524,11 +1506,9 @@ router.post("/complete-reset", async (req, res) => {
|
||||
.delete(sshCredentials)
|
||||
.where(eq(sshCredentials.userId, userId));
|
||||
|
||||
// Now setup new encryption with new DEK
|
||||
await authManager.registerUser(userId, newPassword);
|
||||
authManager.logoutUser(userId);
|
||||
|
||||
// Clear TOTP settings
|
||||
await db
|
||||
.update(users)
|
||||
.set({
|
||||
@@ -1597,13 +1577,11 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
return res.status(404).json({ error: "User not found" });
|
||||
}
|
||||
|
||||
// Verify old password for login hash
|
||||
const isMatch = await bcrypt.compare(oldPassword, user[0].password_hash);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: "Incorrect current password" });
|
||||
}
|
||||
|
||||
// Change encryption keys and login hash
|
||||
const success = await authManager.changeUserPassword(
|
||||
userId,
|
||||
oldPassword,
|
||||
@@ -1619,7 +1597,7 @@ router.post("/change-password", authenticateJWT, async (req, res) => {
|
||||
const password_hash = await bcrypt.hash(newPassword, saltRounds);
|
||||
await db.update(users).set({ password_hash }).where(eq(users.id, userId));
|
||||
|
||||
authManager.logoutUser(userId); // Log out user for security
|
||||
authManager.logoutUser(userId);
|
||||
|
||||
res.json({ message: "Password changed successfully. Please log in again." });
|
||||
});
|
||||
@@ -1836,7 +1814,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
.where(eq(users.id, userRecord.id));
|
||||
}
|
||||
|
||||
// Detect platform and device info
|
||||
const deviceInfo = parseUserAgent(req);
|
||||
const token = await authManager.generateJWTToken(userRecord.id, {
|
||||
deviceType: deviceInfo.type,
|
||||
@@ -1867,7 +1844,6 @@ router.post("/totp/verify-login", async (req, res) => {
|
||||
response.token = token;
|
||||
}
|
||||
|
||||
// Calculate max age based on device type
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -2230,7 +2206,6 @@ router.get("/data-status", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
|
||||
try {
|
||||
// Data lock functionality has been removed - always return unlocked for authenticated users
|
||||
res.json({
|
||||
unlocked: true,
|
||||
message: "Data is unlocked",
|
||||
@@ -2320,10 +2295,8 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
let sessionList;
|
||||
|
||||
if (userRecord.is_admin) {
|
||||
// Admin: Get all sessions with user info
|
||||
sessionList = await authManager.getAllSessions();
|
||||
|
||||
// Join with users to get usernames
|
||||
const enrichedSessions = await Promise.all(
|
||||
sessionList.map(async (session) => {
|
||||
const sessionUser = await db
|
||||
@@ -2341,7 +2314,6 @@ router.get("/sessions", authenticateJWT, async (req, res) => {
|
||||
|
||||
return res.json({ sessions: enrichedSessions });
|
||||
} else {
|
||||
// Regular user: Get only their own sessions
|
||||
sessionList = await authManager.getUserSessions(userId);
|
||||
return res.json({ sessions: sessionList });
|
||||
}
|
||||
@@ -2369,7 +2341,6 @@ router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// Check if session exists
|
||||
const sessionRecords = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
@@ -2382,7 +2353,6 @@ router.delete("/sessions/:sessionId", authenticateJWT, async (req, res) => {
|
||||
|
||||
const session = sessionRecords[0];
|
||||
|
||||
// Non-admin users can only revoke their own sessions
|
||||
if (!userRecord.is_admin && session.userId !== userId) {
|
||||
return res
|
||||
.status(403)
|
||||
@@ -2421,19 +2391,15 @@ router.post("/sessions/revoke-all", authenticateJWT, async (req, res) => {
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// Determine which user's sessions to revoke
|
||||
let revokeUserId = userId;
|
||||
if (targetUserId && userRecord.is_admin) {
|
||||
// Admin can revoke any user's sessions
|
||||
revokeUserId = targetUserId;
|
||||
} else if (targetUserId && targetUserId !== userId) {
|
||||
// Non-admin can only revoke their own sessions
|
||||
return res.status(403).json({
|
||||
error: "Not authorized to revoke sessions for other users",
|
||||
});
|
||||
}
|
||||
|
||||
// Get current session ID if needed
|
||||
let currentSessionId: string | undefined;
|
||||
if (exceptCurrent) {
|
||||
const token =
|
||||
|
||||
@@ -120,9 +120,7 @@ function cleanupSession(sessionId: string) {
|
||||
if (session) {
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// Ignore connection close errors
|
||||
}
|
||||
} catch {}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
}
|
||||
@@ -352,8 +350,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
}
|
||||
config.password = resolvedCredentials.password;
|
||||
} else if (resolvedCredentials.authType === "none") {
|
||||
// Use authHandler to control authentication flow
|
||||
// This ensures we only try keyboard-interactive, not password auth
|
||||
config.authHandler = (
|
||||
methodsLeft: string[] | null,
|
||||
partialSuccess: boolean,
|
||||
@@ -409,7 +405,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
scheduleSessionCleanup(sessionId);
|
||||
res.json({ status: "success", message: "SSH connection established" });
|
||||
|
||||
// Log activity to dashboard API
|
||||
if (hostId && userId) {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -458,14 +453,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
responseSent = true;
|
||||
|
||||
if (authMethodNotAvailable && resolvedCredentials.authType === "none") {
|
||||
fileLogger.info(
|
||||
"Keyboard-interactive not available, requesting credentials",
|
||||
{
|
||||
operation: "file_connect_auth_not_available",
|
||||
sessionId,
|
||||
hostId,
|
||||
},
|
||||
);
|
||||
res.status(200).json({
|
||||
status: "auth_required",
|
||||
message:
|
||||
@@ -557,51 +544,26 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
prompt: prompts[totpPromptIndex].prompt,
|
||||
});
|
||||
} else {
|
||||
// Non-TOTP prompts (password, etc.)
|
||||
const hasStoredPassword =
|
||||
resolvedCredentials.password &&
|
||||
resolvedCredentials.authType !== "none";
|
||||
|
||||
// Check if this is a password prompt
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
// If no stored password (including authType "none"), prompt the user
|
||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||
if (responseSent) {
|
||||
// Connection is already being handled, don't send duplicate responses
|
||||
fileLogger.info(
|
||||
"Skipping duplicate password prompt - response already sent",
|
||||
{
|
||||
operation: "keyboard_interactive_skip",
|
||||
hostId,
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
responseSent = true;
|
||||
|
||||
if (pendingTOTPSessions[sessionId]) {
|
||||
// Session already waiting for TOTP, don't override
|
||||
fileLogger.info("Skipping password prompt - TOTP session pending", {
|
||||
operation: "keyboard_interactive_skip",
|
||||
hostId,
|
||||
sessionId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
keyboardInteractiveResponded = true;
|
||||
|
||||
fileLogger.info("Requesting password from user (authType: none)", {
|
||||
operation: "keyboard_interactive_password",
|
||||
hostId,
|
||||
sessionId,
|
||||
prompt: prompts[passwordPromptIndex].prompt,
|
||||
});
|
||||
|
||||
pendingTOTPSessions[sessionId] = {
|
||||
client,
|
||||
finish,
|
||||
@@ -627,7 +589,6 @@ app.post("/ssh/file_manager/ssh/connect", async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-respond with stored credentials if available
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
@@ -679,9 +640,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// Ignore errors when closing timed out session
|
||||
}
|
||||
} catch {}
|
||||
fileLogger.warn("TOTP session timeout before code submission", {
|
||||
operation: "file_totp_verify",
|
||||
sessionId,
|
||||
@@ -693,7 +652,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
.json({ error: "TOTP session timeout. Please reconnect." });
|
||||
}
|
||||
|
||||
// Build responses for ALL prompts, just like in terminal.ts
|
||||
const responses = (session.prompts || []).map((p, index) => {
|
||||
if (index === session.totpPromptIndex) {
|
||||
return totpCode;
|
||||
@@ -704,22 +662,9 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
return "";
|
||||
});
|
||||
|
||||
fileLogger.info("Full keyboard-interactive response for file manager", {
|
||||
operation: "file_totp_full_response",
|
||||
sessionId,
|
||||
userId,
|
||||
totalPrompts: session.prompts?.length || 0,
|
||||
responsesProvided: responses.filter((r) => r !== "").length,
|
||||
});
|
||||
|
||||
let responseSent = false;
|
||||
let responseTimeout: NodeJS.Timeout;
|
||||
|
||||
// Don't remove event listeners - just add our own 'once' handlers
|
||||
// The ssh2 library manages multiple listeners correctly
|
||||
// Removing them can cause the connection to become unstable
|
||||
|
||||
// CRITICAL: Attach event listeners BEFORE calling finish() to avoid race condition
|
||||
session.client.once("ready", () => {
|
||||
if (responseSent) return;
|
||||
responseSent = true;
|
||||
@@ -727,8 +672,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
|
||||
// Add a small delay to let SSH2 stabilize the connection after keyboard-interactive
|
||||
// This prevents "Not connected" errors when immediately trying to exec commands
|
||||
setTimeout(() => {
|
||||
sshSessions[sessionId] = {
|
||||
client: session.client,
|
||||
@@ -742,7 +685,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
message: "TOTP verified, SSH connection established",
|
||||
});
|
||||
|
||||
// Log activity to dashboard API after connection is stable
|
||||
if (session.hostId && session.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -789,7 +731,7 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, 200); // Give SSH2 connection 200ms to fully stabilize after keyboard-interactive
|
||||
}, 200);
|
||||
});
|
||||
|
||||
session.client.once("error", (err) => {
|
||||
@@ -822,7 +764,6 @@ app.post("/ssh/file_manager/ssh/connect-totp", async (req, res) => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// Now that event listeners are attached, submit the TOTP response
|
||||
session.finish(responses);
|
||||
});
|
||||
|
||||
@@ -2493,15 +2434,6 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => {
|
||||
: code;
|
||||
const cleanOutput = output.replace(/EXIT_CODE:\d+$/, "").trim();
|
||||
|
||||
fileLogger.info("File execution completed", {
|
||||
operation: "execute_file",
|
||||
sessionId,
|
||||
filePath,
|
||||
exitCode: actualExitCode,
|
||||
outputLength: cleanOutput.length,
|
||||
errorLength: errorOutput.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
exitCode: actualExitCode,
|
||||
|
||||
@@ -112,8 +112,6 @@ class SSHConnectionPool {
|
||||
);
|
||||
|
||||
if (totpPrompt) {
|
||||
// Record TOTP failure as permanent - never retry
|
||||
// The recordFailure method will log this once
|
||||
authFailureTracker.recordFailure(host.id, "TOTP", true);
|
||||
client.end();
|
||||
reject(
|
||||
@@ -158,9 +156,7 @@ class SSHConnectionPool {
|
||||
if (!conn.inUse && now - conn.lastUsed > maxAge) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {
|
||||
// Ignore errors when closing stale connections
|
||||
}
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -180,9 +176,7 @@ class SSHConnectionPool {
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
conn.client.end();
|
||||
} catch {
|
||||
// Ignore errors when closing connections during cleanup
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
this.connections.clear();
|
||||
@@ -220,9 +214,7 @@ class RequestQueue {
|
||||
if (request) {
|
||||
try {
|
||||
await request();
|
||||
} catch {
|
||||
// Ignore errors from queued requests
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,13 +264,13 @@ interface AuthFailureRecord {
|
||||
count: number;
|
||||
lastFailure: number;
|
||||
reason: "TOTP" | "AUTH" | "TIMEOUT";
|
||||
permanent: boolean; // If true, don't retry at all
|
||||
permanent: boolean;
|
||||
}
|
||||
|
||||
class AuthFailureTracker {
|
||||
private failures = new Map<number, AuthFailureRecord>();
|
||||
private maxRetries = 3;
|
||||
private backoffBase = 60000; // 1 minute base backoff
|
||||
private backoffBase = 60000;
|
||||
|
||||
recordFailure(
|
||||
hostId: number,
|
||||
@@ -305,17 +297,14 @@ class AuthFailureTracker {
|
||||
const record = this.failures.get(hostId);
|
||||
if (!record) return false;
|
||||
|
||||
// Always skip TOTP hosts
|
||||
if (record.reason === "TOTP" || record.permanent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Skip if we've exceeded max retries
|
||||
if (record.count >= this.maxRetries) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Calculate exponential backoff
|
||||
const backoffTime = this.backoffBase * Math.pow(2, record.count - 1);
|
||||
const timeSinceFailure = Date.now() - record.lastFailure;
|
||||
|
||||
@@ -351,11 +340,9 @@ class AuthFailureTracker {
|
||||
|
||||
reset(hostId: number): void {
|
||||
this.failures.delete(hostId);
|
||||
// Don't log reset - it's not important
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
// Clean up old failures (older than 1 hour)
|
||||
const maxAge = 60 * 60 * 1000;
|
||||
const now = Date.now();
|
||||
|
||||
@@ -459,7 +446,6 @@ class PollingManager {
|
||||
const statsConfig = this.parseStatsConfig(host.statsConfig);
|
||||
const existingConfig = this.pollingConfigs.get(host.id);
|
||||
|
||||
// Clear existing timers if they exist
|
||||
if (existingConfig) {
|
||||
if (existingConfig.statusTimer) {
|
||||
clearInterval(existingConfig.statusTimer);
|
||||
@@ -474,35 +460,27 @@ class PollingManager {
|
||||
statsConfig,
|
||||
};
|
||||
|
||||
// Start status polling if enabled
|
||||
if (statsConfig.statusCheckEnabled) {
|
||||
const intervalMs = statsConfig.statusCheckInterval * 1000;
|
||||
|
||||
// Poll immediately (don't await - let it run in background)
|
||||
this.pollHostStatus(host);
|
||||
|
||||
// Then set up interval to poll periodically
|
||||
config.statusTimer = setInterval(() => {
|
||||
this.pollHostStatus(host);
|
||||
}, intervalMs);
|
||||
} else {
|
||||
// Remove status if monitoring is disabled
|
||||
this.statusStore.delete(host.id);
|
||||
}
|
||||
|
||||
// Start metrics polling if enabled
|
||||
if (statsConfig.metricsEnabled) {
|
||||
const intervalMs = statsConfig.metricsInterval * 1000;
|
||||
|
||||
// Poll immediately (don't await - let it run in background)
|
||||
this.pollHostMetrics(host);
|
||||
|
||||
// Then set up interval to poll periodically
|
||||
config.metricsTimer = setInterval(() => {
|
||||
this.pollHostMetrics(host);
|
||||
}, intervalMs);
|
||||
} else {
|
||||
// Remove metrics if monitoring is disabled
|
||||
this.metricsStore.delete(host.id);
|
||||
}
|
||||
|
||||
@@ -576,12 +554,10 @@ class PollingManager {
|
||||
}
|
||||
|
||||
async refreshHostPolling(userId: string): Promise<void> {
|
||||
// Stop all current polling
|
||||
for (const hostId of this.pollingConfigs.keys()) {
|
||||
this.stopPollingForHost(hostId);
|
||||
}
|
||||
|
||||
// Reinitialize
|
||||
await this.initializePolling(userId);
|
||||
}
|
||||
|
||||
@@ -1019,10 +995,8 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
os: string | null;
|
||||
};
|
||||
}> {
|
||||
// Check if we should skip this host due to auth failures
|
||||
if (authFailureTracker.shouldSkip(host.id)) {
|
||||
const reason = authFailureTracker.getSkipReason(host.id);
|
||||
// Don't log - just skip silently to avoid spam
|
||||
throw new Error(reason || "Authentication failed");
|
||||
}
|
||||
|
||||
@@ -1166,7 +1140,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
availableHuman = null;
|
||||
}
|
||||
|
||||
// Collect network interfaces
|
||||
const interfaces: Array<{
|
||||
name: string;
|
||||
ip: string;
|
||||
@@ -1225,7 +1198,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Collect uptime
|
||||
let uptimeSeconds: number | null = null;
|
||||
let uptimeFormatted: string | null = null;
|
||||
try {
|
||||
@@ -1242,7 +1214,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Collect process information
|
||||
let totalProcesses: number | null = null;
|
||||
let runningProcesses: number | null = null;
|
||||
const topProcesses: Array<{
|
||||
@@ -1285,7 +1256,6 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
runningProcesses = Number(runningCount.stdout.trim());
|
||||
} catch (e) {}
|
||||
|
||||
// Collect system information
|
||||
let hostname: string | null = null;
|
||||
let kernel: string | null = null;
|
||||
let os: string | null = null;
|
||||
@@ -1338,25 +1308,20 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
|
||||
return result;
|
||||
});
|
||||
} catch (error) {
|
||||
// Record authentication failures for backoff
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("TOTP authentication required")) {
|
||||
// TOTP failures are already recorded in keyboard-interactive handler
|
||||
throw error;
|
||||
} else if (
|
||||
error.message.includes("No password available") ||
|
||||
error.message.includes("Unsupported authentication type") ||
|
||||
error.message.includes("No SSH key available")
|
||||
) {
|
||||
// Configuration errors - permanent failures, don't retry
|
||||
// recordFailure will log once when first detected
|
||||
authFailureTracker.recordFailure(host.id, "AUTH", true);
|
||||
} else if (
|
||||
error.message.includes("authentication") ||
|
||||
error.message.includes("Permission denied") ||
|
||||
error.message.includes("All configured authentication methods failed")
|
||||
) {
|
||||
// recordFailure will log once when first detected
|
||||
authFailureTracker.recordFailure(host.id, "AUTH");
|
||||
} else if (
|
||||
error.message.includes("timeout") ||
|
||||
@@ -1384,9 +1349,7 @@ function tcpPing(
|
||||
settled = true;
|
||||
try {
|
||||
socket.destroy();
|
||||
} catch {
|
||||
// Ignore errors when destroying socket
|
||||
}
|
||||
} catch {}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
@@ -1409,7 +1372,6 @@ app.get("/status", async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize polling if no hosts are being polled yet
|
||||
const statuses = pollingManager.getAllStatuses();
|
||||
if (statuses.size === 0) {
|
||||
await pollingManager.initializePolling(userId);
|
||||
@@ -1433,7 +1395,6 @@ app.get("/status/:id", validateHostId, async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize polling if no hosts are being polled yet
|
||||
const statuses = pollingManager.getAllStatuses();
|
||||
if (statuses.size === 0) {
|
||||
await pollingManager.initializePolling(userId);
|
||||
@@ -1520,7 +1481,6 @@ app.listen(PORT, async () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup old auth failures every 10 minutes
|
||||
setInterval(
|
||||
() => {
|
||||
authFailureTracker.cleanup();
|
||||
|
||||
@@ -333,15 +333,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
}
|
||||
|
||||
case "password_response": {
|
||||
const passwordData = data as TOTPResponseData; // Same structure
|
||||
const passwordData = data as TOTPResponseData;
|
||||
if (keyboardInteractiveFinish && passwordData?.code) {
|
||||
const password = passwordData.code;
|
||||
sshLogger.info("Password received from user", {
|
||||
operation: "password_response",
|
||||
userId,
|
||||
passwordLength: password.length,
|
||||
});
|
||||
|
||||
keyboardInteractiveFinish([password]);
|
||||
keyboardInteractiveFinish = null;
|
||||
} else {
|
||||
@@ -374,7 +368,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
keyPassword?: string;
|
||||
};
|
||||
|
||||
// Update the host config with provided credentials
|
||||
if (credentialsData.password) {
|
||||
credentialsData.hostConfig.password = credentialsData.password;
|
||||
credentialsData.hostConfig.authType = "password";
|
||||
@@ -384,10 +377,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
credentialsData.hostConfig.authType = "key";
|
||||
}
|
||||
|
||||
// Cleanup existing connection if any
|
||||
cleanupSSH();
|
||||
|
||||
// Reconnect with new credentials
|
||||
const reconnectData: ConnectToHostData = {
|
||||
cols: credentialsData.cols,
|
||||
rows: credentialsData.rows,
|
||||
@@ -555,8 +546,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
sshConn.on("ready", () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
|
||||
// Immediately try to create shell - don't delay as it can cause connection to be cleaned up
|
||||
// The connection is already ready at this point
|
||||
if (!sshConn) {
|
||||
sshLogger.warn(
|
||||
"SSH connection was cleaned up before shell could be created",
|
||||
@@ -666,11 +655,9 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
JSON.stringify({ type: "connected", message: "SSH connected" }),
|
||||
);
|
||||
|
||||
// Log activity to dashboard API
|
||||
if (id && hostConfig.userId) {
|
||||
(async () => {
|
||||
try {
|
||||
// Fetch host name from database
|
||||
const hosts = await SimpleDBOps.select(
|
||||
getDb()
|
||||
.select()
|
||||
@@ -790,8 +777,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
prompts: Array<{ prompt: string; echo: boolean }>,
|
||||
finish: (responses: string[]) => void,
|
||||
) => {
|
||||
// Notify frontend that keyboard-interactive is available (e.g., for Warpgate OIDC)
|
||||
// This allows the terminal to be displayed immediately so user can see auth prompts
|
||||
if (resolvedCredentials.authType === "none") {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -846,37 +831,19 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
resolvedCredentials.password &&
|
||||
resolvedCredentials.authType !== "none";
|
||||
|
||||
// Check if this is a password prompt
|
||||
const passwordPromptIndex = prompts.findIndex((p) =>
|
||||
/password/i.test(p.prompt),
|
||||
);
|
||||
|
||||
// If no stored password (including authType "none"), prompt the user
|
||||
if (!hasStoredPassword && passwordPromptIndex !== -1) {
|
||||
// Don't block duplicate password prompts - some servers (like Warpgate) may ask multiple times
|
||||
if (keyboardInteractiveResponded && totpPromptSent) {
|
||||
// Only block if we already sent a TOTP prompt
|
||||
sshLogger.info(
|
||||
"Skipping duplicate password prompt after TOTP sent",
|
||||
{
|
||||
operation: "keyboard_interactive_skip",
|
||||
hostId: id,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
keyboardInteractiveResponded = true;
|
||||
|
||||
sshLogger.info("Requesting password from user (authType: none)", {
|
||||
operation: "keyboard_interactive_password",
|
||||
hostId: id,
|
||||
prompt: prompts[passwordPromptIndex].prompt,
|
||||
});
|
||||
|
||||
keyboardInteractiveFinish = (userResponses: string[]) => {
|
||||
const userInput = (userResponses[0] || "").trim();
|
||||
|
||||
// Build responses for all prompts
|
||||
const responses = prompts.map((p, index) => {
|
||||
if (index === passwordPromptIndex) {
|
||||
return userInput;
|
||||
@@ -884,16 +851,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return "";
|
||||
});
|
||||
|
||||
sshLogger.info(
|
||||
"User-provided password being sent to SSH server",
|
||||
{
|
||||
operation: "interactive_password_verification",
|
||||
hostId: id,
|
||||
passwordLength: userInput.length,
|
||||
totalPrompts: prompts.length,
|
||||
},
|
||||
);
|
||||
|
||||
finish(responses);
|
||||
};
|
||||
|
||||
@@ -906,8 +863,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-respond with stored credentials if available
|
||||
// Allow multiple responses - the server might ask multiple times during auth flow
|
||||
const responses = prompts.map((p) => {
|
||||
if (/password/i.test(p.prompt) && resolvedCredentials.password) {
|
||||
return resolvedCredentials.password;
|
||||
@@ -991,28 +946,15 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
};
|
||||
|
||||
if (resolvedCredentials.authType === "none") {
|
||||
// For "none" auth type, allow natural SSH negotiation
|
||||
// The authHandler will try keyboard-interactive if available, otherwise notify frontend
|
||||
// This allows for Warpgate OIDC and other interactive auth scenarios
|
||||
connectConfig.authHandler = (
|
||||
methodsLeft: string[] | null,
|
||||
partialSuccess: boolean,
|
||||
callback: (nextMethod: string | false) => void,
|
||||
) => {
|
||||
if (methodsLeft && methodsLeft.length > 0) {
|
||||
// Prefer keyboard-interactive if available
|
||||
if (methodsLeft.includes("keyboard-interactive")) {
|
||||
callback("keyboard-interactive");
|
||||
} else {
|
||||
// No keyboard-interactive available - notify frontend to show auth dialog
|
||||
sshLogger.info(
|
||||
"Server does not support keyboard-interactive auth for 'none' auth type",
|
||||
{
|
||||
operation: "ssh_auth_handler_no_keyboard",
|
||||
hostId: id,
|
||||
methodsLeft,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "auth_method_not_available",
|
||||
@@ -1024,11 +966,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
|
||||
callback(false);
|
||||
}
|
||||
} else {
|
||||
// No methods left or empty - try to proceed without auth
|
||||
sshLogger.info("No auth methods available, proceeding without auth", {
|
||||
operation: "ssh_auth_no_methods",
|
||||
hostId: id,
|
||||
});
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -217,9 +217,7 @@ function cleanupTunnelResources(
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
try {
|
||||
verification?.conn.end();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
} catch {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -284,9 +282,7 @@ function handleDisconnect(
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
verification?.conn.end();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
} catch {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -642,9 +638,7 @@ async function connectSSHTunnel(
|
||||
|
||||
try {
|
||||
conn.end();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
} catch {}
|
||||
|
||||
activeTunnels.delete(tunnelName);
|
||||
|
||||
@@ -784,9 +778,7 @@ async function connectSSHTunnel(
|
||||
const verification = tunnelVerifications.get(tunnelName);
|
||||
if (verification?.timeout) clearTimeout(verification.timeout);
|
||||
verification?.conn.end();
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
} catch {}
|
||||
tunnelVerifications.delete(tunnelName);
|
||||
}
|
||||
|
||||
@@ -837,13 +829,9 @@ async function connectSSHTunnel(
|
||||
}
|
||||
});
|
||||
|
||||
stream.stdout?.on("data", () => {
|
||||
// Silently consume stdout data
|
||||
});
|
||||
stream.stdout?.on("data", () => {});
|
||||
|
||||
stream.on("error", () => {
|
||||
// Silently consume stream errors
|
||||
});
|
||||
stream.on("error", () => {});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const errorMsg = data.toString().trim();
|
||||
@@ -1222,9 +1210,7 @@ async function killRemoteTunnelByMarker(
|
||||
executeNextKillCommand();
|
||||
});
|
||||
|
||||
stream.on("data", () => {
|
||||
// Silently consume stream data
|
||||
});
|
||||
stream.on("data", () => {});
|
||||
|
||||
stream.stderr.on("data", (data) => {
|
||||
const output = data.toString().trim();
|
||||
|
||||
@@ -21,9 +21,7 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
|
||||
if (persistentConfig.parsed) {
|
||||
Object.assign(process.env, persistentConfig.parsed);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors if .env file doesn't exist
|
||||
}
|
||||
} catch {}
|
||||
|
||||
let version = "unknown";
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ class AuthManager {
|
||||
this.invalidateUserTokens(userId);
|
||||
});
|
||||
|
||||
// Run session cleanup every 5 minutes
|
||||
setInterval(
|
||||
() => {
|
||||
this.cleanupExpiredSessions().catch((error) => {
|
||||
@@ -162,16 +161,15 @@ class AuthManager {
|
||||
): Promise<string> {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
// Determine expiration based on device type
|
||||
let expiresIn = options.expiresIn;
|
||||
if (!expiresIn && !options.pendingTOTP) {
|
||||
if (options.deviceType === "desktop" || options.deviceType === "mobile") {
|
||||
expiresIn = "30d"; // 30 days for desktop and mobile
|
||||
expiresIn = "30d";
|
||||
} else {
|
||||
expiresIn = "7d"; // 7 days for web
|
||||
expiresIn = "7d";
|
||||
}
|
||||
} else if (!expiresIn) {
|
||||
expiresIn = "7d"; // Default
|
||||
expiresIn = "7d";
|
||||
}
|
||||
|
||||
const payload: JWTPayload = { userId };
|
||||
@@ -179,23 +177,19 @@ class AuthManager {
|
||||
payload.pendingTOTP = true;
|
||||
}
|
||||
|
||||
// Create session in database if not a temporary TOTP token
|
||||
if (!options.pendingTOTP && options.deviceType && options.deviceInfo) {
|
||||
const sessionId = nanoid();
|
||||
payload.sessionId = sessionId;
|
||||
|
||||
// Generate the token first to get it for storage
|
||||
const token = jwt.sign(payload, jwtSecret, {
|
||||
expiresIn,
|
||||
} as jwt.SignOptions);
|
||||
|
||||
// Calculate expiration timestamp
|
||||
const expirationMs = this.parseExpiresIn(expiresIn);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + expirationMs).toISOString();
|
||||
const createdAt = now.toISOString();
|
||||
|
||||
// Store session in database
|
||||
try {
|
||||
await db.insert(sessions).values({
|
||||
id: sessionId,
|
||||
@@ -208,27 +202,11 @@ class AuthManager {
|
||||
lastActiveAt: createdAt,
|
||||
});
|
||||
|
||||
databaseLogger.info("Session created", {
|
||||
operation: "session_create",
|
||||
userId,
|
||||
sessionId,
|
||||
deviceType: options.deviceType,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
// Immediately save database to disk to ensure session persists across restarts
|
||||
try {
|
||||
const { saveMemoryDatabaseToFile } = await import(
|
||||
"../database/db/index.js"
|
||||
);
|
||||
await saveMemoryDatabaseToFile();
|
||||
databaseLogger.info(
|
||||
"Database saved immediately after session creation",
|
||||
{
|
||||
operation: "session_create_db_save",
|
||||
sessionId,
|
||||
},
|
||||
);
|
||||
} catch (saveError) {
|
||||
databaseLogger.error(
|
||||
"Failed to save database after session creation",
|
||||
@@ -245,7 +223,6 @@ class AuthManager {
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
// Continue anyway - session tracking is non-critical
|
||||
}
|
||||
|
||||
return token;
|
||||
@@ -259,7 +236,7 @@ class AuthManager {
|
||||
*/
|
||||
private parseExpiresIn(expiresIn: string): number {
|
||||
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return 7 * 24 * 60 * 60 * 1000; // Default 7 days
|
||||
if (!match) return 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2];
|
||||
@@ -282,26 +259,8 @@ class AuthManager {
|
||||
try {
|
||||
const jwtSecret = await this.systemCrypto.getJWTSecret();
|
||||
|
||||
databaseLogger.info("Attempting JWT verification", {
|
||||
operation: "jwt_verify_attempt",
|
||||
tokenLength: token.length,
|
||||
secretLength: jwtSecret.length,
|
||||
});
|
||||
|
||||
const payload = jwt.verify(token, jwtSecret) as JWTPayload;
|
||||
|
||||
databaseLogger.info("JWT signature verified successfully", {
|
||||
operation: "jwt_signature_verified",
|
||||
userId: payload.userId,
|
||||
sessionId: payload.sessionId,
|
||||
hasExpiration: !!payload.exp,
|
||||
expiresAt: payload.exp
|
||||
? new Date(payload.exp * 1000).toISOString()
|
||||
: "N/A",
|
||||
});
|
||||
|
||||
// For tokens with sessionId, verify the session exists in database
|
||||
// This ensures revoked sessions are rejected even after backend restart
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRecords = await db
|
||||
@@ -322,13 +281,6 @@ class AuthManager {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
databaseLogger.info("Session found in database", {
|
||||
operation: "jwt_session_found",
|
||||
sessionId: payload.sessionId,
|
||||
userId: payload.userId,
|
||||
sessionExpiresAt: sessionRecords[0].expiresAt,
|
||||
});
|
||||
} catch (dbError) {
|
||||
databaseLogger.error(
|
||||
"Failed to check session in database during JWT verification",
|
||||
@@ -338,15 +290,8 @@ class AuthManager {
|
||||
sessionId: payload.sessionId,
|
||||
},
|
||||
);
|
||||
// Continue anyway - database errors shouldn't block valid JWTs
|
||||
}
|
||||
}
|
||||
|
||||
databaseLogger.info("JWT verification successful", {
|
||||
operation: "jwt_verify_success",
|
||||
userId: payload.userId,
|
||||
sessionId: payload.sessionId,
|
||||
});
|
||||
return payload;
|
||||
} catch (error) {
|
||||
databaseLogger.warn("JWT verification failed", {
|
||||
@@ -358,35 +303,14 @@ class AuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
invalidateJWTToken(token: string): void {
|
||||
// No-op: Token invalidation is now handled through database session deletion
|
||||
databaseLogger.info(
|
||||
"Token invalidation requested (handled via session deletion)",
|
||||
{
|
||||
operation: "token_invalidate",
|
||||
},
|
||||
);
|
||||
}
|
||||
invalidateJWTToken(token: string): void {}
|
||||
|
||||
invalidateUserTokens(userId: string): void {
|
||||
databaseLogger.info("User tokens invalidation requested due to data lock", {
|
||||
operation: "user_tokens_invalidate",
|
||||
userId,
|
||||
});
|
||||
// Session cleanup will happen through revokeAllUserSessions if needed
|
||||
}
|
||||
invalidateUserTokens(userId: string): void {}
|
||||
|
||||
async revokeSession(sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
// Delete the session from database
|
||||
// The JWT will be invalidated because verifyJWTToken checks for session existence
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
|
||||
databaseLogger.info("Session deleted", {
|
||||
operation: "session_delete",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete session", error, {
|
||||
@@ -402,7 +326,6 @@ class AuthManager {
|
||||
exceptSessionId?: string,
|
||||
): Promise<number> {
|
||||
try {
|
||||
// Get session count before deletion
|
||||
const userSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
@@ -412,8 +335,6 @@ class AuthManager {
|
||||
(s) => !exceptSessionId || s.id !== exceptSessionId,
|
||||
).length;
|
||||
|
||||
// Delete sessions from database
|
||||
// JWTs will be invalidated because verifyJWTToken checks for session existence
|
||||
if (exceptSessionId) {
|
||||
await db
|
||||
.delete(sessions)
|
||||
@@ -427,13 +348,6 @@ class AuthManager {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
}
|
||||
|
||||
databaseLogger.info("User sessions deleted", {
|
||||
operation: "user_sessions_delete",
|
||||
userId,
|
||||
exceptSessionId,
|
||||
deletedCount,
|
||||
});
|
||||
|
||||
return deletedCount;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete user sessions", error, {
|
||||
@@ -446,7 +360,6 @@ class AuthManager {
|
||||
|
||||
async cleanupExpiredSessions(): Promise<number> {
|
||||
try {
|
||||
// Get expired sessions count
|
||||
const expiredSessions = await db
|
||||
.select()
|
||||
.from(sessions)
|
||||
@@ -454,19 +367,10 @@ class AuthManager {
|
||||
|
||||
const expiredCount = expiredSessions.length;
|
||||
|
||||
// Delete expired sessions
|
||||
// JWTs will be invalidated because verifyJWTToken checks for session existence
|
||||
await db
|
||||
.delete(sessions)
|
||||
.where(sql`${sessions.expiresAt} < datetime('now')`);
|
||||
|
||||
if (expiredCount > 0) {
|
||||
databaseLogger.info("Expired sessions cleaned up", {
|
||||
operation: "sessions_cleanup",
|
||||
count: expiredCount,
|
||||
});
|
||||
}
|
||||
|
||||
return expiredCount;
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to cleanup expired sessions", error, {
|
||||
@@ -539,7 +443,6 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Invalid token" });
|
||||
}
|
||||
|
||||
// Check session status if sessionId is present
|
||||
if (payload.sessionId) {
|
||||
try {
|
||||
const sessionRecords = await db
|
||||
@@ -557,9 +460,6 @@ class AuthManager {
|
||||
|
||||
const session = sessionRecords[0];
|
||||
|
||||
// Session exists, no need to check isRevoked since we delete sessions instead
|
||||
|
||||
// Check if session has expired by comparing timestamps
|
||||
const sessionExpiryTime = new Date(session.expiresAt).getTime();
|
||||
const currentTime = Date.now();
|
||||
const isExpired = sessionExpiryTime < currentTime;
|
||||
@@ -579,7 +479,6 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Update lastActiveAt timestamp (async, non-blocking)
|
||||
db.update(sessions)
|
||||
.set({ lastActiveAt: new Date().toISOString() })
|
||||
.where(eq(sessions.id, payload.sessionId))
|
||||
@@ -596,7 +495,6 @@ class AuthManager {
|
||||
operation: "session_check_failed",
|
||||
sessionId: payload.sessionId,
|
||||
});
|
||||
// Continue anyway - session tracking failures shouldn't block auth
|
||||
}
|
||||
}
|
||||
|
||||
@@ -614,14 +512,8 @@ class AuthManager {
|
||||
return res.status(401).json({ error: "Authentication required" });
|
||||
}
|
||||
|
||||
// Try to get data key if available (may be null after restart)
|
||||
const dataKey = this.userCrypto.getUserDataKey(userId);
|
||||
authReq.dataKey = dataKey || undefined;
|
||||
|
||||
// Note: Data key will be null after backend restart until user performs
|
||||
// an operation that requires decryption. This is expected behavior.
|
||||
// Individual routes that need encryption should check dataKey explicitly.
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -688,15 +580,9 @@ class AuthManager {
|
||||
async logoutUser(userId: string, sessionId?: string): Promise<void> {
|
||||
this.userCrypto.logoutUser(userId);
|
||||
|
||||
// Delete the specific session from database if sessionId provided
|
||||
if (sessionId) {
|
||||
try {
|
||||
await db.delete(sessions).where(eq(sessions.id, sessionId));
|
||||
databaseLogger.info("Session deleted on logout", {
|
||||
operation: "session_delete_logout",
|
||||
userId,
|
||||
sessionId,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to delete session on logout", error, {
|
||||
operation: "session_delete_logout_failed",
|
||||
@@ -705,13 +591,8 @@ class AuthManager {
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If no sessionId, delete all sessions for this user
|
||||
try {
|
||||
await db.delete(sessions).where(eq(sessions.userId, userId));
|
||||
databaseLogger.info("All user sessions deleted on logout", {
|
||||
operation: "sessions_delete_logout",
|
||||
userId,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
"Failed to delete user sessions on logout",
|
||||
|
||||
@@ -233,9 +233,7 @@ IP.3 = 0.0.0.0
|
||||
let envContent = "";
|
||||
try {
|
||||
envContent = await fs.readFile(this.ENV_FILE, "utf8");
|
||||
} catch {
|
||||
// File doesn't exist yet, will create with SSL config
|
||||
}
|
||||
} catch {}
|
||||
|
||||
let updatedContent = envContent;
|
||||
let hasChanges = false;
|
||||
|
||||
@@ -393,18 +393,6 @@ class DataCrypto {
|
||||
|
||||
result.success = result.errors.length === 0;
|
||||
|
||||
databaseLogger.info(
|
||||
"User data re-encryption completed after password reset",
|
||||
{
|
||||
operation: "password_reset_reencrypt_completed",
|
||||
userId,
|
||||
success: result.success,
|
||||
reencryptedTables: result.reencryptedTables,
|
||||
reencryptedFieldsCount: result.reencryptedFieldsCount,
|
||||
errorsCount: result.errors.length,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
databaseLogger.error(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Database from "better-sqlite3";
|
||||
/import Database from "better-sqlite3";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { databaseLogger } from "./logger.js";
|
||||
@@ -62,10 +62,6 @@ export class DatabaseMigration {
|
||||
"Empty unencrypted database found alongside encrypted database. Removing empty file.";
|
||||
try {
|
||||
fs.unlinkSync(this.unencryptedDbPath);
|
||||
databaseLogger.info("Removed empty unencrypted database file", {
|
||||
operation: "migration_cleanup_empty",
|
||||
path: this.unencryptedDbPath,
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Failed to remove empty unencrypted database", {
|
||||
operation: "migration_cleanup_empty_failed",
|
||||
|
||||
@@ -71,11 +71,6 @@ export class DatabaseSaveTrigger {
|
||||
this.pendingSave = true;
|
||||
|
||||
try {
|
||||
databaseLogger.info("Force saving database", {
|
||||
operation: "db_save_trigger_force_start",
|
||||
reason,
|
||||
});
|
||||
|
||||
await this.saveFunction();
|
||||
} catch (error) {
|
||||
databaseLogger.error("Database force save failed", error, {
|
||||
@@ -110,9 +105,5 @@ export class DatabaseSaveTrigger {
|
||||
this.pendingSave = false;
|
||||
this.isInitialized = false;
|
||||
this.saveFunction = null;
|
||||
|
||||
databaseLogger.info("Database save trigger cleaned up", {
|
||||
operation: "db_save_trigger_cleanup",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,9 +82,7 @@ export class LazyFieldEncryption {
|
||||
legacyFieldName,
|
||||
);
|
||||
return decrypted;
|
||||
} catch {
|
||||
// Ignore legacy format errors
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const sensitiveFields = [
|
||||
@@ -176,9 +174,7 @@ export class LazyFieldEncryption {
|
||||
wasPlaintext: false,
|
||||
wasLegacyEncryption: true,
|
||||
};
|
||||
} catch {
|
||||
// Ignore legacy format errors
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return {
|
||||
encrypted: fieldValue,
|
||||
|
||||
@@ -6,7 +6,6 @@ type TableName = "users" | "ssh_data" | "ssh_credentials" | "recent_activity";
|
||||
|
||||
class SimpleDBOps {
|
||||
static async insert<T extends Record<string, unknown>>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
data: T,
|
||||
@@ -91,7 +90,6 @@ class SimpleDBOps {
|
||||
}
|
||||
|
||||
static async update<T extends Record<string, unknown>>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: unknown,
|
||||
@@ -110,7 +108,6 @@ class SimpleDBOps {
|
||||
const result = await getDb()
|
||||
.update(table)
|
||||
.set(encryptedData)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.where(where as any)
|
||||
.returning();
|
||||
|
||||
@@ -127,14 +124,12 @@ class SimpleDBOps {
|
||||
}
|
||||
|
||||
static async delete(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
table: SQLiteTable<any>,
|
||||
tableName: TableName,
|
||||
where: unknown,
|
||||
): Promise<unknown[]> {
|
||||
const result = await getDb()
|
||||
.delete(table)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.where(where as any)
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -84,9 +84,7 @@ function detectKeyTypeFromContent(keyContent: string): string {
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch {
|
||||
// Cannot decode key, fallback to length-based detection
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (content.length < 800) {
|
||||
return "ssh-ed25519";
|
||||
@@ -142,9 +140,7 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string {
|
||||
} else if (decodedString.includes("1.3.101.112")) {
|
||||
return "ssh-ed25519";
|
||||
}
|
||||
} catch {
|
||||
// Cannot decode key, fallback to length-based detection
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (content.length < 400) {
|
||||
return "ssh-ed25519";
|
||||
@@ -246,9 +242,7 @@ export function parseSSHKey(
|
||||
|
||||
useSSH2 = true;
|
||||
}
|
||||
} catch {
|
||||
// SSH2 parsing failed, will use fallback method
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!useSSH2) {
|
||||
@@ -274,9 +268,7 @@ export function parseSSHKey(
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fallback parsing also failed
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
privateKey: privateKeyData,
|
||||
|
||||
@@ -107,9 +107,7 @@ class SystemCrypto {
|
||||
process.env.DATABASE_KEY = dbKeyMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore file read errors, will generate new key
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await this.generateAndGuideDatabaseKey();
|
||||
} catch (error) {
|
||||
@@ -146,9 +144,7 @@ class SystemCrypto {
|
||||
process.env.INTERNAL_AUTH_TOKEN = tokenMatch[1];
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore file read errors, will generate new token
|
||||
}
|
||||
} catch {}
|
||||
|
||||
await this.generateAndGuideInternalAuthToken();
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,59 +7,43 @@ export interface DeviceInfo {
|
||||
browser: string;
|
||||
version: string;
|
||||
os: string;
|
||||
deviceInfo: string; // Formatted string like "Chrome 120 on Windows 11"
|
||||
deviceInfo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the platform type based on request headers
|
||||
*/
|
||||
export function detectPlatform(req: Request): DeviceType {
|
||||
const userAgent = req.headers["user-agent"] || "";
|
||||
const electronHeader = req.headers["x-electron-app"];
|
||||
|
||||
// Electron app detection
|
||||
if (electronHeader === "true") {
|
||||
return "desktop";
|
||||
}
|
||||
|
||||
// Mobile app detection
|
||||
if (userAgent.includes("Termix-Mobile")) {
|
||||
return "mobile";
|
||||
}
|
||||
|
||||
// Default to web
|
||||
return "web";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse User-Agent string to extract device information
|
||||
*/
|
||||
export function parseUserAgent(req: Request): DeviceInfo {
|
||||
const userAgent = req.headers["user-agent"] || "Unknown";
|
||||
const platform = detectPlatform(req);
|
||||
|
||||
// For Electron
|
||||
if (platform === "desktop") {
|
||||
return parseElectronUserAgent(userAgent);
|
||||
}
|
||||
|
||||
// For Mobile app
|
||||
if (platform === "mobile") {
|
||||
return parseMobileUserAgent(userAgent);
|
||||
}
|
||||
|
||||
// For web browsers
|
||||
return parseWebUserAgent(userAgent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Electron app user agent
|
||||
*/
|
||||
function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
||||
let os = "Unknown OS";
|
||||
let version = "Unknown";
|
||||
|
||||
// Detect OS
|
||||
if (userAgent.includes("Windows")) {
|
||||
os = parseWindowsVersion(userAgent);
|
||||
} else if (userAgent.includes("Mac OS X")) {
|
||||
@@ -68,7 +52,6 @@ function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
||||
os = "Linux";
|
||||
}
|
||||
|
||||
// Try to extract Electron version
|
||||
const electronMatch = userAgent.match(/Electron\/([\d.]+)/);
|
||||
if (electronMatch) {
|
||||
version = electronMatch[1];
|
||||
@@ -83,23 +66,17 @@ function parseElectronUserAgent(userAgent: string): DeviceInfo {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse mobile app user agent
|
||||
*/
|
||||
function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
||||
let os = "Unknown OS";
|
||||
let version = "Unknown";
|
||||
|
||||
// Check for Termix-Mobile/Platform format first (e.g., "Termix-Mobile/Android" or "Termix-Mobile/iOS")
|
||||
const termixPlatformMatch = userAgent.match(/Termix-Mobile\/(Android|iOS)/i);
|
||||
if (termixPlatformMatch) {
|
||||
const platform = termixPlatformMatch[1];
|
||||
if (platform.toLowerCase() === "android") {
|
||||
// Try to get Android version from full UA string
|
||||
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||
} else if (platform.toLowerCase() === "ios") {
|
||||
// Try to get iOS version from full UA string
|
||||
const iosMatch = userAgent.match(/OS ([\d_]+)/);
|
||||
if (iosMatch) {
|
||||
const iosVersion = iosMatch[1].replace(/_/g, ".");
|
||||
@@ -109,7 +86,6 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: Check for standard Android/iOS patterns in the user agent
|
||||
if (userAgent.includes("Android")) {
|
||||
const androidMatch = userAgent.match(/Android ([\d.]+)/);
|
||||
os = androidMatch ? `Android ${androidMatch[1]}` : "Android";
|
||||
@@ -128,8 +104,6 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract app version (if included in UA)
|
||||
// Match patterns like "Termix-Mobile/1.0.0" or just "Termix-Mobile"
|
||||
const versionMatch = userAgent.match(
|
||||
/Termix-Mobile\/(?:Android|iOS|)([\d.]+)/i,
|
||||
);
|
||||
@@ -146,15 +120,11 @@ function parseMobileUserAgent(userAgent: string): DeviceInfo {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse web browser user agent
|
||||
*/
|
||||
function parseWebUserAgent(userAgent: string): DeviceInfo {
|
||||
let browser = "Unknown Browser";
|
||||
let version = "Unknown";
|
||||
let os = "Unknown OS";
|
||||
|
||||
// Detect browser
|
||||
if (userAgent.includes("Edg/")) {
|
||||
const match = userAgent.match(/Edg\/([\d.]+)/);
|
||||
browser = "Edge";
|
||||
@@ -177,7 +147,6 @@ function parseWebUserAgent(userAgent: string): DeviceInfo {
|
||||
version = match ? match[1] : "Unknown";
|
||||
}
|
||||
|
||||
// Detect OS
|
||||
if (userAgent.includes("Windows")) {
|
||||
os = parseWindowsVersion(userAgent);
|
||||
} else if (userAgent.includes("Mac OS X")) {
|
||||
@@ -201,7 +170,6 @@ function parseWebUserAgent(userAgent: string): DeviceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Shorten version to major.minor
|
||||
if (version !== "Unknown") {
|
||||
const versionParts = version.split(".");
|
||||
version = versionParts.slice(0, 2).join(".");
|
||||
@@ -216,9 +184,6 @@ function parseWebUserAgent(userAgent: string): DeviceInfo {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Windows version from user agent
|
||||
*/
|
||||
function parseWindowsVersion(userAgent: string): string {
|
||||
if (userAgent.includes("Windows NT 10.0")) {
|
||||
return "Windows 10/11";
|
||||
@@ -239,9 +204,6 @@ function parseWindowsVersion(userAgent: string): string {
|
||||
return "Windows";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse macOS version from user agent
|
||||
*/
|
||||
function parseMacVersion(userAgent: string): string {
|
||||
const match = userAgent.match(/Mac OS X ([\d_]+)/);
|
||||
if (match) {
|
||||
@@ -250,7 +212,6 @@ function parseMacVersion(userAgent: string): string {
|
||||
const major = parseInt(parts[0]);
|
||||
const minor = parseInt(parts[1]);
|
||||
|
||||
// macOS naming
|
||||
if (major === 10) {
|
||||
if (minor >= 15) return `macOS ${major}.${minor}`;
|
||||
if (minor === 14) return "macOS Mojave";
|
||||
|
||||
Reference in New Issue
Block a user