Neuer iOS-Layer-1-Filter: ein NEPacketTunnelProvider-DNS-Sinkhole — MDM-frei, ab iOS 16, Parität zum Android-VPN-DNS-Filter. Ersetzt den Apple-seitig blockierten NEURLFilter als Default. NEURLFilter-/PIR-Code bleibt inaktiv als iOS-26-Upgrade-Pfad erhalten (User-Entscheidung). Neues Extension-Target RebreakPacketTunnelExtension/: - PacketTunnelProvider.swift — TUN-Setup (virtuelle DNS-IP 10.0.0.1, nur diese Route ins TUN), Read-Loop, NXDOMAIN-Sinkhole, Upstream-Forward via NWConnection zu 1.1.1.1, Blocklist-Reload via Darwin-Notification. - DnsFilter.swift / HashList.swift / DomainHasher.swift — Swift-Ports der Android-DNS-Filter-Logik. blocklist.bin-Format (sortierte big-endian UInt64, SHA-256-Prefix) 1:1 beibehalten, mmap statt Heap-Load. RebreakProtectionModule.swift: - activateUrlFilter startet jetzt den Packet-Tunnel via NETunnelProviderManager (Default-Layer-1, On-Demand-Auto-Reconnect aktiv). - NEURLFilter-Code in activateNeUrlFilter ausgelagert (inaktiv, behalten). - getDeviceState/disable lesen bzw. stoppen den Tunnel-Status. with-rebreak-protection-ios.js: zweites app_extension-Target, klassischer Embed-Pfad (dstSubfolderSpec 13), packet-tunnel-provider-Entitlement + App-Group. app.config.ts: zweites appExtensions-Target. NICHT auf echtem Gerät verifiziert — NE-Packet-Tunnel laufen nicht im Simulator. Ungetestete Annahmen im Code mit "UNGETESTETE ANNAHME" markiert. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
354 lines
14 KiB
JavaScript
354 lines
14 KiB
JavaScript
/* eslint-disable @typescript-eslint/no-var-requires */
|
|
/**
|
|
* Expo Config-Plugin — bindet das NEURLFilter-ExtensionKit-Target
|
|
* (`RebreakURLFilterExtension`) beim Prebuild ins iOS-Projekt ein.
|
|
*
|
|
* Ersetzt das frühere NEFilter-Plugin. Erkenntnis aus Apples `SimpleURLFilter`-
|
|
* Sample: das Target ist KEIN exotischer Produkttyp — es bleibt das klassische
|
|
* `app_extension`. Die ExtensionKit-Natur kommt aus:
|
|
* 1. der Info.plist (`EXAppExtensionAttributes` / `EXExtensionPointIdentifier`)
|
|
* 2. der Embed-Phase „Embed Foundation Extensions" mit `dstSubfolderSpec = 16`
|
|
* (das klassische „Embed App Extensions" wäre 13).
|
|
*
|
|
* Was es macht:
|
|
* 1) Haupt-App-Entitlements (url-filter-provider, family-controls, app-groups)
|
|
* 2) kopiert `modules/rebreak-protection/ios/RebreakURLFilterExtension/` → `ios/`
|
|
* 3) fügt das Xcode-Target `RebreakURLFilterExtension` hinzu
|
|
*
|
|
* Idempotent — kann beliebig oft via `expo prebuild` laufen.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const {
|
|
withEntitlementsPlist,
|
|
withDangerousMod,
|
|
withXcodeProject,
|
|
} = require('@expo/config-plugins');
|
|
|
|
const APP_GROUP = 'group.org.rebreak.app';
|
|
const TARGET_NAME = 'RebreakURLFilterExtension';
|
|
const EXT_BUNDLE_SUFFIX = 'URLFilterExtension'; // → org.rebreak.app.URLFilterExtension
|
|
const DEVELOPMENT_TEAM = '84BQ7MTFYK';
|
|
const MODULE_DIR = path.join(
|
|
__dirname,
|
|
'..',
|
|
'modules',
|
|
'rebreak-protection',
|
|
'ios',
|
|
TARGET_NAME,
|
|
);
|
|
|
|
// Swift-Quellen (Apple-Bloom vendored + unser Control-Provider).
|
|
const SWIFT_SOURCES = [
|
|
'BloomFilter.swift',
|
|
'Murmur3Hash.swift',
|
|
'FNV1aHash.swift',
|
|
'RebreakURLFilterControlProvider.swift',
|
|
];
|
|
// Bundle-Resources.
|
|
const RESOURCES = ['bloom_filter.plist'];
|
|
|
|
// ─── Packet-Tunnel-Extension (Layer 1 — Default-Filter) ──────────────────────
|
|
// Neues Target: der NEPacketTunnelProvider-DNS-Sinkhole. Ersetzt NEURLFilter
|
|
// als primären, lieferbaren iOS-Filter. KLASSISCHE PluginKit-App-Extension —
|
|
// normaler `app_extension`-Pfad, normale „Embed App Extensions"-Phase
|
|
// (dstSubfolderSpec 13). KEIN ExtensionKit-Sonderweg.
|
|
const PT_TARGET_NAME = 'RebreakPacketTunnelExtension';
|
|
const PT_BUNDLE_SUFFIX = 'PacketTunnelExtension'; // → org.rebreak.app.PacketTunnelExtension
|
|
const PT_MODULE_DIR = path.join(
|
|
__dirname,
|
|
'..',
|
|
'modules',
|
|
'rebreak-protection',
|
|
'ios',
|
|
PT_TARGET_NAME,
|
|
);
|
|
// Swift-Quellen (Provider + Filter-Logik, aus Android nach Swift portiert).
|
|
const PT_SWIFT_SOURCES = [
|
|
'PacketTunnelProvider.swift',
|
|
'DnsFilter.swift',
|
|
'HashList.swift',
|
|
'DomainHasher.swift',
|
|
];
|
|
|
|
// ─── 1) Haupt-App-Entitlements ──────────────────────────────────────────────
|
|
|
|
function withMainAppEntitlements(config) {
|
|
return withEntitlementsPlist(config, (cfg) => {
|
|
// NetworkExtension-Entitlement. Das Array darf mehrere Provider-Typen
|
|
// halten — beide gleichzeitig ist technisch erlaubt:
|
|
// - `packet-tunnel-provider`: der NEU aktive Layer-1-Filter (DNS-Sinkhole,
|
|
// MDM-frei, kein Apple-Capability-Request nötig — direkt in Xcode/Portal).
|
|
// - `url-filter-provider`: NEURLFilter, behalten als iOS-26-Upgrade-Pfad
|
|
// (inaktiv; User-Entscheidung NICHT zu löschen).
|
|
cfg.modResults['com.apple.developer.networking.networkextension'] = [
|
|
'packet-tunnel-provider',
|
|
'url-filter-provider',
|
|
];
|
|
// Family Controls = Kern-Funktion, Apple-Distribution-Entitlement freigegeben
|
|
// (v0.3.4) → DEFAULT AN. Nur ein explizites REBREAK_ENABLE_FAMILY_CONTROLS=0
|
|
// schaltet es ab (Escape-Hatch). Muss identisch zu app.config.ts
|
|
// `familyControlsEnabled` bleiben.
|
|
if (process.env.REBREAK_ENABLE_FAMILY_CONTROLS !== '0') {
|
|
cfg.modResults['com.apple.developer.family-controls'] = true;
|
|
}
|
|
const groups = cfg.modResults['com.apple.security.application-groups'] || [];
|
|
if (!groups.includes(APP_GROUP)) {
|
|
cfg.modResults['com.apple.security.application-groups'] = [...groups, APP_GROUP];
|
|
}
|
|
return cfg;
|
|
});
|
|
}
|
|
|
|
// ─── 2) Extension-Sources ins ios/-Verzeichnis kopieren ─────────────────────
|
|
|
|
function withCopyExtensionSources(config) {
|
|
return withDangerousMod(config, [
|
|
'ios',
|
|
async (cfg) => {
|
|
// Beide Extension-Verzeichnisse nach ios/ kopieren.
|
|
for (const [target, srcDir] of [
|
|
[TARGET_NAME, MODULE_DIR],
|
|
[PT_TARGET_NAME, PT_MODULE_DIR],
|
|
]) {
|
|
const dest = path.join(cfg.modRequest.platformProjectRoot, target);
|
|
if (!fs.existsSync(srcDir)) {
|
|
throw new Error(
|
|
`[with-rebreak-protection-ios] Extension source dir missing: ${srcDir}`,
|
|
);
|
|
}
|
|
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
|
|
for (const file of fs.readdirSync(srcDir)) {
|
|
fs.copyFileSync(path.join(srcDir, file), path.join(dest, file));
|
|
}
|
|
}
|
|
return cfg;
|
|
},
|
|
]);
|
|
}
|
|
|
|
// ─── 3) Xcode-Target hinzufügen ──────────────────────────────────────────────
|
|
|
|
function withExtensionTarget(config) {
|
|
return withXcodeProject(config, async (cfg) => {
|
|
const proj = cfg.modResults;
|
|
|
|
// Idempotenz: skip wenn Target schon angelegt.
|
|
if (proj.pbxTargetByName(TARGET_NAME)) {
|
|
return cfg;
|
|
}
|
|
|
|
const mainBundleId = cfg.ios?.bundleIdentifier;
|
|
if (!mainBundleId) {
|
|
throw new Error('[with-rebreak-protection-ios] ios.bundleIdentifier fehlt in app.config');
|
|
}
|
|
const extBundleId = `${mainBundleId}.${EXT_BUNDLE_SUFFIX}`;
|
|
|
|
// ── Target anlegen (Produkttyp app_extension — wie Apples SimpleURLFilter) ──
|
|
const target = proj.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, extBundleId);
|
|
|
|
// ── Build-Phasen ──
|
|
proj.addBuildPhase(SWIFT_SOURCES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
|
proj.addBuildPhase(RESOURCES, 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
|
proj.addBuildPhase(
|
|
['NetworkExtension.framework'],
|
|
'PBXFrameworksBuildPhase',
|
|
'Frameworks',
|
|
target.uuid,
|
|
);
|
|
|
|
// ── PBXGroup für die Extension-Dateien ──
|
|
const pbxGroup = proj.addPbxGroup(
|
|
[...SWIFT_SOURCES, ...RESOURCES, 'Info.plist', `${TARGET_NAME}.entitlements`],
|
|
TARGET_NAME,
|
|
TARGET_NAME,
|
|
);
|
|
const groups = proj.hash.project.objects.PBXGroup;
|
|
Object.keys(groups).forEach((key) => {
|
|
if (
|
|
groups[key].name === 'CustomTemplate' ||
|
|
(groups[key].name === undefined && groups[key].path === undefined)
|
|
) {
|
|
proj.addToPbxGroup(pbxGroup.uuid, key);
|
|
}
|
|
});
|
|
|
|
// ── Build-Settings auf der Target-Configuration ──
|
|
const configurations = proj.pbxXCBuildConfigurationSection();
|
|
Object.keys(configurations)
|
|
.filter((k) => typeof configurations[k] === 'object')
|
|
.forEach((k) => {
|
|
const buildSettingsObj = configurations[k].buildSettings;
|
|
if (
|
|
buildSettingsObj &&
|
|
buildSettingsObj.PRODUCT_NAME &&
|
|
buildSettingsObj.PRODUCT_NAME.replace(/"/g, '') === TARGET_NAME
|
|
) {
|
|
buildSettingsObj.INFOPLIST_FILE = `"${TARGET_NAME}/Info.plist"`;
|
|
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${TARGET_NAME}/${TARGET_NAME}.entitlements"`;
|
|
// NEURLFilter ist iOS 26+. Die Extension lädt nur dort — höheres
|
|
// Deployment-Target als die Haupt-App (iOS 16+) ist korrekt.
|
|
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '26.0';
|
|
buildSettingsObj.SWIFT_VERSION = '5.0';
|
|
buildSettingsObj.TARGETED_DEVICE_FAMILY = '"1,2"';
|
|
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic';
|
|
// EAS managed credentials setzen DEVELOPMENT_TEAM nur auf der Main-App
|
|
// → Extension braucht expliziten Team-Wert.
|
|
buildSettingsObj.DEVELOPMENT_TEAM = DEVELOPMENT_TEAM;
|
|
}
|
|
});
|
|
|
|
// ── Embed-Phase auf ExtensionKit umstellen ──
|
|
// proj.addTarget() hat bereits eine „Copy Files"-PBXCopyFilesBuildPhase
|
|
// (dstSubfolderSpec 13 = PlugIns/) im Haupt-Target angelegt, die unsere
|
|
// .appex einbettet. iOS' Installer (MIPluginKitBundle) behandelt aber
|
|
// ALLES in PlugIns/ als klassische PluginKit-Extension und verlangt ein
|
|
// NSExtension-Dict im Info.plist — das ExtensionKit-Extensions NICHT haben
|
|
// ("AppexBundleMissingNSExtensionDict", MIInstallerError 39). ExtensionKit-
|
|
// Extensions MÜSSEN nach Extensions/. Also die bestehende Phase umbiegen:
|
|
// dstSubfolderSpec 16 + dstPath $(EXTENSIONS_FOLDER_PATH) — exakt wie
|
|
// Apples SimpleURLFilter.xcodeproj. KEINE zweite Phase anlegen (sonst wird
|
|
// die .appex doppelt eingebettet).
|
|
const mainTargetUuid = proj.getFirstTarget().uuid;
|
|
const copyFilesPhases = proj.hash.project.objects.PBXCopyFilesBuildPhase || {};
|
|
let embedFixed = false;
|
|
Object.keys(copyFilesPhases).forEach((key) => {
|
|
const phase = copyFilesPhases[key];
|
|
if (typeof phase !== 'object') return;
|
|
const embedsOurAppex = (phase.files || []).some(
|
|
(f) => typeof f?.comment === 'string' && f.comment.includes(`${TARGET_NAME}.appex`),
|
|
);
|
|
if (!embedsOurAppex) return;
|
|
phase.name = '"Embed Foundation Extensions"';
|
|
phase.dstSubfolderSpec = 16;
|
|
phase.dstPath = '"$(EXTENSIONS_FOLDER_PATH)"';
|
|
embedFixed = true;
|
|
});
|
|
if (!embedFixed) {
|
|
throw new Error(
|
|
'[with-rebreak-protection-ios] Embed-CopyFiles-Phase für die .appex nicht gefunden',
|
|
);
|
|
}
|
|
|
|
// ── Target-Dependency: Haupt-App baut die Extension vorher ──
|
|
proj.addTargetDependency(mainTargetUuid, [target.uuid]);
|
|
|
|
return cfg;
|
|
});
|
|
}
|
|
|
|
// ─── 4) Packet-Tunnel-Xcode-Target hinzufügen ────────────────────────────────
|
|
//
|
|
// KLASSISCHE App-Extension — anders als das NEURLFilter-Target:
|
|
// - normaler `app_extension`-Produkttyp,
|
|
// - normale „Embed App Extensions"-Phase (dstSubfolderSpec 13, PlugIns/),
|
|
// - KEIN ExtensionKit-Sonderweg (kein dstSubfolderSpec 16, kein
|
|
// EXAppExtensionAttributes). Der `addTarget`-Default ist hier schon korrekt
|
|
// — wir biegen die Embed-Phase NICHT um.
|
|
// - Deployment-Target = Main-App (iOS 16), KEIN 26.0-Gate.
|
|
|
|
function withPacketTunnelTarget(config) {
|
|
return withXcodeProject(config, async (cfg) => {
|
|
const proj = cfg.modResults;
|
|
|
|
// Idempotenz: skip wenn Target schon angelegt.
|
|
if (proj.pbxTargetByName(PT_TARGET_NAME)) {
|
|
return cfg;
|
|
}
|
|
|
|
const mainBundleId = cfg.ios?.bundleIdentifier;
|
|
if (!mainBundleId) {
|
|
throw new Error('[with-rebreak-protection-ios] ios.bundleIdentifier fehlt in app.config');
|
|
}
|
|
const extBundleId = `${mainBundleId}.${PT_BUNDLE_SUFFIX}`;
|
|
|
|
// ── Target anlegen (klassischer app_extension-Produkttyp) ──
|
|
const target = proj.addTarget(
|
|
PT_TARGET_NAME,
|
|
'app_extension',
|
|
PT_TARGET_NAME,
|
|
extBundleId,
|
|
);
|
|
|
|
// ── Build-Phasen ──
|
|
proj.addBuildPhase(
|
|
PT_SWIFT_SOURCES,
|
|
'PBXSourcesBuildPhase',
|
|
'Sources',
|
|
target.uuid,
|
|
);
|
|
proj.addBuildPhase(
|
|
['NetworkExtension.framework'],
|
|
'PBXFrameworksBuildPhase',
|
|
'Frameworks',
|
|
target.uuid,
|
|
);
|
|
|
|
// ── PBXGroup für die Extension-Dateien ──
|
|
const pbxGroup = proj.addPbxGroup(
|
|
[...PT_SWIFT_SOURCES, 'Info.plist', `${PT_TARGET_NAME}.entitlements`],
|
|
PT_TARGET_NAME,
|
|
PT_TARGET_NAME,
|
|
);
|
|
const groups = proj.hash.project.objects.PBXGroup;
|
|
Object.keys(groups).forEach((key) => {
|
|
if (
|
|
groups[key].name === 'CustomTemplate' ||
|
|
(groups[key].name === undefined && groups[key].path === undefined)
|
|
) {
|
|
proj.addToPbxGroup(pbxGroup.uuid, key);
|
|
}
|
|
});
|
|
|
|
// ── Build-Settings auf der Target-Configuration ──
|
|
const configurations = proj.pbxXCBuildConfigurationSection();
|
|
Object.keys(configurations)
|
|
.filter((k) => typeof configurations[k] === 'object')
|
|
.forEach((k) => {
|
|
const buildSettingsObj = configurations[k].buildSettings;
|
|
if (
|
|
buildSettingsObj &&
|
|
buildSettingsObj.PRODUCT_NAME &&
|
|
buildSettingsObj.PRODUCT_NAME.replace(/"/g, '') === PT_TARGET_NAME
|
|
) {
|
|
buildSettingsObj.INFOPLIST_FILE = `"${PT_TARGET_NAME}/Info.plist"`;
|
|
buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${PT_TARGET_NAME}/${PT_TARGET_NAME}.entitlements"`;
|
|
// Packet-Tunnel läuft ab iOS 16 — Deployment-Target = Main-App.
|
|
buildSettingsObj.IPHONEOS_DEPLOYMENT_TARGET = '16.0';
|
|
buildSettingsObj.SWIFT_VERSION = '5.0';
|
|
buildSettingsObj.TARGETED_DEVICE_FAMILY = '"1,2"';
|
|
buildSettingsObj.CODE_SIGN_STYLE = 'Automatic';
|
|
// EAS managed credentials setzen DEVELOPMENT_TEAM nur auf der Main-App
|
|
// → Extension braucht expliziten Team-Wert.
|
|
buildSettingsObj.DEVELOPMENT_TEAM = DEVELOPMENT_TEAM;
|
|
}
|
|
});
|
|
|
|
// ── Embed-Phase: BEWUSST NICHT umbiegen ──
|
|
// proj.addTarget() hat bereits eine „Copy Files"-PBXCopyFilesBuildPhase
|
|
// (dstSubfolderSpec 13 = PlugIns/) im Haupt-Target angelegt, die unsere
|
|
// .appex einbettet. Für eine KLASSISCHE PluginKit-App-Extension (unser
|
|
// NEPacketTunnelProvider hat ein NSExtension-Dict in der Info.plist) ist
|
|
// genau das korrekt — wir lassen die Phase, wie addTarget sie anlegt.
|
|
// KEIN dstSubfolderSpec-16-Umbau wie beim NEURLFilter-Target.
|
|
|
|
// ── Target-Dependency: Haupt-App baut die Extension vorher ──
|
|
const mainTargetUuid = proj.getFirstTarget().uuid;
|
|
proj.addTargetDependency(mainTargetUuid, [target.uuid]);
|
|
|
|
return cfg;
|
|
});
|
|
}
|
|
|
|
// ─── Composition ────────────────────────────────────────────────────────────
|
|
|
|
module.exports = function withRebreakProtectionIos(config) {
|
|
config = withMainAppEntitlements(config);
|
|
config = withCopyExtensionSources(config);
|
|
config = withExtensionTarget(config);
|
|
config = withPacketTunnelTarget(config);
|
|
return config;
|
|
};
|