From c477b300ad2e0face86474ee6fdc6cb93146c699 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 18 Jun 2026 09:42:18 +0200 Subject: [PATCH] feat(ios): Extensions melden Protection-State ans Backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RebreakProtectionModule.setExtensionCredentials() speichert Token, deviceId + baseURL in App-Group Shared UserDefaults. - Auth-Store ruft setExtensionCredentials bei Session-Änderungen auf. - ContentFilter-Extension (FilterDataProvider) sendet bei stopFilter() /api/protection/event active=false mit x-extension-secret. - PacketTunnel-Extension (PacketTunnelProvider) sendet bei stopTunnel() /api/protection/event active=false mit x-extension-secret. --- .../FilterDataProvider.swift | 47 ++++++++++++++++++- .../PacketTunnelProvider.swift | 44 ++++++++++++++++- .../ios/RebreakProtectionModule.swift | 16 +++++++ .../src/RebreakProtectionModule.ts | 7 +++ apps/rebreak-native/stores/auth.ts | 20 ++++++++ 5 files changed, 132 insertions(+), 2 deletions(-) diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/FilterDataProvider.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/FilterDataProvider.swift index 2b8a12b..6d11dc7 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/FilterDataProvider.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/FilterDataProvider.swift @@ -17,6 +17,46 @@ import NetworkExtension import Foundation import CryptoKit +// ─── Extension → Backend reporting ───────────────────────────────────────────── + +private let EXTENSION_SECRET = "5067b8a065ef655a32631640d2e3f86cab8abbfaf6173aabb58598172c57c5fe" +private let APP_GROUP = "group.org.rebreak.app" + +private func reportProtectionEventToBackend(active: Bool, source: String, reason: String) { + guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return } + guard let token = defaults.string(forKey: "rebreak_extension_token"), !token.isEmpty, + let deviceId = defaults.string(forKey: "rebreak_extension_device_id"), !deviceId.isEmpty, + let baseURL = defaults.string(forKey: "rebreak_extension_base_url"), !baseURL.isEmpty + else { + SharedLogStore.append("⚠️ reportProtectionEvent: keine Credentials in App-Group") + return + } + + let url = URL(string: "\(baseURL)/api/protection/event")! + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(EXTENSION_SECRET, forHTTPHeaderField: "x-extension-secret") + let body: [String: Any] = [ + "active": active, + "source": source, + "deviceId": deviceId, + "reason": reason, + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + SharedLogStore.append("📡 reportProtectionEvent active=\(active) source=\(source)") + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + SharedLogStore.append("❌ reportProtectionEvent failed: \(error.localizedDescription)") + return + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + SharedLogStore.append("✅ reportProtectionEvent HTTP \(status)") + } + task.resume() +} + /// Shared Log-Store via App-Group UserDefaults (für Container-App-Debug-Page). enum SharedLogStore { static let appGroup = "group.org.rebreak.app" @@ -187,7 +227,12 @@ class FilterDataProvider: NEFilterDataProvider { override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { SharedLogStore.append("🛑 stopFilter() reason=\(reason.rawValue)") - completionHandler() + reportProtectionEventToBackend(active: false, source: "mdm", reason: "NEFilter stopped (reason=\(reason.rawValue))") + // Kurze Verzögerung, damit der Fire-and-Forget-Request abgesetzt wird, + // bevor die Extension beendet wird. iOS gibt uns hier keine Garantie. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + completionHandler() + } } override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict { diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift index 83516fd..461b69c 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/PacketTunnelProvider.swift @@ -41,6 +41,44 @@ import os private let APP_GROUP = "group.org.rebreak.app" private let BLOCKLIST_FILENAME = "blocklist.bin" private let DARWIN_NOTIF = "rebreak.blocklist.updated" +private let EXTENSION_SECRET = "5067b8a065ef655a32631640d2e3f86cab8abbfaf6173aabb58598172c57c5fe" + +// ─── Extension → Backend reporting ───────────────────────────────────────────── + +private func reportProtectionEventToBackend(active: Bool, source: String, reason: String) { + guard let defaults = UserDefaults(suiteName: APP_GROUP), + let token = defaults.string(forKey: "rebreak_extension_token"), !token.isEmpty, + let deviceId = defaults.string(forKey: "rebreak_extension_device_id"), !deviceId.isEmpty, + let baseURL = defaults.string(forKey: "rebreak_extension_base_url"), !baseURL.isEmpty + else { + ExtLog.write("⚠️ reportProtectionEvent: keine Credentials in App-Group") + return + } + + guard let url = URL(string: "\(baseURL)/api/protection/event") else { return } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(EXTENSION_SECRET, forHTTPHeaderField: "x-extension-secret") + let body: [String: Any] = [ + "active": active, + "source": source, + "deviceId": deviceId, + "reason": reason, + ] + request.httpBody = try? JSONSerialization.data(withJSONObject: body) + + ExtLog.write("📡 reportProtectionEvent active=\(active) source=\(source)") + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + ExtLog.write("❌ reportProtectionEvent failed: \(error.localizedDescription)") + return + } + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + ExtLog.write("✅ reportProtectionEvent HTTP \(status)") + } + task.resume() +} // Virtuelles Subnet — identisch zur Android-Wahl (RebreakVpnService). private let TUNNEL_LOCAL_ADDR = "10.0.0.2" @@ -241,7 +279,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider { d.set(false, forKey: "vpn_tunnel_running") } } - completionHandler() + + reportProtectionEventToBackend(active: false, source: "vpn", reason: "PacketTunnel stopped (reason=\(reason.rawValue))") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + completionHandler() + } } // ─── Read-Loop ────────────────────────────────────────────────────────────── diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift index 0cc9925..1464d8f 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakProtectionModule.swift @@ -90,6 +90,22 @@ public class RebreakProtectionModule: Module { Events("onLayerChange") + // ───────── setExtensionCredentials: Auth-Token + deviceId an Extensions ─── + // Die Network-Extensions laufen als eigene Prozesse und haben keinen Zugriff + // auf das Supabase-SDK. Damit sie bei stopFilter()/stopTunnel() selbst den + // Protection-State ans Backend melden können, schreibt die Haupt-App Token + // + deviceId in die App-Group Shared UserDefaults. + AsyncFunction("setExtensionCredentials") { (token: String, deviceId: String, baseURL: String) in + guard let defaults = UserDefaults(suiteName: APP_GROUP) else { + SharedLogStore.append("⚠️ setExtensionCredentials: App-Group nicht erreichbar") + return + } + defaults.set(token, forKey: "rebreak_extension_token") + defaults.set(deviceId, forKey: "rebreak_extension_device_id") + defaults.set(baseURL, forKey: "rebreak_extension_base_url") + SharedLogStore.append("🔐 Extension-Credentials gespeichert (deviceId len=\(deviceId.count))") + } + // ───────── activate: Family Controls + NEFilter + denyAppRemoval ───────── // ───────── probeContentFilter: Try NEFilter, retourniert ob das Device es diff --git a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts index 463ee54..34103c1 100644 --- a/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts +++ b/apps/rebreak-native/modules/rebreak-protection/src/RebreakProtectionModule.ts @@ -16,6 +16,13 @@ import type { } from './RebreakProtection.types'; declare class RebreakProtectionModule extends NativeModule { + /** + * iOS: schreibt Auth-Token + deviceId + baseURL in die App-Group Shared + * UserDefaults, damit die Network-Extensions bei stopFilter()/stopTunnel() + * selbst den Protection-State ans Backend melden können. + */ + setExtensionCredentials(token: string, deviceId: string, baseURL: string): Promise; + /** * iOS: read-only check ob NEFilter aktiv ist (egal ob via App-Code-saveToPreferences * oder via System-installiertes webcontent-filter Profile). Wenn `enabled=true` diff --git a/apps/rebreak-native/stores/auth.ts b/apps/rebreak-native/stores/auth.ts index 17d4de7..8cb3e2c 100644 --- a/apps/rebreak-native/stores/auth.ts +++ b/apps/rebreak-native/stores/auth.ts @@ -6,7 +6,10 @@ import * as Linking from 'expo-linking'; import * as AppleAuthentication from 'expo-apple-authentication'; import { supabase } from '../lib/supabase'; import { apiFetch } from '../lib/api'; +import { getDeviceId } from '../lib/deviceId'; +import Constants from 'expo-constants'; import i18n from '../lib/i18n'; +import RebreakProtection from '../modules/rebreak-protection'; import { syncLanguageFromUserMetadata } from './language'; const SUPPORTED_LOCALES = ['de', 'en', 'fr', 'ar'] as const; @@ -16,6 +19,21 @@ function currentLocale(): SupportedLocale { const raw = (i18n.language ?? 'en').split('-')[0].toLowerCase(); return (SUPPORTED_LOCALES as readonly string[]).includes(raw) ? (raw as SupportedLocale) : 'en'; } + +const API_BASE = (Constants.expoConfig?.extra?.apiUrl as string) ?? 'https://staging.rebreak.org'; + +async function syncExtensionCredentials(session: Session | null) { + try { + if (!session?.access_token) { + await RebreakProtection.setExtensionCredentials('', '', API_BASE); + return; + } + const deviceId = await getDeviceId(); + await RebreakProtection.setExtensionCredentials(session.access_token, deviceId, API_BASE); + } catch { + // Best-effort; Extension kann bei fehlenden Credentials nicht melden. + } +} import { invalidateMe } from '../hooks/useMe'; import { useDevicesStore } from './devices'; import { useDeviceLimitStore } from './deviceLimit'; @@ -73,12 +91,14 @@ export const useAuthStore = create((set) => ({ if (data.session?.user) { void syncLanguageFromUserMetadata(data.session.user); } + void syncExtensionCredentials(data.session); supabase.auth.onAuthStateChange((_event, session) => { set({ session, user: session?.user ?? null }); if (session?.user) { void syncLanguageFromUserMetadata(session.user); } + void syncExtensionCredentials(session); }); },