* [feature] issue #634: HTTPS + reverse-proxy server endpoint support - Add ServerEndpoint model (scheme-aware base URL, resolve/webSocketUrl/relativeRequestPath, cleartext policy) - AuthManager: baseUrl is canonical; setHost/setPort derive from endpoint; endpointForBuild() for scheme-aware URLs - ApiClient/CookieManager/HermesWsClient: route through ServerEndpoint (no hardcoded http://) - Data migration: ServerUrlMigration converts legacy host/port to baseUrl - UI: Connect/AuthLogin/Settings now use a single Server URL field with transport warning - Build: ALLOW_CLEARTEXT buildConfigField + manifest placeholder for dev - Tests: ServerEndpointTest + ServerUrlMigrationTest; adapt existing suite to baseUrl API Co-Authored-By: M57 <Hy4ri@users.noreply.github.com> * fix(authlogin): basic-auth derives token automatically, no manual entry connectBasicAuth() built a fresh OkHttpClient for the password-login call and a different one for the ws-ticket call, so the Set-Cookie from /auth/password-login (the session token lives in the cookie, not the JSON body) never reached /api/auth/ws-ticket. That 401'd the ticket request, returned null, and the UI fell back to asking the user to paste a token. Both calls now use the shared OkHttpProvider.probe client (same CookieManager jar), so the session cookie flows through and the WS ticket mints automatically — matching the dashboard contract (routes.py: password-login sets Set-Cookie, ws-ticket requires it). Add AuthLoginBasicAuthFlowTest proving the cookie propagates on the shared client (and that ws-ticket 401s without it). Fixes the basic-auth token prompt reported against issue #634. * chore: remove TASK-634-TESTS.md (stray task-tracking doc, not part of feature) * ci: broaden .gitguardian.yml test-file ignores (unit + instrumented + variant dirs) * feat(authlogin): smart auth-mode detection from /api/status + drop pairing-string login - Replace 302-sniff probe with authoritative GET /api/status read (auth_required + auth_providers) to derive DashboardAuthMode. - Add OAUTH mode (stubbed, 'coming soon' UI, TODO tracks issue #639). - Render login UI by detected mode: token (loopback), username+password (basic/gated), or OAuth notice. - Remove legacy pairing-string box + onPairingString/applyLegacyPairing. - Extract internal deriveAuthMode() + add 5 unit tests. Closes nothing; OAuth tracked in #639. * fix(chat): don't block WS connect on empty token in gated (ticket) mode connectWebSocket() gated on getToken() != null, correct for loopback (token) mode but wrong for gated (ticket) mode: the ticket is minted fresh by HermesWsClient.refreshWsTicketIfNeeded() from the persisted session cookie, so getToken() is expected empty at connect time. The early return left the WS unopened -> DISCONNECTED -> chat showed 'reconnect' immediately after basic-auth login. Only require a non-blank token in non-gated mode. Fixes #640. * test: remove onPairingString test cases for deleted pairing-login path The pairing-string login (onPairingString/applyLegacyPairing) was removed from ConnectViewModel, so the 5 tests that exercised it (3 in ConnectViewModelTest, 2 in E2eIntegrationTest) no longer compile. Remove them. Also drop the now-orphaned connect_pairing_string from values-ko. Fixes the Unit & Integration Tests CI failure on PR #638. --------- Co-authored-by: hy4ri <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
d839c5e9ad
commit
26b104b2bb
40 files changed
+1299
-688
No files matched your search
@@ -1,5 +1,7 @@
|
||||
package com.m57.hermescontrol.data.config
|
||||
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@@ -7,6 +9,21 @@ import java.util.UUID
|
||||
data class ConnectionProfile(
|
||||
val id: String = UUID.randomUUID().toString(),
|
||||
val name: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
)
|
||||
/** Legacy fields retained only so pre-base-URL profiles can migrate. */
|
||||
val host: String = "127.0.0.1",
|
||||
val port: Int = 9119,
|
||||
val baseUrl: String? = null,
|
||||
) {
|
||||
val resolvedBaseUrl: String
|
||||
get() =
|
||||
baseUrl
|
||||
?.let {
|
||||
ServerEndpoint
|
||||
.parse(
|
||||
it,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
).baseUrl
|
||||
.toString()
|
||||
}
|
||||
?: ServerEndpoint.fromLegacy(host, port).baseUrl.toString()
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.m57.hermescontrol.data.config
|
||||
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
|
||||
fun ServerStoreState.addOrReplaceServer(profile: ConnectionProfile): ServerStoreState {
|
||||
val updated = connectionProfiles.filter { it.id != profile.id } + profile
|
||||
return copy(connectionProfiles = updated)
|
||||
@@ -31,14 +34,36 @@ fun ServerStoreState.selfHealed(): ServerStoreState {
|
||||
)
|
||||
}
|
||||
|
||||
val ServerStoreState.resolvedHost: String
|
||||
val ServerStoreState.resolvedBaseUrl: String
|
||||
get() {
|
||||
val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId }
|
||||
return selected?.host ?: host
|
||||
if (selected != null) return selected.resolvedBaseUrl
|
||||
return baseUrl
|
||||
?.let {
|
||||
ServerEndpoint
|
||||
.parse(
|
||||
it,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
).baseUrl
|
||||
.toString()
|
||||
}
|
||||
?: ServerEndpoint.fromLegacy(host, port).baseUrl.toString()
|
||||
}
|
||||
|
||||
val ServerStoreState.resolvedHost: String
|
||||
get() =
|
||||
ServerEndpoint
|
||||
.parse(
|
||||
resolvedBaseUrl,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
).baseUrl
|
||||
.host
|
||||
|
||||
val ServerStoreState.resolvedPort: Int
|
||||
get() {
|
||||
val selected = connectionProfiles.firstOrNull { it.id == selectedProfileId }
|
||||
return selected?.port ?: port
|
||||
}
|
||||
get() =
|
||||
ServerEndpoint
|
||||
.parse(
|
||||
resolvedBaseUrl,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
).baseUrl
|
||||
.port
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.config
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.Serializer
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -29,7 +30,7 @@ class ServerStore(
|
||||
try {
|
||||
dataStore.data.first().selfHealed()
|
||||
} catch (e: Exception) {
|
||||
ServerStoreState()
|
||||
ServerStoreSerializer.defaultValue
|
||||
}
|
||||
}
|
||||
_stateFlow = MutableStateFlow(initial)
|
||||
@@ -60,7 +61,8 @@ object ServerStoreSerializer : Serializer<ServerStoreState> {
|
||||
prettyPrint = false
|
||||
}
|
||||
|
||||
override val defaultValue: ServerStoreState = ServerStoreState()
|
||||
override val defaultValue: ServerStoreState =
|
||||
ServerStoreState(baseUrl = ServerEndpoint.DEFAULT_BASE_URL)
|
||||
|
||||
override suspend fun readFrom(input: InputStream): ServerStoreState =
|
||||
try {
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKeys
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
import com.m57.hermescontrol.theme.BottomNavDisplayMode
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
@@ -18,11 +19,30 @@ class ServerStoreMigration(
|
||||
companion object {
|
||||
private const val PREFS_FILE = "hermes_secure_prefs"
|
||||
private const val KEY_MIGRATED = "migrated_to_datastore"
|
||||
private val LEGACY_KEYS =
|
||||
setOf(
|
||||
"host",
|
||||
"port",
|
||||
"auto_reconnect",
|
||||
"theme_preference",
|
||||
"use_dynamic_colors",
|
||||
"theme_preset",
|
||||
"bottom_nav_display_mode",
|
||||
"bottom_nav_items",
|
||||
"connection_profiles",
|
||||
"selected_profile_id",
|
||||
"pinned_models",
|
||||
"ws_auth_param",
|
||||
"typing_effect_enabled",
|
||||
"typing_effect_delay_ms",
|
||||
"auth_token",
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean {
|
||||
val prefs = getPrefs() ?: return false
|
||||
return !prefs.getBoolean(KEY_MIGRATED, false)
|
||||
if (prefs.getBoolean(KEY_MIGRATED, false)) return false
|
||||
return LEGACY_KEYS.any(prefs::contains)
|
||||
}
|
||||
|
||||
override suspend fun migrate(currentData: ServerStoreState): ServerStoreState {
|
||||
@@ -92,13 +112,24 @@ class ServerStoreMigration(
|
||||
return currentData.copy(
|
||||
host = host,
|
||||
port = port,
|
||||
baseUrl = ServerEndpoint.fromLegacy(host, port).baseUrl.toString(),
|
||||
autoReconnect = autoReconnect,
|
||||
themePreference = themePreference,
|
||||
useDynamicColors = useDynamicColors,
|
||||
themePreset = themePreset,
|
||||
bottomNavDisplayMode = bottomNavDisplayMode,
|
||||
bottomNavItems = bottomNavItems,
|
||||
connectionProfiles = connectionProfiles,
|
||||
connectionProfiles =
|
||||
connectionProfiles.map { profile ->
|
||||
profile.copy(
|
||||
baseUrl =
|
||||
profile.baseUrl
|
||||
?: ServerEndpoint.fromLegacy(
|
||||
profile.host,
|
||||
profile.port,
|
||||
).baseUrl.toString(),
|
||||
)
|
||||
},
|
||||
selectedProfileId = selectedProfileId,
|
||||
pinnedModels = pinnedModels,
|
||||
wsAuthParam = wsAuthParam,
|
||||
|
||||
@@ -8,8 +8,10 @@ import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ServerStoreState(
|
||||
/** Legacy host and port retained for one-time migration. */
|
||||
val host: String = "127.0.0.1",
|
||||
val port: Int = 9119,
|
||||
val baseUrl: String? = null,
|
||||
val autoReconnect: Boolean = true,
|
||||
val themePreference: ThemePreference = ThemePreference.SYSTEM,
|
||||
val useDynamicColors: Boolean = true,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.m57.hermescontrol.data.config
|
||||
|
||||
import androidx.datastore.core.DataMigration
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.ServerEndpoint
|
||||
|
||||
/** Migrates the former host-and-port model to canonical complete base URLs. */
|
||||
class ServerUrlMigration : DataMigration<ServerStoreState> {
|
||||
override suspend fun shouldMigrate(currentData: ServerStoreState): Boolean =
|
||||
currentData.baseUrl.isNullOrBlank() ||
|
||||
currentData.connectionProfiles.any { it.baseUrl.isNullOrBlank() }
|
||||
|
||||
override suspend fun migrate(currentData: ServerStoreState): ServerStoreState = migrateState(currentData)
|
||||
|
||||
override suspend fun cleanUp() = Unit
|
||||
|
||||
companion object {
|
||||
internal fun migrateState(state: ServerStoreState): ServerStoreState =
|
||||
state.copy(
|
||||
baseUrl =
|
||||
normalizeExisting(state.baseUrl)
|
||||
?: ServerEndpoint.fromLegacy(
|
||||
state.host,
|
||||
state.port,
|
||||
).baseUrl.toString(),
|
||||
connectionProfiles =
|
||||
state.connectionProfiles.map { profile ->
|
||||
profile.copy(
|
||||
baseUrl =
|
||||
normalizeExisting(profile.baseUrl)
|
||||
?: ServerEndpoint.fromLegacy(
|
||||
profile.host,
|
||||
profile.port,
|
||||
).baseUrl.toString(),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private fun normalizeExisting(raw: String?): String? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
return ServerEndpoint
|
||||
.parse(
|
||||
raw,
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
).baseUrl
|
||||
.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user