From 4f9a241ceab1efe4abf74b1762ab00abe6ae54bb Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:02:59 +1000 Subject: [PATCH] feat: RDG flap qwinsta/logoff via SAC API (0.5.8) Mobile triggers POST events/.../actions/qwinsta and logoff; SAC runs WinRM on the client PC. List and event detail show RDG flap marker. --- README.md | 85 +++--------- app/build.gradle.kts | 4 +- .../java/ru/kalinamall/seaca/data/SacApi.kt | 9 ++ .../ru/kalinamall/seaca/data/SacModels.kt | 24 ++++ .../ru/kalinamall/seaca/data/SacRepository.kt | 2 +- .../seaca/ui/screens/DetailScreens.kt | 127 ++++++++++++++++++ .../seaca/ui/screens/ListScreens.kt | 7 +- .../ru/kalinamall/seaca/ui/util/RdgQwinsta.kt | 71 ++++++++++ docs/ROADMAP.md | 2 +- docs/operator-guide.md | 51 +++---- 10 files changed, 284 insertions(+), 98 deletions(-) create mode 100644 app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt diff --git a/README.md b/README.md index 7831b48..a809bfe 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,43 @@ # Seaca -**Seaca** (Security Event Alert Center App) — нативный мобильный клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center) для Android. +Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center). -Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»). +**Версия:** `0.5.8` (versionCode 22) -## Связанные репозитории +## Возможности -| Репозиторий | Роль | -|-------------|------| -| [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | -| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Агент Linux | -| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Агент Windows | +- Обзор, события, проблемы, хосты, отчёты (API веб-SAC) +- Push (FCM), режимы звука, выбор ringtone +- Ack / Resolve проблем (monitor+) +- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК +- Биометрия при запуске и возврате в приложение +- Формат даты, экран «Моё устройство» -## Стек +## Нет в приложении (только веб-SAC) -| Слой | Технология | -|------|------------| -| UI | Kotlin, Jetpack Compose, Material 3 | -| Сеть | Retrofit / OkHttp, Kotlin Serialization | -| Хранение | DataStore + Android Keystore (токены, URL сервера) | -| Push | **Firebase Cloud Messaging (FCM)** | -| Минимум SDK | Android 8.0 (API 26) | +Пользователи, каналы оповещений, WinRM domain admin, коды регистрации. -## Что есть в приложении +## Привязка -- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями) -- Pull-to-refresh на обзоре и в списках -- Фильтры событий и проблем (severity, status) -- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль) -- Ack / Resolve для роли `operator` и выше -- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено -- Выбор звука уведомления: системный или свой (ringtone picker) -- Биометрическая блокировка при **запуске** и при возврате в приложение -- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход -- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC +1. Админ: мобильные устройства + код `sacmob_…` +2. URL API SAC + код + логин/пароль в Seaca +3. FCM-токен регистрируется автоматически -## Чего нет в приложении (только веб) - -- Управление пользователями SAC -- Настройки каналов (Telegram, SMTP, webhook), severity, policy -- Выдача и отзыв кодов регистрации, список устройств, способ входа на устройство — всё это в **Настройки → Мобильные устройства** на сервере - -## Привязка телефона к серверу - -1. Администратор в веб-SAC включает «Разрешать подключение мобильных устройств». -2. Создаёт **код регистрации** для пользователя (срок действия, одноразовый или с лимитом использований). -3. Оператор в Seaca: URL API SAC (выдаёт администратор) + код + логин/пароль (если код не привязан к предварительной сессии). -4. Приложение регистрирует FCM-токен; устройство появляется в списке на сервере. -5. Администратор может отключить устройство, сменить разрешённый способ входа, переименовать. - -Подробный план — [docs/ROADMAP.md](docs/ROADMAP.md). - -## Статус - -**Версия:** `0.5.7` (Android: Kotlin, Compose; enroll, UI, ack/resolve, FCM, биометрия, звук push, форматы даты) - -## Документация - -| Документ | Назначение | -|----------|------------| -| [docs/operator-guide.md](docs/operator-guide.md) | Инструкция для оператора | -| [docs/ROADMAP.md](docs/ROADMAP.md) | План разработки | -| [SAC: seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) | Админ: коды, устройства, сессия | -| [SAC: seaca-fcm.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-fcm.md) | FCM на сервере | +[docs/operator-guide.md](docs/operator-guide.md) · SAC [docs/seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) ## Сборка -Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`). +JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM. ```bash -# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM ./gradlew assembleDebug -# Windows: -gradlew.bat assembleDebug ``` APK: `app/build/outputs/apk/debug/app-debug.apk` -Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает). +## Репозитории -## Лицензия - -Как у остальных репозиториев семейства SAC (уточняется владельцем проекта). +| Репозиторий | Роль | +|-------------|------| +| security-alert-center | Сервер API | +| ssh-monitor / RDP-login-monitor | Агенты | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b9121c..5060684 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "ru.kalinamall.seaca" minSdk = 26 targetSdk = 35 - versionCode = 21 - versionName = "0.5.7" + versionCode = 22 + versionName = "0.5.8" } buildTypes { diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt index 871f9d7..3b43364 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacApi.kt @@ -36,6 +36,15 @@ interface SacApi { @GET("api/v1/events/{id}") suspend fun getEvent(@Path("id") id: Long): EventDetail + @POST("api/v1/events/{id}/actions/qwinsta") + suspend fun postEventQwinsta(@Path("id") id: Long): AgentCommandResponse + + @POST("api/v1/events/{id}/actions/logoff") + suspend fun postEventLogoff( + @Path("id") id: Long, + @Body body: LogoffActionRequest, + ): AgentCommandResponse + @GET("api/v1/problems") suspend fun listProblems( @Query("page") page: Int = 1, diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt index 793a3f0..4d8b68c 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacModels.kt @@ -58,6 +58,9 @@ data class EventSummary( val title: String, val summary: String, @SerialName("actor_user") val actorUser: String? = null, + @SerialName("rdg_flap") val rdgFlap: Boolean = false, + @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null, + @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null, ) @Serializable @@ -80,6 +83,10 @@ data class EventDetail( val severity: String, val title: String, val summary: String, + @SerialName("actor_user") val actorUser: String? = null, + @SerialName("rdg_flap") val rdgFlap: Boolean = false, + @SerialName("rdg_flap_pair_event_id") val rdgFlapPairEventId: Long? = null, + @SerialName("rdg_flap_qwinsta_event_id") val rdgFlapQwinstaEventId: Long? = null, val details: JsonElement? = null, val raw: JsonElement? = null, val payload: JsonElement? = null, @@ -192,6 +199,23 @@ data class ProblemActionResponse( val status: String, ) +@Serializable +data class LogoffActionRequest( + @SerialName("session_id") val sessionId: Int, +) + +@Serializable +data class AgentCommandResponse( + @SerialName("command_uuid") val commandUuid: String, + @SerialName("command_type") val commandType: String, + val status: String, + @SerialName("result_stdout") val resultStdout: String? = null, + @SerialName("result_stderr") val resultStderr: String? = null, + val target: String? = null, + @SerialName("client_hostname") val clientHostname: String? = null, + @SerialName("internal_ip") val internalIp: String? = null, +) + @Serializable data class ApiErrorBody( val detail: String? = null, diff --git a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt index 2a4347a..a36244b 100644 --- a/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt +++ b/app/src/main/java/ru/kalinamall/seaca/data/SacRepository.kt @@ -117,7 +117,7 @@ class SacRepository(context: Context) { } val okhttp = OkHttpClient.Builder() .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) .sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll)) .hostnameVerifier(TlsTrust.hostnameVerifier(trustAll)) .addInterceptor(authInterceptor) diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt index 358f089..b92d161 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/DetailScreens.kt @@ -17,7 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.TextButton import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator @@ -55,7 +58,13 @@ import ru.kalinamall.seaca.data.NotificationMode import ru.kalinamall.seaca.data.NotificationSoundSettings import ru.kalinamall.seaca.data.NotificationSoundSource import ru.kalinamall.seaca.data.ProblemDetail +import ru.kalinamall.seaca.data.LogoffActionRequest import ru.kalinamall.seaca.data.SacRepository +import ru.kalinamall.seaca.ui.util.QwinstaSessionRow +import ru.kalinamall.seaca.ui.util.agentCommandError +import ru.kalinamall.seaca.ui.util.hasRdgFlapUi +import ru.kalinamall.seaca.ui.util.parseQwinstaSessions +import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId import ru.kalinamall.seaca.push.NotificationHelper import ru.kalinamall.seaca.push.NotificationSoundResolver import ru.kalinamall.seaca.ui.RingtonePicker @@ -78,6 +87,14 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { var data by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var loading by remember { mutableStateOf(true) } + var qwinstaOpen by remember { mutableStateOf(false) } + var qwinstaLoading by remember { mutableStateOf(false) } + var qwinstaError by remember { mutableStateOf(null) } + var qwinstaStdout by remember { mutableStateOf(null) } + var qwinstaSessions by remember { mutableStateOf>(emptyList()) } + var qwinstaMeta by remember { mutableStateOf(null) } + var logoffSessionId by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( initialValue = DateTimeDisplayMode.SLASH, ) @@ -93,8 +110,49 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { } } + fun runQwinsta(targetEventId: Long, actorUser: String?) { + scope.launch { + qwinstaLoading = true + qwinstaError = null + qwinstaStdout = null + qwinstaSessions = emptyList() + try { + val cmd = repo.withAuth { it.postEventQwinsta(targetEventId) } + qwinstaMeta = listOfNotNull(cmd.clientHostname, cmd.internalIp, cmd.target) + .joinToString(" · ") + .ifBlank { null } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + qwinstaError = cmdError + } else { + val out = cmd.resultStdout.orEmpty() + qwinstaStdout = out.ifBlank { null } + qwinstaSessions = parseQwinstaSessions(out, actorUser) + } + } catch (e: Exception) { + qwinstaError = if (e is retrofit2.HttpException) SacRepository.parseError(e) else e.message + } finally { + qwinstaLoading = false + } + } + } + DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) { val d = data ?: return@DetailFrame + if (hasRdgFlapUi(d)) { + Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя") + d.rdgFlapPairEventId?.let { Field("Пара", "#$it") } + rdgFlapQwinstaEventId(d)?.let { targetId -> + Button( + enabled = !qwinstaLoading, + onClick = { + qwinstaOpen = true + runQwinsta(targetId, d.actorUser) + }, + modifier = Modifier.padding(vertical = 4.dp), + ) { Text(if (qwinstaLoading) "qwinsta…" else "qwinsta / logoff") } + } + } Field("Severity", d.severity) Field( "Тип", @@ -110,6 +168,75 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) { EventDetailsSection(d.type, d.details, d.payload) } } + + if (qwinstaOpen) { + AlertDialog( + onDismissRequest = { if (!qwinstaLoading && logoffSessionId == null) qwinstaOpen = false }, + title = { Text("qwinsta") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + qwinstaMeta?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + when { + qwinstaLoading -> CircularProgressIndicator() + qwinstaError != null -> Text(qwinstaError!!, color = MaterialTheme.colorScheme.error) + qwinstaStdout != null -> Text( + qwinstaStdout!!, + style = MaterialTheme.typography.bodySmall, + ) + else -> Text("Нет вывода") + } + qwinstaSessions.forEach { s -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "${s.sessionName} ${s.userName} ${s.id} ${s.state}", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + OutlinedButton( + enabled = logoffSessionId == null && !qwinstaLoading, + onClick = { + val targetId = rdgFlapQwinstaEventId(data ?: return@OutlinedButton) ?: return@OutlinedButton + scope.launch { + logoffSessionId = s.id + qwinstaError = null + try { + val cmd = repo.withAuth { + it.postEventLogoff(targetId, LogoffActionRequest(s.id)) + } + val cmdError = agentCommandError(cmd) + if (cmdError != null) { + qwinstaError = cmdError + } else { + runQwinsta(targetId, data?.actorUser) + } + } catch (e: Exception) { + qwinstaError = if (e is retrofit2.HttpException) { + SacRepository.parseError(e) + } else { + e.message + } + } finally { + logoffSessionId = null + } + } + }, + ) { Text("logoff") } + } + } + } + }, + confirmButton = { + TextButton( + enabled = !qwinstaLoading && logoffSessionId == null, + onClick = { qwinstaOpen = false }, + ) { Text("Закрыть") } + }, + ) + } } @Composable diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt index 6e07855..869b92b 100644 --- a/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt +++ b/app/src/main/java/ru/kalinamall/seaca/ui/screens/ListScreens.kt @@ -47,7 +47,7 @@ import ru.kalinamall.seaca.data.ProblemSummary import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.ForwardArrowButton -import ru.kalinamall.seaca.ui.util.SacDateTimes +import ru.kalinamall.seaca.ui.util.hasRdgFlapUi private val REPORT_TYPE_OPTIONS = listOf( "report.daily.ssh" to "Отчёты ssh клиентов", @@ -189,7 +189,10 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) { res.items to res.total }, itemKey = EventSummary::id, - itemLabel = { "${it.severity} · ${it.title}" }, + itemLabel = { + val prefix = if (hasRdgFlapUi(it)) "RDG flap · " else "" + "$prefix${it.severity} · ${it.title}" + }, itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" }, onOpen = onOpen, severities = listOf(null, "info", "warning", "high", "critical"), diff --git a/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt new file mode 100644 index 0000000..854dcc2 --- /dev/null +++ b/app/src/main/java/ru/kalinamall/seaca/ui/util/RdgQwinsta.kt @@ -0,0 +1,71 @@ +package ru.kalinamall.seaca.ui.util + +import ru.kalinamall.seaca.data.EventDetail +import ru.kalinamall.seaca.data.EventSummary + +data class QwinstaSessionRow( + val sessionName: String, + val userName: String, + val id: Int, + val state: String, +) + +fun hasRdgFlapUi(event: EventSummary): Boolean = + event.rdgFlap || event.rdgFlapQwinstaEventId != null + +fun rdgFlapQwinstaEventId(event: EventSummary): Long? = + event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null + +fun hasRdgFlapUi(event: EventDetail): Boolean = + event.rdgFlap || event.rdgFlapQwinstaEventId != null + +fun rdgFlapQwinstaEventId(event: EventDetail): Long? = + event.rdgFlapQwinstaEventId ?: if (event.rdgFlap) event.id else null + +fun parseQwinstaSessions(stdout: String, actorUser: String?): List { + val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() } + val rows = mutableListOf() + for (line in lines) { + if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue + val parts = line.split(Regex("\\s+")) + if (parts.size < 4) continue + val id = parts[2].toIntOrNull() ?: continue + val userName = parts[1] + if (!actorUser.isNullOrBlank()) { + val norm: (String) -> String = { it.replace(Regex("^B26\\\\", RegexOption.IGNORE_CASE), "").lowercase() } + if (!norm(userName).contains(norm(actorUser))) continue + } + rows.add( + QwinstaSessionRow( + sessionName = parts[0], + userName = userName, + id = id, + state = parts.drop(3).joinToString(" "), + ), + ) + } + if (rows.isEmpty() && stdout.isNotBlank()) { + for (line in lines) { + if (line.startsWith("SESSION", ignoreCase = true) || line.startsWith("---")) continue + val parts = line.split(Regex("\\s+")) + if (parts.size < 4) continue + val id = parts[2].toIntOrNull() ?: continue + rows.add( + QwinstaSessionRow( + sessionName = parts[0], + userName = parts[1], + id = id, + state = parts.drop(3).joinToString(" "), + ), + ) + } + } + return rows +} + +fun agentCommandError(cmd: ru.kalinamall.seaca.data.AgentCommandResponse): String? { + if (cmd.status != "failed") return null + return cmd.resultStderr?.takeIf { it.isNotBlank() } + ?: cmd.resultStdout?.takeIf { it.isNotBlank() } + ?: "Команда завершилась с ошибкой" +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ed4a2be..2cc9dc9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -59,7 +59,7 @@ - [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm` - [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…` - [x] Notification channels по severity (critical / high / default) -- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+) — **после первого APK** +- [x] RDG flap: qwinsta/logoff через SAC API (карточка события) - [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC **Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK) diff --git a/docs/operator-guide.md b/docs/operator-guide.md index dfbf151..4a46c0d 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -1,46 +1,39 @@ -# Seaca — руководство оператора +# Seaca — оператор -## Первая настройка +## Подключение -1. Получите от администратора: - - **URL API SAC** для мобильного приложения (HTTPS, путь `/api/v1/…`); - - код регистрации `sacmob_…`; - - при необходимости — логин и пароль SAC. -2. Установите APK Seaca. -3. На экране подключения введите данные → **Подключиться**. -4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть). - -После успеха код больше не нужен — приложение запоминает сессию. +URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату. ## Разделы | Раздел | Содержание | |--------|------------| -| Обзор | Сводка за 24 ч, открытые проблемы; потяните вниз для обновления | -| События | Лента с фильтром по severity | -| Проблемы | Инциденты; для operator и выше — подтвердить / закрыть | -| Хосты | Агенты ssh-monitor / RDP-login-monitor | -| Отчёты | Суточные отчёты SSH и RDP | -| Моё устройство | Сервер, пользователь, режим push, звук, формат даты/времени, выход | +| Обзор | Сводка 24 ч; pull-to-refresh | +| События | Лента; **RDG flap** в заголовке | +| Проблемы | Ack / Resolve (monitor+) | +| Хосты | Агенты, heartbeat | +| Отчёты | `report.daily.ssh` / `report.daily.rdp` | +| Моё устройство | Push, звук, дата, выход | -## Push-уведомления +## RDG flap — qwinsta / logoff -Настраиваются на экране **Моё устройство**: +Пара RD Gateway **302→303** за несколько секунд: -| Режим | Поведение | -|-------|-----------| -| Push и звук | Уведомление со звуком (системным или выбранным) | -| Push без звука | Только banner, без звука | -| Без push и звука | Данные в приложении по запросу | +1. Событие **302** или **303** (метка **RDG flap**). +2. **qwinsta / logoff** → `POST /api/v1/events/{id}/actions/qwinsta` на SAC. +3. SAC: WinRM **qwinsta** на клиентский ПК (`internal_ip`). +4. В диалоге — сессии, **logoff** по ID. -Нажатие на уведомление открывает карточку события или проблемы. +Нужен **Windows domain admin** в настройках SAC (WinRM). + +## Push + +Режимы на экране «Моё устройство». Тап — карточка события или problem. ## Блокировка -При **запуске** приложения и при возврате из фона может запрашиваться отпечаток / PIN устройства (если включено в системе). +Биометрия/PIN при запуске и возврате в приложение. ## Потеря доступа -Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC. - -Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC). +Новый код или снятие отзыва устройства в веб-SAC.