feat(ios): Extensions melden Protection-State ans Backend
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was successful
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled

- 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.
This commit is contained in:
chahinebrini 2026-06-18 09:42:18 +02:00
parent b0a7091ac7
commit c477b300ad
5 changed files with 132 additions and 2 deletions

View File

@ -17,6 +17,46 @@ import NetworkExtension
import Foundation import Foundation
import CryptoKit 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). /// Shared Log-Store via App-Group UserDefaults (für Container-App-Debug-Page).
enum SharedLogStore { enum SharedLogStore {
static let appGroup = "group.org.rebreak.app" static let appGroup = "group.org.rebreak.app"
@ -187,7 +227,12 @@ class FilterDataProvider: NEFilterDataProvider {
override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) { override func stopFilter(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
SharedLogStore.append("🛑 stopFilter() reason=\(reason.rawValue)") 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 { override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict {

View File

@ -41,6 +41,44 @@ import os
private let APP_GROUP = "group.org.rebreak.app" private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin" private let BLOCKLIST_FILENAME = "blocklist.bin"
private let DARWIN_NOTIF = "rebreak.blocklist.updated" 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). // Virtuelles Subnet identisch zur Android-Wahl (RebreakVpnService).
private let TUNNEL_LOCAL_ADDR = "10.0.0.2" private let TUNNEL_LOCAL_ADDR = "10.0.0.2"
@ -241,7 +279,11 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
d.set(false, forKey: "vpn_tunnel_running") 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 // Read-Loop

View File

@ -90,6 +90,22 @@ public class RebreakProtectionModule: Module {
Events("onLayerChange") 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 // activate: Family Controls + NEFilter + denyAppRemoval
// probeContentFilter: Try NEFilter, retourniert ob das Device es // probeContentFilter: Try NEFilter, retourniert ob das Device es

View File

@ -16,6 +16,13 @@ import type {
} from './RebreakProtection.types'; } from './RebreakProtection.types';
declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEvents> { declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEvents> {
/**
* 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<void>;
/** /**
* iOS: read-only check ob NEFilter aktiv ist (egal ob via App-Code-saveToPreferences * iOS: read-only check ob NEFilter aktiv ist (egal ob via App-Code-saveToPreferences
* oder via System-installiertes webcontent-filter Profile). Wenn `enabled=true` * oder via System-installiertes webcontent-filter Profile). Wenn `enabled=true`

View File

@ -6,7 +6,10 @@ import * as Linking from 'expo-linking';
import * as AppleAuthentication from 'expo-apple-authentication'; import * as AppleAuthentication from 'expo-apple-authentication';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { apiFetch } from '../lib/api'; import { apiFetch } from '../lib/api';
import { getDeviceId } from '../lib/deviceId';
import Constants from 'expo-constants';
import i18n from '../lib/i18n'; import i18n from '../lib/i18n';
import RebreakProtection from '../modules/rebreak-protection';
import { syncLanguageFromUserMetadata } from './language'; import { syncLanguageFromUserMetadata } from './language';
const SUPPORTED_LOCALES = ['de', 'en', 'fr', 'ar'] as const; const SUPPORTED_LOCALES = ['de', 'en', 'fr', 'ar'] as const;
@ -16,6 +19,21 @@ function currentLocale(): SupportedLocale {
const raw = (i18n.language ?? 'en').split('-')[0].toLowerCase(); const raw = (i18n.language ?? 'en').split('-')[0].toLowerCase();
return (SUPPORTED_LOCALES as readonly string[]).includes(raw) ? (raw as SupportedLocale) : 'en'; 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 { invalidateMe } from '../hooks/useMe';
import { useDevicesStore } from './devices'; import { useDevicesStore } from './devices';
import { useDeviceLimitStore } from './deviceLimit'; import { useDeviceLimitStore } from './deviceLimit';
@ -73,12 +91,14 @@ export const useAuthStore = create<AuthState>((set) => ({
if (data.session?.user) { if (data.session?.user) {
void syncLanguageFromUserMetadata(data.session.user); void syncLanguageFromUserMetadata(data.session.user);
} }
void syncExtensionCredentials(data.session);
supabase.auth.onAuthStateChange((_event, session) => { supabase.auth.onAuthStateChange((_event, session) => {
set({ session, user: session?.user ?? null }); set({ session, user: session?.user ?? null });
if (session?.user) { if (session?.user) {
void syncLanguageFromUserMetadata(session.user); void syncLanguageFromUserMetadata(session.user);
} }
void syncExtensionCredentials(session);
}); });
}, },