* [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
@@ -2,7 +2,6 @@ package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import okhttp3.CertificatePinner
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
@@ -41,10 +40,10 @@ object ApiClient {
|
||||
|
||||
/** Creates a standalone, temporary [HermesApiService] without modifying the global instance. */
|
||||
fun createTempService(
|
||||
host: String,
|
||||
port: Int,
|
||||
baseUrl: String,
|
||||
token: String,
|
||||
): HermesApiService {
|
||||
val endpoint = ServerEndpoint.parseForBuild(baseUrl)
|
||||
val tempAuthInterceptor =
|
||||
Interceptor { chain ->
|
||||
val request =
|
||||
@@ -70,7 +69,7 @@ object ApiClient {
|
||||
val tempRetrofit =
|
||||
Retrofit
|
||||
.Builder()
|
||||
.baseUrl("http://$host:$port/")
|
||||
.baseUrl(endpoint.baseUrl)
|
||||
.client(tempOkHttp)
|
||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
@@ -85,7 +84,7 @@ object ApiClient {
|
||||
HttpLoggingInterceptor().apply {
|
||||
level =
|
||||
if (BuildConfig.DEBUG) {
|
||||
HttpLoggingInterceptor.Level.BODY
|
||||
HttpLoggingInterceptor.Level.BASIC
|
||||
} else {
|
||||
HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
@@ -110,8 +109,6 @@ object ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
val certificatePinner = CertificatePinner.Builder().build()
|
||||
|
||||
val okHttp =
|
||||
OkHttpProvider
|
||||
.base
|
||||
@@ -120,13 +117,12 @@ object ApiClient {
|
||||
.addInterceptor(ProfileScopeInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.certificatePinner(certificatePinner)
|
||||
.build()
|
||||
|
||||
val rf =
|
||||
Retrofit
|
||||
.Builder()
|
||||
.baseUrl(AuthManager.baseUrl())
|
||||
.baseUrl(AuthManager.endpointForBuild().baseUrl)
|
||||
.client(okHttp)
|
||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@Serializable
|
||||
private data class PasswordLoginPayload(
|
||||
val provider: String,
|
||||
val username: String,
|
||||
val password: String,
|
||||
val next: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class WebSocketTicketPayload(
|
||||
val ticket: String,
|
||||
)
|
||||
|
||||
/** JSON serialization for authentication requests and responses. */
|
||||
object AuthPayloads {
|
||||
fun passwordLogin(
|
||||
username: String,
|
||||
password: String,
|
||||
): String =
|
||||
OkHttpProvider.json.encodeToString(
|
||||
PasswordLoginPayload(
|
||||
provider = "basic",
|
||||
username = username,
|
||||
password = password,
|
||||
next = "",
|
||||
),
|
||||
)
|
||||
|
||||
fun webSocketTicket(body: String): String? =
|
||||
runCatching {
|
||||
OkHttpProvider.json.decodeFromString<WebSocketTicketPayload>(body)
|
||||
.ticket
|
||||
.takeIf { it.isNotBlank() }
|
||||
}.getOrNull()
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Cookie
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
|
||||
/**
|
||||
* Coordinator for the issue #470 cookie stack.
|
||||
@@ -22,9 +21,9 @@ import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
* - [getSessionCookie] / [setSessionCookie] — backward-compatible accessors
|
||||
* used by [com.m57.hermescontrol.data.local.AuthManager] and the login flow.
|
||||
*
|
||||
* The session cookie is still exposed as a single string because the WS client
|
||||
* and a few REST paths reference it directly; internally it is just the
|
||||
* `hermes_session_at` cookie from the active scope.
|
||||
* A single-value accessor remains for compatibility with migrated sessions;
|
||||
* internally it resolves the dashboard access cookie from the active scope,
|
||||
* including HTTPS prefix variants.
|
||||
*/
|
||||
object CookieManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
@@ -58,37 +57,36 @@ object CookieManager {
|
||||
runBlocking(scope.coroutineContext) { j.useStore(serverId) }
|
||||
}
|
||||
|
||||
/** Read the current `hermes_session_at` cookie value (or null). */
|
||||
/** Read the current dashboard access-cookie value (or null). */
|
||||
fun getSessionCookie(): String? = jar?.getSessionCookieValue()
|
||||
|
||||
/**
|
||||
* Set the `hermes_session_at` session cookie for the active scope.
|
||||
* [host] should be the dashboard host so the cookie matches REST requests.
|
||||
* Set the session cookie for the active scope and canonical endpoint.
|
||||
* HTTPS endpoints produce Secure cookies that cannot be sent over HTTP.
|
||||
*/
|
||||
fun setSessionCookie(
|
||||
rawValue: String?,
|
||||
host: String,
|
||||
endpoint: ServerEndpoint,
|
||||
) {
|
||||
val j = jar ?: return
|
||||
if (rawValue.isNullOrBlank()) {
|
||||
// Clearing: drop the session cookie (and any other cookies) for the
|
||||
// active scope — equivalent to the old AuthManager nulling the
|
||||
// session-cookie pref.
|
||||
j.clearActive()
|
||||
return
|
||||
}
|
||||
val cookie =
|
||||
val builder =
|
||||
Cookie
|
||||
.Builder()
|
||||
.name(SESSION_COOKIE_NAME)
|
||||
.value(rawValue)
|
||||
.expiresAt(System.currentTimeMillis() + 10L * 365 * 24 * 60 * 60 * 1000)
|
||||
.hostOnlyDomain(host)
|
||||
.path("/")
|
||||
.expiresAt(
|
||||
System.currentTimeMillis() +
|
||||
10L * 365 * 24 * 60 * 60 * 1000,
|
||||
)
|
||||
.hostOnlyDomain(endpoint.baseUrl.host)
|
||||
.path(endpoint.baseUrl.encodedPath)
|
||||
.httpOnly()
|
||||
.build()
|
||||
val url = "http://$host/".toHttpUrl()
|
||||
j.saveFromResponse(url, listOf(cookie))
|
||||
if (endpoint.baseUrl.isHttps) builder.secure()
|
||||
j.saveFromResponse(endpoint.baseUrl, listOf(builder.build()))
|
||||
}
|
||||
|
||||
/** Evict expired (non-session) cookies for the active scope. */
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.BuildConfig
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
|
||||
/** Policy applied when a complete dashboard base URL uses cleartext HTTP. */
|
||||
enum class CleartextPolicy {
|
||||
DENY,
|
||||
ALLOW_WITH_WARNING,
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical representation of a Hermes dashboard endpoint.
|
||||
*
|
||||
* The base URL always has an HTTP(S) scheme and a trailing slash. All REST,
|
||||
* authentication, cookie, and WebSocket URLs are derived from this value so a
|
||||
* reverse-proxy path prefix cannot be dropped by an individual caller.
|
||||
*/
|
||||
class ServerEndpoint private constructor(
|
||||
val baseUrl: HttpUrl,
|
||||
val securityWarning: String?,
|
||||
) {
|
||||
val isCleartext: Boolean
|
||||
get() = baseUrl.scheme == "http"
|
||||
|
||||
fun resolve(relativePath: String): HttpUrl {
|
||||
val path = relativePath.trim().trimStart('/')
|
||||
require(path.isNotEmpty()) { "Endpoint path must not be blank" }
|
||||
require(path.split('/').none { it == ".." }) {
|
||||
"Endpoint path must not escape the configured base path"
|
||||
}
|
||||
return baseUrl.newBuilder().addPathSegments(path).build()
|
||||
}
|
||||
|
||||
fun webSocketUrl(
|
||||
authParameter: String,
|
||||
credential: String,
|
||||
): String {
|
||||
require(authParameter == "token" || authParameter == "ticket") {
|
||||
"Unsupported WebSocket authentication parameter"
|
||||
}
|
||||
val socketHttpUrl =
|
||||
resolve("api/ws")
|
||||
.newBuilder()
|
||||
.addQueryParameter(authParameter, credential)
|
||||
.build()
|
||||
val socketScheme = if (baseUrl.isHttps) "wss" else "ws"
|
||||
return replaceScheme(socketHttpUrl.toString(), socketScheme)
|
||||
}
|
||||
|
||||
/** Return a request path relative to this endpoint's proxy prefix. */
|
||||
fun relativeRequestPath(requestUrl: HttpUrl): String {
|
||||
if (!sameOrigin(requestUrl)) return requestUrl.encodedPath
|
||||
val prefix = baseUrl.encodedPath
|
||||
if (!requestUrl.encodedPath.startsWith(prefix)) {
|
||||
return requestUrl.encodedPath
|
||||
}
|
||||
return "/" + requestUrl.encodedPath.removePrefix(prefix)
|
||||
}
|
||||
|
||||
private fun sameOrigin(other: HttpUrl): Boolean =
|
||||
baseUrl.scheme == other.scheme &&
|
||||
baseUrl.host == other.host &&
|
||||
baseUrl.port == other.port
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_BASE_URL = "https://127.0.0.1:9119/"
|
||||
const val CLEARTEXT_WARNING =
|
||||
"Cleartext HTTP exposes credentials and messages. " +
|
||||
"Use HTTPS unless this is a trusted development network."
|
||||
|
||||
fun parse(
|
||||
rawValue: String,
|
||||
cleartextPolicy: CleartextPolicy = CleartextPolicy.DENY,
|
||||
): ServerEndpoint {
|
||||
val raw = rawValue.trim()
|
||||
require(raw.isNotEmpty()) { "Base URL is required" }
|
||||
require(
|
||||
raw.startsWith("https://", ignoreCase = true) ||
|
||||
raw.startsWith("http://", ignoreCase = true),
|
||||
) { "Base URL must use https:// or http://" }
|
||||
|
||||
val parsed =
|
||||
raw.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("Malformed base URL")
|
||||
require(parsed.username.isEmpty() && parsed.password.isEmpty()) {
|
||||
"Base URL must not contain credentials"
|
||||
}
|
||||
require(parsed.encodedQuery == null) {
|
||||
"Base URL must not contain a query"
|
||||
}
|
||||
require(parsed.encodedFragment == null) {
|
||||
"Base URL must not contain a fragment"
|
||||
}
|
||||
|
||||
val normalized =
|
||||
if (parsed.encodedPath.endsWith('/')) {
|
||||
parsed
|
||||
} else {
|
||||
parsed.newBuilder().addPathSegment("").build()
|
||||
}
|
||||
|
||||
if (!normalized.isHttps && cleartextPolicy == CleartextPolicy.DENY) {
|
||||
throw IllegalArgumentException(
|
||||
"Cleartext HTTP is disabled in this build. Use HTTPS.",
|
||||
)
|
||||
}
|
||||
val warning = if (normalized.isHttps) null else CLEARTEXT_WARNING
|
||||
return ServerEndpoint(normalized, warning)
|
||||
}
|
||||
|
||||
fun parseForBuild(rawValue: String): ServerEndpoint =
|
||||
parse(
|
||||
rawValue,
|
||||
if (BuildConfig.ALLOW_CLEARTEXT) {
|
||||
CleartextPolicy.ALLOW_WITH_WARNING
|
||||
} else {
|
||||
CleartextPolicy.DENY
|
||||
},
|
||||
)
|
||||
|
||||
/** Preserve the pre-migration HTTP behavior for an existing install. */
|
||||
fun fromLegacy(
|
||||
host: String,
|
||||
port: Int,
|
||||
): ServerEndpoint {
|
||||
require(port in 1..65535) { "Legacy port is out of range" }
|
||||
val normalizedHost =
|
||||
host.trim().removePrefix("[").removeSuffix("]")
|
||||
val url =
|
||||
HttpUrl
|
||||
.Builder()
|
||||
.scheme("http")
|
||||
.host(normalizedHost)
|
||||
.port(port)
|
||||
.build()
|
||||
return parse(
|
||||
url.toString(),
|
||||
CleartextPolicy.ALLOW_WITH_WARNING,
|
||||
)
|
||||
}
|
||||
|
||||
fun redactWebSocketUrlForLog(rawUrl: String): String {
|
||||
val httpScheme =
|
||||
when {
|
||||
rawUrl.startsWith("wss://", ignoreCase = true) -> "https"
|
||||
rawUrl.startsWith("ws://", ignoreCase = true) -> "http"
|
||||
else -> return "<invalid-websocket-url>"
|
||||
}
|
||||
val socketScheme = if (httpScheme == "https") "wss" else "ws"
|
||||
val converted =
|
||||
replaceScheme(rawUrl, httpScheme).toHttpUrlOrNull()
|
||||
?: return "<invalid-websocket-url>"
|
||||
val redacted = converted.newBuilder().query(null).build().toString()
|
||||
return replaceScheme(redacted, socketScheme)
|
||||
}
|
||||
|
||||
private fun replaceScheme(
|
||||
url: String,
|
||||
scheme: String,
|
||||
): String = url.replaceBefore("://", scheme)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user