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.
This commit is contained in:
PTah
2026-06-20 19:02:59 +10:00
parent 823ac4df57
commit 4f9a241cea
10 changed files with 284 additions and 98 deletions
+22 -63
View File
@@ -1,84 +1,43 @@
# Seaca # 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)
## Связанные репозитории ## Возможности
| Репозиторий | Роль | - Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
|-------------|------| - Push (FCM), режимы звука, выбор ringtone
| [security-alert-center](https://git.kalinamall.ru/PapaTramp/security-alert-center) | Сервер SAC: API, UI, оповещения, управление мобильными устройствами | - Ack / Resolve проблем (monitor+)
| [ssh-monitor](https://git.kalinamall.ru/PapaTramp/ssh-monitor) | Агент Linux | - **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
| [RDP-login-monitor](https://git.kalinamall.ru/PapaTramp/RDP-login-monitor) | Агент Windows | - Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Стек ## Нет в приложении (только веб-SAC)
| Слой | Технология | Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
|------|------------|
| 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) |
## Что есть в приложении ## Привязка
- Обзор, события, отчёты, проблемы, хосты (списки и карточки с читаемыми полями) 1. Админ: мобильные устройства + код `sacmob_…`
- Pull-to-refresh на обзоре и в списках 2. URL API SAC + код + логин/пароль в Seaca
- Фильтры событий и проблем (severity, status) 3. FCM-токен регистрируется автоматически
- Формат даты/времени на выбор (слэш, дефис, точка или системный локаль)
- Ack / Resolve для роли `operator` и выше
- Push с переходом в карточку события или problem; режимы: со звуком, без звука, выключено
- Выбор звука уведомления: системный или свой (ringtone picker)
- Биометрическая блокировка при **запуске** и при возврате в приложение
- Экран «Моё устройство»: сервер, пользователь, роль, версия, настройки push, выход
- Первичная привязка к серверу по **коду регистрации**, который выдаёт администратор в веб-SAC
## Чего нет в приложении (только веб) [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)
- Управление пользователями 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 на сервере |
## Сборка ## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`). JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
```bash ```bash
# Опционально: конфиг Firebase в app/ (шаблон *.example, рабочий файл не в git) — для FCM
./gradlew assembleDebug ./gradlew assembleDebug
# Windows:
gradlew.bat assembleDebug
``` ```
APK: `app/build/outputs/apk/debug/app-debug.apk` APK: `app/build/outputs/apk/debug/app-debug.apk`
Без конфига Firebase приложение собирается, но FCM не инициализируется (просмотр данных работает). ## Репозитории
## Лицензия | Репозиторий | Роль |
|-------------|------|
Как у остальных репозиториев семейства SAC (уточняется владельцем проекта). | security-alert-center | Сервер API |
| ssh-monitor / RDP-login-monitor | Агенты |
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "ru.kalinamall.seaca" applicationId = "ru.kalinamall.seaca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 21 versionCode = 22
versionName = "0.5.7" versionName = "0.5.8"
} }
buildTypes { buildTypes {
@@ -36,6 +36,15 @@ interface SacApi {
@GET("api/v1/events/{id}") @GET("api/v1/events/{id}")
suspend fun getEvent(@Path("id") id: Long): EventDetail 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") @GET("api/v1/problems")
suspend fun listProblems( suspend fun listProblems(
@Query("page") page: Int = 1, @Query("page") page: Int = 1,
@@ -58,6 +58,9 @@ data class EventSummary(
val title: String, val title: String,
val summary: String, val summary: String,
@SerialName("actor_user") val actorUser: String? = null, @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 @Serializable
@@ -80,6 +83,10 @@ data class EventDetail(
val severity: String, val severity: String,
val title: String, val title: String,
val summary: 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 details: JsonElement? = null,
val raw: JsonElement? = null, val raw: JsonElement? = null,
val payload: JsonElement? = null, val payload: JsonElement? = null,
@@ -192,6 +199,23 @@ data class ProblemActionResponse(
val status: String, 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 @Serializable
data class ApiErrorBody( data class ApiErrorBody(
val detail: String? = null, val detail: String? = null,
@@ -117,7 +117,7 @@ class SacRepository(context: Context) {
} }
val okhttp = OkHttpClient.Builder() val okhttp = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(120, TimeUnit.SECONDS)
.sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll)) .sslSocketFactory(TlsTrust.socketFactory(trustAll), TlsTrust.trustManager(trustAll))
.hostnameVerifier(TlsTrust.hostnameVerifier(trustAll)) .hostnameVerifier(TlsTrust.hostnameVerifier(trustAll))
.addInterceptor(authInterceptor) .addInterceptor(authInterceptor)
@@ -17,7 +17,10 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator 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.NotificationSoundSettings
import ru.kalinamall.seaca.data.NotificationSoundSource import ru.kalinamall.seaca.data.NotificationSoundSource
import ru.kalinamall.seaca.data.ProblemDetail import ru.kalinamall.seaca.data.ProblemDetail
import ru.kalinamall.seaca.data.LogoffActionRequest
import ru.kalinamall.seaca.data.SacRepository 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.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver import ru.kalinamall.seaca.push.NotificationSoundResolver
import ru.kalinamall.seaca.ui.RingtonePicker import ru.kalinamall.seaca.ui.RingtonePicker
@@ -78,6 +87,14 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var data by remember { mutableStateOf<EventDetail?>(null) } var data by remember { mutableStateOf<EventDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(true) } var loading by remember { mutableStateOf(true) }
var qwinstaOpen by remember { mutableStateOf(false) }
var qwinstaLoading by remember { mutableStateOf(false) }
var qwinstaError by remember { mutableStateOf<String?>(null) }
var qwinstaStdout by remember { mutableStateOf<String?>(null) }
var qwinstaSessions by remember { mutableStateOf<List<QwinstaSessionRow>>(emptyList()) }
var qwinstaMeta by remember { mutableStateOf<String?>(null) }
var logoffSessionId by remember { mutableStateOf<Int?>(null) }
val scope = rememberCoroutineScope()
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle( val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH, 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) { DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
val d = data ?: return@DetailFrame 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("Severity", d.severity)
Field( Field(
"Тип", "Тип",
@@ -110,6 +168,75 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
EventDetailsSection(d.type, d.details, d.payload) 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 @Composable
@@ -47,7 +47,7 @@ import ru.kalinamall.seaca.data.ProblemSummary
import ru.kalinamall.seaca.data.SacRepository import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.BackArrowButton import ru.kalinamall.seaca.ui.components.BackArrowButton
import ru.kalinamall.seaca.ui.components.ForwardArrowButton 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( private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов", "report.daily.ssh" to "Отчёты ssh клиентов",
@@ -189,7 +189,10 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
res.items to res.total res.items to res.total
}, },
itemKey = EventSummary::id, 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)}" }, itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
onOpen = onOpen, onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"), severities = listOf(null, "info", "warning", "high", "critical"),
@@ -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<QwinstaSessionRow> {
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
val rows = mutableListOf<QwinstaSessionRow>()
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() }
?: "Команда завершилась с ошибкой"
}
+1 -1
View File
@@ -59,7 +59,7 @@
- [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm` - [x] Регистрация FCM-токена: `PUT /api/v1/mobile/devices/me/fcm`
- [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…` - [x] Обработка data message: `kind`, `id`, `severity`, deep link `seaca://…`
- [x] Notification channels по severity (critical / high / default) - [x] Notification channels по severity (critical / high / default)
- [ ] Actions в уведомлении: «Подтвердить» / «Закрыть» problem (operator+) — **после первого APK** - [x] RDG flap: qwinsta/logoff через SAC API (карточка события)
- [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC - [x] Тестовый push из веб-SAC («Проверить push на устройстве») — на стороне SAC
**Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK) **Зависимость:** SAC worker — `use_mobile` в notification policy, `SAC_FCM_*` в `sac-api.env` (включается после первого APK)
+22 -29
View File
@@ -1,46 +1,39 @@
# Seaca — руководство оператора # Seaca — оператор
## Первая настройка ## Подключение
1. Получите от администратора: URL API SAC + код `sacmob_…` + логин/пароль. Self-signed TLS — подтвердить доверие сертификату.
- **URL API SAC** для мобильного приложения (HTTPS, путь `/api/v1/…`);
- код регистрации `sacmob_…`;
- при необходимости — логин и пароль SAC.
2. Установите APK Seaca.
3. На экране подключения введите данные → **Подключиться**.
4. При самоподписанном сертификате подтвердите **«Доверять сертификату»** (внутренняя сеть).
После успеха код больше не нужен — приложение запоминает сессию.
## Разделы ## Разделы
| Раздел | Содержание | | Раздел | Содержание |
|--------|------------| |--------|------------|
| Обзор | Сводка за 24 ч, открытые проблемы; потяните вниз для обновления | | Обзор | Сводка 24 ч; pull-to-refresh |
| События | Лента с фильтром по severity | | События | Лента; **RDG flap** в заголовке |
| Проблемы | Инциденты; для operator и выше — подтвердить / закрыть | | Проблемы | Ack / Resolve (monitor+) |
| Хосты | Агенты ssh-monitor / RDP-login-monitor | | Хосты | Агенты, heartbeat |
| Отчёты | Суточные отчёты SSH и RDP | | Отчёты | `report.daily.ssh` / `report.daily.rdp` |
| Моё устройство | Сервер, пользователь, режим push, звук, формат даты/времени, выход | | Моё устройство | Push, звук, дата, выход |
## Push-уведомления ## RDG flap — qwinsta / logoff
Настраиваются на экране **Моё устройство**: Пара RD Gateway **302→303** за несколько секунд:
| Режим | Поведение | 1. Событие **302** или **303** (метка **RDG flap**).
|-------|-----------| 2. **qwinsta / logoff**`POST /api/v1/events/{id}/actions/qwinsta` на SAC.
| Push и звук | Уведомление со звуком (системным или выбранным) | 3. SAC: WinRM **qwinsta** на клиентский ПК (`internal_ip`).
| Push без звука | Только banner, без звука | 4. В диалоге — сессии, **logoff** по ID.
| Без push и звука | Данные в приложении по запросу |
Нажатие на уведомление открывает карточку события или проблемы. Нужен **Windows domain admin** в настройках SAC (WinRM).
## Push
Режимы на экране «Моё устройство». Тап — карточка события или problem.
## Блокировка ## Блокировка
При **запуске** приложения и при возврате из фона может запрашиваться отпечаток / PIN устройства (если включено в системе). Биометрия/PIN при запуске и возврате в приложение.
## Потеря доступа ## Потеря доступа
Обратитесь к администратору: новый код регистрации или снятие отзыва устройства в веб-SAC. Новый код или снятие отзыва устройства в веб-SAC.
Подробности для админов — в [seaca-mobile.md](https://git.kalinamall.ru/PapaTramp/security-alert-center/src/branch/main/docs/seaca-mobile.md) (репозиторий SAC).