[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

@@ -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()
}
}
}
@@ -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 ──────────────────────────────────────────────
@@ -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)
}
}
@@ -265,7 +265,7 @@ object HermesWsClient {
val request =
Request
.Builder()
.url("http://${AuthManager.getHost()}:${AuthManager.getPort()}/api/auth/ws-ticket")
.url(AuthManager.endpointForBuild().resolve("api/auth/ws-ticket").toString())
.post("{}".toRequestBody())
.build()