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:
@@ -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 | Агенты |
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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).
|
|
||||||
|
|||||||
Reference in New Issue
Block a user