diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00b1c85da..d83514461 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
All notable user-facing changes are listed here. Detailed notes for older releases: [docs/releases/](docs/releases/).
## Unreleased
+- Added an experimental Cloudflare OAuth + API Worker deployment PoC with PKCE, multiple independent Cloudflare logins/accounts, encrypted access-token storage, deploy/update/delete, workers.dev smoke testing, and automatic Worker Pool registration.
## 1.11.0 - 2026-09-19
- Promoted the userspace `awg_warp` backend to the stable channel. Telegram traffic can use AmneziaWG/WARP inside the app without Android `VpnService`, root, or a system TUN interface.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 82f026b6e..35ae480b8 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -6,6 +6,21 @@ plugins {
val releaseVersionCode = 54
val releaseVersionName = "1.11.0"
+fun asBuildConfigString(value: String): String {
+ val escaped = value.replace("\\", "\\\\").replace("\"", "\\\"")
+ return "\"$escaped\""
+}
+
+val cloudflareOAuthClientId = providers.gradleProperty("TGWSPROXY_CF_OAUTH_CLIENT_ID")
+ .orElse(providers.environmentVariable("TGWSPROXY_CF_OAUTH_CLIENT_ID"))
+ .getOrElse("")
+val cloudflareOAuthScopes = providers.gradleProperty("TGWSPROXY_CF_OAUTH_SCOPES")
+ .orElse(providers.environmentVariable("TGWSPROXY_CF_OAUTH_SCOPES"))
+ .getOrElse("memberships.read workers-scripts.read workers-scripts.write")
+val cloudflareOAuthRedirectUri = "tgwsproxy://oauth/cloudflare"
+val generatedCloudflareWorkerAssets = layout.buildDirectory.dir("generated/cloudflare-worker-assets")
+
+
val buildNativeAndroid by tasks.registering(org.gradle.api.tasks.Exec::class) {
val script = rootProject.file("scripts/build-native-android.ps1")
val nativeDir = rootProject.file("native/tgwsproxy")
@@ -36,8 +51,16 @@ val generateAppIcons by tasks.registering(org.gradle.api.tasks.Exec::class) {
}
}
+val syncCloudflareWorkerAssets by tasks.registering(org.gradle.api.tasks.Copy::class) {
+ from(rootProject.file("scripts/cloudflare-worker")) {
+ include("worker.js", "chunk-relay-worker.js", "chunk-relay-status-worker.js")
+ into("cloudflare-worker")
+ }
+ into(generatedCloudflareWorkerAssets)
+}
+
tasks.named("preBuild") {
- dependsOn(buildNativeAndroid, generateAppIcons)
+ dependsOn(buildNativeAndroid, generateAppIcons, syncCloudflareWorkerAssets)
}
android {
@@ -51,6 +74,10 @@ android {
versionCode = releaseVersionCode
versionName = releaseVersionName
+ buildConfigField("String", "CLOUDFLARE_OAUTH_CLIENT_ID", asBuildConfigString(cloudflareOAuthClientId))
+ buildConfigField("String", "CLOUDFLARE_OAUTH_SCOPES", asBuildConfigString(cloudflareOAuthScopes))
+ buildConfigField("String", "CLOUDFLARE_OAUTH_REDIRECT_URI", asBuildConfigString(cloudflareOAuthRedirectUri))
+
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
useSupportLibrary = true
@@ -103,6 +130,7 @@ android {
}
buildFeatures {
compose = true
+ buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.5.8"
@@ -115,6 +143,7 @@ android {
sourceSets {
getByName("main") {
jniLibs.srcDir("src/main/jniLibs")
+ assets.srcDir(generatedCloudflareWorkerAssets)
}
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 0b4cfea72..1d42e6630 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -42,6 +42,22 @@
android:exported="false"
android:theme="@android:style/Theme.NoTitleBar" />
+
+
+
+
+
+
+
+
+
+ val payload = response.body?.string().orEmpty()
+ if (!response.isSuccessful) {
+ throw CloudflareApiException(
+ oauthError(payload, "OAuth token exchange failed"),
+ response.code,
+ )
+ }
+ val json = JSONObject(payload)
+ val token = json.optString("access_token").takeIf { it.isNotBlank() }
+ ?: throw CloudflareApiException("OAuth response does not contain access_token")
+ val expiresInSeconds = json.optLong("expires_in", -1L).takeIf { it > 0 }
+ CloudflareOAuthToken(
+ accessToken = token,
+ expiresAtEpochMs = expiresInSeconds?.let {
+ System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(it)
+ },
+ )
+ }
+ }
+
+ suspend fun listAccounts(accessToken: String): List =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(API_BASE + "/accounts?per_page=50")
+ .get()
+ .bearer(accessToken)
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ val payload = response.body?.string().orEmpty()
+ val result = requireCloudflareResult(response.code, response.isSuccessful, payload)
+ val accounts = result as? JSONArray
+ ?: throw CloudflareApiException("Cloudflare accounts response has unexpected shape")
+ buildList {
+ for (index in 0 until accounts.length()) {
+ val account = accounts.getJSONObject(index)
+ val id = account.optString("id")
+ val name = account.optString("name")
+ if (id.isNotBlank() && name.isNotBlank()) {
+ add(CloudflareAuthorizedAccount(id = id, name = name))
+ }
+ }
+ }
+ }
+ }
+
+ suspend fun revoke(accessToken: String, clientId: String) = withContext(Dispatchers.IO) {
+ val body = FormBody.Builder()
+ .add("token", accessToken)
+ .add("client_id", clientId)
+ .build()
+ val request = Request.Builder()
+ .url(REVOKE_URL)
+ .post(body)
+ .header("Accept", "application/json")
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ throw CloudflareApiException("OAuth token revocation failed", response.code)
+ }
+ }
+ }
+
+ private fun oauthError(payload: String, fallback: String): String {
+ return runCatching {
+ val json = JSONObject(payload)
+ json.optString("error_description")
+ .ifBlank { json.optString("error") }
+ .ifBlank { fallback }
+ }.getOrDefault(fallback)
+ }
+
+ private companion object {
+ const val AUTHORIZATION_URL = "https://dash.cloudflare.com/oauth2/auth"
+ const val TOKEN_URL = "https://dash.cloudflare.com/oauth2/token"
+ const val REVOKE_URL = "https://dash.cloudflare.com/oauth2/revoke"
+ }
+}
+
+data class CloudflareWorkerDeploymentResult(
+ val workerName: String,
+ val workersDevUrl: String,
+ val smokeStatusCode: Int,
+)
+
+class CloudflareWorkerDeploymentClient(
+ context: Context,
+ private val httpClient: OkHttpClient = defaultCloudflareHttpClient(),
+) {
+ private val appContext = context.applicationContext
+
+ suspend fun deploy(
+ accessToken: String,
+ accountId: String,
+ workerName: String,
+ ): CloudflareWorkerDeploymentResult = withContext(Dispatchers.IO) {
+ val normalizedName = CloudflareWorkerName.normalize(workerName)
+ if (!CloudflareWorkerName.isValid(normalizedName)) {
+ throw CloudflareApiException("Invalid Worker name")
+ }
+
+ uploadWorker(accessToken, accountId, normalizedName)
+ enableWorkersDev(accessToken, accountId, normalizedName)
+ val subdomain = getAccountWorkersSubdomain(accessToken, accountId)
+ val url = "https://$normalizedName.$subdomain.workers.dev"
+ val smokeStatus = smokeTest(url)
+
+ CloudflareWorkerDeploymentResult(
+ workerName = normalizedName,
+ workersDevUrl = url,
+ smokeStatusCode = smokeStatus,
+ )
+ }
+
+ suspend fun delete(
+ accessToken: String,
+ accountId: String,
+ workerName: String,
+ ) = withContext(Dispatchers.IO) {
+ val normalizedName = CloudflareWorkerName.normalize(workerName)
+ if (!CloudflareWorkerName.isValid(normalizedName)) {
+ throw CloudflareApiException("Invalid Worker name")
+ }
+ val request = Request.Builder()
+ .url(API_BASE + "/accounts/" + accountId + "/workers/scripts/" + normalizedName)
+ .delete()
+ .bearer(accessToken)
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ val payload = response.body?.string().orEmpty()
+ if (response.code == 404 || (response.isSuccessful && payload.isBlank())) {
+ return@use
+ }
+ requireCloudflareResult(response.code, response.isSuccessful, payload)
+ }
+ }
+
+ private fun uploadWorker(
+ accessToken: String,
+ accountId: String,
+ workerName: String,
+ ) {
+ val metadata = JSONObject()
+ .put("main_module", MAIN_MODULE)
+ .put("compatibility_date", COMPATIBILITY_DATE)
+ .put(
+ "bindings",
+ JSONArray().put(
+ JSONObject()
+ .put("type", "durable_object_namespace")
+ .put("name", "CHUNK_RELAY")
+ .put("class_name", "ChunkRelaySession"),
+ ),
+ )
+ .put(
+ "exports",
+ JSONObject().put(
+ "ChunkRelaySession",
+ JSONObject()
+ .put("type", "durable-object")
+ .put("storage", "sqlite"),
+ ),
+ )
+
+ val multipart = MultipartBody.Builder()
+ .setType(MultipartBody.FORM)
+ .addFormDataPart(
+ "metadata",
+ "metadata.json",
+ metadata.toString().toRequestBody(JSON_MEDIA_TYPE),
+ )
+ MODULES.forEach { moduleName ->
+ multipart.addFormDataPart(
+ moduleName,
+ moduleName,
+ readAsset(moduleName).toRequestBody(JAVASCRIPT_MODULE_MEDIA_TYPE),
+ )
+ }
+
+ val request = Request.Builder()
+ .url(API_BASE + "/accounts/" + accountId + "/workers/scripts/" + workerName)
+ .put(multipart.build())
+ .bearer(accessToken)
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ val payload = response.body?.string().orEmpty()
+ requireCloudflareResult(response.code, response.isSuccessful, payload)
+ }
+ }
+
+ private fun enableWorkersDev(
+ accessToken: String,
+ accountId: String,
+ workerName: String,
+ ) {
+ val body = JSONObject()
+ .put("enabled", true)
+ .put("previews_enabled", false)
+ .toString()
+ .toRequestBody(JSON_MEDIA_TYPE)
+ val request = Request.Builder()
+ .url(API_BASE + "/accounts/" + accountId + "/workers/scripts/" + workerName + "/subdomain")
+ .post(body)
+ .bearer(accessToken)
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ val payload = response.body?.string().orEmpty()
+ requireCloudflareResult(response.code, response.isSuccessful, payload)
+ }
+ }
+
+ private fun getAccountWorkersSubdomain(
+ accessToken: String,
+ accountId: String,
+ ): String {
+ val request = Request.Builder()
+ .url(API_BASE + "/accounts/" + accountId + "/workers/subdomain")
+ .get()
+ .bearer(accessToken)
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ val payload = response.body?.string().orEmpty()
+ val result = requireCloudflareResult(response.code, response.isSuccessful, payload)
+ val subdomain = (result as? JSONObject)?.optString("subdomain").orEmpty()
+ if (subdomain.isBlank()) {
+ throw CloudflareApiException("Cloudflare account does not have a workers.dev subdomain")
+ }
+ return subdomain
+ }
+ }
+
+ private fun smokeTest(url: String): Int {
+ val request = Request.Builder()
+ .url(url)
+ .get()
+ .header("Cache-Control", "no-cache")
+ .build()
+ httpClient.newCall(request).execute().use { response ->
+ if (response.code >= 500) {
+ throw CloudflareApiException(
+ "Worker smoke test returned HTTP " + response.code,
+ response.code,
+ )
+ }
+ return response.code
+ }
+ }
+
+ private fun readAsset(name: String): String {
+ return appContext.assets.open("cloudflare-worker/$name")
+ .bufferedReader()
+ .use { it.readText() }
+ }
+
+ private companion object {
+ const val MAIN_MODULE = "chunk-relay-status-worker.js"
+ const val COMPATIBILITY_DATE = "2026-09-12"
+ val MODULES = listOf(
+ "chunk-relay-status-worker.js",
+ "chunk-relay-worker.js",
+ "worker.js",
+ )
+ val JSON_MEDIA_TYPE = "application/json".toMediaType()
+ val JAVASCRIPT_MODULE_MEDIA_TYPE = "application/javascript+module".toMediaType()
+ }
+}
+
+private fun Request.Builder.bearer(accessToken: String): Request.Builder {
+ return header("Authorization", "Bearer $accessToken")
+ .header("Accept", "application/json")
+}
+
+private fun requireCloudflareResult(
+ statusCode: Int,
+ transportSuccess: Boolean,
+ payload: String,
+): Any? {
+ val json = runCatching { JSONObject(payload) }.getOrNull()
+ val success = json?.optBoolean("success", false) == true
+ if (!transportSuccess || !success) {
+ val errorMessage = json
+ ?.optJSONArray("errors")
+ ?.optJSONObject(0)
+ ?.optString("message")
+ ?.takeIf { it.isNotBlank() }
+ ?: "Cloudflare API request failed"
+ throw CloudflareApiException(errorMessage, statusCode)
+ }
+ return json?.opt("result")
+}
+
+private const val API_BASE = "https://api.cloudflare.com/client/v4"
+
+private fun defaultCloudflareHttpClient(): OkHttpClient {
+ return OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(30, TimeUnit.SECONDS)
+ .callTimeout(45, TimeUnit.SECONDS)
+ .build()
+}
diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthActivity.kt b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthActivity.kt
new file mode 100644
index 000000000..677cf267c
--- /dev/null
+++ b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthActivity.kt
@@ -0,0 +1,589 @@
+package com.amurcanov.tgwsproxy.cloudflare
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.lifecycleScope
+import com.amurcanov.tgwsproxy.R
+import com.amurcanov.tgwsproxy.WorkerDomain
+import com.amurcanov.tgwsproxy.worker.SharedPreferencesWorkerPoolPersistence
+import com.amurcanov.tgwsproxy.worker.WorkerEndpoint
+import com.amurcanov.tgwsproxy.worker.WorkerPoolRepository
+import kotlinx.coroutines.launch
+import java.util.UUID
+
+class CloudflareOAuthActivity : ComponentActivity() {
+ private lateinit var config: CloudflareOAuthConfig
+ private lateinit var sessionStore: CloudflareOAuthSessionStore
+ private lateinit var oauthClient: CloudflareOAuthClient
+ private lateinit var deploymentClient: CloudflareWorkerDeploymentClient
+
+ private var uiState by mutableStateOf(CloudflareProvisioningUiState())
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ config = CloudflareOAuthConfig.fromBuildConfig()
+ sessionStore = CloudflareOAuthSessionStore(this)
+ oauthClient = CloudflareOAuthClient()
+ deploymentClient = CloudflareWorkerDeploymentClient(this)
+ refreshState()
+
+ setContent {
+ val prefs = remember { getSharedPreferences("ProxyPrefs", Context.MODE_PRIVATE) }
+ val context = LocalContext.current
+ val systemDarkTheme = isSystemInDarkTheme()
+ val isDarkTheme = when (prefs.getString("theme_mode", "System")) {
+ "Light" -> false
+ "Dark" -> true
+ else -> systemDarkTheme
+ }
+ val colorScheme = when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ if (isDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+ isDarkTheme -> darkColorScheme()
+ else -> lightColorScheme()
+ }
+
+ MaterialTheme(colorScheme = colorScheme) {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = MaterialTheme.colorScheme.background,
+ ) {
+ CloudflareOAuthScreen(
+ configured = config.isConfigured,
+ state = uiState,
+ onBack = ::finish,
+ onAddLogin = ::startLogin,
+ onSelectAccount = ::selectAccount,
+ onRemoveLogin = ::removeLogin,
+ onDeploy = ::deployWorker,
+ onDelete = ::deleteWorker,
+ )
+ }
+ }
+ }
+
+ handleOAuthCallback(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleOAuthCallback(intent)
+ }
+
+ private fun startLogin() {
+ if (!config.isConfigured || uiState.busy) {
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_not_configured))
+ return
+ }
+ val pkce = CloudflarePkce.create()
+ val state = UUID.randomUUID().toString()
+ pendingPrefs().edit()
+ .putString(KEY_PENDING_STATE, state)
+ .putString(KEY_PENDING_VERIFIER, pkce.verifier)
+ .apply()
+
+ val authorizationUrl = oauthClient.buildAuthorizationUrl(config, pkce, state)
+ startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(authorizationUrl)))
+ }
+
+ private fun handleOAuthCallback(intent: Intent?) {
+ val callback = intent?.data ?: return
+ val expected = Uri.parse(config.redirectUri)
+ if (callback.scheme != expected.scheme ||
+ callback.host != expected.host ||
+ callback.path != expected.path
+ ) {
+ return
+ }
+
+ val pendingState = pendingPrefs().getString(KEY_PENDING_STATE, null)
+ val verifier = pendingPrefs().getString(KEY_PENDING_VERIFIER, null)
+ val returnedState = callback.getQueryParameter("state")
+ val error = callback.getQueryParameter("error")
+ val code = callback.getQueryParameter("code")
+
+ if (pendingState.isNullOrBlank() || verifier.isNullOrBlank() || returnedState != pendingState) {
+ clearPendingLogin()
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_state_mismatch))
+ return
+ }
+ clearPendingLogin()
+
+ if (!error.isNullOrBlank()) {
+ uiState = uiState.copy(
+ message = getString(
+ R.string.cf_oauth_authorization_failed,
+ callback.getQueryParameter("error_description") ?: error,
+ ),
+ )
+ return
+ }
+ if (code.isNullOrBlank()) {
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_missing_code))
+ return
+ }
+
+ lifecycleScope.launch {
+ runOperation {
+ val token = oauthClient.exchangeCode(config, code, verifier)
+ val accounts = oauthClient.listAccounts(token.accessToken)
+ if (accounts.isEmpty()) {
+ throw CloudflareApiException(getString(R.string.cf_oauth_no_accounts))
+ }
+ val session = CloudflareOAuthSession.create(
+ accessToken = token.accessToken,
+ expiresAtEpochMs = token.expiresAtEpochMs,
+ accounts = accounts,
+ )
+ sessionStore.upsertSession(session)
+ sessionStore.saveActiveAccount(
+ ActiveCloudflareAccountRef(session.id, accounts.first().id),
+ )
+ refreshState(getString(R.string.cf_oauth_login_added, accounts.size))
+ }
+ }
+ }
+
+ private fun selectAccount(ref: ActiveCloudflareAccountRef) {
+ sessionStore.saveActiveAccount(ref)
+ refreshState()
+ }
+
+ private fun removeLogin(sessionId: String) {
+ val session = sessionStore.loadSessions().firstOrNull { it.id == sessionId } ?: return
+ lifecycleScope.launch {
+ uiState = uiState.copy(busy = true, message = null)
+ runCatching { oauthClient.revoke(session.accessToken, config.clientId) }
+ sessionStore.removeSession(sessionId)
+ refreshState(getString(R.string.cf_oauth_login_removed))
+ }
+ }
+
+ private fun deployWorker(rawWorkerName: String) {
+ val selection = selectedSessionAndAccount() ?: return
+ val workerName = CloudflareWorkerName.normalize(rawWorkerName)
+ if (!CloudflareWorkerName.isValid(workerName)) {
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_worker_name_invalid))
+ return
+ }
+ val (session, account) = selection
+ if (session.isExpired()) {
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_session_expired))
+ return
+ }
+
+ lifecycleScope.launch {
+ runOperation {
+ val result = deploymentClient.deploy(
+ accessToken = session.accessToken,
+ accountId = account.id,
+ workerName = workerName,
+ )
+ addWorkerToPool(result)
+ refreshState(
+ getString(
+ R.string.cf_oauth_deploy_success,
+ result.workersDevUrl,
+ result.smokeStatusCode,
+ ),
+ )
+ }
+ }
+ }
+
+ private fun deleteWorker(rawWorkerName: String) {
+ val selection = selectedSessionAndAccount() ?: return
+ val workerName = CloudflareWorkerName.normalize(rawWorkerName)
+ if (!CloudflareWorkerName.isValid(workerName)) {
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_worker_name_invalid))
+ return
+ }
+ val (session, account) = selection
+ if (session.isExpired()) {
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_session_expired))
+ return
+ }
+
+ lifecycleScope.launch {
+ runOperation {
+ deploymentClient.delete(
+ accessToken = session.accessToken,
+ accountId = account.id,
+ workerName = workerName,
+ )
+ refreshState(getString(R.string.cf_oauth_delete_success, workerName))
+ }
+ }
+ }
+
+ private fun selectedSessionAndAccount(): Pair? {
+ val active = sessionStore.loadActiveAccount()
+ val sessions = sessionStore.loadSessions()
+ val session = sessions.firstOrNull { it.id == active?.sessionId }
+ val account = session?.accounts?.firstOrNull { it.id == active?.accountId }
+ if (session == null || account == null) {
+ uiState = uiState.copy(message = getString(R.string.cf_oauth_select_account_first))
+ return null
+ }
+ return session to account
+ }
+
+ private suspend fun runOperation(block: suspend () -> Unit) {
+ uiState = uiState.copy(busy = true, message = null)
+ try {
+ block()
+ } catch (error: Exception) {
+ uiState = uiState.copy(
+ busy = false,
+ message = getString(
+ R.string.cf_oauth_operation_failed,
+ error.message ?: error.javaClass.simpleName,
+ ),
+ )
+ }
+ }
+
+ private fun refreshState(message: String? = uiState.message) {
+ val sessions = sessionStore.loadSessions()
+ val savedActive = sessionStore.loadActiveAccount()
+ val active = savedActive?.takeIf { ref ->
+ sessions.any { session ->
+ session.id == ref.sessionId && session.accounts.any { it.id == ref.accountId }
+ }
+ } ?: sessions.firstNotNullOfOrNull { session ->
+ session.accounts.firstOrNull()?.let { account ->
+ ActiveCloudflareAccountRef(session.id, account.id)
+ }
+ }
+ if (active != savedActive) {
+ sessionStore.saveActiveAccount(active)
+ }
+ uiState = CloudflareProvisioningUiState(
+ sessions = sessions,
+ activeAccount = active,
+ busy = false,
+ message = message,
+ )
+ }
+
+ private fun addWorkerToPool(result: CloudflareWorkerDeploymentResult) {
+ val prefs = getSharedPreferences("ProxyPrefs", Context.MODE_PRIVATE)
+ val repository = WorkerPoolRepository(SharedPreferencesWorkerPoolPersistence(prefs))
+ val normalizedDomain = WorkerDomain.normalize(result.workersDevUrl)
+ val existing = repository.getWorkers().firstOrNull {
+ WorkerDomain.normalize(it.url).equals(normalizedDomain, ignoreCase = true)
+ }
+ val worker = if (existing == null) {
+ repository.addWorker(
+ WorkerEndpoint.create(
+ name = getString(R.string.cf_oauth_worker_pool_name, result.workerName),
+ url = result.workersDevUrl,
+ enabled = true,
+ ),
+ ).getOrThrow()
+ } else if (!existing.enabled) {
+ repository.setWorkerEnabled(existing.id, true).getOrThrow()
+ } else {
+ existing
+ }
+ repository.setPoolEnabled(true)
+ repository.selectWorker(worker.id).getOrThrow()
+ }
+
+ private fun pendingPrefs() = getSharedPreferences(PENDING_PREFS_NAME, Context.MODE_PRIVATE)
+
+ private fun clearPendingLogin() {
+ pendingPrefs().edit()
+ .remove(KEY_PENDING_STATE)
+ .remove(KEY_PENDING_VERIFIER)
+ .apply()
+ }
+
+ private companion object {
+ const val PENDING_PREFS_NAME = "CloudflareOAuthPending"
+ const val KEY_PENDING_STATE = "state"
+ const val KEY_PENDING_VERIFIER = "verifier"
+ }
+}
+
+private data class CloudflareProvisioningUiState(
+ val sessions: List = emptyList(),
+ val activeAccount: ActiveCloudflareAccountRef? = null,
+ val busy: Boolean = false,
+ val message: String? = null,
+)
+
+@Composable
+private fun CloudflareOAuthScreen(
+ configured: Boolean,
+ state: CloudflareProvisioningUiState,
+ onBack: () -> Unit,
+ onAddLogin: () -> Unit,
+ onSelectAccount: (ActiveCloudflareAccountRef) -> Unit,
+ onRemoveLogin: (String) -> Unit,
+ onDeploy: (String) -> Unit,
+ onDelete: (String) -> Unit,
+) {
+ var workerName by rememberSaveable { mutableStateOf("tgwsproxy") }
+ val normalizedWorkerName = remember(workerName) { CloudflareWorkerName.normalize(workerName) }
+ val hasActiveAccount = state.activeAccount != null
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ TextButton(onClick = onBack) {
+ Text(stringResource(R.string.action_back))
+ }
+ Text(
+ text = stringResource(R.string.cf_oauth_title),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ }
+ }
+
+ if (!configured) {
+ item {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ ),
+ ) {
+ Text(
+ text = stringResource(R.string.cf_oauth_not_configured),
+ modifier = Modifier.padding(12.dp),
+ )
+ }
+ }
+ }
+
+ item {
+ Text(
+ text = stringResource(R.string.cf_oauth_accounts_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(R.string.cf_oauth_accounts_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Button(
+ onClick = onAddLogin,
+ enabled = configured && !state.busy,
+ ) {
+ Text(stringResource(R.string.cf_oauth_add_login))
+ }
+ }
+
+ if (state.sessions.isEmpty()) {
+ item {
+ Text(
+ text = stringResource(R.string.cf_oauth_no_logins),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ items(state.sessions, key = { it.id }) { session ->
+ CloudflareSessionCard(
+ session = session,
+ activeAccount = state.activeAccount,
+ busy = state.busy,
+ onSelectAccount = onSelectAccount,
+ onRemoveLogin = { onRemoveLogin(session.id) },
+ )
+ }
+
+ item {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(14.dp)) {
+ Text(
+ text = stringResource(R.string.cf_oauth_deploy_title),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = stringResource(R.string.cf_oauth_deploy_hint),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ OutlinedTextField(
+ value = workerName,
+ onValueChange = { workerName = it },
+ label = { Text(stringResource(R.string.cf_oauth_worker_name)) },
+ supportingText = {
+ Text(
+ stringResource(
+ R.string.cf_oauth_worker_name_normalized,
+ normalizedWorkerName.ifBlank { "-" },
+ ),
+ )
+ },
+ enabled = !state.busy,
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(8.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Button(
+ onClick = { onDeploy(normalizedWorkerName) },
+ enabled = configured &&
+ hasActiveAccount &&
+ normalizedWorkerName.isNotBlank() &&
+ !state.busy,
+ ) {
+ Text(stringResource(R.string.cf_oauth_deploy_or_update))
+ }
+ OutlinedButton(
+ onClick = { onDelete(normalizedWorkerName) },
+ enabled = configured &&
+ hasActiveAccount &&
+ normalizedWorkerName.isNotBlank() &&
+ !state.busy,
+ ) {
+ Text(stringResource(R.string.cf_oauth_delete_worker))
+ }
+ }
+ }
+ }
+ }
+
+ if (state.busy) {
+ item {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ CircularProgressIndicator()
+ Text(stringResource(R.string.cf_oauth_operation_running))
+ }
+ }
+ }
+
+ state.message?.let { message ->
+ item {
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ }
+
+ item {
+ Text(
+ text = stringResource(R.string.cf_oauth_security_note),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+}
+
+@Composable
+private fun CloudflareSessionCard(
+ session: CloudflareOAuthSession,
+ activeAccount: ActiveCloudflareAccountRef?,
+ busy: Boolean,
+ onSelectAccount: (ActiveCloudflareAccountRef) -> Unit,
+ onRemoveLogin: () -> Unit,
+) {
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(modifier = Modifier.padding(14.dp)) {
+ Text(
+ text = session.label,
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (session.isExpired()) {
+ Text(
+ text = stringResource(R.string.cf_oauth_session_expired),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ session.accounts.forEach { account ->
+ val ref = ActiveCloudflareAccountRef(session.id, account.id)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = activeAccount == ref,
+ enabled = !busy && !session.isExpired(),
+ onClick = { onSelectAccount(ref) },
+ )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(account.name)
+ Text(
+ account.id,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ TextButton(
+ onClick = onRemoveLogin,
+ enabled = !busy,
+ ) {
+ Text(stringResource(R.string.cf_oauth_remove_login))
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthModels.kt b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthModels.kt
new file mode 100644
index 000000000..4d2d4d605
--- /dev/null
+++ b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthModels.kt
@@ -0,0 +1,132 @@
+package com.amurcanov.tgwsproxy.cloudflare
+
+import com.amurcanov.tgwsproxy.BuildConfig
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.util.Base64
+import java.util.UUID
+
+data class CloudflareOAuthConfig(
+ val clientId: String,
+ val redirectUri: String,
+ val scopes: List,
+) {
+ val isConfigured: Boolean
+ get() = clientId.isNotBlank() && redirectUri.isNotBlank() && scopes.isNotEmpty()
+
+ companion object {
+ fun fromBuildConfig(): CloudflareOAuthConfig {
+ return CloudflareOAuthConfig(
+ clientId = BuildConfig.CLOUDFLARE_OAUTH_CLIENT_ID.trim(),
+ redirectUri = BuildConfig.CLOUDFLARE_OAUTH_REDIRECT_URI.trim(),
+ scopes = BuildConfig.CLOUDFLARE_OAUTH_SCOPES
+ .split(Regex("\\s+"))
+ .map(String::trim)
+ .filter(String::isNotBlank),
+ )
+ }
+ }
+}
+
+data class CloudflarePkcePair(
+ val verifier: String,
+ val challenge: String,
+)
+
+object CloudflarePkce {
+ private const val VERIFIER_BYTES = 32
+
+ fun create(random: SecureRandom = SecureRandom()): CloudflarePkcePair {
+ val bytes = ByteArray(VERIFIER_BYTES)
+ random.nextBytes(bytes)
+ val verifier = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
+ return CloudflarePkcePair(
+ verifier = verifier,
+ challenge = challenge(verifier),
+ )
+ }
+
+ internal fun challenge(verifier: String): String {
+ val digest = MessageDigest.getInstance("SHA-256")
+ .digest(verifier.toByteArray(Charsets.US_ASCII))
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
+ }
+}
+
+data class CloudflareAuthorizedAccount(
+ val id: String,
+ val name: String,
+)
+
+data class CloudflareOAuthToken(
+ val accessToken: String,
+ val expiresAtEpochMs: Long?,
+)
+
+data class CloudflareOAuthSession(
+ val id: String,
+ val label: String,
+ val accessToken: String,
+ val expiresAtEpochMs: Long?,
+ val accounts: List,
+ val createdAtEpochMs: Long,
+) {
+ fun isExpired(nowEpochMs: Long = System.currentTimeMillis()): Boolean {
+ val expiresAt = expiresAtEpochMs ?: return false
+ return nowEpochMs >= expiresAt - EXPIRY_SKEW_MS
+ }
+
+ companion object {
+ private const val EXPIRY_SKEW_MS = 30_000L
+
+ fun create(
+ accessToken: String,
+ expiresAtEpochMs: Long?,
+ accounts: List,
+ nowEpochMs: Long = System.currentTimeMillis(),
+ ): CloudflareOAuthSession {
+ val label = when {
+ accounts.isEmpty() -> "Cloudflare"
+ accounts.size == 1 -> accounts.first().name
+ else -> accounts.take(2).joinToString(" / ") { it.name }
+ }
+ return CloudflareOAuthSession(
+ id = UUID.randomUUID().toString(),
+ label = label,
+ accessToken = accessToken,
+ expiresAtEpochMs = expiresAtEpochMs,
+ accounts = accounts,
+ createdAtEpochMs = nowEpochMs,
+ )
+ }
+ }
+}
+
+data class ActiveCloudflareAccountRef(
+ val sessionId: String,
+ val accountId: String,
+)
+
+object CloudflareWorkerName {
+ private val INVALID_CHARS = Regex("[^a-z0-9-]+")
+ private val REPEATED_DASH = Regex("-+")
+
+ fun normalize(raw: String): String {
+ return raw.trim()
+ .lowercase()
+ .replace(INVALID_CHARS, "-")
+ .replace(REPEATED_DASH, "-")
+ .trim('-')
+ .take(MAX_LENGTH)
+ .trimEnd('-')
+ }
+
+ fun isValid(raw: String): Boolean {
+ val normalized = normalize(raw)
+ return normalized.isNotBlank() &&
+ normalized.length <= MAX_LENGTH &&
+ normalized == raw.trim().lowercase()
+ }
+
+ private const val MAX_LENGTH = 63
+}
diff --git a/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthSessionStore.kt b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthSessionStore.kt
new file mode 100644
index 000000000..3fd6c02e8
--- /dev/null
+++ b/app/src/main/java/com/amurcanov/tgwsproxy/cloudflare/CloudflareOAuthSessionStore.kt
@@ -0,0 +1,185 @@
+package com.amurcanov.tgwsproxy.cloudflare
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import org.json.JSONArray
+import org.json.JSONObject
+import java.security.KeyStore
+import java.util.Base64
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+class CloudflareOAuthSessionStore(
+ context: Context,
+) {
+ private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ private val cipher = CloudflareTokenCipher()
+
+ @Synchronized
+ fun loadSessions(): List {
+ val raw = prefs.getString(KEY_SESSIONS, null) ?: return emptyList()
+ return runCatching {
+ val array = JSONArray(raw)
+ buildList {
+ for (index in 0 until array.length()) {
+ decodeSession(array.getJSONObject(index))?.let(::add)
+ }
+ }
+ }.getOrDefault(emptyList())
+ }
+
+ @Synchronized
+ fun upsertSession(session: CloudflareOAuthSession) {
+ val sessions = loadSessions().toMutableList()
+ val existingIndex = sessions.indexOfFirst { it.id == session.id }
+ if (existingIndex >= 0) {
+ sessions[existingIndex] = session
+ } else {
+ sessions += session
+ }
+ saveSessions(sessions)
+ }
+
+ @Synchronized
+ fun removeSession(sessionId: String) {
+ val sessions = loadSessions().filterNot { it.id == sessionId }
+ saveSessions(sessions)
+ if (loadActiveAccount()?.sessionId == sessionId) {
+ val fallback = sessions.firstNotNullOfOrNull { session ->
+ session.accounts.firstOrNull()?.let { account ->
+ ActiveCloudflareAccountRef(session.id, account.id)
+ }
+ }
+ saveActiveAccount(fallback)
+ }
+ }
+
+ fun loadActiveAccount(): ActiveCloudflareAccountRef? {
+ val sessionId = prefs.getString(KEY_ACTIVE_SESSION_ID, null)?.takeIf { it.isNotBlank() }
+ ?: return null
+ val accountId = prefs.getString(KEY_ACTIVE_ACCOUNT_ID, null)?.takeIf { it.isNotBlank() }
+ ?: return null
+ return ActiveCloudflareAccountRef(sessionId, accountId)
+ }
+
+ fun saveActiveAccount(ref: ActiveCloudflareAccountRef?) {
+ prefs.edit()
+ .putString(KEY_ACTIVE_SESSION_ID, ref?.sessionId)
+ .putString(KEY_ACTIVE_ACCOUNT_ID, ref?.accountId)
+ .apply()
+ }
+
+ private fun saveSessions(sessions: List) {
+ val array = JSONArray()
+ sessions.forEach { session -> array.put(encodeSession(session)) }
+ prefs.edit().putString(KEY_SESSIONS, array.toString()).apply()
+ }
+
+ private fun encodeSession(session: CloudflareOAuthSession): JSONObject {
+ val accounts = JSONArray()
+ session.accounts.forEach { account ->
+ accounts.put(
+ JSONObject()
+ .put("id", account.id)
+ .put("name", account.name),
+ )
+ }
+ return JSONObject()
+ .put("id", session.id)
+ .put("label", session.label)
+ .put("accessToken", cipher.encrypt(session.accessToken))
+ .put("expiresAtEpochMs", session.expiresAtEpochMs ?: JSONObject.NULL)
+ .put("createdAtEpochMs", session.createdAtEpochMs)
+ .put("accounts", accounts)
+ }
+
+ private fun decodeSession(json: JSONObject): CloudflareOAuthSession? {
+ return runCatching {
+ val accountsJson = json.getJSONArray("accounts")
+ val accounts = buildList {
+ for (index in 0 until accountsJson.length()) {
+ val account = accountsJson.getJSONObject(index)
+ add(
+ CloudflareAuthorizedAccount(
+ id = account.getString("id"),
+ name = account.getString("name"),
+ ),
+ )
+ }
+ }
+ CloudflareOAuthSession(
+ id = json.getString("id"),
+ label = json.optString("label").ifBlank { "Cloudflare" },
+ accessToken = cipher.decrypt(json.getString("accessToken")),
+ expiresAtEpochMs = if (json.isNull("expiresAtEpochMs")) {
+ null
+ } else {
+ json.getLong("expiresAtEpochMs")
+ },
+ accounts = accounts,
+ createdAtEpochMs = json.optLong("createdAtEpochMs", 0L),
+ )
+ }.getOrNull()
+ }
+
+ private fun encode(value: ByteArray): String {
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(value)
+ }
+
+ private fun decode(value: String): ByteArray {
+ return Base64.getUrlDecoder().decode(value)
+ }
+
+ private inner class CloudflareTokenCipher {
+ fun encrypt(plainText: String): String {
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
+ val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8))
+ return encode(cipher.iv) + "." + encode(encrypted)
+ }
+
+ fun decrypt(value: String): String {
+ val separator = value.indexOf('.')
+ require(separator > 0 && separator < value.lastIndex)
+ val iv = decode(value.substring(0, separator))
+ val encrypted = decode(value.substring(separator + 1))
+ val cipher = Cipher.getInstance(TRANSFORMATION)
+ cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_BITS, iv))
+ return cipher.doFinal(encrypted).toString(Charsets.UTF_8)
+ }
+
+ private fun getOrCreateKey(): SecretKey {
+ val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
+ val existing = keyStore.getKey(KEY_ALIAS, null) as? SecretKey
+ if (existing != null) {
+ return existing
+ }
+ return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEY_STORE).run {
+ init(
+ KeyGenParameterSpec.Builder(
+ KEY_ALIAS,
+ KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+ )
+ .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+ .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+ .build(),
+ )
+ generateKey()
+ }
+ }
+ }
+
+ private companion object {
+ const val PREFS_NAME = "CloudflareOAuthSessions"
+ const val KEY_SESSIONS = "sessions_v1"
+ const val KEY_ACTIVE_SESSION_ID = "active_session_id_v1"
+ const val KEY_ACTIVE_ACCOUNT_ID = "active_account_id_v1"
+ const val ANDROID_KEY_STORE = "AndroidKeyStore"
+ const val KEY_ALIAS = "tgwsproxy_cloudflare_oauth_access_token_v1"
+ const val TRANSFORMATION = "AES/GCM/NoPadding"
+ const val GCM_TAG_BITS = 128
+ }
+}
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index c94f8aec1..e29e36b26 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -945,4 +945,35 @@
Ошибка MTProto runtime
Локальный listener доступен, но outbound-маршрут не настроен. Сохраните SOCKS5/WS как резервный вариант.
Укажите публичный домен.
+ Аккаунты Cloudflare
+ Войдите в Cloudflare и разверните встроенный chunk-relay Worker без Wrangler.
+ Управлять аккаунтами и Workers
+ Cloudflare OAuth / Worker PoC
+ Входы Cloudflare
+ Каждый вход хранится отдельно. Выберите аккаунт, в который будет развёрнут Worker.
+ Добавить вход Cloudflare
+ Входов Cloudflare пока нет.
+ Удалить вход
+ В этой сборке не задан OAuth client ID.
+ OAuth callback отклонён: state не совпадает с ожидаемым значением входа.
+ Ошибка авторизации Cloudflare: %1$s
+ Cloudflare не вернул authorization code.
+ После авторизации не найдено доступных аккаунтов Cloudflare.
+ Вход Cloudflare добавлен. Доступно аккаунтов: %1$d.
+ Вход Cloudflare удалён.
+ OAuth-сессия истекла. Удалите её и войдите снова.
+ Сначала выберите аккаунт Cloudflare.
+ Имя Worker должно содержать только строчные латинские буквы, цифры и дефисы.
+ Развернуть тестовый Worker
+ Если Worker с таким именем уже существует, выполняется обновление. Успешный workers.dev endpoint автоматически добавляется в Worker Pool.
+ Имя Worker
+ Имя deployment: %1$s
+ Развернуть / обновить
+ Удалить Worker
+ Выполняется операция Cloudflare…
+ Ошибка операции Cloudflare: %1$s
+ Worker развёрнут: %1$s (smoke HTTP %2$d). Добавлен в Worker Pool.
+ Worker %1$s удалён.
+ PoC хранит только OAuth access tokens, зашифрованные Android Keystore. Refresh tokens и client secrets не сохраняются.
+ Cloudflare / %1$s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2368b5bc6..26477f330 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -945,4 +945,35 @@
MTProto port must be between 1 and 65535.
MTProto secret must be exactly 32 hex characters (16 bytes).
Use a valid public domain.
+ Cloudflare accounts
+ Sign in with Cloudflare and deploy the built-in chunk-relay Worker without Wrangler.
+ Manage accounts and Workers
+ Cloudflare OAuth / Worker PoC
+ Cloudflare logins
+ Each login is stored separately. Select the account that will receive the Worker deployment.
+ Add Cloudflare login
+ No Cloudflare logins yet.
+ Remove login
+ OAuth client ID is not configured for this build.
+ OAuth callback was rejected because its state does not match the pending login.
+ Cloudflare authorization failed: %1$s
+ Cloudflare did not return an authorization code.
+ The authorization returned no accessible Cloudflare accounts.
+ Cloudflare login added. Accessible accounts: %1$d.
+ Cloudflare login removed.
+ This OAuth session has expired. Remove it and sign in again.
+ Select a Cloudflare account first.
+ Worker name must contain only lowercase letters, digits and hyphens.
+ Deploy test Worker
+ Deploy updates the same Worker when the name already exists. A successful workers.dev endpoint is added to Worker Pool automatically.
+ Worker name
+ Deployment name: %1$s
+ Deploy / update
+ Delete Worker
+ Cloudflare operation in progress…
+ Cloudflare operation failed: %1$s
+ Worker deployed: %1$s (smoke HTTP %2$d). Added to Worker Pool.
+ Worker %1$s deleted.
+ PoC stores only OAuth access tokens, encrypted with Android Keystore. Refresh tokens and client secrets are not stored.
+ Cloudflare / %1$s
diff --git a/app/src/test/java/com/amurcanov/tgwsproxy/cloudflare/CloudflarePkceTest.kt b/app/src/test/java/com/amurcanov/tgwsproxy/cloudflare/CloudflarePkceTest.kt
new file mode 100644
index 000000000..de1722af7
--- /dev/null
+++ b/app/src/test/java/com/amurcanov/tgwsproxy/cloudflare/CloudflarePkceTest.kt
@@ -0,0 +1,36 @@
+package com.amurcanov.tgwsproxy.cloudflare
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class CloudflarePkceTest {
+ @Test
+ fun rfc7636ChallengeMatchesKnownVector() {
+ val verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
+
+ assertEquals(
+ "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+ CloudflarePkce.challenge(verifier),
+ )
+ }
+
+ @Test
+ fun generatedVerifierIsUrlSafeAndUnpadded() {
+ val pair = CloudflarePkce.create()
+
+ assertTrue(pair.verifier.length in 43..128)
+ assertTrue(pair.verifier.matches(Regex("[A-Za-z0-9_-]+")))
+ assertTrue(pair.challenge.matches(Regex("[A-Za-z0-9_-]+")))
+ assertFalse(pair.verifier.contains('='))
+ assertFalse(pair.challenge.contains('='))
+ }
+
+ @Test
+ fun workerNameNormalizationIsDeterministic() {
+ assertEquals("my-worker-1", CloudflareWorkerName.normalize(" My Worker #1 "))
+ assertTrue(CloudflareWorkerName.isValid("my-worker-1"))
+ assertFalse(CloudflareWorkerName.isValid("My Worker #1"))
+ }
+}
diff --git a/docs/cloudflare-oauth-poc.md b/docs/cloudflare-oauth-poc.md
new file mode 100644
index 000000000..81e3c7e38
--- /dev/null
+++ b/docs/cloudflare-oauth-poc.md
@@ -0,0 +1,74 @@
+# Cloudflare OAuth + Worker deployment PoC
+
+Актуальность проверки: 2026-09-19. Документ относится к Issue #76 и не заменяет production security review.
+
+## Feasibility
+
+Cloudflare поддерживает OAuth 2.0 Authorization Code для third-party clients. Для browser/mobile/desktop/CLI public client требуется PKCE S256 и token_endpoint_auth_method=none; client secret в Android APK не нужен.
+
+Новые OAuth clients создаются private. Для public visibility Cloudflare требует client name, logo, client URL, scopes и подтверждение владения доменом client URL через DNS. Перевод client в public необратим.
+
+OAuth endpoints: authorization https://dash.cloudflare.com/oauth2/auth, token https://dash.cloudflare.com/oauth2/token, revoke https://dash.cloudflare.com/oauth2/revoke.
+
+Cloudflare указывает, что OAuth scope names соответствуют API token permission names. Реальная проверка GET /client/v4/oauth/scopes 2026-09-19 показала рабочие IDs memberships.read, workers-scripts.read и workers-scripts.write; workers-platform.read/write в фактическом списке scopes отсутствуют.
+
+Для Worker deployment endpoint PUT /accounts/{account_id}/workers/scripts/{script_name} требует Workers Scripts Write. PoC отправляет multipart modules chunk-relay-status-worker.js, chunk-relay-worker.js и worker.js, binding CHUNK_RELAY -> ChunkRelaySession и declarative export ChunkRelaySession с storage=sqlite.
+
+Declarative exports используется вместо legacy migrations: текущий Cloudflare API автоматически provision-ит новый SQLite Durable Object namespace при первом deploy и сопоставляет его при последующих deploy того же class.
+
+После upload PoC включает workers.dev route, читает account subdomain через GET /accounts/{account_id}/workers/subdomain, строит https://..workers.dev и выполняет HTTP smoke-test.
+
+## Multi-account model
+
+Один OAuth consent может дать доступ к нескольким Cloudflare accounts. Повторная команда Add Cloudflare login создаёт независимую OAuth session, поэтому несколько Cloudflare identities могут сосуществовать.
+
+Storage model: OAuth session -> encrypted access token -> list of Cloudflare accounts. Активный account выбирается явно; deploy/update/delete используют token именно его session.
+
+PoC не запрашивает и не хранит refresh token. Access token сохраняется только в app-private SharedPreferences в AES/GCM ciphertext, а ключ создаётся в Android Keystore. После expiry требуется повторный вход.
+
+## OAuth client setup
+
+1. Grant type: authorization_code.
+2. Response type: code.
+3. Token authentication method: none.
+4. PKCE: S256.
+5. Redirect URI: tgwsproxy://oauth/cloudflare.
+6. Проверенный набор scopes для PoC: memberships.read workers-scripts.read workers-scripts.write.
+7. Для публичного приложения выполнить publisher domain verification и затем перевести client visibility в public.
+
+Client ID передаётся при сборке, secret отсутствует:
+
+ .\gradlew.bat :app:assembleDebug `
+ -PTGWSPROXY_CF_OAUTH_CLIENT_ID="" `
+ -PTGWSPROXY_CF_OAUTH_SCOPES="memberships.read workers-scripts.read workers-scripts.write"
+
+Те же два значения можно передать environment variables TGWSPROXY_CF_OAUTH_CLIENT_ID и TGWSPROXY_CF_OAUTH_SCOPES.
+
+Если client ID не задан, экран доступен для inspection, но OAuth login отключён.
+
+## Worker source
+
+APK не содержит вручную поддерживаемую вторую копию Worker source. Gradle перед сборкой копирует worker.js, chunk-relay-worker.js и chunk-relay-status-worker.js из scripts/cloudflare-worker/ в generated assets. Android deploy поэтому использует текущие production source-файлы репозитория.
+
+## Deploy behavior
+
+- новый worker name: create/deploy;
+- существующий worker name: PUT того же script и update;
+- delete: DELETE /accounts/{account_id}/workers/scripts/{script_name};
+- успешный workers.dev endpoint автоматически добавляется в TgWsProxy Worker Pool, pool включается, endpoint выбирается активным;
+- повторный deploy того же URL не создаёт дубликат Worker Pool entry.
+
+## Manual acceptance gate
+
+Без зарегистрированного Cloudflare OAuth client и реального Android device нельзя считать выполненными следующие пункты:
+
+- Cloudflare API registration подтверждает поддержку redirect URI tgwsproxy://oauth/cloudflare для Android PKCE client;
+- consent с Workers scopes реально возвращает ожидаемые account resources;
+- выбранные scopes достаточны и могут быть сужены;
+- upload создаёт ChunkRelaySession / SQLite namespace и CHUNK_RELAY binding;
+- workers.dev smoke проходит;
+- второй Cloudflare login сосуществует с первым;
+- redeploy сохраняет Durable Object mapping;
+- delete удаляет test Worker.
+
+До этой проверки Issue #76 остаётся открытым.