[feature] issue #634: HTTPS + reverse-proxy server endpoint support (#638)

* [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:
M57andhy4ri authored and GitHub committed 2026-07-20 17:26:10 +03:00
1 parent d839c5e9ad
commit 26b104b2bb
40 files changed
+1299 -688

No files matched your search

@@ -8,10 +8,14 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.config.ServerStore
import com.m57.hermescontrol.data.config.ServerStoreMigration
import com.m57.hermescontrol.data.config.ServerStoreSerializer
import com.m57.hermescontrol.data.config.ServerUrlMigration
import com.m57.hermescontrol.data.config.resolvedBaseUrl
import com.m57.hermescontrol.data.config.resolvedHost
import com.m57.hermescontrol.data.config.resolvedPort
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.remote.CleartextPolicy
import com.m57.hermescontrol.data.remote.CookieManager
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
@@ -87,7 +91,11 @@ object AuthManager {
val dataStore =
androidx.datastore.core.DataStoreFactory.create(
serializer = ServerStoreSerializer,
migrations = listOf(ServerStoreMigration(context)),
migrations =
listOf(
ServerStoreMigration(context),
ServerUrlMigration(),
),
) {
context.filesDir.resolve("server_store.json")
}
@@ -174,9 +182,7 @@ object AuthManager {
fun getSessionCookie(): String? = CookieManager.getSessionCookie()
fun setSessionCookie(cookie: String?) {
// The session cookie is host-scoped to the current dashboard host so
// the CookieJar only attaches it to matching requests.
CookieManager.setSessionCookie(cookie, AuthManager.getHost())
CookieManager.setSessionCookie(cookie, endpoint())
}
/**
@@ -237,8 +243,7 @@ object AuthManager {
ConnectionProfile(
id = DEFAULT_PROFILE_ID,
name = DEFAULT_PROFILE_NAME,
host = s.host,
port = s.port,
baseUrl = s.resolvedBaseUrl,
),
),
selectedProfileId = DEFAULT_PROFILE_ID,
@@ -359,7 +364,7 @@ object AuthManager {
appScope?.let {
try {
it.cancel()
} catch (e: Exception) {
} catch (_: Exception) {
}
}
appScope = null
@@ -400,11 +405,24 @@ object AuthManager {
}
}
// ── Host ─────────────────────────────────────────────────────────────
// ── Server endpoint ──────────────────────────────────────────────────
fun getHost(): String = serverStore.getLatestState().resolvedHost
fun getBaseUrl(): String = serverStore.getLatestState().resolvedBaseUrl
fun setHost(host: String) {
fun endpoint(): ServerEndpoint =
ServerEndpoint.parse(
getBaseUrl(),
CleartextPolicy.ALLOW_WITH_WARNING,
)
fun endpointForBuild(): ServerEndpoint = ServerEndpoint.parseForBuild(getBaseUrl())
fun setBaseUrl(baseUrl: String) {
val normalized =
ServerEndpoint.parse(
baseUrl,
CleartextPolicy.ALLOW_WITH_WARNING,
).baseUrl.toString()
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
@@ -412,30 +430,36 @@ object AuthManager {
}
serverStore.update { state ->
val profiles =
state.connectionProfiles.map {
if (it.id == selectedId) it.copy(host = host) else it
state.connectionProfiles.map { profile ->
if (profile.id == selectedId) {
profile.copy(
host = "",
port = 0,
baseUrl = normalized,
)
} else {
profile
}
}
state.copy(connectionProfiles = profiles)
}
}
// ── Port ─────────────────────────────────────────────────────────────
/** Compatibility accessors for legacy pairing payloads. */
fun getHost(): String = serverStore.getLatestState().resolvedHost
fun getPort(): Int = serverStore.getLatestState().resolvedPort
fun setHost(host: String) {
val endpoint = endpoint()
val normalizedHost = host.trim().removePrefix("[").removeSuffix("]")
setBaseUrl(endpoint.baseUrl.newBuilder().host(normalizedHost).build().toString())
}
fun setPort(port: Int) {
val selectedId =
getSelectedProfileId() ?: run {
ensureDefaultSelected()
DEFAULT_PROFILE_ID
}
serverStore.update { state ->
val profiles =
state.connectionProfiles.map {
if (it.id == selectedId) it.copy(port = port) else it
}
state.copy(connectionProfiles = profiles)
}
require(port in 1..65535) { "Port is out of range" }
val endpoint = endpoint()
setBaseUrl(endpoint.baseUrl.newBuilder().port(port).build().toString())
}
// ── Auto-reconnect ───────────────────────────────────────────────────
@@ -466,18 +490,17 @@ object AuthManager {
serverStore.update { it.copy(themePreset = preset) }
}
/** Convenience: build the base URL from current host + port.
* NOTE: Uses plain http:// — intended for trusted local network only.
* Exposing the host to a hostile LAN risks token interception. */
fun baseUrl(): String = "http://${getHost()}:${getPort()}/"
/** Canonical build-allowed Retrofit base URL. */
fun baseUrl(): String = endpointForBuild().baseUrl.toString()
/** Convenience: build the WebSocket URL with token query param.
* NOTE: Token in query string — trusted local network only. */
/** Canonical WebSocket URL with an encoded token or short-lived ticket. */
fun wsUrl(): String {
val raw = serverStore.getLatestState().wsAuthParam
val authParam = if (raw.isNullOrBlank()) "token" else raw
val credential = getToken().orEmpty()
return "ws://${getHost()}:${getPort()}/api/ws?$authParam=$credential"
val authParam = if (raw.isBlank()) "token" else raw
return endpointForBuild().webSocketUrl(
authParameter = authParam,
credential = getToken().orEmpty(),
)
}
// ── Bottom nav bar items ──────────────────────────────────────────────