Compare commits

..

15 Commits

Author SHA1 Message Date
PTah 6398d48e06 chore: release 0.5.16 (include_hidden host events)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:25:09 +10:00
PTah 3bb81e40af fix: load hidden host events via include_hidden API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 15:16:10 +10:00
PTah d794d7dc48 fix: hide session terminate after SAC marks event terminated
Read session_terminated from API and update UI after successful logoff.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:45:36 +10:00
PTah 47842e0ef3 docs: sync README versions and features for 0.5.15
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:14:44 +10:00
PTah c78e11f265 feat: release signing via secrets.properties and shared keystore
Gradle reads signing credentials from repo-root secrets.properties;
secrets.properties.example documents setup for work and home builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:55:25 +10:00
PTah 0f05c3b2c3 fix: clear SAC push notifications when app is opened (0.5.15)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:09:39 +10:00
PTah c0842eb84b feat: RDG session break aligned with SAC rdg_qwinsta_enabled (0.5.14)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 19:08:41 +10:00
PTah 477e5ea6ed feat: host sessions and event terminate actions (0.5.13)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 10:22:21 +10:00
PTah 6592f027a1 fix(push): suppress notifications when app is in foreground (0.5.12)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-22 09:11:19 +10:00
PTah c6433d6fd3 chore: keep mirror README templates on kalinamall for push-remotes
Шаблоны github/home/kalinamall остаются в scripts/readme/ на эталонном git.
2026-06-21 20:16:30 +10:00
PTah 0884cc3d29 chore: sync kalinamall (README и файлы зеркала) 2026-06-21 20:13:47 +10:00
PTah 702b61726e fix: restore mirror README templates for home and github
Обновлён kalinamall.md; восстановлены github.md и home.md для push-remotes.sh.
2026-06-21 20:13:40 +10:00
PTah 16dd8ca3cc merge: sync main with kalinamall (0.5.11) 2026-06-21 19:43:30 +10:00
Andrey Lutsenko 2eda925101 fix: .cursor в gitignore, sync без git add -A и без .cursor на remotes 2026-06-14 21:42:45 +10:00
Andrey Lutsenko f912a4889a fix: sync remotes без чужих readme-шаблонов, check-github-clean
Каждое зеркало получает только свой README и шаблон; GitHub без internal URL в дереве.
2026-06-14 21:42:22 +10:00
19 changed files with 780 additions and 102 deletions
+18 -7
View File
@@ -2,14 +2,15 @@
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.11` (versionCode 25)
**Версия:** `0.5.16` (versionCode 30)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG flap** — метка в списке; **qwinsta / logoff** → SAC API → WinRM на клиентский ПК
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
@@ -29,15 +30,25 @@ Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/Pap
JDK 17+, `local.properties`. Опционально `app/google-services.json` для FCM.
**Debug** (ключ свой на каждом ПК):
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
**Release** (один ключ на всех ПК — для обновлений поверх старой версии):
1. Положить `seaca-release.jks` в корень репозитория (не в git).
2. Скопировать `secrets.properties.example``secrets.properties`, подставить пароль (ASCII).
3. `.\gradlew assembleRelease` — APK: `app/build/outputs/apk/release/app-release.apk`
## Репозитории
| Репозиторий | Роль |
|-------------|------|
| security-alert-center | Сервер API |
| ssh-monitor / RDP-login-monitor | Агенты |
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.kalinamall.ru/PapaTramp/seaca |
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/rdp-login-monitor |
+28 -2
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
@@ -5,6 +7,15 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization")
}
val secretsFile = rootProject.file("secrets.properties")
val releaseSecrets = Properties().apply {
if (secretsFile.exists()) {
secretsFile.inputStream().use { load(it) }
}
}
val releaseKeystoreConfigured = secretsFile.exists() &&
releaseSecrets.getProperty("SEACA_STORE_FILE") != null
android {
namespace = "ru.kalinamall.seaca"
compileSdk = 35
@@ -13,12 +24,26 @@ android {
applicationId = "ru.kalinamall.seaca"
minSdk = 26
targetSdk = 35
versionCode = 25
versionName = "0.5.11"
versionCode = 30
versionName = "0.5.16"
}
signingConfigs {
if (releaseKeystoreConfigured) {
create("release") {
storeFile = rootProject.file(releaseSecrets.getProperty("SEACA_STORE_FILE"))
storePassword = releaseSecrets.getProperty("SEACA_STORE_PASSWORD")
keyAlias = releaseSecrets.getProperty("SEACA_KEY_ALIAS")
keyPassword = releaseSecrets.getProperty("SEACA_KEY_PASSWORD")
}
}
}
buildTypes {
release {
if (releaseKeystoreConfigured) {
signingConfig = signingConfigs.getByName("release")
}
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
@@ -54,6 +79,7 @@ dependencies {
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.navigation:navigation-compose:2.8.3")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-process:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.security:security-crypto:1.1.0-alpha06")
@@ -3,12 +3,14 @@ package ru.kalinamall.seaca
import android.app.Application
import kotlinx.coroutines.runBlocking
import ru.kalinamall.seaca.data.SessionStore
import ru.kalinamall.seaca.push.AppForegroundState
import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
class SeacaApplication : Application() {
override fun onCreate() {
super.onCreate()
AppForegroundState.bind(this)
val store = SessionStore(this)
val settings = runBlocking {
NotificationSoundResolver.normalizeStoredSettings(this@SeacaApplication, store)
@@ -29,6 +29,8 @@ interface SacApi {
@Query("page_size") pageSize: Int = 30,
@Query("severity") severity: String? = null,
@Query("type") type: String? = null,
@Query("host_id") hostId: Long? = null,
@Query("include_hidden") includeHidden: Boolean? = null,
@Query("hostname") hostname: String? = null,
@Query("q") q: String? = null,
): EventListResponse
@@ -45,6 +47,21 @@ interface SacApi {
@Body body: LogoffActionRequest,
): AgentCommandResponse
@POST("api/v1/events/{id}/actions/terminate-session")
suspend fun postEventTerminateSession(
@Path("id") id: Long,
@Body body: EventSessionTerminateRequest = EventSessionTerminateRequest(),
): SessionActionResponse
@POST("api/v1/hosts/{id}/actions/sessions/list")
suspend fun postHostSessionsList(@Path("id") id: Long): HostSessionsResponse
@POST("api/v1/hosts/{id}/actions/sessions/terminate")
suspend fun postHostSessionTerminate(
@Path("id") id: Long,
@Body body: HostSessionTerminateRequest,
): SessionActionResponse
@GET("api/v1/problems")
suspend fun listProblems(
@Query("page") page: Int = 1,
@@ -61,6 +61,9 @@ data class EventSummary(
@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,
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
)
@Serializable
@@ -87,6 +90,9 @@ data class EventDetail(
@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,
@SerialName("rdg_access_path") val rdgAccessPath: String? = null,
@SerialName("rdg_qwinsta_enabled") val rdgQwinstaEnabled: Boolean = false,
@SerialName("session_terminated") val sessionTerminated: Boolean = false,
val details: JsonElement? = null,
val raw: JsonElement? = null,
val payload: JsonElement? = null,
@@ -204,6 +210,43 @@ data class LogoffActionRequest(
@SerialName("session_id") val sessionId: Int,
)
@Serializable
data class HostSessionItem(
@SerialName("session_id") val sessionId: String,
val user: String,
val tty: String? = null,
val state: String? = null,
@SerialName("source_ip") val sourceIp: String? = null,
@SerialName("session_name") val sessionName: String? = null,
)
@Serializable
data class HostSessionsResponse(
val ok: Boolean,
val message: String,
val target: String? = null,
val sessions: List<HostSessionItem> = emptyList(),
)
@Serializable
data class HostSessionTerminateRequest(
@SerialName("session_id") val sessionId: String,
)
@Serializable
data class EventSessionTerminateRequest(
@SerialName("session_id") val sessionId: String? = null,
)
@Serializable
data class SessionActionResponse(
val ok: Boolean,
val message: String,
val target: String? = null,
val stdout: String? = null,
val stderr: String? = null,
)
@Serializable
data class AgentCommandResponse(
@SerialName("command_uuid") val commandUuid: String,
@@ -0,0 +1,32 @@
package ru.kalinamall.seaca.push
import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
/** True, пока хотя бы одна activity приложения видима (ProcessLifecycleOwner.onStart). */
object AppForegroundState {
@Volatile
private var inForeground = false
private lateinit var appContext: Context
fun bind(context: Context) {
appContext = context.applicationContext
ProcessLifecycleOwner.get().lifecycle.addObserver(
object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
inForeground = true
NotificationHelper.clearAll(appContext)
}
override fun onStop(owner: LifecycleOwner) {
inForeground = false
}
},
)
}
fun isInForeground(): Boolean = inForeground
}
@@ -83,6 +83,10 @@ object NotificationHelper {
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
}
fun clearAll(context: Context) {
context.getSystemService(NotificationManager::class.java).cancelAll()
}
fun show(
context: Context,
title: String,
@@ -15,6 +15,7 @@ class SeacaMessagingService : FirebaseMessagingService() {
private val repo by lazy { SacRepository(applicationContext) }
override fun onMessageReceived(message: RemoteMessage) {
if (AppForegroundState.isInForeground()) return
val mode = runBlocking { repo.session.getNotificationMode() }
when (mode) {
NotificationMode.OFF -> return
@@ -0,0 +1,239 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import retrofit2.HttpException
import ru.kalinamall.seaca.data.HostSessionItem
import ru.kalinamall.seaca.data.HostSessionTerminateRequest
import ru.kalinamall.seaca.data.SacRepository
@Composable
fun HostSessionsSection(
repo: SacRepository,
hostId: Long,
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
var loading by remember(hostId) { mutableStateOf(false) }
var loaded by remember(hostId) { mutableStateOf(false) }
var sessions by remember(hostId) { mutableStateOf<List<HostSessionItem>>(emptyList()) }
var error by remember(hostId) { mutableStateOf<String?>(null) }
var message by remember(hostId) { mutableStateOf<String?>(null) }
var messageOk by remember(hostId) { mutableStateOf(true) }
var terminatingId by remember(hostId) { mutableStateOf<String?>(null) }
var confirmSession by remember(hostId) { mutableStateOf<HostSessionItem?>(null) }
fun load() {
scope.launch {
loading = true
error = null
message = null
try {
val res = repo.withAuth { it.postHostSessionsList(hostId) }
if (!res.ok) {
error = res.message
sessions = emptyList()
} else {
sessions = res.sessions
loaded = true
}
} catch (e: Exception) {
error = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
loading = false
}
}
}
fun terminate(sessionId: String) {
scope.launch {
terminatingId = sessionId
message = null
try {
val res = repo.withAuth {
it.postHostSessionTerminate(hostId, HostSessionTerminateRequest(sessionId))
}
messageOk = res.ok
message = res.message
if (res.ok) {
sessions = sessions.filter { it.sessionId != sessionId }
}
} catch (e: Exception) {
messageOk = false
message = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminatingId = null
}
}
}
confirmSession?.let { session ->
AlertDialog(
onDismissRequest = { confirmSession = null },
title = { Text("Завершить сессию?") },
text = { Text("Завершить сессию ${session.sessionId} (${session.user})?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val sid = session.sessionId
confirmSession = null
terminate(sid)
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { confirmSession = null }) { Text("Отмена") }
},
)
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Залогиненные пользователи",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
"Linux: loginctl, Windows: qwinsta. Нужны учётные данные admin в настройках SAC.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
if (loaded) {
if (sessions.isEmpty()) {
Text(
"Активных сессий не найдено.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
sessions.forEach { session ->
HostSessionRow(
session = session,
terminating = terminatingId == session.sessionId,
enabled = terminatingId == null && !loading,
onTerminate = { confirmSession = session },
)
}
}
message?.let {
Text(
it,
color = if (messageOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
OutlinedButton(
enabled = !loading,
onClick = { load() },
) {
if (loading) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator()
Text("Загрузка…")
}
} else {
Text(if (loaded) "Обновить" else "Показать пользователей")
}
}
}
}
@Composable
private fun HostSessionRow(
session: HostSessionItem,
terminating: Boolean,
enabled: Boolean,
onTerminate: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.surfaceContainerHigh,
) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(session.user, style = MaterialTheme.typography.bodyMedium)
Text(
buildString {
append("ID ${session.sessionId}")
session.tty?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
session.sessionName?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
session.state?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (terminating) {
CircularProgressIndicator(modifier = Modifier.padding(start = 8.dp))
} else {
Button(
enabled = enabled,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = onTerminate,
) { Text("Завершить") }
}
}
}
}
@Composable
fun EventSessionTerminateButton(
enabled: Boolean,
loading: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
OutlinedButton(
enabled = enabled && !loading,
onClick = onClick,
modifier = modifier,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error,
),
) {
Text(if (loading) "" else "Завершить")
}
}
@@ -0,0 +1,39 @@
package ru.kalinamall.seaca.ui.components
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private val RdgAccessText = Color(0xFF7EC8E3)
private val RdgAccessBackground = Color(0x1A7EC8E3)
private val RdgAccessBorder = Color(0x667EC8E3)
@Composable
fun RdgAccessBadge(
path: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier
.border(1.dp, RdgAccessBorder, RoundedCornerShape(4.dp)),
shape = RoundedCornerShape(4.dp),
color = RdgAccessBackground,
) {
Text(
text = path,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
color = RdgAccessText,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 0.3.sp,
)
}
}
@@ -39,7 +39,7 @@ 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.parseQwinstaSessions
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
class RdgQwinstaController(private val repo: SacRepository) {
var open by mutableStateOf(false)
@@ -61,7 +61,7 @@ class RdgQwinstaController(private val repo: SacRepository) {
private var actorUser: String? = null
fun openFor(event: EventSummary, scope: kotlinx.coroutines.CoroutineScope) {
val targetId = rdgFlapQwinstaEventId(event) ?: return
val targetId = rdgQwinstaEventId(event) ?: return
targetEventId = targetId
actorUser = event.actorUser
open = true
@@ -69,7 +69,7 @@ class RdgQwinstaController(private val repo: SacRepository) {
}
fun openFor(event: EventDetail, scope: kotlinx.coroutines.CoroutineScope) {
val targetId = rdgFlapQwinstaEventId(event) ?: return
val targetId = rdgQwinstaEventId(event) ?: return
targetEventId = targetId
actorUser = event.actorUser
open = true
@@ -191,7 +191,7 @@ fun RdgQwinstaDialog(
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("qwinsta / logoff", style = MaterialTheme.typography.titleLarge)
Text("Оборвать сессию", style = MaterialTheme.typography.titleLarge)
controller.meta?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -329,6 +329,6 @@ fun RdgQwinstaButton(
onClick = onClick,
modifier = modifier,
) {
Text(if (controller.loading) "qwinsta" else "qwinsta")
Text(if (controller.loading) "" else "Оборвать сессию")
}
}
@@ -19,6 +19,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.TextButton
import androidx.compose.material3.Card
@@ -48,6 +49,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonElement
import retrofit2.HttpException
import ru.kalinamall.seaca.R
import ru.kalinamall.seaca.data.EventSummary
import ru.kalinamall.seaca.data.EventDetail
@@ -63,8 +65,12 @@ import ru.kalinamall.seaca.data.SacRepository
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
import ru.kalinamall.seaca.ui.components.EventSessionTerminateButton
import ru.kalinamall.seaca.ui.components.HostSessionsSection
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate
import ru.kalinamall.seaca.ui.util.sessionTerminateLabel
import ru.kalinamall.seaca.push.NotificationHelper
import ru.kalinamall.seaca.push.NotificationSoundResolver
import ru.kalinamall.seaca.ui.RingtonePicker
@@ -89,6 +95,9 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
var loading by remember { mutableStateOf(true) }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
var terminateLoading by remember { mutableStateOf(false) }
var terminateConfirm by remember { mutableStateOf(false) }
var terminateMessage by remember { mutableStateOf<String?>(null) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
@@ -106,16 +115,30 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
DetailFrame(title = data?.title ?: "Событие", loading, error, onBack) {
val d = data ?: return@DetailFrame
if (!d.rdgAccessPath.isNullOrBlank()) {
Field("Путь RDS", d.rdgAccessPath)
}
if (hasRdgFlapUi(d)) {
Field("RDG flap", "302→303 — возможна зависшая сессия на ПК пользователя")
d.rdgFlapPairEventId?.let { Field("Пара", "#$it") }
rdgFlapQwinstaEventId(d)?.let {
}
rdgQwinstaEventId(d)?.let {
RdgQwinstaButton(
controller = qwinsta,
onClick = { qwinsta.openFor(d, scope) },
modifier = Modifier.padding(vertical = 4.dp),
)
}
if (eventSupportsSessionTerminate(d)) {
EventSessionTerminateButton(
enabled = !terminateLoading,
loading = terminateLoading,
onClick = { terminateConfirm = true },
modifier = Modifier.padding(vertical = 4.dp),
)
terminateMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
Field("Severity", d.severity)
Field(
@@ -133,6 +156,53 @@ fun EventDetailScreen(repo: SacRepository, id: Long, onBack: () -> Unit) {
}
}
if (terminateConfirm) {
val d = data
AlertDialog(
onDismissRequest = { terminateConfirm = false },
title = { Text("Завершить сессию?") },
text = {
Text(
if (d != null) {
"Завершить сессию пользователя ${sessionTerminateLabel(d)}?"
} else {
"Завершить сессию пользователя?"
},
)
},
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
terminateConfirm = false
scope.launch {
terminateLoading = true
terminateMessage = null
try {
val res = repo.withAuth { it.postEventTerminateSession(id) }
if (!res.ok) {
terminateMessage = res.message
} else {
data = data?.copy(sessionTerminated = true)
}
} catch (e: Exception) {
terminateMessage = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminateLoading = false
}
}
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { terminateConfirm = false }) { Text("Отмена") }
},
)
}
RdgQwinstaDialog(qwinsta)
}
@@ -316,10 +386,10 @@ fun HostDetailScreen(
}
}
when (HostDetailTab.entries[selectedTab]) {
HostDetailTab.Info -> HostInfoContent(host, dateMode)
HostDetailTab.Info -> HostInfoContent(host, repo, dateMode)
HostDetailTab.Events -> HostEventsContent(
repo = repo,
hostname = host.hostname,
hostId = host.id,
onOpenEvent = onOpenEvent,
)
}
@@ -328,7 +398,7 @@ fun HostDetailScreen(
}
@Composable
private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
private fun HostInfoContent(host: HostDetail, repo: SacRepository, dateMode: DateTimeDisplayMode) {
Column(
Modifier
.fillMaxSize()
@@ -341,6 +411,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
Field("ОС", "${host.osFamily} ${host.osVersion.orEmpty()}".trim())
Field("Агент", host.agentStatus)
Field("Последний контакт", SacDateTimes.format(host.lastSeenAt, dateMode))
HostSessionsSection(repo = repo, hostId = host.id)
host.inventory?.let { inv ->
val lines = inventoryFieldLines(inv)
if (lines.isEmpty()) {
@@ -357,7 +428,7 @@ private fun HostInfoContent(host: HostDetail, dateMode: DateTimeDisplayMode) {
@Composable
private fun HostEventsContent(
repo: SacRepository,
hostname: String,
hostId: Long,
onOpenEvent: (Long) -> Unit,
) {
var page by remember { mutableIntStateOf(1) }
@@ -377,7 +448,12 @@ private fun HostEventsContent(
error = null
try {
val res = repo.withAuth {
it.listEvents(page = page, pageSize = 30, hostname = hostname)
it.listEvents(
page = page,
pageSize = 30,
hostId = hostId,
includeHidden = true,
)
}
items = res.items
total = res.total
@@ -390,7 +466,7 @@ private fun HostEventsContent(
}
}
LaunchedEffect(page, hostname) { load() }
LaunchedEffect(page, hostId) { load() }
Column(
Modifier
@@ -21,6 +21,10 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.TextButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -39,6 +43,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.launch
import retrofit2.HttpException
import ru.kalinamall.seaca.data.DateTimeDisplayMode
import ru.kalinamall.seaca.data.DashboardSummary
import ru.kalinamall.seaca.data.EventListResponse
@@ -47,6 +52,8 @@ import ru.kalinamall.seaca.data.HostSummary
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.EventSessionTerminateButton
import ru.kalinamall.seaca.ui.components.RdgAccessBadge
import ru.kalinamall.seaca.ui.components.RdgFlapBadge
import ru.kalinamall.seaca.ui.components.RdgQwinstaButton
import ru.kalinamall.seaca.ui.components.RdgQwinstaDialog
@@ -54,7 +61,9 @@ import ru.kalinamall.seaca.ui.components.rememberRdgQwinstaController
import ru.kalinamall.seaca.ui.components.ForwardArrowButton
import ru.kalinamall.seaca.ui.util.SacDateTimes
import ru.kalinamall.seaca.ui.util.hasRdgFlapUi
import ru.kalinamall.seaca.ui.util.rdgFlapQwinstaEventId
import ru.kalinamall.seaca.ui.util.rdgQwinstaEventId
import ru.kalinamall.seaca.ui.util.eventSupportsSessionTerminate
import ru.kalinamall.seaca.ui.util.sessionTerminateLabel
private val REPORT_TYPE_OPTIONS = listOf(
"report.daily.ssh" to "Отчёты ssh клиентов",
@@ -130,6 +139,9 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
verticalAlignment = Alignment.Top,
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!e.rdgAccessPath.isNullOrBlank()) {
RdgAccessBadge(path = e.rdgAccessPath)
}
if (hasRdgFlapUi(e)) {
RdgFlapBadge(pairEventId = e.rdgFlapPairEventId)
}
@@ -138,7 +150,7 @@ fun DashboardScreen(repo: SacRepository, refreshKey: Int = 0) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
if (rdgFlapQwinstaEventId(e) != null) {
rdgQwinstaEventId(e)?.let {
RdgQwinstaButton(
controller = qwinsta,
onClick = { qwinsta.openFor(e, scope) },
@@ -192,10 +204,53 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
var hostname by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val qwinsta = rememberRdgQwinstaController(repo)
var terminateLoadingId by remember { mutableStateOf<Long?>(null) }
var terminateConfirm by remember { mutableStateOf<EventSummary?>(null) }
var terminateError by remember { mutableStateOf<String?>(null) }
var locallyTerminatedIds by remember { mutableStateOf(setOf<Long>()) }
val dateMode by repo.session.dateTimeDisplayMode.collectAsStateWithLifecycle(
initialValue = DateTimeDisplayMode.SLASH,
)
terminateConfirm?.let { event ->
AlertDialog(
onDismissRequest = { terminateConfirm = null },
title = { Text("Завершить сессию?") },
text = { Text("Завершить сессию пользователя ${sessionTerminateLabel(event)}?") },
confirmButton = {
Button(
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
onClick = {
val target = event
terminateConfirm = null
scope.launch {
terminateLoadingId = target.id
terminateError = null
try {
val res = repo.withAuth { it.postEventTerminateSession(target.id) }
if (!res.ok) {
terminateError = res.message
} else {
locallyTerminatedIds = locallyTerminatedIds + target.id
}
} catch (e: Exception) {
terminateError = if (e is HttpException) SacRepository.parseError(e) else e.message
} finally {
terminateLoadingId = null
}
}
},
) { Text("Завершить") }
},
dismissButton = {
TextButton(onClick = { terminateConfirm = null }) { Text("Отмена") }
},
)
}
PagedListScreen(
title = "События",
loadPage = { page, severity, host ->
@@ -212,15 +267,24 @@ fun EventsScreen(repo: SacRepository, onOpen: (Long) -> Unit) {
itemKey = EventSummary::id,
itemLabel = { "${it.severity} · ${it.title}" },
itemSub = { "${it.hostname} · ${SacDateTimes.format(it.occurredAt, dateMode)}" },
itemRdgAccessPath = { it.rdgAccessPath },
itemRdgFlap = { hasRdgFlapUi(it) },
itemRdgFlapPairId = { it.rdgFlapPairEventId },
itemQwinsta = { rdgFlapQwinstaEventId(it) != null },
itemQwinsta = { rdgQwinstaEventId(it) != null },
onQwinsta = { qwinsta.openFor(it, scope) },
qwinstaLoading = qwinsta.loading,
itemTerminate = {
eventSupportsSessionTerminate(it) && it.id !in locallyTerminatedIds
},
onTerminate = { terminateConfirm = it },
terminateLoadingId = terminateLoadingId,
onOpen = onOpen,
severities = listOf(null, "info", "warning", "high", "critical"),
hostnameFilter = hostname,
headerContent = {
terminateError?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
OutlinedTextField(
value = hostname,
onValueChange = { hostname = it },
@@ -354,11 +418,15 @@ private fun <T> PagedListScreen(
itemSub: (T) -> String,
onOpen: (Long) -> Unit,
severities: List<String?>,
itemRdgAccessPath: ((T) -> String?)? = null,
itemRdgFlap: ((T) -> Boolean)? = null,
itemRdgFlapPairId: ((T) -> Long?)? = null,
itemQwinsta: ((T) -> Boolean)? = null,
onQwinsta: ((T) -> Unit)? = null,
qwinstaLoading: Boolean = false,
itemTerminate: ((T) -> Boolean)? = null,
onTerminate: ((T) -> Unit)? = null,
terminateLoadingId: Long? = null,
filterLabel: String = "Severity",
hostnameFilter: String = "",
headerContent: @Composable () -> Unit = {},
@@ -421,11 +489,15 @@ private fun <T> PagedListScreen(
title = itemLabel(item),
subtitle = itemSub(item),
onClick = { onOpen(itemKey(item)) },
rdgAccessPath = itemRdgAccessPath?.invoke(item),
rdgFlap = itemRdgFlap?.invoke(item) == true,
rdgFlapPairId = itemRdgFlapPairId?.invoke(item),
showQwinsta = itemQwinsta?.invoke(item) == true,
onQwinstaClick = onQwinsta?.let { handler -> { handler(item) } },
qwinstaLoading = qwinstaLoading,
showTerminate = itemTerminate?.invoke(item) == true,
onTerminateClick = onTerminate?.let { handler -> { handler(item) } },
terminateLoading = terminateLoadingId == itemKey(item),
)
}
}
@@ -439,11 +511,15 @@ private fun ListRow(
title: String,
subtitle: String,
onClick: () -> Unit,
rdgAccessPath: String? = null,
rdgFlap: Boolean = false,
rdgFlapPairId: Long? = null,
showQwinsta: Boolean = false,
onQwinstaClick: (() -> Unit)? = null,
qwinstaLoading: Boolean = false,
showTerminate: Boolean = false,
onTerminateClick: (() -> Unit)? = null,
terminateLoading: Boolean = false,
) {
Card(
Modifier
@@ -461,6 +537,9 @@ private fun ListRow(
.clickable(onClick = onClick),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (!rdgAccessPath.isNullOrBlank()) {
RdgAccessBadge(path = rdgAccessPath)
}
if (rdgFlap) {
RdgFlapBadge(pairEventId = rdgFlapPairId)
}
@@ -471,7 +550,14 @@ private fun ListRow(
OutlinedButton(
enabled = !qwinstaLoading,
onClick = onQwinstaClick,
) { Text(if (qwinstaLoading) "qwinsta" else "qwinsta / logoff") }
) { Text(if (qwinstaLoading) "" else "Оборвать сессию") }
}
if (showTerminate && onTerminateClick != null) {
EventSessionTerminateButton(
enabled = !terminateLoading,
loading = terminateLoading,
onClick = onTerminateClick,
)
}
}
}
@@ -13,14 +13,31 @@ data class QwinstaSessionRow(
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 hasRdgAccessPath(event: EventSummary): Boolean =
!event.rdgAccessPath.isNullOrBlank()
fun hasRdgAccessPath(event: EventDetail): Boolean =
!event.rdgAccessPath.isNullOrBlank()
/** Как SAC rdgQwinstaEventId: кнопка только при rdg_qwinsta_enabled от API. */
fun rdgQwinstaEventId(event: EventSummary): Long? {
if (!event.rdgQwinstaEnabled) return null
return event.rdgFlapQwinstaEventId ?: event.id
}
fun rdgQwinstaEventId(event: EventDetail): Long? {
if (!event.rdgQwinstaEnabled) return null
return event.rdgFlapQwinstaEventId ?: event.id
}
/** @deprecated use rdgQwinstaEventId */
fun rdgFlapQwinstaEventId(event: EventSummary): Long? = rdgQwinstaEventId(event)
/** @deprecated use rdgQwinstaEventId */
fun rdgFlapQwinstaEventId(event: EventDetail): Long? = rdgQwinstaEventId(event)
fun parseQwinstaSessions(stdout: String, actorUser: String?): List<QwinstaSessionRow> {
val lines = stdout.lines().map { it.trim() }.filter { it.isNotEmpty() }
@@ -0,0 +1,26 @@
package ru.kalinamall.seaca.ui.util
import ru.kalinamall.seaca.data.EventDetail
import ru.kalinamall.seaca.data.EventSummary
private val LINUX_SESSION_EVENT_TYPES = setOf(
"session.logind.new",
"ssh.login.success",
"privilege.sudo.command",
)
private val WINDOWS_SESSION_EVENT_TYPES = setOf("rdp.login.success")
fun eventSupportsSessionTerminate(event: EventSummary): Boolean =
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
!event.sessionTerminated
fun eventSupportsSessionTerminate(event: EventDetail): Boolean =
(event.type in LINUX_SESSION_EVENT_TYPES || event.type in WINDOWS_SESSION_EVENT_TYPES) &&
!event.sessionTerminated
fun sessionTerminateLabel(event: EventSummary): String =
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
fun sessionTerminateLabel(event: EventDetail): String =
event.actorUser?.takeIf { it.isNotBlank() } ?: event.title
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://github.com/PTah/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Нет в приложении (только веб-SAC)
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
## Привязка
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
[docs/operator-guide.md](docs/operator-guide.md)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Related repositories
| Repository | URL |
|------------|-----|
| seaca | https://github.com/PTah/seaca |
| security-alert-center | https://github.com/PTah/security-alert-center |
| ssh-monitor | https://github.com/PTah/ssh-monitor |
| RDP-login-monitor | https://github.com/PTah/RDP-login-monitor |
+46
View File
@@ -0,0 +1,46 @@
# Seaca
Android-клиент [Security Alert Center (SAC)](https://git.papatramp.ru/PapaTramp/security-alert-center).
**Версия:** `0.5.15` (versionCode 29)
## Возможности
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
## Нет в приложении (только веб-SAC)
Пользователи, каналы оповещений, WinRM domain admin, коды регистрации.
## Привязка
1. Админ: мобильные устройства + код `sacmob_…`
2. URL API SAC + код + логин/пароль в Seaca
3. FCM-токен регистрируется автоматически
[docs/operator-guide.md](docs/operator-guide.md)
## Сборка
JDK 17+, `local.properties`. Опционально `app/google-services.json` for FCM.
```bash
./gradlew assembleDebug
```
APK: `app/build/outputs/apk/debug/app-debug.apk`
## Репозитории
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.papatramp.ru/PapaTramp/seaca |
| security-alert-center | https://git.papatramp.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.papatramp.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.papatramp.ru/PapaTramp/rdp-login-monitor |
+25 -66
View File
@@ -1,87 +1,46 @@
# Seaca
**Seaca** (Security Event Alert Center App) — нативный мобильный клиент **Security Alert Center (SAC)** для Android.
Android-клиент [Security Alert Center (SAC)](https://git.kalinamall.ru/PapaTramp/security-alert-center).
Оператор видит те же данные, что и в веб-интерфейсе SAC: обзор, события, отчёты, проблемы и хосты. Push-уведомления приходят по тем же правилам severity и cooldown, что Telegram/email/webhook на сервере. Управление пользователями, каналами оповещений и привязкой устройств — **только в веб-SAC** (раздел «Настройки»).
**Версия:** `0.5.15` (versionCode 29)
## Связанные репозитории
## Возможности
| Репозиторий | Роль |
|-------------|------|
| security-alert-center | Сервер SAC: API, UI, оповещения, управление мобильными устройствами |
| ssh-monitor | Агент Linux |
| RDP-login-monitor | Агент Windows |
- Обзор, события, проблемы, хосты, отчёты (API веб-SAC)
- Push (FCM), режимы звука, выбор ringtone; уведомления снимаются при открытии приложения
- Ack / Resolve проблем (monitor+)
- **RDG** — метка flap, путь RDS; **«Оборвать сессию»** → SAC API → WinRM на клиентский ПК
- Завершение сессий SSH/RDP с карточки события и списка активных сессий на хосте
- Биометрия при запуске и возврате в приложение
- Формат даты, экран «Моё устройство»
| Репозиторий | URL |
|-------------|-----|
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/RDP-login-monitor |
## Стек
## Нет в приложении (только веб-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) | План разработки |
[docs/operator-guide.md](docs/operator-guide.md)
## Сборка
Требуется **JDK 17+** (Android Studio JBR или отдельная установка) и Android SDK (`local.properties` с `sdk.dir=…`).
JDK 17+, `local.properties`. Опционально `app/google-services.json` for 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 (уточняется владельцем проекта).
| Репозиторий | URL |
|-------------|-----|
| seaca | https://git.kalinamall.ru/PapaTramp/seaca |
| security-alert-center | https://git.kalinamall.ru/PapaTramp/security-alert-center |
| ssh-monitor | https://git.kalinamall.ru/PapaTramp/ssh-monitor |
| RDP-login-monitor | https://git.kalinamall.ru/PapaTramp/rdp-login-monitor |
+8
View File
@@ -0,0 +1,8 @@
# Скопируйте в secrets.properties в корне репозитория (рядом с gradlew.bat).
# Пароль — только ASCII (латиница, цифры, символы). Store и key — один пароль.
# seaca-release.jks — тот же файл на рабочем и домашнем ПК (не в git).
SEACA_STORE_FILE=seaca-release.jks
SEACA_STORE_PASSWORD=change-me-ascii-only
SEACA_KEY_ALIAS=seaca
SEACA_KEY_PASSWORD=change-me-ascii-only