feat: unify gateway settings into Connection screen and add WS auth key
Consolidate gateway port/API key management into a single Connection screen that updates the server via config API, and separate WS auth from gateway auth by adding api_key to WebSocketConfig. - Remove GatewayConfig.Host (hardcode 127.0.0.1) - Add WebSocketConfig.APIKey with constant-time auth check on WS upgrade - Exclude gateway section from Backend Config schema UI - Refactor Android App Settings → Connection (port + API key only) - Fetch WS connection info dynamically from config API - Remove wsPort/wsUrl from GatewaySettings and WebSocketClient.apiKey Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7746cafe50
commit
38da89539e
14 changed files with 137 additions and 138 deletions
|
|
@ -1,15 +1,22 @@
|
||||||
package io.clawdroid
|
package io.clawdroid
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
import io.clawdroid.backend.api.GatewaySettingsStore
|
import io.clawdroid.backend.api.GatewaySettingsStore
|
||||||
|
import io.clawdroid.backend.config.ConfigApiClient
|
||||||
import io.clawdroid.backend.config.configModule
|
import io.clawdroid.backend.config.configModule
|
||||||
import io.clawdroid.core.data.remote.WebSocketClient
|
import io.clawdroid.core.data.remote.WebSocketClient
|
||||||
import io.clawdroid.di.appModule
|
import io.clawdroid.di.appModule
|
||||||
import io.clawdroid.receiver.NotificationHelper
|
import io.clawdroid.receiver.NotificationHelper
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
|
import java.net.URLEncoder
|
||||||
|
|
||||||
class ClawDroidApp : Application() {
|
class ClawDroidApp : Application() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
|
|
@ -23,16 +30,51 @@ class ClawDroidApp : Application() {
|
||||||
val koin = koinApp.koin
|
val koin = koinApp.koin
|
||||||
val settingsStore: GatewaySettingsStore = koin.get()
|
val settingsStore: GatewaySettingsStore = koin.get()
|
||||||
val wsClient: WebSocketClient = koin.get()
|
val wsClient: WebSocketClient = koin.get()
|
||||||
|
val configApiClient: ConfigApiClient = koin.get()
|
||||||
val scope: CoroutineScope = koin.get()
|
val scope: CoroutineScope = koin.get()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
settingsStore.settings.collect { s ->
|
// Only react when httpPort or apiKey actually change
|
||||||
if (wsClient.wsUrl != s.wsUrl || wsClient.apiKey != s.apiKey) {
|
settingsStore.settings
|
||||||
|
.map { it.httpPort to it.apiKey }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect {
|
||||||
|
// Fetch WS connection info from config API
|
||||||
|
val wsUrl = fetchWsUrl(configApiClient)
|
||||||
|
if (wsClient.wsUrl != wsUrl) {
|
||||||
wsClient.disconnect()
|
wsClient.disconnect()
|
||||||
wsClient.wsUrl = s.wsUrl
|
wsClient.wsUrl = wsUrl
|
||||||
wsClient.apiKey = s.apiKey
|
|
||||||
wsClient.connect()
|
wsClient.connect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun fetchWsUrl(configApiClient: ConfigApiClient): String {
|
||||||
|
return try {
|
||||||
|
val cfg = configApiClient.getConfig()
|
||||||
|
val ws = cfg["channels"]?.jsonObject?.get("websocket")?.jsonObject
|
||||||
|
if (ws != null) {
|
||||||
|
val host = ws["host"]?.jsonPrimitive?.content ?: "127.0.0.1"
|
||||||
|
val port = ws["port"]?.jsonPrimitive?.content ?: "18793"
|
||||||
|
val path = ws["path"]?.jsonPrimitive?.content ?: "/ws"
|
||||||
|
val wsApiKey = ws["api_key"]?.jsonPrimitive?.content ?: ""
|
||||||
|
buildString {
|
||||||
|
append("ws://$host:$port$path")
|
||||||
|
if (wsApiKey.isNotEmpty()) {
|
||||||
|
append("?api_key=${URLEncoder.encode(wsApiKey, "UTF-8")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DEFAULT_WS_URL
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to fetch WS config from API, using defaults", e)
|
||||||
|
DEFAULT_WS_URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ClawDroidApp"
|
||||||
|
private const val DEFAULT_WS_URL = "ws://127.0.0.1:18793/ws"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,7 @@ val appModule = module {
|
||||||
val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also {
|
val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also {
|
||||||
prefs.edit().putString("client_id", it).apply()
|
prefs.edit().putString("client_id", it).apply()
|
||||||
}
|
}
|
||||||
val gwSettings = get<GatewaySettingsStore>().settings.value
|
WebSocketClient(get(), get(), clientId)
|
||||||
WebSocketClient(get(), get(), clientId).apply {
|
|
||||||
wsUrl = gwSettings.wsUrl
|
|
||||||
apiKey = gwSettings.apiKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageFileStorage
|
// ImageFileStorage
|
||||||
|
|
@ -134,5 +130,5 @@ val appModule = module {
|
||||||
// ViewModel
|
// ViewModel
|
||||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get(), get(), get()) }
|
||||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
viewModel { SettingsViewModel(get(), get(), get()) }
|
||||||
viewModel { AppSettingsViewModel(get()) }
|
viewModel { AppSettingsViewModel(get(), get()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ fun AppSettingsScreen(
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
var apiKeyHidden by remember { mutableStateOf(true) }
|
var apiKeyHidden by remember { mutableStateOf(true) }
|
||||||
val saveEnabled by remember { derivedStateOf { !uiState.hasErrors } }
|
val saveEnabled by remember { derivedStateOf { !uiState.hasErrors && !uiState.saving } }
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -92,7 +92,7 @@ fun AppSettingsScreen(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text("App Settings") },
|
title = { Text("Connection") },
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
),
|
),
|
||||||
|
|
@ -115,7 +115,7 @@ fun AppSettingsScreen(
|
||||||
),
|
),
|
||||||
modifier = Modifier.padding(end = 8.dp),
|
modifier = Modifier.padding(end = 8.dp),
|
||||||
) {
|
) {
|
||||||
Text("Save")
|
Text(if (uiState.saving) "Saving…" else "Save")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -135,10 +135,23 @@ fun AppSettingsScreen(
|
||||||
color = NeonCyan,
|
color = NeonCyan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.httpPort,
|
||||||
|
onValueChange = { viewModel.onHttpPortChange(it) },
|
||||||
|
label = { Text("Port", color = TextSecondary) },
|
||||||
|
placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) },
|
||||||
|
singleLine = true,
|
||||||
|
isError = uiState.httpPortError != null,
|
||||||
|
supportingText = uiState.httpPortError?.let { err -> { Text(err) } },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
colors = appSettingsFieldColors(),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = uiState.apiKey,
|
value = uiState.apiKey,
|
||||||
onValueChange = { viewModel.onApiKeyChange(it) },
|
onValueChange = { viewModel.onApiKeyChange(it) },
|
||||||
label = { Text("Gateway API Key", color = TextSecondary) },
|
label = { Text("API Key", color = TextSecondary) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None,
|
visualTransformation = if (apiKeyHidden) PasswordVisualTransformation() else VisualTransformation.None,
|
||||||
trailingIcon = {
|
trailingIcon = {
|
||||||
|
|
@ -154,34 +167,16 @@ fun AppSettingsScreen(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
|
||||||
OutlinedTextField(
|
uiState.error?.let { error ->
|
||||||
value = uiState.wsPort,
|
Text(
|
||||||
onValueChange = { viewModel.onWsPortChange(it) },
|
error,
|
||||||
label = { Text("Gateway WS Port", color = TextSecondary) },
|
style = MaterialTheme.typography.bodySmall,
|
||||||
placeholder = { Text("18793", color = TextSecondary.copy(alpha = 0.5f)) },
|
color = MaterialTheme.colorScheme.error,
|
||||||
singleLine = true,
|
|
||||||
isError = uiState.wsPortError != null,
|
|
||||||
supportingText = uiState.wsPortError?.let { err -> { Text(err) } },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
colors = appSettingsFieldColors(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
|
|
||||||
OutlinedTextField(
|
|
||||||
value = uiState.httpPort,
|
|
||||||
onValueChange = { viewModel.onHttpPortChange(it) },
|
|
||||||
label = { Text("Gateway HTTP Port", color = TextSecondary) },
|
|
||||||
placeholder = { Text("18790", color = TextSecondary.copy(alpha = 0.5f)) },
|
|
||||||
singleLine = true,
|
|
||||||
isError = uiState.httpPortError != null,
|
|
||||||
supportingText = uiState.httpPortError?.let { err -> { Text(err) } },
|
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
colors = appSettingsFieldColors(),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,23 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import io.clawdroid.backend.api.GatewaySettings
|
import io.clawdroid.backend.api.GatewaySettings
|
||||||
import io.clawdroid.backend.api.GatewaySettingsStore
|
import io.clawdroid.backend.api.GatewaySettingsStore
|
||||||
|
import io.clawdroid.backend.config.ConfigApiClient
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
|
||||||
data class AppSettingsUiState(
|
data class AppSettingsUiState(
|
||||||
val apiKey: String = "",
|
val apiKey: String = "",
|
||||||
val wsPort: String = "18793",
|
|
||||||
val httpPort: String = "18790",
|
val httpPort: String = "18790",
|
||||||
|
val saving: Boolean = false,
|
||||||
|
val error: String? = null,
|
||||||
) {
|
) {
|
||||||
val wsPortError: String? get() = portError(wsPort)
|
|
||||||
val httpPortError: String? get() = portError(httpPort)
|
val httpPortError: String? get() = portError(httpPort)
|
||||||
val hasErrors: Boolean get() = wsPortError != null || httpPortError != null
|
val hasErrors: Boolean get() = httpPortError != null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun portError(value: String): String? {
|
private fun portError(value: String): String? {
|
||||||
|
|
@ -28,6 +31,7 @@ private fun portError(value: String): String? {
|
||||||
|
|
||||||
class AppSettingsViewModel(
|
class AppSettingsViewModel(
|
||||||
private val settingsStore: GatewaySettingsStore,
|
private val settingsStore: GatewaySettingsStore,
|
||||||
|
private val configApiClient: ConfigApiClient,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
private val _uiState = MutableStateFlow(AppSettingsUiState())
|
||||||
|
|
@ -37,43 +41,48 @@ class AppSettingsViewModel(
|
||||||
val current = settingsStore.settings.value
|
val current = settingsStore.settings.value
|
||||||
_uiState.value = AppSettingsUiState(
|
_uiState.value = AppSettingsUiState(
|
||||||
apiKey = current.apiKey,
|
apiKey = current.apiKey,
|
||||||
wsPort = current.wsPort.toString(),
|
|
||||||
httpPort = current.httpPort.toString(),
|
httpPort = current.httpPort.toString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onApiKeyChange(value: String) {
|
fun onApiKeyChange(value: String) {
|
||||||
_uiState.update { it.copy(apiKey = value) }
|
_uiState.update { it.copy(apiKey = value, error = null) }
|
||||||
}
|
|
||||||
|
|
||||||
fun onWsPortChange(value: String) {
|
|
||||||
if (value.isEmpty() || value.toIntOrNull() != null) {
|
|
||||||
_uiState.update { it.copy(wsPort = value) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onHttpPortChange(value: String) {
|
fun onHttpPortChange(value: String) {
|
||||||
if (value.isEmpty() || value.toIntOrNull() != null) {
|
if (value.isEmpty() || value.toIntOrNull() != null) {
|
||||||
_uiState.update { it.copy(httpPort = value) }
|
_uiState.update { it.copy(httpPort = value, error = null) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun save(onComplete: () -> Unit) {
|
fun save(onComplete: () -> Unit) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
if (state.hasErrors) return@launch
|
if (state.hasErrors || state.saving) return@launch
|
||||||
|
|
||||||
|
_uiState.update { it.copy(saving = true, error = null) }
|
||||||
|
|
||||||
val defaults = GatewaySettings()
|
val defaults = GatewaySettings()
|
||||||
fun validPort(raw: String, fallback: Int): Int {
|
val newPort = state.httpPort.toIntOrNull()?.takeIf { it in 1..65535 } ?: defaults.httpPort
|
||||||
val port = raw.toIntOrNull() ?: return fallback
|
val newKey = state.apiKey
|
||||||
return if (port in 1..65535) port else fallback
|
|
||||||
|
// Send update via config API using current (old) connection settings
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("gateway", buildJsonObject {
|
||||||
|
put("port", JsonPrimitive(newPort))
|
||||||
|
put("api_key", JsonPrimitive(newKey))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
val settings = GatewaySettings(
|
|
||||||
wsPort = validPort(state.wsPort, defaults.wsPort),
|
try {
|
||||||
httpPort = validPort(state.httpPort, defaults.httpPort),
|
configApiClient.saveConfig(payload)
|
||||||
apiKey = state.apiKey,
|
// Persist new values locally after remote success
|
||||||
)
|
settingsStore.update(GatewaySettings(httpPort = newPort, apiKey = newKey))
|
||||||
settingsStore.update(settings)
|
_uiState.update { it.copy(saving = false) }
|
||||||
onComplete()
|
onComplete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update { it.copy(saving = false, error = e.message ?: "Save failed") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ class GatewaySettingsStoreImpl(
|
||||||
) : GatewaySettingsStore {
|
) : GatewaySettingsStore {
|
||||||
|
|
||||||
private object Keys {
|
private object Keys {
|
||||||
val WS_PORT = intPreferencesKey("ws_port")
|
|
||||||
val HTTP_PORT = intPreferencesKey("http_port")
|
val HTTP_PORT = intPreferencesKey("http_port")
|
||||||
val API_KEY = stringPreferencesKey("api_key")
|
val API_KEY = stringPreferencesKey("api_key")
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +28,6 @@ class GatewaySettingsStoreImpl(
|
||||||
override val settings: StateFlow<GatewaySettings> =
|
override val settings: StateFlow<GatewaySettings> =
|
||||||
context.gatewayDataStore.data.map { prefs ->
|
context.gatewayDataStore.data.map { prefs ->
|
||||||
GatewaySettings(
|
GatewaySettings(
|
||||||
wsPort = prefs[Keys.WS_PORT] ?: DEFAULT.wsPort,
|
|
||||||
httpPort = prefs[Keys.HTTP_PORT] ?: DEFAULT.httpPort,
|
httpPort = prefs[Keys.HTTP_PORT] ?: DEFAULT.httpPort,
|
||||||
apiKey = prefs[Keys.API_KEY] ?: DEFAULT.apiKey,
|
apiKey = prefs[Keys.API_KEY] ?: DEFAULT.apiKey,
|
||||||
)
|
)
|
||||||
|
|
@ -41,7 +39,6 @@ class GatewaySettingsStoreImpl(
|
||||||
|
|
||||||
override suspend fun update(settings: GatewaySettings) {
|
override suspend fun update(settings: GatewaySettings) {
|
||||||
context.gatewayDataStore.edit { prefs ->
|
context.gatewayDataStore.edit { prefs ->
|
||||||
prefs[Keys.WS_PORT] = settings.wsPort
|
|
||||||
prefs[Keys.HTTP_PORT] = settings.httpPort
|
prefs[Keys.HTTP_PORT] = settings.httpPort
|
||||||
prefs[Keys.API_KEY] = settings.apiKey
|
prefs[Keys.API_KEY] = settings.apiKey
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
package io.clawdroid.backend.api
|
package io.clawdroid.backend.api
|
||||||
|
|
||||||
data class GatewaySettings(
|
data class GatewaySettings(
|
||||||
val wsPort: Int = 18793,
|
|
||||||
val httpPort: Int = 18790,
|
val httpPort: Int = 18790,
|
||||||
val apiKey: String = "",
|
val apiKey: String = "",
|
||||||
) {
|
) {
|
||||||
val wsUrl: String get() = "ws://127.0.0.1:$wsPort/ws"
|
|
||||||
val httpBaseUrl: String get() = "http://127.0.0.1:$httpPort"
|
val httpBaseUrl: String get() = "http://127.0.0.1:$httpPort"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import java.net.URLEncoder
|
|
||||||
|
|
||||||
class WebSocketClient(
|
class WebSocketClient(
|
||||||
private val client: HttpClient,
|
private val client: HttpClient,
|
||||||
|
|
@ -42,7 +42,6 @@ class WebSocketClient(
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
@Volatile var wsUrl: String = "ws://127.0.0.1:18793/ws"
|
@Volatile var wsUrl: String = "ws://127.0.0.1:18793/ws"
|
||||||
@Volatile var apiKey: String = ""
|
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
if (connectJob?.isActive == true) return
|
if (connectJob?.isActive == true) return
|
||||||
|
|
@ -52,11 +51,8 @@ class WebSocketClient(
|
||||||
try {
|
try {
|
||||||
_connectionState.value = ConnectionState.CONNECTING
|
_connectionState.value = ConnectionState.CONNECTING
|
||||||
val currentWsUrl = wsUrl
|
val currentWsUrl = wsUrl
|
||||||
val currentApiKey = apiKey
|
val separator = if ('?' in currentWsUrl) '&' else '?'
|
||||||
val url = buildString {
|
val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType"
|
||||||
append("$currentWsUrl?client_id=$clientId&client_type=$clientType")
|
|
||||||
if (currentApiKey.isNotEmpty()) append("&api_key=${URLEncoder.encode(currentApiKey, "UTF-8")}")
|
|
||||||
}
|
|
||||||
client.webSocket(url) {
|
client.webSocket(url) {
|
||||||
session = this
|
session = this
|
||||||
_connectionState.value = ConnectionState.CONNECTED
|
_connectionState.value = ConnectionState.CONNECTED
|
||||||
|
|
|
||||||
|
|
@ -492,7 +492,7 @@ func gatewayCmd() {
|
||||||
fmt.Printf("Error starting gateway HTTP server: %v\n", err)
|
fmt.Printf("Error starting gateway HTTP server: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Printf("✓ Config API started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Config API started on 127.0.0.1:%d\n", cfg.Gateway.Port)
|
||||||
|
|
||||||
// Setup cron tool and service
|
// Setup cron tool and service
|
||||||
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled)
|
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled)
|
||||||
|
|
@ -539,7 +539,7 @@ func gatewayCmd() {
|
||||||
fmt.Println("⚠ Warning: No channels enabled")
|
fmt.Println("⚠ Warning: No channels enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
|
fmt.Printf("✓ Gateway started on 127.0.0.1:%d\n", cfg.Gateway.Port)
|
||||||
fmt.Println("Press Ctrl+C to stop")
|
fmt.Println("Press Ctrl+C to stop")
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package channels
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -198,6 +199,13 @@ func (c *WebSocketChannel) maybeBroadcast(msg bus.OutboundMessage, clientType st
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
|
func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if key := c.config.APIKey; key != "" {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("api_key")), []byte(key)) != 1 {
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
conn, err := c.upgrader.Upgrade(w, r, nil)
|
conn, err := c.upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("websocket", "Upgrade failed", map[string]interface{}{
|
logger.ErrorCF("websocket", "Upgrade failed", map[string]interface{}{
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ type WebSocketConfig struct {
|
||||||
Host string `json:"host" label:"Host" env:"CLAWDROID_CHANNELS_WEBSOCKET_HOST"`
|
Host string `json:"host" label:"Host" env:"CLAWDROID_CHANNELS_WEBSOCKET_HOST"`
|
||||||
Port int `json:"port" label:"Port" env:"CLAWDROID_CHANNELS_WEBSOCKET_PORT"`
|
Port int `json:"port" label:"Port" env:"CLAWDROID_CHANNELS_WEBSOCKET_PORT"`
|
||||||
Path string `json:"path" label:"Path" env:"CLAWDROID_CHANNELS_WEBSOCKET_PATH"`
|
Path string `json:"path" label:"Path" env:"CLAWDROID_CHANNELS_WEBSOCKET_PATH"`
|
||||||
|
APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_CHANNELS_WEBSOCKET_API_KEY"`
|
||||||
AllowFrom FlexibleStringSlice `json:"allow_from" label:"Allow From" env:"CLAWDROID_CHANNELS_WEBSOCKET_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" label:"Allow From" env:"CLAWDROID_CHANNELS_WEBSOCKET_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,7 +139,6 @@ type RateLimitsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type GatewayConfig struct {
|
type GatewayConfig struct {
|
||||||
Host string `json:"host" label:"Host" env:"CLAWDROID_GATEWAY_HOST"`
|
|
||||||
Port int `json:"port" label:"Port" env:"CLAWDROID_GATEWAY_PORT"`
|
Port int `json:"port" label:"Port" env:"CLAWDROID_GATEWAY_PORT"`
|
||||||
APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_GATEWAY_API_KEY"`
|
APIKey string `json:"api_key" label:"API Key" env:"CLAWDROID_GATEWAY_API_KEY"`
|
||||||
}
|
}
|
||||||
|
|
@ -249,7 +249,6 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
Host: "127.0.0.1",
|
|
||||||
Port: 18790,
|
Port: 18790,
|
||||||
},
|
},
|
||||||
Tools: ToolsConfig{
|
Tools: ToolsConfig{
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,6 @@ func TestDefaultConfig_Temperature(t *testing.T) {
|
||||||
func TestDefaultConfig_Gateway(t *testing.T) {
|
func TestDefaultConfig_Gateway(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
if cfg.Gateway.Host != "127.0.0.1" {
|
|
||||||
t.Error("Gateway host should have default value")
|
|
||||||
}
|
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
t.Error("Gateway port should have default value")
|
t.Error("Gateway port should have default value")
|
||||||
}
|
}
|
||||||
|
|
@ -207,9 +204,6 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
if cfg.Agents.Defaults.MaxToolIterations == 0 {
|
||||||
t.Error("MaxToolIterations should not be zero")
|
t.Error("MaxToolIterations should not be zero")
|
||||||
}
|
}
|
||||||
if cfg.Gateway.Host != "127.0.0.1" {
|
|
||||||
t.Error("Gateway host should have default value")
|
|
||||||
}
|
|
||||||
if cfg.Gateway.Port == 0 {
|
if cfg.Gateway.Port == 0 {
|
||||||
t.Error("Gateway port should have default value")
|
t.Error("Gateway port should have default value")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,11 @@ func BuildSchema(defaultCfg *config.Config) SchemaResponse {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gateway is managed via the Connection screen, not the Backend Config UI.
|
||||||
|
if jsonTag == "gateway" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
section := SchemaSection{
|
section := SchemaSection{
|
||||||
Key: jsonTag,
|
Key: jsonTag,
|
||||||
Label: labelTag(field),
|
Label: labelTag(field),
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ func (s *Server) Start() error {
|
||||||
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
|
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
|
||||||
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig))
|
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig))
|
||||||
|
|
||||||
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
|
addr := fmt.Sprintf("127.0.0.1:%d", s.cfg.Gateway.Port)
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: mux,
|
Handler: mux,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ func TestServerStop_NoServer_NoError(t *testing.T) {
|
||||||
|
|
||||||
func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
|
func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Gateway.Host = "127.0.0.1"
|
|
||||||
cfg.Gateway.Port = -1 // invalid port prevents real listen but Start still builds handler
|
cfg.Gateway.Port = -1 // invalid port prevents real listen but Start still builds handler
|
||||||
cfg.Gateway.APIKey = "test-key"
|
cfg.Gateway.APIKey = "test-key"
|
||||||
|
|
||||||
|
|
@ -106,9 +105,9 @@ func TestServerStart_RegistersRoutesAndAuth(t *testing.T) {
|
||||||
|
|
||||||
func TestBuildSchema_SectionCount(t *testing.T) {
|
func TestBuildSchema_SectionCount(t *testing.T) {
|
||||||
schema := BuildSchema(config.DefaultConfig())
|
schema := BuildSchema(config.DefaultConfig())
|
||||||
// Config has exported fields: LLM, Agents, Channels, Gateway, Tools, Heartbeat, RateLimits
|
// Config has exported fields: LLM, Agents, Channels, Tools, Heartbeat, RateLimits (gateway excluded)
|
||||||
if len(schema.Sections) < 7 {
|
if len(schema.Sections) < 6 {
|
||||||
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections))
|
t.Errorf("expected at least 6 sections, got %d", len(schema.Sections))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -342,8 +341,6 @@ func TestBuildSchema_DefaultValues(t *testing.T) {
|
||||||
{"agents", "defaults.max_tokens", float64(8192)}, // JSON numbers → float64
|
{"agents", "defaults.max_tokens", float64(8192)}, // JSON numbers → float64
|
||||||
{"agents", "defaults.context_window", float64(128000)},
|
{"agents", "defaults.context_window", float64(128000)},
|
||||||
{"agents", "defaults.restrict_to_workspace", true},
|
{"agents", "defaults.restrict_to_workspace", true},
|
||||||
{"gateway", "host", "127.0.0.1"},
|
|
||||||
{"gateway", "port", float64(18790)},
|
|
||||||
{"heartbeat", "enabled", true},
|
{"heartbeat", "enabled", true},
|
||||||
{"heartbeat", "interval", float64(30)},
|
{"heartbeat", "interval", float64(30)},
|
||||||
}
|
}
|
||||||
|
|
@ -387,8 +384,8 @@ func TestHandleGetSchema_Response(t *testing.T) {
|
||||||
t.Fatalf("failed to decode response: %v", err)
|
t.Fatalf("failed to decode response: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(schema.Sections) < 7 {
|
if len(schema.Sections) < 6 {
|
||||||
t.Errorf("expected at least 7 sections, got %d", len(schema.Sections))
|
t.Errorf("expected at least 6 sections, got %d", len(schema.Sections))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify each section has at least one field
|
// Verify each section has at least one field
|
||||||
|
|
@ -451,7 +448,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.LLM.Model = "test-model"
|
cfg.LLM.Model = "test-model"
|
||||||
cfg.LLM.BaseURL = "https://example.com"
|
cfg.LLM.BaseURL = "https://example.com"
|
||||||
cfg.Gateway.Host = "0.0.0.0"
|
|
||||||
cfg.Gateway.Port = 9999
|
cfg.Gateway.Port = 9999
|
||||||
|
|
||||||
s := newTestServer(cfg)
|
s := newTestServer(cfg)
|
||||||
|
|
@ -475,9 +471,6 @@ func TestHandleGetConfig_NonSecretValues(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
gw := result["gateway"].(map[string]interface{})
|
gw := result["gateway"].(map[string]interface{})
|
||||||
if gw["host"] != "0.0.0.0" {
|
|
||||||
t.Errorf("gateway host = %v, want %q", gw["host"], "0.0.0.0")
|
|
||||||
}
|
|
||||||
if gw["port"] != float64(9999) {
|
if gw["port"] != float64(9999) {
|
||||||
t.Errorf("gateway port = %v, want %v", gw["port"], 9999)
|
t.Errorf("gateway port = %v, want %v", gw["port"], 9999)
|
||||||
}
|
}
|
||||||
|
|
@ -723,7 +716,6 @@ func TestHandlePutConfig_SaveError_500(t *testing.T) {
|
||||||
func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
|
func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.LLM.Model = "original-model"
|
cfg.LLM.Model = "original-model"
|
||||||
cfg.Gateway.Host = "10.0.0.1"
|
|
||||||
cfg.Gateway.Port = 12345
|
cfg.Gateway.Port = 12345
|
||||||
cfg.Channels.Telegram.Enabled = true
|
cfg.Channels.Telegram.Enabled = true
|
||||||
cfg.Heartbeat.Interval = 60
|
cfg.Heartbeat.Interval = 60
|
||||||
|
|
@ -751,9 +743,6 @@ func TestHandlePutConfig_PartialUpdatePreservesOtherSections(t *testing.T) {
|
||||||
if saved.LLM.Model != "new-model" {
|
if saved.LLM.Model != "new-model" {
|
||||||
t.Errorf("LLM model = %q, want %q", saved.LLM.Model, "new-model")
|
t.Errorf("LLM model = %q, want %q", saved.LLM.Model, "new-model")
|
||||||
}
|
}
|
||||||
if saved.Gateway.Host != "10.0.0.1" {
|
|
||||||
t.Errorf("Gateway host = %q, want %q (should be preserved)", saved.Gateway.Host, "10.0.0.1")
|
|
||||||
}
|
|
||||||
if saved.Gateway.Port != 12345 {
|
if saved.Gateway.Port != 12345 {
|
||||||
t.Errorf("Gateway port = %d, want %d (should be preserved)", saved.Gateway.Port, 12345)
|
t.Errorf("Gateway port = %d, want %d (should be preserved)", saved.Gateway.Port, 12345)
|
||||||
}
|
}
|
||||||
|
|
@ -1200,7 +1189,6 @@ func TestBuildSchema_SectionLabels(t *testing.T) {
|
||||||
|
|
||||||
func TestServerRouting_PutConfig_AuthRequired(t *testing.T) {
|
func TestServerRouting_PutConfig_AuthRequired(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Gateway.Host = "127.0.0.1"
|
|
||||||
cfg.Gateway.Port = -1
|
cfg.Gateway.Port = -1
|
||||||
cfg.Gateway.APIKey = "route-test-key"
|
cfg.Gateway.APIKey = "route-test-key"
|
||||||
|
|
||||||
|
|
@ -2064,7 +2052,6 @@ func TestHandlePutConfig_ThenGetRoundTrip(t *testing.T) {
|
||||||
|
|
||||||
func TestServerStop_CalledTwice(t *testing.T) {
|
func TestServerStop_CalledTwice(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Gateway.Host = "127.0.0.1"
|
|
||||||
cfg.Gateway.Port = -1
|
cfg.Gateway.Port = -1
|
||||||
|
|
||||||
s := NewServer(cfg, "/tmp/config.json", nil)
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
||||||
|
|
@ -2085,7 +2072,6 @@ func TestServerStop_CalledTwice(t *testing.T) {
|
||||||
|
|
||||||
func TestServerRouting_UnsupportedMethods(t *testing.T) {
|
func TestServerRouting_UnsupportedMethods(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Gateway.Host = "127.0.0.1"
|
|
||||||
cfg.Gateway.Port = -1
|
cfg.Gateway.Port = -1
|
||||||
|
|
||||||
s := NewServer(cfg, "/tmp/config.json", nil)
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
||||||
|
|
@ -2107,7 +2093,6 @@ func TestServerRouting_UnsupportedMethods(t *testing.T) {
|
||||||
|
|
||||||
func TestServerRouting_UnknownPath(t *testing.T) {
|
func TestServerRouting_UnknownPath(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Gateway.Host = "127.0.0.1"
|
|
||||||
cfg.Gateway.Port = -1
|
cfg.Gateway.Port = -1
|
||||||
|
|
||||||
s := NewServer(cfg, "/tmp/config.json", nil)
|
s := NewServer(cfg, "/tmp/config.json", nil)
|
||||||
|
|
@ -2168,7 +2153,6 @@ func TestAuthMiddleware_TokenWithWhitespace_403(t *testing.T) {
|
||||||
|
|
||||||
func TestServerRouting_SchemaAuthRequired(t *testing.T) {
|
func TestServerRouting_SchemaAuthRequired(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Gateway.Host = "127.0.0.1"
|
|
||||||
cfg.Gateway.Port = -1
|
cfg.Gateway.Port = -1
|
||||||
cfg.Gateway.APIKey = "schema-auth-key"
|
cfg.Gateway.APIKey = "schema-auth-key"
|
||||||
|
|
||||||
|
|
@ -2336,38 +2320,14 @@ func TestBuildSchema_RateLimitsSectionFields(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- #30: gateway section all fields in schema ---
|
// --- #30: gateway section excluded from schema ---
|
||||||
|
|
||||||
func TestBuildSchema_GatewaySectionFields(t *testing.T) {
|
func TestBuildSchema_GatewaySectionExcluded(t *testing.T) {
|
||||||
schema := BuildSchema(config.DefaultConfig())
|
schema := BuildSchema(config.DefaultConfig())
|
||||||
|
|
||||||
var gw *SchemaSection
|
for _, sec := range schema.Sections {
|
||||||
for i := range schema.Sections {
|
if sec.Key == "gateway" {
|
||||||
if schema.Sections[i].Key == "gateway" {
|
t.Error("gateway section should be excluded from schema")
|
||||||
gw = &schema.Sections[i]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if gw == nil {
|
|
||||||
t.Fatal("gateway section not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
keySet := map[string]bool{}
|
|
||||||
for _, f := range gw.Fields {
|
|
||||||
keySet[f.Key] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
wantKeys := []string{"host", "port", "api_key"}
|
|
||||||
for _, k := range wantKeys {
|
|
||||||
if !keySet[k] {
|
|
||||||
t.Errorf("gateway field %q not found", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// api_key should be secret
|
|
||||||
for _, f := range gw.Fields {
|
|
||||||
if f.Key == "api_key" && !f.Secret {
|
|
||||||
t.Error("gateway api_key should be marked as secret")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue