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:
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** (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 | Агенты |
+2 -2
View File
@@ -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 {
@@ -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,
@@ -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,
@@ -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)
@@ -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<EventDetail?>(null) }
var error by remember { mutableStateOf<String?>(null) }
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(
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
@@ -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"),
@@ -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] Обработка 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)
+22 -29
View File
@@ -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.