Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5af945778b | |||
| f92973f5fe | |||
| 380d795ffb | |||
| caf005f04b | |||
| 9787c218ac | |||
| 926b8e96af | |||
| ae39e35fb4 | |||
| 48a3d6be33 | |||
| 24ff45ca7e | |||
| b8201a82cd | |||
| 3a33656cac | |||
| 2f5b372a80 | |||
| 32bec75665 | |||
| 9b96537aa6 | |||
| 18ee7c469b | |||
| e9ad719424 | |||
| d1e1a478f8 | |||
| cacf608fde | |||
| a9c245d468 | |||
| 8b6a01d280 |
@@ -14,3 +14,7 @@ worker.id
|
||||
/chat
|
||||
*.exe
|
||||
registry.db
|
||||
|
||||
# local workspace (no committear: replace absoluto al registry)
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
+13
-10
@@ -1,12 +1,15 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
# Android / Gradle build artifacts
|
||||
*.iml
|
||||
.idea/
|
||||
captures/
|
||||
.cxx/
|
||||
.gradle/
|
||||
/local.properties
|
||||
/.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/app/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
|
||||
# The gomobile binding is a build artifact (~24 MB). Regenerate it from ../mobile
|
||||
# with `gomobile bind` (see README.md); it is not versioned.
|
||||
app/libs/*.aar
|
||||
app/libs/*.jar
|
||||
# binding gomobile regenerable (38MB): ver mobile/gen_aar.sh
|
||||
/app/libs/*.aar
|
||||
/app/libs/*-sources.jar
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# unibus · app Android
|
||||
|
||||
Cliente móvil nativo de unibus. La app no habla con un gateway: embebe un **peer
|
||||
real** del bus a través del binding gomobile `mobile/unibus.go`, de modo que el
|
||||
cifrado extremo a extremo corre **en el dispositivo**. Cada teléfono es un peer
|
||||
de primera clase del bus, igual que cualquier peer Go.
|
||||
|
||||
## Arquitectura
|
||||
|
||||
```
|
||||
Kotlin/Compose UI ──> BusViewModel ──> com.unibus.core.mobile.Session (.aar)
|
||||
│ (NATS data plane + E2E crypto, en Go)
|
||||
▼
|
||||
membershipd (control plane HTTP :8470)
|
||||
NATS (data plane :4250)
|
||||
```
|
||||
|
||||
- `BusViewModel` traduce intents de UI en llamadas al binding. Las llamadas de red
|
||||
(`newSession`, `createRoom`, `join`, `publish`) corren en `Dispatchers.IO`.
|
||||
- Los frames entrantes llegan por `FrameListener.onFrame` en una goroutine NATS
|
||||
(hilo JNI); se publican en un `StateFlow` (thread-safe) que Compose recolecta en
|
||||
el hilo principal.
|
||||
|
||||
## Requisitos
|
||||
|
||||
- Android SDK (compileSdk 34), NDK (para regenerar el `.aar`), JDK 17.
|
||||
- El binding `app/libs/unibus.aar` (no versionado: es un artefacto de ~24 MB).
|
||||
|
||||
## 1. Generar el binding (.aar)
|
||||
|
||||
Desde la raíz del repo de la app (`projects/message_bus/apps/unibus`):
|
||||
|
||||
```bash
|
||||
export ANDROID_HOME=$HOME/android-sdk
|
||||
export ANDROID_NDK_HOME=$HOME/android-sdk/ndk/26.3.11579264
|
||||
mkdir -p android/app/libs
|
||||
gomobile bind -target=android -androidapi 21 -javapkg com.unibus.core \
|
||||
-o android/app/libs/unibus.aar ./mobile
|
||||
```
|
||||
|
||||
Esto produce `unibus.aar` con la clase estática `com.unibus.core.mobile.Mobile`
|
||||
(`generateIdentity`, `newSession`) y los tipos `Session` y `FrameListener`.
|
||||
|
||||
## 2. Compilar el APK
|
||||
|
||||
```bash
|
||||
cd android
|
||||
export JAVA_HOME=$HOME/android-sdk/jdk-17/jdk-17.0.19+10
|
||||
export ANDROID_HOME=$HOME/android-sdk
|
||||
./gradlew assembleDebug
|
||||
# APK: app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
`local.properties` apunta a `sdk.dir`; ajústalo si tu SDK está en otra ruta.
|
||||
|
||||
## 3. Arrancar el bus y probar en el emulador
|
||||
|
||||
```bash
|
||||
# 1. En el PC: control plane + NATS embebido (HTTP :8470, NATS :4250)
|
||||
cd projects/message_bus/apps/unibus && go run ./cmd/membershipd
|
||||
|
||||
# 2. Emulador Pixel_API34
|
||||
$ANDROID_HOME/emulator/emulator -avd Pixel_API34 &
|
||||
|
||||
# 3. Instalar + lanzar
|
||||
adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
adb shell am start -n com.unibus.app/.MainActivity
|
||||
```
|
||||
|
||||
En la pantalla de conexión, desde el emulador el host del PC es `10.0.2.2`:
|
||||
|
||||
- **Host (control plane):** `http://10.0.2.2:8470`
|
||||
- **NATS (data plane):** `nats://10.0.2.2:4250`
|
||||
|
||||
Para un teléfono físico en la misma LAN, usa la IP LAN del PC en lugar de
|
||||
`10.0.2.2`.
|
||||
|
||||
## Notas
|
||||
|
||||
- La identidad del peer se guarda en `filesDir/peer.id` (claves privadas
|
||||
Ed25519 + X25519). No se sincroniza ni se respalda.
|
||||
- Una room creada en modo "cifrar (E2E)" usa la política Matrix (cifrada,
|
||||
persistida, firmada); en modo normal usa NATS cleartext.
|
||||
@@ -1,7 +1,7 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -14,10 +14,21 @@ android {
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
// The unibus.aar ships native libgojni.so for these ABIs. Limit the APK
|
||||
// to the desktop/emulator + phone ABIs we actually target.
|
||||
ndk {
|
||||
abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -27,17 +38,13 @@ android {
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
// Compose compiler matching Kotlin 1.9.24.
|
||||
kotlinCompilerExtensionVersion = "1.5.14"
|
||||
}
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
@@ -46,21 +53,23 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// The unibus gomobile binding: a real bus peer that does NATS + E2E crypto
|
||||
// on the device. All protocol logic lives here, shared with every other peer.
|
||||
// gomobile binding over pkg/client (real end-to-end crypto on device).
|
||||
implementation(files("libs/unibus.aar"))
|
||||
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.09.03")
|
||||
implementation("androidx.core:core-ktx:1.13.1")
|
||||
implementation("androidx.activity:activity-compose:1.9.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.2")
|
||||
|
||||
val composeBom = platform("androidx.compose:compose-bom:2024.06.00")
|
||||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.activity:activity-compose:1.9.2")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.6")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# libs/
|
||||
|
||||
`unibus.aar` (binding gomobile sobre `pkg/client`, ~38 MB con `libgojni.so` para
|
||||
4 ABIs) vive aquí pero **no se versiona** — es un artefacto de build reproducible.
|
||||
|
||||
Regenéralo con:
|
||||
|
||||
```bash
|
||||
../../mobile/gen_aar.sh
|
||||
```
|
||||
|
||||
(desde la raíz del repo: `./mobile/gen_aar.sh`). Requiere Go + gomobile + Android NDK.
|
||||
Vendored
+3
-3
@@ -1,4 +1,4 @@
|
||||
# gomobile generates JNI-bound classes under com.unibus.core.mobile and go.*.
|
||||
# They are reached from native code, so keep them intact even when minifying.
|
||||
-keep class com.unibus.core.mobile.** { *; }
|
||||
# gomobile binding: keep the generated Go<->Java bridge classes intact so the
|
||||
# JNI layer can find them by name at runtime.
|
||||
-keep class go.** { *; }
|
||||
-keep class com.unibus.core.mobile.** { *; }
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- The bus is reached over the network (NATS data plane + control plane). -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.Unibus">
|
||||
android:label="unibus"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:theme="@style/Theme.Unibus"
|
||||
android:supportsRtl="true">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
android:theme="@style/Theme.Unibus">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.unibus.app
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.unibus.app.data.Message
|
||||
import com.unibus.app.data.MockUnibusRepository
|
||||
import com.unibus.app.data.Room
|
||||
import com.unibus.app.data.UnibusRepository
|
||||
import com.unibus.app.data.User
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Estado de la app. Orquesta el [UnibusRepository] (mock por defecto) y expone
|
||||
* estado observable a Compose. Cambiar el repo por [com.unibus.app.data.BindingUnibusRepository]
|
||||
* conecta la UI al bus real sin tocar las pantallas.
|
||||
*/
|
||||
class AppViewModel(
|
||||
private val repo: UnibusRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
// Constructor no-arg para que androidx `viewModel()` lo instancie por
|
||||
// reflexión. Por defecto usa el repositorio mock (iteración de diseño).
|
||||
constructor() : this(MockUnibusRepository())
|
||||
|
||||
var user by mutableStateOf<User?>(null)
|
||||
private set
|
||||
var rooms by mutableStateOf<List<Room>>(emptyList())
|
||||
private set
|
||||
var activeRoomId by mutableStateOf<String?>(null)
|
||||
private set
|
||||
var messages by mutableStateOf<List<Message>>(emptyList())
|
||||
private set
|
||||
var connecting by mutableStateOf(false)
|
||||
private set
|
||||
var error by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
val activeRoom: Room?
|
||||
get() = rooms.firstOrNull { it.id == activeRoomId }
|
||||
|
||||
fun connect(handle: String, password: String) {
|
||||
if (connecting) return
|
||||
connecting = true
|
||||
error = null
|
||||
viewModelScope.launch {
|
||||
repo.connect(handle, password)
|
||||
.onSuccess {
|
||||
user = it
|
||||
rooms = repo.listRooms()
|
||||
}
|
||||
.onFailure { error = it.message ?: "No se pudo conectar" }
|
||||
connecting = false
|
||||
}
|
||||
}
|
||||
|
||||
fun openRoom(id: String) {
|
||||
activeRoomId = id
|
||||
messages = repo.messagesOf(id)
|
||||
repo.subscribe(id) { incoming ->
|
||||
if (activeRoomId == id) messages = messages + incoming
|
||||
}
|
||||
}
|
||||
|
||||
fun closeRoom() {
|
||||
activeRoomId = null
|
||||
messages = emptyList()
|
||||
}
|
||||
|
||||
fun send(text: String) {
|
||||
val rid = activeRoomId ?: return
|
||||
val body = text.trim()
|
||||
if (body.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
repo.send(rid, body).onSuccess { messages = messages + it }
|
||||
}
|
||||
}
|
||||
|
||||
fun logout() {
|
||||
repo.close()
|
||||
user = null
|
||||
rooms = emptyList()
|
||||
activeRoomId = null
|
||||
messages = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package com.unibus.app
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.unibus.core.mobile.FrameListener
|
||||
import com.unibus.core.mobile.Mobile
|
||||
import com.unibus.core.mobile.Session
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/** One chat message shown in the UI. */
|
||||
data class ChatMessage(
|
||||
val sender: String,
|
||||
val text: String,
|
||||
val mine: Boolean,
|
||||
val ts: Long,
|
||||
)
|
||||
|
||||
/** The whole observable UI state of the app. */
|
||||
data class BusState(
|
||||
val connecting: Boolean = false,
|
||||
val connected: Boolean = false,
|
||||
val endpointId: String = "",
|
||||
val roomId: String = "",
|
||||
val roomSubject: String = "",
|
||||
val status: String = "",
|
||||
val error: String? = null,
|
||||
val messages: List<ChatMessage> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* BusViewModel drives a real unibus peer on the device through the gomobile
|
||||
* binding. The binding performs NATS transport and end-to-end crypto natively;
|
||||
* this class only translates UI intents into binding calls and exposes the
|
||||
* incoming frames as observable state.
|
||||
*
|
||||
* Threading: every binding call that touches the network (newSession, createRoom,
|
||||
* join, publish) runs off the main thread on Dispatchers.IO to avoid
|
||||
* NetworkOnMainThreadException. Incoming frames arrive on a JNI-attached NATS
|
||||
* goroutine via [onFrame]; we only append to a thread-safe StateFlow there, and
|
||||
* Compose collects that flow on the main thread.
|
||||
*/
|
||||
class BusViewModel(app: Application) : AndroidViewModel(app), FrameListener {
|
||||
private val _state = MutableStateFlow(BusState())
|
||||
val state: StateFlow<BusState> = _state.asStateFlow()
|
||||
|
||||
private var session: Session? = null
|
||||
private var myEndpoint: String = ""
|
||||
|
||||
private val idPath: String
|
||||
get() = File(getApplication<Application>().filesDir, "peer.id").absolutePath
|
||||
|
||||
override fun onFrame(roomID: String, sender: String, msgID: String, text: String) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
messages = it.messages + ChatMessage(
|
||||
sender = sender,
|
||||
text = text,
|
||||
mine = sender == myEndpoint,
|
||||
ts = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun connect(host: String, nats: String, peerName: String) {
|
||||
if (_state.value.connecting) return
|
||||
_state.update { it.copy(connecting = true, error = null, status = "Conectando…") }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val s = Mobile.newSession(idPath, nats.trim(), host.trim())
|
||||
session = s
|
||||
myEndpoint = s.endpointID()
|
||||
_state.update {
|
||||
it.copy(
|
||||
connecting = false,
|
||||
connected = true,
|
||||
endpointId = myEndpoint,
|
||||
status = "Conectado como $peerName",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update {
|
||||
it.copy(connecting = false, connected = false, error = e.message ?: "error desconocido")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createRoom(subject: String, encrypted: Boolean) {
|
||||
val s = session ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mode = if (encrypted) "matrix" else "nats"
|
||||
val roomId = s.createRoom(subject.trim(), mode)
|
||||
s.subscribe(roomId, this@BusViewModel)
|
||||
_state.update {
|
||||
it.copy(
|
||||
roomId = roomId,
|
||||
roomSubject = subject.trim(),
|
||||
messages = emptyList(),
|
||||
status = "Room creada",
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = e.message ?: "error al crear room") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun joinRoom(roomId: String) {
|
||||
val s = session ?: return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val rid = roomId.trim()
|
||||
s.join(rid)
|
||||
s.subscribe(rid, this@BusViewModel)
|
||||
_state.update {
|
||||
it.copy(roomId = rid, roomSubject = "(unida)", messages = emptyList(), status = "Unido a la room")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = e.message ?: "error al unirse") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun publish(text: String) {
|
||||
val s = session ?: return
|
||||
val room = _state.value.roomId
|
||||
if (room.isEmpty() || text.isBlank()) return
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
s.publish(room, text)
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = e.message ?: "error al publicar") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** card returns this peer's shareable public identity (no secret). */
|
||||
fun card(): String = try {
|
||||
session?.card() ?: ""
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
|
||||
fun clearError() = _state.update { it.copy(error = null) }
|
||||
|
||||
override fun onCleared() {
|
||||
try {
|
||||
session?.close()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
session = null
|
||||
}
|
||||
}
|
||||
@@ -2,306 +2,62 @@ package com.unibus.app
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.unibus.app.ui.ChatScreen
|
||||
import com.unibus.app.ui.LoginScreen
|
||||
import com.unibus.app.ui.RoomListScreen
|
||||
import com.unibus.app.ui.theme.LocalUnibusColors
|
||||
import com.unibus.app.ui.theme.UnibusColors
|
||||
import com.unibus.app.ui.theme.UnibusTheme
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val vm: BusViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
UnibusApp(vm)
|
||||
UnibusTheme {
|
||||
CompositionLocalProvider(LocalUnibusColors provides UnibusColors()) {
|
||||
UnibusApp()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navegación por estado (sin librería de routing — KISS): el usuario fluye
|
||||
* Login → lista de rooms → chat, igual que la web pero en una sola columna.
|
||||
*/
|
||||
@Composable
|
||||
fun UnibusApp(vm: BusViewModel) {
|
||||
val state by vm.state.collectAsState()
|
||||
if (!state.connected) {
|
||||
ConnectScreen(
|
||||
connecting = state.connecting,
|
||||
error = state.error,
|
||||
onConnect = { host, nats, name -> vm.connect(host, nats, name) },
|
||||
)
|
||||
} else {
|
||||
ChatScreen(state = state, vm = vm)
|
||||
}
|
||||
}
|
||||
private fun UnibusApp(vm: AppViewModel = viewModel()) {
|
||||
val user = vm.user
|
||||
val activeRoom = vm.activeRoom
|
||||
|
||||
@Composable
|
||||
fun ConnectScreen(
|
||||
connecting: Boolean,
|
||||
error: String?,
|
||||
onConnect: (String, String, String) -> Unit,
|
||||
) {
|
||||
var host by rememberSaveable { mutableStateOf("http://10.0.2.2:8470") }
|
||||
var nats by rememberSaveable { mutableStateOf("nats://10.0.2.2:4250") }
|
||||
var name by rememberSaveable { mutableStateOf("android") }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("unibus", style = MaterialTheme.typography.headlineMedium)
|
||||
Text(
|
||||
"chat cifrado extremo a extremo sobre NATS",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
when {
|
||||
user == null -> LoginScreen(
|
||||
connecting = vm.connecting,
|
||||
error = vm.error,
|
||||
onLogin = { handle, password -> vm.connect(handle, password) },
|
||||
)
|
||||
Spacer(Modifier.height(24.dp))
|
||||
OutlinedTextField(
|
||||
value = host,
|
||||
onValueChange = { host = it },
|
||||
label = { Text("Host (control plane)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = nats,
|
||||
onValueChange = { nats = it },
|
||||
label = { Text("NATS (data plane)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("Identidad") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
if (error != null) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Text(error, color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
Button(
|
||||
onClick = { onConnect(host, nats, name) },
|
||||
enabled = !connecting,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (connecting) {
|
||||
CircularProgressIndicator(modifier = Modifier.height(18.dp).width(18.dp), strokeWidth = 2.dp)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(if (connecting) "Conectando…" else "Conectar")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatScreen(state: BusState, vm: BusViewModel) {
|
||||
var subject by rememberSaveable { mutableStateOf("room.general") }
|
||||
var encrypt by rememberSaveable { mutableStateOf(false) }
|
||||
var joinId by rememberSaveable { mutableStateOf("") }
|
||||
var draft by rememberSaveable { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
activeRoom == null -> RoomListScreen(
|
||||
user = user,
|
||||
rooms = vm.rooms,
|
||||
onSelect = { vm.openRoom(it) },
|
||||
onLogout = { vm.logout() },
|
||||
)
|
||||
|
||||
LaunchedEffect(state.messages.size) {
|
||||
if (state.messages.isNotEmpty()) listState.animateScrollToItem(state.messages.size - 1)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text("unibus", style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
state.status.ifEmpty { state.endpointId.take(12) + "…" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
else -> {
|
||||
BackHandler { vm.closeRoom() }
|
||||
ChatScreen(
|
||||
room = activeRoom,
|
||||
messages = vm.messages,
|
||||
onSend = { vm.send(it) },
|
||||
onBack = { vm.closeRoom() },
|
||||
)
|
||||
},
|
||||
) { inner ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(inner)
|
||||
.padding(horizontal = 12.dp),
|
||||
) {
|
||||
// Room controls.
|
||||
Card(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = subject,
|
||||
onValueChange = { subject = it },
|
||||
label = { Text("subject") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = { vm.createRoom(subject, encrypt) }) {
|
||||
Icon(Icons.Filled.Add, contentDescription = "crear")
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Switch(checked = encrypt, onCheckedChange = { encrypt = it })
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Icon(Icons.Filled.Lock, contentDescription = null, modifier = Modifier.height(16.dp))
|
||||
Text("cifrar (E2E)", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = joinId,
|
||||
onValueChange = { joinId = it },
|
||||
label = { Text("unirse por room id") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton(onClick = { if (joinId.isNotBlank()) vm.joinRoom(joinId) }) {
|
||||
Text("Unir")
|
||||
}
|
||||
}
|
||||
if (state.roomId.isNotEmpty()) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
"room: ${state.roomSubject} · ${state.roomId}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
state.error,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Messages.
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
itemsIndexed(state.messages, key = { i, m -> "${m.ts}-$i" }) { _, m ->
|
||||
MessageBubble(m)
|
||||
}
|
||||
}
|
||||
|
||||
// Composer.
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Mensaje…") },
|
||||
singleLine = true,
|
||||
enabled = state.roomId.isNotEmpty(),
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = {
|
||||
vm.publish(draft)
|
||||
draft = ""
|
||||
},
|
||||
enabled = state.roomId.isNotEmpty() && draft.isNotBlank(),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "enviar")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val timeFmt = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
|
||||
|
||||
@Composable
|
||||
fun MessageBubble(m: ChatMessage) {
|
||||
val align = if (m.mine) Alignment.End else Alignment.Start
|
||||
Column(modifier = Modifier.fillMaxWidth(), horizontalAlignment = align) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(0.8f),
|
||||
) {
|
||||
Column(Modifier.padding(8.dp)) {
|
||||
if (!m.mine) {
|
||||
Text(
|
||||
m.sender.take(12) + "…",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
Text(m.text, style = MaterialTheme.typography.bodyMedium)
|
||||
Text(
|
||||
timeFmt.format(Date(m.ts)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.unibus.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.unibus.core.mobile.FrameListener
|
||||
import com.unibus.core.mobile.Mobile
|
||||
import com.unibus.core.mobile.Session
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Implementación real sobre el binding gomobile (pkg/client): cifrado de extremo
|
||||
* a extremo EN el dispositivo, igual que cualquier otro peer del bus. Comparte
|
||||
* interfaz con [MockUnibusRepository], así que la UI no cambia al enchufarla.
|
||||
*
|
||||
* Estado: cableado completo y compilable contra unibus.aar. La iteración 1 de la
|
||||
* app corre sobre el mock para iterar el diseño; para activar el bus real basta
|
||||
* con instanciar este repo en [com.unibus.app.MainActivity] pasando las URLs del
|
||||
* bus y (si el bus exige TLS+auth) el ca.crt en assets.
|
||||
*
|
||||
* Contrato de membresía (issue 0006e): tras CreateRoom / Join / Invite hay que
|
||||
* llamar [refresh] ANTES de subscribe/publish en esa room, o un bus seguro
|
||||
* deniega el subject. refresh() además tira las suscripciones: re-suscribir luego.
|
||||
*/
|
||||
class BindingUnibusRepository(
|
||||
context: Context,
|
||||
private val natsURL: String,
|
||||
private val ctrlURL: String,
|
||||
) : UnibusRepository {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private var session: Session? = null
|
||||
private var user: User? = null
|
||||
|
||||
@Serializable
|
||||
private data class RoomDTO(
|
||||
val room_id: String,
|
||||
val subject: String,
|
||||
val epoch: Int = 0,
|
||||
val encrypted: Boolean = false,
|
||||
val role: String = "",
|
||||
)
|
||||
|
||||
/** Ruta sandbox de la identidad de larga duración (claves privadas). */
|
||||
private fun identityPath(): String =
|
||||
File(appContext.filesDir, "identity.key").absolutePath
|
||||
|
||||
/**
|
||||
* Copia ca.crt de assets a un fichero local y devuelve su ruta; "" si no hay
|
||||
* (bus de desarrollo en texto plano). El binding pinea TLS a este CA cuando
|
||||
* la ruta no está vacía.
|
||||
*/
|
||||
private fun caPathOrEmpty(): String {
|
||||
return try {
|
||||
val out = File(appContext.filesDir, "ca.crt")
|
||||
appContext.assets.open("ca.crt").use { input ->
|
||||
out.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
out.absolutePath
|
||||
} catch (_: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun connect(handle: String, password: String): Result<User> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// La identidad se persiste cifrada en el sandbox; password la
|
||||
// desbloquea en una iteración futura (hoy LoadOrCreateIdentity la
|
||||
// crea/lee directamente). handle es la etiqueta visible local.
|
||||
Mobile.generateIdentity(identityPath())
|
||||
val s = Mobile.newSession(identityPath(), natsURL, ctrlURL, caPathOrEmpty())
|
||||
session = s
|
||||
val u = User(id = s.endpointID(), handle = handle)
|
||||
user = u
|
||||
Result.success(u)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun listRooms(): List<Room> = withContext(Dispatchers.IO) {
|
||||
val s = session ?: return@withContext emptyList()
|
||||
val raw = runCatching { s.listRoomsJSON() }.getOrDefault("[]")
|
||||
val dtos = runCatching { json.decodeFromString<List<RoomDTO>>(raw) }.getOrDefault(emptyList())
|
||||
dtos.map {
|
||||
Room(
|
||||
id = it.room_id,
|
||||
name = it.subject,
|
||||
encrypted = it.encrypted,
|
||||
lastMessage = "",
|
||||
lastTs = System.currentTimeMillis(),
|
||||
unread = 0,
|
||||
messages = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun messagesOf(roomId: String): List<Message> = emptyList()
|
||||
|
||||
override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
|
||||
val s = session ?: return
|
||||
val myId = user?.id
|
||||
// FrameListener.onFrame llega en una goroutine de NATS: saltamos al hilo
|
||||
// principal antes de tocar estado de Compose.
|
||||
val listener = object : FrameListener {
|
||||
override fun onFrame(rid: String, sender: String, msgID: String, text: String) {
|
||||
val msg = Message(
|
||||
id = msgID,
|
||||
sender = sender,
|
||||
body = text,
|
||||
ts = System.currentTimeMillis(),
|
||||
mine = sender == myId,
|
||||
)
|
||||
mainHandler.post { onMessage(msg) }
|
||||
}
|
||||
}
|
||||
runCatching { s.subscribe(roomId, listener) }
|
||||
}
|
||||
|
||||
override suspend fun send(roomId: String, text: String): Result<Message> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val s = session ?: return@withContext Result.failure(IllegalStateException("sin sesión"))
|
||||
try {
|
||||
s.publish(roomId, text)
|
||||
Result.success(
|
||||
Message(
|
||||
id = "local-${System.currentTimeMillis()}",
|
||||
sender = user?.id ?: "yo",
|
||||
body = text,
|
||||
ts = System.currentTimeMillis(),
|
||||
mine = true,
|
||||
),
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reaplica permisos tras un cambio de membresía. Re-suscribir después. */
|
||||
suspend fun refresh(): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching { session?.refreshSession(); Unit }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { session?.close() }
|
||||
session = null
|
||||
user = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.unibus.app.data
|
||||
|
||||
// Datos de muestra para iterar el diseño sin el bus conectado (espejo de mock.ts).
|
||||
private const val NOW = 1749300000000L
|
||||
private fun m(n: Int): Long = NOW - n * 60_000L
|
||||
|
||||
val MOCK_ROOMS: List<Room> = listOf(
|
||||
Room(
|
||||
id = "general",
|
||||
name = "general",
|
||||
encrypted = true,
|
||||
lastMessage = "¿Lo desplegamos hoy?",
|
||||
lastTs = m(2),
|
||||
unread = 3,
|
||||
messages = listOf(
|
||||
Message("1", "ana", "Buenas, ¿cómo va el cluster?", m(40)),
|
||||
Message("2", "lucas", "Los 3 nodos en R3, quorum verde", m(38), mine = true),
|
||||
Message("3", "ana", "Brutal. ¿Y el frontend?", m(30)),
|
||||
Message("4", "leo", "Primera iteración lista, estilo Element", m(6)),
|
||||
Message("5", "ana", "¿Lo desplegamos hoy?", m(2)),
|
||||
),
|
||||
),
|
||||
Room(
|
||||
id = "board",
|
||||
name = "board · privado",
|
||||
encrypted = true,
|
||||
lastMessage = "Os paso el acta cifrada",
|
||||
lastTs = m(95),
|
||||
unread = 0,
|
||||
messages = listOf(
|
||||
Message("1", "ceo", "Reunión a las 18:00", m(120)),
|
||||
Message("2", "lucas", "Anotado", m(96), mine = true),
|
||||
Message("3", "ceo", "Os paso el acta cifrada", m(95)),
|
||||
),
|
||||
),
|
||||
Room(
|
||||
id = "bots",
|
||||
name = "bots",
|
||||
encrypted = false,
|
||||
lastMessage = "echo: ping",
|
||||
lastTs = m(210),
|
||||
unread = 0,
|
||||
messages = listOf(
|
||||
Message("1", "lucas", "!ping", m(212), mine = true),
|
||||
Message("2", "echobot", "echo: ping", m(210)),
|
||||
),
|
||||
),
|
||||
Room(
|
||||
id = "infra",
|
||||
name = "infra",
|
||||
encrypted = true,
|
||||
lastMessage = "magnus + homer + datardos OK",
|
||||
lastTs = m(330),
|
||||
unread = 1,
|
||||
messages = listOf(
|
||||
Message("1", "leo", "magnus + homer + datardos OK", m(330)),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.unibus.app.data
|
||||
|
||||
/**
|
||||
* Modelos de dominio de la UI. En la iteración 1 se llenan con datos mock; más
|
||||
* adelante vendrán del binding gomobile (pkg/client) a través de
|
||||
* [UnibusRepository]. Reflejan los tipos de la app web (types.ts).
|
||||
*/
|
||||
|
||||
data class User(
|
||||
val id: String,
|
||||
val handle: String,
|
||||
)
|
||||
|
||||
data class Message(
|
||||
val id: String,
|
||||
val sender: String, // handle
|
||||
val body: String,
|
||||
val ts: Long, // epoch ms
|
||||
val mine: Boolean = false,
|
||||
)
|
||||
|
||||
data class Room(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val encrypted: Boolean,
|
||||
val lastMessage: String,
|
||||
val lastTs: Long,
|
||||
val unread: Int,
|
||||
val messages: List<Message>,
|
||||
)
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.unibus.app.data
|
||||
|
||||
/**
|
||||
* Capa de repositorio que aísla la UI de la fuente de datos. La iteración 1 usa
|
||||
* [MockUnibusRepository] (en memoria) para iterar el diseño. Cuando se enchufe
|
||||
* el bus real, [BindingUnibusRepository] (en BindingRepository.kt) implementa
|
||||
* esta misma interfaz sobre el binding gomobile (pkg/client), sin tocar la UI.
|
||||
*/
|
||||
interface UnibusRepository {
|
||||
/** Desbloquea/crea la identidad y conecta al bus. Devuelve el usuario logueado. */
|
||||
suspend fun connect(handle: String, password: String): Result<User>
|
||||
|
||||
/** Rooms a las que pertenece el peer. */
|
||||
suspend fun listRooms(): List<Room>
|
||||
|
||||
/** Mensajes históricos conocidos de una room (mock: los del propio Room). */
|
||||
fun messagesOf(roomId: String): List<Message>
|
||||
|
||||
/**
|
||||
* Suscribe a una room. [onMessage] se invoca por cada mensaje entrante.
|
||||
* Las implementaciones que vienen del bus DEBEN entregar [onMessage] en el
|
||||
* hilo principal (el binding lo recibe en una goroutine de NATS).
|
||||
*/
|
||||
fun subscribe(roomId: String, onMessage: (Message) -> Unit)
|
||||
|
||||
/** Publica texto en la room. */
|
||||
suspend fun send(roomId: String, text: String): Result<Message>
|
||||
|
||||
/** Cierra la sesión. */
|
||||
fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementación en memoria: arranca con [MOCK_ROOMS] y acumula los mensajes que
|
||||
* el usuario envía. No toca red ni binding — sirve para construir y revisar la UI.
|
||||
*/
|
||||
class MockUnibusRepository : UnibusRepository {
|
||||
private var user: User? = null
|
||||
private val sent = mutableMapOf<String, MutableList<Message>>()
|
||||
|
||||
override suspend fun connect(handle: String, password: String): Result<User> {
|
||||
val u = User(id = handle, handle = handle)
|
||||
user = u
|
||||
return Result.success(u)
|
||||
}
|
||||
|
||||
override suspend fun listRooms(): List<Room> = MOCK_ROOMS
|
||||
|
||||
override fun messagesOf(roomId: String): List<Message> {
|
||||
val base = MOCK_ROOMS.firstOrNull { it.id == roomId }?.messages.orEmpty()
|
||||
return base + (sent[roomId].orEmpty())
|
||||
}
|
||||
|
||||
override fun subscribe(roomId: String, onMessage: (Message) -> Unit) {
|
||||
// El mock no recibe tráfico entrante; el eco lo gestiona la UI al enviar.
|
||||
}
|
||||
|
||||
override suspend fun send(roomId: String, text: String): Result<Message> {
|
||||
val handle = user?.handle ?: "yo"
|
||||
val msg = Message(
|
||||
id = "local-${System.currentTimeMillis()}",
|
||||
sender = handle,
|
||||
body = text,
|
||||
ts = System.currentTimeMillis(),
|
||||
mine = true,
|
||||
)
|
||||
sent.getOrPut(roomId) { mutableListOf() }.add(msg)
|
||||
return Result.success(msg)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
user = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package com.unibus.app.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.unibus.app.data.Message
|
||||
import com.unibus.app.data.Room
|
||||
import com.unibus.app.ui.theme.Brand3
|
||||
import com.unibus.app.ui.theme.LocalUnibusColors
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
room: Room,
|
||||
messages: List<Message>,
|
||||
onSend: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val colors = LocalUnibusColors.current
|
||||
var draft by remember { mutableStateOf("") }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(messages.size, room.id) {
|
||||
if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.chatBg),
|
||||
) {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Atrás", tint = Color.White)
|
||||
}
|
||||
InitialsAvatar(room.name, size = 38.dp, rounded = true, accent = true)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
room.name,
|
||||
fontWeight = FontWeight(650),
|
||||
fontSize = 16.sp,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Icon(
|
||||
if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
|
||||
contentDescription = null,
|
||||
tint = colors.dimmed,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(14.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (room.encrypted) "cifrada · E2E" else "abierta · cleartext",
|
||||
color = colors.dimmed,
|
||||
fontSize = 11.sp,
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { /* opciones de room (futuro) */ }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = "Opciones", tint = colors.dimmed)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = colors.divider)
|
||||
|
||||
// Mensajes
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(messages, key = { it.id }) { msg -> MessageRow(msg) }
|
||||
}
|
||||
|
||||
HorizontalDivider(color = colors.divider)
|
||||
|
||||
// Composer
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { /* adjuntar (futuro) */ }) {
|
||||
Icon(Icons.Filled.AttachFile, contentDescription = "Adjuntar", tint = colors.dimmed)
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = draft,
|
||||
onValueChange = { draft = it },
|
||||
placeholder = { Text("Mensaje a ${room.name}") },
|
||||
singleLine = true,
|
||||
shape = CircleShape,
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = colors.field,
|
||||
unfocusedContainerColor = colors.field,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
keyboardOptions = KeyboardOptions(imeAction = androidx.compose.ui.text.input.ImeAction.Send),
|
||||
keyboardActions = KeyboardActions(onSend = {
|
||||
if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" }
|
||||
}),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.size(46.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (draft.trim().isEmpty()) colors.field else colors.brand),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { if (draft.trim().isNotEmpty()) { onSend(draft); draft = "" } },
|
||||
enabled = draft.trim().isNotEmpty(),
|
||||
) {
|
||||
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Enviar", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageRow(msg: Message) {
|
||||
val colors = LocalUnibusColors.current
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
InitialsAvatar(msg.sender, size = 36.dp, rounded = false, accent = msg.mine)
|
||||
Column(modifier = Modifier.padding(start = 10.dp)) {
|
||||
Row(verticalAlignment = Alignment.Bottom) {
|
||||
Text(
|
||||
msg.sender,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
color = if (msg.mine) Brand3 else Color.White,
|
||||
)
|
||||
Text(
|
||||
timeShort(msg.ts),
|
||||
color = colors.dimmed,
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
msg.body,
|
||||
fontSize = 14.sp,
|
||||
color = com.unibus.app.ui.theme.OnSurface,
|
||||
modifier = Modifier.padding(top = 1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.unibus.app.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.unibus.app.ui.theme.Brand5
|
||||
|
||||
/**
|
||||
* Avatar con iniciales, equivalente al <Avatar> de la web. [rounded] = esquinas
|
||||
* (rooms/chat header) vs círculo (usuarios). [accent] colorea el de marca.
|
||||
*/
|
||||
@Composable
|
||||
fun InitialsAvatar(
|
||||
text: String,
|
||||
size: Dp = 42.dp,
|
||||
rounded: Boolean = true,
|
||||
accent: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val shape = if (rounded) RoundedCornerShape((size.value * 0.28f).dp) else CircleShape
|
||||
val bg = if (accent) Brand5 else Color(0xFF3A3D44) // gris neutro tipo Avatar color="gray"
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.clip(shape)
|
||||
.background(bg),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = initials(text),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = (size.value * 0.36f).sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package com.unibus.app.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.VpnKey
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.unibus.app.ui.theme.Brand4
|
||||
import com.unibus.app.ui.theme.Dark7
|
||||
import com.unibus.app.ui.theme.Dark9
|
||||
import com.unibus.app.ui.theme.LocalUnibusColors
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
connecting: Boolean,
|
||||
error: String?,
|
||||
onLogin: (handle: String, password: String) -> Unit,
|
||||
) {
|
||||
val colors = LocalUnibusColors.current
|
||||
var handle by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
val ready = handle.trim().isNotEmpty() && password.isNotEmpty() && !connecting
|
||||
|
||||
fun submit() {
|
||||
if (ready) onLogin(handle.trim(), password)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Dark9),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.padding(24.dp)
|
||||
.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Dark7),
|
||||
shape = androidx.compose.foundation.shape.RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
// ThemeIcon "light brand" — círculo translúcido con candado.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(60.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Brand4.copy(alpha = 0.18f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Lock,
|
||||
contentDescription = null,
|
||||
tint = Brand4,
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("unibus", fontSize = 26.sp, color = Brand4)
|
||||
Text(
|
||||
"Mensajería cifrada de extremo a extremo",
|
||||
color = colors.dimmed,
|
||||
fontSize = 13.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = handle,
|
||||
onValueChange = { handle = it },
|
||||
label = { Text("Identidad") },
|
||||
placeholder = { Text("tu-handle") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text("Contraseña") },
|
||||
placeholder = { Text("••••••••") },
|
||||
singleLine = true,
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
leadingIcon = { Icon(Icons.Filled.VpnKey, contentDescription = null) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
|
||||
keyboardActions = KeyboardActions(onGo = { submit() }),
|
||||
)
|
||||
Text(
|
||||
"Desbloquea tu identidad cifrada en este dispositivo",
|
||||
color = colors.dimmed,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
if (error != null) {
|
||||
Text(error, color = androidx.compose.ui.graphics.Color(0xFFFF6B6B), fontSize = 13.sp)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = { submit() },
|
||||
enabled = ready,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
if (connecting) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(18.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
)
|
||||
} else {
|
||||
Text("Conectar")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package com.unibus.app.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Tag
|
||||
import androidx.compose.material3.Badge
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.unibus.app.data.Room
|
||||
import com.unibus.app.data.User
|
||||
import com.unibus.app.ui.theme.LocalUnibusColors
|
||||
|
||||
@Composable
|
||||
fun RoomListScreen(
|
||||
user: User,
|
||||
rooms: List<Room>,
|
||||
onSelect: (String) -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
) {
|
||||
val colors = LocalUnibusColors.current
|
||||
var query by remember { mutableStateOf("") }
|
||||
val q = query.trim().lowercase()
|
||||
val filtered = if (q.isEmpty()) rooms else rooms.filter {
|
||||
it.name.lowercase().contains(q) || it.messages.any { m -> m.body.lowercase().contains(q) }
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(colors.sidebarBg),
|
||||
) {
|
||||
// Header: avatar + handle + menú
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
InitialsAvatar(user.handle, size = 36.dp, rounded = false, accent = true)
|
||||
Text(
|
||||
user.handle,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 15.sp,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 10.dp),
|
||||
)
|
||||
var menuOpen by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
IconButton(onClick = { menuOpen = true }) {
|
||||
Icon(Icons.Filled.MoreVert, contentDescription = "Menú", tint = colors.dimmed)
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("Desconectar") },
|
||||
onClick = { menuOpen = false; onLogout() },
|
||||
leadingIcon = {
|
||||
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buscador
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Buscar rooms, usuarios, mensajes…") },
|
||||
leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null, modifier = Modifier.size(18.dp)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
)
|
||||
|
||||
HorizontalDivider(color = colors.divider)
|
||||
|
||||
if (filtered.isEmpty()) {
|
||||
Text(
|
||||
"Sin resultados",
|
||||
color = colors.dimmed,
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items(filtered, key = { it.id }) { room ->
|
||||
RoomItem(room = room, onClick = { onSelect(room.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RoomItem(room: Room, onClick: () -> Unit) {
|
||||
val colors = LocalUnibusColors.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.clickable(onClick = onClick)
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
InitialsAvatar(room.name, size = 46.dp, rounded = true)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 10.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
if (room.encrypted) Icons.Filled.Lock else Icons.Filled.Tag,
|
||||
contentDescription = if (room.encrypted) "cifrada" else "abierta",
|
||||
tint = colors.dimmed,
|
||||
modifier = Modifier.size(13.dp),
|
||||
)
|
||||
Text(
|
||||
room.name,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 4.dp),
|
||||
)
|
||||
Text(timeShort(room.lastTs), color = colors.dimmed, fontSize = 11.sp)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
room.lastMessage,
|
||||
color = colors.dimmed,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (room.unread > 0) {
|
||||
Badge(
|
||||
containerColor = colors.brand,
|
||||
contentColor = Color.White,
|
||||
) { Text(room.unread.toString()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.unibus.app.ui
|
||||
|
||||
import java.util.Calendar
|
||||
|
||||
/** Iniciales (hasta 2 letras/dígitos) para los avatares, igual que la web. */
|
||||
fun initials(s: String): String {
|
||||
val cleaned = s.filter { it.isLetterOrDigit() }
|
||||
return if (cleaned.isEmpty()) "?" else cleaned.take(2).uppercase()
|
||||
}
|
||||
|
||||
/** Hora corta HH:mm a partir de epoch ms. */
|
||||
fun timeShort(ts: Long): String {
|
||||
val c = Calendar.getInstance().apply { timeInMillis = ts }
|
||||
val h = c.get(Calendar.HOUR_OF_DAY).toString().padStart(2, '0')
|
||||
val min = c.get(Calendar.MINUTE).toString().padStart(2, '0')
|
||||
return "$h:$min"
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.unibus.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Typography
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// --- Brand: índigo/violeta de unibus (mismos tonos que el tema Mantine de la web) ---
|
||||
val Brand2 = Color(0xFFB5A3F5) // brand.2
|
||||
val Brand3 = Color(0xFF8D70ED) // brand.3 — legible sobre fondo oscuro
|
||||
val Brand4 = Color(0xFF6C47E6) // brand.4 — acento principal
|
||||
val Brand5 = Color(0xFF5A2FE2) // brand.5 — filled
|
||||
|
||||
// --- Grises oscuros equivalentes a la escala dark.* de Mantine ---
|
||||
val Dark9 = Color(0xFF101113) // fondo de la app (login)
|
||||
val Dark8 = Color(0xFF141517) // sidebar / lista de rooms
|
||||
val Dark7 = Color(0xFF1A1B1E) // panel de chat / superficie
|
||||
val Dark6 = Color(0xFF25262B) // item activo / elevado
|
||||
val Dark5 = Color(0xFF2C2E33) // campos de entrada
|
||||
val Dark4 = Color(0xFF373A40) // bordes / divisores
|
||||
val Dimmed = Color(0xFF909296) // texto secundario
|
||||
val OnSurface = Color(0xFFE3E3E6) // texto principal
|
||||
|
||||
/**
|
||||
* Tokens de color que Material 3 no expresa directamente y que la UI replica de
|
||||
* la web (matices dark.6/7/8/9, color "dimmed", borde). Se exponen vía un
|
||||
* CompositionLocal para que cualquier composable los lea sin prop-drilling.
|
||||
*/
|
||||
data class UnibusColors(
|
||||
val appBg: Color = Dark9,
|
||||
val sidebarBg: Color = Dark8,
|
||||
val chatBg: Color = Dark7,
|
||||
val activeItem: Color = Dark6,
|
||||
val field: Color = Dark5,
|
||||
val divider: Color = Dark4,
|
||||
val dimmed: Color = Dimmed,
|
||||
val brand: Color = Brand4,
|
||||
)
|
||||
|
||||
val LocalUnibusColors = staticCompositionLocalOf { UnibusColors() }
|
||||
|
||||
private val UnibusDarkScheme = darkColorScheme(
|
||||
primary = Brand4,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = Brand5,
|
||||
onPrimaryContainer = Color.White,
|
||||
secondary = Brand3,
|
||||
background = Dark9,
|
||||
onBackground = OnSurface,
|
||||
surface = Dark7,
|
||||
onSurface = OnSurface,
|
||||
surfaceVariant = Dark6,
|
||||
onSurfaceVariant = Dimmed,
|
||||
outline = Dark4,
|
||||
error = Color(0xFFFF6B6B),
|
||||
)
|
||||
|
||||
private val UnibusTypography = Typography(
|
||||
titleLarge = Typography().titleLarge.copy(fontWeight = FontWeight(650)),
|
||||
titleMedium = Typography().titleMedium.copy(fontWeight = FontWeight(650)),
|
||||
bodyMedium = Typography().bodyMedium.copy(fontSize = 14.sp),
|
||||
labelLarge = Typography().labelLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun UnibusTheme(content: @Composable () -> Unit) {
|
||||
// unibus es dark-first; ignoramos el modo del sistema a propósito.
|
||||
@Suppress("UNUSED_EXPRESSION")
|
||||
isSystemInDarkTheme()
|
||||
MaterialTheme(
|
||||
colorScheme = UnibusDarkScheme,
|
||||
typography = UnibusTypography,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Material "lock" glyph, white, centered in the adaptive-icon safe zone.
|
||||
24dp source scaled x3 (=72dp) and translated by 18 to center it. -->
|
||||
<group
|
||||
android:scaleX="3"
|
||||
android:scaleY="3"
|
||||
android:translateX="18"
|
||||
android:translateY="18">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,17c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM18,8h-1V6c0,-2.76 -2.24,-5 -5,-5S7,3.24 7,6v2H6c-1.1,0 -2,0.9 -2,2v10c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V10c0,-1.1 -0.9,-2 -2,-2zM9,6c0,-1.66 1.34,-3 3,-3s3,1.34 3,3v2H9V6z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/unibus_brand" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/unibus_brand" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- dark.9 — app background -->
|
||||
<color name="unibus_bg">#101113</color>
|
||||
<!-- brand.5 — índigo/violeta accent, used as launcher icon background -->
|
||||
<color name="unibus_brand">#5A2FE2</color>
|
||||
</resources>
|
||||
@@ -1,6 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- A minimal Material3 base theme; the real UI styling is driven by Compose
|
||||
Material3 (MaterialTheme) at runtime. -->
|
||||
<style name="Theme.Unibus" parent="android:Theme.Material.NoActionBar" />
|
||||
<!-- Compose-only host theme: no action bar, dark window background matching
|
||||
the app's dark.9 surface so there is no white flash before Compose draws. -->
|
||||
<style name="Theme.Unibus" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowBackground">@color/unibus_bg</item>
|
||||
<item name="android:statusBarColor">@color/unibus_bg</item>
|
||||
<item name="android:navigationBarColor">@color/unibus_bg</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// Top-level build file. Plugin versions are declared here and applied in the
|
||||
// module build scripts. AGP 8.5 + Kotlin 2.0 (with the dedicated Compose
|
||||
// compiler plugin) target the locally installed SDK (compileSdk 34).
|
||||
plugins {
|
||||
id("com.android.application") version "8.5.2" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
|
||||
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.24" apply false
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.caching=true
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.code.style=official
|
||||
org.gradle.caching=true
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
Vendored
+2
-5
@@ -15,8 +15,6 @@
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
@@ -57,7 +55,7 @@
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
@@ -86,8 +84,7 @@ done
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
|
||||
' "$PWD" ) || exit
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
Vendored
-2
@@ -13,8 +13,6 @@
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
|
||||
@@ -11,6 +11,7 @@ pluginManagement {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: unibus
|
||||
lang: go
|
||||
domain: infra
|
||||
version: 0.7.0
|
||||
version: 0.8.0
|
||||
description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo."
|
||||
tags: [service, messaging, nats, e2e]
|
||||
uses_functions:
|
||||
@@ -154,6 +154,30 @@ agent.<nombre>.{in,out} inbox/outbox de agente LLM (agent.scout.in)
|
||||
|
||||
## Capability growth log
|
||||
|
||||
- v0.8.0 (2026-06-07) — completar y endurecer el cluster (issue 0006, fases
|
||||
0006a–0006g) que cierra los bloqueantes de la auditoría dedicada del cluster
|
||||
(report 0008) y cablea el control plane descentralizado que 0003 dejó a medias.
|
||||
(0006a) Se cablea el nonce replicado en el binario: un nodo con `--cluster-name`
|
||||
usa el bucket JetStream KV compartido obligatoriamente (fail-fast si no se crea),
|
||||
cerrando el replay cross-node (N3); el "ciclo bootstrap" se resuelve con una
|
||||
identidad interna efímera que el authenticator reconoce (full perms) y una
|
||||
conexión in-process privilegiada. (0006b) Se cierra la fuga del control plane
|
||||
por `$JS.API.>` (N2): la ACL pasa a un allow-set cerrado por-room (JS API solo de
|
||||
los streams `UNIBUS_<room>` del peer), dejando `KV_UNIBUS_*`/`OBJ_*` fuera del
|
||||
set y, por tanto, denegados. (0006c) Se cablea el store KV descentralizado
|
||||
(`--store kv|sqlite`, default sqlite = baseline idéntico) con un `storeHolder`
|
||||
fail-closed que rompe el ciclo bootstrap del authenticator. (0006d) Posture
|
||||
homogénea: un nodo rechaza unirse al cluster sin `enforce`, y `/healthz` publica
|
||||
la posture (N1). (0006e) Todos los clientes llaman `RefreshSession` tras cambios
|
||||
de membresía (N4), de modo que la ACL es usable bajo enforce sin desactivarla.
|
||||
(0006f) Bajos: secreto de cluster fuera de argv (`--cluster-pass-file`/env +
|
||||
inyección en routes), `migrate-to-kv` rechaza target remoto sin `--ca`, y docs
|
||||
de CA separada para routes + R1 SPOF vs R3 HA. (0006g) Material de deploy del
|
||||
cluster de 3 nodos (magnus+homer+datardos) en `deploy/cluster/` (certs, unit,
|
||||
script de despliegue dry-run, runbook) — sin tocar ningún VPS. Toda la
|
||||
regresión de auditorías previas + los ataques 0008 siguen verdes; govulncheck 0
|
||||
alcanzables. Branch-by-abstraction: con `--store sqlite` el single-node sigue
|
||||
idéntico y desplegable en todo momento.
|
||||
- v0.7.0 (2026-06-07) — hardening de seguridad 2 (issue 0005, fases 0005a–0005e)
|
||||
que cierra los hallazgos nuevos de la re-auditoría red-team (report 0006) y
|
||||
lleva el veredicto de exposición pública a "sí-con-condiciones". (0005a) Bump de
|
||||
|
||||
@@ -69,6 +69,12 @@ func runSimple(natsURL, ctrlURL, roomSub, idFile, caFile string) {
|
||||
if err := c.Join(roomID); err != nil {
|
||||
log.Fatalf("join: %v", err)
|
||||
}
|
||||
// Membership-change contract (issue 0006e): refresh so the just-created room's
|
||||
// subject is subscribable under enforce+ACL (permissions are frozen at connect
|
||||
// time). Must run BEFORE Subscribe — RefreshSession drops active subscriptions.
|
||||
if err := c.RefreshSession(); err != nil {
|
||||
log.Fatalf("refresh session after create room: %v", err)
|
||||
}
|
||||
sub, err := c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
||||
fmt.Printf("[%s] %s: %s\n", f.Subject, shortID(f.Sender), string(plaintext))
|
||||
})
|
||||
@@ -122,12 +128,21 @@ func runEncryptedDemo(natsURL, ctrlURL, caFile string) {
|
||||
must(err, "A create room")
|
||||
fmt.Printf(" room.test -> %s (E2E, persisted, signed)\n", roomID)
|
||||
|
||||
// Membership-change contract (issue 0006e): A only became a member of this room
|
||||
// after connecting, so refresh to gain its subject + per-room JetStream API
|
||||
// under enforce+ACL before publishing.
|
||||
must(a.RefreshSession(), "A refresh after create room")
|
||||
|
||||
// A invites B (seals K to B's X25519 key).
|
||||
must(a.Invite(roomID, b.Endpoint()), "A invite B")
|
||||
|
||||
// B joins (fetches + decrypts K).
|
||||
must(b.Join(roomID), "B join")
|
||||
|
||||
// B became a member via the invite above; refresh so B can subscribe to the
|
||||
// room's subject under enforce+ACL (before subscribing — refresh drops subs).
|
||||
must(b.RefreshSession(), "B refresh after join")
|
||||
|
||||
// B subscribes; capture received plaintexts.
|
||||
recv := make(chan string, 4)
|
||||
subB, err := b.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
// Regression for audit report 0008, vector N3: the binary must wire the
|
||||
// replicated nonce store on a clustered node so a signed request accepted on one
|
||||
// node cannot be replayed to another. The auditor's ephemeral attack showed the
|
||||
// OLD binary never called UseReplicatedNonces (each node kept a per-process
|
||||
// cache), so a captured request replayed to a second node with 200+200. These
|
||||
// tests drive the SAME helper the binary uses (wireReplicatedNonces) so they
|
||||
// prove the WIRING, not just the underlying API.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("free port: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// signed008 builds a transport-signed control-plane request with a caller-chosen
|
||||
// ts+nonce, so a test can reuse the exact same signed bytes against two nodes to
|
||||
// exercise replay.
|
||||
func signed008(t *testing.T, baseURL, method, path string, body []byte, id cs.Identity, ts int64, nonce string) *http.Request {
|
||||
t.Helper()
|
||||
canonical := membership.CanonicalRequest(method, path, strconv.FormatInt(ts, 10), nonce, body)
|
||||
sig := cs.SignEd25519(id.SignPriv, canonical)
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, baseURL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatalf("new request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Unibus-Pub", hex.EncodeToString(id.SignPub))
|
||||
req.Header.Set("X-Unibus-Ts", strconv.FormatInt(ts, 10))
|
||||
req.Header.Set("X-Unibus-Nonce", nonce)
|
||||
req.Header.Set("X-Unibus-Sig", base64.StdEncoding.EncodeToString(sig))
|
||||
return req
|
||||
}
|
||||
|
||||
func randNonce(t *testing.T) string {
|
||||
t.Helper()
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
t.Fatalf("nonce: %v", err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(raw)
|
||||
}
|
||||
|
||||
// TestAttack0008_N3 is the blocker regression: two clustered membershipd nodes
|
||||
// wired through wireReplicatedNonces share a JetStream KV nonce bucket, so a
|
||||
// request accepted on node A is rejected (401) when replayed to node B. Before
|
||||
// the fix the binary never wired this and the replay returned 200.
|
||||
func TestAttack0008_N3(t *testing.T) {
|
||||
// One NATS+JetStream backing the shared nonce bucket (no client auth needed:
|
||||
// the test drives the membership.Server's nonce store directly via HTTP).
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nats: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatalf("jetstream: %v", err)
|
||||
}
|
||||
|
||||
// Shared control-plane state (stand-in for the replicated store) + two nodes.
|
||||
dir := t.TempDir()
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
alice, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("identity: %v", err)
|
||||
}
|
||||
if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleAdmin); err != nil {
|
||||
t.Fatalf("add alice: %v", err)
|
||||
}
|
||||
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
||||
|
||||
// Each node is wired EXACTLY as the binary wires a clustered node.
|
||||
mkNode := func() *httptest.Server {
|
||||
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
|
||||
if err := wireReplicatedNonces(srv, js, true /*clustered*/, 1); err != nil {
|
||||
t.Fatalf("wireReplicatedNonces: %v", err)
|
||||
}
|
||||
return httptest.NewServer(srv)
|
||||
}
|
||||
nodeA := mkNode()
|
||||
t.Cleanup(nodeA.Close)
|
||||
nodeB := mkNode()
|
||||
t.Cleanup(nodeB.Close)
|
||||
|
||||
ts := time.Now().Unix()
|
||||
nonce := randNonce(t)
|
||||
path := "/members/" + frame.EndpointID(alice.SignPub) + "/rooms"
|
||||
|
||||
// Golden: alice's signed request is accepted on node A.
|
||||
respA, err := http.DefaultClient.Do(signed008(t, nodeA.URL, "GET", path, nil, alice, ts, nonce))
|
||||
if err != nil {
|
||||
t.Fatalf("do A: %v", err)
|
||||
}
|
||||
respA.Body.Close()
|
||||
if respA.StatusCode != http.StatusOK {
|
||||
t.Fatalf("node A first use: status %d, want 200", respA.StatusCode)
|
||||
}
|
||||
|
||||
// Error path (the attack): replay the SAME signed bytes to node B → 401.
|
||||
respB, err := http.DefaultClient.Do(signed008(t, nodeB.URL, "GET", path, nil, alice, ts, nonce))
|
||||
if err != nil {
|
||||
t.Fatalf("do B: %v", err)
|
||||
}
|
||||
respB.Body.Close()
|
||||
if respB.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("cross-node replay to node B: status %d, want 401 (replayed nonce must be rejected)", respB.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack0008_N3_StandaloneKeepsLocalCache is the edge: a NON-clustered node
|
||||
// must NOT require JetStream — wireReplicatedNonces is a no-op and the node keeps
|
||||
// its in-memory cache, which still rejects a same-node replay (the single-node
|
||||
// guarantee is unchanged). This proves the fix does not add a JetStream
|
||||
// dependency to standalone deployments.
|
||||
func TestAttack0008_N3_StandaloneKeepsLocalCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
alice, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("identity: %v", err)
|
||||
}
|
||||
if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleAdmin); err != nil {
|
||||
t.Fatalf("add alice: %v", err)
|
||||
}
|
||||
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
||||
|
||||
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
|
||||
// Standalone: clustered=false, js=nil. Must succeed (no JetStream needed).
|
||||
if err := wireReplicatedNonces(srv, nil, false /*clustered*/, 1); err != nil {
|
||||
t.Fatalf("standalone wireReplicatedNonces must be a no-op, got: %v", err)
|
||||
}
|
||||
node := httptest.NewServer(srv)
|
||||
t.Cleanup(node.Close)
|
||||
|
||||
ts := time.Now().Unix()
|
||||
nonce := randNonce(t)
|
||||
path := "/members/" + frame.EndpointID(alice.SignPub) + "/rooms"
|
||||
|
||||
resp1, err := http.DefaultClient.Do(signed008(t, node.URL, "GET", path, nil, alice, ts, nonce))
|
||||
if err != nil {
|
||||
t.Fatalf("do 1: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
if resp1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("first use: status %d, want 200", resp1.StatusCode)
|
||||
}
|
||||
// Same-node replay is still rejected by the in-memory cache.
|
||||
resp2, err := http.DefaultClient.Do(signed008(t, node.URL, "GET", path, nil, alice, ts, nonce))
|
||||
if err != nil {
|
||||
t.Fatalf("do 2: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("same-node replay: status %d, want 401", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack0008_N3_ClusteredRequiresJetStream proves the hard rule: a clustered
|
||||
// node with NO JetStream available refuses (error), so the binary fails fast
|
||||
// instead of silently running with a per-process cache.
|
||||
func TestAttack0008_N3_ClusteredRequiresJetStream(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
||||
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
|
||||
if err := wireReplicatedNonces(srv, nil, true /*clustered*/, 1); err == nil {
|
||||
t.Fatalf("clustered node with no JetStream must fail, got nil")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
@@ -21,6 +23,74 @@ func splitRoutes(csv string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveClusterPass resolves the cluster route secret WITHOUT leaking it through
|
||||
// argv (audit 0008 N1-low: --cluster-pass in argv is visible in ps/journald).
|
||||
// Precedence: --cluster-pass-file (read + trim the file), then the env var
|
||||
// UNIBUS_CLUSTER_PASS, then the legacy --cluster-pass flag (argv-visible, kept for
|
||||
// dev/compat). env is injected (os.Getenv result) so the function stays testable.
|
||||
// It returns the secret and a short source label for logging (never the secret).
|
||||
func resolveClusterPass(passFlag, passFile, env string) (secret, source string, err error) {
|
||||
if passFile != "" {
|
||||
b, rerr := os.ReadFile(passFile)
|
||||
if rerr != nil {
|
||||
return "", "", fmt.Errorf("read --cluster-pass-file %q: %w", passFile, rerr)
|
||||
}
|
||||
return strings.TrimSpace(string(b)), "file", nil
|
||||
}
|
||||
if env != "" {
|
||||
return env, "env", nil
|
||||
}
|
||||
if passFlag != "" {
|
||||
return passFlag, "flag", nil
|
||||
}
|
||||
return "", "none", nil
|
||||
}
|
||||
|
||||
// injectRouteCreds rewrites each route URL that carries NO userinfo to embed
|
||||
// user:pass, so the cluster secret is supplied once (via file/env) instead of
|
||||
// repeated in every --routes argv entry where ps/journald would expose it. A route
|
||||
// that already carries userinfo is left untouched (operator override). With an
|
||||
// empty user it is a no-op. A malformed route URL is an error (configuration bug)
|
||||
// rather than a silently dropped peer.
|
||||
func injectRouteCreds(routes []string, user, pass string) ([]string, error) {
|
||||
if user == "" {
|
||||
return routes, nil
|
||||
}
|
||||
out := make([]string, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
u, err := url.Parse(r)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse route %q: %w", r, err)
|
||||
}
|
||||
if u.User == nil {
|
||||
u.User = url.UserPassword(user, pass)
|
||||
}
|
||||
out = append(out, u.String())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isLoopbackURL reports whether a NATS url targets this host only (loopback). Used
|
||||
// to guard migrate-to-kv (audit 0008 N6): pushing the allowlist to a REMOTE NATS
|
||||
// without TLS would send handles/roles/sign-pubs in cleartext, so a remote target
|
||||
// must be TLS-pinned (--ca). A url we cannot classify is treated as NON-loopback
|
||||
// (conservative: it then requires --ca).
|
||||
func isLoopbackURL(natsURL string) bool {
|
||||
u, err := url.Parse(natsURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
switch host {
|
||||
case "localhost":
|
||||
return true
|
||||
case "":
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
return ip != nil && ip.IsLoopback()
|
||||
}
|
||||
|
||||
// isLoopbackBind reports whether the --bind value keeps the service reachable
|
||||
// only from this host. An empty bind means "all interfaces" (public), and a
|
||||
// hostname we cannot resolve to a loopback literal is treated as public — the
|
||||
@@ -83,7 +153,17 @@ func validateBootConfig(bind string, mode membership.AuthMode, tlsCert, tlsKey s
|
||||
// The three route-TLS paths are all-or-nothing (mutual TLS needs the node cert,
|
||||
// its key, and the CA together), independent of the bind, so a partial TLS
|
||||
// config never silently degrades to plaintext routes.
|
||||
func validateClusterConfig(clusterName, bind, user, pass, rtCert, rtKey, rtCA string) error {
|
||||
//
|
||||
// Homogeneous posture (issue 0006d, audit 0008 N1): a cluster is only as secure
|
||||
// as its weakest node — the data plane forwards every subject between nodes, so a
|
||||
// single node running without enforced auth lets an unauthenticated peer
|
||||
// Subscribe(">") on it and harvest the traffic forwarded from the ACL'd nodes.
|
||||
// This node therefore REFUSES to join a cluster unless it runs --bus-auth enforce,
|
||||
// regardless of bind: a clustered node is a production node, and there is no safe
|
||||
// "dev cluster without auth". (A peer running a tampered binary is out of this
|
||||
// node's control; /healthz exposes each node's posture so a monitor can detect
|
||||
// one that is not enforce+ACL — see Server.Posture.)
|
||||
func validateClusterConfig(clusterName, bind, user, pass, rtCert, rtKey, rtCA string, mode membership.AuthMode) error {
|
||||
rtAny := rtCert != "" || rtKey != "" || rtCA != ""
|
||||
rtAll := rtCert != "" && rtKey != "" && rtCA != ""
|
||||
if rtAny && !rtAll {
|
||||
@@ -93,6 +173,13 @@ func validateClusterConfig(clusterName, bind, user, pass, rtCert, rtKey, rtCA st
|
||||
if clusterName == "" {
|
||||
return nil // standalone: no route layer to secure
|
||||
}
|
||||
// A clustered node MUST enforce auth (homogeneous posture). Checked before the
|
||||
// loopback shortcut so even a loopback cluster cannot form without enforce.
|
||||
if mode != membership.AuthEnforce {
|
||||
return fmt.Errorf(
|
||||
"refusing to start: cluster %q requires --bus-auth enforce; a cluster node without enforced auth+ACL lets an unauthenticated peer harvest the traffic forwarded from the other nodes (audit 0008 N1) — every node must run the same enforce+ACL+TLS posture",
|
||||
clusterName)
|
||||
}
|
||||
if isLoopbackBind(bind) {
|
||||
return nil // loopback cluster is dev-only and unreachable from outside
|
||||
}
|
||||
|
||||
@@ -108,31 +108,40 @@ func TestBootConfigPolicy(t *testing.T) {
|
||||
// route-TLS flags are all-or-nothing regardless of bind.
|
||||
func TestClusterConfigPolicy(t *testing.T) {
|
||||
const c, k, ca = "node.crt", "node.key", "ca.crt"
|
||||
en := membership.AuthEnforce
|
||||
off := membership.AuthOff
|
||||
soft := membership.AuthSoft
|
||||
cases := []struct {
|
||||
name string
|
||||
clusterName, bind string
|
||||
user, pass string
|
||||
rtCert, rtKey, rtCA string
|
||||
wantErr bool
|
||||
name string
|
||||
clusterName, bind string
|
||||
user, pass string
|
||||
rtCert, rtKey, rtCA string
|
||||
mode membership.AuthMode
|
||||
wantErr bool
|
||||
}{
|
||||
// Standalone (no cluster name) is always allowed, even on a public bind.
|
||||
{"standalone-public", "", "0.0.0.0", "", "", "", "", "", false},
|
||||
// Loopback dev cluster: unguarded (unreachable from outside).
|
||||
{"loopback-cluster-bare", "unibus", "127.0.0.1", "", "", "", "", "", false},
|
||||
// Golden: full public HA config.
|
||||
{"public-full", "unibus", "0.0.0.0", "u", "p", c, k, ca, false},
|
||||
// Error: public cluster without a route secret.
|
||||
{"public-no-secret", "unibus", "0.0.0.0", "", "", c, k, ca, true},
|
||||
{"public-half-secret", "unibus", "0.0.0.0", "u", "", c, k, ca, true},
|
||||
// Standalone (no cluster name) is always allowed, even on a public bind and
|
||||
// without enforce — the cluster posture rule does not apply to a single node.
|
||||
{"standalone-public-off", "", "0.0.0.0", "", "", "", "", "", off, false},
|
||||
// Loopback dev cluster WITH enforce: allowed (unreachable from outside).
|
||||
{"loopback-cluster-enforce", "unibus", "127.0.0.1", "", "", "", "", "", en, false},
|
||||
// Golden: full public HA config under enforce.
|
||||
{"public-full-enforce", "unibus", "0.0.0.0", "u", "p", c, k, ca, en, false},
|
||||
// N1 (audit 0008): a clustered node WITHOUT enforce is refused — even on
|
||||
// loopback — so no weak node can join the cluster.
|
||||
{"cluster-off-refused", "unibus", "127.0.0.1", "", "", "", "", "", off, true},
|
||||
{"cluster-soft-refused", "unibus", "0.0.0.0", "u", "p", c, k, ca, soft, true},
|
||||
// Error: public cluster without a route secret (enforce on, fails on secret).
|
||||
{"public-no-secret", "unibus", "0.0.0.0", "", "", c, k, ca, en, true},
|
||||
{"public-half-secret", "unibus", "0.0.0.0", "u", "", c, k, ca, en, true},
|
||||
// Error: public cluster without mutual route TLS.
|
||||
{"public-no-tls", "unibus", "10.0.0.1", "u", "p", "", "", "", true},
|
||||
// Error: partial route-TLS flags trip regardless of bind.
|
||||
{"loopback-partial-tls", "unibus", "127.0.0.1", "", "", c, "", "", true},
|
||||
{"standalone-partial-tls", "", "127.0.0.1", "", "", c, k, "", true},
|
||||
{"public-no-tls", "unibus", "10.0.0.1", "u", "p", "", "", "", en, true},
|
||||
// Error: partial route-TLS flags trip regardless of bind/mode.
|
||||
{"loopback-partial-tls", "unibus", "127.0.0.1", "", "", c, "", "", en, true},
|
||||
{"standalone-partial-tls", "", "127.0.0.1", "", "", c, k, "", off, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := validateClusterConfig(tc.clusterName, tc.bind, tc.user, tc.pass, tc.rtCert, tc.rtKey, tc.rtCA)
|
||||
err := validateClusterConfig(tc.clusterName, tc.bind, tc.user, tc.pass, tc.rtCert, tc.rtKey, tc.rtCA, tc.mode)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Fatalf("cluster config %+v should be refused", tc)
|
||||
}
|
||||
@@ -143,6 +152,22 @@ func TestClusterConfigPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAttack0008_N1 is the regression for audit 0008 N1 scenario 2: a node
|
||||
// configured to join a cluster while NOT enforcing auth (the weak node that lets
|
||||
// an unauthenticated peer harvest the cluster's forwarded traffic) must be refused
|
||||
// at startup. The homogeneous-posture rule makes this binary unable to BE that
|
||||
// weak node.
|
||||
func TestAttack0008_N1(t *testing.T) {
|
||||
// Weak node: clustered but --bus-auth off -> refused.
|
||||
if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthOff); err == nil {
|
||||
t.Fatalf("a clustered node without enforce must be refused (audit 0008 N1)")
|
||||
}
|
||||
// Same node WITH enforce + full route security -> allowed.
|
||||
if err := validateClusterConfig("unibus", "0.0.0.0", "u", "p", "n.crt", "n.key", "ca.crt", membership.AuthEnforce); err != nil {
|
||||
t.Fatalf("a clustered enforce node with full route security must be allowed, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitRoutes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/busauth"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
server "github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
// connectInternalJS opens a privileged JetStream client from membershipd to its
|
||||
// OWN embedded NATS server. This is the resolution of the "bootstrap cycle"
|
||||
// (issue 0006a/c): the service needs JetStream to create the replicated nonce
|
||||
// bucket and the control-plane KV, but under enforce the data plane only accepts
|
||||
// allowlisted clients confined to their rooms. The connection therefore
|
||||
// authenticates with the process's ephemeral internal identity — the identity the
|
||||
// authenticator was built to recognize (NewNkeyAuthenticatorACLInternal) and
|
||||
// grant full permissions — without ever appearing in the user allowlist.
|
||||
//
|
||||
// It uses the in-process transport (nats.InProcessServer), a Go pipe inside the
|
||||
// process, so it bypasses TLS entirely: no CA wiring is needed for this
|
||||
// self-connection even when the public data plane is TLS-only. useNkey mirrors
|
||||
// whether the embedded server enforces auth: under enforce the internal identity
|
||||
// presents its nkey; without enforce the server accepts an unauthenticated
|
||||
// in-process client and the nkey is omitted.
|
||||
//
|
||||
// The caller owns the returned connection and must Close it on shutdown (after
|
||||
// the JetStream context is no longer used).
|
||||
func connectInternalJS(ns *server.Server, internalID cs.Identity, useNkey bool) (*nats.Conn, jetstream.JetStream, error) {
|
||||
opts := []nats.Option{
|
||||
nats.Name("membershipd-internal"),
|
||||
nats.InProcessServer(ns),
|
||||
}
|
||||
if useNkey {
|
||||
pub, sign, err := busauth.ClientNkey(internalID.SignPriv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("internal nkey: %w", err)
|
||||
}
|
||||
opts = append(opts, nats.Nkey(pub, sign))
|
||||
}
|
||||
// The URL is ignored for an in-process connection; the InProcessServer option
|
||||
// supplies the transport.
|
||||
nc, err := nats.Connect("", opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("connect internal nats: %w", err)
|
||||
}
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, fmt.Errorf("internal jetstream: %w", err)
|
||||
}
|
||||
return nc, js, nil
|
||||
}
|
||||
|
||||
// connectExternalJS opens a JetStream client to an EXTERNAL NATS the operator
|
||||
// runs (membershipd started with --nats-url). Unlike the embedded path there is
|
||||
// no in-process transport and no internal identity: the external server enforces
|
||||
// its own auth, so membershipd connects as a plain client (optionally TLS-pinned
|
||||
// to the bus CA). It is best-effort and intended for an operator-managed cluster;
|
||||
// the standard unibus deploy uses the embedded server (connectInternalJS).
|
||||
func connectExternalJS(natsURL, caPath string) (*nats.Conn, jetstream.JetStream, error) {
|
||||
opts := []nats.Option{nats.Name("membershipd-internal")}
|
||||
if caPath != "" {
|
||||
tlsCfg, err := busauth.LoadCATLSConfig(caPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("load CA %q: %w", caPath, err)
|
||||
}
|
||||
opts = append(opts, nats.Secure(tlsCfg))
|
||||
}
|
||||
nc, err := nats.Connect(natsURL, opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("connect external nats %q: %w", natsURL, err)
|
||||
}
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
nc.Close()
|
||||
return nil, nil, fmt.Errorf("external jetstream: %w", err)
|
||||
}
|
||||
return nc, js, nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
// Bootstrap test for issue 0006a/c: under enforce, membershipd must still reach
|
||||
// JetStream on its OWN embedded server to create the nonce/KV buckets. It does so
|
||||
// with an ephemeral internal identity the authenticator grants full permissions
|
||||
// (NewNkeyAuthenticatorACLInternal). These tests prove that privileged
|
||||
// self-connection works AND that no other identity can claim it.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/busauth"
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
func icFreePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("free port: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
// TestInternalConnPrivilegedUnderEnforce: with an enforce authenticator that
|
||||
// authorizes NO bus user, the internal identity still connects in-process and has
|
||||
// full permissions — it creates a KV bucket and round-trips a value. This is the
|
||||
// resolution of the bootstrap cycle the audit flagged as the reason the KV store
|
||||
// was never wired.
|
||||
func TestInternalConnPrivilegedUnderEnforce(t *testing.T) {
|
||||
internalID, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("internal identity: %v", err)
|
||||
}
|
||||
internalPubHex := hex.EncodeToString(internalID.SignPub)
|
||||
|
||||
// Authenticator: no bus user is authorized; only the internal identity passes.
|
||||
auth := busauth.NewNkeyAuthenticatorACLInternal(
|
||||
func(string) bool { return false },
|
||||
busauth.PermissionsFromSubjects(func(string) ([]string, error) { return []string{"_INBOX.>"}, nil }),
|
||||
internalPubHex,
|
||||
)
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: icFreePort(t), Auth: auth,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nats: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
|
||||
nc, js, err := connectInternalJS(ns, internalID, true /*useNkey*/)
|
||||
if err != nil {
|
||||
t.Fatalf("connectInternalJS: %v", err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "KV_UNIBUS_test", Replicas: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("internal conn could not create KV bucket (full perms expected): %v", err)
|
||||
}
|
||||
if _, err := kv.Put(ctx, "k", []byte("v")); err != nil {
|
||||
t.Fatalf("kv put: %v", err)
|
||||
}
|
||||
e, err := kv.Get(ctx, "k")
|
||||
if err != nil || string(e.Value()) != "v" {
|
||||
t.Fatalf("kv get: val=%q err=%v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInternalConnOutsiderRejected: an identity that is neither the internal one
|
||||
// nor an allowlisted bus user cannot connect — proving the internal bypass is
|
||||
// scoped to the exact internal key, not a blanket hole.
|
||||
func TestInternalConnOutsiderRejected(t *testing.T) {
|
||||
internalID, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("internal identity: %v", err)
|
||||
}
|
||||
auth := busauth.NewNkeyAuthenticatorACLInternal(
|
||||
func(string) bool { return false },
|
||||
busauth.PermissionsFromSubjects(func(string) ([]string, error) { return []string{"_INBOX.>"}, nil }),
|
||||
hex.EncodeToString(internalID.SignPub),
|
||||
)
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: icFreePort(t), Auth: auth,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nats: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
|
||||
outsider, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("outsider identity: %v", err)
|
||||
}
|
||||
pub, sign, err := busauth.ClientNkey(outsider.SignPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("outsider nkey: %v", err)
|
||||
}
|
||||
conn, err := nats.Connect(ns.ClientURL(),
|
||||
nats.Nkey(pub, sign),
|
||||
nats.MaxReconnects(0),
|
||||
nats.Timeout(2*time.Second),
|
||||
)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
t.Fatalf("outsider (unauthorized, non-internal) must be rejected, but connected")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package main
|
||||
|
||||
// Wiring tests for issue 0006c: --store kv selects the replicated JetStream KV
|
||||
// control plane, the authenticator serves from it through the storeHolder, and a
|
||||
// new node sees state created by another (the divergence that per-node SQLite
|
||||
// caused — audit 0008 N5 — is gone). Branch-by-abstraction is verified elsewhere
|
||||
// (the SQLite default path is the unchanged baseline covered by the existing
|
||||
// suite).
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/busauth"
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// TestKVStoreBootstrapUnderEnforce drives the exact decentralized boot the binary
|
||||
// performs: build the authenticator over an empty holder, start NATS, open the
|
||||
// privileged internal connection, open the KV store, publish it into the holder,
|
||||
// then a real bus user (seeded into the KV store) authenticates over nkey. This
|
||||
// proves the bootstrap cycle is broken correctly — the KV-backed control plane
|
||||
// authorizes live clients under enforce.
|
||||
func TestKVStoreBootstrapUnderEnforce(t *testing.T) {
|
||||
internalID, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("internal identity: %v", err)
|
||||
}
|
||||
holder := &storeHolder{}
|
||||
auth := busauth.NewNkeyAuthenticatorACLInternal(
|
||||
holder.IsAuthorized,
|
||||
busauth.PermissionsFromSubjects(holder.subjectACL),
|
||||
hex.EncodeToString(internalID.SignPub),
|
||||
)
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), Auth: auth,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nats: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
|
||||
// Privileged internal connection opens the KV store while the holder still
|
||||
// denies every normal client.
|
||||
intNC, js, err := connectInternalJS(ns, internalID, true)
|
||||
if err != nil {
|
||||
t.Fatalf("connectInternalJS: %v", err)
|
||||
}
|
||||
t.Cleanup(intNC.Close)
|
||||
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("open kv store: %v", err)
|
||||
}
|
||||
holder.set(kvStore)
|
||||
|
||||
// Seed a bus user into the KV control plane.
|
||||
alice, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("alice: %v", err)
|
||||
}
|
||||
if err := kvStore.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleMember); err != nil {
|
||||
t.Fatalf("seed alice: %v", err)
|
||||
}
|
||||
|
||||
// alice authenticates over nkey — authorized via the KV store through the holder.
|
||||
pub, sign, err := busauth.ClientNkey(alice.SignPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("alice nkey: %v", err)
|
||||
}
|
||||
aliceNC, err := nats.Connect(ns.ClientURL(), nats.Nkey(pub, sign), nats.MaxReconnects(0), nats.Timeout(2*time.Second))
|
||||
if err != nil {
|
||||
t.Fatalf("alice (KV-authorized) must connect under enforce: %v", err)
|
||||
}
|
||||
aliceNC.Close()
|
||||
|
||||
// An outsider not in the KV store is denied (fail closed).
|
||||
outsider, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("outsider: %v", err)
|
||||
}
|
||||
opub, osign, err := busauth.ClientNkey(outsider.SignPriv)
|
||||
if err != nil {
|
||||
t.Fatalf("outsider nkey: %v", err)
|
||||
}
|
||||
if oc, err := nats.Connect(ns.ClientURL(), nats.Nkey(opub, osign), nats.MaxReconnects(0), nats.Timeout(2*time.Second)); err == nil {
|
||||
oc.Close()
|
||||
t.Fatalf("an outsider absent from the KV store must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestKVStoreDecentralizedConsistency: a room/user created via one node's KV store
|
||||
// is immediately visible to another node's KV store over the same JetStream — the
|
||||
// shared, replicated control plane that ends the per-node SQLite divergence.
|
||||
func TestKVStoreDecentralizedConsistency(t *testing.T) {
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("nats: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
|
||||
open := func() membership.Store {
|
||||
nc, err := nats.Connect(ns.ClientURL())
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
t.Cleanup(nc.Close)
|
||||
js, err := jetstream.New(nc)
|
||||
if err != nil {
|
||||
t.Fatalf("jetstream: %v", err)
|
||||
}
|
||||
st, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("open kv: %v", err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
nodeA := open()
|
||||
nodeB := open()
|
||||
|
||||
owner, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("owner: %v", err)
|
||||
}
|
||||
ownerPub := hex.EncodeToString(owner.SignPub)
|
||||
if err := nodeA.AddUser(ownerPub, "owner", membership.RoleAdmin); err != nil {
|
||||
t.Fatalf("nodeA add user: %v", err)
|
||||
}
|
||||
if err := nodeA.CreateRoom(
|
||||
membership.RoomInfo{RoomID: "ROOMX", Subject: "room.shared.x", OwnerEndpoint: "owner-ep"},
|
||||
owner.SignPub, owner.KexPub, nil,
|
||||
); err != nil {
|
||||
t.Fatalf("nodeA create room: %v", err)
|
||||
}
|
||||
|
||||
// nodeB (a different connection, same buckets) sees both immediately.
|
||||
if !nodeB.IsAuthorized(ownerPub) {
|
||||
t.Fatalf("nodeB must see the user created on nodeA (decentralized state divergence)")
|
||||
}
|
||||
got, err := nodeB.GetRoom("ROOMX")
|
||||
if err != nil {
|
||||
t.Fatalf("nodeB must see the room created on nodeA: %v", err)
|
||||
}
|
||||
if got.Subject != "room.shared.x" {
|
||||
t.Fatalf("nodeB read wrong room subject: %q", got.Subject)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestResolveClusterPass verifies the secret resolution precedence
|
||||
// (file > env > flag) that keeps the cluster password out of argv (issue 0006f).
|
||||
func TestResolveClusterPass(t *testing.T) {
|
||||
// file wins over env and flag, and is trimmed.
|
||||
f := filepath.Join(t.TempDir(), "pass")
|
||||
if err := os.WriteFile(f, []byte("filesecret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if got, src, err := resolveClusterPass("flagsecret", f, "envsecret"); err != nil || got != "filesecret" || src != "file" {
|
||||
t.Fatalf("file precedence: got %q src %q err %v", got, src, err)
|
||||
}
|
||||
// env wins over flag when no file.
|
||||
if got, src, err := resolveClusterPass("flagsecret", "", "envsecret"); err != nil || got != "envsecret" || src != "env" {
|
||||
t.Fatalf("env precedence: got %q src %q err %v", got, src, err)
|
||||
}
|
||||
// flag is the last resort.
|
||||
if got, src, err := resolveClusterPass("flagsecret", "", ""); err != nil || got != "flagsecret" || src != "flag" {
|
||||
t.Fatalf("flag fallback: got %q src %q err %v", got, src, err)
|
||||
}
|
||||
// none set.
|
||||
if got, src, err := resolveClusterPass("", "", ""); err != nil || got != "" || src != "none" {
|
||||
t.Fatalf("none: got %q src %q err %v", got, src, err)
|
||||
}
|
||||
// missing file is an error.
|
||||
if _, _, err := resolveClusterPass("", filepath.Join(t.TempDir(), "nope"), ""); err == nil {
|
||||
t.Fatalf("missing file must error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestInjectRouteCreds verifies the secret is injected only into routes that omit
|
||||
// userinfo, so --routes argv need not carry the password (issue 0006f).
|
||||
func TestInjectRouteCreds(t *testing.T) {
|
||||
in := []string{"nats://10.0.0.2:6250", "nats://override:pw@10.0.0.3:6250"}
|
||||
out, err := injectRouteCreds(in, "user", "secret")
|
||||
if err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if !strings.Contains(out[0], "user:secret@10.0.0.2:6250") {
|
||||
t.Fatalf("creds not injected into bare route: %q", out[0])
|
||||
}
|
||||
if !strings.Contains(out[1], "override:pw@10.0.0.3:6250") {
|
||||
t.Fatalf("existing userinfo must be preserved: %q", out[1])
|
||||
}
|
||||
// empty user is a no-op.
|
||||
noop, err := injectRouteCreds(in, "", "")
|
||||
if err != nil || noop[0] != in[0] {
|
||||
t.Fatalf("empty user must be a no-op: %v %q", err, noop[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsLoopbackURL guards migrate-to-kv against pushing the allowlist cleartext
|
||||
// to a remote NATS (issue 0006f, audit 0008 N6).
|
||||
func TestIsLoopbackURL(t *testing.T) {
|
||||
loop := []string{"nats://127.0.0.1:4250", "nats://localhost:4250", "nats://[::1]:4250"}
|
||||
for _, u := range loop {
|
||||
if !isLoopbackURL(u) {
|
||||
t.Fatalf("%q should be loopback", u)
|
||||
}
|
||||
}
|
||||
remote := []string{"nats://10.0.0.2:4250", "nats://bus.example.com:4250", "::not-a-url"}
|
||||
for _, u := range remote {
|
||||
if isLoopbackURL(u) {
|
||||
t.Fatalf("%q should NOT be loopback", u)
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
-14
@@ -7,6 +7,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -15,6 +16,10 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
server "github.com/nats-io/nats-server/v2/server"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
@@ -58,10 +63,26 @@ func main() {
|
||||
clusterPort = flag.Int("cluster-port", 6250, "route listener port for server-to-server cluster traffic")
|
||||
routesCSV = flag.String("routes", "", "comma-separated nats-route URLs of the OTHER nodes, e.g. nats://user:pass@10.0.0.2:6250")
|
||||
clusterUser = flag.String("cluster-user", "", "shared route secret username (gates the route listener)")
|
||||
clusterPass = flag.String("cluster-pass", "", "shared route secret password")
|
||||
clusterPass = flag.String("cluster-pass", "", "shared route secret password (argv-visible — prefer --cluster-pass-file or UNIBUS_CLUSTER_PASS)")
|
||||
// Secret out of argv (issue 0006f, audit 0008 N1-low): a password in
|
||||
// --cluster-pass / --routes is visible in ps/journald. Prefer a file or the
|
||||
// UNIBUS_CLUSTER_PASS env var; routes may then omit userinfo and the secret
|
||||
// is injected from here.
|
||||
clusterPassFile = flag.String("cluster-pass-file", "", "path to a file holding the cluster route password (preferred over --cluster-pass; keeps the secret out of argv)")
|
||||
routeTLSCert = flag.String("route-tls-cert", "", "this node's route certificate (CA-signed); enables mutual route TLS with --route-tls-key/--route-tls-ca")
|
||||
routeTLSKey = flag.String("route-tls-key", "", "this node's route private key")
|
||||
routeTLSCA = flag.String("route-tls-ca", "", "bus CA that signs every node's route certificate (deploy/tls/ca.crt)")
|
||||
// Replicated control plane (issue 0006a/c): the JetStream replication factor
|
||||
// for the shared nonce bucket (and, with --store kv, the control-plane KV).
|
||||
// 1 for a 1-2 node rollout, 3 for real HA quorum (raise in place with
|
||||
// `nats stream update --replicas 3` when the third node joins).
|
||||
kvReplicas = flag.Int("kv-replicas", 1, "JetStream replication factor for the shared nonce/KV buckets (1..3)")
|
||||
caFile = flag.String("ca", "", "bus CA cert; only used to pin TLS on the internal JetStream connection to an EXTERNAL --nats-url (the embedded server uses an in-process connection that needs no CA)")
|
||||
// Control-plane store backend (issue 0006c, feature flag decentralized):
|
||||
// "sqlite" (default) keeps the local single-node SQLite control plane;
|
||||
// "kv" puts rooms/members/keys/users in replicated JetStream KV so any node
|
||||
// in the cluster serves the same state.
|
||||
storeBackend = flag.String("store", "sqlite", "control-plane store backend: sqlite (default, single-node) | kv (replicated JetStream, decentralized)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
@@ -69,6 +90,17 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
if *storeBackend != "sqlite" && *storeBackend != "kv" {
|
||||
log.Fatalf("--store must be \"sqlite\" or \"kv\", got %q", *storeBackend)
|
||||
}
|
||||
|
||||
// Resolve the cluster route secret out of argv (file/env preferred). The
|
||||
// resolved value (not *clusterPass) is what guards the route layer and is
|
||||
// injected into peer route URLs below.
|
||||
clusterPassResolved, passSource, err := resolveClusterPass(*clusterPass, *clusterPassFile, os.Getenv("UNIBUS_CLUSTER_PASS"))
|
||||
if err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
// Fail-open guard (audit H2): a non-loopback bind, or any TLS flag, demands
|
||||
// --bus-auth enforce. This makes an insecure public startup impossible rather
|
||||
@@ -78,21 +110,62 @@ func main() {
|
||||
}
|
||||
// Cluster route guard (issue 0003a): a public cluster needs a route secret
|
||||
// and mutual route TLS, and the route-TLS flags are all-or-nothing.
|
||||
if err := validateClusterConfig(*clusterName, *bind, *clusterUser, *clusterPass, *routeTLSCert, *routeTLSKey, *routeTLSCA); err != nil {
|
||||
if err := validateClusterConfig(*clusterName, *bind, *clusterUser, clusterPassResolved, *routeTLSCert, *routeTLSKey, *routeTLSCA, authMode); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[membershipd] ")
|
||||
|
||||
// Control plane store first: the NATS authenticator consults IsAuthorized, so
|
||||
// the store must exist before the embedded server starts.
|
||||
store, err := membership.Open(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open membership store: %v", err)
|
||||
// A clustered node shares its control plane with peers, so it needs a JetStream
|
||||
// client to manage the replicated nonce bucket (issue 0006a). --store kv (issue
|
||||
// 0006c) also needs JetStream, for the control-plane KV itself. A standalone
|
||||
// single-node SQLite deployment needs none of this and keeps the in-process,
|
||||
// in-memory behavior unchanged.
|
||||
clustered := *clusterName != ""
|
||||
decentralized := *storeBackend == "kv"
|
||||
needJS := clustered || decentralized
|
||||
enforce := authMode == membership.AuthEnforce
|
||||
|
||||
// Internal service identity (issue 0006a): when the embedded data plane enforces
|
||||
// auth, membershipd must still connect to its OWN server to manage JetStream.
|
||||
// It does so with this ephemeral identity, which the authenticator is built to
|
||||
// recognize and grant full permissions (it never enters the user allowlist). It
|
||||
// is only generated when actually needed (JetStream required AND enforce on AND
|
||||
// the server is embedded), so a standalone or non-enforce node is unchanged.
|
||||
var internalID cs.Identity
|
||||
var internalPubHex string
|
||||
if needJS && enforce && *natsURL == "" {
|
||||
internalID, err = cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
log.Fatalf("generate internal identity: %v", err)
|
||||
}
|
||||
internalPubHex = hex.EncodeToString(internalID.SignPub)
|
||||
}
|
||||
defer store.Close()
|
||||
log.Printf("membership store: %s", *dbPath)
|
||||
|
||||
// The authenticator consults the store through a holder so it can be built
|
||||
// before the store exists: with --store kv the JetStream KV store opens only
|
||||
// after NATS is up (the bootstrap cycle). In the default SQLite path the store
|
||||
// is opened and set into the holder right here, before the server starts, so
|
||||
// behavior is identical to the pre-0006c baseline. `store` is the final store
|
||||
// used by the HTTP server (set below for the KV path).
|
||||
holder := &storeHolder{}
|
||||
var store membership.Store
|
||||
if !decentralized {
|
||||
store, err = membership.Open(*dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("open membership store: %v", err)
|
||||
}
|
||||
holder.set(store)
|
||||
log.Printf("membership store: sqlite %s", *dbPath)
|
||||
}
|
||||
// Close whichever store ends up final (SQLite closes its file; the JetStream KV
|
||||
// store's Close is a no-op — its NATS connection is closed separately).
|
||||
defer func() {
|
||||
if store != nil {
|
||||
store.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
blobs, err := blobstore.New(*storeDir)
|
||||
if err != nil {
|
||||
@@ -118,14 +191,21 @@ func main() {
|
||||
}
|
||||
// Cluster (issue 0003a): with a cluster name, join the route layer for HA.
|
||||
if *clusterName != "" {
|
||||
// Inject the resolved secret into peer route URLs that omit userinfo, so
|
||||
// the password need not appear in --routes argv (issue 0006f).
|
||||
routes, rerr := injectRouteCreds(splitRoutes(*routesCSV), *clusterUser, clusterPassResolved)
|
||||
if rerr != nil {
|
||||
log.Fatalf("%v", rerr)
|
||||
}
|
||||
cc := &embeddednats.ClusterConfig{
|
||||
Name: *clusterName,
|
||||
Host: *bind,
|
||||
Port: *clusterPort,
|
||||
Routes: splitRoutes(*routesCSV),
|
||||
Routes: routes,
|
||||
Username: *clusterUser,
|
||||
Password: *clusterPass,
|
||||
Password: clusterPassResolved,
|
||||
}
|
||||
log.Printf("cluster route secret source: %s", passSource)
|
||||
if *routeTLSCert != "" {
|
||||
rtls, err := busauth.RouteTLSConfig(*routeTLSCert, *routeTLSKey, *routeTLSCA)
|
||||
if err != nil {
|
||||
@@ -145,9 +225,10 @@ func main() {
|
||||
// Subscribe(">") and harvest every room's subject and JetStream activity.
|
||||
// NATS freezes permissions at connect time, so a peer that joins a room
|
||||
// after connecting must client.RefreshSession to gain that room's subject.
|
||||
cfg.Auth = busauth.NewNkeyAuthenticatorACL(
|
||||
store.IsAuthorized,
|
||||
busauth.PermissionsFromSubjects(membership.SubjectACLFor(store)),
|
||||
cfg.Auth = busauth.NewNkeyAuthenticatorACLInternal(
|
||||
holder.IsAuthorized,
|
||||
busauth.PermissionsFromSubjects(holder.subjectACL),
|
||||
internalPubHex,
|
||||
)
|
||||
log.Printf("NATS nkey authentication: ON (enforce, per-subject ACL)")
|
||||
}
|
||||
@@ -172,6 +253,38 @@ func main() {
|
||||
log.Printf("using external NATS: %s", natsClientURL)
|
||||
}
|
||||
|
||||
// JetStream client + decentralized store (issue 0006a/c). needJS is set for a
|
||||
// clustered node (shared nonce bucket) and for --store kv (the KV control
|
||||
// plane). Open the privileged JetStream client first (in-process for the
|
||||
// embedded server, a plain client for external NATS), then — for --store kv —
|
||||
// open the replicated KV store and publish it into the holder so the
|
||||
// authenticator and HTTP server serve from it. The privileged connection is the
|
||||
// only client that can connect in this window (the holder still denies everyone
|
||||
// else; the internal identity bypasses the store).
|
||||
var js jetstream.JetStream
|
||||
if needJS {
|
||||
var internalNC *nats.Conn
|
||||
if *natsURL == "" {
|
||||
internalNC, js, err = connectInternalJS(ns, internalID, enforce)
|
||||
} else {
|
||||
internalNC, js, err = connectExternalJS(natsClientURL, *caFile)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("internal JetStream connection (required by --cluster-name/--store kv): %v", err)
|
||||
}
|
||||
defer internalNC.Close()
|
||||
|
||||
if decentralized {
|
||||
kvStore, err := membership.OpenJetStream(js, membership.JetStreamConfig{Replicas: *kvReplicas})
|
||||
if err != nil {
|
||||
log.Fatalf("open decentralized control-plane KV store: %v", err)
|
||||
}
|
||||
store = kvStore
|
||||
holder.set(store)
|
||||
log.Printf("membership store: jetstream KV (replicas=%d)", *kvReplicas)
|
||||
}
|
||||
}
|
||||
|
||||
srv := membership.NewServer(store, blobs, authMode)
|
||||
// On a public (non-loopback) bind, disable cleartext rooms: the embedded NATS
|
||||
// has no per-subject ACL, so cleartext content would be readable by any
|
||||
@@ -181,6 +294,32 @@ func main() {
|
||||
srv.RequireEncryptedRooms = true
|
||||
log.Printf("cleartext rooms: DISABLED (public bind requires end-to-end encryption)")
|
||||
}
|
||||
// Publish this node's posture on /healthz so a monitor (or a peer) can detect a
|
||||
// cluster member not running the homogeneous enforce+ACL+TLS posture (audit
|
||||
// 0008 N1). enforce implies the per-subject ACL in this binary (they are wired
|
||||
// together above).
|
||||
srv.Posture = membership.Posture{
|
||||
Enforce: enforce,
|
||||
ACL: enforce,
|
||||
TLS: *tlsCert != "",
|
||||
Cluster: clustered,
|
||||
Store: *storeBackend,
|
||||
}
|
||||
|
||||
// Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST
|
||||
// share its nonce store across the cluster, or a request accepted on one node
|
||||
// can be replayed to another. HARD requirement: if the bucket cannot be created
|
||||
// the node refuses to start rather than run with a per-process cache that leaves
|
||||
// the replay hole open.
|
||||
if needJS {
|
||||
if err := wireReplicatedNonces(srv, js, clustered, *kvReplicas); err != nil {
|
||||
log.Fatalf("%v", err)
|
||||
}
|
||||
if clustered {
|
||||
log.Printf("anti-replay: replicated nonce bucket \"KV_UNIBUS_nonces\" (replicas=%d) — cluster-safe", *kvReplicas)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("control-plane auth: %s", authMode)
|
||||
addr := *bind + ":" + *httpPort
|
||||
httpSrv := &http.Server{
|
||||
|
||||
@@ -33,6 +33,14 @@ func runMigrateCLI(args []string) {
|
||||
fmt.Fprintln(os.Stderr, "membershipd migrate-to-kv: --nats-url is required (the cluster to write the KV buckets into)")
|
||||
os.Exit(2)
|
||||
}
|
||||
// Confidentiality guard (issue 0006f, audit 0008 N6): the migration writes the
|
||||
// allowlist (handles, roles, signing pubkeys) into the KV. Against a REMOTE NATS
|
||||
// without TLS that metadata would travel in cleartext, so a remote target MUST
|
||||
// be TLS-pinned with --ca. A loopback target is local-only and exempt.
|
||||
if !isLoopbackURL(*natsURL) && *ca == "" {
|
||||
fmt.Fprintf(os.Stderr, "membershipd migrate-to-kv: refusing to migrate to remote %q without --ca; the allowlist (handles/roles/sign pubs) would travel in cleartext — pin TLS with --ca, or run against a loopback nats-url\n", *natsURL)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
// Back up the SQLite database first so a botched migration can be undone.
|
||||
var backupPath string
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
)
|
||||
|
||||
// storeHolder is a concurrency-safe slot for the control-plane store, used to
|
||||
// break the decentralized bootstrap cycle (issue 0006c): the NATS authenticator
|
||||
// must be built BEFORE the embedded server starts, but the JetStream KV store can
|
||||
// only be opened AFTER NATS is up (it needs a JetStream client). The authenticator
|
||||
// therefore consults the holder instead of a concrete store.
|
||||
//
|
||||
// Fail-closed by construction: until the store is set, IsAuthorized denies and
|
||||
// SubjectACL errors, so any client connecting in the startup window is rejected.
|
||||
// The only connection expected in that window is membershipd's own internal
|
||||
// service identity, which the authenticator recognizes by key and lets through
|
||||
// without consulting the store at all. In the SQLite (default) path the store is
|
||||
// set before StartServer, so the window does not exist and behavior is identical
|
||||
// to the pre-0006c baseline.
|
||||
type storeHolder struct {
|
||||
mu sync.RWMutex
|
||||
s membership.Store
|
||||
}
|
||||
|
||||
func (h *storeHolder) set(s membership.Store) {
|
||||
h.mu.Lock()
|
||||
h.s = s
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *storeHolder) get() membership.Store {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.s
|
||||
}
|
||||
|
||||
// IsAuthorized reports whether signPubHex is an active bus user, denying while the
|
||||
// store is not yet set (fail closed). It is the predicate the nkey authenticator
|
||||
// uses for every connecting client.
|
||||
func (h *storeHolder) IsAuthorized(signPubHex string) bool {
|
||||
s := h.get()
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
return s.IsAuthorized(signPubHex)
|
||||
}
|
||||
|
||||
// subjectACL derives the per-subject permissions for signPubHex via the live
|
||||
// store, erroring (so the caller fails closed and denies the connection) while the
|
||||
// store is not yet set.
|
||||
func (h *storeHolder) subjectACL(signPubHex string) ([]string, error) {
|
||||
s := h.get()
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("control-plane store not ready")
|
||||
}
|
||||
return membership.SubjectACLFor(s)(signPubHex)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
)
|
||||
|
||||
// TestStoreHolderFailClosed: an empty holder denies everything (the bootstrap
|
||||
// window before the store is set), and starts serving once a store is published.
|
||||
func TestStoreHolderFailClosed(t *testing.T) {
|
||||
h := &storeHolder{}
|
||||
|
||||
// Empty: deny + error (fail closed).
|
||||
if h.IsAuthorized("anything") {
|
||||
t.Fatalf("empty holder must deny IsAuthorized")
|
||||
}
|
||||
if _, err := h.subjectACL("anything"); err == nil {
|
||||
t.Fatalf("empty holder must error from subjectACL (fail closed)")
|
||||
}
|
||||
|
||||
// After set: serves from the real store.
|
||||
store, err := membership.Open(filepath.Join(t.TempDir(), "unibus.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
id, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("identity: %v", err)
|
||||
}
|
||||
pub := hex.EncodeToString(id.SignPub)
|
||||
if err := store.AddUser(pub, "alice", membership.RoleMember); err != nil {
|
||||
t.Fatalf("add user: %v", err)
|
||||
}
|
||||
h.set(store)
|
||||
|
||||
if !h.IsAuthorized(pub) {
|
||||
t.Fatalf("after set, an active user must be authorized")
|
||||
}
|
||||
if _, err := h.subjectACL(pub); err != nil {
|
||||
t.Fatalf("after set, subjectACL must succeed: %v", err)
|
||||
}
|
||||
if h.IsAuthorized("deadbeef") {
|
||||
t.Fatalf("a non-user must not be authorized")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// wireReplicatedNonces applies the cluster anti-replay policy to srv. It is the
|
||||
// single piece of wiring the binary uses to decide whether a node must share its
|
||||
// nonce store, extracted so a regression test exercises the EXACT decision the
|
||||
// running binary makes (issue 0006a, audit 0008 N3).
|
||||
//
|
||||
// Policy:
|
||||
// - A clustered node (clustered == true) MUST use the shared JetStream KV nonce
|
||||
// bucket. Every node sees the same bucket, so a request accepted on one node
|
||||
// cannot be replayed to another whose per-process cache never saw the nonce.
|
||||
// A missing JetStream context, or a failure to create the bucket, is a FATAL
|
||||
// configuration error returned to the caller — a clustered node running with a
|
||||
// per-process nonce cache is precisely the replay hole the audit flagged, so
|
||||
// it must refuse to start rather than serve insecurely.
|
||||
// - A standalone node (clustered == false) keeps the in-memory cache that
|
||||
// NewServer installed: there is no second node to replay to, so the shared
|
||||
// bucket would only add a JetStream dependency for no security gain.
|
||||
//
|
||||
// replicas is the nonce bucket's replication factor (R1..R3). Returns nil when no
|
||||
// action is required (standalone).
|
||||
func wireReplicatedNonces(srv *membership.Server, js jetstream.JetStream, clustered bool, replicas int) error {
|
||||
if !clustered {
|
||||
return nil // standalone: the in-memory nonce cache is sufficient and safe
|
||||
}
|
||||
if js == nil {
|
||||
return fmt.Errorf("clustered node requires JetStream for the shared nonce bucket, but none is available")
|
||||
}
|
||||
if err := srv.UseReplicatedNonces(js, replicas); err != nil {
|
||||
return fmt.Errorf("replicated nonces: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -47,6 +47,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("create room: %v", err)
|
||||
}
|
||||
// Membership-change contract (issue 0006e): the bus freezes per-subject
|
||||
// permissions at connect time, and this room did not exist then. Refresh the
|
||||
// session so the new room's subject becomes publishable under enforce+ACL. On
|
||||
// an unsecured/dev bus this is a harmless reconnect.
|
||||
if err := c.RefreshSession(); err != nil {
|
||||
log.Fatalf("refresh session after create room: %v", err)
|
||||
}
|
||||
log.Printf("room %q -> %s (subject %s, cleartext)", *roomSub, roomID, *roomSub)
|
||||
|
||||
stop := make(chan os.Signal, 1)
|
||||
|
||||
@@ -65,3 +65,34 @@ curl -fsS http://<host-lan-ip>:8470/healthz
|
||||
- To run against an external NATS instead of the embedded one, append
|
||||
`--nats-url nats://<host>:4222` to `ExecStart` and re-run `daemon-reload` +
|
||||
`restart`.
|
||||
|
||||
## Clustering (HA) — see `deploy/cluster/`
|
||||
|
||||
The single-node service above is secure on its own. Running unibus as a
|
||||
multi-node **cluster** has extra hardening rules (issues 0006a–0006f); the full
|
||||
runbook and the generated material live in `deploy/cluster/`. Key points an
|
||||
operator must know:
|
||||
|
||||
- **Homogeneous posture (0006d).** Every node MUST run `--bus-auth enforce` (the
|
||||
binary refuses to join a cluster otherwise) and present mutual route TLS on a
|
||||
public bind. `/healthz` publishes each node's `posture` so a monitor can flag a
|
||||
node that is not `enforce`+`acl`+`tls`.
|
||||
- **Separate route CA (0006f).** The cluster route layer authenticates *nodes*,
|
||||
not bus users — sign the route certs with a **dedicated cluster CA**
|
||||
(`--route-tls-ca`), NOT the client data-plane CA (`--tls-cert`'s CA). Keeping
|
||||
the two trust roots separate means a client cert can never be presented to the
|
||||
route port. `deploy/cluster/generate-cluster-certs.sh` builds this CA.
|
||||
- **Secret out of argv (0006f).** Pass the route password via
|
||||
`--cluster-pass-file` or the `UNIBUS_CLUSTER_PASS` env var, NOT `--cluster-pass`
|
||||
or a `nats://user:pass@host` in `--routes` (both are visible in `ps`/journald).
|
||||
When the secret comes from a file/env, list peers as bare `--routes
|
||||
nats://<host>:6250` and the binary injects the credentials.
|
||||
- **`migrate-to-kv` confidentiality (0006f).** The migration writes the allowlist
|
||||
(handles/roles/sign pubs) into KV. Run it only against a **loopback** nats-url,
|
||||
or pin TLS with `--ca` for a remote target — otherwise that metadata travels in
|
||||
cleartext. The binary refuses a remote target without `--ca`.
|
||||
- **R1 is NOT HA (0006a/N3-DoS).** With `--kv-replicas 1` the control plane
|
||||
(including the nonce bucket) is a single point of failure: if the node owning
|
||||
the stream dies, every authenticated request fails closed (auth DoS). Real HA
|
||||
needs **R3** (quorum 2/3): raise replicas in place with `nats stream update
|
||||
--replicas 3` once the third node has joined. Do not advertise R1 as HA.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated TLS material and secrets — NEVER commit (audit 0008: keys/secret).
|
||||
out/
|
||||
build/
|
||||
secrets/
|
||||
*.key
|
||||
*.srl
|
||||
cluster-ca.crt
|
||||
@@ -0,0 +1,181 @@
|
||||
# unibus cluster — 3-node deploy runbook (issue 0006g)
|
||||
|
||||
This directory holds the material to bring up unibus as a **3-node cluster**
|
||||
(`magnus` + `homer` + `datardos`) for real HA: with **R3** replication the control
|
||||
plane (rooms/members/keys/users on JetStream KV + the anti-replay nonce bucket)
|
||||
survives the loss of any one node (quorum 2/3).
|
||||
|
||||
> **The agent that authored this never touched a VPS.** Every step that changes a
|
||||
> remote host is marked **HUMAN** and is executed by the operator. `deploy-cluster.sh`
|
||||
> defaults to a dry run.
|
||||
|
||||
## Files
|
||||
|
||||
| File | What it is |
|
||||
|---|---|
|
||||
| `nodes.env` | Topology: cluster name, ports, and the per-node rows (name, ssh host, public IP, WG IP). **HUMAN fills the placeholders.** |
|
||||
| `generate-cluster-certs.sh` | Mints a **separate cluster route CA** + a route cert per node, and a data-plane server cert per node signed by the **client CA** (`../tls/ca.*`). |
|
||||
| `membershipd-cluster.service` | One systemd unit, parameterized per node by `/opt/unibus/cluster.env`. enforce + per-subject ACL + TLS + `--store kv`, `Restart=always`. |
|
||||
| `deploy-cluster.sh` | Cross-builds the linux binary, generates each node's `cluster.env`, and (with `--yes`) rsyncs everything + installs the unit. Staggered start is manual. |
|
||||
|
||||
Generated keys/secrets (`out/`, `build/`, `secrets/`) are **gitignored** — they are
|
||||
secret and never leave the operator's trusted machine except over the secure
|
||||
rsync channel.
|
||||
|
||||
## Topology
|
||||
|
||||
| Node | SSH | Public IP | WireGuard IP | Role |
|
||||
|---|---|---|---|---|
|
||||
| magnus | `magnus` | `<MAGNUS_PUBLIC_IP>` | `<MAGNUS_WG_IP>` | seed (first up) |
|
||||
| homer | `homer` | `141.94.69.66` | `<HOMER_WG_IP>` | replica |
|
||||
| datardos | `dd` | `51.91.100.142` | `<DATARDOS_WG_IP>` (10.21.0.x) | replica |
|
||||
|
||||
The route layer (server-to-server) prefers the **WireGuard mesh**
|
||||
(`ROUTE_NETWORK=wg`); the client data plane and the HTTP control plane are reached
|
||||
over the public IPs. The route CA is **separate** from the client CA, so a client
|
||||
cert can never be presented to the route port.
|
||||
|
||||
## Prerequisites (HUMAN, once)
|
||||
|
||||
1. **Fill `nodes.env`** — replace every `<PLACEHOLDER>` (magnus public IP, all WG
|
||||
IPs). The scripts refuse to run while any remain.
|
||||
2. **Client CA exists** — `../tls/ca.crt` + `../tls/ca.key`. If not, run
|
||||
`../tls/generate-certs.sh` on the CA host (om) first. The cluster reuses this CA
|
||||
for the data plane so existing clients keep trusting the bus.
|
||||
3. **Mint cluster TLS**:
|
||||
```bash
|
||||
./generate-cluster-certs.sh # writes out/<name>/ ; --force to rotate the cluster CA
|
||||
```
|
||||
4. **Create the route secret** (out of argv, shared by all nodes):
|
||||
```bash
|
||||
mkdir -p secrets && openssl rand -hex 32 > secrets/cluster.pass
|
||||
```
|
||||
5. **SSH** to each node's SSH host as `root` works (`ssh magnus true`, `ssh dd true`, ...).
|
||||
|
||||
## Stage the nodes
|
||||
|
||||
```bash
|
||||
./deploy-cluster.sh # DRY RUN — prints the full plan, touches nothing
|
||||
./deploy-cluster.sh --yes # HUMAN: actually rsync + install the unit on all 3 nodes
|
||||
```
|
||||
|
||||
This cross-builds `membershipd` (linux/amd64, `CGO_ENABLED=0`), writes each node's
|
||||
`cluster.env` (its `NODE_NAME` and the `--routes` to the OTHER two nodes), and
|
||||
ships the binary, the node's TLS material, the secret, the env file and the unit.
|
||||
It does **not** start anything.
|
||||
|
||||
## Seed the first admin into the KV (HUMAN — loopback bootstrap)
|
||||
|
||||
The empty KV control plane has no users, and under `enforce` no external tool can
|
||||
write the FIRST admin over NATS (it would need to be an admin already — a
|
||||
chicken-and-egg). The `user` CLI also writes only to a local SQLite file, not the
|
||||
KV. So the first admin is seeded on the seed node through a **loopback, no-auth
|
||||
bootstrap** that populates the same JetStream store the cluster unit then reuses:
|
||||
|
||||
```bash
|
||||
ssh root@magnus 'bash -s' <<'SEED'
|
||||
set -euo pipefail
|
||||
cd /opt/unibus
|
||||
# a) Put the first admin into a local SQLite seed file.
|
||||
./membershipd user add --db ./seed.db --handle root --sign-pub <ADMIN_SIGN_PUB_HEX> --role admin
|
||||
# b) Bring up a TEMPORARY loopback, no-auth, single-node KV server on the cluster's
|
||||
# own JetStream store dir (not exposed; bus-auth off is allowed on 127.0.0.1).
|
||||
./membershipd --store kv --bus-auth off --bind 127.0.0.1 \
|
||||
--nats-store ./local_files/jetstream --db ./seed.db >/tmp/seed-boot.log 2>&1 &
|
||||
BOOT=$!; sleep 2
|
||||
# c) Migrate the admin from SQLite into the replicated KV (loopback — no --ca needed).
|
||||
./membershipd migrate-to-kv --db ./seed.db --nats-url nats://127.0.0.1:4250 --replicas 1
|
||||
# d) Stop the bootstrap server. The KV buckets persist in ./local_files/jetstream.
|
||||
kill "$BOOT"; wait "$BOOT" 2>/dev/null || true
|
||||
rm -f ./seed.db
|
||||
SEED
|
||||
```
|
||||
|
||||
> The KV written here lives in `./local_files/jetstream`, which the cluster unit
|
||||
> reuses (`--nats-store` default), so the admin is present when the enforce cluster
|
||||
> starts. Additional users are added the same loopback way until a
|
||||
> `user add --store kv` exists (see GAP in report 0009).
|
||||
|
||||
## Bring up (HUMAN — staggered)
|
||||
|
||||
Bring up the seed first, then the replicas one at a time, checking each joins.
|
||||
|
||||
```bash
|
||||
# 1. Seed node (after the seed step above).
|
||||
ssh root@magnus 'systemctl enable --now membershipd-cluster'
|
||||
ssh root@magnus 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt'
|
||||
|
||||
# 2. Replicas, one at a time.
|
||||
ssh root@homer 'systemctl enable --now membershipd-cluster'
|
||||
ssh root@datardos 'systemctl enable --now membershipd-cluster'
|
||||
```
|
||||
|
||||
> Initial rollout runs at **R1** (`KV_REPLICAS=1` in `nodes.env`): the buckets live
|
||||
> on the seed only. This is NOT HA yet — see "Scale to R3".
|
||||
|
||||
## Promote an existing single-node (SQLite) deployment (HUMAN, optional)
|
||||
|
||||
Instead of seeding fresh, you can migrate an existing single-node `unibus.db` into
|
||||
the KV — **loopback only** (the allowlist would otherwise travel cleartext; the
|
||||
command refuses a remote target without `--ca`). Use the same loopback-bootstrap
|
||||
shape as the seed step (temporary `--bus-auth off` server on 127.0.0.1, then
|
||||
`migrate-to-kv --db /opt/unibus/local_files/unibus.db`).
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# Posture on every node — all must be enforce+acl+tls+cluster, store=kv.
|
||||
for h in magnus homer datardos; do
|
||||
echo "== $h =="
|
||||
ssh root@$h 'curl -fsS https://127.0.0.1:8470/healthz --cacert /opt/unibus/tls/ca.crt'
|
||||
done
|
||||
|
||||
# Cluster + JetStream meta-group health (needs the `nats` CLI on a node):
|
||||
ssh root@magnus 'nats --server nats://127.0.0.1:4250 server report jetstream'
|
||||
ssh root@magnus 'nats --server nats://127.0.0.1:4250 server list' # 3 servers, routes up
|
||||
```
|
||||
|
||||
A healthy cluster shows 3 routed servers and a JetStream meta-group with a leader.
|
||||
|
||||
## Scale to R3 (HUMAN — real HA)
|
||||
|
||||
Once all three nodes are up and routed, raise the replication factor of every
|
||||
control-plane stream from 1 to 3 IN PLACE (no data loss), then flip `KV_REPLICAS=3`
|
||||
in `nodes.env` so future (re)deploys keep it:
|
||||
|
||||
```bash
|
||||
for s in KV_UNIBUS_users KV_UNIBUS_rooms KV_UNIBUS_members KV_UNIBUS_room_keys \
|
||||
KV_UNIBUS_rooms_by_member KV_UNIBUS_nonces; do
|
||||
ssh root@magnus "nats --server nats://127.0.0.1:4250 stream update $s --replicas 3 -f"
|
||||
done
|
||||
# (also OBJ_UNIBUS_blobs if the object store is in use)
|
||||
```
|
||||
|
||||
Until this is done, R1 means the seed node is a **single point of failure for
|
||||
authentication**: if it dies, the nonce/KV control plane is unreachable and every
|
||||
authenticated request fails closed (auth DoS). R1 is a rollout step, not HA.
|
||||
|
||||
## Chaos test (HUMAN — requires the 3 live VPS; NOT run here)
|
||||
|
||||
Validate quorum tolerance after R3:
|
||||
|
||||
```bash
|
||||
# Kill one node; the cluster keeps serving (quorum 2/3).
|
||||
ssh root@datardos 'systemctl stop membershipd-cluster'
|
||||
# -> clients fail over (multiple seed URLs); reads/writes still succeed.
|
||||
ssh root@datardos 'systemctl start membershipd-cluster' # rejoins, catches up
|
||||
|
||||
# Kill two nodes; quorum is LOST — the control plane should fail CLOSED (deny),
|
||||
# never fail open. Verify a request is rejected, not silently served.
|
||||
```
|
||||
|
||||
This network-level chaos test (kill 1/3, kill 2/3, partition/split-brain) is part
|
||||
of the deploy validation (issue 0003f) and runs against the real VPS — it is
|
||||
deliberately out of scope for the authoring agent.
|
||||
|
||||
## Rollback
|
||||
|
||||
`membershipd` does not delete data. To revert a node to standalone SQLite, stop
|
||||
the unit and start it without `--store kv`/`--cluster-name`; the KV buckets remain
|
||||
for a later retry. To rotate the cluster CA, re-run `generate-cluster-certs.sh
|
||||
--force` and re-stage (every node must get the new `cluster-ca.crt` together).
|
||||
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deploy-cluster.sh — cross-build membershipd and stage it onto the three cluster
|
||||
# nodes (issue 0006g). DEFAULT IS DRY-RUN: it prints the plan and touches nothing.
|
||||
# Pass --yes to actually rsync + run remote commands. Steps that a HUMAN must run
|
||||
# (or confirm) are marked "HUMAN:".
|
||||
#
|
||||
# Prerequisites (HUMAN, once):
|
||||
# 1. Fill nodes.env (no <PLACEHOLDER> left).
|
||||
# 2. ./generate-cluster-certs.sh (mints out/<name>/ TLS material)
|
||||
# 3. Create the route secret locally: mkdir -p secrets && openssl rand -hex 32 > secrets/cluster.pass
|
||||
# (secrets/ is gitignored; it is rsynced to each node as cluster.pass)
|
||||
# 4. SSH access to every node's SSH_HOST with sudo-less root (SSH_USER=root).
|
||||
#
|
||||
# What it does per node (with --yes):
|
||||
# - rsync the membershipd binary, the node's TLS material, the unit, the
|
||||
# generated cluster.env and the route secret into REMOTE_DIR.
|
||||
# - install + daemon-reload the systemd unit.
|
||||
# Start is STAGGERED and left to the human (see README): start the seed node,
|
||||
# seed the admin, then start the rest.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source ./nodes.env
|
||||
|
||||
APPLY=0
|
||||
[[ "${1:-}" == "--yes" ]] && APPLY=1
|
||||
|
||||
if grep -q '<[A-Z_]\+>' nodes.env; then
|
||||
echo "ERROR: nodes.env still has <PLACEHOLDER> values — fill them in first." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SECRET_FILE="secrets/cluster.pass"
|
||||
if [[ ! -f "$SECRET_FILE" ]]; then
|
||||
echo "ERROR: $SECRET_FILE missing. HUMAN: mkdir -p secrets && openssl rand -hex 32 > $SECRET_FILE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
run() {
|
||||
# Echo every action; only execute it under --yes.
|
||||
echo " + $*"
|
||||
if [[ $APPLY -eq 1 ]]; then
|
||||
"$@"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==> [1/3] cross-build membershipd (linux/amd64, CGO disabled)"
|
||||
run env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o build/membershipd ../../cmd/membershipd
|
||||
|
||||
# Build the comma-separated route list for a node = the OTHER nodes' addresses on
|
||||
# the chosen network, with NO userinfo (the secret is injected by membershipd from
|
||||
# the file). Echoes nothing; prints the value.
|
||||
routes_for() {
|
||||
local self="$1" out=""
|
||||
local row name _ssh pub wg addr
|
||||
for row in "${CLUSTER_NODES[@]}"; do
|
||||
read -r name _ssh pub wg <<<"$row"
|
||||
[[ "$name" == "$self" ]] && continue
|
||||
if [[ "$ROUTE_NETWORK" == "public" ]]; then addr="$pub"; else addr="$wg"; fi
|
||||
out+="nats://${addr}:${NATS_ROUTE_PORT},"
|
||||
done
|
||||
echo "${out%,}"
|
||||
}
|
||||
|
||||
echo "==> [2/3] stage each node (REMOTE_DIR=$REMOTE_DIR)"
|
||||
for row in "${CLUSTER_NODES[@]}"; do
|
||||
read -r name ssh _pub _wg <<<"$row"
|
||||
target="${SSH_USER}@${ssh}"
|
||||
nodedir="out/${name}"
|
||||
if [[ ! -d "$nodedir" ]]; then
|
||||
echo "ERROR: $nodedir missing — run ./generate-cluster-certs.sh first." >&2
|
||||
exit 2
|
||||
fi
|
||||
routes="$(routes_for "$name")"
|
||||
|
||||
echo "-- node ${name} (ssh ${ssh}) routes=${routes}"
|
||||
|
||||
# Generate this node's cluster.env locally, then ship it.
|
||||
envfile="build/cluster-${name}.env"
|
||||
mkdir -p build
|
||||
cat > "$envfile" <<EOF
|
||||
NODE_NAME=${name}
|
||||
CLUSTER_NAME=${CLUSTER_NAME}
|
||||
CLUSTER_USER=${CLUSTER_USER}
|
||||
KV_REPLICAS=${KV_REPLICAS}
|
||||
HTTP_PORT=${HTTP_PORT}
|
||||
NATS_CLIENT_PORT=${NATS_CLIENT_PORT}
|
||||
NATS_ROUTE_PORT=${NATS_ROUTE_PORT}
|
||||
ROUTES=${routes}
|
||||
CLUSTER_PASS_FILE=${REMOTE_DIR}/secrets/cluster.pass
|
||||
TLS_CERT=${REMOTE_DIR}/tls/server-${name}.crt
|
||||
TLS_KEY=${REMOTE_DIR}/tls/server-${name}.key
|
||||
ROUTE_TLS_CERT=${REMOTE_DIR}/tls/route-${name}.crt
|
||||
ROUTE_TLS_KEY=${REMOTE_DIR}/tls/route-${name}.key
|
||||
ROUTE_TLS_CA=${REMOTE_DIR}/tls/cluster-ca.crt
|
||||
EOF
|
||||
|
||||
run ssh "$target" "mkdir -p ${REMOTE_DIR}/tls ${REMOTE_DIR}/secrets"
|
||||
run rsync -az build/membershipd "${target}:${REMOTE_DIR}/membershipd"
|
||||
run rsync -az "${nodedir}/" "${target}:${REMOTE_DIR}/tls/"
|
||||
run rsync -az "$SECRET_FILE" "${target}:${REMOTE_DIR}/secrets/cluster.pass"
|
||||
run rsync -az "$envfile" "${target}:${REMOTE_DIR}/cluster.env"
|
||||
run rsync -az membershipd-cluster.service "${target}:/etc/systemd/system/membershipd-cluster.service"
|
||||
run ssh "$target" "chmod 600 ${REMOTE_DIR}/secrets/cluster.pass ${REMOTE_DIR}/tls/*.key && systemctl daemon-reload"
|
||||
done
|
||||
|
||||
echo "==> [3/3] staged."
|
||||
if [[ $APPLY -eq 0 ]]; then
|
||||
echo " DRY-RUN: nothing was sent. Re-run with --yes to apply."
|
||||
fi
|
||||
cat <<'NEXT'
|
||||
|
||||
HUMAN — staggered start (do NOT enable all at once; see README "Bring up"):
|
||||
1. Seed node first (e.g. magnus):
|
||||
ssh root@magnus 'systemctl enable --now membershipd-cluster'
|
||||
ssh root@magnus '/opt/unibus/membershipd user add --admin ...' # seed admin
|
||||
2. Then the other two, one at a time, checking quorum after each:
|
||||
ssh root@homer 'systemctl enable --now membershipd-cluster'
|
||||
ssh root@datardos 'systemctl enable --now membershipd-cluster'
|
||||
3. Verify posture + quorum (README "Verify").
|
||||
4. Scale replicas 1 -> 3 once all three are up (README "Scale to R3").
|
||||
NEXT
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# generate-cluster-certs.sh — mint the TLS material for a unibus 3-node cluster
|
||||
# (issue 0006g). Run ONCE on a trusted machine (e.g. om, which custodies the bus
|
||||
# CA); distribute the per-node output to each node over a secure channel. This
|
||||
# script touches NO remote host.
|
||||
#
|
||||
# It produces two trust roots, kept SEPARATE on purpose (audit 0008 N1-low):
|
||||
#
|
||||
# 1. The CLUSTER route CA (cluster-ca.crt/key, generated here): signs each
|
||||
# node's ROUTE certificate. The route layer authenticates NODES, not bus
|
||||
# users, so it must NOT share the client data-plane CA — a client cert can
|
||||
# then never be presented to the route port.
|
||||
# 2. The CLIENT data-plane CA (../tls/ca.crt/key, the one clients pin): signs
|
||||
# each node's DATA-PLANE server certificate. Reused, not regenerated, so
|
||||
# existing clients keep trusting the bus.
|
||||
#
|
||||
# Per node it emits, under out/<name>/:
|
||||
# route-<name>.crt/key route cert (cluster CA), EKU server+clientAuth
|
||||
# (each node is BOTH server and dialer to its peers)
|
||||
# server-<name>.crt/key data-plane cert (client CA), EKU serverAuth
|
||||
# cluster-ca.crt the route CA cert (for --route-tls-ca)
|
||||
# ca.crt the client CA cert (for clients / control-plane TLS)
|
||||
#
|
||||
# SANs per node = its public IP + its WireGuard IP + its hostname + localhost.
|
||||
#
|
||||
# Key material: EC P-256 (Go crypto/tls + nats-server friendly), matching
|
||||
# ../tls/generate-certs.sh.
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source ./nodes.env
|
||||
|
||||
# Refuse to run while any placeholder remains (HUMAN must fill nodes.env first).
|
||||
if grep -q '<[A-Z_]\+>' nodes.env; then
|
||||
echo "ERROR: nodes.env still has <PLACEHOLDER> values — fill them in first." >&2
|
||||
grep -n '<[A-Z_]\+>' nodes.env >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
CLIENT_CA_CRT="../tls/ca.crt"
|
||||
CLIENT_CA_KEY="../tls/ca.key"
|
||||
if [[ ! -f "$CLIENT_CA_CRT" || ! -f "$CLIENT_CA_KEY" ]]; then
|
||||
echo "ERROR: client data-plane CA not found at ../tls/ca.{crt,key}." >&2
|
||||
echo " Run ../tls/generate-certs.sh first (it mints the client CA)." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
DAYS_CA=3650
|
||||
DAYS_CRT=825
|
||||
|
||||
force=0
|
||||
[[ "${1:-}" == "--force" ]] && force=1
|
||||
|
||||
# --- cluster route CA (separate trust root) ---
|
||||
if [[ ! -f cluster-ca.crt || ! -f cluster-ca.key || $force -eq 1 ]]; then
|
||||
echo "==> generating cluster route CA (separate from the client CA)"
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out cluster-ca.key
|
||||
chmod 600 cluster-ca.key
|
||||
openssl req -x509 -new -key cluster-ca.key -sha256 -days "$DAYS_CA" \
|
||||
-subj "/CN=unibus-cluster-ca" -out cluster-ca.crt
|
||||
else
|
||||
echo "==> reusing existing cluster route CA (pass --force to regenerate)"
|
||||
fi
|
||||
|
||||
# mint <out_key> <out_crt> <subject_cn> <san> <eku> <ca_crt> <ca_key>
|
||||
mint_cert() {
|
||||
local out_key="$1" out_crt="$2" cn="$3" san="$4" eku="$5" ca_crt="$6" ca_key="$7"
|
||||
local csr ext
|
||||
csr="$(mktemp)"
|
||||
ext="$(mktemp)"
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out "$out_key"
|
||||
chmod 600 "$out_key"
|
||||
openssl req -new -key "$out_key" -subj "/CN=${cn}" -out "$csr"
|
||||
cat > "$ext" <<EOF
|
||||
subjectAltName=${san}
|
||||
extendedKeyUsage=${eku}
|
||||
keyUsage=digitalSignature,keyEncipherment
|
||||
EOF
|
||||
openssl x509 -req -in "$csr" -CA "$ca_crt" -CAkey "$ca_key" -CAcreateserial \
|
||||
-sha256 -days "$DAYS_CRT" -extfile "$ext" -out "$out_crt"
|
||||
rm -f "$csr" "$ext"
|
||||
}
|
||||
|
||||
for row in "${CLUSTER_NODES[@]}"; do
|
||||
read -r name _ssh pub wg <<<"$row"
|
||||
echo "==> node ${name}: SAN IP:${pub}, IP:${wg}, DNS:${name}, localhost, 127.0.0.1"
|
||||
nodedir="out/${name}"
|
||||
mkdir -p "$nodedir"
|
||||
san="IP:${pub},IP:${wg},DNS:${name},DNS:localhost,IP:127.0.0.1"
|
||||
|
||||
# Route cert: signed by the cluster CA; server+client auth (mutual routes).
|
||||
mint_cert "${nodedir}/route-${name}.key" "${nodedir}/route-${name}.crt" \
|
||||
"unibus-route-${name}" "$san" "serverAuth,clientAuth" \
|
||||
cluster-ca.crt cluster-ca.key
|
||||
|
||||
# Data-plane server cert: signed by the client CA; serverAuth only.
|
||||
mint_cert "${nodedir}/server-${name}.key" "${nodedir}/server-${name}.crt" \
|
||||
"unibus-${name}" "$san" "serverAuth" \
|
||||
"$CLIENT_CA_CRT" "$CLIENT_CA_KEY"
|
||||
|
||||
# Co-locate the two CA certs each node needs.
|
||||
cp cluster-ca.crt "${nodedir}/cluster-ca.crt"
|
||||
cp "$CLIENT_CA_CRT" "${nodedir}/ca.crt"
|
||||
done
|
||||
|
||||
rm -f cluster-ca.srl ../tls/ca.srl 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "==> done. Per-node material under out/<name>/ (KEYS ARE SECRET — never git):"
|
||||
for row in "${CLUSTER_NODES[@]}"; do
|
||||
read -r name _rest <<<"$row"
|
||||
echo " out/${name}/ (route-${name}.*, server-${name}.*, cluster-ca.crt, ca.crt)"
|
||||
done
|
||||
echo
|
||||
echo "verify a SAN with:"
|
||||
echo " openssl x509 -in out/<name>/server-<name>.crt -noout -text | grep -A1 'Subject Alternative Name'"
|
||||
@@ -0,0 +1,45 @@
|
||||
[Unit]
|
||||
# unibus membershipd — cluster node (issue 0006g).
|
||||
#
|
||||
# One unit, parameterized per node by /opt/unibus/cluster.env (generated by
|
||||
# deploy-cluster.sh): NODE_NAME, ROUTES and the cert paths differ per node, the
|
||||
# rest of the posture (enforce + per-subject ACL + TLS + --store kv) is identical
|
||||
# on every node, which is the homogeneous posture a secure cluster requires
|
||||
# (audit 0008 N1).
|
||||
Description=unibus membershipd (cluster node)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/opt/unibus
|
||||
EnvironmentFile=/opt/unibus/cluster.env
|
||||
# The route password comes from a FILE referenced by ${CLUSTER_PASS_FILE}, never
|
||||
# from argv (audit 0008 N1-low). The peer --routes carry no userinfo; membershipd
|
||||
# injects the credentials from the file/user.
|
||||
ExecStart=/opt/unibus/membershipd \
|
||||
--bind 0.0.0.0 \
|
||||
--bus-auth enforce \
|
||||
--http-port ${HTTP_PORT} \
|
||||
--nats-port ${NATS_CLIENT_PORT} \
|
||||
--tls-cert ${TLS_CERT} \
|
||||
--tls-key ${TLS_KEY} \
|
||||
--cluster-name ${CLUSTER_NAME} \
|
||||
--server-name ${NODE_NAME} \
|
||||
--cluster-port ${NATS_ROUTE_PORT} \
|
||||
--routes ${ROUTES} \
|
||||
--cluster-user ${CLUSTER_USER} \
|
||||
--cluster-pass-file ${CLUSTER_PASS_FILE} \
|
||||
--route-tls-cert ${ROUTE_TLS_CERT} \
|
||||
--route-tls-key ${ROUTE_TLS_KEY} \
|
||||
--route-tls-ca ${ROUTE_TLS_CA} \
|
||||
--store kv \
|
||||
--kv-replicas ${KV_REPLICAS}
|
||||
# Restart=always (NOT on-failure): a clean SIGTERM exits success, and on-failure
|
||||
# would then NOT restart, leaving the node silently dead (see function_tags.md).
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
LimitNOFILE=65536
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,44 @@
|
||||
# Cluster topology for the unibus 3-node deployment (issue 0006g).
|
||||
#
|
||||
# This file is SOURCED by generate-cluster-certs.sh and deploy-cluster.sh.
|
||||
#
|
||||
# HUMAN: fill in every <PLACEHOLDER> with the real value before running the
|
||||
# scripts. The public IPs known at authoring time are pre-filled; the WireGuard
|
||||
# mesh IPs and magnus's public IP must be supplied. The scripts refuse to run
|
||||
# while any <PLACEHOLDER> remains.
|
||||
|
||||
# Cluster identity (must be identical on every node).
|
||||
CLUSTER_NAME="unibus"
|
||||
# Route-secret username; the password is NOT here — it lives in a file (see
|
||||
# CLUSTER_PASS_FILE in deploy-cluster.sh) so it never lands in argv or git.
|
||||
CLUSTER_USER="unibus-cluster"
|
||||
|
||||
# KV/nonce replication factor. START AT 1 for the initial 1->3 rollout, then raise
|
||||
# to 3 IN PLACE (see README "Scale to R3") once all three nodes have joined. Only
|
||||
# set this to 3 here after the third node is up and you re-run the KV update.
|
||||
KV_REPLICAS=1
|
||||
|
||||
# Ports (same on every node; the route port is server-to-server only).
|
||||
NATS_CLIENT_PORT=4250
|
||||
NATS_ROUTE_PORT=6250
|
||||
HTTP_PORT=8470
|
||||
|
||||
# Remote install layout and SSH login user.
|
||||
REMOTE_DIR="/opt/unibus"
|
||||
SSH_USER="root"
|
||||
|
||||
# Which address family the inter-node routes use. "wg" builds --routes from the
|
||||
# WireGuard mesh IPs (private server-to-server links, preferred); "public" uses
|
||||
# the public IPs. The route layer is always mutual-TLS regardless.
|
||||
ROUTE_NETWORK="wg"
|
||||
|
||||
# One row per node: NAME SSH_HOST PUBLIC_IP WG_IP
|
||||
# NAME -> --server-name and the per-node cert filenames (unique).
|
||||
# SSH_HOST -> the `ssh <SSH_HOST>` alias (see ~/.ssh/config).
|
||||
# PUBLIC_IP -> public address; goes in the cert SANs (client-facing data plane).
|
||||
# WG_IP -> WireGuard mesh address; cert SAN + route target when ROUTE_NETWORK=wg.
|
||||
CLUSTER_NODES=(
|
||||
"magnus magnus <MAGNUS_PUBLIC_IP> <MAGNUS_WG_IP>"
|
||||
"homer homer 141.94.69.66 <HOMER_WG_IP>"
|
||||
"datardos dd 51.91.100.142 <DATARDOS_WG_IP>"
|
||||
)
|
||||
@@ -18,7 +18,7 @@
|
||||
"decentralized": {
|
||||
"enabled": false,
|
||||
"issue": "0003",
|
||||
"description": "Control-plane state on replicated JetStream KV instead of local SQLite (branch-by-abstraction membership.Store: sqliteStore default OFF, jetstreamStore ON). The route cluster (0003a) and the KV store (0003b) ship behind this flag; the membershipd boot wiring that selects the KV store completes with the session/reconnect redesign (0003e) and is activated on the multi-node deploy (0003f). OFF keeps the single-node SQLite control plane unchanged.",
|
||||
"description": "Control-plane state on replicated JetStream KV instead of local SQLite (branch-by-abstraction membership.Store: sqliteStore default, jetstreamStore opt-in). The route cluster (0003a) and the KV store (0003b) shipped behind this flag; the membershipd boot wiring that selects the store is COMPLETE since issue 0006c and is realized at runtime with the server flag --store kv|sqlite (default sqlite). The internal-identity bootstrap (0006a) lets membershipd open the KV store on its own embedded NATS under enforce. Per-deploy opt-in: a node joins the decentralized control plane by starting with --store kv (and --cluster-name for HA). OFF (--store sqlite) keeps the single-node SQLite control plane unchanged.",
|
||||
"added": "2026-06-07",
|
||||
"enabled_at": null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
issue: 0006
|
||||
title: Completar y endurecer el cluster — wiring del control plane KV + N1-N6 de la auditoría 0008
|
||||
status: spec
|
||||
status: done
|
||||
created: 2026-06-07
|
||||
closed: 2026-06-07
|
||||
closed_by: fases 0006a–0006g (ver report 0009); unibus v0.8.0
|
||||
domain: security
|
||||
scope: unibus (cmd/membershipd, pkg/membership, pkg/embeddednats, pkg/busauth, pkg/client)
|
||||
depends_on: 0003 (completa su wiring), 0005 (hereda el bus single-node ya seguro)
|
||||
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenera el binding gomobile (unibus.aar) a partir de ./mobile sobre pkg/client.
|
||||
#
|
||||
# El .aar (~38 MB, con libgojni.so para 4 ABIs) NO se versiona: es un artefacto
|
||||
# de build reproducible. Este script lo regenera. Requisitos:
|
||||
# - Go con gomobile/gobind instalados:
|
||||
# go install golang.org/x/mobile/cmd/gomobile@latest
|
||||
# go install golang.org/x/mobile/cmd/gobind@latest
|
||||
# gomobile init
|
||||
# - Android NDK (este repo usó 26.3.11579264 dentro del Android SDK).
|
||||
#
|
||||
# En un worktree fuera del árbol del registry, pkg/client importa
|
||||
# "fn-registry/functions/cybersecurity" vía el `replace` del go.mod. Si ese
|
||||
# replace relativo no resuelve (p. ej. worktree en /tmp), crea un go.work local
|
||||
# (gitignored) con: replace fn-registry => /ruta/absoluta/a/fn_registry
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
: "${ANDROID_HOME:=$HOME/android-sdk}"
|
||||
: "${ANDROID_NDK_HOME:=$ANDROID_HOME/ndk/26.3.11579264}"
|
||||
export ANDROID_HOME ANDROID_NDK_HOME
|
||||
export PATH="$HOME/go/bin:$PATH"
|
||||
|
||||
OUT="android/app/libs/unibus.aar"
|
||||
mkdir -p "$(dirname "$OUT")"
|
||||
|
||||
echo "==> gomobile bind -> $OUT"
|
||||
gomobile bind \
|
||||
-target=android \
|
||||
-androidapi 21 \
|
||||
-javapkg com.unibus.core \
|
||||
-o "$OUT" \
|
||||
./mobile
|
||||
|
||||
echo "==> OK: $OUT"
|
||||
ls -lh "$OUT"
|
||||
+77
-6
@@ -22,9 +22,14 @@ import (
|
||||
)
|
||||
|
||||
// FrameListener receives decrypted messages for a subscribed room. The Android
|
||||
// side implements this interface. Its methods are invoked from a NATS delivery
|
||||
// goroutine, so implementations must hop back to the UI thread (for example via
|
||||
// a coroutine on the main dispatcher) before touching Android views.
|
||||
// side implements this interface.
|
||||
//
|
||||
// IMPORTANT (threading): OnFrame is invoked from a NATS delivery goroutine, NOT
|
||||
// the Android main thread. A Kotlin implementation MUST hop back to the UI
|
||||
// thread before touching any Compose state or Android view — for example with
|
||||
// `withContext(Dispatchers.Main)` from a coroutine, or by posting to a
|
||||
// MutableStateFlow that the UI collects. Touching views directly from here
|
||||
// crashes with CalledFromWrongThreadException.
|
||||
type FrameListener interface {
|
||||
OnFrame(roomID string, sender string, msgID string, text string)
|
||||
}
|
||||
@@ -37,7 +42,8 @@ type Session struct {
|
||||
|
||||
// GenerateIdentity creates (or loads) the long-term keypair stored at path.
|
||||
// Call it once on first launch. The resulting file holds the peer's private
|
||||
// Ed25519 and X25519 keys and must be kept private to the app sandbox.
|
||||
// Ed25519 and X25519 keys and must be kept private to the app sandbox
|
||||
// (use Context.getFilesDir() on Android).
|
||||
func GenerateIdentity(path string) error {
|
||||
_, err := client.LoadOrCreateIdentity(path)
|
||||
return err
|
||||
@@ -45,7 +51,7 @@ func GenerateIdentity(path string) error {
|
||||
|
||||
// NewSession loads the identity at idPath and connects to the bus. natsURL is
|
||||
// the data plane (for example tls://host:4250) and ctrlURL is the control plane
|
||||
// HTTP endpoint (for example http://host:8470). caPath is the path to the bus
|
||||
// HTTP endpoint (for example https://host:8470). caPath is the path to the bus
|
||||
// CA certificate (ca.crt) bundled with the app: when set, the session connects
|
||||
// securely (TLS pinned to that CA + nkey authentication on the data plane),
|
||||
// matching a bus running with auth + TLS. Pass an empty caPath to connect in
|
||||
@@ -68,9 +74,24 @@ func (s *Session) EndpointID() string {
|
||||
return s.c.Endpoint().ID
|
||||
}
|
||||
|
||||
// ConnectedServer returns the NATS URL the session is currently connected to,
|
||||
// useful for surfacing a "connected to" hint in the UI.
|
||||
func (s *Session) ConnectedServer() string {
|
||||
return s.c.ConnectedServer()
|
||||
}
|
||||
|
||||
// IsConnected reports whether the underlying NATS connection is live.
|
||||
func (s *Session) IsConnected() bool {
|
||||
return s.c.IsConnected()
|
||||
}
|
||||
|
||||
// CreateRoom opens a room on the given subject. mode is "matrix" for the
|
||||
// encrypted, persisted and signed policy, or "nats" for plain cleartext. It
|
||||
// returns the room id used by Join, Publish and Subscribe.
|
||||
//
|
||||
// On a secured bus, call RefreshSession after CreateRoom and before
|
||||
// Subscribe/Publish so the bus re-derives this peer's per-subject permissions
|
||||
// from its new membership (issue 0006e).
|
||||
func (s *Session) CreateRoom(subject, mode string) (string, error) {
|
||||
p := room.ModeNATS
|
||||
if mode == "matrix" {
|
||||
@@ -85,13 +106,27 @@ func (s *Session) Join(roomID string) error {
|
||||
return s.c.Join(roomID)
|
||||
}
|
||||
|
||||
// RefreshSession reconnects the data plane so the bus re-derives this peer's
|
||||
// per-subject permissions from its current room membership.
|
||||
//
|
||||
// Membership-change contract (issue 0006e): a secured bus (--bus-auth enforce)
|
||||
// freezes a connection's permissions at connect time. After ANY membership change
|
||||
// — a room you just created, were invited to, or joined — call RefreshSession
|
||||
// BEFORE Publish/Subscribe on that room, or the bus denies the new room's subject.
|
||||
// It also drops active subscriptions, so re-Subscribe afterwards. On an unsecured
|
||||
// bus it is a harmless reconnect. A mobile/gateway caller wires this exactly like
|
||||
// cmd/chat and cmd/worker do: CreateRoom -> RefreshSession -> Subscribe/Publish.
|
||||
func (s *Session) RefreshSession() error {
|
||||
return s.c.RefreshSession()
|
||||
}
|
||||
|
||||
// Publish sends a UTF-8 text message to the room.
|
||||
func (s *Session) Publish(roomID, text string) error {
|
||||
return s.c.Publish(roomID, []byte(text))
|
||||
}
|
||||
|
||||
// Subscribe streams decrypted messages of the room to the listener until the
|
||||
// session is closed.
|
||||
// session is closed. See FrameListener for the threading contract.
|
||||
func (s *Session) Subscribe(roomID string, l FrameListener) error {
|
||||
_, err := s.c.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
||||
l.OnFrame(roomID, f.Sender, f.MsgID, string(plaintext))
|
||||
@@ -99,6 +134,42 @@ func (s *Session) Subscribe(roomID string, l FrameListener) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// roomJSON is the flat shape returned by ListRoomsJSON for each room the peer
|
||||
// belongs to. It mirrors the fields the UI needs to render a room list item.
|
||||
type roomJSON struct {
|
||||
RoomID string `json:"room_id"`
|
||||
Subject string `json:"subject"`
|
||||
Epoch int `json:"epoch"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// ListRoomsJSON returns the peer's rooms as a JSON array string. gomobile does
|
||||
// not bind slices of structs cleanly across the boundary, so the list is
|
||||
// marshalled to JSON and the Kotlin side decodes it (kotlinx.serialization).
|
||||
// Each element is a roomJSON object.
|
||||
func (s *Session) ListRoomsJSON() (string, error) {
|
||||
refs, err := s.c.ListMyRooms()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
out := make([]roomJSON, 0, len(refs))
|
||||
for _, r := range refs {
|
||||
out = append(out, roomJSON{
|
||||
RoomID: r.RoomID,
|
||||
Subject: r.Subject,
|
||||
Epoch: r.Epoch,
|
||||
Encrypted: r.Policy.Encrypt,
|
||||
Role: r.Role,
|
||||
})
|
||||
}
|
||||
b, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// cardJSON is the portable, copy-pasteable public identity a peer shares so a
|
||||
// room owner can invite it to an encrypted room. It carries no secret: only the
|
||||
// endpoint id and the two public keys (signing + key-exchange), base64-encoded
|
||||
|
||||
@@ -82,6 +82,15 @@ type PermissionsFunc func(signPubHex string) (*server.Permissions, error)
|
||||
type nkeyAuthenticatorACL struct {
|
||||
isAuthorized func(signPubHex string) bool
|
||||
perms PermissionsFunc
|
||||
// internalPubHex is the lowercase-hex Ed25519 public key of membershipd's own
|
||||
// ephemeral internal service identity. A connection that proves that key is
|
||||
// granted full permissions WITHOUT consulting the allowlist, so the service can
|
||||
// bootstrap and manage JetStream (the replicated nonce bucket and, when
|
||||
// decentralized, the control-plane KV buckets) against its own embedded server
|
||||
// even while the data plane confines every client to its rooms. Empty disables
|
||||
// the internal-identity path entirely (behavior identical to a plain ACL
|
||||
// authenticator).
|
||||
internalPubHex string
|
||||
}
|
||||
|
||||
// NewNkeyAuthenticatorACL builds an authenticator that authorizes by the bus
|
||||
@@ -94,6 +103,29 @@ func NewNkeyAuthenticatorACL(isAuthorized func(signPubHex string) bool, perms Pe
|
||||
return &nkeyAuthenticatorACL{isAuthorized: isAuthorized, perms: perms}
|
||||
}
|
||||
|
||||
// NewNkeyAuthenticatorACLInternal is NewNkeyAuthenticatorACL that also recognizes
|
||||
// membershipd's internal service identity (internalPubHex, the lowercase hex of
|
||||
// its ephemeral Ed25519 public key): a connection proving that key is granted
|
||||
// full permissions without an allowlist lookup, so the service can create and
|
||||
// manage JetStream against its own embedded server under enforce (issue 0006a/c —
|
||||
// the replicated nonce bucket and the control-plane KV). Every other identity
|
||||
// goes through the allowlist + per-subject ACL unchanged. An empty internalPubHex
|
||||
// is identical to NewNkeyAuthenticatorACL, so this is a superset and safe to use
|
||||
// everywhere the plain constructor was used.
|
||||
func NewNkeyAuthenticatorACLInternal(isAuthorized func(signPubHex string) bool, perms PermissionsFunc, internalPubHex string) server.Authentication {
|
||||
return &nkeyAuthenticatorACL{isAuthorized: isAuthorized, perms: perms, internalPubHex: internalPubHex}
|
||||
}
|
||||
|
||||
// fullPermissions grants publish and subscribe on every subject (">"). It is the
|
||||
// permission set for membershipd's own internal service connection, which must
|
||||
// manage the JetStream control plane (nonce bucket + KV buckets) over NATS. It is
|
||||
// NEVER granted to a bus user — only to the process's own ephemeral internal
|
||||
// identity, recognized by exact public-key match in Check.
|
||||
func fullPermissions() *server.Permissions {
|
||||
sp := &server.SubjectPermission{Allow: []string{">"}}
|
||||
return &server.Permissions{Publish: sp, Subscribe: sp}
|
||||
}
|
||||
|
||||
// Check verifies the nkey, authorizes against the allowlist, then derives and
|
||||
// registers the connection's subject permissions. A permissions-derivation
|
||||
// error denies the connection (fail closed) rather than granting open access.
|
||||
@@ -102,6 +134,14 @@ func (a *nkeyAuthenticatorACL) Check(c server.ClientAuthentication) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// membershipd's own internal service identity bypasses the allowlist and is
|
||||
// granted full permissions so the service can bootstrap JetStream under
|
||||
// enforce. The key is matched exactly against the cryptographically verified
|
||||
// connecting key, so no other identity can claim these permissions.
|
||||
if a.internalPubHex != "" && signPubHex == a.internalPubHex {
|
||||
c.RegisterUser(&server.User{Permissions: fullPermissions()})
|
||||
return true
|
||||
}
|
||||
if !a.isAuthorized(signPubHex) {
|
||||
return false
|
||||
}
|
||||
|
||||
+83
-17
@@ -1,31 +1,95 @@
|
||||
package membership
|
||||
|
||||
// Per-subject data-plane access control derived from room membership (issue
|
||||
// 0003e, audit H4 residual). The control plane already authorizes metadata by
|
||||
// membership; this is the matching restriction on the NATS data plane so a
|
||||
// registered peer can only publish/subscribe on the subjects of the rooms it
|
||||
// actually belongs to — not on every subject on the bus.
|
||||
// 0003e, audit H4 residual; tightened in issue 0006b for audit 0008 N2). The
|
||||
// control plane already authorizes metadata by membership; this is the matching
|
||||
// restriction on the NATS data plane so a registered peer can only
|
||||
// publish/subscribe on the subjects of the rooms it actually belongs to — and can
|
||||
// only reach the JetStream API of ITS OWN rooms' streams, never the control-plane
|
||||
// KV buckets.
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
)
|
||||
|
||||
// clientInfraSubjects are the subjects every peer needs regardless of room
|
||||
// membership: the request/reply inbox space and the JetStream API (the durable
|
||||
// plane of persisted rooms). They are granted to all authorized peers so
|
||||
// request/reply and persisted-room history keep working under the subject ACL.
|
||||
var clientInfraSubjects = []string{"_INBOX.>", "$JS.API.>"}
|
||||
// clientInfraSubjects are the subjects every authorized peer needs regardless of
|
||||
// room membership, kept deliberately MINIMAL (issue 0006b, audit 0008 N2):
|
||||
//
|
||||
// - "_INBOX.>" — request/reply plus the JetStream pull-consumer delivery
|
||||
// and publish-ack inboxes.
|
||||
// - "$JS.API.INFO" — account-level JetStream info (limits/usage counters). It
|
||||
// exposes NO room/user/key contents, so granting it leaks nothing.
|
||||
//
|
||||
// It NO LONGER contains "$JS.API.>". That broad grant was the N2 leak: it let any
|
||||
// registered peer drive the whole JetStream API and read the control-plane KV
|
||||
// buckets (KV_UNIBUS_users/rooms/members/room_keys) and the object store directly
|
||||
// over NATS, bypassing the HTTP authorization (requireMember and the own-endpoint
|
||||
// checks). JetStream API access is now granted PER ROOM, scoped to the stream of
|
||||
// each room the peer belongs to (jsSubjectsFor). Because the control-plane KV
|
||||
// streams (KV_UNIBUS_*) and the object store (OBJ_UNIBUS_*) are never a room
|
||||
// stream, they fall outside the closed allow set and are denied by default.
|
||||
var clientInfraSubjects = []string{"_INBOX.>", "$JS.API.INFO"}
|
||||
|
||||
// SubjectACLFor returns a function that maps a signing public key (lowercase
|
||||
// hex) to the data-plane subjects that identity may publish and subscribe to:
|
||||
// the subject of every room it belongs to, plus the client infrastructure
|
||||
// subjects. It reads the live membership store, so the permissions reflect the
|
||||
// identity's rooms at the moment it connects. A decode error or a store failure
|
||||
// is returned as an error so the caller can fail closed (deny the connection)
|
||||
// rather than grant open access.
|
||||
// roomStreamName is the JetStream stream name a persisted room maps to. It MUST
|
||||
// stay identical to pkg/client.streamName ("UNIBUS_" + sanitized roomID) so the
|
||||
// per-room ACL grants exactly the subjects the client's JetStream calls use. Room
|
||||
// ids are ULIDs (no '.'), so the sanitizing is a no-op in practice, but the rule
|
||||
// is replicated defensively so the producer (client) and the authorizer (this
|
||||
// ACL) never drift apart.
|
||||
func roomStreamName(roomID string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len("UNIBUS_") + len(roomID))
|
||||
b.WriteString("UNIBUS_")
|
||||
for _, r := range roomID {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune('_')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// jsSubjectsFor returns the MINIMAL JetStream API subjects a peer needs to use the
|
||||
// durable stream of ONE persisted room: create/update/info the stream, manage and
|
||||
// pull from its durable consumer, and ack deliveries. Every subject embeds this
|
||||
// room's stream name, so the grant cannot reach another room's stream nor any
|
||||
// control-plane stream (KV_UNIBUS_* / OBJ_UNIBUS_*). The wildcard layout matches
|
||||
// the NATS JetStream API subject grammar (the stream name is the trailing token
|
||||
// of single-verb requests and follows a two-token verb for MSG.GET / MSG.NEXT /
|
||||
// DURABLE.CREATE):
|
||||
//
|
||||
// $JS.API.STREAM.<verb>.<stream> verb in {CREATE,UPDATE,INFO,DELETE,PURGE,...}
|
||||
// $JS.API.STREAM.MSG.<op>.<stream> op in {GET,DELETE}
|
||||
// $JS.API.CONSUMER.<verb>.<stream> verb in {LIST,NAMES,CREATE(ephemeral)}
|
||||
// $JS.API.CONSUMER.<verb>.<stream>.<consumer>... verb in {CREATE,INFO,DELETE}
|
||||
// $JS.API.CONSUMER.<v1>.<v2>.<stream>.<cons> {MSG.NEXT, DURABLE.CREATE}
|
||||
// $JS.ACK.<stream>.> message acknowledgements
|
||||
func jsSubjectsFor(roomID string) []string {
|
||||
s := roomStreamName(roomID)
|
||||
return []string{
|
||||
"$JS.API.STREAM.*." + s,
|
||||
"$JS.API.STREAM.*.*." + s,
|
||||
"$JS.API.CONSUMER.*." + s,
|
||||
"$JS.API.CONSUMER.*." + s + ".>",
|
||||
"$JS.API.CONSUMER.*.*." + s + ".>",
|
||||
"$JS.ACK." + s + ".>",
|
||||
}
|
||||
}
|
||||
|
||||
// SubjectACLFor returns a function that maps a signing public key (lowercase hex)
|
||||
// to the data-plane subjects that identity may publish and subscribe to: the
|
||||
// subject of every room it belongs to, the per-room JetStream API subjects of
|
||||
// those rooms (so persisted-room history keeps working), plus the minimal client
|
||||
// infrastructure subjects. It reads the live membership store, so the permissions
|
||||
// reflect the identity's rooms at the moment it connects. A decode error or a
|
||||
// store failure is returned as an error so the caller can fail closed (deny the
|
||||
// connection) rather than grant open access.
|
||||
//
|
||||
// Because NATS freezes permissions at connect time, a peer invited to a new room
|
||||
// after connecting must reconnect (client.RefreshSession) to pick up the new
|
||||
@@ -42,10 +106,12 @@ func SubjectACLFor(store Store) func(signPubHex string) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("acl: list rooms for %s: %w", endpoint, err)
|
||||
}
|
||||
subjects := make([]string, 0, len(rooms)+len(clientInfraSubjects))
|
||||
// clientInfra + per room: the room subject + that room's JetStream API.
|
||||
subjects := make([]string, 0, len(clientInfraSubjects)+len(rooms)*7)
|
||||
subjects = append(subjects, clientInfraSubjects...)
|
||||
for _, r := range rooms {
|
||||
subjects = append(subjects, r.Subject)
|
||||
subjects = append(subjects, jsSubjectsFor(r.RoomID)...)
|
||||
}
|
||||
return subjects, nil
|
||||
}
|
||||
|
||||
@@ -229,14 +229,11 @@ func TestSubjectACLIsolation(t *testing.T) {
|
||||
// - golden: the member still pub/subs her own room, and the non-member never
|
||||
// captures that traffic.
|
||||
//
|
||||
// Residual (DOCUMENTED, not closed here): the client-infra grant includes
|
||||
// "$JS.API.>", shared by all peers so per-connection JetStream works. A peer that
|
||||
// subscribes specifically to "$JS.API.>" can still observe stream-management
|
||||
// requests whose subjects embed the stream name derived from a room id. Fully
|
||||
// closing that needs NATS accounts/permissions isolation per identity (deferred to
|
||||
// the 0003 decentralization line). The high-impact leak the auditor exploited —
|
||||
// the room subject itself and JetStream advisories captured via "Subscribe(\">\")"
|
||||
// — is closed.
|
||||
// Residual now CLOSED (issue 0006b, audit 0008 N2): the client-infra grant no
|
||||
// longer includes "$JS.API.>". JetStream API access is granted per-room only
|
||||
// (membership.jsSubjectsFor), so a peer can reach the API of its OWN rooms'
|
||||
// streams but not the control-plane KV buckets (KV_UNIBUS_*) nor another room's
|
||||
// stream. See TestAttack0008_N2 for the closed-leak regression.
|
||||
func TestReaudit_H4_WildcardMetadataLeak(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package membership_test
|
||||
|
||||
// Regression for audit report 0008, vector N2: with the broad "$JS.API.>" grant
|
||||
// removed (issue 0006b), a registered peer that belongs to no room can no longer
|
||||
// read the control-plane KV buckets over NATS, while the per-room JetStream API of
|
||||
// a peer's OWN rooms keeps working. The auditor's ephemeral attack populated the
|
||||
// KV control plane and had a registered non-member harvest the allowlist, the room
|
||||
// graph and the sealed-key metadata directly through "$JS.API.>".
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/busauth"
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
server "github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
// startACLNatsInternal is startACLNats plus a recognized internal service identity
|
||||
// (so the test can seed the KV control plane with full permissions, exactly as the
|
||||
// decentralized membershipd does at bootstrap).
|
||||
func startACLNatsInternal(t *testing.T, store membership.Store, internalPubHex string) *server.Server {
|
||||
t.Helper()
|
||||
auth := busauth.NewNkeyAuthenticatorACLInternal(store.IsAuthorized, aclPermsFunc(store), internalPubHex)
|
||||
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
||||
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: aclFreePort(t), Auth: auth,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("acl nats: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
||||
return ns
|
||||
}
|
||||
|
||||
// TestAttack0008_N2 reproduces the control-plane KV leak and proves it is closed.
|
||||
//
|
||||
// error : eve (registered, member of no room) cannot read the KV buckets — the
|
||||
// JetStream KV API and the raw $KV subject space are both denied.
|
||||
// golden: the owner of a persisted room can still drive the JetStream API of HER
|
||||
// OWN room's stream (so persisted-room history keeps working).
|
||||
// edge : eve cannot reach another room's stream API either (cross-room JS deny).
|
||||
func TestAttack0008_N2(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// The HTTP control-plane store stays SQLite; the KV buckets below stand in for
|
||||
// the decentralized control plane the attack targets.
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
ceo, eve, internalID := mustID(t), mustID(t), mustID(t)
|
||||
ceoEP := frame.EndpointID(ceo.SignPub)
|
||||
mustAddUser(t, store, ceo, "ceo-root-admin")
|
||||
mustAddUser(t, store, eve, "eve") // registered, member of nothing
|
||||
// A persisted room owned by ceo: ceo is a member, so her per-room JS is allowed.
|
||||
if err := store.CreateRoom(
|
||||
membership.RoomInfo{RoomID: "PRIVROOM", Subject: "room.board.ma-deal", Encrypt: true, Persist: true, OwnerEndpoint: ceoEP},
|
||||
ceo.SignPub, ceo.KexPub, []byte("sealed-self"),
|
||||
); err != nil {
|
||||
t.Fatalf("create room: %v", err)
|
||||
}
|
||||
|
||||
internalPubHex := hex.EncodeToString(internalID.SignPub)
|
||||
ns := startACLNatsInternal(t, store, internalPubHex)
|
||||
url := ns.ClientURL()
|
||||
|
||||
// Seed the KV control plane with the privileged internal identity (full perms),
|
||||
// simulating the decentralized buckets the attack reads.
|
||||
intErr := make(chan error, 4)
|
||||
intNC := nkeyConn(t, url, internalID, intErr)
|
||||
intJS, err := jetstream.New(intNC)
|
||||
if err != nil {
|
||||
t.Fatalf("internal jetstream: %v", err)
|
||||
}
|
||||
kvStore, err := membership.OpenJetStream(intJS, membership.JetStreamConfig{Replicas: 1, OpTimeout: 3 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("open kv buckets: %v", err)
|
||||
}
|
||||
if err := kvStore.AddUser(hex.EncodeToString(ceo.SignPub), "ceo-root-admin", membership.RoleAdmin); err != nil {
|
||||
t.Fatalf("seed kv user: %v", err)
|
||||
}
|
||||
|
||||
// Each JetStream op gets its own short context: a DENIED request never gets a
|
||||
// reply, so it blocks until its own deadline — a shared context would be
|
||||
// exhausted by the first denied call and starve the rest.
|
||||
freshCtx := func(d time.Duration) (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), d)
|
||||
}
|
||||
|
||||
// --- error: eve cannot read the control-plane KV buckets ------------------
|
||||
eveErr := make(chan error, 8)
|
||||
eveNC := nkeyConn(t, url, eve, eveErr)
|
||||
eveJS, err := jetstream.New(eveNC)
|
||||
if err != nil {
|
||||
t.Fatalf("eve jetstream: %v", err)
|
||||
}
|
||||
// (a) The KV API: binding the bucket requires STREAM.INFO.KV_UNIBUS_users, which
|
||||
// eve has no permission for, so this must fail (no leak of users).
|
||||
kvCtx, kvCancel := freshCtx(2 * time.Second)
|
||||
if kv, err := eveJS.KeyValue(kvCtx, "UNIBUS_users"); err == nil {
|
||||
if e, gerr := kv.Get(kvCtx, hex.EncodeToString(ceo.SignPub)); gerr == nil {
|
||||
kvCancel()
|
||||
t.Fatalf("eve read the control-plane KV users bucket: %q (N2 leak still open)", string(e.Value()))
|
||||
}
|
||||
kvCancel()
|
||||
t.Fatalf("eve was able to BIND the KV users bucket (N2 leak still open)")
|
||||
}
|
||||
kvCancel()
|
||||
// (b) The raw KV subject space: a direct subscribe must be a permissions
|
||||
// violation (delivered async to the error handler).
|
||||
drain(eveErr)
|
||||
if _, err := eveNC.Subscribe("$KV.UNIBUS_users.>", func(*nats.Msg) {}); err != nil {
|
||||
t.Fatalf("eve sub $KV: %v", err)
|
||||
}
|
||||
_ = eveNC.Flush()
|
||||
if e := waitErr(eveErr, 1*time.Second); e == nil {
|
||||
t.Fatalf("eve subscribing to $KV.UNIBUS_users.> must raise a permissions violation")
|
||||
}
|
||||
|
||||
// --- edge: eve cannot reach another room's stream API ---------------------
|
||||
edgeCtx, edgeCancel := freshCtx(2 * time.Second)
|
||||
if _, err := eveJS.Stream(edgeCtx, "UNIBUS_PRIVROOM"); err == nil {
|
||||
edgeCancel()
|
||||
t.Fatalf("eve reached the foreign room stream API (cross-room JS not isolated)")
|
||||
}
|
||||
edgeCancel()
|
||||
|
||||
// --- golden: ceo can drive the JetStream API of HER OWN room's stream ------
|
||||
ceoErr := make(chan error, 4)
|
||||
ceoNC := nkeyConn(t, url, ceo, ceoErr)
|
||||
ceoJS, err := jetstream.New(ceoNC)
|
||||
if err != nil {
|
||||
t.Fatalf("ceo jetstream: %v", err)
|
||||
}
|
||||
goldenCtx, goldenCancel := freshCtx(5 * time.Second)
|
||||
defer goldenCancel()
|
||||
if _, err := ceoJS.CreateOrUpdateStream(goldenCtx, jetstream.StreamConfig{
|
||||
Name: "UNIBUS_PRIVROOM",
|
||||
Subjects: []string{"room.board.ma-deal"},
|
||||
Storage: jetstream.FileStorage,
|
||||
}); err != nil {
|
||||
t.Fatalf("ceo could not manage her OWN room stream (per-room JS broken): %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package membership_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
)
|
||||
|
||||
// TestHealthExposesPosture: /healthz publishes the node's security posture so a
|
||||
// monitor (or a peer) can detect a cluster member that is not enforce+ACL+TLS
|
||||
// (audit 0008 N1). The probe stays unauthenticated.
|
||||
func TestHealthExposesPosture(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
||||
|
||||
srv := membership.NewServer(store, blobs, membership.AuthEnforce)
|
||||
srv.Posture = membership.Posture{Enforce: true, ACL: true, TLS: true, Cluster: true, Store: "kv"}
|
||||
ts := httptest.NewServer(srv)
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
resp, err := http.Get(ts.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("get healthz: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("healthz status %d, want 200", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
var got struct {
|
||||
Status string `json:"status"`
|
||||
Posture membership.Posture `json:"posture"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatalf("decode healthz %q: %v", string(body), err)
|
||||
}
|
||||
if got.Status != "ok" {
|
||||
t.Fatalf("status = %q, want ok", got.Status)
|
||||
}
|
||||
if !got.Posture.Enforce || !got.Posture.ACL || !got.Posture.TLS || !got.Posture.Cluster {
|
||||
t.Fatalf("posture not surfaced correctly: %+v", got.Posture)
|
||||
}
|
||||
if got.Posture.Store != "kv" {
|
||||
t.Fatalf("posture.store = %q, want kv", got.Posture.Store)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package membership_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
"github.com/enmanuel/unibus/pkg/client"
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/enmanuel/unibus/pkg/room"
|
||||
)
|
||||
|
||||
// TestClientCreateRoomRefreshPublishFlow is the issue 0006e DoD: under enforce+ACL
|
||||
// a peer creates a room AFTER connecting, and pub/sub works without manual
|
||||
// intervention because the client follows the membership-change contract
|
||||
// (CreateRoom -> RefreshSession -> Subscribe/Publish), exactly as cmd/chat and
|
||||
// cmd/worker now do. This is the end-to-end flow through the client API, proving
|
||||
// the ACL is usable under enforce rather than something an operator must disable.
|
||||
func TestClientCreateRoomRefreshPublishFlow(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
alice, bob := mustID(t), mustID(t)
|
||||
mustAddUser(t, store, alice, "alice")
|
||||
mustAddUser(t, store, bob, "bob")
|
||||
|
||||
srv := startACLNats(t, store) // data plane: enforce + per-subject ACL
|
||||
blobs, _ := blobstore.New(filepath.Join(dir, "blobs"))
|
||||
ctrl := newCtrl(t, store, blobs)
|
||||
|
||||
aliceC, err := client.NewWithOptions(srv.ClientURL(), ctrl, alice, client.Options{UseNkey: true})
|
||||
if err != nil {
|
||||
t.Fatalf("connect alice: %v", err)
|
||||
}
|
||||
defer aliceC.Close()
|
||||
bobC, err := client.NewWithOptions(srv.ClientURL(), ctrl, bob, client.Options{UseNkey: true})
|
||||
if err != nil {
|
||||
t.Fatalf("connect bob: %v", err)
|
||||
}
|
||||
defer bobC.Close()
|
||||
|
||||
// alice creates a room AFTER connecting: the subject was not in her ACL at
|
||||
// connect time, so she must refresh to publish on it (the worker contract).
|
||||
roomID, err := aliceC.CreateRoom("room.flow.x", room.ModeNATS)
|
||||
if err != nil {
|
||||
t.Fatalf("alice create room: %v", err)
|
||||
}
|
||||
if err := aliceC.RefreshSession(); err != nil {
|
||||
t.Fatalf("alice refresh: %v", err)
|
||||
}
|
||||
|
||||
// alice invites bob; bob joins then refreshes to gain the subject (the chat
|
||||
// subscriber contract), and only then subscribes.
|
||||
if err := aliceC.Invite(roomID, bobC.Endpoint()); err != nil {
|
||||
t.Fatalf("alice invite bob: %v", err)
|
||||
}
|
||||
if err := bobC.Join(roomID); err != nil {
|
||||
t.Fatalf("bob join: %v", err)
|
||||
}
|
||||
if err := bobC.RefreshSession(); err != nil {
|
||||
t.Fatalf("bob refresh: %v", err)
|
||||
}
|
||||
got := make(chan string, 4)
|
||||
sub, err := bobC.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) { got <- string(plaintext) })
|
||||
if err != nil {
|
||||
t.Fatalf("bob subscribe after refresh: %v", err)
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
time.Sleep(200 * time.Millisecond) // let the subscription settle
|
||||
|
||||
if err := aliceC.Publish(roomID, []byte("hello-under-acl")); err != nil {
|
||||
t.Fatalf("alice publish after refresh: %v", err)
|
||||
}
|
||||
select {
|
||||
case msg := <-got:
|
||||
if msg != "hello-under-acl" {
|
||||
t.Fatalf("bob got %q", msg)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("bob did not receive the message: the create->refresh->subscribe flow is broken under enforce+ACL")
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,25 @@ type Server struct {
|
||||
// (non-loopback) bind. See dev/0004d-dataplane-acl.md for the full rationale
|
||||
// and the residual metadata exposure this does NOT close.
|
||||
RequireEncryptedRooms bool
|
||||
|
||||
// Posture is the node's security posture, surfaced on /healthz so an operator
|
||||
// or a peer can detect a node NOT running the homogeneous enforce+ACL+TLS
|
||||
// posture a secure cluster requires (audit 0008 N1). It is set by the command;
|
||||
// the zero value (all false) reflects an unsecured dev node.
|
||||
Posture Posture
|
||||
}
|
||||
|
||||
// Posture describes the security posture a membershipd node runs with. It is
|
||||
// non-secret operational metadata (booleans + the store backend name), published
|
||||
// on /healthz so a monitor can flag a cluster member that is not enforce+ACL+TLS
|
||||
// — the weak node that would let an unauthenticated peer harvest the cluster's
|
||||
// forwarded traffic (audit 0008 N1).
|
||||
type Posture struct {
|
||||
Enforce bool `json:"enforce"`
|
||||
ACL bool `json:"acl"`
|
||||
TLS bool `json:"tls"`
|
||||
Cluster bool `json:"cluster"`
|
||||
Store string `json:"store"` // "sqlite" | "kv"
|
||||
}
|
||||
|
||||
// NewServer wires the membership store and blob store into an http.Handler. The
|
||||
@@ -390,7 +409,7 @@ func (s *Server) verifyOwnerSig(roomID, by string, sig, canonical []byte) (Membe
|
||||
// ---- handlers -------------------------------------------------------------
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "posture": s.Posture})
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
# unibus playground
|
||||
|
||||
An all-in-one, web-based sandbox for the **unibus** message bus. One command
|
||||
brings up the entire stack embedded — no NATS to install, no services to wire —
|
||||
and a browser UI lets you exercise the bus visually: create peers, create and
|
||||
join rooms (cleartext or end-to-end encrypted), invite, publish, watch messages
|
||||
arrive live, and kick members (forward secrecy).
|
||||
|
||||
This is a **playground** (see `.claude/rules/playgrounds.md`): it lives inside
|
||||
the `unibus` app, reuses the parent Go module (no separate `go.mod`), is not
|
||||
indexed, and keeps all runtime state under `playground/local_files/` (ephemeral,
|
||||
safe to delete).
|
||||
|
||||
## Run
|
||||
|
||||
From the `unibus` app directory:
|
||||
|
||||
```bash
|
||||
cd /home/enmanuel/fn_registry/projects/message_bus/apps/unibus
|
||||
go run ./playground
|
||||
```
|
||||
|
||||
Then open **http://localhost:7700** in your browser.
|
||||
|
||||
Stop with `Ctrl-C` — the server tears down the web UI, every bus client, the
|
||||
control plane, and the embedded NATS cleanly (no orphaned processes).
|
||||
|
||||
## Architecture
|
||||
|
||||
The browser never speaks NATS. The Go server is the actual bus peer:
|
||||
|
||||
```
|
||||
browser ──fetch/SSE──▶ playground server (:7700)
|
||||
│ holds one unibus client per named peer
|
||||
├──HTTP──▶ membership control plane (127.0.0.1:8480)
|
||||
└──NATS──▶ embedded NATS + JetStream (:4260)
|
||||
```
|
||||
|
||||
- **:7700** — web UI (the only browser-facing port).
|
||||
- **127.0.0.1:8480** — membership control plane (rooms, members, sealed keys,
|
||||
rekey, blobs). Internal only.
|
||||
- **:4260** — embedded NATS + JetStream (the data plane). Internal only.
|
||||
|
||||
Each named peer gets its own long-term identity, persisted to
|
||||
`playground/local_files/<name>.id`, so a peer keeps the same endpoint across
|
||||
restarts. When a peer creates or joins a room, the server subscribes on its
|
||||
behalf and streams every received frame to that peer's open browser tabs over
|
||||
Server-Sent Events.
|
||||
|
||||
The playground only orchestrates the public unibus client API
|
||||
(`CreateRoom`, `Join`, `Subscribe`, `Publish`, `Invite`, `Kick`); it never
|
||||
reimplements bus or crypto logic.
|
||||
|
||||
## Try it: 2 peers + encryption + kick
|
||||
|
||||
1. Open **two browser tabs** on http://localhost:7700.
|
||||
2. Tab A: type `alice`, click **Connect**.
|
||||
3. Tab B: type `bob`, click **Connect**.
|
||||
4. Tab A (alice): type a subject like `room.general`, tick **🔒 encrypted
|
||||
(E2E)**, click **Create room**. Copy the resulting `room_id`.
|
||||
5. Tab A (alice): in the Action panel, pick `bob` as the target peer (use the
|
||||
↻ button to refresh the peer list if needed) and click **Invite to this
|
||||
room**.
|
||||
6. Tab B (bob): paste the `room_id` into the join field and click **Join**.
|
||||
7. Type messages in **both** tabs and hit Send — each message appears live in
|
||||
both tabs, tagged with subject, sender, time, and 🔒 (encrypted) or `clear`.
|
||||
8. Tab A (alice): click **Kick from this room** with `bob` selected. The room
|
||||
key rotates to a new epoch. New messages alice sends are no longer visible to
|
||||
bob — **forward secrecy**: bob no longer holds the current key.
|
||||
|
||||
Cleartext rooms (leave the checkbox unticked) behave like plain NATS fan-out:
|
||||
fast, ephemeral, unsigned. Encrypted rooms are the Matrix-like mode: E2E
|
||||
encrypted, persisted, and per-message signed.
|
||||
|
||||
## Benchmark: throughput simulator
|
||||
|
||||
The bottom panel of the UI is a performance simulator. Press **▶ Ejecutar
|
||||
benchmark** and one publisher floods a fresh room with thousands of messages
|
||||
that N subscribers receive (fan-out); a live canvas chart animates the sent vs
|
||||
received totals while it runs.
|
||||
|
||||
The two policy axes are exposed as **independent flags**, so the benchmark
|
||||
measures the cost of each layer in isolation:
|
||||
|
||||
| JetStream | Encryption | Room policy | What it costs |
|
||||
|---|---|---|---|
|
||||
| off | off | `{Encrypt:false, Persist:false}` | plain core NATS fan-out |
|
||||
| **on** | off | `{Encrypt:false, Persist:true}` | durable JetStream (publish ack per message) |
|
||||
| off | **on** | `{Encrypt:true, Persist:false}` | AEAD + Ed25519 signature per message, core transport |
|
||||
| **on** | **on** | `{Encrypt:true, Persist:true}` | full E2E + durable history |
|
||||
|
||||
A **payload size** slider (16 B – 8 KiB) sets the message size. Encrypted or
|
||||
persistent runs are capped to 30 000 messages (each message pays per-message
|
||||
crypto and/or a JetStream ack, so they run much slower than plain NATS).
|
||||
|
||||
The benchmark uses its own ephemeral peers (fresh identities, never persisted),
|
||||
so it never touches the named peers of the manual sandbox.
|
||||
|
||||
It is driven by an SSE endpoint that streams progress samples:
|
||||
|
||||
```bash
|
||||
curl -N "http://localhost:7700/api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0"
|
||||
# emits: data: {"type":"start",...} data: {"type":"sample",...} data: {"type":"done",...}
|
||||
```
|
||||
|
||||
Query params: `n_msgs`, `n_subs` (1–16), `payload` (bytes), `encrypt` (0/1),
|
||||
`persist` (0/1).
|
||||
|
||||
## State / cleanup
|
||||
|
||||
All writable state lives under `playground/local_files/`:
|
||||
|
||||
- `<name>.id` — per-peer identity (private keys; treat like an SSH key).
|
||||
- `play.db` — membership store (rooms, members, sealed keys).
|
||||
- `blobs/` — media blob store.
|
||||
- `js/` — embedded JetStream store.
|
||||
|
||||
Delete the whole `playground/local_files/` directory to reset to a clean slate.
|
||||
It is gitignored and never distributed.
|
||||
@@ -1,594 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>unibus playground</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--panel: #161b22;
|
||||
--panel2: #1c2230;
|
||||
--border: #2b333f;
|
||||
--fg: #e6edf3;
|
||||
--muted: #8b98a5;
|
||||
--accent: #2f81f7;
|
||||
--green: #3fb950;
|
||||
--gold: #d29922;
|
||||
--red: #f85149;
|
||||
--mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
header h1 { margin: 0; font-size: 18px; letter-spacing: 0.5px; }
|
||||
header .sub { color: var(--muted); font-size: 12px; }
|
||||
.wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.col { display: flex; flex-direction: column; gap: 14px; }
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.card h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--muted);
|
||||
}
|
||||
label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 3px; }
|
||||
input[type=text], select {
|
||||
width: 100%;
|
||||
background: var(--panel2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
padding: 7px 9px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||
.row { display: flex; gap: 8px; align-items: center; }
|
||||
.row > * { flex: 1; }
|
||||
.checkrow { display: flex; align-items: center; gap: 6px; margin: 10px 0; }
|
||||
.checkrow input { flex: 0 0 auto; width: auto; }
|
||||
.checkrow label { margin: 0; flex: 0 0 auto; }
|
||||
button {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #fff;
|
||||
padding: 7px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-family: var(--mono);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
button:hover { filter: brightness(1.12); }
|
||||
button.ghost { background: var(--panel2); border: 1px solid var(--border); color: var(--fg); }
|
||||
button.danger { background: #3a1d1d; border: 1px solid var(--red); color: var(--red); }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.pill {
|
||||
display: inline-block;
|
||||
background: var(--panel2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 2px 9px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.pill.on { color: var(--green); border-color: var(--green); }
|
||||
.ident { word-break: break-all; font-size: 11px; color: var(--gold); margin-top: 6px; }
|
||||
.copy {
|
||||
cursor: pointer; color: var(--accent); font-size: 11px;
|
||||
margin-left: 6px; text-decoration: underline;
|
||||
}
|
||||
#log {
|
||||
background: #08090c;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
height: 520px;
|
||||
overflow-y: auto;
|
||||
font-size: 12.5px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.msg { padding: 2px 0; border-bottom: 1px solid #11151b; }
|
||||
.msg .subj { color: var(--accent); }
|
||||
.msg .from { color: var(--gold); }
|
||||
.msg .meta { color: var(--muted); font-size: 11px; }
|
||||
.msg .enc { color: var(--green); }
|
||||
.msg .clear { color: var(--muted); }
|
||||
.sys { color: var(--muted); font-style: italic; }
|
||||
.err { color: var(--red); }
|
||||
.help {
|
||||
background: var(--panel2);
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.help b { color: var(--fg); }
|
||||
.help code { color: var(--gold); }
|
||||
.status { font-size: 11px; color: var(--muted); margin-top: 6px; min-height: 14px; }
|
||||
.status.ok { color: var(--green); }
|
||||
.status.bad { color: var(--red); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>unibus playground</h1>
|
||||
<span class="sub">embedded NATS + JetStream · E2E rooms · forward secrecy · SSE</span>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
<!-- LEFT COLUMN: controls -->
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<h2>1 · Identity</h2>
|
||||
<label>Peer name</label>
|
||||
<div class="row">
|
||||
<input id="peerName" type="text" placeholder="alice" autocomplete="off" />
|
||||
<button id="connectBtn" style="flex:0 0 auto">Connect</button>
|
||||
</div>
|
||||
<div id="peerIdent" class="ident"></div>
|
||||
<div id="connStatus" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2 · Rooms</h2>
|
||||
<label>Subject (e.g. room.general)</label>
|
||||
<input id="roomSubject" type="text" placeholder="room.general" autocomplete="off" />
|
||||
<div class="checkrow">
|
||||
<input id="roomEncrypt" type="checkbox" />
|
||||
<label for="roomEncrypt">🔒 encrypted (E2E)</label>
|
||||
</div>
|
||||
<div class="checkrow">
|
||||
<input id="roomPersist" type="checkbox" />
|
||||
<label for="roomPersist">🗂 persistente (historial)</label>
|
||||
</div>
|
||||
<div class="help" style="margin:-4px 0 8px; font-size:12px; color:var(--muted)">
|
||||
persistente = quien se une despues ve el historial; sin persistir = solo mensajes nuevos (NATS simple).
|
||||
</div>
|
||||
<button id="createRoomBtn" disabled>Create room</button>
|
||||
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
|
||||
<label>Join by room_id</label>
|
||||
<input id="joinRoomId" type="text" placeholder="01J..." autocomplete="off" />
|
||||
<button id="joinBtn" class="ghost" disabled>Join</button>
|
||||
<div id="roomStatus" class="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3 · Action</h2>
|
||||
<label>Active room</label>
|
||||
<select id="activeRoom"></select>
|
||||
<label>Message</label>
|
||||
<div class="row">
|
||||
<input id="msgText" type="text" placeholder="hello bus" autocomplete="off" />
|
||||
<button id="sendBtn" style="flex:0 0 auto" disabled>Send</button>
|
||||
</div>
|
||||
<div style="border-top:1px solid var(--border); margin:12px 0"></div>
|
||||
<label>Target peer</label>
|
||||
<div class="row">
|
||||
<select id="targetPeer"></select>
|
||||
<button id="refreshPeersBtn" class="ghost" style="flex:0 0 auto" title="reload peer list">↻</button>
|
||||
</div>
|
||||
<button id="inviteBtn" disabled>Invite to this room</button>
|
||||
<button id="kickBtn" class="danger" disabled>Kick from this room</button>
|
||||
<div id="actionStatus" class="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: live messages + help -->
|
||||
<div class="col">
|
||||
<div class="card" style="padding-bottom:8px">
|
||||
<h2>Live messages <span id="streamPill" class="pill">disconnected</span></h2>
|
||||
<div id="log"></div>
|
||||
</div>
|
||||
<div class="help">
|
||||
<b>ⓘ How to try it</b><br />
|
||||
Open <b>2 tabs</b>. Connect as <code>alice</code> in one and <code>bob</code> in the other.
|
||||
In alice: create a <code>🔒 encrypted</code> room, copy the <code>room_id</code>,
|
||||
then pick <code>bob</code> as target and <b>Invite to this room</b>.
|
||||
In bob: paste that <code>room_id</code> and <b>Join</b>.
|
||||
Type in both → messages appear live on each side.
|
||||
In alice: <b>Kick</b> bob → bob stops seeing new messages (forward secrecy: the room
|
||||
key rotates and bob no longer holds it).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BENCHMARK: full-width performance simulator -->
|
||||
<div style="padding: 0 20px 32px; max-width: 1200px;">
|
||||
<div class="card">
|
||||
<h2>Benchmark de rendimiento · 1 publisher → N subscribers</h2>
|
||||
<div style="display:flex; gap:26px; flex-wrap:wrap; align-items:flex-end; margin-bottom:6px;">
|
||||
<div style="min-width:230px;">
|
||||
<label>Mensajes a publicar · <span id="bMsgsVal" style="color:var(--fg)">20 000</span></label>
|
||||
<input id="bMsgs" type="range" min="1000" max="200000" step="1000" value="20000" style="width:100%; accent-color:var(--accent);" />
|
||||
</div>
|
||||
<div style="min-width:160px;">
|
||||
<label>Subscribers · <span id="bSubsVal" style="color:var(--fg)">3</span></label>
|
||||
<input id="bSubs" type="range" min="1" max="16" step="1" value="3" style="width:100%; accent-color:var(--accent);" />
|
||||
</div>
|
||||
<div style="min-width:200px;">
|
||||
<label>Tamaño payload · <span id="bPayVal" style="color:var(--fg)">128 B</span></label>
|
||||
<input id="bPay" type="range" min="16" max="8192" step="16" value="128" style="width:100%; accent-color:var(--accent);" />
|
||||
</div>
|
||||
<div class="checkrow" style="margin:0;">
|
||||
<input id="bPersist" type="checkbox" />
|
||||
<label for="bPersist">🗂 JetStream (persistente)</label>
|
||||
</div>
|
||||
<div class="checkrow" style="margin:0;">
|
||||
<input id="bEncrypt" type="checkbox" />
|
||||
<label for="bEncrypt">🔒 Encriptación E2E</label>
|
||||
</div>
|
||||
<button id="bRun" style="margin:0;">▶ Ejecutar benchmark</button>
|
||||
</div>
|
||||
<div class="help" style="margin:6px 0 12px;">
|
||||
<b>JetStream</b> y <b>Encriptación</b> son ejes independientes: NATS core (ambos off) · JetStream durable · E2E (AEAD + firma Ed25519 por mensaje) · E2E + JetStream. Los modos con cripto o persistencia se limitan a 30 000 mensajes (cada mensaje paga cifrado/firma/ack).
|
||||
</div>
|
||||
<div style="display:flex; gap:30px; flex-wrap:wrap; margin:4px 2px 8px;">
|
||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Enviados</div><div id="bSent" style="font-size:22px; color:var(--accent);">0</div></div>
|
||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Recibidos (Σ subs)</div><div id="bRecv" style="font-size:22px; color:var(--green);">0</div></div>
|
||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Throughput recv</div><div id="bTps" style="font-size:22px; color:var(--gold);">0</div></div>
|
||||
<div><div style="font-size:11px; color:var(--muted); text-transform:uppercase; letter-spacing:.05em;">Tiempo</div><div id="bTime" style="font-size:22px;">0.00 s</div></div>
|
||||
</div>
|
||||
<canvas id="bChart" style="width:100%; height:300px; display:block; background:#08090c; border:1px solid var(--border); border-radius:8px;"></canvas>
|
||||
<div style="display:flex; gap:18px; font-size:12px; color:var(--muted); margin-top:6px;">
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--accent);margin-right:6px;"></span>enviados (publisher)</span>
|
||||
<span><span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--green);margin-right:6px;"></span>recibidos (suma de subscribers)</span>
|
||||
</div>
|
||||
<div id="bStatus" class="status" style="margin-top:8px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
|
||||
const state = {
|
||||
peer: null, // connected peer name
|
||||
rooms: {}, // room_id -> {subject, encrypt}
|
||||
es: null, // EventSource
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
async function api(path, body) {
|
||||
const opts = { method: "POST", headers: { "Content-Type": "application/json" } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(path, opts);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
||||
return data;
|
||||
}
|
||||
async function apiGet(path) {
|
||||
const res = await fetch(path);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
||||
return data;
|
||||
}
|
||||
|
||||
function setStatus(id, msg, kind) {
|
||||
const el = $(id);
|
||||
el.textContent = msg || "";
|
||||
el.className = "status" + (kind ? " " + kind : "");
|
||||
}
|
||||
|
||||
function short(s, n = 10) {
|
||||
if (!s) return "";
|
||||
return s.length <= n * 2 ? s : s.slice(0, n) + "…" + s.slice(-4);
|
||||
}
|
||||
|
||||
function hhmmss(ms) {
|
||||
const d = new Date(ms);
|
||||
const p = (x) => String(x).padStart(2, "0");
|
||||
return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds());
|
||||
}
|
||||
|
||||
function logSys(text, cls) {
|
||||
const log = $("log");
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg " + (cls || "sys");
|
||||
div.textContent = text;
|
||||
log.appendChild(div);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function logMsg(ev) {
|
||||
const log = $("log");
|
||||
const div = document.createElement("div");
|
||||
div.className = "msg";
|
||||
const enc = ev.encrypted
|
||||
? '<span class="enc">🔒</span>'
|
||||
: '<span class="clear">clear</span>';
|
||||
div.innerHTML =
|
||||
'<span class="subj">[' + escapeHtml(ev.subject) + ']</span> ' +
|
||||
'<span class="from">' + escapeHtml(short(ev.sender)) + '</span> ↦ ' +
|
||||
escapeHtml(ev.text) +
|
||||
' <span class="meta">· ' + hhmmss(ev.ts) + ' · ' + enc + '</span>';
|
||||
log.appendChild(div);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function refreshRoomSelect() {
|
||||
const sel = $("activeRoom");
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = "";
|
||||
for (const [id, info] of Object.entries(state.rooms)) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = id;
|
||||
opt.textContent = info.subject + " (" + short(id, 6) + ")" + (info.encrypt ? " 🔒" : "");
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if (state.rooms[cur]) sel.value = cur;
|
||||
const has = Object.keys(state.rooms).length > 0;
|
||||
$("sendBtn").disabled = !has;
|
||||
$("inviteBtn").disabled = !has;
|
||||
$("kickBtn").disabled = !has;
|
||||
}
|
||||
|
||||
async function refreshPeers() {
|
||||
try {
|
||||
const peers = await apiGet("/api/peers");
|
||||
const sel = $("targetPeer");
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = "";
|
||||
for (const p of peers) {
|
||||
if (p.name === state.peer) continue; // don't target yourself
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p.name;
|
||||
opt.textContent = p.name + " (" + short(p.endpoint_id, 6) + ")";
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
if ([...sel.options].some((o) => o.value === cur)) sel.value = cur;
|
||||
} catch (e) {
|
||||
setStatus("actionStatus", "peers: " + e.message, "bad");
|
||||
}
|
||||
}
|
||||
|
||||
function openStream(name) {
|
||||
if (state.es) state.es.close();
|
||||
const es = new EventSource("/api/stream?peer=" + encodeURIComponent(name));
|
||||
es.onopen = () => {
|
||||
$("streamPill").textContent = "live: " + name;
|
||||
$("streamPill").className = "pill on";
|
||||
};
|
||||
es.onmessage = (e) => {
|
||||
try { logMsg(JSON.parse(e.data)); } catch (_) {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
$("streamPill").textContent = "reconnecting…";
|
||||
$("streamPill").className = "pill";
|
||||
};
|
||||
state.es = es;
|
||||
}
|
||||
|
||||
// ---- handlers ----
|
||||
|
||||
$("connectBtn").onclick = async () => {
|
||||
const name = $("peerName").value.trim();
|
||||
if (!name) { setStatus("connStatus", "enter a name", "bad"); return; }
|
||||
try {
|
||||
const res = await api("/api/peer", { name });
|
||||
state.peer = res.name;
|
||||
state.rooms = {};
|
||||
refreshRoomSelect();
|
||||
$("peerIdent").innerHTML =
|
||||
'endpoint: ' + escapeHtml(res.endpoint_id) +
|
||||
' <span class="copy" id="copyId">copy</span>';
|
||||
$("copyId").onclick = () => navigator.clipboard.writeText(res.endpoint_id);
|
||||
setStatus("connStatus", "connected as " + res.name, "ok");
|
||||
$("createRoomBtn").disabled = false;
|
||||
$("joinBtn").disabled = false;
|
||||
$("log").innerHTML = "";
|
||||
logSys("connected as " + res.name + " — listening for messages");
|
||||
openStream(res.name);
|
||||
refreshPeers();
|
||||
} catch (e) {
|
||||
setStatus("connStatus", e.message, "bad");
|
||||
}
|
||||
};
|
||||
|
||||
$("createRoomBtn").onclick = async () => {
|
||||
const subject = $("roomSubject").value.trim();
|
||||
const encrypt = $("roomEncrypt").checked;
|
||||
const persist = $("roomPersist").checked;
|
||||
if (!subject) { setStatus("roomStatus", "subject required", "bad"); return; }
|
||||
try {
|
||||
const res = await api("/api/room", { peer: state.peer, subject, encrypt, persist });
|
||||
state.rooms[res.room_id] = { subject: res.subject, encrypt: res.encrypt };
|
||||
refreshRoomSelect();
|
||||
$("activeRoom").value = res.room_id;
|
||||
setStatus("roomStatus", "created " + res.room_id + " (click to copy)", "ok");
|
||||
$("roomStatus").style.cursor = "pointer";
|
||||
$("roomStatus").onclick = () => navigator.clipboard.writeText(res.room_id);
|
||||
logSys("created room " + res.subject + " [" + short(res.room_id) + "]" + (encrypt ? " 🔒" : "") + (res.persist ? " 🗄" : ""));
|
||||
} catch (e) {
|
||||
setStatus("roomStatus", e.message, "bad");
|
||||
}
|
||||
};
|
||||
|
||||
$("joinBtn").onclick = async () => {
|
||||
const roomId = $("joinRoomId").value.trim();
|
||||
if (!roomId) { setStatus("roomStatus", "room_id required", "bad"); return; }
|
||||
try {
|
||||
const res = await api("/api/join", { peer: state.peer, room_id: roomId });
|
||||
state.rooms[roomId] = { subject: res.subject, encrypt: res.encrypt };
|
||||
refreshRoomSelect();
|
||||
$("activeRoom").value = roomId;
|
||||
setStatus("roomStatus", "joined " + res.subject + (res.encrypt ? " 🔒" : ""), "ok");
|
||||
logSys("joined room " + res.subject + " [" + short(roomId) + "]");
|
||||
} catch (e) {
|
||||
setStatus("roomStatus", e.message, "bad");
|
||||
}
|
||||
};
|
||||
|
||||
$("sendBtn").onclick = async () => {
|
||||
const roomId = $("activeRoom").value;
|
||||
const text = $("msgText").value;
|
||||
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
||||
try {
|
||||
await api("/api/publish", { peer: state.peer, room_id: roomId, text });
|
||||
$("msgText").value = "";
|
||||
setStatus("actionStatus", "sent", "ok");
|
||||
} catch (e) {
|
||||
setStatus("actionStatus", e.message, "bad");
|
||||
}
|
||||
};
|
||||
$("msgText").addEventListener("keydown", (e) => { if (e.key === "Enter") $("sendBtn").click(); });
|
||||
|
||||
$("inviteBtn").onclick = async () => {
|
||||
const roomId = $("activeRoom").value;
|
||||
const target = $("targetPeer").value;
|
||||
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
||||
if (!target) { setStatus("actionStatus", "no target peer (connect another peer first)", "bad"); return; }
|
||||
try {
|
||||
await api("/api/invite", { peer: state.peer, room_id: roomId, target });
|
||||
setStatus("actionStatus", "invited " + target, "ok");
|
||||
logSys("invited " + target + " to " + short(roomId));
|
||||
} catch (e) {
|
||||
setStatus("actionStatus", e.message, "bad");
|
||||
}
|
||||
};
|
||||
|
||||
$("kickBtn").onclick = async () => {
|
||||
const roomId = $("activeRoom").value;
|
||||
const target = $("targetPeer").value;
|
||||
if (!roomId) { setStatus("actionStatus", "select a room", "bad"); return; }
|
||||
if (!target) { setStatus("actionStatus", "no target peer", "bad"); return; }
|
||||
try {
|
||||
await api("/api/kick", { peer: state.peer, room_id: roomId, target });
|
||||
setStatus("actionStatus", "kicked " + target + " (key rotated)", "ok");
|
||||
logSys("kicked " + target + " from " + short(roomId) + " — key rotated (forward secrecy)");
|
||||
} catch (e) {
|
||||
setStatus("actionStatus", e.message, "bad");
|
||||
}
|
||||
};
|
||||
|
||||
$("refreshPeersBtn").onclick = refreshPeers;
|
||||
$("peerName").addEventListener("keydown", (e) => { if (e.key === "Enter") $("connectBtn").click(); });
|
||||
|
||||
// ---- benchmark ----
|
||||
const fmtN = (n) => Number(n).toLocaleString("es-ES");
|
||||
const bMsgs = $("bMsgs"), bSubs = $("bSubs"), bPay = $("bPay");
|
||||
bMsgs.oninput = () => $("bMsgsVal").textContent = fmtN(+bMsgs.value);
|
||||
bSubs.oninput = () => $("bSubsVal").textContent = bSubs.value;
|
||||
bPay.oninput = () => $("bPayVal").textContent = fmtN(+bPay.value) + " B";
|
||||
|
||||
let bSamples = [], bRunning = false, bES = null;
|
||||
const bCanvas = $("bChart"), bCtx = bCanvas.getContext("2d");
|
||||
function cssVar(n) { return getComputedStyle(document.documentElement).getPropertyValue(n).trim(); }
|
||||
|
||||
function bResize() {
|
||||
const dpr = window.devicePixelRatio || 1, r = bCanvas.getBoundingClientRect();
|
||||
bCanvas.width = r.width * dpr; bCanvas.height = r.height * dpr;
|
||||
bCtx.setTransform(dpr, 0, 0, dpr, 0, 0); bDraw();
|
||||
}
|
||||
window.addEventListener("resize", bResize);
|
||||
|
||||
function bDraw() {
|
||||
const r = bCanvas.getBoundingClientRect(), W = r.width, H = r.height;
|
||||
const padL = 70, padR = 14, padT = 12, padB = 26;
|
||||
bCtx.clearRect(0, 0, W, H);
|
||||
const tMax = bSamples.length ? Math.max(bSamples[bSamples.length - 1].t, 0.001) : 1;
|
||||
const yMax = bSamples.length ? Math.max(...bSamples.map(s => Math.max(s.sent, s.recv)), 1) : 1;
|
||||
bCtx.strokeStyle = "#2b333f"; bCtx.fillStyle = "#8b98a5"; bCtx.font = "11px ui-monospace";
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const yy = (H - padB) - (i / 5) * (H - padT - padB);
|
||||
bCtx.beginPath(); bCtx.moveTo(padL, yy); bCtx.lineTo(W - padR, yy); bCtx.stroke();
|
||||
bCtx.textAlign = "right"; bCtx.fillText(fmtN(Math.round((i / 5) * yMax)), padL - 8, yy + 3);
|
||||
}
|
||||
bCtx.textAlign = "center";
|
||||
bCtx.fillText("0 s", padL, H - padB + 15);
|
||||
bCtx.fillText(tMax.toFixed(2) + " s", W - padR, H - padB + 15);
|
||||
if (bSamples.length < 2) return;
|
||||
const x = (t) => padL + (t / tMax) * (W - padL - padR);
|
||||
const y = (v) => (H - padB) - (v / yMax) * (H - padT - padB);
|
||||
const line = (key, color) => {
|
||||
bCtx.beginPath(); bCtx.lineWidth = 2.2; bCtx.strokeStyle = color;
|
||||
bSamples.forEach((s, i) => { const px = x(s.t), py = y(s[key]); i ? bCtx.lineTo(px, py) : bCtx.moveTo(px, py); });
|
||||
bCtx.stroke();
|
||||
};
|
||||
line("sent", cssVar("--accent"));
|
||||
line("recv", cssVar("--green"));
|
||||
}
|
||||
|
||||
function bSetRunning(v) { bRunning = v; $("bRun").disabled = v; }
|
||||
|
||||
$("bRun").onclick = () => {
|
||||
if (bRunning) return;
|
||||
bSamples = []; bSetRunning(true);
|
||||
$("bSent").textContent = "0"; $("bRecv").textContent = "0"; $("bTps").textContent = "0"; $("bTime").textContent = "0.00 s";
|
||||
setStatus("bStatus", "conectando…");
|
||||
const qs = new URLSearchParams({
|
||||
n_msgs: bMsgs.value, n_subs: bSubs.value, payload: bPay.value,
|
||||
encrypt: $("bEncrypt").checked ? "1" : "0", persist: $("bPersist").checked ? "1" : "0",
|
||||
});
|
||||
const es = new EventSource("/api/bench?" + qs.toString());
|
||||
bES = es;
|
||||
const finish = () => { try { es.close(); } catch (_) {} bSetRunning(false); };
|
||||
es.addEventListener("end", finish);
|
||||
es.onmessage = (e) => {
|
||||
let m; try { m = JSON.parse(e.data); } catch (_) { return; }
|
||||
if (m.type === "start") {
|
||||
setStatus("bStatus",
|
||||
"corriendo… " + fmtN(m.n_msgs) + " msgs → " + m.n_subs + " subs · payload " + fmtN(m.payload) + "B"
|
||||
+ (m.encrypt ? " · \u{1F512} E2E" : "") + (m.persist ? " · \u{1F5C4} JetStream" : "")
|
||||
+ (m.capped ? " · (limitado a 30k)" : ""), "");
|
||||
} else if (m.type === "sample") {
|
||||
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
|
||||
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv); $("bTime").textContent = m.t.toFixed(2) + " s";
|
||||
if (bSamples.length >= 2) {
|
||||
const a = bSamples[bSamples.length - 2], b = bSamples[bSamples.length - 1], dt = b.t - a.t;
|
||||
if (dt > 0) $("bTps").textContent = fmtN(Math.round((b.recv - a.recv) / dt));
|
||||
}
|
||||
bDraw();
|
||||
} else if (m.type === "done") {
|
||||
bSamples.push({ t: m.t, sent: m.sent, recv: m.recv });
|
||||
$("bSent").textContent = fmtN(m.sent); $("bRecv").textContent = fmtN(m.recv);
|
||||
$("bTps").textContent = fmtN(m.recv_tps); $("bTime").textContent = m.t.toFixed(2) + " s";
|
||||
setStatus("bStatus",
|
||||
"✓ " + m.t.toFixed(2) + "s · pub " + fmtN(m.pub_tps) + "/s · recv " + fmtN(m.recv_tps) + "/s · fan-out ×"
|
||||
+ m.n_subs + " · por sub [" + (m.per_sub || []).map(fmtN).join(", ") + "]", "ok");
|
||||
bDraw(); finish();
|
||||
} else if (m.type === "error") {
|
||||
setStatus("bStatus", "error: " + m.msg, "bad"); finish();
|
||||
}
|
||||
};
|
||||
es.onerror = () => { if (bRunning) { setStatus("bStatus", "conexión SSE perdida", "bad"); finish(); } };
|
||||
};
|
||||
|
||||
bResize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,933 +0,0 @@
|
||||
// Command playground is an all-in-one, web-based sandbox for the unibus message
|
||||
// bus. A single `go run ./playground` launches the entire stack embedded:
|
||||
//
|
||||
// - an embedded NATS server with JetStream (the data plane),
|
||||
// - the membership control plane (rooms, members, sealed keys, rekey) over an
|
||||
// internal HTTP server,
|
||||
// - the media blob store, and
|
||||
// - a browser-facing web UI on :7700.
|
||||
//
|
||||
// The browser never speaks NATS. The Go server is the actual bus peer: it holds
|
||||
// one unibus client per named peer, subscribes to rooms on the peer's behalf,
|
||||
// and streams received messages to the browser over Server-Sent Events. The
|
||||
// browser drives everything with plain fetch() + EventSource() — no build step,
|
||||
// no JS framework, no external libraries.
|
||||
//
|
||||
// This is a playground (see .claude/rules/playgrounds.md): it lives inside the
|
||||
// unibus app, reuses the parent module (no new go.mod), is not indexed, and
|
||||
// stores ephemeral state under playground/local_files/.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
"github.com/enmanuel/unibus/pkg/client"
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/enmanuel/unibus/pkg/room"
|
||||
)
|
||||
|
||||
// Fixed ports (verified free before assignment — do not change without reason).
|
||||
const (
|
||||
webAddr = "127.0.0.1:7700" // browser-facing web UI
|
||||
ctrlAddr = "127.0.0.1:8480" // internal membership control plane
|
||||
ctrlURL = "http://" + ctrlAddr
|
||||
natsPort = 4260 // internal embedded NATS
|
||||
natsURL = "nats://127.0.0.1:4260"
|
||||
localFiles = "playground/local_files"
|
||||
)
|
||||
|
||||
//go:embed index.html
|
||||
var indexHTML []byte
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event: a message received by a peer on one of its subscribed rooms. Fanned
|
||||
// out to every SSE listener attached to that peer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Event struct {
|
||||
RoomID string `json:"room_id"`
|
||||
Subject string `json:"subject"`
|
||||
Sender string `json:"sender"`
|
||||
Text string `json:"text"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
TS int64 `json:"ts"` // unix millis
|
||||
}
|
||||
|
||||
// roomInfo caches the per-room metadata a peer needs to label incoming frames.
|
||||
type roomInfo struct {
|
||||
subject string
|
||||
encrypt bool
|
||||
}
|
||||
|
||||
// peerState holds everything about one named peer: its bus client, its public
|
||||
// endpoint, its live subscriptions, the rooms it knows, and the set of SSE
|
||||
// listener channels currently attached to it.
|
||||
type peerState struct {
|
||||
name string
|
||||
client *client.Client
|
||||
endpoint client.Endpoint
|
||||
|
||||
mu sync.Mutex
|
||||
subs map[string]*client.Sub // roomID -> subscription
|
||||
rooms map[string]roomInfo // roomID -> subject/encrypt
|
||||
listeners map[chan Event]struct{} // attached SSE channels
|
||||
}
|
||||
|
||||
// emit fans an event out to all attached listeners without blocking on a slow
|
||||
// or disconnected consumer.
|
||||
func (p *peerState) emit(ev Event) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for ch := range p.listeners {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default: // listener buffer full: drop rather than block the NATS callback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *peerState) addListener(ch chan Event) {
|
||||
p.mu.Lock()
|
||||
p.listeners[ch] = struct{}{}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *peerState) removeListener(ch chan Event) {
|
||||
p.mu.Lock()
|
||||
delete(p.listeners, ch)
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *peerState) setRoom(roomID string, info roomInfo) {
|
||||
p.mu.Lock()
|
||||
p.rooms[roomID] = info
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// roomList returns a snapshot of the rooms this peer knows (created or joined),
|
||||
// so the SPA can render the peer's room list without re-deriving it client-side.
|
||||
func (p *peerState) roomList() []map[string]any {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
out := make([]map[string]any, 0, len(p.rooms))
|
||||
for id, info := range p.rooms {
|
||||
out = append(out, map[string]any{
|
||||
"room_id": id,
|
||||
"subject": info.subject,
|
||||
"encrypt": info.encrypt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hub: the registry of peers, protected by a single mutex.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
peers map[string]*peerState
|
||||
}
|
||||
|
||||
func newHub() *Hub { return &Hub{peers: map[string]*peerState{}} }
|
||||
|
||||
// getOrCreate returns the peer for name, creating its identity + bus client on
|
||||
// first use. Identities persist to playground/local_files/<name>.id so a peer
|
||||
// keeps the same endpoint across restarts.
|
||||
func (h *Hub) getOrCreate(name string) (*peerState, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if p, ok := h.peers[name]; ok {
|
||||
return p, nil
|
||||
}
|
||||
idPath := filepath.Join(localFiles, name+".id")
|
||||
id, err := client.LoadOrCreateIdentity(idPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("identity for %q: %w", name, err)
|
||||
}
|
||||
c, err := client.New(natsURL, ctrlURL, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client for %q: %w", name, err)
|
||||
}
|
||||
p := &peerState{
|
||||
name: name,
|
||||
client: c,
|
||||
endpoint: c.Endpoint(),
|
||||
subs: map[string]*client.Sub{},
|
||||
rooms: map[string]roomInfo{},
|
||||
listeners: map[chan Event]struct{}{},
|
||||
}
|
||||
h.peers[name] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// lookup returns an already-created peer or false.
|
||||
func (h *Hub) lookup(name string) (*peerState, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
p, ok := h.peers[name]
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// list returns a snapshot of all peers (name + endpoint id).
|
||||
func (h *Hub) list() []map[string]string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
out := make([]map[string]string, 0, len(h.peers))
|
||||
for name, p := range h.peers {
|
||||
out = append(out, map[string]string{"name": name, "endpoint_id": p.endpoint.ID})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Hub) closeAll() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, p := range h.peers {
|
||||
p.mu.Lock()
|
||||
for _, sub := range p.subs {
|
||||
_ = sub.Unsubscribe()
|
||||
}
|
||||
p.mu.Unlock()
|
||||
_ = p.client.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeRoom subscribes the peer to a room (idempotent) and wires the frame
|
||||
// handler to fan incoming messages out as Events. info labels each event with
|
||||
// the room's subject and encryption flag.
|
||||
func (p *peerState) subscribeRoom(roomID string, info roomInfo) error {
|
||||
p.mu.Lock()
|
||||
if _, already := p.subs[roomID]; already {
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
sub, err := p.client.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
||||
p.emit(Event{
|
||||
RoomID: roomID,
|
||||
Subject: info.subject,
|
||||
Sender: f.Sender,
|
||||
Text: string(plaintext),
|
||||
Encrypted: info.encrypt,
|
||||
TS: time.Now().UnixMilli(),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe room %s: %w", roomID, err)
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.subs[roomID] = sub
|
||||
p.mu.Unlock()
|
||||
p.setRoom(roomID, info)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Control-plane helper: fetch a room's subject + policy from membershipd. The
|
||||
// client package keeps fetchRoom private, so the playground talks to the
|
||||
// control plane directly (read endpoints are unauthenticated by design).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ctrlRoomResp struct {
|
||||
Subject string `json:"subject"`
|
||||
Epoch int `json:"epoch"`
|
||||
Policy struct {
|
||||
Encrypt bool `json:"encrypt"`
|
||||
Persist bool `json:"persist"`
|
||||
SignMsgs bool `json:"sign_msgs"`
|
||||
} `json:"policy"`
|
||||
}
|
||||
|
||||
func fetchRoomInfo(roomID string) (roomInfo, error) {
|
||||
resp, err := http.Get(ctrlURL + "/rooms/" + roomID)
|
||||
if err != nil {
|
||||
return roomInfo{}, fmt.Errorf("fetch room %s: %w", roomID, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return roomInfo{}, fmt.Errorf("room %s not found (status %d)", roomID, resp.StatusCode)
|
||||
}
|
||||
var r ctrlRoomResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
|
||||
return roomInfo{}, fmt.Errorf("decode room %s: %w", roomID, err)
|
||||
}
|
||||
return roomInfo{subject: r.Subject, encrypt: r.Policy.Encrypt}, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP handlers (web UI on :7700).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func decodeBody(r *http.Request, out any) error {
|
||||
defer r.Body.Close()
|
||||
return json.NewDecoder(r.Body).Decode(out)
|
||||
}
|
||||
|
||||
func (h *Hub) handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(indexHTML)
|
||||
}
|
||||
|
||||
func (h *Hub) handlePeer(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Name == "" {
|
||||
writeErr(w, http.StatusBadRequest, "name required")
|
||||
return
|
||||
}
|
||||
p, err := h.getOrCreate(req.Name)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"name": p.name, "endpoint_id": p.endpoint.ID})
|
||||
}
|
||||
|
||||
func (h *Hub) handlePeers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, h.list())
|
||||
}
|
||||
|
||||
func (h *Hub) handleRoom(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Peer string `json:"peer"`
|
||||
Subject string `json:"subject"`
|
||||
Encrypt bool `json:"encrypt"`
|
||||
Persist bool `json:"persist"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.Subject == "" {
|
||||
writeErr(w, http.StatusBadRequest, "peer and subject required")
|
||||
return
|
||||
}
|
||||
p, ok := h.lookup(req.Peer)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
||||
return
|
||||
}
|
||||
// The two checkboxes map to an explicit per-room policy. encrypt drives both
|
||||
// encryption and per-message signing; persist (default false) independently
|
||||
// toggles durable JetStream history. persist=false keeps plain ephemeral NATS.
|
||||
policy := room.Policy{Encrypt: req.Encrypt, Persist: req.Persist, SignMsgs: req.Encrypt}
|
||||
roomID, err := p.client.CreateRoom(req.Subject, policy)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
info := roomInfo{subject: req.Subject, encrypt: req.Encrypt}
|
||||
if err := p.subscribeRoom(roomID, info); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"room_id": roomID, "subject": req.Subject, "encrypt": req.Encrypt, "persist": req.Persist,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Hub) handleJoin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Peer string `json:"peer"`
|
||||
RoomID string `json:"room_id"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "peer and room_id required")
|
||||
return
|
||||
}
|
||||
p, ok := h.lookup(req.Peer)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
||||
return
|
||||
}
|
||||
if err := p.client.Join(req.RoomID); err != nil {
|
||||
writeErr(w, http.StatusBadRequest, "join failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
info, err := fetchRoomInfo(req.RoomID)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := p.subscribeRoom(req.RoomID, info); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"subject": info.subject, "encrypt": info.encrypt})
|
||||
}
|
||||
|
||||
func (h *Hub) handleInvite(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Peer string `json:"peer"`
|
||||
RoomID string `json:"room_id"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" {
|
||||
writeErr(w, http.StatusBadRequest, "peer, room_id and target required")
|
||||
return
|
||||
}
|
||||
p, ok := h.lookup(req.Peer)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
||||
return
|
||||
}
|
||||
target, ok := h.lookup(req.Target)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist; connect it first")
|
||||
return
|
||||
}
|
||||
if err := p.client.Invite(req.RoomID, target.endpoint); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "invited", "target": req.Target})
|
||||
}
|
||||
|
||||
func (h *Hub) handlePublish(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Peer string `json:"peer"`
|
||||
RoomID string `json:"room_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "peer and room_id required")
|
||||
return
|
||||
}
|
||||
p, ok := h.lookup(req.Peer)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
||||
return
|
||||
}
|
||||
if err := p.client.Publish(req.RoomID, []byte(req.Text)); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "published"})
|
||||
}
|
||||
|
||||
func (h *Hub) handleKick(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Peer string `json:"peer"`
|
||||
RoomID string `json:"room_id"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
if err := decodeBody(r, &req); err != nil || req.Peer == "" || req.RoomID == "" || req.Target == "" {
|
||||
writeErr(w, http.StatusBadRequest, "peer, room_id and target required")
|
||||
return
|
||||
}
|
||||
p, ok := h.lookup(req.Peer)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "unknown peer "+req.Peer)
|
||||
return
|
||||
}
|
||||
target, ok := h.lookup(req.Target)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "target peer "+req.Target+" does not exist")
|
||||
return
|
||||
}
|
||||
if err := p.client.Kick(req.RoomID, target.endpoint.ID); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "kicked", "target": req.Target})
|
||||
}
|
||||
|
||||
// handleRooms returns the rooms a peer knows (created or joined). The SPA polls
|
||||
// or calls this after create/join to refresh its room list.
|
||||
//
|
||||
// GET /api/rooms?peer=ana
|
||||
func (h *Hub) handleRooms(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("peer")
|
||||
if name == "" {
|
||||
writeErr(w, http.StatusBadRequest, "peer query param required")
|
||||
return
|
||||
}
|
||||
p, ok := h.lookup(name)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "unknown peer "+name)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p.roomList())
|
||||
}
|
||||
|
||||
// handleMembers lists the members of a room (endpoint id + role) so the SPA can
|
||||
// render a members panel and drive invite/kick. It proxies the control plane's
|
||||
// unauthenticated read endpoint; the public keys it returns are not secret.
|
||||
//
|
||||
// GET /api/members?room_id=<id>
|
||||
func (h *Hub) handleMembers(w http.ResponseWriter, r *http.Request) {
|
||||
roomID := r.URL.Query().Get("room_id")
|
||||
if roomID == "" {
|
||||
writeErr(w, http.StatusBadRequest, "room_id query param required")
|
||||
return
|
||||
}
|
||||
resp, err := http.Get(ctrlURL + "/rooms/" + roomID + "/members")
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "fetch members: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// withCORS allows the SPA running under the Vite dev server (a different origin)
|
||||
// to call the gateway. It answers preflight OPTIONS and tags every response with
|
||||
// permissive CORS headers. v1 trusts the local network, mirroring the control
|
||||
// plane's auth model.
|
||||
func withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleStream is the SSE endpoint. The browser opens one EventSource per peer;
|
||||
// each received Event is emitted as a `data: <json>\n\n` block. The listener is
|
||||
// cleaned up when the HTTP request context is cancelled (tab closed / reload).
|
||||
func (h *Hub) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.URL.Query().Get("peer")
|
||||
if name == "" {
|
||||
writeErr(w, http.StatusBadRequest, "peer query param required")
|
||||
return
|
||||
}
|
||||
p, ok := h.lookup(name)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusBadRequest, "unknown peer "+name)
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
|
||||
ch := make(chan Event, 64)
|
||||
p.addListener(ch)
|
||||
defer p.removeListener(ch)
|
||||
|
||||
// Initial comment so the browser marks the stream open immediately.
|
||||
fmt.Fprintf(w, ": connected to %s\n\n", name)
|
||||
flusher.Flush()
|
||||
|
||||
ctx := r.Context()
|
||||
ping := time.NewTicker(20 * time.Second)
|
||||
defer ping.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ping.C:
|
||||
fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
case ev := <-ch:
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Benchmark: one publisher floods a room with thousands of messages that N
|
||||
// subscribers receive. The two policy axes are exposed as independent flags:
|
||||
// encrypt (AEAD payload + Ed25519 per-message signature) and persist (durable
|
||||
// JetStream history vs ephemeral core NATS). Payload size is configurable. The
|
||||
// benchmark uses its own ephemeral peers (not the hub's named peers) so it never
|
||||
// interferes with the manual sandbox, and streams progress samples over SSE so
|
||||
// the browser can animate a live throughput chart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// benchSample is one Server-Sent Event of a running benchmark.
|
||||
type benchSample struct {
|
||||
Type string `json:"type"` // "start" | "sample" | "done" | "error"
|
||||
T float64 `json:"t"`
|
||||
Sent int64 `json:"sent"`
|
||||
Recv int64 `json:"recv"`
|
||||
NMsgs int `json:"n_msgs,omitempty"`
|
||||
NSubs int `json:"n_subs,omitempty"`
|
||||
Payload int `json:"payload,omitempty"`
|
||||
Encrypt bool `json:"encrypt,omitempty"`
|
||||
Persist bool `json:"persist,omitempty"`
|
||||
Capped bool `json:"capped,omitempty"`
|
||||
PubTps int64 `json:"pub_tps,omitempty"`
|
||||
RecvTps int64 `json:"recv_tps,omitempty"`
|
||||
PerSub []int64 `json:"per_sub,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
|
||||
// runBench wires up one publisher + nSubs subscribers, publishes nMsgs payloads,
|
||||
// and calls emit periodically with the running totals. emit is only ever called
|
||||
// from the calling goroutine (the SSE handler), so it needs no locking.
|
||||
func runBench(ctx context.Context, emit func(benchSample), nMsgs, nSubs, payloadBytes int, encrypt, persist bool) {
|
||||
policy := room.Policy{Encrypt: encrypt, Persist: persist, SignMsgs: encrypt}
|
||||
subject := fmt.Sprintf("bench.%d", time.Now().UnixNano())
|
||||
|
||||
newPeer := func() (*client.Client, error) {
|
||||
id, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.New(natsURL, ctrlURL, id)
|
||||
}
|
||||
|
||||
pub, err := newPeer()
|
||||
if err != nil {
|
||||
emit(benchSample{Type: "error", Msg: "publisher: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer pub.Close()
|
||||
|
||||
roomID, err := pub.CreateRoom(subject, policy)
|
||||
if err != nil {
|
||||
emit(benchSample{Type: "error", Msg: "create room: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
counters := make([]int64, nSubs)
|
||||
subClients := make([]*client.Client, 0, nSubs)
|
||||
defer func() {
|
||||
for _, c := range subClients {
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// One room, N subscribers. For encrypted rooms each subscriber must be invited
|
||||
// (sealed key) and join before subscribing; for cleartext rooms Subscribe on
|
||||
// the shared roomID is enough.
|
||||
for i := 0; i < nSubs; i++ {
|
||||
c, err := newPeer()
|
||||
if err != nil {
|
||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscriber %d: %v", i, err)})
|
||||
return
|
||||
}
|
||||
subClients = append(subClients, c)
|
||||
if encrypt {
|
||||
if err := pub.Invite(roomID, c.Endpoint()); err != nil {
|
||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("invite %d: %v", i, err)})
|
||||
return
|
||||
}
|
||||
if err := c.Join(roomID); err != nil {
|
||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("join %d: %v", i, err)})
|
||||
return
|
||||
}
|
||||
}
|
||||
idx := i
|
||||
if _, err := c.Subscribe(roomID, func(_ frame.Frame, _ []byte) {
|
||||
atomic.AddInt64(&counters[idx], 1)
|
||||
}); err != nil {
|
||||
emit(benchSample{Type: "error", Msg: fmt.Sprintf("subscribe %d: %v", i, err)})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
sumRecv := func() int64 {
|
||||
var s int64
|
||||
for i := range counters {
|
||||
s += atomic.LoadInt64(&counters[i])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
payload := bytes.Repeat([]byte{'x'}, payloadBytes)
|
||||
var sent int64
|
||||
|
||||
emit(benchSample{Type: "start", NMsgs: nMsgs, NSubs: nSubs, Payload: payloadBytes, Encrypt: encrypt, Persist: persist})
|
||||
|
||||
t0 := time.Now()
|
||||
done := make(chan struct{})
|
||||
var pubErr atomic.Value
|
||||
go func() {
|
||||
defer close(done)
|
||||
for k := 0; k < nMsgs; k++ {
|
||||
if err := pub.Publish(roomID, payload); err != nil {
|
||||
pubErr.Store(err)
|
||||
return
|
||||
}
|
||||
atomic.AddInt64(&sent, 1)
|
||||
if k%256 == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(60 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
deadline := time.After(120 * time.Second)
|
||||
target := int64(nMsgs) * int64(nSubs)
|
||||
|
||||
sampleLoop:
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-deadline:
|
||||
break sampleLoop
|
||||
case <-done:
|
||||
break sampleLoop
|
||||
case <-ticker.C:
|
||||
emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()})
|
||||
}
|
||||
}
|
||||
if v := pubErr.Load(); v != nil {
|
||||
emit(benchSample{Type: "error", Msg: "publish: " + v.(error).Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Final drain: keep sampling until every subscriber has caught up (or we give up).
|
||||
for i := 0; i < 240; i++ {
|
||||
if sumRecv() >= target {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
emit(benchSample{Type: "sample", T: time.Since(t0).Seconds(), Sent: atomic.LoadInt64(&sent), Recv: sumRecv()})
|
||||
}
|
||||
|
||||
dur := time.Since(t0).Seconds()
|
||||
finalSent := atomic.LoadInt64(&sent)
|
||||
finalRecv := sumRecv()
|
||||
per := make([]int64, nSubs)
|
||||
for i := range counters {
|
||||
per[i] = atomic.LoadInt64(&counters[i])
|
||||
}
|
||||
var pubTps, recvTps int64
|
||||
if dur > 0 {
|
||||
pubTps = int64(float64(finalSent) / dur)
|
||||
recvTps = int64(float64(finalRecv) / dur)
|
||||
}
|
||||
emit(benchSample{Type: "done", T: dur, Sent: finalSent, Recv: finalRecv, PerSub: per, PubTps: pubTps, RecvTps: recvTps, NSubs: nSubs})
|
||||
}
|
||||
|
||||
// handleBench is the SSE endpoint that drives a benchmark from query params:
|
||||
//
|
||||
// GET /api/bench?n_msgs=20000&n_subs=3&payload=128&encrypt=0&persist=0
|
||||
//
|
||||
// Encrypted/persistent runs are capped to a lower message count (the per-message
|
||||
// crypto + JetStream ack make them far slower); the cap is reported in the start
|
||||
// sample so the UI can show it.
|
||||
func (h *Hub) handleBench(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
atoiDef := func(k string, def int) int {
|
||||
if v, err := strconv.Atoi(q.Get(k)); err == nil {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
truthy := func(k string) bool { v := q.Get(k); return v == "1" || v == "true" }
|
||||
|
||||
nMsgs := atoiDef("n_msgs", 20000)
|
||||
nSubs := atoiDef("n_subs", 3)
|
||||
payload := atoiDef("payload", 128)
|
||||
encrypt := truthy("encrypt")
|
||||
persist := truthy("persist")
|
||||
|
||||
if nSubs < 1 {
|
||||
nSubs = 1
|
||||
} else if nSubs > 16 {
|
||||
nSubs = 16
|
||||
}
|
||||
if payload < 1 {
|
||||
payload = 1
|
||||
} else if payload > 8192 {
|
||||
payload = 8192
|
||||
}
|
||||
if nMsgs < 100 {
|
||||
nMsgs = 100
|
||||
}
|
||||
maxMsgs := 200000
|
||||
if encrypt || persist {
|
||||
maxMsgs = 30000 // crypto + JetStream ack are much slower; keep the run bounded
|
||||
}
|
||||
capped := false
|
||||
if nMsgs > maxMsgs {
|
||||
nMsgs, capped = maxMsgs, true
|
||||
}
|
||||
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeErr(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
fmt.Fprintf(w, ": bench start\n\n")
|
||||
flusher.Flush()
|
||||
|
||||
emit := func(s benchSample) {
|
||||
if s.Type == "start" {
|
||||
s.Capped = capped
|
||||
}
|
||||
b, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", b)
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
runBench(r.Context(), emit, nMsgs, nSubs, payload, encrypt, persist)
|
||||
fmt.Fprintf(w, "event: end\ndata: {}\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// main: bring up NATS, control plane, and the web server; tear them all down
|
||||
// cleanly on signal.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
|
||||
log.SetPrefix("[playground] ")
|
||||
|
||||
if err := os.MkdirAll(localFiles, 0o755); err != nil {
|
||||
log.Fatalf("mkdir %s: %v", localFiles, err)
|
||||
}
|
||||
|
||||
// 1. Data plane: embedded NATS + JetStream on the fixed internal port.
|
||||
ns, err := embeddednats.Start(filepath.Join(localFiles, "js"), natsPort)
|
||||
if err != nil {
|
||||
log.Fatalf("start embedded nats: %v", err)
|
||||
}
|
||||
log.Printf("embedded NATS (JetStream) ready: %s", embeddednats.ClientURL(ns))
|
||||
|
||||
// 2. Control plane: membership store + blob store + internal HTTP server.
|
||||
store, err := membership.Open(filepath.Join(localFiles, "play.db"))
|
||||
if err != nil {
|
||||
ns.Shutdown()
|
||||
log.Fatalf("open membership store: %v", err)
|
||||
}
|
||||
blobs, err := blobstore.New(filepath.Join(localFiles, "blobs"))
|
||||
if err != nil {
|
||||
store.Close()
|
||||
ns.Shutdown()
|
||||
log.Fatalf("open blob store: %v", err)
|
||||
}
|
||||
// AuthOff: the playground is a local dev gateway that has not migrated to
|
||||
// signed control-plane requests or a secured upstream bus yet. What it would
|
||||
// need is written up in dev/0001e-remaining-clients.md (issue 0001, phase 0001e).
|
||||
ctrlSrv := &http.Server{Addr: ctrlAddr, Handler: membership.NewServer(store, blobs, membership.AuthOff)}
|
||||
go func() {
|
||||
if err := ctrlSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("control plane: %v", err)
|
||||
}
|
||||
}()
|
||||
if err := waitHealthy(ctrlURL+"/healthz", 5*time.Second); err != nil {
|
||||
log.Fatalf("control plane not healthy: %v", err)
|
||||
}
|
||||
log.Printf("control plane ready: %s", ctrlURL)
|
||||
|
||||
// 3. Web UI on :7700.
|
||||
hub := newHub()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", hub.handleIndex)
|
||||
mux.HandleFunc("POST /api/peer", hub.handlePeer)
|
||||
mux.HandleFunc("GET /api/peers", hub.handlePeers)
|
||||
mux.HandleFunc("POST /api/room", hub.handleRoom)
|
||||
mux.HandleFunc("POST /api/join", hub.handleJoin)
|
||||
mux.HandleFunc("POST /api/invite", hub.handleInvite)
|
||||
mux.HandleFunc("POST /api/publish", hub.handlePublish)
|
||||
mux.HandleFunc("POST /api/kick", hub.handleKick)
|
||||
mux.HandleFunc("GET /api/rooms", hub.handleRooms)
|
||||
mux.HandleFunc("GET /api/members", hub.handleMembers)
|
||||
mux.HandleFunc("GET /api/stream", hub.handleStream)
|
||||
mux.HandleFunc("GET /api/bench", hub.handleBench)
|
||||
webSrv := &http.Server{Addr: webAddr, Handler: withCORS(mux)}
|
||||
go func() {
|
||||
if err := webSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("web server: %v", err)
|
||||
}
|
||||
}()
|
||||
log.Printf("web UI ready: http://%s", webAddr)
|
||||
log.Printf("open http://localhost:7700 in two browser tabs to try the bus")
|
||||
|
||||
// 4. Graceful shutdown.
|
||||
stop := make(chan os.Signal, 1)
|
||||
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-stop
|
||||
log.Printf("shutting down...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = webSrv.Shutdown(ctx)
|
||||
hub.closeAll()
|
||||
_ = ctrlSrv.Shutdown(ctx)
|
||||
store.Close()
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
log.Printf("bye")
|
||||
}
|
||||
|
||||
// waitHealthy polls url until it returns a 2xx/3xx or the deadline elapses.
|
||||
func waitHealthy(url string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
c := &http.Client{Timeout: 500 * time.Millisecond}
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := c.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < 400 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout waiting for %s", url)
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>unibus · chat</title>
|
||||
<title>unibus</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+1
-2
@@ -3,7 +3,6 @@
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "SPA de chat para el bus unibus (rooms cifradas E2E, mensajes en vivo por SSE).",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
@@ -23,7 +22,7 @@
|
||||
"postcss": "^8.4.49",
|
||||
"postcss-preset-mantine": "^1.17.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"typescript": "^5.6.3",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+38
-38
@@ -10,7 +10,7 @@ importers:
|
||||
dependencies:
|
||||
'@mantine/core':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@mantine/hooks':
|
||||
specifier: ^9.3.0
|
||||
version: 9.3.0(react@19.2.7)
|
||||
@@ -26,10 +26,10 @@ importers:
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.16
|
||||
version: 19.2.17
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.0
|
||||
version: 19.2.3(@types/react@19.2.16)
|
||||
version: 19.2.3(@types/react@19.2.17)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.3.4
|
||||
version: 4.7.0(vite@6.4.3(sugarss@5.0.1(postcss@8.5.15)))
|
||||
@@ -43,8 +43,8 @@ importers:
|
||||
specifier: ^7.0.1
|
||||
version: 7.0.1(postcss@8.5.15)
|
||||
typescript:
|
||||
specifier: ^5.6.3
|
||||
version: 5.9.3
|
||||
specifier: ~5.6.3
|
||||
version: 5.6.3
|
||||
vite:
|
||||
specifier: ^6.0.3
|
||||
version: 6.4.3(sugarss@5.0.1(postcss@8.5.15))
|
||||
@@ -508,8 +508,8 @@ packages:
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
|
||||
'@types/react@19.2.16':
|
||||
resolution: {integrity: sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==}
|
||||
'@types/react@19.2.17':
|
||||
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0':
|
||||
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
|
||||
@@ -517,8 +517,8 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
baseline-browser-mapping@2.10.33:
|
||||
resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==}
|
||||
baseline-browser-mapping@2.10.34:
|
||||
resolution: {integrity: sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -531,8 +531,8 @@ packages:
|
||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
caniuse-lite@1.0.30001793:
|
||||
resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==}
|
||||
caniuse-lite@1.0.30001797:
|
||||
resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
@@ -756,8 +756,8 @@ packages:
|
||||
resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
typescript@5.6.3:
|
||||
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1069,7 +1069,7 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
'@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.7))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@floating-ui/react': 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@mantine/hooks': 9.3.0(react@19.2.7)
|
||||
@@ -1077,7 +1077,7 @@ snapshots:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
react-number-format: 5.4.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.16)(react@19.2.7)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
|
||||
type-fest: 5.7.0
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
@@ -1193,11 +1193,11 @@ snapshots:
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.16)':
|
||||
'@types/react-dom@19.2.3(@types/react@19.2.17)':
|
||||
dependencies:
|
||||
'@types/react': 19.2.16
|
||||
'@types/react': 19.2.17
|
||||
|
||||
'@types/react@19.2.16':
|
||||
'@types/react@19.2.17':
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
@@ -1213,19 +1213,19 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
baseline-browser-mapping@2.10.33: {}
|
||||
baseline-browser-mapping@2.10.34: {}
|
||||
|
||||
browserslist@4.28.2:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.10.33
|
||||
caniuse-lite: 1.0.30001793
|
||||
baseline-browser-mapping: 2.10.34
|
||||
caniuse-lite: 1.0.30001797
|
||||
electron-to-chromium: 1.5.368
|
||||
node-releases: 2.0.47
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
camelcase-css@2.0.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001793: {}
|
||||
caniuse-lite@1.0.30001797: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
@@ -1356,32 +1356,32 @@ snapshots:
|
||||
|
||||
react-refresh@0.17.0: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.16)(react@19.2.7):
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7)
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.16
|
||||
'@types/react': 19.2.17
|
||||
|
||||
react-remove-scroll@2.7.2(@types/react@19.2.16)(react@19.2.7):
|
||||
react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
react-remove-scroll-bar: 2.3.8(@types/react@19.2.16)(react@19.2.7)
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.16)(react@19.2.7)
|
||||
react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.7)
|
||||
react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.7)
|
||||
tslib: 2.8.1
|
||||
use-callback-ref: 1.3.3(@types/react@19.2.16)(react@19.2.7)
|
||||
use-sidecar: 1.1.3(@types/react@19.2.16)(react@19.2.7)
|
||||
use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.7)
|
||||
use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.7)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.16
|
||||
'@types/react': 19.2.17
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.16)(react@19.2.7):
|
||||
react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
react: 19.2.7
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.16
|
||||
'@types/react': 19.2.17
|
||||
|
||||
react@19.2.7: {}
|
||||
|
||||
@@ -1441,7 +1441,7 @@ snapshots:
|
||||
dependencies:
|
||||
tagged-tag: 1.0.0
|
||||
|
||||
typescript@5.9.3: {}
|
||||
typescript@5.6.3: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.2):
|
||||
dependencies:
|
||||
@@ -1449,20 +1449,20 @@ snapshots:
|
||||
escalade: 3.2.0
|
||||
picocolors: 1.1.1
|
||||
|
||||
use-callback-ref@1.3.3(@types/react@19.2.16)(react@19.2.7):
|
||||
use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.16
|
||||
'@types/react': 19.2.17
|
||||
|
||||
use-sidecar@1.1.3(@types/react@19.2.16)(react@19.2.7):
|
||||
use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.7):
|
||||
dependencies:
|
||||
detect-node-es: 1.1.0
|
||||
react: 19.2.7
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.16
|
||||
'@types/react': 19.2.17
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
|
||||
+6
-24
@@ -1,29 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { GatewayClient } from "./api";
|
||||
import type { Peer } from "./types";
|
||||
import { ConnectScreen } from "./components/ConnectScreen";
|
||||
import { ChatLayout } from "./components/ChatLayout";
|
||||
import { Login } from "./Login";
|
||||
import { ChatShell } from "./ChatShell";
|
||||
import type { User } from "./types";
|
||||
|
||||
// Connection holds the live gateway client plus the identity it connected as.
|
||||
interface Connection {
|
||||
client: GatewayClient;
|
||||
peer: Peer;
|
||||
}
|
||||
|
||||
// App is the root: it shows the connect screen until the user picks a gateway
|
||||
// URL and a peer name, then swaps to the full chat layout. Disconnecting drops
|
||||
// back to the connect screen.
|
||||
export function App() {
|
||||
const [conn, setConn] = useState<Connection | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
if (!conn) {
|
||||
return <ConnectScreen onConnect={(client, peer) => setConn({ client, peer })} />;
|
||||
}
|
||||
return (
|
||||
<ChatLayout
|
||||
client={conn.client}
|
||||
peer={conn.peer}
|
||||
onDisconnect={() => setConn(null)}
|
||||
/>
|
||||
);
|
||||
if (!user) return <Login onLogin={setUser} />;
|
||||
return <ChatShell user={user} onLogout={() => setUser(null)} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Avatar,
|
||||
Box,
|
||||
Center,
|
||||
Divider,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconSend,
|
||||
IconLock,
|
||||
IconHash,
|
||||
IconDotsVertical,
|
||||
IconPaperclip,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Message, Room, User } from "./types";
|
||||
|
||||
function initials(s: string) {
|
||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||
}
|
||||
function timeShort(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
||||
d.getMinutes(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function MessageRow({ msg }: { msg: Message }) {
|
||||
return (
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<Avatar radius="xl" size={36} color={msg.mine ? "brand" : "gray"}>
|
||||
{initials(msg.sender)}
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text size="sm" fw={600} c={msg.mine ? "brand.4" : undefined}>
|
||||
{msg.sender}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{timeShort(msg.ts)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" style={{ wordBreak: "break-word" }}>
|
||||
{msg.body}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
room,
|
||||
user,
|
||||
}: {
|
||||
room: Room | undefined;
|
||||
user: User;
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [extra, setExtra] = useState<Record<string, Message[]>>({});
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
|
||||
const msgs = room ? [...room.messages, ...(extra[room.id] ?? [])] : [];
|
||||
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||
}, [room?.id, msgs.length]);
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="dimmed">Selecciona una conversación</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const body = draft.trim();
|
||||
if (!body) return;
|
||||
const msg: Message = {
|
||||
id: `local-${Date.now()}`,
|
||||
sender: user.handle,
|
||||
body,
|
||||
ts: Date.now(),
|
||||
mine: true,
|
||||
};
|
||||
setExtra((e) => ({ ...e, [room.id]: [...(e[room.id] ?? []), msg] }));
|
||||
setDraft("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack h="100vh" gap={0}>
|
||||
<Group justify="space-between" px="md" py="xs" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Avatar radius="md" size={38} color="brand">
|
||||
{initials(room.name)}
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text fw={650} truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
{room.encrypted ? (
|
||||
<Tooltip label="Cifrada de extremo a extremo">
|
||||
<IconLock size={14} style={{ opacity: 0.6 }} />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<IconHash size={14} style={{ opacity: 0.6 }} />
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{room.encrypted ? "cifrada · E2E" : "abierta · cleartext"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDotsVertical size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<Divider color="dark.4" />
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
|
||||
<Stack gap="lg" p="md">
|
||||
{msgs.map((m) => (
|
||||
<MessageRow key={m.id} msg={m} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Divider color="dark.4" />
|
||||
<Group p="sm" gap="xs" wrap="nowrap">
|
||||
<ActionIcon variant="subtle" color="gray" size="lg">
|
||||
<IconPaperclip size={18} />
|
||||
</ActionIcon>
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
radius="xl"
|
||||
placeholder={`Mensaje a ${room.name}`}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
color="brand"
|
||||
onClick={send}
|
||||
disabled={!draft.trim()}
|
||||
>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { Flex, Box } from "@mantine/core";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { ChatPanel } from "./ChatPanel";
|
||||
import { MOCK_ROOMS } from "./mock";
|
||||
import type { User } from "./types";
|
||||
|
||||
export function ChatShell({
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
user: User;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [rooms] = useState(MOCK_ROOMS);
|
||||
const [activeId, setActiveId] = useState<string>(rooms[0]?.id ?? "");
|
||||
const active = rooms.find((r) => r.id === activeId);
|
||||
|
||||
return (
|
||||
<Flex h="100vh" w="100vw" style={{ overflow: "hidden" }}>
|
||||
<Box
|
||||
w={320}
|
||||
h="100%"
|
||||
bg="dark.8"
|
||||
style={{
|
||||
borderRight: "1px solid var(--mantine-color-dark-4)",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Sidebar
|
||||
user={user}
|
||||
rooms={rooms}
|
||||
activeId={activeId}
|
||||
onSelect={setActiveId}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
|
||||
<ChatPanel room={active} user={user} />
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
PasswordInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconShieldLock, IconKey } from "@tabler/icons-react";
|
||||
import type { User } from "./types";
|
||||
|
||||
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
|
||||
const [handle, setHandle] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const ready = handle.trim().length > 0 && password.length > 0;
|
||||
const connect = () => {
|
||||
const h = handle.trim();
|
||||
if (ready) onLogin({ id: h, handle: h });
|
||||
};
|
||||
|
||||
return (
|
||||
<Center h="100vh" bg="dark.9">
|
||||
<Card w={380} p="xl" radius="lg" withBorder bg="dark.7">
|
||||
<Stack align="center" gap="lg">
|
||||
<ThemeIcon size={60} radius="xl" variant="light" color="brand">
|
||||
<IconShieldLock size={32} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={2} align="center">
|
||||
<Title order={2}>unibus</Title>
|
||||
<Text c="dimmed" size="sm">
|
||||
Mensajería cifrada de extremo a extremo
|
||||
</Text>
|
||||
</Stack>
|
||||
<TextInput
|
||||
w="100%"
|
||||
label="Identidad"
|
||||
placeholder="tu-handle"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
||||
data-autofocus
|
||||
/>
|
||||
<PasswordInput
|
||||
w="100%"
|
||||
label="Contraseña"
|
||||
description="Desbloquea tu identidad cifrada en este dispositivo"
|
||||
placeholder="••••••••"
|
||||
leftSection={<IconKey size={16} />}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
||||
/>
|
||||
<Button w="100%" size="md" onClick={connect} disabled={!ready}>
|
||||
Conectar
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
Menu,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconSearch,
|
||||
IconLogout,
|
||||
IconDots,
|
||||
IconLock,
|
||||
IconHash,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Room, User } from "./types";
|
||||
|
||||
function initials(s: string) {
|
||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function timeShort(ts: number) {
|
||||
const d = new Date(ts);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(
|
||||
d.getMinutes(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function RoomItem({
|
||||
room,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
room: Room;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
p="xs"
|
||||
style={{
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
backgroundColor: active
|
||||
? "var(--mantine-color-dark-6)"
|
||||
: "transparent",
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar radius="md" size={42} color={active ? "brand" : "gray"}>
|
||||
{initials(room.name)}
|
||||
</Avatar>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{room.encrypted ? (
|
||||
<IconLock size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
) : (
|
||||
<IconHash size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
)}
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{timeShort(room.lastTs)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between" gap={4} wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{room.lastMessage}
|
||||
</Text>
|
||||
{room.unread > 0 && (
|
||||
<Badge size="sm" circle variant="filled" color="brand">
|
||||
{room.unread}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
user,
|
||||
rooms,
|
||||
activeId,
|
||||
onSelect,
|
||||
onLogout,
|
||||
}: {
|
||||
user: User;
|
||||
rooms: Room[];
|
||||
activeId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const query = q.trim().toLowerCase();
|
||||
const filtered = query
|
||||
? rooms.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(query) ||
|
||||
r.messages.some((m) => m.body.toLowerCase().includes(query)),
|
||||
)
|
||||
: rooms;
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap={0}>
|
||||
<Group justify="space-between" px="sm" py="xs" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Avatar radius="xl" size={34} color="brand">
|
||||
{initials(user.handle)}
|
||||
</Avatar>
|
||||
<Text fw={600} size="sm" truncate>
|
||||
{user.handle}
|
||||
</Text>
|
||||
</Group>
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton c="dimmed">
|
||||
<IconDots size={18} />
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<IconLogout size={15} />}
|
||||
onClick={onLogout}
|
||||
>
|
||||
Desconectar
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
|
||||
<Box px="sm" pb="sm">
|
||||
<TextInput
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.currentTarget.value)}
|
||||
placeholder="Buscar rooms, usuarios, mensajes…"
|
||||
leftSection={<IconSearch size={16} />}
|
||||
radius="md"
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
<Divider color="dark.4" />
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} type="scroll">
|
||||
<Stack gap={2} p={6}>
|
||||
{filtered.map((room) => (
|
||||
<RoomItem
|
||||
key={room.id}
|
||||
room={room}
|
||||
active={room.id === activeId}
|
||||
onClick={() => onSelect(room.id)}
|
||||
/>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Text c="dimmed" size="sm" ta="center" mt="md">
|
||||
Sin resultados
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// GatewayClient is the SPA's typed wrapper over the unibus gateway HTTP API.
|
||||
// Every method is a thin fetch against the gateway, which hosts one real Go bus
|
||||
// peer per name and performs all NATS + end-to-end crypto on the browser's
|
||||
// behalf. The base URL is chosen at runtime on the connect screen.
|
||||
import type { BusEvent, Member, Peer, Room } from "./types";
|
||||
|
||||
export class GatewayClient {
|
||||
constructor(public readonly baseURL: string) {
|
||||
// Normalize: drop a trailing slash so `${base}/api/...` never doubles up.
|
||||
this.baseURL = baseURL.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
private async req<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(this.baseURL + path, {
|
||||
method,
|
||||
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
let msg = text;
|
||||
try {
|
||||
const j = JSON.parse(text);
|
||||
if (j && typeof j.error === "string") msg = j.error;
|
||||
} catch {
|
||||
// not JSON: keep the raw text
|
||||
}
|
||||
throw new Error(msg || `HTTP ${res.status}`);
|
||||
}
|
||||
return (text ? JSON.parse(text) : {}) as T;
|
||||
}
|
||||
|
||||
// connect creates (or recovers) the named peer on the gateway and returns its
|
||||
// public identity. The identity persists across gateway restarts.
|
||||
connect(name: string): Promise<Peer> {
|
||||
return this.req<Peer>("POST", "/api/peer", { name });
|
||||
}
|
||||
|
||||
// peers lists every peer currently hosted by the gateway (for the invite picker
|
||||
// and to label senders by name).
|
||||
peers(): Promise<Peer[]> {
|
||||
return this.req<Peer[]>("GET", "/api/peers");
|
||||
}
|
||||
|
||||
// rooms lists the rooms the named peer knows (created or joined).
|
||||
rooms(peer: string): Promise<Room[]> {
|
||||
return this.req<Room[]>("GET", `/api/rooms?peer=${encodeURIComponent(peer)}`);
|
||||
}
|
||||
|
||||
// members lists the participants of a room.
|
||||
members(roomID: string): Promise<Member[]> {
|
||||
return this.req<Member[]>("GET", `/api/members?room_id=${encodeURIComponent(roomID)}`);
|
||||
}
|
||||
|
||||
// createRoom opens a room on the given subject. encrypt drives both E2E
|
||||
// encryption and per-message signing; the peer is auto-subscribed.
|
||||
createRoom(peer: string, subject: string, encrypt: boolean): Promise<Room & { persist: boolean }> {
|
||||
return this.req("POST", "/api/room", { peer, subject, encrypt, persist: false });
|
||||
}
|
||||
|
||||
// join subscribes the peer to an existing room (must have been invited first
|
||||
// when the room is encrypted).
|
||||
join(peer: string, roomID: string): Promise<{ subject: string; encrypt: boolean }> {
|
||||
return this.req("POST", "/api/join", { peer, room_id: roomID });
|
||||
}
|
||||
|
||||
// invite adds another connected peer (by name) to a room, sealing the room key
|
||||
// to it. Caller must be the room owner.
|
||||
invite(peer: string, roomID: string, target: string): Promise<{ status: string }> {
|
||||
return this.req("POST", "/api/invite", { peer, room_id: roomID, target });
|
||||
}
|
||||
|
||||
// publish sends a text message to a room.
|
||||
publish(peer: string, roomID: string, text: string): Promise<{ status: string }> {
|
||||
return this.req("POST", "/api/publish", { peer, room_id: roomID, text });
|
||||
}
|
||||
|
||||
// kick removes a peer (by name) from a room and rotates the key (forward
|
||||
// secrecy). Caller must be the room owner.
|
||||
kick(peer: string, roomID: string, target: string): Promise<{ status: string }> {
|
||||
return this.req("POST", "/api/kick", { peer, room_id: roomID, target });
|
||||
}
|
||||
|
||||
// stream opens the SSE channel for a peer. onEvent fires for each received bus
|
||||
// message; onError fires if the stream drops. Returns the EventSource so the
|
||||
// caller can close it.
|
||||
stream(peer: string, onEvent: (ev: BusEvent) => void, onError?: () => void): EventSource {
|
||||
const es = new EventSource(`${this.baseURL}/api/stream?peer=${encodeURIComponent(peer)}`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
onEvent(JSON.parse(e.data) as BusEvent);
|
||||
} catch {
|
||||
// ignore malformed frames (keepalive comments never reach onmessage)
|
||||
}
|
||||
};
|
||||
if (onError) es.onerror = onError;
|
||||
return es;
|
||||
}
|
||||
}
|
||||
@@ -1,285 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Group,
|
||||
Title,
|
||||
Badge,
|
||||
Button,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
ThemeIcon,
|
||||
Alert,
|
||||
Transition,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconBolt,
|
||||
IconLogout,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertTriangle,
|
||||
} from "@tabler/icons-react";
|
||||
import { GatewayClient } from "../api";
|
||||
import type { Member, Message, Peer, Room } from "../types";
|
||||
import { RoomList } from "./RoomList";
|
||||
import { MessagePane } from "./MessagePane";
|
||||
import { MembersPane } from "./MembersPane";
|
||||
|
||||
interface Props {
|
||||
client: GatewayClient;
|
||||
peer: Peer;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
// short renders the first 10 chars of an endpoint id, enough to disambiguate.
|
||||
export function short(endpoint: string): string {
|
||||
return endpoint.length > 12 ? endpoint.slice(0, 10) + "…" : endpoint;
|
||||
}
|
||||
|
||||
// ChatLayout owns all chat state: the peer's rooms, the active room, the
|
||||
// per-room message log fed by the SSE stream, the directory of connected peers
|
||||
// (to label senders and pick invitees), and the active room's member list. Every
|
||||
// bus action goes through the gateway client.
|
||||
export function ChatLayout({ client, peer, onDisconnect }: Props) {
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [activeRoom, setActiveRoom] = useState<string | null>(null);
|
||||
const [messages, setMessages] = useState<Record<string, Message[]>>({});
|
||||
const [peers, setPeers] = useState<Peer[]>([]);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const seq = useRef(0);
|
||||
|
||||
const fail = useCallback((e: unknown) => {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}, []);
|
||||
|
||||
// ---- data refreshers ----------------------------------------------------
|
||||
|
||||
const refreshRooms = useCallback(async () => {
|
||||
try {
|
||||
setRooms(await client.rooms(peer.name));
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}, [client, peer.name, fail]);
|
||||
|
||||
const refreshPeers = useCallback(async () => {
|
||||
try {
|
||||
setPeers(await client.peers());
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
}, [client, fail]);
|
||||
|
||||
const refreshMembers = useCallback(
|
||||
async (roomID: string) => {
|
||||
try {
|
||||
setMembers(await client.members(roomID));
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, fail],
|
||||
);
|
||||
|
||||
// ---- live stream (SSE) --------------------------------------------------
|
||||
|
||||
useEffect(() => {
|
||||
const es = client.stream(
|
||||
peer.name,
|
||||
(ev) => {
|
||||
seq.current += 1;
|
||||
const msg: Message = { ...ev, id: `${ev.ts}-${seq.current}` };
|
||||
setMessages((prev) => {
|
||||
const list = prev[ev.room_id] ?? [];
|
||||
return { ...prev, [ev.room_id]: [...list, msg] };
|
||||
});
|
||||
},
|
||||
() => setError("Se perdió la conexión con el gateway (stream SSE)"),
|
||||
);
|
||||
return () => es.close();
|
||||
}, [client, peer.name]);
|
||||
|
||||
// Initial load.
|
||||
useEffect(() => {
|
||||
refreshRooms();
|
||||
refreshPeers();
|
||||
}, [refreshRooms, refreshPeers]);
|
||||
|
||||
// Refresh members whenever the active room changes.
|
||||
useEffect(() => {
|
||||
if (activeRoom) refreshMembers(activeRoom);
|
||||
else setMembers([]);
|
||||
}, [activeRoom, refreshMembers]);
|
||||
|
||||
// ---- actions ------------------------------------------------------------
|
||||
|
||||
const onCreateRoom = useCallback(
|
||||
async (subject: string, encrypt: boolean) => {
|
||||
try {
|
||||
const r = await client.createRoom(peer.name, subject, encrypt);
|
||||
await refreshRooms();
|
||||
setActiveRoom(r.room_id);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, refreshRooms, fail],
|
||||
);
|
||||
|
||||
const onJoinRoom = useCallback(
|
||||
async (roomID: string) => {
|
||||
try {
|
||||
await client.join(peer.name, roomID);
|
||||
await refreshRooms();
|
||||
setActiveRoom(roomID);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, refreshRooms, fail],
|
||||
);
|
||||
|
||||
const onInvite = useCallback(
|
||||
async (target: string) => {
|
||||
if (!activeRoom) return;
|
||||
try {
|
||||
await client.invite(peer.name, activeRoom, target);
|
||||
await refreshMembers(activeRoom);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, activeRoom, refreshMembers, fail],
|
||||
);
|
||||
|
||||
const onKick = useCallback(
|
||||
async (target: string) => {
|
||||
if (!activeRoom) return;
|
||||
try {
|
||||
await client.kick(peer.name, activeRoom, target);
|
||||
await refreshMembers(activeRoom);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, activeRoom, refreshMembers, fail],
|
||||
);
|
||||
|
||||
const onPublish = useCallback(
|
||||
async (text: string) => {
|
||||
if (!activeRoom) return;
|
||||
try {
|
||||
await client.publish(peer.name, activeRoom, text);
|
||||
} catch (e) {
|
||||
fail(e);
|
||||
}
|
||||
},
|
||||
[client, peer.name, activeRoom, fail],
|
||||
);
|
||||
|
||||
// endpoint -> display name, using the peer directory; falls back to a short id.
|
||||
const nameFor = useMemo(() => {
|
||||
const byEndpoint = new Map(peers.map((p) => [p.endpoint_id, p.name]));
|
||||
return (endpoint: string) =>
|
||||
endpoint === peer.endpoint_id ? peer.name : byEndpoint.get(endpoint) ?? short(endpoint);
|
||||
}, [peers, peer]);
|
||||
|
||||
const activeRoomObj = rooms.find((r) => r.room_id === activeRoom) ?? null;
|
||||
const iAmOwner = members.some((m) => m.endpoint === peer.endpoint_id && m.role === "owner");
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 60 }}
|
||||
navbar={{ width: 300, breakpoint: "sm" }}
|
||||
aside={{ width: 300, breakpoint: "md", collapsed: { desktop: !activeRoom, mobile: true } }}
|
||||
padding={0}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<ThemeIcon variant="light" color="violet" radius="md">
|
||||
<IconBolt size={18} />
|
||||
</ThemeIcon>
|
||||
<Title order={4}>unibus</Title>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Badge variant="light" color="violet" size="lg">
|
||||
{peer.name}
|
||||
</Badge>
|
||||
<CopyButton value={peer.endpoint_id}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "¡copiado!" : peer.endpoint_id} withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={onDisconnect}
|
||||
>
|
||||
Salir
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar>
|
||||
<RoomList
|
||||
rooms={rooms}
|
||||
activeRoom={activeRoom}
|
||||
onSelect={setActiveRoom}
|
||||
onCreateRoom={onCreateRoom}
|
||||
onJoinRoom={onJoinRoom}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main h="100vh">
|
||||
{error && (
|
||||
<Transition mounted={!!error} transition="slide-down">
|
||||
{(styles) => (
|
||||
<Alert
|
||||
style={{ ...styles, position: "absolute", top: 70, left: "50%", transform: "translateX(-50%)", zIndex: 200, minWidth: 360 }}
|
||||
color="red"
|
||||
variant="filled"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
withCloseButton
|
||||
onClose={() => setError(null)}
|
||||
title="Error"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Transition>
|
||||
)}
|
||||
<MessagePane
|
||||
room={activeRoomObj}
|
||||
messages={activeRoom ? messages[activeRoom] ?? [] : []}
|
||||
myEndpoint={peer.endpoint_id}
|
||||
nameFor={nameFor}
|
||||
onPublish={onPublish}
|
||||
/>
|
||||
</AppShell.Main>
|
||||
|
||||
<AppShell.Aside>
|
||||
{activeRoomObj && (
|
||||
<MembersPane
|
||||
room={activeRoomObj}
|
||||
members={members}
|
||||
peers={peers}
|
||||
myEndpoint={peer.endpoint_id}
|
||||
iAmOwner={iAmOwner}
|
||||
nameFor={nameFor}
|
||||
onInvite={onInvite}
|
||||
onKick={onKick}
|
||||
onRefresh={() => activeRoom && refreshMembers(activeRoom)}
|
||||
/>
|
||||
)}
|
||||
</AppShell.Aside>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Alert,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { IconBolt, IconPlugConnected, IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { GatewayClient } from "../api";
|
||||
import type { Peer } from "../types";
|
||||
|
||||
const LS_GATEWAY = "unibus.gateway";
|
||||
const LS_PEER = "unibus.peer";
|
||||
|
||||
interface Props {
|
||||
onConnect: (client: GatewayClient, peer: Peer) => void;
|
||||
}
|
||||
|
||||
// ConnectScreen asks for the gateway URL and the identity (peer name) to connect
|
||||
// as. Both persist in localStorage so a reload reconnects with one click. The
|
||||
// gateway hosts the real Go bus peer; the browser only drives it.
|
||||
export function ConnectScreen({ onConnect }: Props) {
|
||||
const [gateway, setGateway] = useState(
|
||||
() => localStorage.getItem(LS_GATEWAY) ?? "http://localhost:7700",
|
||||
);
|
||||
const [name, setName] = useState(() => localStorage.getItem(LS_PEER) ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const connect = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
setError("Elige un nombre de identidad");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const client = new GatewayClient(gateway.trim());
|
||||
const peer = await client.connect(trimmed);
|
||||
localStorage.setItem(LS_GATEWAY, client.baseURL);
|
||||
localStorage.setItem(LS_PEER, trimmed);
|
||||
onConnect(client, peer);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Center h="100vh" p="md">
|
||||
<Card withBorder shadow="md" radius="lg" p="xl" w={420} maw="100%">
|
||||
<Stack gap="lg">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size="xl" radius="md" variant="light" color="violet">
|
||||
<IconBolt size={26} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={3}>unibus</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
chat cifrado extremo a extremo sobre NATS
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<TextInput
|
||||
label="Gateway"
|
||||
description="URL del gateway web de unibus"
|
||||
placeholder="http://localhost:7700"
|
||||
value={gateway}
|
||||
onChange={(e) => setGateway(e.currentTarget.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<TextInput
|
||||
label="Identidad"
|
||||
description="Tu nombre de peer en el bus (persistente)"
|
||||
placeholder="ana"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
||||
disabled={busy}
|
||||
data-autofocus
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="No se pudo conectar"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
leftSection={<IconPlugConnected size={18} />}
|
||||
onClick={connect}
|
||||
loading={busy}
|
||||
fullWidth
|
||||
size="md"
|
||||
>
|
||||
Conectar
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Badge,
|
||||
Select,
|
||||
Button,
|
||||
ActionIcon,
|
||||
Divider,
|
||||
Box,
|
||||
Avatar,
|
||||
Tooltip,
|
||||
ScrollArea,
|
||||
} from "@mantine/core";
|
||||
import { IconUserPlus, IconUserMinus, IconRefresh, IconUsers } from "@tabler/icons-react";
|
||||
import type { Member, Peer, Room } from "../types";
|
||||
|
||||
interface Props {
|
||||
room: Room;
|
||||
members: Member[];
|
||||
peers: Peer[];
|
||||
myEndpoint: string;
|
||||
iAmOwner: boolean;
|
||||
nameFor: (endpoint: string) => string;
|
||||
onInvite: (target: string) => void;
|
||||
onKick: (target: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
// MembersPane is the right column: who is in the active room, plus invite (pick a
|
||||
// connected peer) and kick (owner only). Invite/kick address peers by name; the
|
||||
// gateway resolves the name to its bus endpoint.
|
||||
export function MembersPane({
|
||||
room,
|
||||
members,
|
||||
peers,
|
||||
myEndpoint,
|
||||
iAmOwner,
|
||||
nameFor,
|
||||
onInvite,
|
||||
onKick,
|
||||
onRefresh,
|
||||
}: Props) {
|
||||
const [target, setTarget] = useState<string | null>(null);
|
||||
|
||||
const memberEndpoints = new Set(members.map((m) => m.endpoint));
|
||||
// Candidates to invite: connected peers not already in the room.
|
||||
const candidates = peers
|
||||
.filter((p) => !memberEndpoints.has(p.endpoint_id))
|
||||
.map((p) => ({ value: p.name, label: p.name }));
|
||||
|
||||
const invite = () => {
|
||||
if (target) {
|
||||
onInvite(target);
|
||||
setTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
||||
<Group gap="xs">
|
||||
<IconUsers size={18} />
|
||||
<Text fw={600}>Miembros</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
{members.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Tooltip label="Recargar" withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={onRefresh}>
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Box p="md">
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
|
||||
Invitar {room.encrypt && "(reparte la clave)"}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-end">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
size="xs"
|
||||
placeholder="peer conectado"
|
||||
data={candidates}
|
||||
value={target}
|
||||
onChange={setTarget}
|
||||
searchable
|
||||
nothingFoundMessage="sin peers libres"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconUserPlus size={14} />}
|
||||
onClick={invite}
|
||||
disabled={!target}
|
||||
>
|
||||
Invitar
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={4} p="md">
|
||||
{members.map((m) => {
|
||||
const isMe = m.endpoint === myEndpoint;
|
||||
const name = nameFor(m.endpoint);
|
||||
const canKick = iAmOwner && !isMe && m.role !== "owner";
|
||||
return (
|
||||
<Group key={m.endpoint} justify="space-between" wrap="nowrap" gap="xs">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Avatar size="sm" radius="xl" color="violet">
|
||||
{name.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={isMe ? 700 : 500} truncate>
|
||||
{name} {isMe && "(tú)"}
|
||||
</Text>
|
||||
<Text size="9px" c="dimmed" truncate>
|
||||
{m.endpoint}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{m.role === "owner" && (
|
||||
<Badge size="xs" color="yellow" variant="light">
|
||||
owner
|
||||
</Badge>
|
||||
)}
|
||||
{canKick && (
|
||||
<Tooltip label="Expulsar (rota la clave)" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => onKick(name)}
|
||||
>
|
||||
<IconUserMinus size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Badge,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
TextInput,
|
||||
ActionIcon,
|
||||
Center,
|
||||
ThemeIcon,
|
||||
Box,
|
||||
CopyButton,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconLock,
|
||||
IconHash,
|
||||
IconSend,
|
||||
IconMessages,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Message, Room } from "../types";
|
||||
|
||||
interface Props {
|
||||
room: Room | null;
|
||||
messages: Message[];
|
||||
myEndpoint: string;
|
||||
nameFor: (endpoint: string) => string;
|
||||
onPublish: (text: string) => void;
|
||||
}
|
||||
|
||||
// formatTime renders a message timestamp as HH:mm:ss in 24h European style.
|
||||
function formatTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString("es-ES", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// MessagePane is the center column: the active room's live message log plus the
|
||||
// composer. Own messages align right; others align left and show the sender.
|
||||
export function MessagePane({ room, messages, myEndpoint, nameFor, onPublish }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const viewport = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to the newest message.
|
||||
useEffect(() => {
|
||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight, behavior: "smooth" });
|
||||
}, [messages.length]);
|
||||
|
||||
if (!room) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<ThemeIcon size={64} radius="xl" variant="light" color="gray">
|
||||
<IconMessages size={34} />
|
||||
</ThemeIcon>
|
||||
<Text c="dimmed">Elige o crea una room para empezar a chatear</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const send = () => {
|
||||
const t = text.trim();
|
||||
if (t) {
|
||||
onPublish(t);
|
||||
setText("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<Group justify="space-between" px="md" py="sm" wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{room.encrypt ? <IconLock size={18} /> : <IconHash size={18} />}
|
||||
<Text fw={600}>{room.subject}</Text>
|
||||
{room.encrypt && (
|
||||
<Badge size="sm" color="teal" variant="light">
|
||||
cifrada E2E
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<CopyButton value={room.room_id}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? "¡copiado!" : "copiar room id"} withArrow>
|
||||
<ActionIcon variant="subtle" color="gray" onClick={copy}>
|
||||
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Group>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport} p="md">
|
||||
<Stack gap="sm">
|
||||
{messages.length === 0 && (
|
||||
<Text c="dimmed" ta="center" py="xl" size="sm">
|
||||
No hay mensajes todavía.
|
||||
</Text>
|
||||
)}
|
||||
{messages.map((m) => {
|
||||
const mine = m.sender === myEndpoint;
|
||||
return (
|
||||
<Box
|
||||
key={m.id}
|
||||
style={{ display: "flex", justifyContent: mine ? "flex-end" : "flex-start" }}
|
||||
>
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="xs"
|
||||
radius="md"
|
||||
p="xs"
|
||||
bg={mine ? "violet.9" : undefined}
|
||||
maw="75%"
|
||||
>
|
||||
{!mine && (
|
||||
<Text size="xs" fw={700} c="violet.4">
|
||||
{nameFor(m.sender)}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="sm" style={{ wordBreak: "break-word", whiteSpace: "pre-wrap" }}>
|
||||
{m.text}
|
||||
</Text>
|
||||
<Text size="9px" c="dimmed" ta="right" mt={2}>
|
||||
{formatTime(m.ts)}
|
||||
</Text>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
<Group p="md" gap="xs" wrap="nowrap" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
||||
<TextInput
|
||||
style={{ flex: 1 }}
|
||||
placeholder={`Mensaje a ${room.subject}…`}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
||||
/>
|
||||
<ActionIcon size="lg" onClick={send} disabled={!text.trim()}>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Checkbox,
|
||||
Button,
|
||||
Divider,
|
||||
Text,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Group,
|
||||
Box,
|
||||
} from "@mantine/core";
|
||||
import { IconLock, IconHash, IconPlus, IconDoorEnter } from "@tabler/icons-react";
|
||||
import type { Room } from "../types";
|
||||
|
||||
interface Props {
|
||||
rooms: Room[];
|
||||
activeRoom: string | null;
|
||||
onSelect: (roomID: string) => void;
|
||||
onCreateRoom: (subject: string, encrypt: boolean) => void;
|
||||
onJoinRoom: (roomID: string) => void;
|
||||
}
|
||||
|
||||
// RoomList is the navbar: create a room, join one by id, and pick the active
|
||||
// room from the peer's known rooms.
|
||||
export function RoomList({ rooms, activeRoom, onSelect, onCreateRoom, onJoinRoom }: Props) {
|
||||
const [subject, setSubject] = useState("room.general");
|
||||
const [encrypt, setEncrypt] = useState(true);
|
||||
const [joinID, setJoinID] = useState("");
|
||||
|
||||
const create = () => {
|
||||
if (subject.trim()) onCreateRoom(subject.trim(), encrypt);
|
||||
};
|
||||
const join = () => {
|
||||
if (joinID.trim()) {
|
||||
onJoinRoom(joinID.trim());
|
||||
setJoinID("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap={0} h="100%">
|
||||
<Box p="md">
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
|
||||
Crear room
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="subject (room.general)"
|
||||
leftSection={<IconHash size={14} />}
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && create()}
|
||||
/>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label="Cifrado extremo a extremo"
|
||||
checked={encrypt}
|
||||
onChange={(e) => setEncrypt(e.currentTarget.checked)}
|
||||
/>
|
||||
<Button size="xs" leftSection={<IconPlus size={14} />} onClick={create}>
|
||||
Crear
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box p="md">
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb="xs">
|
||||
Unirse por id
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="room id"
|
||||
value={joinID}
|
||||
onChange={(e) => setJoinID(e.currentTarget.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && join()}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button size="xs" variant="light" onClick={join} px="sm">
|
||||
<IconDoorEnter size={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" px="md" pt="md" pb="xs">
|
||||
Rooms ({rooms.length})
|
||||
</Text>
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Stack gap={2} px="xs" pb="md">
|
||||
{rooms.length === 0 && (
|
||||
<Text size="sm" c="dimmed" px="sm" py="lg" ta="center">
|
||||
Aún no hay rooms. Crea o únete a una.
|
||||
</Text>
|
||||
)}
|
||||
{rooms.map((r) => (
|
||||
<NavLink
|
||||
key={r.room_id}
|
||||
active={r.room_id === activeRoom}
|
||||
onClick={() => onSelect(r.room_id)}
|
||||
label={r.subject}
|
||||
description={r.room_id.slice(0, 14) + "…"}
|
||||
leftSection={
|
||||
r.encrypt ? <IconLock size={16} /> : <IconHash size={16} />
|
||||
}
|
||||
variant="filled"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+6
-6
@@ -1,14 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import "@mantine/core/styles.css";
|
||||
import { theme } from "./theme";
|
||||
import { App } from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<MantineProvider theme={theme} defaultColorScheme="dark">
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<MantineProvider theme={theme} forceColorScheme="dark">
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</React.StrictMode>,
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { Room } from "./types";
|
||||
|
||||
// Datos de muestra para iterar el diseño sin el bus conectado.
|
||||
const now = 1749300000000;
|
||||
const m = (n: number) => now - n * 60_000;
|
||||
|
||||
export const MOCK_ROOMS: Room[] = [
|
||||
{
|
||||
id: "general",
|
||||
name: "general",
|
||||
encrypted: true,
|
||||
lastMessage: "¿Lo desplegamos hoy?",
|
||||
lastTs: m(2),
|
||||
unread: 3,
|
||||
messages: [
|
||||
{ id: "1", sender: "ana", body: "Buenas, ¿cómo va el cluster?", ts: m(40) },
|
||||
{ id: "2", sender: "lucas", body: "Los 3 nodos en R3, quorum verde", ts: m(38), mine: true },
|
||||
{ id: "3", sender: "ana", body: "Brutal. ¿Y el frontend?", ts: m(30) },
|
||||
{ id: "4", sender: "leo", body: "Primera iteración lista, estilo Element", ts: m(6) },
|
||||
{ id: "5", sender: "ana", body: "¿Lo desplegamos hoy?", ts: m(2) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "board",
|
||||
name: "board · privado",
|
||||
encrypted: true,
|
||||
lastMessage: "Os paso el acta cifrada",
|
||||
lastTs: m(95),
|
||||
unread: 0,
|
||||
messages: [
|
||||
{ id: "1", sender: "ceo", body: "Reunión a las 18:00", ts: m(120) },
|
||||
{ id: "2", sender: "lucas", body: "Anotado", ts: m(96), mine: true },
|
||||
{ id: "3", sender: "ceo", body: "Os paso el acta cifrada", ts: m(95) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "bots",
|
||||
name: "bots",
|
||||
encrypted: false,
|
||||
lastMessage: "echo: ping",
|
||||
lastTs: m(210),
|
||||
unread: 0,
|
||||
messages: [
|
||||
{ id: "1", sender: "lucas", body: "!ping", ts: m(212), mine: true },
|
||||
{ id: "2", sender: "echobot", body: "echo: ping", ts: m(210) },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "infra",
|
||||
name: "infra",
|
||||
encrypted: true,
|
||||
lastMessage: "magnus + homer + datardos OK",
|
||||
lastTs: m(330),
|
||||
unread: 1,
|
||||
messages: [
|
||||
{ id: "1", sender: "leo", body: "magnus + homer + datardos OK", ts: m(330) },
|
||||
],
|
||||
},
|
||||
];
|
||||
+20
-10
@@ -1,14 +1,24 @@
|
||||
import { createTheme } from "@mantine/core";
|
||||
import { createTheme, type MantineColorsTuple } from "@mantine/core";
|
||||
|
||||
// Acento de marca de unibus — un violeta-índigo moderno.
|
||||
const brand: MantineColorsTuple = [
|
||||
"#f1edff",
|
||||
"#dcd3ff",
|
||||
"#b5a3f5",
|
||||
"#8d70ed",
|
||||
"#6c47e6",
|
||||
"#5a2fe2",
|
||||
"#5023e0",
|
||||
"#4119c7",
|
||||
"#3915b3",
|
||||
"#2f0f9e",
|
||||
];
|
||||
|
||||
// The unibus theme: a single accent color and a slightly tighter default radius.
|
||||
// Mantine generates all its CSS variables from this; the SPA never hand-writes
|
||||
// CSS or color literals.
|
||||
export const theme = createTheme({
|
||||
primaryColor: "violet",
|
||||
defaultRadius: "md",
|
||||
primaryColor: "brand",
|
||||
colors: { brand },
|
||||
fontFamily:
|
||||
"Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
|
||||
headings: {
|
||||
fontWeight: "650",
|
||||
},
|
||||
"Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
defaultRadius: "md",
|
||||
headings: { fontWeight: "650" },
|
||||
});
|
||||
|
||||
+22
-38
@@ -1,41 +1,25 @@
|
||||
// Domain types shared across the SPA. They mirror the JSON the unibus gateway
|
||||
// (playground/server.go) returns; the browser never speaks NATS or crypto
|
||||
// directly — the Go peer behind the gateway does, so every type here is a plain
|
||||
// view of a gateway response.
|
||||
// Tipos de dominio de la UI. En la iteración 1 se llenan con datos mock;
|
||||
// más adelante vendrán del gateway (REST/SSE) que es un peer del bus.
|
||||
|
||||
// Peer is a named identity hosted by the gateway. endpoint_id is the stable bus
|
||||
// endpoint (base64url of sha256(signPub)).
|
||||
export interface Peer {
|
||||
name: string;
|
||||
endpoint_id: string;
|
||||
}
|
||||
|
||||
// Room is a channel the connected peer created or joined. encrypt true means the
|
||||
// payloads are sealed end-to-end with the room key.
|
||||
export interface Room {
|
||||
room_id: string;
|
||||
subject: string;
|
||||
encrypt: boolean;
|
||||
}
|
||||
|
||||
// Member is one participant of a room as reported by the control plane.
|
||||
export interface Member {
|
||||
endpoint: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
// BusEvent is one Server-Sent Event delivered on /api/stream: a message a peer
|
||||
// received on one of its subscribed rooms, already decrypted by the Go peer.
|
||||
export interface BusEvent {
|
||||
room_id: string;
|
||||
subject: string;
|
||||
sender: string;
|
||||
text: string;
|
||||
encrypted: boolean;
|
||||
ts: number; // unix millis
|
||||
}
|
||||
|
||||
// Message is a BusEvent enriched with a stable local id for React keys.
|
||||
export interface Message extends BusEvent {
|
||||
export interface User {
|
||||
id: string;
|
||||
handle: string;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
sender: string; // handle
|
||||
body: string;
|
||||
ts: number; // epoch ms
|
||||
mine?: boolean;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
encrypted: boolean;
|
||||
lastMessage: string;
|
||||
lastTs: number;
|
||||
unread: number;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
@@ -5,17 +5,16 @@
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
+1
-8
@@ -1,14 +1,7 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// The SPA talks to the unibus gateway over plain fetch + EventSource; the
|
||||
// gateway URL is chosen at runtime on the connect screen, so nothing is proxied
|
||||
// here. The dev server runs on a fixed port so the gateway's permissive CORS is
|
||||
// predictable.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
},
|
||||
server: { host: true, port: 5181 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user