diff --git a/apps/rebreak-native/.gitignore b/apps/rebreak-native/.gitignore index 1cc3d9d..4bd5ccd 100644 --- a/apps/rebreak-native/.gitignore +++ b/apps/rebreak-native/.gitignore @@ -7,9 +7,17 @@ dist/ web-build/ expo-env.d.ts -# Native -ios/ -android/ +# Native (Expo-prebuild output — regenerated by `expo prebuild`, not tracked). +# Anchored so it does NOT swallow the custom native module's source under modules/. +/ios/ +/android/ +# Custom native module (modules/rebreak-protection/{android,ios}) source IS tracked, +# but its build artifacts are not. +modules/*/android/build/ +modules/*/android/.cxx/ +modules/*/android/.gradle/ +modules/*/ios/build/ +modules/*/ios/Pods/ *.jks *.p12 *.key diff --git a/apps/rebreak-native/modules/rebreak-protection/android/build.gradle b/apps/rebreak-native/modules/rebreak-protection/android/build.gradle new file mode 100644 index 0000000..981d2ba --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'expo-module-gradle-plugin' +} + +group = 'org.rebreak' +version = '0.1.0' + +android { + namespace "expo.modules.rebreakprotection" + defaultConfig { + versionCode 1 + versionName "0.1.0" + minSdkVersion 26 + } + testOptions { + unitTests.returnDefaultValues = true + } +} + +dependencies { + implementation "androidx.core:core-ktx:1.12.0" + testImplementation "junit:junit:4.13.2" +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt new file mode 100644 index 0000000..63e9b33 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/RebreakProtectionModule.kt @@ -0,0 +1,494 @@ +package expo.modules.rebreakprotection + +import android.accessibilityservice.AccessibilityServiceInfo +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.provider.Settings +import android.util.Log +import android.view.accessibility.AccessibilityManager +import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.CodedException +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService +import expo.modules.rebreakprotection.vpn.RebreakVpnService +import java.io.File +import java.net.HttpURLConnection +import java.net.UnknownHostException +import java.net.URL +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.concurrent.Executors + +/** + * Step-3-Implementation des Android-Pendants zu iOS NEFilter. + * + * activate() → VpnService.prepare() → User-Consent → start RebreakVpnService + * disable() → ACTION_STOP → Service stoppt + * getDeviceState() → vpn / accessibility / tamperLock + Blocklist-Metadaten + * syncBlocklist() → HTTP-Download + ETag-Caching + atomic-replace + Service-Reload + * runHealthProbe() → HEAD bet365.com → blocked/loaded/timeout/offline + * openSystemSettings(target) → Generic Settings-Intent + * isAccessibilityEnabled() → Live-Check via AccessibilityManager + Settings.Secure + * openAccessibilitySettings() → Deep-link zu Rebreak-Detail-Page (fallback liste) + * armTamperLock() → setzt SharedPref-Flag, requires VPN+A11y + * disarmTamperLock() → cleart SharedPref-Flag + * getProtectionStatus() → vpn/a11y/blocklistCount/tamperArmed + */ +class RebreakProtectionModule : Module() { + + private var pendingActivatePromise: Promise? = null + private val ioExecutor = Executors.newCachedThreadPool() + + override fun definition() = ModuleDefinition { + Name("RebreakProtection") + + Events("onLayerChange") + + OnActivityResult { _, payload -> + if (payload.requestCode != VPN_CONSENT_REQUEST_CODE) return@OnActivityResult + val promise = pendingActivatePromise ?: return@OnActivityResult + pendingActivatePromise = null + if (payload.resultCode == Activity.RESULT_OK) { + startVpnService() + saveEnabled(true) + promise.resolve(activateSuccessResult()) + } else { + saveEnabled(false) + promise.resolve( + mapOf( + "allLayersOn" to false, + "missingLayers" to listOf("vpn", "accessibility", "tamperLock"), + "errors" to listOf("vpn_permission_denied"), + ) + ) + } + sendLayerChange() + } + + AsyncFunction("activate") { promise: Promise -> + val activity = appContext.currentActivity + ?: return@AsyncFunction promise.reject( + CodedException("no_activity", "Activity nicht verfügbar — App im Hintergrund?", null) + ) + + val consentIntent = VpnService.prepare(activity) + if (consentIntent == null) { + startVpnService() + saveEnabled(true) + promise.resolve(activateSuccessResult()) + sendLayerChange() + } else { + if (pendingActivatePromise != null) { + promise.reject(CodedException("consent_in_flight", "Bereits laufender VPN-Consent-Dialog", null)) + return@AsyncFunction + } + pendingActivatePromise = promise + activity.startActivityForResult(consentIntent, VPN_CONSENT_REQUEST_CODE) + } + } + + AsyncFunction("disable") { + val ctx = requireContext() + val intent = Intent(ctx, RebreakVpnService::class.java).apply { + action = RebreakVpnService.ACTION_STOP + } + try { + ctx.startService(intent) + } catch (e: Exception) { + Log.w(TAG, "stopService failed: ${e.message}") + } + saveEnabled(false) + // Tamper-Lock IMMER mit-disarmen — egal welcher Caller `disable()` aufruft. + // Sonst läuft der Accessibility-Watchdog (Settings-Block + Gambling-Block) + // nach dem Cooldown weiter, obwohl der User legitim deaktiviert hat. + disarmTamperLock(ctx) + sendLayerChange() + mapOf("allLayersOff" to true) + } + + AsyncFunction("getDeviceState") { + val ctx = requireContext() + buildDeviceState(ctx) + } + + AsyncFunction("syncBlocklist") { opts: Map, promise: Promise -> + val baseURL = opts["baseURL"] as? String + val authToken = opts["authToken"] as? String + if (baseURL.isNullOrBlank() || authToken.isNullOrBlank()) { + promise.reject(CodedException("missing_params", "baseURL und authToken required", null)) + return@AsyncFunction + } + val ctx = requireContext() + ioExecutor.execute { + try { + val result = downloadBlocklist(ctx, baseURL, authToken) + // VpnService-Reload — NUR wenn der Filter tatsächlich an ist. + // `startService` würde den Service sonst re-createn, obwohl der + // User den Schutz (ggf. nach Cooldown) deaktiviert hat. + if (isVpnEffectivelyOn(ctx)) { + val reload = Intent(ctx, RebreakVpnService::class.java).apply { + action = RebreakVpnService.ACTION_RELOAD + } + try { ctx.startService(reload) } catch (_: Exception) {} + } + promise.resolve(result) + sendLayerChange() + } catch (e: Exception) { + promise.reject(CodedException("sync_failed", e.message ?: "unknown", e)) + } + } + } + + AsyncFunction("runHealthProbe") { opts: Map?, promise: Promise -> + val target = (opts?.get("target") as? String) ?: "https://bet365.com" + val timeoutMs = (((opts?.get("timeoutSeconds") as? Number) ?: 5).toInt()) * 1000 + ioExecutor.execute { + val started = System.currentTimeMillis() + var outcome = "offline" + var reason = "init" + try { + val url = URL(target) + val conn = (url.openConnection() as HttpURLConnection).apply { + connectTimeout = timeoutMs + readTimeout = timeoutMs + requestMethod = "HEAD" + instanceFollowRedirects = false + } + val code = conn.responseCode + conn.disconnect() + outcome = "loaded" + reason = "http_$code" + } catch (_: UnknownHostException) { + outcome = "blocked" + reason = "dns_unresolved" + } catch (_: java.net.SocketTimeoutException) { + outcome = "timeout" + reason = "timeout" + } catch (e: Exception) { + outcome = "offline" + reason = e::class.java.simpleName + } + val elapsed = (System.currentTimeMillis() - started).toInt() + promise.resolve( + mapOf( + "outcome" to outcome, + "reason" to reason, + "durationMs" to elapsed, + "target" to target, + ) + ) + } + } + + AsyncFunction("openSystemSettings") { target: String? -> + val ctx = requireContext() + val action = when (target) { + "vpn" -> Settings.ACTION_VPN_SETTINGS + "accessibility" -> Settings.ACTION_ACCESSIBILITY_SETTINGS + "notifications" -> Settings.ACTION_APP_NOTIFICATION_SETTINGS + else -> Settings.ACTION_SETTINGS + } + val intent = Intent(action).apply { + if (target == "notifications") { + putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName) + } + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + ctx.startActivity(intent) + Unit + } + + // ─── Layer 2: Accessibility + Tamper ────────────────────────────────── + + AsyncFunction("isAccessibilityEnabled") { + mapOf("enabled" to isAccessibilityServiceEnabled(requireContext())) + } + + AsyncFunction("openAccessibilitySettings") { promise: Promise -> + val ctx = requireContext() + val component = ComponentName(ctx, RebreakAccessibilityService::class.java) + val tries = listOf( + // Direkt zur Rebreak-Detail-Seite (Android 11+, viele OEMs) + Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS").apply { + putExtra(":settings:fragment_args_key", component.flattenToString()) + val args = android.os.Bundle() + args.putString(":settings:fragment_args_key", component.flattenToString()) + putExtra(":settings:show_fragment_args", args) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + // Fallback: Allgemeine Bedienungshilfen-Liste + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + for (intent in tries) { + try { + ctx.startActivity(intent) + promise.resolve(mapOf("opened" to true)) + return@AsyncFunction + } catch (e: Exception) { + Log.w(TAG, "openAccessibilitySettings try failed: ${e.message}") + } + } + promise.reject(CodedException("open_failed", "no accessibility settings activity available", null)) + } + + AsyncFunction("armTamperLock") { promise: Promise -> + val ctx = requireContext() + val vpn = isVpnEffectivelyOn(ctx) + val a11y = isAccessibilityServiceEnabled(ctx) + if (!vpn || !a11y) { + promise.reject( + CodedException( + "preconditions_not_met", + "VPN and Accessibility must both be active before arming", + null, + ) + ) + return@AsyncFunction + } + val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + prefs.edit().putBoolean(KEY_TAMPER_ARMED, true).apply() + Log.i(TAG, "tamper-lock ARMED") + promise.resolve(mapOf("armed" to true)) + sendLayerChange() + } + + AsyncFunction("disarmTamperLock") { + val ctx = requireContext() + disarmTamperLock(ctx) + sendLayerChange() + mapOf("armed" to false) + } + + AsyncFunction("getProtectionStatus") { + val ctx = requireContext() + val vpn = isVpnEffectivelyOn(ctx) + val a11y = isAccessibilityServiceEnabled(ctx) + val count = currentHashCount(ctx) + val armed = isTamperLockArmed(ctx) + Log.i(TAG, "getProtectionStatus: vpn=$vpn, a11y=$a11y, blocklist=$count, armed=$armed") + mapOf( + "vpnEnabled" to vpn, + "accessibilityEnabled" to a11y, + "blocklistCount" to count, + "tamperArmed" to armed, + ) + } + } + + // ─── Helpers ──────────────────────────────────────────────────────────── + + private fun requireContext(): Context = + appContext.reactContext + ?: throw CodedException("no_context", "ReactContext nicht verfügbar", null) + + private fun startVpnService() { + val ctx = requireContext() + val intent = Intent(ctx, RebreakVpnService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ctx.startForegroundService(intent) + } else { + ctx.startService(intent) + } + } + + private fun buildDeviceState(ctx: Context): Map { + val blocklist = File(ctx.filesDir, BLOCKLIST_FILENAME) + val count = if (blocklist.exists()) (blocklist.length() / 8).toInt() else 0 + val lastSyncAt = prefs(ctx).getString(PREF_LAST_SYNC, null) + return mapOf( + "vpn" to isVpnEffectivelyOn(ctx), + "accessibility" to isAccessibilityServiceEnabled(ctx), + "tamperLock" to isTamperLockArmed(ctx), + "blocklistCount" to count, + "blocklistLastSyncAt" to lastSyncAt, + ) + } + + private fun activateSuccessResult(): Map = mapOf( + "allLayersOn" to false, + "missingLayers" to listOf("accessibility", "tamperLock"), + "errors" to emptyList(), + ) + + private fun sendLayerChange() { + val ctx = appContext.reactContext ?: return + sendEvent("onLayerChange", buildDeviceState(ctx)) + } + + private fun prefs(ctx: Context) = + ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + + private fun saveEnabled(enabled: Boolean) { + prefs(requireContext()).edit().putBoolean(KEY_ENABLED, enabled).apply() + } + + private fun isEnabledFlag(ctx: Context): Boolean = + prefs(ctx).getBoolean(KEY_ENABLED, false) + + private fun isTamperLockArmed(ctx: Context): Boolean = + prefs(ctx).getBoolean(KEY_TAMPER_ARMED, false) + + /** Cleart das Tamper-Armed-Flag. Geteilte Logik zwischen der + * `disarmTamperLock`-AsyncFunction und `disable()` — robust für alle Caller. */ + private fun disarmTamperLock(ctx: Context) { + prefs(ctx).edit().putBoolean(KEY_TAMPER_ARMED, false).apply() + Log.i(TAG, "tamper-lock DISARMED") + } + + private fun currentHashCount(ctx: Context): Int { + val file = File(ctx.filesDir, BLOCKLIST_FILENAME) + return if (file.exists()) (file.length() / 8L).toInt() else 0 + } + + /** + * Live-Check: ist unser VPN tatsächlich aktiv? + * 1. VpnService.prepare(ctx) == null → wir haben Permission + * 2. UND (companion-Live-Flag ODER Prefs-Flag) + * + * Wenn der User in System-Settings das VPN ausgeschaltet hat, returnt + * prepare() ein non-null Intent (= "musst Permission neu holen") UND + * unser onRevoke clear't bereits die Prefs-Flag. Doppelt abgesichert. + */ + private fun isVpnEffectivelyOn(ctx: Context): Boolean { + val live = RebreakVpnService.isRunning + val intent = try { + VpnService.prepare(ctx) + } catch (_: Exception) { + null + } + val flag = isEnabledFlag(ctx) + Log.d(TAG, "isVpnEffectivelyOn: live=$live, prepareIntent=${intent != null}, flag=$flag") + if (intent != null) { + // Permission entzogen → definitely off + if (flag) prefs(ctx).edit().putBoolean(KEY_ENABLED, false).apply() + return false + } + return live || flag + } + + /** + * Liest Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES — kommagetrennte + * Liste von ComponentName-Strings. Wir matchen exakt unseren Service. + * + * Vorteil ggü. AccessibilityManager.getEnabledAccessibilityServiceList: + * funktioniert auch wenn der Service gerade neu installiert wurde und + * der Cache noch nicht aktualisiert ist. + */ + private fun isAccessibilityServiceEnabled(ctx: Context): Boolean { + val pkg = ctx.packageName + val expectedClass = RebreakAccessibilityService::class.java.name + + // Primary: AccessibilityManager API — funktioniert auf allen OEMs + // (Samsung One UI returnt manchmal null bei Settings.Secure). + try { + val am = ctx.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager + if (am != null) { + val list = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK) + Log.d(TAG, "a11y check via AccessibilityManager: ${list?.size ?: 0} services") + for (info in list ?: emptyList()) { + val id = info.id ?: continue + if (id.contains(pkg) && id.contains("RebreakAccessibilityService")) { + Log.d(TAG, "a11y MATCH via AM: $id") + return true + } + } + } + } catch (e: Exception) { + Log.w(TAG, "AccessibilityManager check failed: ${e.message}") + } + + // Fallback: Settings.Secure (Standard-Android) + val expected = ComponentName(ctx, RebreakAccessibilityService::class.java) + val expectedFlat = expected.flattenToString() + val enabled = try { + Settings.Secure.getString( + ctx.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) + } catch (e: Exception) { + null + } + Log.d(TAG, "a11y check via Settings.Secure: expected=$expectedFlat, settings=$enabled") + if (!enabled.isNullOrBlank()) { + if (enabled.contains(expectedFlat)) return true + if (enabled.contains(expectedClass)) return true + } + return false + } + + private fun downloadBlocklist( + ctx: Context, baseURL: String, authToken: String + ): Map { + val url = URL("$baseURL/api/url-filter/blocklist.bin") + val conn = (url.openConnection() as HttpURLConnection).apply { + setRequestProperty("Authorization", "Bearer $authToken") + connectTimeout = 10_000 + readTimeout = 30_000 + } + val p = prefs(ctx) + p.getString(PREF_ETAG, null)?.let { conn.setRequestProperty("If-None-Match", it) } + + val now = isoTimestamp() + return try { + when (val code = conn.responseCode) { + 304 -> { + val existing = File(ctx.filesDir, BLOCKLIST_FILENAME) + val count = (if (existing.exists()) existing.length() else 0L) / 8 + mapOf("updated" to false, "count" to count.toInt()) + } + 200 -> { + val bytes = conn.inputStream.use { it.readBytes() } + writeBlocklistAtomic(ctx, bytes) + val edits = p.edit() + conn.getHeaderField("etag")?.let { edits.putString(PREF_ETAG, it) } + edits.putString(PREF_LAST_SYNC, now).apply() + val plan = conn.getHeaderField("x-rebreak-plan") + mapOf( + "updated" to true, + "count" to bytes.size / 8, + "plan" to plan, + ) + } + else -> throw RuntimeException("HTTP $code") + } + } finally { + conn.disconnect() + } + } + + private fun writeBlocklistAtomic(ctx: Context, bytes: ByteArray) { + val finalFile = File(ctx.filesDir, BLOCKLIST_FILENAME) + val tmpFile = File(ctx.filesDir, "$BLOCKLIST_FILENAME.tmp") + tmpFile.writeBytes(bytes) + if (!tmpFile.renameTo(finalFile)) { + finalFile.delete() + tmpFile.renameTo(finalFile) + } + } + + private fun isoTimestamp(): String { + val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + df.timeZone = TimeZone.getTimeZone("UTC") + return df.format(Date()) + } + + companion object { + private const val TAG = "RebreakProtection" + private const val VPN_CONSENT_REQUEST_CODE = 42101 + private const val BLOCKLIST_FILENAME = "blocklist.bin" + private const val PREFS = "rebreak_filter_prefs" + private const val KEY_ENABLED = "filter_enabled" + private const val KEY_TAMPER_ARMED = "tamper_armed" + private const val PREF_ETAG = "rebreak_blocklist_etag" + private const val PREF_LAST_SYNC = "rebreak_blocklist_last_sync" + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt new file mode 100644 index 0000000..73715d2 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt @@ -0,0 +1,489 @@ +package expo.modules.rebreakprotection.accessibility + +import android.accessibilityservice.AccessibilityService +import android.accessibilityservice.AccessibilityServiceInfo +import android.content.Intent +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.widget.Toast +import expo.modules.rebreakprotection.filter.HashList +import java.io.File + +/** + * URL-Filter-Layer 2 — parallel zum VpnService DNS-Filter. + * + * Hintergrund: Der VpnService kann vom User in System-Settings ausgeschaltet + * werden (Always-on VPN ist bei Free-Konsumenten-Devices nicht erzwingbar). + * Die Accessibility-Permission hingegen ist ihm "vertrauter" — man gibt sie + * einmal und vergisst sie. Solange der User sie nicht explizit entzieht, + * läuft dieser Filter weiter. + * + * Funktionsweise (parallel zu iOS NEFilterBrowserFlow): + * - Service hört auf TYPE_WINDOW_CONTENT_CHANGED + TYPE_WINDOW_STATE_CHANGED + * - Filtert nach Browser-Packages (Chrome/Firefox/Edge/Samsung/Brave/Opera) + * - Liest die Adressleiste via AccessibilityNodeInfo + * - Hash-Match gegen blocklist.bin (gleiche Datei wie VpnService) + * - Bei Treffer: GLOBAL_ACTION_BACK + Toast + * + * Throttling: pro Browser nur alle 600ms eine URL-Prüfung — sonst feuert + * Chrome bei jeder Frame-Änderung Hunderte Events. + */ +class RebreakAccessibilityService : AccessibilityService() { + + private lateinit var hashList: HashList + private val mainHandler = Handler(Looper.getMainLooper()) + private val lastCheck = HashMap() + private val lastBlockedUrl = HashMap() + private var lastSettingsCheck: Long = 0L + /** Nach einem TAMPER-BLOCK: 3s lang keinen weiteren Block triggern. + * Verhindert Toast-Spam wenn User legitim in Settings navigieren will und + * Page-Transitions noch alte Keyword-Matches durchziehen. */ + private var lastBlockAt: Long = 0L + private val POST_BLOCK_COOLDOWN_MS = 3000L + + override fun onCreate() { + super.onCreate() + hashList = HashList(File(applicationContext.filesDir, "blocklist.bin")) + hashList.load() + Log.i(TAG, "service created — ${hashList.count()} hashes") + } + + override fun onServiceConnected() { + super.onServiceConnected() + // Reload bei jedem Connect — User könnte zwischenzeitlich syncBlocklist + // gemacht haben. + hashList.load() + Log.i(TAG, "service connected — ${hashList.count()} hashes loaded") + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event == null) return + + // Globaler Kill-Switch: Wenn der User den Schutz NICHT will, tut dieser + // Service GAR NICHTS — kein Gambling-Block, kein Settings-Block. Der + // a11y-Service selbst kann sich nicht programmatisch deaktivieren, also + // ist das hier die einzige Stelle wo wir ihn vollständig stilllegen. + // "Schutz aktiv?" = Tamper-Lock armed (App-Lock opt-in) ODER der + // `filter_enabled`-Flag aus den SharedPrefs (den `disable()` auf false + // setzt). Wer den App-Lock nicht opt-in't, hat trotzdem den normalen + // Free-Blocker → dann greift `filter_enabled`. + if (!isTamperLockArmed() && !isProtectionEnabled()) return + + val pkg = event.packageName?.toString() ?: return + + // Settings-Watchdog: User versucht eine Schutz-relevante Settings-Page + // zu öffnen → Sofort BACK + Toast. Wir reagieren auf STATE_CHANGED + // (Activity-Wechsel) UND CONTENT_CHANGED (Dialog-Inhalt lädt nach) — + // weil bei manchen OEMs (Samsung) der Inhalt erst NACH der Activity + // gerendert wird und der erste Scan leer wäre. + if (pkg in WATCHED_SETTINGS_PACKAGES && + (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED || + event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED)) { + if (handleProtectedSettingsBlock(pkg, event)) return + } + + if (!BROWSER_PACKAGES.contains(pkg)) return + + // Throttle pro Browser-Package + val now = System.currentTimeMillis() + val last = lastCheck[pkg] ?: 0L + if (now - last < THROTTLE_MS) return + lastCheck[pkg] = now + + val root = rootInActiveWindow ?: return + val url = extractUrl(root, pkg) ?: return + val host = extractHost(url) ?: return + + if (hashList.matchesAnySuffix(host)) { + // Vermeiden, beim selben URL ständig Back zu feuern + if (lastBlockedUrl[pkg] == url) return + lastBlockedUrl[pkg] = url + Log.i(TAG, "BLOCKED via accessibility: $host (in $pkg)") + performGlobalAction(GLOBAL_ACTION_BACK) + mainHandler.post { + Toast.makeText( + applicationContext, + "Rebreak hat diese Seite blockiert", + Toast.LENGTH_SHORT, + ).show() + } + } else { + // URL nicht (mehr) blockiert → letzte-blockiert-Cache löschen + lastBlockedUrl.remove(pkg) + } + } + + override fun onInterrupt() { + Log.w(TAG, "service interrupted") + } + + /** + * Tamper-Protection: User versucht in System-Settings eine Schutz-relevante + * Seite zu öffnen (VPN deaktivieren, App löschen, A11y für Rebreak abschalten). + * + * Strategie: TYPE_WINDOW_STATE_CHANGED → wenn package + className auf eine + * gefährliche Activity matchen → GLOBAL_ACTION_BACK + Toast. User wird sofort + * rausgeworfen, kann nicht togglen. + * + * Bypass-Vektoren die wir damit NICHT abdecken: + * - Safe Mode (Reboot mit Power-Long-Press) → Apps off → A11y off → freie Bahn + * - ADB / Root → kein normaler User + * + * Aktiviert nur wenn User aktuell als "committed" gilt (rebreak_blocker == true + * in SharedPreferences). Sonst wäre der Lock auch beim Neuinstall-Onboarding + * aktiv und würde User aussperren. + * + * @return true wenn die Activity geblockt wurde + */ + private fun handleProtectedSettingsBlock(pkg: String, event: AccessibilityEvent): Boolean { + // Tamper-Lock ist nur aktiv wenn User EXPLIZIT verriegelt hat (über + // App-Button "Schutz fest verriegeln"). Sonst würde der Watchdog + // sofort die Setup-Seiten blockieren und User aussperren. + if (!isTamperLockArmed()) return false + if (!isUserCommittedToProtection()) return false + if (pkg !in WATCHED_SETTINGS_PACKAGES) return false + + // Throttle: max alle 400ms eine Settings-Inspection (CONTENT_CHANGED + // feuert sonst hunderte Mal pro Sekunde während User scrollt) + val now = System.currentTimeMillis() + if (now - lastSettingsCheck < 400) return false + lastSettingsCheck = now + + // Post-Block Cooldown: nach einem Block 3s lang keinen weiteren Block + // damit User legitim in Settings navigieren kann ohne dass alte + // Keyword-Matches in Page-Transitions repeatedly auslösen. + if (now - lastBlockAt < POST_BLOCK_COOLDOWN_MS) { + Log.d(TAG, "settings-watch: cooldown active, skipping block") + return false + } + + val className = event.className?.toString() ?: return false + + // DEBUG: alle Settings-Activities mitloggen damit wir OEM-Variationen sehen + Log.i(TAG, "settings-watch: $pkg / $className") + + // Phase 1 — Class-Name-Match (ältere Stock-Android-Patterns) + val classMatchDangerous = DANGEROUS_ACTIVITY_PATTERNS.any { pattern -> + className.contains(pattern, ignoreCase = true) + } + + // Phase 2 — Window-Content-Match: IMMER scannen (außer wir haben schon + // einen className-Match). OEMs benutzen für Dialoge oft className die + // weder in unseren Patterns noch als "generic container" erkannt werden + // (z.B. Samsung's "AppDialog", Stock-Android's "ManageDialog$2"). Der + // Keyword-Cluster-Scan ist unsere Safety-Net: 2 Keywords aus dem + // gleichen Cluster = Block. Default false-positive Risk durch Throttling + // (alle 400ms eine Inspection). + var contentReason: String? = null + if (!classMatchDangerous) { + contentReason = scanWindowForDangerousContent() + } + + val isDangerous = classMatchDangerous || contentReason != null + if (!isDangerous) return false + + Log.w(TAG, "TAMPER-BLOCK: $pkg / $className (reason=${contentReason ?: "class-match"})") + lastBlockAt = now // post-block cooldown startet jetzt + // Doppel-BACK: einmal um Activity zu schließen, einmal als Backup falls + // erste BACK nur einen Dialog dismissed. + performGlobalAction(GLOBAL_ACTION_BACK) + mainHandler.postDelayed({ + performGlobalAction(GLOBAL_ACTION_BACK) + }, 200) + + mainHandler.post { + Toast.makeText( + applicationContext, + "🛡 Diese Einstellung ist während des Schutzes gesperrt", + Toast.LENGTH_LONG, + ).show() + } + return true + } + + /** + * Scannt die aktuelle Window-Hierarchie nach Texten die auf eine + * VPN/A11y/App-Uninstall-Page hindeuten. Wird genutzt wenn die Activity + * generisch ist (z.B. Samsung's SubSettings) — dann müssen wir den + * Inhalt selbst inspizieren. + */ + private fun scanWindowForDangerousContent(): String? { + val root = rootInActiveWindow ?: return null + val texts = mutableListOf() + collectAllText(root, texts, depth = 0) + val joined = texts.joinToString(" | ").lowercase() + // DEBUG: was steht eigentlich auf der Page? Hilft beim Patterns-Tuning. + Log.d(TAG, "settings-content-text: ${joined.take(500)}") + + // High-confidence Keywords: 1 Treffer reicht (sehr spezifisch zu uns) + for (keyword in HIGH_CONFIDENCE_KEYWORDS) { + if (joined.contains(keyword)) { + Log.d(TAG, "settings-watch: high-confidence keyword match: '$keyword'") + return "high-confidence:$keyword" + } + } + + // Standard-Cluster: min 2 Keywords nötig (false-positive-Schutz) + for ((cluster, keywords) in DANGEROUS_TEXT_CLUSTERS) { + val matchCount = keywords.count { joined.contains(it) } + if (matchCount >= 2) { + Log.d(TAG, "settings-watch: cluster $cluster matched $matchCount keywords") + return cluster + } + } + return null + } + + private fun collectAllText(node: AccessibilityNodeInfo?, sink: MutableList, depth: Int) { + if (node == null || depth > 10) return + node.text?.toString()?.takeIf { it.isNotBlank() }?.let { sink.add(it) } + node.contentDescription?.toString()?.takeIf { it.isNotBlank() }?.let { sink.add(it) } + for (i in 0 until node.childCount) { + collectAllText(node.getChild(i), sink, depth + 1) + } + } + + /** Liest den `filter_enabled`-Flag aus SharedPreferences (gleicher Storage + + * Key wie `RebreakProtectionModule.saveEnabled` / `KEY_ENABLED`). `disable()` + * setzt ihn auf false — danach ist der Schutz aus und dieser Service passiv. */ + private fun isProtectionEnabled(): Boolean { + return try { + val prefs = applicationContext.getSharedPreferences( + "rebreak_filter_prefs", + android.content.Context.MODE_PRIVATE, + ) + prefs.getBoolean("filter_enabled", false) + } catch (_: Exception) { + false + } + } + + /** Liest den Commitment-Flag aus SharedPreferences (gleicher Storage wie der + * Plugin nutzt). User ist "committed" wenn er Schutz mal aktiv aktiviert hat + * und ihn nicht legitim (= durch Cooldown-Ende) deaktiviert hat. */ + private fun isUserCommittedToProtection(): Boolean = isProtectionEnabled() + + /** "Verriegelt"-Flag — User hat über App-Button "Schutz fest verriegeln" + * bestätigt dass Settings-Tampering blockiert werden soll. Default: false + * damit Onboarding nicht aussperrt. */ + private fun isTamperLockArmed(): Boolean { + return try { + val prefs = applicationContext.getSharedPreferences( + "rebreak_filter_prefs", + android.content.Context.MODE_PRIVATE, + ) + prefs.getBoolean("tamper_armed", false) + } catch (_: Exception) { + false + } + } + + /** + * Liest die Adressleiste aus dem Browser-View-Tree. + * + * Browser benutzen unterschiedliche View-IDs für ihre URL-Bar — wir + * probieren die gängigen durch. Fallback: alle EditText-Nodes nach + * URL-artigem Inhalt scannen. + */ + private fun extractUrl(root: AccessibilityNodeInfo, pkg: String): String? { + val candidateIds = URL_BAR_IDS[pkg] ?: emptyList() + for (id in candidateIds) { + val nodes = try { + root.findAccessibilityNodeInfosByViewId(id) + } catch (e: Exception) { + null + } ?: continue + for (node in nodes) { + val text = node.text?.toString() ?: continue + if (looksLikeUrl(text)) return text + } + } + // Fallback — scanne den ganzen Tree breadth-first nach URL-artigem Text + return scanTreeForUrl(root, depth = 0) + } + + private fun scanTreeForUrl(node: AccessibilityNodeInfo, depth: Int): String? { + if (depth > MAX_TREE_DEPTH) return null + val text = node.text?.toString() + if (text != null && looksLikeUrl(text)) return text + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + val found = scanTreeForUrl(child, depth + 1) + if (found != null) return found + } + return null + } + + private fun looksLikeUrl(text: String): Boolean { + val t = text.trim() + if (t.length < 3 || t.length > 2048) return false + // Schnell-Filter: enthält Punkt, kein Whitespace + if (' ' in t || '\n' in t) return false + // Normalisiere — Browser zeigen oft "https://" weggekürzt + val normalized = if (t.startsWith("http://") || t.startsWith("https://")) t else "https://$t" + return try { + val u = java.net.URI(normalized) + val host = u.host + host != null && host.contains('.') + } catch (_: Exception) { + false + } + } + + private fun extractHost(url: String): String? { + val normalized = if (url.startsWith("http://") || url.startsWith("https://")) url else "https://$url" + return try { + java.net.URI(normalized).host?.lowercase() + } catch (_: Exception) { + null + } + } + + companion object { + private const val TAG = "RebreakA11y" + private const val THROTTLE_MS = 600L + private const val MAX_TREE_DEPTH = 12 + + // Erweitern wir, wenn User-Reports zeigen dass ihr Browser nicht erkannt wird. + val BROWSER_PACKAGES = setOf( + "com.android.chrome", // Chrome + "com.chrome.beta", + "com.chrome.dev", + "com.chrome.canary", + "org.mozilla.firefox", // Firefox + "org.mozilla.firefox_beta", + "org.mozilla.fenix", // Firefox Fenix + "com.microsoft.emmx", // Edge + "com.sec.android.app.sbrowser", // Samsung Internet + "com.brave.browser", // Brave + "com.opera.browser", // Opera + "com.opera.mini.native", + "com.duckduckgo.mobile.android", // DuckDuckGo + "com.vivaldi.browser", // Vivaldi + "org.torproject.torbrowser", // Tor + ) + + // View-IDs der Adressleiste pro Browser. Bekannte Stand 2026. + // findAccessibilityNodeInfosByViewId erwartet das volle ID-Tag, + // d.h. ":id/". + private val URL_BAR_IDS = mapOf( + "com.android.chrome" to listOf("com.android.chrome:id/url_bar"), + "com.chrome.beta" to listOf("com.chrome.beta:id/url_bar"), + "com.chrome.dev" to listOf("com.chrome.dev:id/url_bar"), + "com.chrome.canary" to listOf("com.chrome.canary:id/url_bar"), + "org.mozilla.firefox" to listOf("org.mozilla.firefox:id/mozac_browser_toolbar_url_view"), + "org.mozilla.firefox_beta" to listOf("org.mozilla.firefox_beta:id/mozac_browser_toolbar_url_view"), + "org.mozilla.fenix" to listOf("org.mozilla.fenix:id/mozac_browser_toolbar_url_view"), + "com.microsoft.emmx" to listOf("com.microsoft.emmx:id/url_bar"), + "com.sec.android.app.sbrowser" to listOf("com.sec.android.app.sbrowser:id/location_bar_edit_text"), + "com.brave.browser" to listOf("com.brave.browser:id/url_bar"), + "com.opera.browser" to listOf("com.opera.browser:id/url_field"), + "com.opera.mini.native" to listOf("com.opera.mini.native:id/url_field"), + "com.duckduckgo.mobile.android" to listOf("com.duckduckgo.mobile.android:id/omnibarTextInput"), + "com.vivaldi.browser" to listOf("com.vivaldi.browser:id/url_bar"), + ) + + const val ACTION_RELOAD_BLOCKLIST = "expo.modules.rebreakprotection.action.A11Y_RELOAD" + + // Settings-Apps die wir auf Tamper-Versuche überwachen. + // Stock + Samsung One UI haben unterschiedliche Package-Namen, + // wir whitelist'n alle bekannten. + val WATCHED_SETTINGS_PACKAGES = setOf( + "com.android.settings", + "com.android.vpndialogs", + "com.android.packageinstaller", + "com.google.android.packageinstaller", + "com.samsung.android.app.settings", + "com.samsung.accessibility", + // Play Store: User könnte hier auf "Deinstallieren" tippen für Rebreak + "com.android.vending", + ) + + // Activity-Class-Patterns die geblockt werden (Substring-Match, case-insensitive). + // Decken Stock-Android + Samsung One UI ab. Patterns sind bewusst breit + // damit OEM-Variationen mitgenommen werden. + /** + * High-confidence Keywords — wenn EINER davon im Window-Content + * auftaucht, blocken wir sofort. Sind alle hochspezifisch und + * tauchen praktisch nur auf VPN/A11y/Uninstall-Detail-Pages auf. + */ + val HIGH_CONFIDENCE_KEYWORDS = listOf( + "rebreak filter", // VPN-Profil-Name aus Builder.setSession + "filtert glücksspielseiten", // A11y-Service-Summary + "rebreak deinstallieren", + "rebreak entfernen", + "rebreak löschen", + ) + + /** + * Standard-Cluster — min 2 Keywords pro Cluster nötig damit + * harmlose Settings-Suche keine false-positives auslöst. + */ + val DANGEROUS_TEXT_CLUSTERS = mapOf( + "vpn-page" to listOf( + "vpn", + "rebreak filter", // unser Profil-Name (siehe Builder.setSession) + "always-on", + "always-on-vpn", + "verbindung trennen", + "trennen", + "verbindungen ohne vpn", + "block connections", + "vpn-profil", + "konto entfernen", + "vergessen", + "always-on vpn", + ), + "a11y-page" to listOf( + "bedienungshilfe", + "eingabehilfe", + "accessibility", + "filtert glücksspiel", // unser A11y-Service-Summary + "rebreak filter", + "installierte apps", + "installed services", + "downloaded apps", + "berechtigung erteilen", + "service deaktivieren", + "service ausschalten", + ), + "uninstall-page" to listOf( + "deinstallieren", + "uninstall", + "rebreak", + "möchten sie diese app", + "do you want to uninstall", + "app entfernen", + "force stop", + "stopp erzwingen", + "speicher", + "daten löschen", + "clear data", + "cache leeren", + ), + ) + + val DANGEROUS_ACTIVITY_PATTERNS = listOf( + // VPN-Settings + VPN-Profil-Dialoge + "VpnSettings", + "VpnConfig", + "VpnDialog", + "ManageDialog", // com.android.vpndialogs.ManageDialog + "ConfirmAddOns", // com.android.vpndialogs.ConfirmAddOnsActivity + + // App-Deinstallieren-Dialoge + App-Info-Pages + "Uninstaller", // com.android.packageinstaller.UninstallerActivity + "InstalledAppDetails", // App-Info-Page (kann zu uninstall führen) + "ApplicationDetails", // Variations + + // Accessibility-Settings (paradox: A11y würde sich selbst aushebeln) + "AccessibilitySettings", + "AccessibilityDetails", + "InstalledServiceActivity", // Samsung + "AccessibilityShortcut", + ) + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/DnsFilter.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/DnsFilter.kt new file mode 100644 index 0000000..95897cd --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/DnsFilter.kt @@ -0,0 +1,225 @@ +package expo.modules.rebreakprotection.filter + +import android.util.Log +import java.io.FileOutputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.util.concurrent.Executors +import java.util.concurrent.ThreadPoolExecutor + +/** + * IPv4 + UDP + DNS Packet-Parsing und Response-Builder. + * + * Verarbeitungsmodell: + * - Block-Queries → sofort NXDOMAIN-Response (synchron, returns ByteArray) + * - Allow-Queries → async Thread-Pool: forwarded an Upstream, schreibt Response + * direkt ins TUN (synchronized auf outputLock). + * + * Damit blockt EINE langsame Forward-Query nicht den ganzen Filter-Loop. + */ +object DnsFilter { + + private const val TAG = "RebreakDnsFilter" + private const val UPSTREAM_DNS = "1.1.1.1" + private const val UPSTREAM_PORT = 53 + private const val DNS_PORT = 53 + + private val forwardPool: ThreadPoolExecutor = + Executors.newCachedThreadPool() as ThreadPoolExecutor + + /** + * Verarbeitet ein Paket vom TUN. + * - Bei Block: schreibt synchron NXDOMAIN-Response in `output`. + * - Bei Allow: fired Thread für Forward + Async-Write. + * - Bei Drop (kein DNS, IPv6, etc.): nichts. + * + * Caller (VpnService) muss vor jedem `output.write(...)` den `outputLock` + * synchronisieren — wir tun das hier intern. + */ + fun process( + packet: ByteArray, + length: Int, + hashList: HashList, + output: FileOutputStream, + outputLock: Any, + protectSocket: (DatagramSocket) -> Boolean, + ) { + if (length < 28) return // min IPv4(20) + UDP(8) + + // IPv4-Check + val version = (packet[0].toInt() ushr 4) and 0xF + if (version != 4) return + + val ihl = (packet[0].toInt() and 0xF) * 4 + if (length < ihl + 8) return + + // UDP only + val protocol = packet[9].toInt() and 0xFF + if (protocol != 17) return + + // UDP-Header + val udpStart = ihl + val srcPort = ((packet[udpStart].toInt() and 0xFF) shl 8) or (packet[udpStart + 1].toInt() and 0xFF) + val dstPort = ((packet[udpStart + 2].toInt() and 0xFF) shl 8) or (packet[udpStart + 3].toInt() and 0xFF) + if (dstPort != DNS_PORT) return + + val dnsStart = udpStart + 8 + val dnsLen = length - dnsStart + if (dnsLen < 12) return + + val domain = parseQname(packet, dnsStart + 12) ?: return + + val srcIp = packet.copyOfRange(12, 16) + val dstIp = packet.copyOfRange(16, 20) + + if (hashList.matchesAnySuffix(domain)) { + Log.i(TAG, "BLOCKED: $domain") + val resp = buildNxDomainResponse(packet, length, ihl, srcIp, dstIp, srcPort, dstPort) + writeSynchronized(output, outputLock, resp) + return + } + + // Async forward — kopiere Buffer-Slice damit nicht überschrieben wird beim + // nächsten read() im VpnService-Loop + val packetCopy = packet.copyOf(length) + forwardPool.execute { + forwardAndWrite( + packetCopy, length, ihl, srcIp, dstIp, srcPort, dstPort, + output, outputLock, protectSocket, domain, + ) + } + } + + private fun parseQname(packet: ByteArray, start: Int): String? { + val sb = StringBuilder() + var pos = start + while (pos < packet.size) { + val len = packet[pos].toInt() and 0xFF + if (len == 0) break + if ((len and 0xC0) != 0) return null + pos++ + if (pos + len > packet.size) return null + if (sb.isNotEmpty()) sb.append('.') + sb.append(String(packet, pos, len, Charsets.US_ASCII)) + pos += len + } + return if (sb.isEmpty()) null else sb.toString() + } + + private fun buildNxDomainResponse( + request: ByteArray, + length: Int, + ihl: Int, + srcIp: ByteArray, + dstIp: ByteArray, + srcPort: Int, + dstPort: Int, + ): ByteArray { + val resp = request.copyOf(length) + System.arraycopy(dstIp, 0, resp, 12, 4) + System.arraycopy(srcIp, 0, resp, 16, 4) + resp[10] = 0; resp[11] = 0 + + val udpStart = ihl + resp[udpStart] = (dstPort ushr 8).toByte() + resp[udpStart + 1] = (dstPort and 0xFF).toByte() + resp[udpStart + 2] = (srcPort ushr 8).toByte() + resp[udpStart + 3] = (srcPort and 0xFF).toByte() + resp[udpStart + 6] = 0; resp[udpStart + 7] = 0 + + val dnsStart = udpStart + 8 + resp[dnsStart + 2] = 0x81.toByte() + resp[dnsStart + 3] = 0x83.toByte() // RCODE=3 (NXDOMAIN) + for (i in 6..11) resp[dnsStart + i] = 0 + + return recomputeIpChecksum(resp, ihl) + } + + private fun forwardAndWrite( + request: ByteArray, + length: Int, + ihl: Int, + srcIp: ByteArray, + dstIp: ByteArray, + srcPort: Int, + dstPort: Int, + output: FileOutputStream, + outputLock: Any, + protectSocket: (DatagramSocket) -> Boolean, + domainForLog: String, + ) { + var sock: DatagramSocket? = null + try { + sock = DatagramSocket() + if (!protectSocket(sock)) { + Log.w(TAG, "protect() failed for $domainForLog — DNS forward would loop, dropping") + return + } + sock.soTimeout = 4000 + + val dnsStart = ihl + 8 + val dnsLen = length - dnsStart + val query = request.copyOfRange(dnsStart, dnsStart + dnsLen) + + val upstreamAddr = InetAddress.getByName(UPSTREAM_DNS) + sock.send(DatagramPacket(query, query.size, upstreamAddr, UPSTREAM_PORT)) + + val buf = ByteArray(4096) + val pkt = DatagramPacket(buf, buf.size) + sock.receive(pkt) + + val totalLen = ihl + 8 + pkt.length + val resp = ByteArray(totalLen) + System.arraycopy(request, 0, resp, 0, ihl) + resp[2] = (totalLen ushr 8).toByte() + resp[3] = (totalLen and 0xFF).toByte() + System.arraycopy(dstIp, 0, resp, 12, 4) + System.arraycopy(srcIp, 0, resp, 16, 4) + resp[10] = 0; resp[11] = 0 + + val udpStart = ihl + resp[udpStart] = (dstPort ushr 8).toByte() + resp[udpStart + 1] = (dstPort and 0xFF).toByte() + resp[udpStart + 2] = (srcPort ushr 8).toByte() + resp[udpStart + 3] = (srcPort and 0xFF).toByte() + val udpLen = 8 + pkt.length + resp[udpStart + 4] = (udpLen ushr 8).toByte() + resp[udpStart + 5] = (udpLen and 0xFF).toByte() + resp[udpStart + 6] = 0; resp[udpStart + 7] = 0 + + System.arraycopy(pkt.data, 0, resp, ihl + 8, pkt.length) + recomputeIpChecksum(resp, ihl) + + writeSynchronized(output, outputLock, resp) + } catch (e: Exception) { + Log.w(TAG, "forward failed for $domainForLog: ${e.javaClass.simpleName}: ${e.message}") + } finally { + try { sock?.close() } catch (_: Exception) {} + } + } + + private fun writeSynchronized(output: FileOutputStream, outputLock: Any, data: ByteArray) { + synchronized(outputLock) { + try { + output.write(data) + } catch (e: Exception) { + Log.w(TAG, "TUN write failed: ${e.message}") + } + } + } + + private fun recomputeIpChecksum(packet: ByteArray, ihl: Int): ByteArray { + packet[10] = 0; packet[11] = 0 + var sum = 0L + for (i in 0 until ihl step 2) { + val word = ((packet[i].toInt() and 0xFF) shl 8) or (packet[i + 1].toInt() and 0xFF) + sum += word + } + while (sum ushr 16 != 0L) sum = (sum and 0xFFFF) + (sum ushr 16) + val checksum = (sum.inv() and 0xFFFF).toInt() + packet[10] = (checksum ushr 8).toByte() + packet[11] = (checksum and 0xFF).toByte() + return packet + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/DomainHasher.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/DomainHasher.kt new file mode 100644 index 0000000..f710e25 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/DomainHasher.kt @@ -0,0 +1,43 @@ +package expo.modules.rebreakprotection.filter + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.security.MessageDigest + +/** + * Domain-Hashing — IDENTISCH zu `server/utils/domainHash.ts` und iOS' `DomainHasher.swift`. + * + * Produziert die ersten 8 Bytes des SHA-256 als big-endian UInt64. + * Server liefert die Hash-Liste in dieser Form, iOS und Android lesen sie 1:1. + * + * Privacy: arbeitet rein lokal — keine Klartext-Domain verlässt das Gerät. + */ +object DomainHasher { + + /** + * Normalisiert einen Hostname analog zu Server + iOS. + * - lowercase + * - http(s):// strippen + * - Pfad/Query/Anchor (alles ab erstem `/`) abschneiden + * - leading "www." entfernen + */ + fun normalize(host: String): String { + var h = host.trim().lowercase() + if (h.startsWith("https://")) h = h.substring(8) + else if (h.startsWith("http://")) h = h.substring(7) + val slash = h.indexOf('/') + if (slash >= 0) h = h.substring(0, slash) + if (h.startsWith("www.")) h = h.substring(4) + return h + } + + /** + * SHA-256(normalize(host)).first(8) als big-endian UInt64. + * Returnt Long (Kotlin hat kein UInt64) — bit-pattern identisch zu UInt64-BE. + */ + fun hash(host: String): Long { + val normalized = normalize(host) + val digest = MessageDigest.getInstance("SHA-256").digest(normalized.toByteArray(Charsets.UTF_8)) + return ByteBuffer.wrap(digest, 0, 8).order(ByteOrder.BIG_ENDIAN).long + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/HashList.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/HashList.kt new file mode 100644 index 0000000..e2bcc82 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/filter/HashList.kt @@ -0,0 +1,104 @@ +package expo.modules.rebreakprotection.filter + +import java.io.File +import java.io.RandomAccessFile +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.FileChannel +import java.util.concurrent.atomic.AtomicReference + +/** + * Memory-mapped binary Hash-Liste — sortierte 64-bit big-endian UInt64s. + * + * Datei-Format identisch zu iOS und Server: jeder Hash 8 Bytes, sortiert. + * Reader macht Binary-Search → O(log n) lookup. + * + * Reload-Strategie: bei jedem load() wird neu gemappt. Aufrufer muss load() + * triggern (z.B. nach syncBlocklist). + * + * Thread-safe: AtomicReference auf das aktuelle Snapshot — Lesen ist lock-free. + */ +class HashList(private val file: File) { + + private data class Snapshot(val buffer: ByteBuffer?, val count: Int) + + private val state = AtomicReference(Snapshot(null, 0)) + + /** + * Lädt blocklist.bin neu (mmap). Idempotent — kann zur Laufzeit mehrfach + * aufgerufen werden, alte mmap wird vom GC eingesammelt. + */ + @Synchronized + fun load() { + if (!file.exists()) { + state.set(Snapshot(null, 0)) + return + } + try { + RandomAccessFile(file, "r").use { raf -> + val ch = raf.channel + val size = ch.size() + if (size <= 0 || size % 8L != 0L) { + state.set(Snapshot(null, 0)) + return + } + val buf = ch.map(FileChannel.MapMode.READ_ONLY, 0, size) + .order(ByteOrder.BIG_ENDIAN) + state.set(Snapshot(buf, (size / 8L).toInt())) + } + } catch (e: Exception) { + state.set(Snapshot(null, 0)) + } + } + + /** Aktuelle Anzahl der Hashes (zum Logging). */ + fun count(): Int = state.get().count + + /** True wenn die Datei aktuell als blocklist gemmapt ist. */ + fun isLoaded(): Boolean = state.get().buffer != null + + /** + * Binary-Search auf sortierten 64-bit Hashes. O(log n). + * Long-Vergleich ist hier UNSIGNED-semantisch — die Datei ist als unsigned + * sortiert. Wir machen unsigned-Comparison via Long.compareUnsigned. + */ + fun contains(hash: Long): Boolean { + val snap = state.get() + val buf = snap.buffer ?: return false + val count = snap.count + if (count == 0) return false + + var lo = 0 + var hi = count - 1 + while (lo <= hi) { + val mid = (lo + hi) ushr 1 + val v = buf.getLong(mid * 8) + val cmp = java.lang.Long.compareUnsigned(v, hash) + when { + cmp == 0 -> return true + cmp < 0 -> lo = mid + 1 + else -> hi = mid - 1 + } + } + return false + } + + /** + * Subdomain-Match (max 5 Iterationen, parallel zu iOS). + * Für `evil.shop.bet365.com` testen wir der Reihe nach: + * evil.shop.bet365.com → shop.bet365.com → bet365.com → com (TLD wird nie matchen). + */ + fun matchesAnySuffix(host: String): Boolean { + var current = DomainHasher.normalize(host) + var iter = 0 + while (iter < 5) { + if (contains(DomainHasher.hash(current))) return true + val dot = current.indexOf('.') + if (dot < 0) return false + current = current.substring(dot + 1) + if (!current.contains('.')) return false + iter++ + } + return false + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnService.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnService.kt new file mode 100644 index 0000000..4d1ec96 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/vpn/RebreakVpnService.kt @@ -0,0 +1,233 @@ +package expo.modules.rebreakprotection.vpn + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.os.ParcelFileDescriptor +import android.util.Log +import androidx.core.app.NotificationCompat +import expo.modules.rebreakprotection.filter.DnsFilter +import expo.modules.rebreakprotection.filter.HashList +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.net.DatagramSocket + +/** + * VpnService-Subclass — Pendant zum iOS NEFilterDataProvider. + * + * Architektur: + * - TUN-Interface mit kleiner virtueller Subnet (10.0.0.0/24) + * - System-DNS auf 10.0.0.1 → DNS-Queries kommen ins TUN + * - Worker-Thread liest Pakete, DnsFilter prüft + antwortet + * - Foreground-Service (mit Notification) damit Android den Service nicht + * aggressiv killt + * + * Service-Lifecycle: + * - START_FILTER → onStartCommand → startVpn → return START_STICKY + * - STOP_FILTER → onStartCommand → stopVpn → stopSelf + * - User schaltet VPN in System-Settings ab → onRevoke → cleanup + */ +class RebreakVpnService : VpnService() { + + private var tun: ParcelFileDescriptor? = null + private var workerThread: Thread? = null + @Volatile private var running = false + private lateinit var hashList: HashList + + override fun onCreate() { + super.onCreate() + hashList = HashList(File(applicationContext.filesDir, BLOCKLIST_FILENAME)) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_STOP -> { + stopVpn() + stopSelf() + return START_NOT_STICKY + } + ACTION_RELOAD -> { + hashList.load() + Log.i(TAG, "blocklist reloaded — ${hashList.count()} hashes") + return START_STICKY + } + else -> { + startForeground(NOTIF_ID, buildNotification()) + hashList.load() + Log.i(TAG, "blocklist loaded — ${hashList.count()} hashes") + startVpn() + return START_STICKY + } + } + } + + private fun startVpn() { + if (running) return + try { + val builder = Builder() + .addAddress(LOCAL_TUN_ADDR, 24) + .addRoute(VIRTUAL_DNS_ADDR, 32) // nur DNS-Queries an unsere virtuelle DNS-IP routen + .addDnsServer(VIRTUAL_DNS_ADDR) + .setSession("Rebreak Filter") + .setBlocking(true) + + // Eigene App vom TUN ausnehmen — sonst routen unsere eigenen + // Backend-Calls durch unser TUN und können das Internet nicht erreichen + try { + builder.addDisallowedApplication(packageName) + } catch (e: Exception) { + Log.w(TAG, "addDisallowedApplication failed: ${e.message}") + } + + tun = builder.establish() ?: run { + Log.e(TAG, "TUN establish() returned null — wahrscheinlich VpnService-Permission entzogen") + stopSelf() + return + } + running = true + isRunning = true + // Prefs-Flag synchronisieren — auch wenn der VpnService extern gestartet + // wurde (z.B. wenn User in System-Settings VPN toggle on). Sonst denkt + // unser Plugin "VPN ist aus" obwohl Tunnel aktiv ist. + setEnabledFlag(true) + workerThread = Thread { runFilterLoop() }.also { it.start() } + Log.i(TAG, "VPN established — TUN-Interface aktiv") + } catch (e: Exception) { + Log.e(TAG, "VPN start failed: ${e.message}", e) + stopSelf() + } + } + + private fun stopVpn() { + running = false + isRunning = false + try { tun?.close() } catch (_: Exception) {} + tun = null + try { workerThread?.interrupt() } catch (_: Exception) {} + workerThread = null + Log.i(TAG, "VPN stopped") + } + + private fun runFilterLoop() { + val tunFd = tun?.fileDescriptor ?: return + val input = FileInputStream(tunFd) + val output = FileOutputStream(tunFd) + val outputLock = Any() + val buf = ByteArray(32_767) + + while (running && !Thread.currentThread().isInterrupted) { + try { + val len = input.read(buf) + if (len <= 0) continue + + // process() schreibt selbst ins TUN — entweder synchron (Block→ + // NXDOMAIN) oder async via Thread-Pool (Forward→Upstream). + DnsFilter.process( + packet = buf, + length = len, + hashList = hashList, + output = output, + outputLock = outputLock, + protectSocket = { sock: DatagramSocket -> protect(sock) }, + ) + } catch (e: Exception) { + if (running) { + Log.w(TAG, "filter-loop error: ${e.message}") + } + try { Thread.sleep(50) } catch (_: InterruptedException) { break } + } + } + } + + override fun onRevoke() { + Log.i(TAG, "onRevoke: User hat VPN in System-Settings deaktiviert") + clearEnabledFlag() + stopVpn() + stopSelf() + super.onRevoke() + } + + override fun onDestroy() { + // KEIN clearEnabledFlag hier — onDestroy feuert auch wenn System uns + // wegen Low-Memory killt. Wir wollen dann beim nächsten Start wieder + // auto-restart (START_STICKY), nicht "user-hat-disabled" signalisieren. + // Nur onRevoke (= explizit User-Toggle in System-Settings) clear'd. + stopVpn() + super.onDestroy() + } + + /** Persistierte Flag (gleicher Storage wie der Plugin) clearen — nur + * bei explizitem User-Revoke (System-Settings-Toggle aus). */ + private fun clearEnabledFlag() = setEnabledFlag(false) + + /** Setzt die Plugin-Prefs-Flag synchron mit dem tatsächlichen Service-State. */ + private fun setEnabledFlag(value: Boolean) { + try { + val prefs = applicationContext.getSharedPreferences( + "rebreak_filter_prefs", + Context.MODE_PRIVATE, + ) + prefs.edit().putBoolean("filter_enabled", value).apply() + } catch (e: Exception) { + Log.w(TAG, "setEnabledFlag($value) failed: ${e.message}") + } + } + + private fun buildNotification(): Notification { + val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + CHANNEL_ID, + "Rebreak-Schutz", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Aktiver Schutz vor Glücksspielseiten" + setShowBadge(false) + } + nm.createNotificationChannel(channel) + } + + val launchIntent = packageManager.getLaunchIntentForPackage(packageName) + val pendingIntent = launchIntent?.let { + PendingIntent.getActivity( + this, 0, it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + + return NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Rebreak schützt dich") + .setContentText("Glücksspielseiten werden blockiert") + // Module-Context: kein App-eigenes Drawable verfügbar. + // System-Drawable als Fallback bis wir ein eigenes Icon im Module bündeln. + .setSmallIcon(android.R.drawable.ic_lock_lock) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentIntent(pendingIntent) + .build() + } + + companion object { + private const val TAG = "RebreakVpn" + private const val CHANNEL_ID = "rebreak_filter_channel" + private const val NOTIF_ID = 4711 + + const val LOCAL_TUN_ADDR = "10.0.0.2" + const val VIRTUAL_DNS_ADDR = "10.0.0.1" + const val BLOCKLIST_FILENAME = "blocklist.bin" + + const val ACTION_STOP = "expo.modules.rebreakprotection.action.STOP" + const val ACTION_START = "expo.modules.rebreakprotection.action.START" + const val ACTION_RELOAD = "expo.modules.rebreakprotection.action.RELOAD" + + /** Process-lokale Live-Flag — ist der TUN gerade etabliert? + * Plugin nutzt das als zweiten Indikator (zusätzlich zur Prefs-Flag). */ + @Volatile var isRunning: Boolean = false + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..91b8d64 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values-en/strings.xml @@ -0,0 +1,5 @@ + + + Rebreak filters URLs in your browser to block gambling sites — even when the VPN is off. Without this permission the app cannot fully maintain protection. + Filters gambling sites in the browser + diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..ecba9d5 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Rebreak filtert URLs in deinem Browser, um Glücksspielseiten zu blockieren — auch wenn das VPN nicht aktiv ist. Ohne diese Berechtigung kann die App ihren Schutz nicht vollständig aufrechterhalten. + Filtert Glücksspielseiten im Browser + diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/xml/accessibility_service_config.xml b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/xml/accessibility_service_config.xml new file mode 100644 index 0000000..a5c6cf2 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/res/xml/accessibility_service_config.xml @@ -0,0 +1,22 @@ + + + diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/test/java/expo/modules/rebreakprotection/filter/DomainHasherTest.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/test/java/expo/modules/rebreakprotection/filter/DomainHasherTest.kt new file mode 100644 index 0000000..c79afd0 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/test/java/expo/modules/rebreakprotection/filter/DomainHasherTest.kt @@ -0,0 +1,89 @@ +package expo.modules.rebreakprotection.filter + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Verifiziert dass `DomainHasher` byte-genau die gleichen Hashes produziert wie: + * - Server (`apps/rebreak/server/utils/domainHash.ts`) + * - iOS (`apps/rebreak/ios/App/RebreakURLFilter/FilterControlProvider.swift`) + * + * Fixtures generiert via Node: + * const { createHash } = require("node:crypto"); + * createHash("sha256").update(normalize("bet365.com")).digest().readBigUInt64BE(0); + * + * Wenn diese Tests rot werden, ist Cross-Platform-Sync kaputt — der Filter + * würde Hashes vom Server nicht mehr finden. + */ +class DomainHasherTest { + + private fun u(decimal: String): Long = java.lang.Long.parseUnsignedLong(decimal) + + @Test + fun `hash bet365_com matches server fixture`() { + assertEquals(u("16386124564103164180"), DomainHasher.hash("bet365.com")) + } + + @Test + fun `hash is case-insensitive`() { + assertEquals( + DomainHasher.hash("bet365.com"), + DomainHasher.hash("BET365.COM") + ) + } + + @Test + fun `hash strips https protocol`() { + assertEquals( + DomainHasher.hash("bet365.com"), + DomainHasher.hash("https://bet365.com") + ) + } + + @Test + fun `hash strips path and www-prefix`() { + assertEquals( + DomainHasher.hash("bet365.com"), + DomainHasher.hash("https://www.bet365.com/sport/football") + ) + } + + @Test + fun `tipico_de matches server fixture`() { + assertEquals(u("15748628718838809648"), DomainHasher.hash("tipico.de")) + } + + @Test + fun `pokerstars_com matches server fixture`() { + assertEquals(u("10067937357410366326"), DomainHasher.hash("pokerstars.com")) + } + + @Test + fun `casino_com matches server fixture`() { + assertEquals(u("6264962142342037058"), DomainHasher.hash("casino.com")) + } + + @Test + fun `evil subdomain has different hash than parent`() { + // Subdomain-Match passiert in DnsFilter, nicht im Hasher. + // Hier nur prüfen dass evil.bet365.com !== bet365.com: + val parent = DomainHasher.hash("bet365.com") + val sub = DomainHasher.hash("evil.bet365.com") + assert(parent != sub) + assertEquals(u("3035823198435943299"), sub) + } + + @Test + fun `whitespace is trimmed`() { + assertEquals( + DomainHasher.hash("bet365.com"), + DomainHasher.hash(" bet365.com ") + ) + } + + @Test + fun `non-blocked domain has its own hash`() { + assertEquals(u("9120123751810482011"), DomainHasher.hash("rebreak.org")) + assertEquals(u("15333025010904606490"), DomainHasher.hash("google.com")) + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtection.podspec b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtection.podspec new file mode 100644 index 0000000..b2d8281 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtection.podspec @@ -0,0 +1,26 @@ +Pod::Spec.new do |s| + s.name = 'RebreakProtection' + s.version = '0.1.0' + s.summary = 'ReBreak unified protection — NEFilter + Family Controls.' + s.description = 'Single native module that wraps NEFilterDataProvider + AuthorizationCenter + ManagedSettings for the ReBreak gambling-recovery app.' + s.author = 'ReBreak' + s.homepage = 'https://rebreak.org' + s.license = { :type => 'Proprietary' } + s.platforms = { :ios => '15.1', :tvos => '15.1' } + s.swift_version = '5.9' + s.source = { :git => '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule', + } + + # NUR Top-Level-Sources ins Hauptmodul. RebreakURLFilter/ enthält Sources + # für die NetworkExtension — die werden vom Config-Plugin in ein separates + # Xcode-Target gepackt, NICHT in die Hauptmodul-Lib. + s.source_files = '*.{h,m,mm,swift,hpp,cpp}' + s.exclude_files = 'RebreakURLFilter/**/*' +end diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift new file mode 100644 index 0000000..9421b9f --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift @@ -0,0 +1,569 @@ +import ExpoModulesCore +import Foundation +import NetworkExtension +import FamilyControls +import ManagedSettings +import WebKit +import UIKit +import UserNotifications + +// ─── Konstanten ─────────────────────────────────────────────────────────────── + +private let APP_GROUP = "group.org.rebreak.app" +private let BLOCKLIST_FILENAME = "blocklist.bin" +private let ETAG_KEY = "blocklist_etag" +private let LAST_SYNC_KEY = "blocklist_last_sync_at" +private let DARWIN_NOTIF = "rebreak.blocklist.updated" +private let MS_STORE_NAME = "rebreak.shield" + +// ─── Shared Log-Store ───────────────────────────────────────────────────────── + +fileprivate enum SharedLogStore { + static let logKey = "url_filter_logs" + static let maxEntries = 200 + + static func append(_ message: String) { + NSLog("REBREAK_PROTECTION %@", message) + guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return } + let timestamp = ISO8601DateFormatter().string(from: Date()) + let entry = "[\(timestamp)] \(message)" + var logs = defaults.stringArray(forKey: logKey) ?? [] + logs.append(entry) + if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) } + defaults.set(logs, forKey: logKey) + } +} + +// ─── Module ─────────────────────────────────────────────────────────────────── + +/// Vereinheitlichtes ReBreak-Schutz-Modul. +/// +/// Wraps: +/// - NEFilterDataProvider Konfiguration (URL-Filter Layer) +/// - AuthorizationCenter + ManagedSettings (Family Controls — denyAppRemoval) +/// - Blocklist-Sync mit Retry-Backoff +/// - End-to-End Health-Probe via hidden WKWebView +/// +/// Single Source of Truth für Layer-State auf iOS. Cooldown-Logik bleibt +/// JS-seitig (`lib/protection.ts` → Backend-API). +public class RebreakProtectionModule: Module { + + // Health-Probe State (UI-Thread) + private var probeWebView: WKWebView? + private var probeDelegate: HealthProbeDelegate? + + public func definition() -> ModuleDefinition { + Name("RebreakProtection") + + Events("onLayerChange") + + // ───────── activate: Family Controls + NEFilter + denyAppRemoval ───────── + + // ───────── activateUrlFilter: NUR NEFilter ───────── + + AsyncFunction("activateUrlFilter") { () async -> [String: Any] in + var error: String? = nil + var enabled = false + do { + let manager = NEFilterManager.shared() + SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...") + try await manager.loadFromPreferences() + + let config = NEFilterProviderConfiguration() + config.filterBrowsers = true + config.filterSockets = false + manager.providerConfiguration = config + manager.localizedDescription = "Rebreak URL Filter" + manager.isEnabled = true + + SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences (System-Dialog)...") + try await manager.saveToPreferences() + enabled = manager.isEnabled + SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(enabled))") + } catch let e as NSError { + error = "\(e.domain):\(e.code) \(e.localizedDescription)" + SharedLogStore.append("❌ NEFilter enable failed: \(error!)") + } + var result: [String: Any] = ["enabled": enabled] + if let error = error { result["error"] = error } + return result + } + + // ───────── activateFamilyControls: NUR FC + denyAppRemoval ───────── + + AsyncFunction("activateFamilyControls") { () async -> [String: Any] in + var error: String? = nil + var enabled = false + if #available(iOS 16.0, *) { + do { + try await AuthorizationCenter.shared.requestAuthorization(for: .individual) + let authorized = AuthorizationCenter.shared.authorizationStatus == .approved + SharedLogStore.append("✅ FamilyControls authorized (\(authorized))") + if authorized { + let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) + store.application.denyAppRemoval = true + store.application.denyAppInstallation = false + let lockActive = (store.application.denyAppRemoval as? Bool) == true + enabled = lockActive + SharedLogStore.append("🔒 denyAppRemoval = \(lockActive)") + if !lockActive { + error = "denyAppRemoval_not_active" + } + } else { + enabled = false + } + } catch let e as NSError { + error = "\(e.domain):\(e.code) \(e.localizedDescription)" + SharedLogStore.append("❌ FamilyControls auth failed: \(error!)") + } + } else { + error = "iOS 16+ required for FamilyControls" + } + var result: [String: Any] = ["enabled": enabled] + if let error = error { result["error"] = error } + return result + } + + // ───────── activate (legacy, alle Layer in einem Call) ───────── + + AsyncFunction("activate") { () async -> [String: Any] in + var missingLayers: [String] = [] + var errors: [String] = [] + + // 1) Family Controls Authorization (iOS 16+) + var familyControlsApproved = false + if #available(iOS 16.0, *) { + do { + try await AuthorizationCenter.shared.requestAuthorization(for: .individual) + familyControlsApproved = AuthorizationCenter.shared.authorizationStatus == .approved + SharedLogStore.append("✅ FamilyControls authorized") + } catch let error as NSError { + let msg = "FamilyControls auth failed: \(error.domain):\(error.code) \(error.localizedDescription)" + SharedLogStore.append("❌ \(msg)") + errors.append(msg) + } + } else { + errors.append("iOS 16+ required for FamilyControls") + } + if !familyControlsApproved { + missingLayers.append("familyControls") + } + + // 2) NEFilter aktivieren + var urlFilterOn = false + do { + let manager = NEFilterManager.shared() + SharedLogStore.append("📥 [activate] loadFromPreferences...") + try await manager.loadFromPreferences() + + let config = NEFilterProviderConfiguration() + config.filterBrowsers = true + config.filterSockets = false + manager.providerConfiguration = config + manager.localizedDescription = "Rebreak URL Filter" + manager.isEnabled = true + + SharedLogStore.append("💾 [activate] saveToPreferences (System-Dialog)...") + try await manager.saveToPreferences() + urlFilterOn = manager.isEnabled + SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(urlFilterOn))") + } catch let error as NSError { + let msg = "NEFilter enable failed: \(error.domain):\(error.code) \(error.localizedDescription)" + SharedLogStore.append("❌ \(msg)") + errors.append(msg) + } + if !urlFilterOn { + missingLayers.append("urlFilter") + } + + // 3) ManagedSettings denyAppRemoval (nur wenn FamilyControls approved) + if #available(iOS 16.0, *), familyControlsApproved { + let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) + store.application.denyAppRemoval = true + store.application.denyAppInstallation = false + SharedLogStore.append("🔒 denyAppRemoval = true") + } + + return [ + "allLayersOn": missingLayers.isEmpty, + "missingLayers": missingLayers, + // Errors ans JS hochbubblen damit das UI wirklich anzeigen kann was failt + "errors": errors, + ] + } + + // ───────── disable: NUR aufrufen wenn JS-Cooldown abgelaufen! ───────── + + AsyncFunction("disable") { () async -> [String: Any] in + // NEFilter + do { + let manager = NEFilterManager.shared() + try await manager.loadFromPreferences() + try await manager.removeFromPreferences() + SharedLogStore.append("✅ NEFilter disabled") + } catch { + SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)") + } + + // ManagedSettings (löst denyAppRemoval) + if #available(iOS 16.0, *) { + let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) + store.clearAllSettings() + SharedLogStore.append("🔓 ManagedSettings cleared") + } + + // Blocklist-Datei löschen + Self.clearBlocklistFile() + + return ["allLayersOff": true] + } + + // ───────── getDeviceState: aktueller Status aller Layer ───────── + + AsyncFunction("getDeviceState") { () async -> [String: Any] in + // NEFilter + var urlFilter = false + do { + let manager = NEFilterManager.shared() + try await manager.loadFromPreferences() + urlFilter = manager.isEnabled + } catch { + // ignore + } + + // FamilyControls + var familyControls = false + var appDeletionLock = false + if #available(iOS 16.0, *) { + familyControls = AuthorizationCenter.shared.authorizationStatus == .approved + let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME)) + appDeletionLock = (store.application.denyAppRemoval as? Bool) == true + } + + let count = Self.currentHashCount() + let lastSync = UserDefaults(suiteName: APP_GROUP)?.string(forKey: LAST_SYNC_KEY) + + return [ + "urlFilter": urlFilter, + "familyControls": familyControls, + "appDeletionLock": appDeletionLock, + "blocklistCount": count, + "blocklistLastSyncAt": lastSync ?? NSNull(), + ] + } + + // ───────── syncBlocklist: download + atomic write + DarwinNotif ───────── + + AsyncFunction("syncBlocklist") { (opts: [String: String]) async throws -> [String: Any] in + guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else { + throw NSError( + domain: "RebreakProtection", code: 400, + userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"] + ) + } + guard let endpoint = URL(string: "\(baseURL)/api/url-filter/blocklist.bin") else { + throw NSError( + domain: "RebreakProtection", code: 400, + userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"] + ) + } + + // Retry mit Backoff (1s, 2s) — fängt iOS NECP-Race nach saveToPreferences + let maxAttempts = 3 + var attempt = 1 + var lastError: Error? + + while attempt <= maxAttempts { + do { + var request = URLRequest(url: endpoint) + request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") + if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: ETAG_KEY) { + request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match") + } + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw NSError( + domain: "RebreakProtection", code: 500, + userInfo: [NSLocalizedDescriptionKey: "invalid response"] + ) + } + + if httpResponse.statusCode == 304 { + SharedLogStore.append("📡 sync 304 (cached)") + return ["updated": false, "count": Self.currentHashCount()] + } + + guard httpResponse.statusCode == 200 else { + throw NSError( + domain: "RebreakProtection", code: httpResponse.statusCode, + userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"] + ) + } + + guard let appGroupURL = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) + else { + throw NSError( + domain: "RebreakProtection", code: 500, + userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"] + ) + } + + let finalURL = appGroupURL.appendingPathComponent(BLOCKLIST_FILENAME) + let tmpURL = finalURL.appendingPathExtension("tmp") + try data.write(to: tmpURL, options: .atomic) + + // DiGA-Hardening + try? (tmpURL as NSURL).setResourceValue( + URLFileProtection.complete, + forKey: .fileProtectionKey + ) + var mut = tmpURL + var rv = URLResourceValues() + rv.isExcludedFromBackup = true + try? mut.setResourceValues(rv) + + // Atomic replace + _ = try? FileManager.default.removeItem(at: finalURL) + try FileManager.default.moveItem(at: tmpURL, to: finalURL) + + // ETag + lastSync persistieren + let defaults = UserDefaults(suiteName: APP_GROUP) + if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") { + defaults?.set(newEtag, forKey: ETAG_KEY) + } + defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: LAST_SYNC_KEY) + + // Extension benachrichtigen (cross-process) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(DARWIN_NOTIF as CFString), + nil, nil, true + ) + + let count = data.count / 8 + let plan = httpResponse.value(forHTTPHeaderField: "x-rebreak-plan") ?? "" + SharedLogStore.append("📡 sync ok: \(count) hashes (plan=\(plan))") + + var result: [String: Any] = ["updated": true, "count": count] + if !plan.isEmpty { result["plan"] = plan } + return result + + } catch { + lastError = error + let nsError = error as NSError + let isTransient = nsError.domain == NSURLErrorDomain && ( + nsError.code == NSURLErrorNotConnectedToInternet || + nsError.code == NSURLErrorCannotConnectToHost || + nsError.code == NSURLErrorTimedOut || + nsError.code == NSURLErrorNetworkConnectionLost || + nsError.code == NSURLErrorCannotFindHost + ) + if isTransient && attempt < maxAttempts { + let delaySec = UInt64(attempt) + SharedLogStore.append("⏳ sync transient \(nsError.code), retry in \(delaySec)s") + try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000) + attempt += 1 + continue + } + SharedLogStore.append("❌ sync failed: \(error.localizedDescription)") + throw error + } + } + + throw lastError ?? NSError( + domain: "RebreakProtection", code: 500, + userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"] + ) + } + + // ───────── runHealthProbe: hidden WKWebView gegen bet365 ───────── + + AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in + let target = (opts?["target"] as? String) ?? "https://bet365.com" + let timeoutSec = (opts?["timeoutSeconds"] as? Double) ?? 5.0 + guard let url = URL(string: target) else { + return [ + "outcome": "offline", + "reason": "invalid_target", + "durationMs": 0, + "target": target, + ] + } + + return await withCheckedContinuation { continuation in + DispatchQueue.main.async { [weak self] in + guard let self = self else { + continuation.resume(returning: [ + "outcome": "offline", + "reason": "module_gone", + "durationMs": 0, + "target": target, + ]) + return + } + + // Vorherige Probe abbrechen + self.probeWebView?.stopLoading() + self.probeWebView?.navigationDelegate = nil + self.probeWebView = nil + self.probeDelegate = nil + + let config = WKWebViewConfiguration() + config.websiteDataStore = .nonPersistent() + let webView = WKWebView( + frame: CGRect(x: 0, y: 0, width: 1, height: 1), + configuration: config + ) + + let delegate = HealthProbeDelegate(timeoutSeconds: timeoutSec) { [weak self] result in + self?.probeWebView?.stopLoading() + self?.probeWebView?.navigationDelegate = nil + self?.probeWebView = nil + self?.probeDelegate = nil + + switch result { + case .blocked(let reason, let durationMs): + SharedLogStore.append("🛡️ probe BLOCKED: \(target) — \(reason) — \(durationMs)ms") + continuation.resume(returning: [ + "outcome": "blocked", "reason": reason, + "durationMs": durationMs, "target": target, + ]) + case .loaded(let durationMs): + SharedLogStore.append("🚨 probe LOADED \(target) (filter DEAD) — \(durationMs)ms") + continuation.resume(returning: [ + "outcome": "loaded", "reason": "page_loaded", + "durationMs": durationMs, "target": target, + ]) + case .offline(let reason): + continuation.resume(returning: [ + "outcome": "offline", "reason": reason, + "durationMs": 0, "target": target, + ]) + case .timeout: + continuation.resume(returning: [ + "outcome": "timeout", "reason": "no_resolution", + "durationMs": Int(timeoutSec * 1000), "target": target, + ]) + } + } + + self.probeWebView = webView + self.probeDelegate = delegate + webView.navigationDelegate = delegate + var request = URLRequest(url: url) + request.timeoutInterval = timeoutSec + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + webView.load(request) + } + } + } + + // ───────── openSystemSettings ───────── + + AsyncFunction("openSystemSettings") { (target: String?) -> Void in + // Auf iOS gibt's nur einen single-entry-point (Settings-App). Spezifische + // Tabs (Screen Time / Notifications) öffnen sich nicht zuverlässig per + // URL-Scheme — Apple deny'd das seit iOS 13. Wir öffnen die App-Settings + // unserer App; der User navigiert von dort weiter. + DispatchQueue.main.async { + if let url = URL(string: UIApplication.openSettingsURLString), + UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } + } + + // ─── Helpers ──────────────────────────────────────────────────────────────── + + private static func currentHashCount() -> Int { + guard let url = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)? + .appendingPathComponent(BLOCKLIST_FILENAME), + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let size = attrs[.size] as? Int + else { return 0 } + return size / 8 + } + + private static func clearBlocklistFile() { + if let url = FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)? + .appendingPathComponent(BLOCKLIST_FILENAME) { + try? FileManager.default.removeItem(at: url) + } + UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: ETAG_KEY) + UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: LAST_SYNC_KEY) + CFNotificationCenterPostNotification( + CFNotificationCenterGetDarwinNotifyCenter(), + CFNotificationName(DARWIN_NOTIF as CFString), + nil, nil, true + ) + } +} + +// ─── HealthProbeDelegate ────────────────────────────────────────────────────── + +private final class HealthProbeDelegate: NSObject, WKNavigationDelegate { + enum Outcome { + case blocked(reason: String, durationMs: Int) + case loaded(durationMs: Int) + case offline(reason: String) + case timeout + } + + private let onComplete: (Outcome) -> Void + private let startTime: Date + private var resolved = false + private var timeoutItem: DispatchWorkItem? + + init(timeoutSeconds: Double, onComplete: @escaping (Outcome) -> Void) { + self.onComplete = onComplete + self.startTime = Date() + super.init() + let item = DispatchWorkItem { [weak self] in self?.resolveOnce(.timeout) } + self.timeoutItem = item + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: item) + } + + private func resolveOnce(_ outcome: Outcome) { + if resolved { return } + resolved = true + timeoutItem?.cancel() + timeoutItem = nil + onComplete(outcome) + } + + private func durationMs() -> Int { + return Int(Date().timeIntervalSince(startTime) * 1000) + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + resolveOnce(.loaded(durationMs: durationMs())) + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + let nsError = error as NSError + let reasonStr = "\(nsError.domain):\(nsError.code)" + if nsError.domain == NSURLErrorDomain && + (nsError.code == NSURLErrorNotConnectedToInternet || + nsError.code == NSURLErrorCannotFindHost || + nsError.code == NSURLErrorTimedOut) { + resolveOnce(.offline(reason: reasonStr)) + } else { + resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs())) + } + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + let nsError = error as NSError + let reasonStr = "\(nsError.domain):\(nsError.code)" + if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet { + resolveOnce(.offline(reason: reasonStr)) + } else { + resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs())) + } + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/FilterControlProvider.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/FilterControlProvider.swift new file mode 100644 index 0000000..2b8a12b --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/FilterControlProvider.swift @@ -0,0 +1,228 @@ +// +// FilterDataProvider.swift +// RebreakURLFilter — NEFilterDataProvider mit memory-mapped Hash-Liste. +// +// Architektur: +// - Container-App lädt `blocklist.bin` vom Server runter (sortierte 64-bit Hashes) +// - File liegt in App-Group: group.org.rebreak.app/blocklist.bin +// - Diese Extension memory-mapped die Datei und macht Binary-Search pro Flow +// - Memory-Footprint: <1 MB (mmap working-set) +// +// Privacy: +// - Keine Klartext-Domains auf Disk (nur SHA-256/64-bit Hashes) +// - User-Browsing-URL verlässt das Gerät nie +// + +import NetworkExtension +import Foundation +import CryptoKit + +/// Shared Log-Store via App-Group UserDefaults (für Container-App-Debug-Page). +enum SharedLogStore { + static let appGroup = "group.org.rebreak.app" + static let logKey = "url_filter_logs" + static let maxEntries = 200 + + static func append(_ message: String) { + NSLog("REBREAK_URL_FILTER %@", message) + guard let defaults = UserDefaults(suiteName: appGroup) else { return } + let timestamp = ISO8601DateFormatter().string(from: Date()) + let entry = "[\(timestamp)] \(message)" + var logs = defaults.stringArray(forKey: logKey) ?? [] + logs.append(entry) + if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) } + defaults.set(logs, forKey: logKey) + } +} + +/// Domain-Hashing — IDENTISCH zu `server/utils/domainHash.ts`. +/// Server schickt: SHA-256(salt:domain).first(8) als big-endian UInt64. +enum DomainHasher { + static func normalize(_ host: String) -> String { + var h = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if h.hasPrefix("https://") { h = String(h.dropFirst(8)) } + else if h.hasPrefix("http://") { h = String(h.dropFirst(7)) } + if let slash = h.firstIndex(of: "/") { h = String(h[.. UInt64 { + let normalized = normalize(host) + let input = salt.isEmpty ? normalized : "\(salt):\(normalized)" + guard let data = input.data(using: .utf8) else { return 0 } + let digest = SHA256.hash(data: data) + // First 8 bytes as big-endian UInt64 (matches Node's readBigUInt64BE) + var result: UInt64 = 0 + for (i, byte) in digest.prefix(8).enumerated() { + result |= UInt64(byte) << UInt64((7 - i) * 8) + } + return result + } +} + +/// Memory-mapped Binary-Hash-Liste. Lädt die Datei lazy beim ersten Zugriff, +/// reloaded bei DarwinNotification "rebreak.blocklist.updated". +final class HashListMmap { + static let shared = HashListMmap() + + private static let appGroup = "group.org.rebreak.app" + private static let filename = "blocklist.bin" + + private var data: Data? + private var hashCount: Int = 0 + private var loadedMtime: Date? + private var lastMtimeCheck: Date = .distantPast + private let queue = DispatchQueue(label: "rebreak.hashlist.reload") + + private init() { + load() + observeUpdates() + } + + private static var fileURL: URL? { + FileManager.default + .containerURL(forSecurityApplicationGroupIdentifier: appGroup)? + .appendingPathComponent(filename) + } + + private static func currentMtime() -> Date? { + guard let url = fileURL, + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path), + let mtime = attrs[.modificationDate] as? Date + else { return nil } + return mtime + } + + /// Polled die mtime von blocklist.bin (max. 1×/sec) und reloaded den mmap + /// wenn sich was geändert hat. Macht uns unabhängig von DarwinNotifications, + /// die verloren gehen können wenn die Extension idle ist. + private func refreshIfChanged() { + let needsReload: Bool = queue.sync { + let now = Date() + if now.timeIntervalSince(lastMtimeCheck) < 1.0 { return false } + lastMtimeCheck = now + return loadedMtime != Self.currentMtime() + } + if needsReload { load() } + } + + private func load() { + queue.sync { + guard let url = Self.fileURL, + FileManager.default.fileExists(atPath: url.path), + let mmapped = try? Data(contentsOf: url, options: .alwaysMapped) + else { + self.data = nil + self.hashCount = 0 + self.loadedMtime = nil + SharedLogStore.append("ℹ️ blocklist.bin not present — block-set ist leer") + return + } + self.data = mmapped + self.hashCount = mmapped.count / 8 + self.loadedMtime = Self.currentMtime() + SharedLogStore.append("📂 blocklist.bin loaded: \(self.hashCount) hashes (\(mmapped.count) bytes)") + } + } + + private func observeUpdates() { + // Container-App feuert DarwinNotification nach Sync. Apple's + // CFNotificationCenterGetDarwinNotifyCenter erlaubt cross-process events + // ohne shared state. + let name = "rebreak.blocklist.updated" as CFString + let center = CFNotificationCenterGetDarwinNotifyCenter() + let observer = Unmanaged.passUnretained(self).toOpaque() + CFNotificationCenterAddObserver( + center, + observer, + { _, observer, _, _, _ in + guard let observer = observer else { return } + let me = Unmanaged.fromOpaque(observer).takeUnretainedValue() + me.load() + }, + name, + nil, + .deliverImmediately + ) + } + + /// Binary-search auf sortierten 64-bit Hashes. O(log n). + func contains(_ hash: UInt64) -> Bool { + // Cheap mtime-check (rate-limited 1×/sec) — fängt verlorene + // DarwinNotifications und stale-mmap nach atomic-replace. + refreshIfChanged() + return queue.sync { + guard let data = self.data, self.hashCount > 0 else { return false } + var lo = 0 + var hi = self.hashCount - 1 + while lo <= hi { + let mid = (lo + hi) / 2 + let offset = mid * 8 + // Read big-endian UInt64 at offset + var value: UInt64 = 0 + data.withUnsafeBytes { ptr in + let base = ptr.baseAddress!.advanced(by: offset) + for i in 0..<8 { + value = (value << 8) | UInt64(base.load(fromByteOffset: i, as: UInt8.self)) + } + } + if value == hash { return true } + if value < hash { lo = mid + 1 } + else { hi = mid - 1 } + } + return false + } + } +} + +class FilterDataProvider: NEFilterDataProvider { + + override func startFilter(completionHandler: @escaping (Error?) -> Void) { + SharedLogStore.append("🚀 startFilter() called") + // Trigger initial load + _ = HashListMmap.shared + completionHandler(nil) + } + + override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { + SharedLogStore.append("🛑 stopFilter() reason=\(reason.rawValue)") + completionHandler() + } + + override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { + guard let browserFlow = flow as? NEFilterBrowserFlow, + let url = browserFlow.url, + let host = url.host + else { + return .allow() + } + + let normalizedHost = DomainHasher.normalize(host) + let hashList = HashListMmap.shared + + // Subdomain-Match: für `evil.shop.bet365.com` testen wir + // - evil.shop.bet365.com + // - shop.bet365.com + // - bet365.com + // - com (wird in der Praxis nie matchen — TLD steht nicht in der Liste) + // Max 5 Iterationen (Cap zur Sicherheit). + var current = normalizedHost + var iter = 0 + while iter < 5 { + let h = DomainHasher.hash(current) + if hashList.contains(h) { + SharedLogStore.append("🚫 BLOCKED: \(normalizedHost) (matched suffix: \(current))") + return .drop() + } + // Strippen bis zum nächsten Punkt + guard let dot = current.firstIndex(of: ".") else { break } + current = String(current[current.index(after: dot)...]) + // Stoppen wenn kein Punkt mehr übrig (= TLD) + if !current.contains(".") { break } + iter += 1 + } + + return .allow() + } +} diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/Info.plist new file mode 100644 index 0000000..0054c1b --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + RebreakURLFilter + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.filter-data + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).FilterDataProvider + + + diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/RebreakURLFilter.entitlements b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/RebreakURLFilter.entitlements new file mode 100644 index 0000000..5583680 --- /dev/null +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilter/RebreakURLFilter.entitlements @@ -0,0 +1,14 @@ + + + + + com.apple.developer.networking.networkextension + + content-filter-provider + + com.apple.security.application-groups + + group.org.rebreak.app + + +