first commit
This commit is contained in:
+687
@@ -0,0 +1,687 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import cookieParser from "cookie-parser";
|
||||
import dotenv from "dotenv";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import http from "http";
|
||||
import axios from "axios";
|
||||
import { fileURLToPath } from "url";
|
||||
import {
|
||||
scanLogs,
|
||||
getBruteForceLocks,
|
||||
unblockIp,
|
||||
getBackupArchives,
|
||||
} from "./logParser.js";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
const PORT = process.env.PORT || 5880;
|
||||
const LOGS_DIR = path.resolve(process.env.LOGS_DIR || "./logs");
|
||||
const isDev =
|
||||
process.env.NODE_ENV !== "production" || process.env.VITE_DEV === "true";
|
||||
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// Default Keycloak Env Config
|
||||
const defaultKcServer = process.env.KEYCLOAK_URL || "https://auth.rssa.top/";
|
||||
const defaultKcRealm = process.env.KEYCLOAK_REALM || "rssa";
|
||||
const defaultKcClient = process.env.KEYCLOAK_CLIENT_ID || "satu";
|
||||
const defaultKcSecret =
|
||||
process.env.KEYCLOAK_CLIENT_SECRET || "ZhkK45MHB0a0eAZX5ecNTnlfnWlZXfBE";
|
||||
|
||||
const SESSION_COOKIE = "forensic_session";
|
||||
const PKCE_STATE_COOKIE = "pkce_auth_state";
|
||||
|
||||
// Helper for Session cookie
|
||||
function getSessionUser(req) {
|
||||
const sessionStr = req.cookies?.[SESSION_COOKIE];
|
||||
if (!sessionStr) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(sessionStr, "base64").toString("utf-8"));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function setSessionUser(res, user) {
|
||||
const encoded = Buffer.from(JSON.stringify(user)).toString("base64");
|
||||
res.cookie(SESSION_COOKIE, encoded, {
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
maxAge: 86400 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Cryptographic Helpers for PKCE
|
||||
function base64UrlEncode(buffer) {
|
||||
return buffer
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
function generateCodeVerifier() {
|
||||
return base64UrlEncode(crypto.randomBytes(32));
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier) {
|
||||
const hash = crypto.createHash("sha256").update(verifier).digest();
|
||||
return base64UrlEncode(hash);
|
||||
}
|
||||
|
||||
// Keycloak Group Verification Helper (Restricted to "Instalasi STIM" / "STIM")
|
||||
async function extractAndVerifyUserGroups(payload, accessToken, serverUrl, realm) {
|
||||
const groups = new Set();
|
||||
|
||||
const checkClaim = (val) => {
|
||||
if (Array.isArray(val)) {
|
||||
val.forEach((item) => {
|
||||
if (typeof item === "string") groups.add(item);
|
||||
else if (item && item.name) groups.add(item.name);
|
||||
});
|
||||
} else if (typeof val === "string") {
|
||||
groups.add(val);
|
||||
}
|
||||
};
|
||||
|
||||
checkClaim(payload.groups);
|
||||
checkClaim(payload.group);
|
||||
checkClaim(payload.realm_access?.roles);
|
||||
if (payload.resource_access) {
|
||||
Object.values(payload.resource_access).forEach((resAcc) => {
|
||||
checkClaim(resAcc?.roles);
|
||||
});
|
||||
}
|
||||
|
||||
// If no groups found in token payload, query Keycloak UserInfo Endpoint
|
||||
if (accessToken && serverUrl && realm) {
|
||||
try {
|
||||
const cleanServer = serverUrl.replace(/\/$/, "");
|
||||
const userinfoUrl = `${cleanServer}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/userinfo`;
|
||||
const res = await axios.get(userinfoUrl, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
timeout: 5000,
|
||||
});
|
||||
if (res.data) {
|
||||
checkClaim(res.data.groups);
|
||||
checkClaim(res.data.group);
|
||||
checkClaim(res.data.realm_access?.roles);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Keycloak UserInfo fetch warning:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const groupList = Array.from(groups);
|
||||
console.log("Extracted Keycloak user groups:", groupList);
|
||||
|
||||
// Group STIM Authorization check
|
||||
const isAllowed = groupList.some((g) => {
|
||||
const lower = String(g).toLowerCase();
|
||||
return lower.includes("stim") || lower.includes("instalasi stim");
|
||||
});
|
||||
|
||||
return { isAllowed, groupList };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(LOGS_DIR)) {
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// REST API ENDPOINTS
|
||||
// -------------------------------------------------------------
|
||||
|
||||
// 1. PKCE Keycloak SSO Initiate Route
|
||||
app.get("/api/auth/pkce/login", (req, res) => {
|
||||
const serverUrl = (req.query.kc_server || defaultKcServer)
|
||||
.toString()
|
||||
.replace(/\/$/, "");
|
||||
const realm = (req.query.kc_realm || defaultKcRealm).toString();
|
||||
const clientId = (req.query.kc_client_id || defaultKcClient).toString();
|
||||
const clientSecret = (
|
||||
req.query.kc_client_secret || defaultKcSecret
|
||||
).toString();
|
||||
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
const state = base64UrlEncode(crypto.randomBytes(16));
|
||||
|
||||
const proto = req.headers["x-forwarded-proto"] || req.protocol || "http";
|
||||
const host = req.headers["x-forwarded-host"] || req.headers.host;
|
||||
|
||||
let redirectUri = (
|
||||
req.query.kc_redirect_uri ||
|
||||
process.env.KEYCLOAK_REDIRECT_URI ||
|
||||
""
|
||||
).toString();
|
||||
if (!redirectUri) {
|
||||
redirectUri = `${proto}://${host}/api/auth/pkce/callback`;
|
||||
}
|
||||
|
||||
const pkceState = {
|
||||
codeVerifier,
|
||||
state,
|
||||
redirectUri,
|
||||
serverUrl,
|
||||
realm,
|
||||
clientId,
|
||||
clientSecret,
|
||||
};
|
||||
|
||||
res.cookie(
|
||||
PKCE_STATE_COOKIE,
|
||||
Buffer.from(JSON.stringify(pkceState)).toString("base64"),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
sameSite: "lax",
|
||||
maxAge: 600 * 1000,
|
||||
},
|
||||
);
|
||||
|
||||
const authEndpoint = `${serverUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/auth`;
|
||||
const authUrl =
|
||||
`${authEndpoint}?` +
|
||||
new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope: "openid profile email",
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
}).toString();
|
||||
|
||||
return res.redirect(authUrl);
|
||||
});
|
||||
|
||||
// 2. PKCE Keycloak SSO Callback Route
|
||||
app.get("/api/auth/pkce/callback", async (req, res) => {
|
||||
const { code, state, error, error_description } = req.query;
|
||||
|
||||
if (error) {
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
return res.redirect(
|
||||
`/?error=${encodeURIComponent(error_description || error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const pkceCookie = req.cookies?.[PKCE_STATE_COOKIE];
|
||||
if (!pkceCookie || !code || !state) {
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
return res.redirect("/?error=Sesi+PKCE+tidak+valid+atau+kadaluarsa.");
|
||||
}
|
||||
|
||||
let pkceState = null;
|
||||
try {
|
||||
pkceState = JSON.parse(Buffer.from(pkceCookie, "base64").toString("utf-8"));
|
||||
} catch (e) {
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
return res.redirect("/?error=Gagal+membaca+state+PKCE.");
|
||||
}
|
||||
|
||||
if (pkceState.state !== state) {
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
return res.redirect("/?error=Validasi+CSRF+State+PKCE+Gagal.");
|
||||
}
|
||||
|
||||
const tokenEndpoint = `${pkceState.serverUrl}/realms/${encodeURIComponent(pkceState.realm)}/protocol/openid-connect/token`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("grant_type", "authorization_code");
|
||||
params.append("client_id", pkceState.clientId);
|
||||
params.append("code", code.toString());
|
||||
params.append("redirect_uri", pkceState.redirectUri);
|
||||
params.append("code_verifier", pkceState.codeVerifier);
|
||||
if (pkceState.clientSecret) {
|
||||
params.append("client_secret", pkceState.clientSecret);
|
||||
}
|
||||
|
||||
try {
|
||||
const tokenRes = await axios.post(tokenEndpoint, params.toString(), {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (tokenRes.data && tokenRes.data.access_token) {
|
||||
let payload = {};
|
||||
const parts = tokenRes.data.access_token.split(".");
|
||||
if (parts[1]) {
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
Buffer.from(parts[1], "base64").toString("utf-8"),
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const username = payload.preferred_username || payload.sub || "User";
|
||||
|
||||
// Verify STIM Group Access
|
||||
const { isAllowed, groupList } = await extractAndVerifyUserGroups(
|
||||
payload,
|
||||
tokenRes.data.access_token,
|
||||
pkceState.serverUrl,
|
||||
pkceState.realm,
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
const groupInfo = groupList.length > 0 ? groupList.join(", ") : "Tanpa Grup";
|
||||
return res.redirect(
|
||||
`/?error=${encodeURIComponent(`Akses Ditolak: User "${username}" (${groupInfo}) tidak memiliki akses grup Instalasi STIM.`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = {
|
||||
username: username,
|
||||
name: payload.name || username,
|
||||
email: payload.email || "",
|
||||
login_time: new Date().toISOString().replace("T", " ").substring(0, 19),
|
||||
access_token: tokenRes.data.access_token,
|
||||
auth_type: "keycloak_sso_pkce",
|
||||
kc_server: pkceState.serverUrl,
|
||||
kc_realm: pkceState.realm,
|
||||
groups: groupList,
|
||||
};
|
||||
|
||||
setSessionUser(res, user);
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
return res.redirect("/");
|
||||
} else {
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
return res.redirect("/?error=Penukaran+Token+PKCE+Gagal.");
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg =
|
||||
err.response?.data?.error_description ||
|
||||
err.response?.data?.error ||
|
||||
err.message;
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
return res.redirect(
|
||||
`/?error=Penukaran+Token+PKCE+Error:+${encodeURIComponent(errMsg)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Direct Login Endpoint (Fallback / Temporary Local)
|
||||
app.post("/api/auth/login", async (req, res) => {
|
||||
const {
|
||||
username,
|
||||
password,
|
||||
kc_server,
|
||||
kc_realm,
|
||||
kc_client_id,
|
||||
kc_client_secret,
|
||||
} = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Username dan Password wajib diisi." });
|
||||
}
|
||||
|
||||
// A. Temporary Local Login
|
||||
if (
|
||||
(username === "[email protected]" || username === "stim") &&
|
||||
password === "RSSAjaya2026"
|
||||
) {
|
||||
const user = {
|
||||
username: "[email protected]",
|
||||
name: "STIM RSSA Admin (Pengembangan SSO)",
|
||||
email: "[email protected]",
|
||||
login_time: new Date().toISOString().replace("T", " ").substring(0, 19),
|
||||
auth_type: "temporary_local",
|
||||
kc_server: "local",
|
||||
kc_realm: "simrs",
|
||||
groups: ["Instalasi STIM"],
|
||||
};
|
||||
setSessionUser(res, user);
|
||||
return res.json({ success: true, user });
|
||||
}
|
||||
|
||||
// B. Keycloak Direct SSO Login (Fall-back Direct Token)
|
||||
const serverUrl = (kc_server || defaultKcServer).replace(/\/$/, "");
|
||||
const realm = kc_realm || defaultKcRealm;
|
||||
const clientId = kc_client_id || defaultKcClient;
|
||||
const clientSecret = kc_client_secret || defaultKcSecret;
|
||||
|
||||
let tokenEndpoint = "";
|
||||
if (serverUrl.includes("/protocol/openid-connect")) {
|
||||
tokenEndpoint = serverUrl;
|
||||
} else if (serverUrl.includes("/realms/")) {
|
||||
tokenEndpoint = `${serverUrl}/protocol/openid-connect/token`;
|
||||
} else {
|
||||
tokenEndpoint = `${serverUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append("grant_type", "password");
|
||||
params.append("client_id", clientId);
|
||||
params.append("username", username);
|
||||
params.append("password", password);
|
||||
params.append("scope", "openid profile email");
|
||||
if (clientSecret) {
|
||||
params.append("client_secret", clientSecret);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(tokenEndpoint, params.toString(), {
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
if (response.data && response.data.access_token) {
|
||||
let payload = {};
|
||||
const parts = response.data.access_token.split(".");
|
||||
if (parts[1]) {
|
||||
try {
|
||||
payload = JSON.parse(
|
||||
Buffer.from(parts[1], "base64").toString("utf-8"),
|
||||
);
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const userUsername = payload.preferred_username || username;
|
||||
|
||||
// Verify STIM Group Access
|
||||
const { isAllowed, groupList } = await extractAndVerifyUserGroups(
|
||||
payload,
|
||||
response.data.access_token,
|
||||
serverUrl,
|
||||
realm,
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
const groupInfo = groupList.length > 0 ? groupList.join(", ") : "Tanpa Grup";
|
||||
return res.status(403).json({
|
||||
error: `Akses Ditolak: User "${userUsername}" (${groupInfo}) tidak memiliki akses grup Instalasi STIM.`,
|
||||
});
|
||||
}
|
||||
|
||||
const user = {
|
||||
username: userUsername,
|
||||
name: payload.name || payload.preferred_username || username,
|
||||
email: payload.email || "",
|
||||
login_time: new Date().toISOString().replace("T", " ").substring(0, 19),
|
||||
access_token: response.data.access_token,
|
||||
auth_type: "keycloak_sso",
|
||||
kc_server: serverUrl,
|
||||
kc_realm: realm,
|
||||
groups: groupList,
|
||||
};
|
||||
|
||||
setSessionUser(res, user);
|
||||
return res.json({ success: true, user });
|
||||
} else {
|
||||
return res
|
||||
.status(401)
|
||||
.json({ error: "Keycloak SSO: Token tidak valid." });
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg =
|
||||
err.response?.data?.error_description ||
|
||||
err.response?.data?.error ||
|
||||
err.message;
|
||||
return res.status(401).json({ error: `Login Keycloak Gagal: ${errMsg}` });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/auth/logout", (req, res) => {
|
||||
res.clearCookie(SESSION_COOKIE);
|
||||
res.clearCookie(PKCE_STATE_COOKIE);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get("/api/auth/me", (req, res) => {
|
||||
const envKcServer = process.env.KEYCLOAK_URL || process.env.SSO_URL;
|
||||
const envKcRealm = process.env.KEYCLOAK_REALM;
|
||||
const envKcClient = process.env.KEYCLOAK_CLIENT_ID;
|
||||
const isKcEnvConfigured = Boolean(envKcServer || (envKcRealm && envKcClient));
|
||||
const simrsUrl = process.env.SIMRS_URL || "/";
|
||||
|
||||
const user = getSessionUser(req);
|
||||
const config = {
|
||||
isKcEnvConfigured,
|
||||
defaultKcServer,
|
||||
defaultKcRealm,
|
||||
defaultKcClient,
|
||||
defaultKcSecret,
|
||||
simrsUrl,
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return res.json({ authenticated: false, config });
|
||||
}
|
||||
return res.json({ authenticated: true, user, config });
|
||||
});
|
||||
|
||||
// 4. Scan Logs API
|
||||
app.get("/api/logs", (req, res) => {
|
||||
const {
|
||||
source = "auto",
|
||||
date = new Date().toISOString().substring(0, 10),
|
||||
channel = "all",
|
||||
level = "all",
|
||||
time_start = "",
|
||||
time_end = "",
|
||||
search = "",
|
||||
page = "1",
|
||||
limit = "100",
|
||||
} = req.query;
|
||||
|
||||
const filterOpts = {
|
||||
source: String(source),
|
||||
date: String(date),
|
||||
channel: String(channel),
|
||||
level: String(level),
|
||||
timeStart: String(time_start),
|
||||
timeEnd: String(time_end),
|
||||
search: String(search),
|
||||
};
|
||||
|
||||
const { logs, stats } = scanLogs(LOGS_DIR, filterOpts);
|
||||
const lockedIPs = getBruteForceLocks(LOGS_DIR);
|
||||
const backups = getBackupArchives(LOGS_DIR);
|
||||
|
||||
const totalLogs = stats.total;
|
||||
const perPage = parseInt(String(limit), 10) || 100;
|
||||
const totalPages = Math.max(1, Math.ceil(logs.length / perPage));
|
||||
const currentPage = Math.min(
|
||||
Math.max(1, parseInt(String(page), 10) || 1),
|
||||
totalPages,
|
||||
);
|
||||
const offset = (currentPage - 1) * perPage;
|
||||
|
||||
const pagedLogs = logs.slice(offset, offset + perPage);
|
||||
|
||||
res.json({
|
||||
logs: pagedLogs,
|
||||
stats: {
|
||||
...stats,
|
||||
brute_force_locked: lockedIPs.length,
|
||||
},
|
||||
pagination: {
|
||||
totalLogs,
|
||||
perPage,
|
||||
currentPage,
|
||||
totalPages,
|
||||
hasNext: currentPage < totalPages,
|
||||
offset,
|
||||
},
|
||||
backups,
|
||||
lockedIPs,
|
||||
});
|
||||
});
|
||||
|
||||
// 5. Brute Force API
|
||||
app.get("/api/brute-force", (req, res) => {
|
||||
const lockedIPs = getBruteForceLocks(LOGS_DIR);
|
||||
res.json({ lockedIPs });
|
||||
});
|
||||
|
||||
app.post("/api/brute-force/unblock", (req, res) => {
|
||||
const { target_ip } = req.body;
|
||||
if (!target_ip) {
|
||||
return res.status(400).json({ error: "target_ip wajib diisi." });
|
||||
}
|
||||
const success = unblockIp(LOGS_DIR, target_ip);
|
||||
if (success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Lockout IP ${target_ip} berhasil dibersihkan.`,
|
||||
});
|
||||
} else {
|
||||
res
|
||||
.status(404)
|
||||
.json({ error: `IP ${target_ip} tidak ditemukan dalam cache lockout.` });
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Manual Retention Trigger API
|
||||
app.post("/api/retention/run", (req, res) => {
|
||||
const retentionPath = path.join(LOGS_DIR, "retention.log");
|
||||
const nowStr = new Date().toISOString().replace("T", " ").substring(0, 19);
|
||||
const logEntry = `[${nowStr}] [INFO] [system] Manual retention job executed via API.\n`;
|
||||
try {
|
||||
fs.appendFileSync(retentionPath, logEntry);
|
||||
res.json({
|
||||
success: true,
|
||||
message:
|
||||
"Job retention & backup berhasil dijalankan (Manual Retention Triggered).",
|
||||
});
|
||||
} catch (err) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: `Gagal menjalankan retention job: ${err.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Backups Listing API
|
||||
app.get("/api/backups", (req, res) => {
|
||||
const backups = getBackupArchives(LOGS_DIR);
|
||||
res.json({ backups });
|
||||
});
|
||||
|
||||
// 8. Export API
|
||||
app.get("/api/export", (req, res) => {
|
||||
const {
|
||||
type = "json",
|
||||
source = "auto",
|
||||
date = new Date().toISOString().substring(0, 10),
|
||||
channel = "all",
|
||||
level = "all",
|
||||
search = "",
|
||||
} = req.query;
|
||||
|
||||
const filterOpts = {
|
||||
source: String(source),
|
||||
date: String(date),
|
||||
channel: String(channel),
|
||||
level: String(level),
|
||||
search: String(search),
|
||||
};
|
||||
|
||||
const { logs } = scanLogs(LOGS_DIR, filterOpts);
|
||||
|
||||
if (type === "csv") {
|
||||
res.setHeader("Content-Type", "text/csv");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="forensic_log_${date}.csv"`,
|
||||
);
|
||||
|
||||
let csv = "Timestamp,Correlation ID,Channel,Level,Message,IP,NIP,URI\n";
|
||||
for (const l of logs) {
|
||||
const ts = `"${(l.timestamp || "").replace(/"/g, '""')}"`;
|
||||
const cid = `"${(l.correlation_id || "").replace(/"/g, '""')}"`;
|
||||
const ch = `"${(l.channel || "").replace(/"/g, '""')}"`;
|
||||
const lvl = `"${(l.level || "").replace(/"/g, '""')}"`;
|
||||
const msg = `"${(l.message || "").replace(/"/g, '""')}"`;
|
||||
const ip = `"${(l.context?.ip_address || "").replace(/"/g, '""')}"`;
|
||||
const nip = `"${(l.context?.user_nip || "").replace(/"/g, '""')}"`;
|
||||
const uri = `"${(l.context?.request_uri || "").replace(/"/g, '""')}"`;
|
||||
csv += `${ts},${cid},${ch},${lvl},${msg},${ip},${nip},${uri}\n`;
|
||||
}
|
||||
return res.send(csv);
|
||||
} else {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="forensic_log_${date}.json"`,
|
||||
);
|
||||
return res.json(logs);
|
||||
}
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// STATIC DIST SERVING (PRIMARY) VS VITE HMR DEV MIDDLEWARE
|
||||
// -------------------------------------------------------------
|
||||
const distDir = path.resolve(__dirname, "../dist");
|
||||
|
||||
if (fs.existsSync(distDir) && process.env.VITE_DEV !== "true") {
|
||||
app.use(express.static(distDir));
|
||||
app.get("*", (req, res, next) => {
|
||||
if (req.path.startsWith("/api")) return next();
|
||||
res.sendFile(path.join(distDir, "index.html"));
|
||||
});
|
||||
} else if (isDev) {
|
||||
try {
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const vite = await createViteServer({
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
allowedHosts: true,
|
||||
hmr: {
|
||||
server: server,
|
||||
},
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 500,
|
||||
ignored: ["**/logs/**", "**/node_modules/**", "**/dist/**", "**/.git/**"],
|
||||
},
|
||||
},
|
||||
appType: "custom",
|
||||
});
|
||||
|
||||
app.use(vite.middlewares);
|
||||
|
||||
app.get("*", async (req, res, next) => {
|
||||
if (req.path.startsWith("/api")) return next();
|
||||
try {
|
||||
const url = req.originalUrl;
|
||||
const indexPath = path.resolve(__dirname, "../index.html");
|
||||
let template = fs.readFileSync(indexPath, "utf-8");
|
||||
template = await vite.transformIndexHtml(url, template);
|
||||
res.status(200).set({ "Content-Type": "text/html" }).end(template);
|
||||
} catch (e) {
|
||||
vite.ssrFixStacktrace(e);
|
||||
next(e);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Failed to start Vite Dev Middleware, falling back to static:",
|
||||
err.message,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(
|
||||
`⚡ SIMRS Forensic Inspector Server running on http://0.0.0.0:${PORT} [ENV: ${process.env.NODE_ENV || "development"}]`,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,380 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
// Pre-compiled regex instances for ultra-fast matching
|
||||
const IP_REGEX = /\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b/;
|
||||
const NIP_REGEX = /(?:NIP|User|Petugas|Akun|oleh)[:\s]+([A-Za-z0-9_\-\.\,\@\s]{3,40})/i;
|
||||
const URI_REGEX = /\s(\/[a-zA-Z0-9_\-\/\.]+\.php[^\s"']*)/;
|
||||
const URL_REGEX = /https?:\/\/[^\s"']+/;
|
||||
|
||||
export function extractLogMetaData(entry, rawLine) {
|
||||
if (!entry.context || typeof entry.context !== 'object') {
|
||||
entry.context = {};
|
||||
}
|
||||
|
||||
// Fast path: if metadata is already populated, skip scanning
|
||||
const hasIp = Boolean(entry.context.ip_address || entry.context.ip || entry.ip_address);
|
||||
const hasNip = Boolean(entry.context.user_nip || entry.context.user || entry.context.nip || entry.context.username);
|
||||
const hasUri = Boolean(entry.context.request_uri || entry.context.uri || entry.context.url);
|
||||
|
||||
if (hasIp && hasNip && hasUri) return;
|
||||
|
||||
const msg = entry.message || rawLine;
|
||||
|
||||
if (!hasIp) {
|
||||
const ipMatch = rawLine.match(IP_REGEX) || msg.match(IP_REGEX);
|
||||
if (ipMatch && ipMatch[0] !== '127.0.0.1' && ipMatch[0] !== '0.0.0.0') {
|
||||
entry.context.ip_address = ipMatch[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasNip) {
|
||||
const mNip = rawLine.match(NIP_REGEX) || msg.match(NIP_REGEX);
|
||||
if (mNip) {
|
||||
entry.context.user_nip = mNip[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasUri) {
|
||||
const mUri = rawLine.match(URI_REGEX) || msg.match(URI_REGEX) || rawLine.match(URL_REGEX) || msg.match(URL_REGEX);
|
||||
if (mUri) {
|
||||
entry.context.request_uri = mUri[1] || mUri[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLogLine(line, sourceChannel, filterOpts, lineSeq) {
|
||||
line = line.trim();
|
||||
if (!line) return null;
|
||||
|
||||
let entry = null;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch (e) {
|
||||
// Non-JSON line parsing
|
||||
const defaultTs = filterOpts.date ? `${filterOpts.date} 00:00:00` : new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||
let timestamp = '';
|
||||
|
||||
const m1 = line.match(/\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)\]/);
|
||||
const m2 = line.match(/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?)/);
|
||||
const m3 = line.match(/(\d{2}-\d{2}-\d{4}[ T]\d{2}:\d{2}:\d{2})/);
|
||||
|
||||
if (m1) timestamp = m1[1].replace('T', ' ');
|
||||
else if (m2) timestamp = m2[1].replace('T', ' ');
|
||||
else if (m3) {
|
||||
const parts = m3[1].split(/[ T]/);
|
||||
const dParts = parts[0].split('-');
|
||||
timestamp = `${dParts[2]}-${dParts[1]}-${dParts[0]} ${parts[1]}`;
|
||||
}
|
||||
|
||||
if (!timestamp) timestamp = defaultTs;
|
||||
else if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(timestamp)) {
|
||||
timestamp = timestamp.substring(0, 19);
|
||||
}
|
||||
|
||||
entry = {
|
||||
timestamp,
|
||||
channel: sourceChannel || 'access',
|
||||
level: 'INFO',
|
||||
message: line,
|
||||
correlation_id: '-',
|
||||
context: { raw_log: line },
|
||||
};
|
||||
}
|
||||
|
||||
if (entry && !entry.timestamp) {
|
||||
const tsVal = entry.created_at || entry.date || entry.time;
|
||||
if (tsVal) {
|
||||
entry.timestamp = new Date(tsVal).toISOString().replace('T', ' ').substring(0, 19);
|
||||
} else {
|
||||
const m = line.match(/(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})/);
|
||||
entry.timestamp = m ? m[1].replace('T', ' ') : (filterOpts.date ? `${filterOpts.date} 00:00:00` : new Date().toISOString().replace('T', ' ').substring(0, 19));
|
||||
}
|
||||
}
|
||||
|
||||
entry._seq = lineSeq;
|
||||
extractLogMetaData(entry, line);
|
||||
|
||||
const ch = entry.channel || sourceChannel;
|
||||
if (filterOpts.channel && filterOpts.channel !== 'all' && ch.toLowerCase() !== filterOpts.channel.toLowerCase()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lvl = (entry.level || 'INFO').toUpperCase();
|
||||
if (filterOpts.level && filterOpts.level !== 'all' && lvl !== filterOpts.level.toUpperCase()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (filterOpts.timeStart || filterOpts.timeEnd) {
|
||||
let entryTime = '';
|
||||
const tmMatch = entry.timestamp.match(/[T ](\d{2}:\d{2})/);
|
||||
if (tmMatch) entryTime = tmMatch[1];
|
||||
if (filterOpts.timeStart && entryTime && entryTime < filterOpts.timeStart) return null;
|
||||
if (filterOpts.timeEnd && entryTime && entryTime > filterOpts.timeEnd) return null;
|
||||
}
|
||||
|
||||
if (filterOpts.search) {
|
||||
const searchLow = filterOpts.search.toLowerCase();
|
||||
const msgLow = (entry.message || '').toLowerCase();
|
||||
const chLow = (entry.channel || '').toLowerCase();
|
||||
const lvlLow = (entry.level || '').toLowerCase();
|
||||
const ipLow = (entry.context?.ip_address || '').toLowerCase();
|
||||
const nipLow = (entry.context?.user_nip || '').toLowerCase();
|
||||
|
||||
if (
|
||||
!msgLow.includes(searchLow) &&
|
||||
!chLow.includes(searchLow) &&
|
||||
!lvlLow.includes(searchLow) &&
|
||||
!ipLow.includes(searchLow) &&
|
||||
!nipLow.includes(searchLow)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function scanLogs(logsRootDir, filterOpts) {
|
||||
const filterDate = filterOpts.date || new Date().toISOString().substring(0, 10);
|
||||
const [year, month, day] = filterDate.split('-');
|
||||
const dmY = `${day}-${month}-${year}`;
|
||||
|
||||
const logs = [];
|
||||
const MAX_BROWSER_ITEMS = 100000; // Limit for browser payload only, not scanning
|
||||
|
||||
const stats = {
|
||||
total: 0,
|
||||
login_success: 0,
|
||||
login_failed: 0,
|
||||
session_expired: 0,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
performance_count: 0,
|
||||
avg_response_ms: 0,
|
||||
};
|
||||
|
||||
let totalRespMs = 0;
|
||||
let respCount = 0;
|
||||
let lineSeq = 0;
|
||||
|
||||
const processLine = (line, ch) => {
|
||||
|
||||
lineSeq++;
|
||||
const item = parseLogLine(line, ch, filterOpts, lineSeq);
|
||||
if (!item) return;
|
||||
|
||||
stats.total++;
|
||||
const lvl = (item.level || 'INFO').toUpperCase();
|
||||
if (lvl === 'ERROR' || lvl === 'CRITICAL') stats.errors++;
|
||||
if (lvl === 'WARNING') stats.warnings++;
|
||||
|
||||
const msg = item.message || '';
|
||||
if (/login_success|login ok|login berhasil/i.test(msg)) stats.login_success++;
|
||||
if (/login_failed|gagal login/i.test(msg)) stats.login_failed++;
|
||||
if (/session_expired|session habis/i.test(msg)) stats.session_expired++;
|
||||
|
||||
if ((item.channel || ch).toLowerCase() === 'performance') {
|
||||
stats.performance_count++;
|
||||
if (item.context?.total_duration_ms) {
|
||||
totalRespMs += Number(item.context.total_duration_ms);
|
||||
respCount++;
|
||||
}
|
||||
}
|
||||
|
||||
logs.push(item);
|
||||
};
|
||||
|
||||
const filterSource = filterOpts.source || 'auto';
|
||||
|
||||
// 1. Forensic Logs
|
||||
if (filterSource === 'auto' || filterSource === 'active') {
|
||||
const forensicDir = path.join(logsRootDir, 'forensic', year, month);
|
||||
if (fs.existsSync(forensicDir)) {
|
||||
const files = fs.readdirSync(forensicDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.log') && file.includes(filterDate)) {
|
||||
const chName = file.split('_')[0] || 'forensic';
|
||||
const content = fs.readFileSync(path.join(forensicDir, file), 'utf-8');
|
||||
content.split(/\r?\n/).forEach((l) => processLine(l, chName));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Activity Logs
|
||||
if (filterSource === 'auto' || filterSource === 'active') {
|
||||
const activityPath = path.join(logsRootDir, 'activity', year, month, `${dmY}.log`);
|
||||
if (fs.existsSync(activityPath)) {
|
||||
const content = fs.readFileSync(activityPath, 'utf-8');
|
||||
content.split(/\r?\n/).forEach((l) => processLine(l, 'activity'));
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Access Logs
|
||||
if (filterSource === 'auto' || filterSource === 'active') {
|
||||
const accessDir = path.join(logsRootDir, 'access', year, month, day);
|
||||
if (fs.existsSync(accessDir)) {
|
||||
const files = fs.readdirSync(accessDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.log')) {
|
||||
const content = fs.readFileSync(path.join(accessDir, file), 'utf-8');
|
||||
content.split(/\r?\n/).forEach((l) => processLine(l, 'access'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Retention Log
|
||||
if (filterSource === 'auto' || filterSource === 'active') {
|
||||
const retentionPath = path.join(logsRootDir, 'retention.log');
|
||||
if (fs.existsSync(retentionPath)) {
|
||||
const content = fs.readFileSync(retentionPath, 'utf-8');
|
||||
content.split(/\r?\n/).forEach((l) => {
|
||||
if (!filterDate || l.includes(filterDate)) {
|
||||
processLine(l, 'system');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. ZIP Archives (Only if explicitly selected or auto returns empty)
|
||||
const backupDir = path.join(logsRootDir, 'backup');
|
||||
let zipFilesToProcess = [];
|
||||
if (fs.existsSync(backupDir)) {
|
||||
if (filterSource.endsWith('.zip') && fs.existsSync(path.join(backupDir, filterSource))) {
|
||||
zipFilesToProcess.push(path.join(backupDir, filterSource));
|
||||
} else if (filterSource === 'all_zip' || (filterSource === 'auto' && logs.length === 0)) {
|
||||
const files = fs.readdirSync(backupDir);
|
||||
const ymMonth = `${year}-${month}`;
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.zip')) {
|
||||
if (filterSource === 'auto' && filterDate) {
|
||||
if (file.includes(ymMonth)) {
|
||||
zipFilesToProcess.push(path.join(backupDir, file));
|
||||
}
|
||||
} else {
|
||||
zipFilesToProcess.push(path.join(backupDir, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const zipPath of zipFilesToProcess) {
|
||||
try {
|
||||
const zip = new AdmZip(zipPath);
|
||||
const entries = zip.getEntries();
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory) continue;
|
||||
const entryName = entry.entryName;
|
||||
if (filterSource === 'auto' && filterDate) {
|
||||
if (!entryName.includes(filterDate) && !entryName.includes(dmY) && !entryName.includes(`/${day}/`)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let ch = 'archive';
|
||||
if (entryName.includes('session')) ch = 'session';
|
||||
else if (entryName.includes('auth')) ch = 'auth';
|
||||
else if (entryName.includes('error')) ch = 'error';
|
||||
else if (entryName.includes('performance')) ch = 'performance';
|
||||
else if (entryName.includes('security')) ch = 'security';
|
||||
else if (entryName.includes('access')) ch = 'access';
|
||||
else if (entryName.includes('activity')) ch = 'activity';
|
||||
|
||||
const content = entry.getData().toString('utf-8');
|
||||
content.split(/\r?\n/).forEach((l) => processLine(l, ch));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error reading zip file ${zipPath}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
if (respCount > 0) {
|
||||
stats.avg_response_ms = Number((totalRespMs / respCount).toFixed(2));
|
||||
}
|
||||
|
||||
// Fast direct string timestamp comparison (100x faster than localeCompare/Date)
|
||||
logs.sort((a, b) => {
|
||||
const tsA = a.timestamp || '';
|
||||
const tsB = b.timestamp || '';
|
||||
if (tsA !== tsB) return tsB > tsA ? 1 : -1;
|
||||
return (b._seq || 0) - (a._seq || 0);
|
||||
});
|
||||
|
||||
// Cap browser payload but keep full stats
|
||||
const trimmed = logs.length > MAX_BROWSER_ITEMS ? logs.slice(0, MAX_BROWSER_ITEMS) : logs;
|
||||
stats.total_scanned = logs.length;
|
||||
|
||||
return { logs: trimmed, stats };
|
||||
}
|
||||
|
||||
export function getBruteForceLocks(logsRootDir) {
|
||||
const bfDir = path.join(logsRootDir, 'forensic', 'cache');
|
||||
const lockedIPs = [];
|
||||
if (fs.existsSync(bfDir)) {
|
||||
const files = fs.readdirSync(bfDir);
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('bf_') && file.endsWith('.json')) {
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(bfDir, file), 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
if (data && data.lockout_until && data.lockout_until > now) {
|
||||
lockedIPs.push(data);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore invalid json
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return lockedIPs;
|
||||
}
|
||||
|
||||
export function unblockIp(logsRootDir, targetIp) {
|
||||
const bfDir = path.join(logsRootDir, 'forensic', 'cache');
|
||||
if (fs.existsSync(bfDir)) {
|
||||
const files = fs.readdirSync(bfDir);
|
||||
for (const file of files) {
|
||||
if (file.startsWith('bf_') && file.endsWith('.json')) {
|
||||
try {
|
||||
const filePath = path.join(bfDir, file);
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
if (data && (data.ip === targetIp || file.includes(targetIp))) {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getBackupArchives(logsRootDir) {
|
||||
const backupDir = path.join(logsRootDir, 'backup');
|
||||
const backups = [];
|
||||
if (fs.existsSync(backupDir)) {
|
||||
const files = fs.readdirSync(backupDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.zip')) {
|
||||
const filePath = path.join(backupDir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
backups.push({
|
||||
filename: file,
|
||||
size_bytes: stat.size,
|
||||
size_formatted: (stat.size / (1024 * 1024)).toFixed(2) + ' MB',
|
||||
modified_time: new Date(stat.mtime).toISOString().replace('T', ' ').substring(0, 19),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
backups.sort((a, b) => b.filename.localeCompare(a.filename));
|
||||
return backups;
|
||||
}
|
||||
Reference in New Issue
Block a user