feat(ios): Extensions melden Protection-State ans Backend
- 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:
parent
b0a7091ac7
commit
c477b300ad
@ -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,8 +227,13 @@ 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)")
|
||||||
|
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()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict {
|
override func handleNewFlow(_ flow: NEFilterFlow) -> NEFilterNewFlowVerdict {
|
||||||
guard let browserFlow = flow as? NEFilterBrowserFlow,
|
guard let browserFlow = flow as? NEFilterBrowserFlow,
|
||||||
|
|||||||
@ -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,8 +279,12 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
|||||||
d.set(false, forKey: "vpn_tunnel_running")
|
d.set(false, forKey: "vpn_tunnel_running")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportProtectionEventToBackend(active: false, source: "vpn", reason: "PacketTunnel stopped (reason=\(reason.rawValue))")
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Read-Loop ──────────────────────────────────────────────────────────────
|
// ─── Read-Loop ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user