Files
security-alert-center/frontend/src/api.ts
T
PTah 80320ae698 fix: harden SAC security per audit (0.4.15)
Close audit findings: anti-spoof client IP for login rate limit, JWT/CORS
enforce on startup, SSH host-key verification and sudo via stdin, optional
WinRM HTTPS, SSE httpOnly cookie auth, ingest body limit, ILIKE escaping,
and HMAC API key hashing with legacy SHA-256 fallback.
2026-07-07 19:32:12 +10:00

798 lines
22 KiB
TypeScript

const TOKEN_KEY = "sac_token";
const ROLE_KEY = "sac_role";
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getRole(): string | null {
return localStorage.getItem(ROLE_KEY);
}
export function isAdmin(): boolean {
return getRole() === "admin";
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function setRole(role: string): void {
localStorage.setItem(ROLE_KEY, role);
}
export function setAuth(token: string, role: string): void {
setToken(token);
setRole(role);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ROLE_KEY);
}
export async function logoutApi(): Promise<void> {
try {
await fetch("/api/v1/auth/logout", { method: "POST", credentials: "same-origin" });
} catch {
/* ignore network errors on logout */
}
}
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function redirectToErrorPage(status: 401 | 403 | 429): void {
window.location.href = `/${status}`;
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
if (!headers.has("Content-Type") && init.body) {
headers.set("Content-Type", "application/json");
}
const token = getToken();
const hadToken = !!token;
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
const res = await fetch(path, { ...init, headers, credentials: "same-origin" });
const text = await res.text();
if (res.status === 401) {
if (hadToken) {
clearToken();
redirectToErrorPage(401);
}
throw new ApiError(parseApiError(text, 401) || "Unauthorized", 401);
}
if (res.status === 403) {
if (hadToken) {
redirectToErrorPage(403);
}
throw new ApiError(parseApiError(text, 403) || "Forbidden", 403);
}
if (res.status === 429) {
redirectToErrorPage(429);
throw new ApiError(parseApiError(text, 429) || "Too many requests", 429);
}
if (!res.ok) {
throw new ApiError(parseApiError(text, res.status) || res.statusText, res.status);
}
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
export interface TokenResponse {
access_token: string;
token_type: string;
username: string;
role: string;
}
export interface MeResponse {
username: string;
role: string;
}
export function fetchMe(): Promise<MeResponse> {
return apiFetch<MeResponse>("/api/v1/auth/me");
}
export function parseApiError(text: string, status?: number): string {
if (status === 502 || status === 503 || status === 504) {
if (/<title>\s*502 Bad Gateway/i.test(text) || /502 Bad Gateway/i.test(text)) {
return "Сервер SAC временно недоступен (перезапуск или обновление). Подождите 5–10 секунд и обновите страницу.";
}
if (/<title>\s*503 Service Unavailable/i.test(text) || /503 Service Unavailable/i.test(text)) {
return "SAC временно перегружен. Повторите запрос через несколько секунд.";
}
if (/<title>\s*504 Gateway Time-out/i.test(text) || /504 Gateway Time-out/i.test(text)) {
return "Превышено время ожидания ответа SAC. Долгая операция могла ещё выполняться на сервере.";
}
return "Временная ошибка шлюза SAC. Обновите страницу.";
}
if (!text) return "";
if (text.trimStart().startsWith("<")) {
return status ? `Ошибка HTTP ${status}` : "Ошибка сервера";
}
try {
const data = JSON.parse(text) as { detail?: string | Array<{ msg?: string }> };
if (typeof data.detail === "string") return data.detail;
if (Array.isArray(data.detail)) {
return data.detail.map((item) => item.msg ?? String(item)).join("; ");
}
} catch {
/* plain text */
}
return text;
}
export interface UserSummary {
id: number;
username: string;
role: string;
is_active: boolean;
created_at: string;
}
export interface UserListResponse {
items: UserSummary[];
}
export interface UpdateUserPayload {
username?: string;
role?: string;
password?: string;
is_active?: boolean;
}
export function fetchUsers(): Promise<UserListResponse> {
return apiFetch<UserListResponse>("/api/v1/users");
}
export function createUser(payload: {
username: string;
password: string;
role: string;
}): Promise<UserSummary> {
return apiFetch<UserSummary>("/api/v1/users", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function updateUser(userId: number, payload: UpdateUserPayload): Promise<UserSummary> {
return apiFetch<UserSummary>(`/api/v1/users/${userId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export interface EventSummary {
id: number;
event_id: string;
host_id: number;
hostname: string;
display_name?: string | null;
product_version?: string | null;
occurred_at: string;
received_at: string;
category: string;
type: string;
severity: string;
title: string;
summary: string;
actor_user?: string | null;
rdg_flap?: boolean;
rdg_flap_pair_event_id?: number | null;
rdg_flap_qwinsta_event_id?: number | null;
rdg_access_path?: string | null;
rdg_qwinsta_enabled?: boolean;
session_terminated?: boolean;
}
export interface AgentCommandResponse {
command_uuid: string;
command_type: string;
status: string;
result_stdout?: string | null;
result_stderr?: string | null;
created_at?: string | null;
completed_at?: string | null;
target?: string | null;
client_hostname?: string | null;
internal_ip?: string | null;
}
export function postEventQwinsta(eventId: number): Promise<AgentCommandResponse> {
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/qwinsta`, {
method: "POST",
});
}
export function postEventLogoff(eventId: number, sessionId: number): Promise<AgentCommandResponse> {
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/logoff`, {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
});
}
export function fetchEventAction(eventId: number, commandUuid: string): Promise<AgentCommandResponse> {
return apiFetch<AgentCommandResponse>(`/api/v1/events/${eventId}/actions/${commandUuid}`);
}
export interface EventListResponse {
items: EventSummary[];
total: number;
page: number;
page_size: number;
}
export interface EventDetail extends EventSummary {
details: Record<string, unknown> | null;
raw: Record<string, unknown> | null;
payload: Record<string, unknown>;
}
export interface HostSummary {
id: number;
hostname: string;
display_name: string | null;
os_family: string;
os_version?: string | null;
product: string;
product_version: string | null;
ipv4: string | null;
last_seen_at: string;
event_count: number | null;
last_heartbeat_at: string | null;
last_daily_report_at: string | null;
last_inventory_at?: string | null;
agent_status: "online" | "stale" | "unknown";
version_status?: "ok" | "outdated" | "unknown";
pending_agent_update?: boolean;
ssh_admin_ok?: boolean | null;
ssh_admin_checked_at?: string | null;
}
export interface HostDetail extends HostSummary {
agent_instance_id: string | null;
ipv6: string | null;
tags: string[] | null;
use_sac_mode: string | null;
inventory: Record<string, unknown> | null;
inventory_updated_at: string | null;
created_at: string | null;
pending_update_requested_at?: string | null;
pending_update_target_version?: string | null;
agent_config_revision?: number;
agent_config?: Record<string, unknown> | null;
agent_update_state?: string | null;
agent_update_last_at?: string | null;
agent_update_last_error?: string | null;
}
export function fetchHost(id: number): Promise<HostDetail> {
return apiFetch<HostDetail>(`/api/v1/hosts/${id}`);
}
export function fetchRecentEvents(limit = 1): Promise<EventSummary[]> {
return apiFetch<EventSummary[]>(`/api/v1/dashboards/recent-events?limit=${limit}`);
}
export interface HostListResponse {
items: HostSummary[];
total: number;
page: number;
page_size: number;
latest_agent_versions?: Record<string, string>;
/** Эталон для «устарела»: max(git latest, min, max в fleet) */
reference_agent_versions?: Record<string, string>;
/** Последние версии из git (version.txt на main) */
git_latest_rdp_version?: string | null;
git_latest_ssh_version?: string | null;
}
export interface ProblemSummary {
id: number;
host_id: number | null;
hostname: string | null;
display_name?: string | null;
title: string;
summary: string;
severity: string;
status: string;
rule_id: string | null;
fingerprint?: string;
event_count?: number;
last_seen_at?: string;
created_at: string;
updated_at: string;
}
export interface ProblemListResponse {
items: ProblemSummary[];
total: number;
page: number;
page_size: number;
}
export interface ProblemEventItem {
id: number;
event_id: string;
occurred_at: string;
type: string;
severity: string;
title: string;
summary: string;
}
export interface ProblemDetail extends ProblemSummary {
events: ProblemEventItem[];
}
export function fetchProblem(id: number): Promise<ProblemDetail> {
return apiFetch<ProblemDetail>(`/api/v1/problems/${id}`);
}
export async function ackProblem(problemId: number): Promise<{ id: number; status: string }> {
return apiFetch(`/api/v1/problems/${problemId}/ack`, { method: "POST" });
}
export async function resolveProblem(problemId: number): Promise<{ id: number; status: string }> {
return apiFetch(`/api/v1/problems/${problemId}/resolve`, { method: "POST" });
}
export interface TopHostItem {
host_id: number;
hostname: string;
display_name?: string | null;
count: number;
}
export interface TopTypeItem {
type: string;
count: number;
}
export interface MobileSettings {
devices_allowed: boolean;
max_devices_per_user: number;
min_app_version: string | null;
fcm_enabled: boolean;
fcm_configured: boolean;
source: string;
}
export interface MobileEnrollmentCode {
id: number;
label: string;
code_prefix: string;
target_user_id: number | null;
target_username: string | null;
login_mode: string;
max_uses: number;
use_count: number;
expires_at: string;
revoked_at: string | null;
created_at: string;
is_active: boolean;
}
export interface MobileEnrollmentCodeCreated extends MobileEnrollmentCode {
enrollment_code: string;
}
export interface MobileDevice {
id: number;
user_id: number;
username: string;
device_uuid: string;
display_name: string;
platform: string;
app_version: string | null;
has_fcm_token: boolean;
enrolled_at: string;
last_seen_at: string | null;
revoked_at: string | null;
is_active: boolean;
}
export function fetchMobileSettings(): Promise<MobileSettings> {
return apiFetch<MobileSettings>("/api/v1/mobile/settings");
}
export function updateMobileSettings(payload: {
devices_allowed: boolean;
max_devices_per_user: number;
min_app_version?: string | null;
}): Promise<MobileSettings> {
return apiFetch<MobileSettings>("/api/v1/mobile/settings", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function fetchMobileEnrollmentCodes(): Promise<MobileEnrollmentCode[]> {
return apiFetch<MobileEnrollmentCode[]>("/api/v1/mobile/enrollment-codes");
}
export function createMobileEnrollmentCode(payload: {
label?: string;
target_user_id?: number | null;
login_mode?: string;
max_uses?: number;
expires_in_hours?: number;
}): Promise<MobileEnrollmentCodeCreated> {
return apiFetch<MobileEnrollmentCodeCreated>("/api/v1/mobile/enrollment-codes", {
method: "POST",
body: JSON.stringify(payload),
});
}
export function revokeMobileEnrollmentCode(codeId: number): Promise<void> {
return apiFetch<void>(`/api/v1/mobile/enrollment-codes/${codeId}`, { method: "DELETE" });
}
export function fetchMobileDevices(): Promise<MobileDevice[]> {
return apiFetch<MobileDevice[]>("/api/v1/mobile/devices");
}
export function revokeMobileDevice(deviceId: number): Promise<MobileDevice> {
return apiFetch<MobileDevice>(`/api/v1/mobile/devices/${deviceId}/revoke`, { method: "POST" });
}
export function deleteMobileDevice(deviceId: number): Promise<void> {
return apiFetch<void>(`/api/v1/mobile/devices/${deviceId}`, { method: "DELETE" });
}
export function testMobileDevicePush(deviceId: number): Promise<{ message: string }> {
return apiFetch(`/api/v1/mobile/devices/${deviceId}/test-push`, { method: "POST" });
}
export interface WinAdminSettings {
configured: boolean;
user: string | null;
password_hint: string | null;
source: string;
}
export function fetchWinAdminSettings(): Promise<WinAdminSettings> {
return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin");
}
export function updateWinAdminSettings(payload: {
user?: string | null;
password?: string | null;
}): Promise<WinAdminSettings> {
return apiFetch<WinAdminSettings>("/api/v1/settings/win-admin", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface RdpFlapSettings {
auto_disconnect: boolean;
win_admin_configured: boolean;
source: string;
}
export function fetchRdpFlapSettings(): Promise<RdpFlapSettings> {
return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap");
}
export function updateRdpFlapSettings(payload: { auto_disconnect: boolean }): Promise<RdpFlapSettings> {
return apiFetch<RdpFlapSettings>("/api/v1/settings/rdp-flap", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface HostWinRmTestResult {
ok: boolean;
message: string;
target: string;
hostname: string | null;
}
export function testHostWinRm(hostId: number): Promise<HostWinRmTestResult> {
return apiFetch<HostWinRmTestResult>(`/api/v1/hosts/${hostId}/actions/winrm-test`, {
method: "POST",
});
}
export interface LinuxAdminSettings {
configured: boolean;
user: string | null;
password_hint: string | null;
source: string;
}
export function fetchLinuxAdminSettings(): Promise<LinuxAdminSettings> {
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin");
}
export function updateLinuxAdminSettings(payload: {
user?: string | null;
password?: string | null;
}): Promise<LinuxAdminSettings> {
return apiFetch<LinuxAdminSettings>("/api/v1/settings/linux-admin", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface HostSshActionResult {
ok: boolean;
message: string;
target: string;
stdout: string | null;
stderr: string | null;
exit_code: number | null;
product_version?: string | null;
ssh_admin_ok?: boolean | null;
}
export function testHostSsh(hostId: number): Promise<HostSshActionResult> {
return apiFetch<HostSshActionResult>(`/api/v1/hosts/${hostId}/actions/ssh-test`, {
method: "POST",
});
}
export interface HostRemoteActionStartResult {
status: string;
host_id: number;
title: string;
message: string;
}
export interface HostRemoteActionJobStatus {
active: boolean;
host_id: number;
hostname?: string | null;
status?: string | null;
title?: string;
message?: string;
stdout?: string | null;
stderr?: string | null;
output?: string | null;
ok?: boolean | null;
exit_code?: number | null;
product_version?: string | null;
agent_update_state?: string | null;
started_at?: string | null;
finished_at?: string | null;
}
export function updateHostAgentViaSsh(hostId: number): Promise<HostRemoteActionStartResult> {
return apiFetch<HostRemoteActionStartResult>(`/api/v1/hosts/${hostId}/actions/agent-update`, {
method: "POST",
});
}
export interface HostAgentUpdateRequestResult {
status: string;
pending_agent_update: boolean;
target_version: string | null;
agent_update_state: string | null;
}
export function requestHostAgentUpdate(hostId: number): Promise<HostAgentUpdateRequestResult> {
return apiFetch<HostAgentUpdateRequestResult>(
`/api/v1/hosts/${hostId}/actions/request-agent-update`,
{ method: "POST" },
);
}
export function runHostAgentUpdateFallback(hostId: number): Promise<HostRemoteActionStartResult> {
return apiFetch<HostRemoteActionStartResult>(
`/api/v1/hosts/${hostId}/actions/agent-update-fallback`,
{ method: "POST" },
);
}
export interface HostManualAddRequest {
platform: "windows" | "linux";
target: string;
}
export function addHostManually(body: HostManualAddRequest): Promise<HostRemoteActionStartResult> {
return apiFetch<HostRemoteActionStartResult>("/api/v1/hosts/manual-add", {
method: "POST",
body: JSON.stringify(body),
});
}
export function fetchHostRemoteJob(hostId: number): Promise<HostRemoteActionJobStatus> {
return apiFetch<HostRemoteActionJobStatus>(`/api/v1/hosts/${hostId}/actions/remote-job`);
}
export interface HostSessionItem {
session_id: string;
user: string;
tty?: string | null;
state?: string | null;
source_ip?: string | null;
session_name?: string | null;
}
export interface HostSessionsResponse {
ok: boolean;
message: string;
target?: string | null;
sessions: HostSessionItem[];
}
export interface HostSessionTerminateResponse {
ok: boolean;
message: string;
target?: string | null;
stdout?: string | null;
stderr?: string | null;
}
export function fetchHostSessions(hostId: number): Promise<HostSessionsResponse> {
return apiFetch<HostSessionsResponse>(`/api/v1/hosts/${hostId}/actions/sessions/list`, {
method: "POST",
});
}
export function terminateHostSession(
hostId: number,
sessionId: string,
): Promise<HostSessionTerminateResponse> {
return apiFetch<HostSessionTerminateResponse>(`/api/v1/hosts/${hostId}/actions/sessions/terminate`, {
method: "POST",
body: JSON.stringify({ session_id: sessionId }),
});
}
export function postEventTerminateSession(
eventId: number,
sessionId?: string,
): Promise<HostSessionTerminateResponse> {
return apiFetch<HostSessionTerminateResponse>(`/api/v1/events/${eventId}/actions/terminate-session`, {
method: "POST",
body: JSON.stringify(sessionId ? { session_id: sessionId } : {}),
});
}
export interface HostAgentConfigResponse {
config_revision: number;
settings: Record<string, unknown>;
}
export function patchHostAgentConfig(
hostId: number,
settings: Record<string, unknown>,
): Promise<HostAgentConfigResponse> {
return apiFetch<HostAgentConfigResponse>(`/api/v1/hosts/${hostId}/agent-config`, {
method: "PATCH",
body: JSON.stringify({ settings }),
});
}
export interface AgentUpdateSettings {
mode: "gpo" | "sac";
fallback_enabled: boolean;
fallback_after_minutes: number;
recommended_rdp_version: string | null;
recommended_ssh_version: string | null;
min_rdp_version: string | null;
min_ssh_version: string | null;
win_agent_update_script: string | null;
rdp_git_repo_url: string | null;
ssh_git_repo_url: string | null;
git_branch: string;
git_latest_rdp_version: string | null;
git_latest_ssh_version: string | null;
git_fetched_at: string | null;
git_errors: Record<string, string>;
source: string;
}
export interface AgentGitTestResult {
git_latest_rdp_version: string | null;
git_latest_ssh_version: string | null;
git_fetched_at: string | null;
git_errors: Record<string, string>;
from_cache: boolean;
}
export function fetchAgentUpdateSettings(): Promise<AgentUpdateSettings> {
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates");
}
export function saveAgentUpdateSettings(
payload: Partial<AgentUpdateSettings>,
): Promise<AgentUpdateSettings> {
return apiFetch<AgentUpdateSettings>("/api/v1/settings/agent-updates", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export interface AgentGitTestPayload {
rdp_git_repo_url?: string | null;
ssh_git_repo_url?: string | null;
git_branch?: string | null;
}
export function testAgentGitRelease(payload?: AgentGitTestPayload): Promise<AgentGitTestResult> {
return apiFetch<AgentGitTestResult>("/api/v1/settings/agent-updates/test-git", {
method: "POST",
body: JSON.stringify(payload ?? {}),
});
}
export interface LoginSecuritySettings {
ip_whitelist: string[];
ip_whitelist_text: string;
sync_fail2ban: boolean;
max_failures: number;
window_minutes: number;
fail2ban_available: boolean;
source: string;
}
export interface LoginBlockItem {
ip_address: string;
scope: string;
failure_count: number | null;
usernames: string[];
blocked_until: string | null;
reason: string;
}
export function fetchLoginSecuritySettings(): Promise<LoginSecuritySettings> {
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security");
}
export function saveLoginSecuritySettings(payload: {
ip_whitelist_text: string;
sync_fail2ban: boolean;
}): Promise<LoginSecuritySettings> {
return apiFetch<LoginSecuritySettings>("/api/v1/settings/login-security", {
method: "PUT",
body: JSON.stringify(payload),
});
}
export function fetchLoginSecurityBlocks(): Promise<LoginBlockItem[]> {
return apiFetch<LoginBlockItem[]>("/api/v1/settings/login-security/blocks");
}
export function unblockLoginSecurityIp(payload: {
ip_address: string;
scope: "web" | "ssh" | "both";
}): Promise<{ messages: string[] }> {
return apiFetch<{ messages: string[] }>("/api/v1/settings/login-security/unblock", {
method: "POST",
body: JSON.stringify(payload),
});
}
export interface DashboardSummary {
events_last_24h: number;
hosts_total: number;
hosts_stale: number;
heartbeats_24h: number;
daily_reports_24h: number;
problems_open: number;
problems_opened_24h: number;
problems_resolved_24h: number;
severity_24h: Record<string, number>;
top_hosts: TopHostItem[];
top_event_types: TopTypeItem[];
recent_events: EventSummary[];
}