diff --git a/.gitignore b/.gitignore index 3c70068df..899b6e6cf 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ proguard/ # native binaries libs/ obj/ +dnsdaemon/libs/ +dnsdaemon/obj/ # IDE related .idea/ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8bb863f3e..7526ed642 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -162,6 +162,9 @@ + @@ -224,6 +227,9 @@ + diff --git a/app/src/main/assets/dnsd/arm64-v8a/afwall_dnsd b/app/src/main/assets/dnsd/arm64-v8a/afwall_dnsd new file mode 100755 index 000000000..e20dee88e Binary files /dev/null and b/app/src/main/assets/dnsd/arm64-v8a/afwall_dnsd differ diff --git a/app/src/main/assets/dnsd/armeabi-v7a/afwall_dnsd b/app/src/main/assets/dnsd/armeabi-v7a/afwall_dnsd new file mode 100755 index 000000000..b9a0c2b37 Binary files /dev/null and b/app/src/main/assets/dnsd/armeabi-v7a/afwall_dnsd differ diff --git a/app/src/main/assets/dnsd/x86/afwall_dnsd b/app/src/main/assets/dnsd/x86/afwall_dnsd new file mode 100755 index 000000000..55fffb589 Binary files /dev/null and b/app/src/main/assets/dnsd/x86/afwall_dnsd differ diff --git a/app/src/main/assets/dnsd/x86_64/afwall_dnsd b/app/src/main/assets/dnsd/x86_64/afwall_dnsd new file mode 100755 index 000000000..6606a0725 Binary files /dev/null and b/app/src/main/assets/dnsd/x86_64/afwall_dnsd differ diff --git a/app/src/main/java/dev/ukanth/ufirewall/Api.java b/app/src/main/java/dev/ukanth/ufirewall/Api.java index 2e423851c..be38dbdf6 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/Api.java +++ b/app/src/main/java/dev/ukanth/ufirewall/Api.java @@ -129,6 +129,7 @@ import dev.ukanth.ufirewall.MainActivity.GetAppList; import dev.ukanth.ufirewall.customrules.CustomRule; import dev.ukanth.ufirewall.customrules.CustomRule_Table; +import dev.ukanth.ufirewall.dns.DnsHijackManager; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.log.LogData; import dev.ukanth.ufirewall.log.LogData_Table; @@ -1173,6 +1174,10 @@ private static boolean applyIptablesRulesImpl(final Context ctx, RuleDataSet rul cmds.add("-t nat -I OUTPUT 1 -j " + chainName); } + // DNS redirect must be built in the same root batch as firewall apply so stale root + // grants fail through normal apply logging instead of leaving half-installed redirects. + DnsHijackManager.appendApplyCommands(ctx, cmds, ipv6); + // custom rules in afwall-{3g,wifi,reject} supersede everything else addCustomRules(Api.PREF_CUSTOMSCRIPT, cmds, ipv6); @@ -1348,7 +1353,6 @@ private static void iptablesCommands(List in, List out, boolean } boolean firstLit = true; for (String s : in) { - s = s + waitTime; if (s.matches("#LITERAL# .*")) { if (firstLit) { // export vars for the benefit of custom scripts @@ -1359,11 +1363,12 @@ private static void iptablesCommands(List in, List out, boolean + "export IPV6=" + (ipv6 ? "1" : "0") + "; " + "true"); } + // Literal commands may be supervisor/control scripts and must not inherit iptables flags. out.add(s.replaceFirst("^#LITERAL# ", "")); } else if (s.matches("#NOCHK# .*")) { - out.add(s.replaceFirst("^#NOCHK# ", "#NOCHK# " + ipPath + " ")); + out.add(s.replaceFirst("^#NOCHK# ", "#NOCHK# " + ipPath + " ") + waitTime); } else { - out.add(ipPath + " " + s); + out.add(ipPath + " " + s + waitTime); } } } @@ -1800,6 +1805,9 @@ public static void purgeIptables(Context ctx, boolean showErrors, RootCommand ca } else { cmdsv4.add("#NOCHK# -D OUTPUT -j " + chainName); } + // Purging firewall rules must also remove DNS capture, otherwise the root daemon can keep + // receiving traffic after the user thinks protection has been disabled. + DnsHijackManager.appendPurgeCommands(ctx, cmds, false); //make sure reset the OUTPUT chain to accept state. cmds.add("-P OUTPUT ACCEPT"); @@ -1828,6 +1836,7 @@ public static void purgeIptables(Context ctx, boolean showErrors, RootCommand ca cmdsv6.add("-F " + chainName + s); } cmdsv6.add("#NOCHK# -D OUTPUT -j " + chainName); + DnsHijackManager.appendPurgeCommands(ctx, cmdsv6, true); cmdsv6.add("-P OUTPUT ACCEPT"); if (G.enableInbound()) { cmdsv6.add("-D INPUT -j " + chainName + "-input"); diff --git a/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java b/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java index f880b282b..da8a0765a 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/MainActivity.java @@ -102,6 +102,7 @@ import dev.ukanth.ufirewall.activity.CustomScriptActivity; import dev.ukanth.ufirewall.activity.HelpActivity; import dev.ukanth.ufirewall.activity.LogHubActivity; +import dev.ukanth.ufirewall.dns.DnsHijackManager; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.preferences.PreferencesActivity; import dev.ukanth.ufirewall.profiles.ProfileData; @@ -1080,10 +1081,36 @@ public void onItemSelected(AdapterView parent, View view, int position, long showOrLoadApplications(); if (G.applyOnSwitchProfiles()) { applyOrSaveRules(); + } else { + refreshDnsProfilePolicyAfterSwitch(); } } } + private void refreshDnsProfilePolicyAfterSwitch() { + if (!G.enableDnsHijack() || !G.dnsHijackUseProfilePolicy()) { + return; + } + Api.setRulesUpToDate(false); + ApplicationErrorLog.add(this, "DNS profile policy changed; refreshing DNS service for active profile: " + + G.activeDnsHijackPolicyProfile()); + DnsHijackManager.repairDnsProtection(this, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + if (state.exitCode == 0) { + ApplicationErrorLog.add(MainActivity.this, + "DNS profile policy refresh completed for active profile: " + + G.activeDnsHijackPolicyProfile()); + } else { + ApplicationErrorLog.add(MainActivity.this, + "DNS profile policy refresh failed after profile switch"); + runOnUiThread(() -> Api.toast(MainActivity.this, + getString(R.string.dns_hijack_redirect_settings_failed))); + } + } + }); + } + @Override public void onNothingSelected(AdapterView parent) { diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationErrorsActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationErrorsActivity.java index e312d2ade..912d44519 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationErrorsActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationErrorsActivity.java @@ -13,6 +13,7 @@ import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.dns.DnsHijackManager; import dev.ukanth.ufirewall.util.ApplicationErrorLog; import dev.ukanth.ufirewall.util.G; import dev.ukanth.ufirewall.util.ThemeHelper; @@ -76,6 +77,7 @@ public boolean onOptionsItemSelected(MenuItem item) { } private void refreshData() { + DnsHijackManager.syncServiceLogsToAppLog(this); String errors = ApplicationErrorLog.get(this); content.setText(errors.trim().isEmpty() ? getString(R.string.application_errors_empty) : errors); } diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationLogActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationLogActivity.java index f14889083..cde9a1ce4 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationLogActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/ApplicationLogActivity.java @@ -4,6 +4,7 @@ import android.os.Bundle; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.dns.DnsHijackManager; import dev.ukanth.ufirewall.log.Log; public class ApplicationLogActivity extends RulesActivity { @@ -19,6 +20,7 @@ protected void onCreate(Bundle savedInstanceState) { protected void populateData(final Context ctx) { result = new StringBuilder(); updateLoadingState(getString(R.string.loading)); + DnsHijackManager.syncServiceLogsToAppLog(ctx); writeHeading(result, false, "Logcat"); result.append(Log.getApplicationLog()); updateLoadingState(getString(R.string.ready)); diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java index cf2a8b234..fdd404023 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/DataDumpActivity.java @@ -222,6 +222,8 @@ private String detectSectionType(String line) { return "ifconfig"; } else if (trimmed.contains("System info")) { return "system"; + } else if (trimmed.equals(getString(R.string.dns_diagnostics_title))) { + return "dns"; } else if (trimmed.contains("Preferences")) { return "preferences"; } else if (trimmed.equals(getString(R.string.application_errors_title))) { @@ -266,6 +268,17 @@ private void processSectionContent(String sectionType, String content) { systemCard.setVisibility(View.VISIBLE); systemContent.setText(trimmedContent); break; + + case "dns": + systemCard.setVisibility(View.VISIBLE); + String existingSystem = systemContent.getText().toString(); + String dnsContent = getString(R.string.dns_diagnostics_title) + "\n" + trimmedContent; + if (!existingSystem.isEmpty()) { + systemContent.setText(existingSystem + "\n\n" + dnsContent); + } else { + systemContent.setText(dnsContent); + } + break; case "preferences": preferencesCard.setVisibility(View.VISIBLE); diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/DiagnosticsActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/DiagnosticsActivity.java index b1ae788ed..82eca7ac3 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/DiagnosticsActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/DiagnosticsActivity.java @@ -1,18 +1,63 @@ package dev.ukanth.ufirewall.activity; +import android.app.Activity; +import android.content.ActivityNotFoundException; import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.view.MenuItem; +import android.view.SubMenu; +import com.afollestad.materialdialogs.MaterialDialog; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.broadcast.DnsBlocklistUpdateReceiver; +import dev.ukanth.ufirewall.dns.DnsHijackManager; +import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.util.ApplicationErrorLog; +import dev.ukanth.ufirewall.util.G; public class DiagnosticsActivity extends RulesActivity { + private static final int REQUEST_EXPORT_DNS_DIAGNOSTICS = 5060; + private static final int MENU_DNS_RELOAD = 101; + private static final int MENU_DNS_RESTART = 102; + private static final int MENU_DNS_STOP = 103; + private static final int MENU_DNS_REPAIR = 104; + private static final int MENU_DNS_EXPORT = 105; + private static final int MENU_DNS_EMERGENCY_CLEANUP = 106; + private static final int MENU_DNS_AUTH_TEST = 107; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setTitle(getString(R.string.log_hub_diagnostics)); } + @Override + protected void populateMenu(SubMenu sub) { + super.populateMenu(sub); + sub.add(0, MENU_DNS_REPAIR, 0, R.string.dns_diagnostics_repair).setIcon(R.drawable.ic_apply_menu); + sub.add(0, MENU_DNS_RELOAD, 0, R.string.dns_diagnostics_reload).setIcon(R.drawable.ic_refresh); + sub.add(0, MENU_DNS_RESTART, 0, R.string.dns_diagnostics_restart).setIcon(R.drawable.ic_apply_menu); + sub.add(0, MENU_DNS_STOP, 0, R.string.dns_diagnostics_stop).setIcon(R.drawable.ic_clearlog); + sub.add(0, MENU_DNS_AUTH_TEST, 0, R.string.dns_dashboard_auth_test) + .setIcon(R.drawable.ic_apply_menu); + sub.add(0, MENU_DNS_EMERGENCY_CLEANUP, 0, R.string.dns_diagnostics_emergency_cleanup) + .setIcon(R.drawable.ic_clearlog); + sub.add(0, MENU_DNS_EXPORT, 0, R.string.dns_diagnostics_export).setIcon(R.drawable.ic_export); + } + @Override protected void populateData(final Context ctx) { result = new StringBuilder(); @@ -24,4 +69,276 @@ protected void populateData(final Context ctx) { protected boolean includeApplicationLog() { return false; } + + @Override + protected void appendIfconfig(final Context ctx) { + writeHeading(result, true, "ifconfig"); + updateLoadingState(getString(R.string.loading_system_info)); + Api.runIfconfig(ctx, new RootCommand() + .setLogging(true) + .setCallback(new RootCommand.Callback() { + public void cbFunc(RootCommand state) { + result.append(state.res); + appendDnsDiagnostics(ctx); + } + })); + } + + private void appendDnsDiagnostics(final Context ctx) { + writeHeading(result, true, getString(R.string.dns_diagnostics_title)); + result.append(DnsHijackManager.collectLocalDiagnostics(ctx)); + result.append("\n[control auth self-test]\n"); + result.append(DnsHijackManager.runControlAuthSelfTest(ctx)); + updateLoadingState(getString(R.string.dns_diagnostics_loading)); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + if (!isActivityActive()) { + return; + } + result.append("\n[root checks]\n"); + if (state.res != null) { + result.append(state.res); + } else { + result.append("No root diagnostic output.\n"); + } + appendSystemInfo(ctx); + } + }) + .run(ctx, DnsHijackManager.buildRootDiagnosticsCommands(ctx)); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case MENU_DNS_REPAIR: + runDnsRepair(); + return true; + case MENU_DNS_RELOAD: + runDnsAction("reload", getString(R.string.dns_diagnostics_reload_queued)); + return true; + case MENU_DNS_RESTART: + runDnsRestart(); + return true; + case MENU_DNS_STOP: + runDnsAction("stop", getString(R.string.dns_diagnostics_stop_queued)); + return true; + case MENU_DNS_AUTH_TEST: + runDnsAuthTest(); + return true; + case MENU_DNS_EMERGENCY_CLEANUP: + confirmDnsEmergencyCleanup(); + return true; + case MENU_DNS_EXPORT: + startDnsDiagnosticsExportPicker(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_EXPORT_DNS_DIAGNOSTICS && resultCode == Activity.RESULT_OK) { + exportDnsDiagnosticsToUri(data); + } + } + + private void runDnsRepair() { + String successMessage = getString(R.string.dns_diagnostics_repair_queued); + updateLoadingState(successMessage); + DnsHijackManager.repairDnsProtection(this, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + runOnUiThread(() -> { + if (!isActivityActive()) { + return; + } + if (state.exitCode == 0) { + Api.toast(DiagnosticsActivity.this, successMessage); + } else { + Api.toast(DiagnosticsActivity.this, getString(R.string.error_apply)); + } + populateData(DiagnosticsActivity.this); + }); + } + }); + } + + private void runDnsRestart() { + String successMessage = getString(R.string.dns_diagnostics_restart_queued); + updateLoadingState(successMessage); + DnsHijackManager.repairDnsProtection(this, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + runOnUiThread(() -> { + if (!isActivityActive()) { + return; + } + if (state.exitCode == 0) { + Api.toast(DiagnosticsActivity.this, successMessage); + } else { + Api.toast(DiagnosticsActivity.this, getString(R.string.error_apply)); + } + populateData(DiagnosticsActivity.this); + }); + } + }); + } + + private void runDnsAction(String action, String successMessage) { + updateLoadingState(successMessage); + DnsHijackManager.runSupervisorAction(this, action, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + runOnUiThread(() -> { + if (!isActivityActive()) { + return; + } + if (state.exitCode == 0) { + Api.toast(DiagnosticsActivity.this, successMessage); + } else { + Api.toast(DiagnosticsActivity.this, getString(R.string.error_apply)); + } + populateData(DiagnosticsActivity.this); + }); + } + }); + } + + private void runDnsAuthTest() { + updateLoadingState(getString(R.string.dns_dashboard_auth_test_queued)); + new Thread(() -> { + String result = DnsHijackManager.runControlAuthSelfTest(this); + runOnUiThread(() -> { + if (!isActivityActive()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_dashboard_auth_test_title) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.no_data_available) + : result) + .positiveText(R.string.OK) + .show(); + populateData(DiagnosticsActivity.this); + }); + }, "dns-auth-test").start(); + } + + private void confirmDnsEmergencyCleanup() { + new MaterialDialog.Builder(this) + .title(R.string.dns_hijack_emergency_cleanup_title) + .content(R.string.dns_hijack_emergency_cleanup_confirm) + .positiveText(R.string.OK) + .negativeText(android.R.string.cancel) + .onPositive((dialog, which) -> runDnsEmergencyCleanup()) + .show(); + } + + private void runDnsEmergencyCleanup() { + updateLoadingState(getString(R.string.dns_hijack_emergency_cleanup_queued)); + ApplicationErrorLog.add(this, "DNS emergency cleanup requested from diagnostics"); + DnsHijackManager.emergencyCleanupDnsProtection(this, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + runOnUiThread(() -> { + if (!isActivityActive()) { + return; + } + if (state.exitCode == 0) { + G.enableDnsHijack(false); + G.dnsHijackBootPersistence(false); + Api.setRulesUpToDate(false); + DnsBlocklistUpdateReceiver.scheduleOrCancel(DiagnosticsActivity.this); + Api.toast(DiagnosticsActivity.this, + getString(R.string.dns_hijack_emergency_cleanup_complete)); + } else { + Api.toast(DiagnosticsActivity.this, + getString(R.string.dns_hijack_emergency_cleanup_failed)); + } + populateData(DiagnosticsActivity.this); + }); + } + }); + } + + private void startDnsDiagnosticsExportPicker() { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TITLE, buildDnsDiagnosticsExportFilename()); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + try { + startActivityForResult(intent, REQUEST_EXPORT_DNS_DIAGNOSTICS); + } catch (ActivityNotFoundException e) { + Api.copyToClipboard(this, currentDnsDiagnosticsText()); + Api.toast(this, getString(R.string.dns_diagnostics_export_picker_missing)); + ApplicationErrorLog.add(this, + "DNS diagnostics export picker unavailable; report copied to clipboard"); + } + } + + private void exportDnsDiagnosticsToUri(Intent data) { + if (data == null || data.getData() == null) { + Api.toast(this, getString(R.string.export_logs_fail)); + return; + } + Uri uri = data.getData(); + takePersistablePermission(data, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + try (OutputStream output = getContentResolver().openOutputStream(uri, "wt")) { + if (output == null) { + throw new IOException("Unable to open selected diagnostics export location"); + } + output.write(currentDnsDiagnosticsText().getBytes(StandardCharsets.UTF_8)); + output.flush(); + Api.toast(this, getString(R.string.dns_diagnostics_export_success)); + ApplicationErrorLog.add(this, "DNS diagnostics exported through system picker"); + } catch (IOException | RuntimeException e) { + Api.toast(this, getString(R.string.export_logs_fail)); + ApplicationErrorLog.add(this, "DNS diagnostics export failed: " + e.getMessage()); + } + } + + private void takePersistablePermission(Intent data, int permissionFlag) { + Uri uri = data.getData(); + if (uri == null) { + return; + } + int flags = data.getFlags() & permissionFlag; + if (flags == 0) { + return; + } + try { + getContentResolver().takePersistableUriPermission(uri, flags); + } catch (SecurityException e) { + ApplicationErrorLog.add(this, + "DNS diagnostics export URI permission could not be persisted: " + + e.getMessage()); + } + } + + private String buildDnsDiagnosticsExportFilename() { + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US) + .format(new Date()); + return "afwall-dns-diagnostics-" + timestamp + ".log"; + } + + private String currentDnsDiagnosticsText() { + if (dataText != null && !dataText.trim().isEmpty()) { + return dataText; + } + return result == null ? "" : result.toString(); + } + + private boolean isActivityActive() { + return !isFinishing() + && (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 || !isDestroyed()); + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/DnsQueriesActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/DnsQueriesActivity.java new file mode 100644 index 000000000..00c87ec93 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/DnsQueriesActivity.java @@ -0,0 +1,565 @@ +package dev.ukanth.ufirewall.activity; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Menu; +import android.view.MenuItem; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.widget.Toolbar; + +import com.afollestad.materialdialogs.MaterialDialog; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.dns.DnsHijackManager; +import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.util.ApplicationErrorLog; +import dev.ukanth.ufirewall.util.G; +import dev.ukanth.ufirewall.util.ThemeHelper; + +public class DnsQueriesActivity extends AppCompatActivity { + + private static final String TAG = "AFWallDnsQueries"; + private static final int REQUEST_EXPORT_DNS_QUERIES = 50; + private static final int MENU_REFRESH = 1; + private static final int MENU_COPY = 2; + private static final int MENU_SEARCH = 3; + private static final int MENU_TOGGLE_HISTORY = 4; + private static final int MENU_EXPORT = 5; + private static final int QUERY_ACTION_VIEW_APP = 100; + private static final int QUERY_ACTION_VIEW_RULE = 101; + private static final int QUERY_ACTION_ENABLE_DNS_CAPTURE = 102; + private static final int QUERY_ACTION_DISABLE_DNS_CAPTURE = 103; + private static final long REFRESH_MS = 2500L; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final List entries = new ArrayList<>(); + private final Map uidLabelCache = new HashMap<>(); + private final Map uidPackageCache = new HashMap<>(); + private ArrayAdapter adapter; + private TextView status; + private boolean showHistory; + private String historyFilter = ""; + + private final Runnable autoRefresh = new Runnable() { + @Override + public void run() { + if (!showHistory) { + loadQueries(false); + handler.postDelayed(this, REFRESH_MS); + } + } + }; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTheme(G.getSelectedThemeStyle(this)); + setContentView(R.layout.activity_dns_queries); + + Toolbar toolbar = findViewById(R.id.dns_queries_toolbar); + setSupportActionBar(toolbar); + setTitle(getString(R.string.dns_queries_title)); + toolbar.setNavigationOnClickListener(v -> finish()); + if (getSupportActionBar() != null) { + getSupportActionBar().setHomeButtonEnabled(true); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + ThemeHelper.apply(this); + + status = findViewById(R.id.dns_queries_status); + ListView list = findViewById(R.id.dns_queries_list); + adapter = new ArrayAdapter<>(this, android.R.layout.simple_list_item_1, new ArrayList<>()); + list.setAdapter(adapter); + list.setOnItemClickListener((parent, view, position, id) -> showQueryActions(entries.get(position))); + loadQueries(true); + } + + @Override + protected void onResume() { + super.onResume(); + if (!showHistory) { + handler.postDelayed(autoRefresh, REFRESH_MS); + } + } + + @Override + protected void onPause() { + handler.removeCallbacks(autoRefresh); + super.onPause(); + } + + @Override + protected void onDestroy() { + executor.shutdownNow(); + super.onDestroy(); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + menu.add(0, MENU_REFRESH, 0, R.string.refresh).setIcon(R.drawable.ic_refresh); + menu.add(0, MENU_SEARCH, 0, R.string.Search).setIcon(R.drawable.ic_search); + menu.add(0, MENU_TOGGLE_HISTORY, 0, + showHistory ? R.string.dns_queries_live : R.string.dns_queries_history); + menu.add(0, MENU_COPY, 0, R.string.copy).setIcon(R.drawable.ic_copy); + menu.add(0, MENU_EXPORT, 0, R.string.export_to_sd).setIcon(R.drawable.ic_export); + return true; + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + switch (item.getItemId()) { + case android.R.id.home: + finish(); + return true; + case MENU_REFRESH: + loadQueries(true); + return true; + case MENU_SEARCH: + showSearchDialog(); + return true; + case MENU_TOGGLE_HISTORY: + setHistoryMode(!showHistory); + return true; + case MENU_COPY: + Api.copyToClipboard(this, buildTextDump()); + return true; + case MENU_EXPORT: + startQueryExportPicker(); + return true; + default: + return super.onOptionsItemSelected(item); + } + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_EXPORT_DNS_QUERIES && resultCode == Activity.RESULT_OK) { + exportQueriesToUri(data); + } + } + + private void loadQueries(boolean showLoading) { + if (executor.isShutdown()) { + return; + } + if (showLoading) { + status.setText(R.string.loading); + } + final boolean useHistory = showHistory; + final String filter = historyFilter; + executor.execute(() -> { + List latest = useHistory + ? DnsHijackManager.getHistoricalQueries(this, filter) + : DnsHijackManager.getRecentQueries(this); + runOnUiThread(() -> updateQueries(latest, useHistory, filter)); + }); + } + + private void updateQueries(List latest, boolean history, String filter) { + entries.clear(); + adapter.clear(); + for (int i = latest.size() - 1; i >= 0; i--) { + DnsHijackManager.QueryEntry entry = latest.get(i); + entries.add(entry); + adapter.add(formatEntry(entry)); + } + adapter.notifyDataSetChanged(); + if (history) { + if (filter == null || filter.trim().isEmpty()) { + status.setText(getString(R.string.dns_queries_history_status, latest.size())); + } else { + status.setText(getString(R.string.dns_queries_history_filter_status, + latest.size(), filter.trim())); + } + } else { + status.setText(getString(R.string.dns_queries_status, latest.size())); + } + } + + private String formatEntry(DnsHijackManager.QueryEntry entry) { + String time = entry.timestamp > 0 + ? new SimpleDateFormat("HH:mm:ss", Locale.US).format(new Date(entry.timestamp * 1000L)) + : "--:--:--"; + return time + " " + entry.result + " " + entry.domain + " " + entry.latency + + "\n" + entry.transport.toUpperCase(Locale.US) + " " + entry.qtype + + " app=" + resolveAppLabel(entry) + " source=" + entry.source + " uid=" + entry.uid + + " rule=" + entry.rule + " upstream=" + entry.upstream; + } + + private void showQueryActions(DnsHijackManager.QueryEntry entry) { + if (entry == null || !entry.hasDomain()) { + return; + } + List labels = new ArrayList<>(); + List actions = new ArrayList<>(); + addQueryAction(labels, actions, R.string.dns_query_allow_exact, + DnsHijackManager.RULE_ALLOW_EXACT); + addQueryAction(labels, actions, R.string.dns_query_allow_suffix, + DnsHijackManager.RULE_ALLOW_SUFFIX); + addQueryAction(labels, actions, R.string.dns_query_temp_allow, + DnsHijackManager.RULE_TEMP_ALLOW); + int uid = parseQueryUid(entry); + if (uid >= 0) { + boolean captured = G.dnsHijackUidCaptured(uid); + addQueryAction(labels, actions, captured + ? R.string.dns_query_disable_dns_capture + : R.string.dns_query_enable_dns_capture, + captured + ? QUERY_ACTION_DISABLE_DNS_CAPTURE + : QUERY_ACTION_ENABLE_DNS_CAPTURE); + addQueryAction(labels, actions, R.string.dns_query_app_allow_exact, + DnsHijackManager.RULE_APP_ALLOW_EXACT); + addQueryAction(labels, actions, R.string.dns_query_app_allow_suffix, + DnsHijackManager.RULE_APP_ALLOW_SUFFIX); + } + addQueryAction(labels, actions, R.string.dns_query_block_exact, + DnsHijackManager.RULE_BLOCK_EXACT); + addQueryAction(labels, actions, R.string.dns_query_block_suffix, + DnsHijackManager.RULE_BLOCK_SUFFIX); + addQueryAction(labels, actions, R.string.dns_query_temp_block, + DnsHijackManager.RULE_TEMP_BLOCK); + if (uid >= 0) { + addQueryAction(labels, actions, R.string.dns_query_app_block_exact, + DnsHijackManager.RULE_APP_BLOCK_EXACT); + addQueryAction(labels, actions, R.string.dns_query_app_block_suffix, + DnsHijackManager.RULE_APP_BLOCK_SUFFIX); + } + addQueryAction(labels, actions, R.string.dns_query_view_rule, + QUERY_ACTION_VIEW_RULE); + if (resolveAppPackage(entry) != null) { + addQueryAction(labels, actions, R.string.dns_query_view_app, + QUERY_ACTION_VIEW_APP); + } + new MaterialDialog.Builder(this) + .title(entry.domain) + .items(labels) + .itemsCallback((dialog, view, which, text) -> applyQueryAction(entry, actions.get(which))) + .negativeText(R.string.Cancel) + .show(); + } + + private void addQueryAction(List labels, List actions, + int labelRes, int action) { + labels.add(getString(labelRes)); + actions.add(action); + } + + private void applyQueryAction(DnsHijackManager.QueryEntry entry, int actionId) { + int action; + if (actionId == QUERY_ACTION_VIEW_APP) { + openQueryApp(entry); + return; + } + if (actionId == QUERY_ACTION_VIEW_RULE) { + showRuleDetails(entry); + return; + } + if (actionId == QUERY_ACTION_ENABLE_DNS_CAPTURE + || actionId == QUERY_ACTION_DISABLE_DNS_CAPTURE) { + updateQueryAppDnsCapture(entry, actionId == QUERY_ACTION_ENABLE_DNS_CAPTURE); + return; + } + switch (actionId) { + case DnsHijackManager.RULE_ALLOW_EXACT: + action = DnsHijackManager.RULE_ALLOW_EXACT; + break; + case DnsHijackManager.RULE_ALLOW_SUFFIX: + action = DnsHijackManager.RULE_ALLOW_SUFFIX; + break; + case DnsHijackManager.RULE_TEMP_ALLOW: + action = DnsHijackManager.RULE_TEMP_ALLOW; + break; + case DnsHijackManager.RULE_APP_ALLOW_EXACT: + action = DnsHijackManager.RULE_APP_ALLOW_EXACT; + break; + case DnsHijackManager.RULE_APP_ALLOW_SUFFIX: + action = DnsHijackManager.RULE_APP_ALLOW_SUFFIX; + break; + case DnsHijackManager.RULE_BLOCK_EXACT: + action = DnsHijackManager.RULE_BLOCK_EXACT; + break; + case DnsHijackManager.RULE_BLOCK_SUFFIX: + action = DnsHijackManager.RULE_BLOCK_SUFFIX; + break; + case DnsHijackManager.RULE_TEMP_BLOCK: + action = DnsHijackManager.RULE_TEMP_BLOCK; + break; + case DnsHijackManager.RULE_APP_BLOCK_EXACT: + action = DnsHijackManager.RULE_APP_BLOCK_EXACT; + break; + case DnsHijackManager.RULE_APP_BLOCK_SUFFIX: + action = DnsHijackManager.RULE_APP_BLOCK_SUFFIX; + break; + default: + return; + } + boolean added = DnsHijackManager.addRuleFromQuery(this, entry, action); + Api.setRulesUpToDate(false); + Api.toast(this, getString(added ? R.string.dns_query_rule_added : R.string.dns_query_rule_exists)); + loadQueries(false); + } + + private void showRuleDetails(DnsHijackManager.QueryEntry entry) { + String timestamp = entry.timestamp > 0 + ? new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + .format(new Date(entry.timestamp * 1000L)) + : "unknown"; + StringBuilder details = new StringBuilder(); + details.append("Time: ").append(timestamp).append('\n'); + details.append("Domain: ").append(entry.domain).append('\n'); + details.append("Decision: ").append(entry.result).append('\n'); + details.append("Rule source: ").append(entry.rule).append('\n'); + details.append("Action: ").append(entry.action).append('\n'); + details.append("Query type: ").append(entry.qtype).append('\n'); + details.append("Transport: ").append(entry.transport).append('\n'); + details.append("Source: ").append(entry.source).append('\n'); + details.append("UID: ").append(entry.uid).append('\n'); + details.append("App: ").append(resolveAppLabel(entry)).append('\n'); + details.append("DNS capture: ").append(G.dnsHijackUidCaptured(parseQueryUid(entry)) + ? "enabled" : "disabled").append('\n'); + details.append("Upstream: ").append(entry.upstream).append('\n'); + details.append("Latency: ").append(entry.latency); + + new MaterialDialog.Builder(this) + .title(R.string.dns_query_rule_details_title) + .content(details.toString()) + .positiveText(R.string.OK) + .show(); + } + + private void openQueryApp(DnsHijackManager.QueryEntry entry) { + String packageName = resolveAppPackage(entry); + if (packageName == null) { + Api.toast(this, getString(R.string.dns_query_app_unknown)); + return; + } + try { + Api.showInstalledAppDetails(this, packageName); + } catch (RuntimeException e) { + Log.w(TAG, "Unable to open app details for DNS query UID", e); + Api.toast(this, getString(R.string.dns_query_app_unknown)); + } + } + + private void updateQueryAppDnsCapture(DnsHijackManager.QueryEntry entry, boolean enabled) { + int uid = parseQueryUid(entry); + if (uid < 0) { + Api.toast(this, getString(R.string.dns_query_app_unknown)); + return; + } + boolean changed = G.dnsHijackUidCaptured(uid, enabled); + if (!changed) { + Api.toast(this, getString(R.string.dns_query_dns_capture_unchanged)); + return; + } + Api.setRulesUpToDate(false); + int toast = enabled + ? R.string.dns_query_dns_capture_enabled + : R.string.dns_query_dns_capture_disabled; + ApplicationErrorLog.add(this, "DNS capture " + (enabled ? "enabled" : "disabled") + + " for UID " + uid + " from live query action"); + if (!G.enableDnsHijack()) { + Api.toast(this, getString(toast)); + loadQueries(false); + return; + } + DnsHijackManager.repairDnsProtection(this, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + runOnUiThread(() -> { + if (state.exitCode != 0) { + ApplicationErrorLog.add(DnsQueriesActivity.this, + "DNS capture policy changed for UID " + uid + + " but redirect repair failed"); + } + Api.toast(DnsQueriesActivity.this, getString(state.exitCode == 0 + ? toast + : R.string.error_apply)); + loadQueries(false); + }); + } + }); + } + + private String resolveAppLabel(DnsHijackManager.QueryEntry entry) { + int uid = parseQueryUid(entry); + if (uid < 0) { + return "unknown"; + } + String cached = uidLabelCache.get(uid); + if (cached != null) { + return cached; + } + + PackageManager packageManager = getPackageManager(); + String[] packages = packageManager.getPackagesForUid(uid); + if (packages == null || packages.length == 0) { + String fallback = "uid " + uid; + uidLabelCache.put(uid, fallback); + return fallback; + } + + String label = packages[0]; + try { + ApplicationInfo info = packageManager.getApplicationInfo(packages[0], + PackageManager.GET_META_DATA); + CharSequence resolved = packageManager.getApplicationLabel(info); + if (resolved != null && resolved.length() > 0) { + label = resolved.toString(); + } + } catch (PackageManager.NameNotFoundException ignored) { + } + if (packages.length > 1) { + label += " +" + (packages.length - 1); + } + uidLabelCache.put(uid, label); + uidPackageCache.put(uid, packages[0]); + return label; + } + + private String resolveAppPackage(DnsHijackManager.QueryEntry entry) { + int uid = parseQueryUid(entry); + if (uid < 0) { + return null; + } + if (uidPackageCache.containsKey(uid)) { + return uidPackageCache.get(uid); + } + String[] packages = getPackageManager().getPackagesForUid(uid); + String packageName = packages == null || packages.length == 0 ? null : packages[0]; + uidPackageCache.put(uid, packageName); + return packageName; + } + + private int parseQueryUid(DnsHijackManager.QueryEntry entry) { + if (entry == null || entry.uid == null) { + return -1; + } + try { + return Integer.parseInt(entry.uid.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private void setHistoryMode(boolean enabled) { + showHistory = enabled; + if (showHistory) { + handler.removeCallbacks(autoRefresh); + } else { + historyFilter = ""; + handler.removeCallbacks(autoRefresh); + handler.postDelayed(autoRefresh, REFRESH_MS); + } + invalidateOptionsMenu(); + loadQueries(true); + } + + private void showSearchDialog() { + new MaterialDialog.Builder(this) + .title(R.string.Search) + .input(getString(R.string.dns_queries_search_hint), historyFilter, (dialog, input) -> { + historyFilter = input == null ? "" : input.toString().trim(); + showHistory = true; + handler.removeCallbacks(autoRefresh); + invalidateOptionsMenu(); + loadQueries(true); + }) + .negativeText(R.string.Cancel) + .show(); + } + + private void startQueryExportPicker() { + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TITLE, buildExportFilename()); + intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + try { + startActivityForResult(intent, REQUEST_EXPORT_DNS_QUERIES); + } catch (ActivityNotFoundException e) { + Log.w(TAG, "System export picker unavailable for DNS query export", e); + Api.copyToClipboard(this, buildTextDump()); + Api.toast(this, getString(R.string.dns_queries_export_picker_missing)); + } + } + + private void exportQueriesToUri(Intent data) { + if (data == null || data.getData() == null) { + Api.toast(this, getString(R.string.export_logs_fail)); + return; + } + Uri uri = data.getData(); + takePersistablePermission(data, Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + try (OutputStream output = getContentResolver().openOutputStream(uri, "wt")) { + if (output == null) { + throw new IOException("Unable to open selected export location"); + } + output.write(buildTextDump().getBytes(StandardCharsets.UTF_8)); + output.flush(); + Api.toast(this, getString(R.string.dns_queries_export_success)); + } catch (IOException | RuntimeException e) { + Log.e(TAG, "Unable to export DNS queries", e); + Api.toast(this, getString(R.string.export_logs_fail)); + } + } + + private void takePersistablePermission(Intent data, int permissionFlag) { + Uri uri = data.getData(); + if (uri == null) { + return; + } + int flags = data.getFlags() & permissionFlag; + if (flags == 0) { + return; + } + try { + getContentResolver().takePersistableUriPermission(uri, flags); + } catch (SecurityException e) { + Log.w(TAG, "Unable to persist DNS query export URI permission", e); + } + } + + private String buildExportFilename() { + String timestamp = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss", Locale.US).format(new Date()); + return "afwall-dns-queries-" + timestamp + ".log"; + } + + private String buildTextDump() { + StringBuilder dump = new StringBuilder(); + for (DnsHijackManager.QueryEntry entry : entries) { + dump.append(entry.displayLine()).append('\n'); + } + return dump.toString(); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/LogHubActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/LogHubActivity.java index c9108024b..0b03572a0 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/LogHubActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/LogHubActivity.java @@ -11,7 +11,8 @@ import android.os.Environment; import android.os.Handler; import android.os.Looper; -import android.view.View; +import android.widget.Button; +import android.widget.TextView; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; @@ -35,12 +36,16 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; import dev.ukanth.ufirewall.BuildConfig; import dev.ukanth.ufirewall.R; import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.broadcast.DnsBlocklistUpdateReceiver; +import dev.ukanth.ufirewall.dns.DnsBlocklistManager; +import dev.ukanth.ufirewall.dns.DnsHijackManager; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.log.LogInfo; import dev.ukanth.ufirewall.service.RootCommand; @@ -56,6 +61,16 @@ public class LogHubActivity extends AppCompatActivity { private static final int EXPORT_IPTABLES_IPV6 = 2; private static final int EXPORT_APPLICATION_LOG = 3; private static final int EXPORT_APPLICATION_ERRORS = 4; + private static final int EXPORT_DNS_QUERIES = 5; + private static final int EXPORT_DNS_SERVICE_EVENTS = 6; + + private final ExecutorService dashboardExecutor = Executors.newSingleThreadExecutor(); + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + // Root status checks can complete after newer actions; version refreshes so old results stay hidden. + private final AtomicInteger dashboardLoadGeneration = new AtomicInteger(); + private TextView dnsDashboardStatus; + private TextView dnsDashboardDetails; + private Button dnsDashboardPauseResume; @Override protected void onCreate(Bundle savedInstanceState) { @@ -74,7 +89,48 @@ protected void onCreate(Bundle savedInstanceState) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); } + dnsDashboardStatus = findViewById(R.id.log_hub_dns_dashboard_status); + dnsDashboardDetails = findViewById(R.id.log_hub_dns_dashboard_details); + dnsDashboardPauseResume = findViewById(R.id.log_hub_dns_dashboard_pause_resume); + findViewById(R.id.log_hub_dns_dashboard_queries) + .setOnClickListener(v -> startActivity(new Intent(this, DnsQueriesActivity.class))); + findViewById(R.id.log_hub_dns_dashboard_capture_test) + .setOnClickListener(v -> runDashboardDnsCaptureTest()); + findViewById(R.id.log_hub_dns_dashboard_service_events) + .setOnClickListener(v -> showDashboardDnsServiceEvents()); + findViewById(R.id.log_hub_dns_dashboard_validate) + .setOnClickListener(v -> runDashboardDnsValidation()); + findViewById(R.id.log_hub_dns_dashboard_auth_test) + .setOnClickListener(v -> runDashboardDnsAuthTest()); + findViewById(R.id.log_hub_dns_dashboard_benchmark) + .setOnClickListener(v -> runDashboardDnsBenchmark()); + dnsDashboardPauseResume.setOnClickListener(v -> runDashboardPauseResume()); + findViewById(R.id.log_hub_dns_dashboard_update) + .setOnClickListener(v -> runDashboardBlocklistUpdate()); + findViewById(R.id.log_hub_dns_dashboard_flush_cache) + .setOnClickListener(v -> runDashboardDaemonMaintenance( + "flush_cache", + R.string.dns_dashboard_flush_cache_title, + R.string.dns_dashboard_flush_cache_queued)); + findViewById(R.id.log_hub_dns_dashboard_flush_logs) + .setOnClickListener(v -> runDashboardDaemonMaintenance( + "flush_logs", + R.string.dns_dashboard_flush_logs_title, + R.string.dns_dashboard_flush_logs_queued)); + findViewById(R.id.log_hub_dns_dashboard_clear_logs) + .setOnClickListener(v -> confirmDashboardClearDnsLogs()); + findViewById(R.id.log_hub_dns_dashboard_restart) + .setOnClickListener(v -> runDashboardDnsRestart()); + findViewById(R.id.log_hub_dns_dashboard_repair) + .setOnClickListener(v -> runDashboardDnsRepair()); + findViewById(R.id.log_hub_dns_dashboard_module_status) + .setOnClickListener(v -> showDashboardDnsModuleStatus()); + findViewById(R.id.log_hub_dns_dashboard_remove_service) + .setOnClickListener(v -> confirmDashboardDnsServiceRemoval()); + findViewById(R.id.log_hub_dns_dashboard_emergency_cleanup) + .setOnClickListener(v -> confirmDashboardEmergencyDnsCleanup()); findViewById(R.id.log_hub_blocked_requests).setOnClickListener(v -> openBlockedRequests()); + findViewById(R.id.log_hub_dns_queries).setOnClickListener(v -> startActivity(new Intent(this, DnsQueriesActivity.class))); findViewById(R.id.log_hub_iptables).setOnClickListener(v -> startActivity(new Intent(this, RulesActivity.class))); findViewById(R.id.log_hub_diagnostics).setOnClickListener(v -> startActivity(new Intent(this, DiagnosticsActivity.class))); findViewById(R.id.log_hub_application_log).setOnClickListener(v -> startActivity(new Intent(this, ApplicationLogActivity.class))); @@ -82,17 +138,527 @@ protected void onCreate(Bundle savedInstanceState) { findViewById(R.id.log_hub_export_logs).setOnClickListener(v -> showExportLogSelection()); } + @Override + protected void onResume() { + super.onResume(); + loadDnsDashboard(); + } + + @Override + protected void onDestroy() { + dashboardExecutor.shutdownNow(); + super.onDestroy(); + } + private void openBlockedRequests() { Intent intent = new Intent(this, G.oldLogView() ? OldLogActivity.class : LogActivity.class); startActivity(intent); } + private void loadDnsDashboard() { + if (!canUseDashboardUi()) { + return; + } + int generation = dashboardLoadGeneration.incrementAndGet(); + dnsDashboardStatus.setText(R.string.dns_dashboard_loading); + dnsDashboardDetails.setText(R.string.dns_dashboard_loading); + dashboardExecutor.execute(() -> { + DnsHijackManager.DnsDashboardSnapshot snapshot = + DnsHijackManager.getDashboardSnapshot(this); + mainHandler.post(() -> { + if (!canUseDashboardUi() || generation != dashboardLoadGeneration.get()) { + return; + } + dnsDashboardStatus.setText(snapshot.statusLine); + dnsDashboardDetails.setText(snapshot.detailLine); + if (dnsDashboardPauseResume != null) { + dnsDashboardPauseResume.setText(G.enableDnsHijack() + ? R.string.dns_dashboard_pause + : R.string.dns_dashboard_resume); + } + loadDnsRedirectStatus(snapshot, generation); + }); + }); + } + + private void loadDnsRedirectStatus(DnsHijackManager.DnsDashboardSnapshot snapshot, int generation) { + if (!canUseDashboardUi() || generation != dashboardLoadGeneration.get()) { + return; + } + String baseDetails = snapshot == null ? "" : snapshot.detailLine; + if (!G.enableDnsHijack()) { + dnsDashboardDetails.setText(baseDetails + + "\nRedirect rules: disabled" + + "\n" + DnsHijackManager.formatDashboardReadiness(snapshot, "")); + return; + } + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + String output = state.res == null ? "" : state.res.toString(); + String redirectStatus = DnsHijackManager.formatRootRedirectStatus(output); + if (state.exitCode != 0) { + ApplicationErrorLog.add(LogHubActivity.this, + "DNS dashboard redirect status check failed"); + } else if (!DnsHijackManager.isRootRedirectStatusHealthy(output)) { + ApplicationErrorLog.add(LogHubActivity.this, + "DNS dashboard redirect status needs repair: " + redirectStatus); + } + mainHandler.post(() -> { + if (!canUseDashboardUi() + || generation != dashboardLoadGeneration.get()) { + return; + } + dnsDashboardDetails.setText(baseDetails + "\n" + redirectStatus + + "\n" + DnsHijackManager.formatDashboardReadiness( + snapshot, output)); + }); + } + }) + .run(getApplicationContext(), DnsHijackManager.buildRootRedirectStatusCommands(this)); + } + + private void runDashboardPauseResume() { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + boolean pause = G.enableDnsHijack(); + String message = getString(pause + ? R.string.dns_dashboard_pause_queued + : R.string.dns_dashboard_resume_queued); + dnsDashboardStatus.setText(message); + RootCommand.Callback callback = new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + if (state.exitCode == 0) { + Api.toast(LogHubActivity.this, message); + DnsBlocklistUpdateReceiver.scheduleOrCancel(LogHubActivity.this); + } else { + Api.toast(LogHubActivity.this, getString(R.string.error_apply)); + } + loadDnsDashboard(); + }); + } + }; + if (pause) { + DnsHijackManager.pauseDnsProtection(this, callback); + } else { + DnsHijackManager.resumeDnsProtection(this, callback); + } + } + + private void runDashboardBlocklistUpdate() { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(R.string.dns_hijack_update_blocklists_title); + dashboardExecutor.execute(() -> { + DnsBlocklistManager.Result result = DnsBlocklistManager.updateFromConfiguredUrls(this); + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + handleDashboardBlocklistResult(result); + }); + }); + } + + private void handleDashboardBlocklistResult(DnsBlocklistManager.Result result) { + if (result == null || !canUseDashboardUi()) { + return; + } + if (!result.failed) { + Api.setRulesUpToDate(false); + if (G.enableDnsHijack()) { + DnsHijackManager.requestReload(this); + } + DnsBlocklistUpdateReceiver.scheduleOrCancel(this); + Api.toast(this, getString(R.string.dns_hijack_blocklist_active)); + } else { + Api.toast(this, getString(R.string.dns_hijack_blocklist_failed)); + } + new MaterialDialog.Builder(this) + .title(result.failed ? R.string.dns_hijack_blocklist_failed : R.string.dns_hijack_blocklist_active) + .content(result.summary()) + .positiveText(R.string.OK) + .show(); + loadDnsDashboard(); + } + + private void runDashboardDnsCaptureTest() { + if (!canUseDashboardUi()) { + return; + } + if (!G.enableDnsHijack()) { + Api.toast(this, getString(R.string.dns_dashboard_disabled)); + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(R.string.dns_dashboard_capture_test_queued); + dashboardExecutor.execute(() -> { + String result = DnsHijackManager.runCaptureProbe(this); + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_dashboard_capture_test_title) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.no_data_available) + : result) + .positiveText(R.string.OK) + .show(); + loadDnsDashboard(); + }); + }); + } + + private void runDashboardDnsRepair() { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + String message = getString(R.string.dns_diagnostics_repair_queued); + dnsDashboardStatus.setText(message); + DnsHijackManager.repairDnsProtection(this, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + if (state.exitCode == 0) { + Api.toast(LogHubActivity.this, message); + } else { + Api.toast(LogHubActivity.this, getString(R.string.error_apply)); + } + loadDnsDashboard(); + }); + } + }); + } + + private void confirmDashboardDnsServiceRemoval() { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_hijack_remove_service_title) + .content(R.string.dns_hijack_remove_service_confirm) + .positiveText(R.string.OK) + .negativeText(android.R.string.cancel) + .onPositive((dialog, which) -> runDashboardDnsServiceRemoval()) + .show(); + } + + private void runDashboardDnsServiceRemoval() { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + G.enableDnsHijack(false); + G.dnsHijackBootPersistence(false); + Api.setRulesUpToDate(false); + DnsBlocklistUpdateReceiver.scheduleOrCancel(this); + String message = getString(R.string.dns_dashboard_remove_queued); + dnsDashboardStatus.setText(message); + ApplicationErrorLog.add(this, "DNS dashboard service removal requested"); + DnsHijackManager.applyDnsProtectionPreference(this, false, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + if (state.exitCode == 0) { + Api.toast(LogHubActivity.this, + getString(R.string.dns_hijack_disable_complete)); + } else { + ApplicationErrorLog.add(LogHubActivity.this, + "DNS dashboard service removal root cleanup failed; leaving DNS settings disabled for fail-open recovery"); + Api.toast(LogHubActivity.this, + getString(R.string.dns_hijack_disable_failed)); + } + loadDnsDashboard(); + }); + } + }); + } + + private void confirmDashboardEmergencyDnsCleanup() { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_hijack_emergency_cleanup_title) + .content(R.string.dns_hijack_emergency_cleanup_confirm) + .positiveText(R.string.OK) + .negativeText(android.R.string.cancel) + .onPositive((dialog, which) -> runDashboardEmergencyDnsCleanup()) + .show(); + } + + private void runDashboardEmergencyDnsCleanup() { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(R.string.dns_hijack_emergency_cleanup_queued); + ApplicationErrorLog.add(this, "DNS dashboard emergency cleanup requested"); + DnsHijackManager.emergencyCleanupDnsProtection(this, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + if (state.exitCode == 0) { + G.enableDnsHijack(false); + G.dnsHijackBootPersistence(false); + Api.setRulesUpToDate(false); + DnsBlocklistUpdateReceiver.scheduleOrCancel(LogHubActivity.this); + Api.toast(LogHubActivity.this, + getString(R.string.dns_hijack_emergency_cleanup_complete)); + } else { + Api.toast(LogHubActivity.this, + getString(R.string.dns_hijack_emergency_cleanup_failed)); + } + loadDnsDashboard(); + }); + } + }); + } + + private void showDashboardDnsServiceEvents() { + if (!canUseDashboardUi()) { + return; + } + dashboardExecutor.execute(() -> { + String events = DnsHijackManager.getRecentServiceEvents(this); + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_dashboard_service_events_title) + .content(events == null || events.trim().isEmpty() + ? getString(R.string.dns_dashboard_service_events_empty) + : events) + .positiveText(R.string.OK) + .show(); + }); + }); + } + + private void runDashboardDnsValidation() { + if (!canUseDashboardUi()) { + return; + } + if (!G.enableDnsHijack()) { + Api.toast(this, getString(R.string.dns_dashboard_disabled)); + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(R.string.dns_dashboard_validate_queued); + dashboardExecutor.execute(() -> { + String result = DnsHijackManager.validateDnsConfiguration(this); + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_dashboard_validate_title) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.no_data_available) + : result) + .positiveText(R.string.OK) + .show(); + loadDnsDashboard(); + }); + }); + } + + private void runDashboardDnsAuthTest() { + if (!canUseDashboardUi()) { + return; + } + if (!G.enableDnsHijack()) { + Api.toast(this, getString(R.string.dns_dashboard_disabled)); + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(R.string.dns_dashboard_auth_test_queued); + dashboardExecutor.execute(() -> { + String result = DnsHijackManager.runControlAuthSelfTest(this); + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_dashboard_auth_test_title) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.no_data_available) + : result) + .positiveText(R.string.OK) + .show(); + loadDnsDashboard(); + }); + }); + } + + private void runDashboardDnsBenchmark() { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(R.string.dns_dashboard_benchmark_queued); + dashboardExecutor.execute(() -> { + String result = DnsHijackManager.benchmarkUpstreams(this); + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_hijack_benchmark_upstreams_title) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.dns_hijack_benchmark_empty) + : result) + .positiveText(R.string.OK) + .show(); + loadDnsDashboard(); + }); + }); + } + + private void confirmDashboardClearDnsLogs() { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(R.string.dns_dashboard_clear_logs_title) + .content(R.string.dns_dashboard_clear_logs_confirm) + .positiveText(R.string.OK) + .negativeText(android.R.string.cancel) + .onPositive((dialog, which) -> runDashboardDaemonMaintenance( + "clear_logs", + R.string.dns_dashboard_clear_logs_title, + R.string.dns_dashboard_clear_logs_queued)) + .show(); + } + + private void runDashboardDaemonMaintenance(String action, int titleRes, int queuedRes) { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(queuedRes); + dashboardExecutor.execute(() -> { + String result = DnsHijackManager.runDaemonMaintenanceAction(this, action); + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + new MaterialDialog.Builder(this) + .title(titleRes) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.no_data_available) + : result) + .positiveText(R.string.OK) + .show(); + loadDnsDashboard(); + }); + }); + } + + private void showDashboardDnsModuleStatus() { + if (!canUseDashboardUi()) { + return; + } + invalidateDashboardLoads(); + dnsDashboardStatus.setText(R.string.dns_hijack_module_status_queued); + ApplicationErrorLog.add(this, "DNS Magisk module status requested from Log Hub"); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_su) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + String output = state.res == null ? "" : state.res.toString().trim(); + if (state.exitCode == 0) { + ApplicationErrorLog.add(LogHubActivity.this, + "DNS Magisk module status completed"); + } else { + ApplicationErrorLog.add(LogHubActivity.this, + "DNS Magisk module status failed with exit " + + state.exitCode); + } + new MaterialDialog.Builder(LogHubActivity.this) + .title(R.string.dns_hijack_module_status_title) + .content(output.isEmpty() + ? getString(R.string.no_data_available) + : output) + .positiveText(R.string.OK) + .show(); + loadDnsDashboard(); + }); + } + }) + .run(this, DnsHijackManager.buildMagiskModuleStatusCommands(this)); + } + + private void runDashboardDnsRestart() { + if (!canUseDashboardUi()) { + return; + } + if (!G.enableDnsHijack()) { + Api.toast(this, getString(R.string.dns_dashboard_disabled)); + return; + } + invalidateDashboardLoads(); + String message = getString(R.string.dns_diagnostics_restart_queued); + dnsDashboardStatus.setText(message); + DnsHijackManager.runSupervisorAction(this, "restart", new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + mainHandler.post(() -> { + if (!canUseDashboardUi()) { + return; + } + if (state.exitCode == 0) { + Api.toast(LogHubActivity.this, message); + } else { + Api.toast(LogHubActivity.this, getString(R.string.error_apply)); + } + loadDnsDashboard(); + }); + } + }); + } + private void showExportLogSelection() { List items = new ArrayList<>(); List itemValues = new ArrayList<>(); items.add(getString(R.string.log_hub_blocked_requests)); itemValues.add(EXPORT_BLOCKED_REQUESTS); + items.add(getString(R.string.dns_queries_title)); + itemValues.add(EXPORT_DNS_QUERIES); + items.add(getString(R.string.dns_dashboard_service_events_title)); + itemValues.add(EXPORT_DNS_SERVICE_EVENTS); items.add(getString(R.string.export_logs_iptables_ipv4)); itemValues.add(EXPORT_IPTABLES_IPV4); if (G.enableIPv6()) { @@ -197,6 +763,9 @@ private void sendSelectedLogs(Integer[] selectedSections) { } File finalAttachment = attachment; new Handler(Looper.getMainLooper()).post(() -> { + if (!isActivityActive()) { + return; + } if (G.zipLogReports() && finalAttachment != null) { sendZippedLogReport(finalAttachment); } else { @@ -305,6 +874,9 @@ private void writeSelectedLogs(File directory, Integer[] selectedSections) { boolean finalSuccess = success; String finalFilename = filename; new Handler(Looper.getMainLooper()).post(() -> { + if (!isActivityActive()) { + return; + } if (finalSuccess) { Api.toast(this, getString(R.string.export_rules_success) + finalFilename, Toast.LENGTH_LONG); } else { @@ -314,6 +886,7 @@ private void writeSelectedLogs(File directory, Integer[] selectedSections) { } private String buildExportContent(Integer[] selectedSections) { + DnsHijackManager.syncServiceLogsToAppLog(this); StringBuilder builder = new StringBuilder(); for (Integer section : selectedSections) { if (section == null) { @@ -340,11 +913,29 @@ private String buildExportContent(Integer[] selectedSections) { appendExportSection(builder, getString(R.string.application_errors_title), errors.trim().isEmpty() ? getString(R.string.application_errors_empty) : errors); break; + case EXPORT_DNS_QUERIES: + appendExportSection(builder, getString(R.string.dns_queries_title), buildDnsQueryExport()); + break; + case EXPORT_DNS_SERVICE_EVENTS: + String serviceEvents = DnsHijackManager.getRecentServiceEvents(this); + appendExportSection(builder, getString(R.string.dns_dashboard_service_events_title), + serviceEvents.trim().isEmpty() + ? getString(R.string.dns_dashboard_service_events_empty) + : serviceEvents); + break; } } return builder.toString(); } + private String buildDnsQueryExport() { + StringBuilder dns = new StringBuilder(); + for (DnsHijackManager.QueryEntry entry : DnsHijackManager.getRecentQueries(this)) { + dns.append(entry.displayLine()).append('\n'); + } + return dns.toString(); + } + private String fetchIptablesExport(boolean ipv6) { CountDownLatch latch = new CountDownLatch(1); StringBuilder rules = new StringBuilder(); @@ -387,4 +978,20 @@ private void appendExportSection(StringBuilder builder, String title, String con private void initTheme() { setTheme(G.getSelectedThemeStyle(this)); } + + private boolean canUseDashboardUi() { + return isActivityActive() + && !dashboardExecutor.isShutdown() + && dnsDashboardStatus != null + && dnsDashboardDetails != null; + } + + private void invalidateDashboardLoads() { + dashboardLoadGeneration.incrementAndGet(); + } + + private boolean isActivityActive() { + return !isFinishing() + && (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 || !isDestroyed()); + } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java b/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java index 8f9e5345f..667acba53 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java +++ b/app/src/main/java/dev/ukanth/ufirewall/activity/RulesActivity.java @@ -43,6 +43,7 @@ import dev.ukanth.ufirewall.InterfaceDetails; import dev.ukanth.ufirewall.InterfaceTracker; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.dns.DnsHijackManager; import dev.ukanth.ufirewall.log.Log; import dev.ukanth.ufirewall.service.RootCommand; import dev.ukanth.ufirewall.util.ApplicationErrorLog; @@ -118,6 +119,7 @@ protected void appendPreferences(final Context ctx) { if (includeApplicationErrors()) { writeHeading(result, true, getString(R.string.application_errors_title)); + DnsHijackManager.syncServiceLogsToAppLog(ctx); String applicationErrors = ApplicationErrorLog.get(ctx); if (applicationErrors.trim().isEmpty()) { result.append(getString(R.string.application_errors_empty)).append("\n"); diff --git a/app/src/main/java/dev/ukanth/ufirewall/broadcast/DnsBlocklistUpdateReceiver.java b/app/src/main/java/dev/ukanth/ufirewall/broadcast/DnsBlocklistUpdateReceiver.java new file mode 100644 index 000000000..a71b3b911 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/broadcast/DnsBlocklistUpdateReceiver.java @@ -0,0 +1,118 @@ +package dev.ukanth.ufirewall.broadcast; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.dns.DnsBlocklistManager; +import dev.ukanth.ufirewall.dns.DnsHijackManager; +import dev.ukanth.ufirewall.util.ApplicationErrorLog; +import dev.ukanth.ufirewall.util.G; + +public class DnsBlocklistUpdateReceiver extends BroadcastReceiver { + + private static final String TAG = "AFWallDnsBlocklistAlarm"; + private static final String ACTION_UPDATE = "dev.ukanth.ufirewall.action.DNS_BLOCKLIST_UPDATE"; + + @Override + public void onReceive(Context context, Intent intent) { + if (context == null || intent == null || !ACTION_UPDATE.equals(intent.getAction())) { + return; + } + + PendingResult pendingResult = goAsync(); + Context appContext = context.getApplicationContext(); + new Thread(() -> { + try { + runScheduledUpdate(appContext); + } finally { + pendingResult.finish(); + } + }, "AFWallDnsBlocklistUpdate").start(); + } + + public static void scheduleOrCancel(Context context) { + if (context == null) { + return; + } + if (!shouldSchedule()) { + cancel(context); + return; + } + + long interval = G.dnsHijackBlocklistUpdateIntervalHours() * 60L * 60L * 1000L; + long firstRun = SystemClock.elapsedRealtime() + interval; + AlarmManager alarmManager = (AlarmManager) context.getApplicationContext() + .getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + ApplicationErrorLog.add(context, "DNS blocklist scheduled updates could not be scheduled: AlarmManager unavailable"); + return; + } + + alarmManager.setInexactRepeating(AlarmManager.ELAPSED_REALTIME_WAKEUP, firstRun, interval, + pendingIntent(context)); + ApplicationErrorLog.add(context, "DNS blocklist scheduled updates enabled every " + + G.dnsHijackBlocklistUpdateIntervalHours() + " hours"); + } + + public static void cancel(Context context) { + if (context == null) { + return; + } + AlarmManager alarmManager = (AlarmManager) context.getApplicationContext() + .getSystemService(Context.ALARM_SERVICE); + if (alarmManager != null) { + alarmManager.cancel(pendingIntent(context)); + } + } + + private static void runScheduledUpdate(Context context) { + if (!shouldSchedule()) { + cancel(context); + return; + } + + ApplicationErrorLog.add(context, "Scheduled DNS blocklist update started"); + try { + DnsBlocklistManager.Result result = DnsBlocklistManager.updateFromConfiguredUrls(context); + if (result.failed) { + ApplicationErrorLog.add(context, "Scheduled DNS blocklist update failed: " + + result.message); + return; + } + + Api.setRulesUpToDate(false); + if (G.enableDnsHijack()) { + DnsHijackManager.requestReload(context); + } + ApplicationErrorLog.add(context, "Scheduled DNS blocklist update completed: " + + result.message); + } catch (RuntimeException e) { + Log.e(TAG, "Scheduled DNS blocklist update crashed", e); + ApplicationErrorLog.add(context, "Scheduled DNS blocklist update crashed: " + + e.getMessage()); + } + } + + private static boolean shouldSchedule() { + return G.enableDnsHijack() + && G.dnsHijackScheduledBlocklistUpdates() + && !G.dnsHijackBlocklistUrls().trim().isEmpty(); + } + + private static PendingIntent pendingIntent(Context context) { + Intent intent = new Intent(context.getApplicationContext(), DnsBlocklistUpdateReceiver.class); + intent.setAction(ACTION_UPDATE); + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + flags |= PendingIntent.FLAG_IMMUTABLE; + } + return PendingIntent.getBroadcast(context.getApplicationContext(), 0, intent, flags); + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java b/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java index ec745f8b4..2c5cc4a52 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java +++ b/app/src/main/java/dev/ukanth/ufirewall/broadcast/OnBootReceiver.java @@ -48,6 +48,7 @@ public void onReceive(Context context, Intent intent) { // Use BootRuleManager for robust rule application BootRuleManager.initializeBootRuleApplication(context); + DnsBlocklistUpdateReceiver.scheduleOrCancel(context); //register private DNS change listener diff --git a/app/src/main/java/dev/ukanth/ufirewall/dns/DnsBlocklistManager.java b/app/src/main/java/dev/ukanth/ufirewall/dns/DnsBlocklistManager.java new file mode 100644 index 000000000..559927773 --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/dns/DnsBlocklistManager.java @@ -0,0 +1,863 @@ +package dev.ukanth.ufirewall.dns; + +import android.content.Context; +import android.net.Uri; +import android.util.Log; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Pattern; + +import dev.ukanth.ufirewall.util.ApplicationErrorLog; +import dev.ukanth.ufirewall.util.G; + +public final class DnsBlocklistManager { + + private static final String TAG = "AFWallDnsBlocklists"; + private static final String DIR = "dnsd_blocklists"; + private static final String EXACT_FILE = "block_exact.txt"; + private static final String SUFFIX_FILE = "block_suffix.txt"; + private static final String EXACT_BACKUP = "block_exact.previous.txt"; + private static final String SUFFIX_BACKUP = "block_suffix.previous.txt"; + private static final String META_FILE = "metadata.txt"; + private static final int JSON_DETECT_BYTES = 8192; + private static final int MAX_JSON_BUNDLE_BYTES = 16 * 1024 * 1024; + private static final int JSON_TARGET_GENERIC = 0; + private static final int JSON_TARGET_EXACT = 1; + private static final int JSON_TARGET_SUFFIX = 2; + private static final Pattern DOMAIN_PATTERN = Pattern.compile( + "^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$"); + + private DnsBlocklistManager() { + } + + public static Result importFromUri(Context context, Uri uri) { + Result result = new Result("file import"); + try (InputStream input = context.getContentResolver().openInputStream(uri)) { + if (input == null) { + result.failed = true; + result.message = "Unable to open selected blocklist"; + return result; + } + result.sources = 1; + parsePossiblyJsonStream(input, result); + activate(context, result); + return result; + } catch (IOException e) { + result.failed = true; + result.message = e.getMessage(); + Log.e(TAG, "DNS blocklist import failed", e); + ApplicationErrorLog.add(context, "DNS blocklist import failed: " + e.getMessage()); + return result; + } + } + + public static Result importFromText(Context context, String text) { + Result result = new Result("paste import"); + if (text == null || text.trim().isEmpty()) { + result.failed = true; + result.message = "No DNS blocklist text was provided"; + return result; + } + result.sources = 1; + try (InputStream input = new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))) { + parsePossiblyJsonStream(input, result); + activate(context, result); + return result; + } catch (IOException e) { + result.failed = true; + result.message = e.getMessage(); + Log.e(TAG, "DNS blocklist paste import failed", e); + ApplicationErrorLog.add(context, "DNS blocklist paste import failed: " + e.getMessage()); + return result; + } + } + + public static Result updateFromConfiguredUrls(Context context) { + Result result = new Result("URL update"); + String raw = G.dnsHijackBlocklistUrls(); + if (raw.trim().isEmpty()) { + result.failed = true; + result.message = "No DNS blocklist URLs configured"; + return result; + } + + String[] urls = raw.split("[\\r\\n,]+"); + for (String item : urls) { + String url = item == null ? "" : item.trim(); + if (url.isEmpty() || url.startsWith("#")) { + continue; + } + result.sources++; + downloadAndParse(url, result); + } + + if (result.sources == 0) { + result.failed = true; + result.message = "No valid DNS blocklist URLs configured"; + return result; + } + if (!result.hasRules()) { + result.failed = true; + result.message = "No valid DNS blocklist entries found"; + return result; + } + + try { + activate(context, result); + } catch (IOException e) { + result.failed = true; + result.message = e.getMessage(); + Log.e(TAG, "DNS blocklist URL activation failed", e); + ApplicationErrorLog.add(context, "DNS blocklist URL activation failed: " + e.getMessage()); + } + return result; + } + + public static Result restorePrevious(Context context) { + Result result = new Result("rollback"); + File dir = blocklistDir(context); + File exactBackup = new File(dir, EXACT_BACKUP); + File suffixBackup = new File(dir, SUFFIX_BACKUP); + if (!exactBackup.exists() && !suffixBackup.exists()) { + result.failed = true; + result.message = "No previous DNS blocklist backup exists"; + return result; + } + try { + restoreBackup(exactBackup, new File(dir, EXACT_FILE)); + restoreBackup(suffixBackup, new File(dir, SUFFIX_FILE)); + result.finish(); + writeMetadata(context, "rollback", "rollback", countLines(new File(dir, EXACT_FILE)), + countLines(new File(dir, SUFFIX_FILE)), 0, 0, 0, 0, 0, + result.durationMs()); + result.message = "Restored previous DNS blocklist"; + return result; + } catch (IOException e) { + result.failed = true; + result.message = e.getMessage(); + Log.e(TAG, "DNS blocklist rollback failed", e); + ApplicationErrorLog.add(context, "DNS blocklist rollback failed: " + e.getMessage()); + return result; + } + } + + public static String getSummary(Context context) { + File meta = new File(blocklistDir(context), META_FILE); + if (!meta.exists()) { + return "No imported DNS blocklist is active."; + } + try (FileInputStream input = new FileInputStream(meta)) { + byte[] data = new byte[(int) Math.min(meta.length(), 8192)]; + int read = input.read(data); + return read <= 0 ? "No imported DNS blocklist is active." : new String(data, 0, read, StandardCharsets.UTF_8); + } catch (IOException e) { + return "Unable to read DNS blocklist metadata: " + e.getMessage(); + } + } + + public static boolean copyGlobalBlocklistToActiveProfile(Context context) { + File sourceDir = context.getApplicationContext().getDir(DIR, Context.MODE_PRIVATE); + File targetDir = blocklistDir(context); + if (sourceDir.equals(targetDir)) { + return true; + } + if (!targetDir.exists() && !targetDir.mkdirs()) { + ApplicationErrorLog.add(context, "Unable to create active profile DNS blocklist directory"); + return false; + } + try { + copyIfExists(new File(sourceDir, EXACT_FILE), new File(targetDir, EXACT_FILE)); + copyIfExists(new File(sourceDir, SUFFIX_FILE), new File(targetDir, SUFFIX_FILE)); + copyIfExists(new File(sourceDir, EXACT_BACKUP), new File(targetDir, EXACT_BACKUP)); + copyIfExists(new File(sourceDir, SUFFIX_BACKUP), new File(targetDir, SUFFIX_BACKUP)); + copyIfExists(new File(sourceDir, META_FILE), new File(targetDir, META_FILE)); + return true; + } catch (IOException e) { + Log.e(TAG, "DNS profile blocklist copy failed", e); + ApplicationErrorLog.add(context, "DNS profile blocklist copy failed: " + e.getMessage()); + return false; + } + } + + static File exactBlockFile(Context context) { + return new File(blocklistDir(context), EXACT_FILE); + } + + static File suffixBlockFile(Context context) { + return new File(blocklistDir(context), SUFFIX_FILE); + } + + private static void downloadAndParse(String urlString, Result result) { + HttpURLConnection connection = null; + try { + URL url = new URL(urlString); + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(15000); + connection.setReadTimeout(30000); + connection.setInstanceFollowRedirects(true); + int code = connection.getResponseCode(); + if (code < 200 || code >= 300) { + result.invalid++; + result.notes.append(urlString).append(": HTTP ").append(code).append('\n'); + return; + } + parsePossiblyJsonStream(connection.getInputStream(), result); + } catch (IOException e) { + result.invalid++; + result.notes.append(urlString).append(": ").append(e.getMessage()).append('\n'); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private static void parsePossiblyJsonStream(InputStream rawInput, Result result) throws IOException { + BufferedInputStream input = new BufferedInputStream(rawInput); + input.mark(JSON_DETECT_BYTES); + int first = firstNonWhitespace(input); + input.reset(); + if (first == '{' || first == '[') { + parseJsonBundle(readLimitedString(input), result); + } else { + parseStream(input, result); + } + } + + private static int firstNonWhitespace(InputStream input) throws IOException { + for (int i = 0; i < JSON_DETECT_BYTES; i++) { + int value = input.read(); + if (value == -1) { + return -1; + } + if (!Character.isWhitespace((char) value)) { + return value; + } + } + return -1; + } + + private static String readLimitedString(InputStream input) throws IOException { + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int total = 0; + int read; + while ((read = input.read(buffer)) != -1) { + total += read; + if (total > MAX_JSON_BUNDLE_BYTES) { + throw new IOException("JSON DNS blocklist bundle is too large to import safely"); + } + output.write(buffer, 0, read); + } + return output.toString(StandardCharsets.UTF_8.name()); + } + + private static void parseJsonBundle(String rawJson, Result result) throws IOException { + String json = rawJson == null ? "" : rawJson.trim(); + if (json.isEmpty()) { + return; + } + result.sourceLabel = result.sourceLabel + " JSON bundle"; + result.notes.append("Detected JSON DNS blocklist bundle\n"); + try { + if (json.startsWith("[")) { + parseJsonArray(new JSONArray(json), result, JSON_TARGET_GENERIC); + } else { + parseJsonObject(new JSONObject(json), result); + } + } catch (JSONException e) { + throw new IOException("Invalid JSON DNS blocklist bundle: " + e.getMessage(), e); + } + } + + private static void parseJsonObject(JSONObject object, Result result) throws JSONException { + boolean handled = false; + Iterator keys = object.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object value = object.opt(key); + if (JSONObject.NULL.equals(value)) { + continue; + } + if (isJsonAllowKey(key)) { + result.skipped += countJsonEntries(value); + continue; + } + int target = classifyJsonRuleKey(key); + if (target == JSON_TARGET_EXACT || target == JSON_TARGET_SUFFIX) { + parseJsonValue(value, result, target); + handled = true; + } else if (isJsonGenericRuleKey(key)) { + parseJsonValue(value, result, JSON_TARGET_GENERIC); + handled = true; + } else if (isJsonWrapperKey(key) && (value instanceof JSONObject || value instanceof JSONArray)) { + parseJsonValue(value, result, JSON_TARGET_GENERIC); + handled = true; + } + } + + if (!handled) { + parseJsonRuleObject(object, result, JSON_TARGET_GENERIC); + } + } + + private static void parseJsonArray(JSONArray array, Result result, int target) throws JSONException { + for (int i = 0; i < array.length(); i++) { + parseJsonValue(array.opt(i), result, target); + } + } + + private static void parseJsonValue(Object value, Result result, int target) throws JSONException { + if (value == null || JSONObject.NULL.equals(value)) { + return; + } + if (value instanceof JSONArray) { + parseJsonArray((JSONArray) value, result, target); + } else if (value instanceof JSONObject) { + JSONObject object = (JSONObject) value; + if (!parseJsonRuleObject(object, result, target)) { + parseJsonObject(object, result); + } + } else { + parseJsonScalar(String.valueOf(value), result, target); + } + } + + private static boolean parseJsonRuleObject(JSONObject object, Result result, int parentTarget) throws JSONException { + String domain = firstJsonString(object, "domain", "host", "hostname", "pattern", "value"); + if (domain.isEmpty()) { + return false; + } + String action = firstJsonString(object, "action", "policy", "decision", "list"); + if (isAllowToken(action)) { + result.skipped++; + return true; + } + String type = firstJsonString(object, "type", "kind", "rule", "rule_type", "match"); + if (containsToken(type, "regex")) { + result.skipped++; + return true; + } + int target = parentTarget == JSON_TARGET_EXACT || parentTarget == JSON_TARGET_SUFFIX + ? parentTarget : JSON_TARGET_GENERIC; + if (containsToken(type, "suffix") || containsToken(type, "wildcard") + || containsToken(type, "subdomain")) { + target = JSON_TARGET_SUFFIX; + } + parseJsonScalar(domain, result, target); + return true; + } + + private static void parseJsonScalar(String raw, Result result, int target) { + String value = raw == null ? "" : raw.trim(); + if (value.isEmpty()) { + result.skipped++; + return; + } + if (target == JSON_TARGET_SUFFIX) { + result.lines++; + addDomain(result.suffixRules, normalizeDomain(value), result); + } else if (target == JSON_TARGET_EXACT) { + result.lines++; + addDomain(result.exactRules, normalizeDomain(value), result); + } else { + parseLine(value, result); + } + } + + private static String firstJsonString(JSONObject object, String... keys) { + for (String key : keys) { + String value = object.optString(key, ""); + if (!value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + + private static int classifyJsonRuleKey(String rawKey) { + String key = normalizeJsonKey(rawKey); + if (key.contains("suffix") || key.contains("wildcard")) { + return JSON_TARGET_SUFFIX; + } + if (key.equals("exact") || key.equals("exactblock") || key.equals("blockexact") + || key.equals("host") || key.equals("hosts")) { + return JSON_TARGET_EXACT; + } + return JSON_TARGET_GENERIC; + } + + private static boolean isJsonGenericRuleKey(String rawKey) { + String key = normalizeJsonKey(rawKey); + return key.equals("domain") || key.equals("domains") + || key.equals("rule") || key.equals("rules") + || key.equals("entry") || key.equals("entries") + || key.equals("item") || key.equals("items") + || key.equals("block") || key.equals("blocked") + || key.equals("deny") || key.equals("denied") + || key.equals("blacklist") || key.equals("denylist") + || key.equals("blocklist") || key.equals("blocklists") + || (key.contains("domain") && (key.contains("block") + || key.contains("deny") || key.contains("disallow"))); + } + + private static boolean isJsonWrapperKey(String rawKey) { + String key = normalizeJsonKey(rawKey); + return key.equals("data") || key.equals("backup") || key.equals("export") + || key.equals("dnsblocklist") || key.equals("dnsblocklists") + || key.equals("blocklist") || key.equals("blocklists"); + } + + private static boolean isJsonAllowKey(String rawKey) { + String key = normalizeJsonKey(rawKey); + return isAllowToken(key); + } + + private static String normalizeJsonKey(String rawKey) { + return rawKey == null ? "" : rawKey.toLowerCase(Locale.US).replaceAll("[^a-z0-9]", ""); + } + + private static boolean containsToken(String value, String token) { + return value != null && value.toLowerCase(Locale.US).contains(token); + } + + private static boolean isAllowToken(String value) { + if (value == null) { + return false; + } + String token = value.toLowerCase(Locale.US); + if (token.contains("disallow") || token.contains("deny") || token.contains("block")) { + return false; + } + return token.contains("allow") || token.contains("white"); + } + + private static int countJsonEntries(Object value) { + if (value instanceof JSONArray) { + return ((JSONArray) value).length(); + } + return value == null || JSONObject.NULL.equals(value) ? 0 : 1; + } + + private static void parseStream(InputStream input, Result result) throws IOException { + BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8)); + String line; + while ((line = reader.readLine()) != null) { + parseLine(line, result); + } + } + + private static void parseLine(String raw, Result result) { + result.lines++; + String line = raw == null ? "" : raw.trim(); + if (line.isEmpty() || line.startsWith("#") || line.startsWith("!") || line.startsWith("[")) { + result.skipped++; + return; + } + + if (line.startsWith("@@") || isUnsupportedAdblockRule(line)) { + result.skipped++; + return; + } + + if (line.startsWith("||")) { + String domain = normalizeDomain(line.substring(2).replaceFirst("[\\^/$].*$", "")); + addDomain(result.suffixRules, domain, result); + return; + } + + if (parseDnsmasqAddressRule(line, result)) { + return; + } + + if (parseUnboundLocalZoneRule(line, result)) { + return; + } + + if (line.startsWith("0.0.0.0 ") || line.startsWith("127.0.0.1 ") + || line.startsWith("::1 ") || line.startsWith(":: ")) { + parseHostsDomains(line.split("\\s+"), 1, result); + return; + } + + String[] parts = line.split("\\s+"); + if (parts.length > 1 && isAddressToken(parts[0])) { + parseHostsDomains(parts, 1, result); + return; + } + + String candidate = line.split("[#\\s]")[0]; + candidate = stripAdblockAnchors(candidate); + if (candidate.startsWith("*.") || candidate.startsWith(".")) { + addDomain(result.suffixRules, normalizeDomain(candidate), result); + } else { + addDomain(result.exactRules, normalizeDomain(candidate), result); + } + } + + private static void parseHostsDomains(String[] parts, int start, Result result) { + boolean parsed = false; + for (int i = start; i < parts.length; i++) { + String token = parts[i] == null ? "" : parts[i].trim(); + if (token.isEmpty() || token.startsWith("#")) { + break; + } + if (isAddressToken(token)) { + continue; + } + addDomain(result.exactRules, normalizeDomain(token), result); + parsed = true; + } + if (!parsed) { + result.skipped++; + } + } + + private static boolean parseDnsmasqAddressRule(String line, Result result) { + String prefix = "address=/"; + int end; + if (!line.startsWith(prefix)) { + return false; + } + end = line.indexOf('/', prefix.length()); + if (end <= prefix.length()) { + result.invalid++; + return true; + } + addDomain(result.suffixRules, normalizeDomain(line.substring(prefix.length(), end)), result); + return true; + } + + private static boolean parseUnboundLocalZoneRule(String line, Result result) { + String lower = line.toLowerCase(Locale.US); + String value; + int firstQuote; + int secondQuote; + if (!lower.startsWith("local-zone:")) { + return false; + } + if (!(lower.contains("always_null") || lower.contains("always_nxdomain") + || lower.contains("redirect") || lower.contains("static"))) { + result.skipped++; + return true; + } + firstQuote = line.indexOf('"'); + secondQuote = firstQuote >= 0 ? line.indexOf('"', firstQuote + 1) : -1; + if (firstQuote >= 0 && secondQuote > firstQuote) { + value = line.substring(firstQuote + 1, secondQuote); + } else { + String[] parts = line.substring("local-zone:".length()).trim().split("\\s+"); + value = parts.length == 0 ? "" : parts[0]; + } + addDomain(result.suffixRules, normalizeDomain(value), result); + return true; + } + + private static boolean isUnsupportedAdblockRule(String line) { + return line.startsWith("/") || line.contains("##") || line.contains("#@#") + || line.contains("#?#") || line.contains("#$#") + || line.contains("$scriptlet") || line.contains("$removeparam"); + } + + private static String stripAdblockAnchors(String candidate) { + String value = candidate == null ? "" : candidate.trim(); + value = value.replaceFirst("^\\|+", ""); + value = value.replaceFirst("[\\^|].*$", ""); + return value; + } + + private static void addDomain(Set target, String domain, Result result) { + if (domain == null || domain.isEmpty() || "localhost".equals(domain)) { + result.skipped++; + return; + } + if (!DOMAIN_PATTERN.matcher(domain).matches()) { + result.invalid++; + return; + } + if (!target.add(domain)) { + result.duplicates++; + } + } + + private static String normalizeDomain(String raw) { + if (raw == null) { + return ""; + } + String domain = raw.trim().toLowerCase(Locale.US); + domain = domain.replaceFirst("^https?://", ""); + domain = domain.replaceFirst("/.*$", ""); + domain = domain.replaceFirst("^\\*\\.", ""); + domain = domain.replaceFirst("^\\.", ""); + domain = domain.replaceFirst("\\.$", ""); + return domain; + } + + private static boolean isAddressToken(String token) { + return token.matches("^[0-9.]+$") || token.contains(":"); + } + + private static void activate(Context context, Result result) throws IOException { + if (!result.hasRules()) { + result.failed = true; + result.message = "No valid DNS blocklist entries found"; + return; + } + + File dir = blocklistDir(context); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Unable to create DNS blocklist directory"); + } + File exact = new File(dir, EXACT_FILE); + File suffix = new File(dir, SUFFIX_FILE); + File exactBackup = new File(dir, EXACT_BACKUP); + File suffixBackup = new File(dir, SUFFIX_BACKUP); + File exactTmp = new File(dir, EXACT_FILE + ".tmp"); + File suffixTmp = new File(dir, SUFFIX_FILE + ".tmp"); + writeTempRuleFile(exactTmp, result.exactRules); + writeTempRuleFile(suffixTmp, result.suffixRules); + backupCurrent(exact, exactBackup); + backupCurrent(suffix, suffixBackup); + try { + replaceFromTemp(exactTmp, exact); + replaceFromTemp(suffixTmp, suffix); + validateActivatedBlocklist(context, exactBackup, suffixBackup, exact, suffix); + } catch (IOException e) { + boolean restored = true; + try { + restoreBackup(exactBackup, exact); + restoreBackup(suffixBackup, suffix); + } catch (IOException restoreError) { + restored = false; + e.addSuppressed(restoreError); + } + throw new IOException("DNS blocklist activation failed; previous blocklist " + + (restored ? "restored" : "restore failed") + ": " + e.getMessage(), e); + } finally { + deleteIfExists(exactTmp); + deleteIfExists(suffixTmp); + } + result.finish(); + writeMetadata(context, result.sourceLabel, "active", result.exactRules.size(), result.suffixRules.size(), + result.duplicates, result.invalid, result.skipped, result.sources, result.lines, + result.durationMs()); + result.message = "Activated DNS blocklist: " + result.exactRules.size() + + " exact, " + result.suffixRules.size() + " wildcard"; + ApplicationErrorLog.add(context, result.message + " in " + formatDuration(result.durationMs())); + } + + private static void validateActivatedBlocklist(Context context, File exactBackup, File suffixBackup, + File exact, File suffix) throws IOException { + if (!G.enableDnsHijack()) { + return; + } + String validation = DnsHijackManager.validateDnsConfiguration(context); + if (!DnsHijackManager.isDnsValidationRejected(validation)) { + return; + } + restoreBackup(exactBackup, exact); + restoreBackup(suffixBackup, suffix); + throw new IOException("DNS blocklist activation rejected by daemon validation; " + + "previous blocklist restored"); + } + + private static void writeTempRuleFile(File tmp, Set rules) throws IOException { + if (tmp.exists() && !tmp.delete()) { + throw new IOException("Unable to replace temporary " + tmp.getName()); + } + try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(tmp, false), StandardCharsets.UTF_8)) { + for (String rule : rules) { + writer.write(rule); + writer.write('\n'); + } + } + } + + private static void replaceFromTemp(File tmp, File target) throws IOException { + if (target.exists() && !target.delete()) { + throw new IOException("Unable to replace " + target.getName()); + } + if (!tmp.renameTo(target)) { + throw new IOException("Unable to activate " + target.getName()); + } + } + + private static void backupCurrent(File source, File backup) throws IOException { + if (source.exists()) { + copyIfExists(source, backup); + } else { + deleteIfExists(backup); + } + } + + private static void restoreBackup(File backup, File target) throws IOException { + if (backup.exists()) { + copyIfExists(backup, target); + } else { + deleteIfExists(target); + } + } + + private static void writeMetadata(Context context, String source, String status, int exact, int suffix, + int duplicates, int invalid, int skipped, int sources, + int lines, long durationMs) throws IOException { + File meta = new File(blocklistDir(context), META_FILE); + String timestamp = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(new Date()); + try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(meta, false), StandardCharsets.UTF_8)) { + writer.write("updated="); + writer.write(timestamp); + writer.write('\n'); + writer.write("status="); + writer.write(status == null ? "unknown" : status); + writer.write('\n'); + writer.write("source="); + writer.write(source == null ? "unknown" : source); + writer.write('\n'); + writer.write("sources="); + writer.write(String.valueOf(sources)); + writer.write('\n'); + writer.write("lines="); + writer.write(String.valueOf(lines)); + writer.write('\n'); + writer.write("exact="); + writer.write(String.valueOf(exact)); + writer.write('\n'); + writer.write("wildcard="); + writer.write(String.valueOf(suffix)); + writer.write('\n'); + writer.write("duplicates="); + writer.write(String.valueOf(duplicates)); + writer.write('\n'); + writer.write("invalid="); + writer.write(String.valueOf(invalid)); + writer.write('\n'); + writer.write("skipped="); + writer.write(String.valueOf(skipped)); + writer.write('\n'); + writer.write("duration_ms="); + writer.write(String.valueOf(durationMs)); + writer.write('\n'); + } + } + + private static String formatDuration(long durationMs) { + if (durationMs < 1000L) { + return durationMs + "ms"; + } + return String.format(Locale.US, "%.1fs", durationMs / 1000.0d); + } + + private static int countLines(File file) throws IOException { + if (!file.exists()) { + return 0; + } + int count = 0; + try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { + while (reader.readLine() != null) { + count++; + } + } + return count; + } + + private static void copyIfExists(File source, File target) throws IOException { + if (!source.exists()) { + return; + } + try (FileInputStream input = new FileInputStream(source); + FileOutputStream output = new FileOutputStream(target, false)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } + } + + private static void deleteIfExists(File file) throws IOException { + if (file.exists() && !file.delete()) { + throw new IOException("Unable to delete " + file.getName()); + } + } + + private static File blocklistDir(Context context) { + return context.getApplicationContext() + .getDir(G.dnsHijackBlocklistDirectoryName(DIR), Context.MODE_PRIVATE); + } + + public static final class Result { + private final Set exactRules = new LinkedHashSet<>(); + private final Set suffixRules = new LinkedHashSet<>(); + private final StringBuilder notes = new StringBuilder(); + private final long startedAtMs = System.currentTimeMillis(); + private long finishedAtMs; + private String sourceLabel; + public int lines; + public int duplicates; + public int invalid; + public int skipped; + public int sources; + public boolean failed; + public String message = ""; + + private Result(String sourceLabel) { + this.sourceLabel = sourceLabel; + } + + private void finish() { + if (finishedAtMs == 0L) { + finishedAtMs = System.currentTimeMillis(); + } + } + + private long durationMs() { + long end = finishedAtMs == 0L ? System.currentTimeMillis() : finishedAtMs; + return Math.max(0L, end - startedAtMs); + } + + public boolean hasRules() { + return !exactRules.isEmpty() || !suffixRules.isEmpty(); + } + + public String summary() { + StringBuilder summary = new StringBuilder(); + summary.append(message); + summary.append("\nStatus: ").append(failed ? "failed" : "complete"); + summary.append("\nSources: ").append(sources); + summary.append("\nLines: ").append(lines); + summary.append("\nExact: ").append(exactRules.size()); + summary.append("\nWildcard: ").append(suffixRules.size()); + summary.append("\nDuplicates: ").append(duplicates); + summary.append("\nInvalid: ").append(invalid); + summary.append("\nSkipped: ").append(skipped); + summary.append("\nDuration: ").append(formatDuration(durationMs())); + if (notes.length() > 0) { + summary.append("\n\n").append(notes); + } + return summary.toString(); + } + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/dns/DnsHijackManager.java b/app/src/main/java/dev/ukanth/ufirewall/dns/DnsHijackManager.java new file mode 100644 index 000000000..022ee34ea --- /dev/null +++ b/app/src/main/java/dev/ukanth/ufirewall/dns/DnsHijackManager.java @@ -0,0 +1,5508 @@ +package dev.ukanth.ufirewall.dns; + +import android.content.Context; +import android.content.SharedPreferences; +import android.net.LocalSocket; +import android.net.LocalSocketAddress; +import android.os.Build; +import android.provider.Settings; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.RandomAccessFile; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import dev.ukanth.ufirewall.Api; +import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.log.Log; +import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.util.ApplicationErrorLog; +import dev.ukanth.ufirewall.util.G; + +public final class DnsHijackManager { + + private static final String TAG = "AFWallDnsHijack"; + private static final String DAEMON_NAME = "afwall_dnsd"; + private static final String WORK_DIR = "dnsd"; + private static final String ENABLED_MARKER = "afwall_dnsd.enabled"; + private static final String SUPERVISOR = "afwall_dnsd_supervisor.sh"; + private static final String CONF = "afwall_dnsd.conf"; + private static final String CONTROL_TOKEN = "afwall_dnsd.control"; + private static final String PID = "afwall_dnsd.pid"; + private static final String SOCKET = "afwall_dnsd.sock"; + private static final String QUERY_LOG = "afwall_dnsd.log"; + private static final String DAEMON_EVENT_LOG = "afwall_dnsd_events.log"; + private static final String SUPERVISOR_LOG = "afwall_dnsd_supervisor.log"; + private static final String SUPERVISOR_PID = "afwall_dnsd_supervisor.pid"; + private static final String RESTART_COUNT = "afwall_dnsd_restart_count"; + private static final String LAST_EXIT = "afwall_dnsd_last_exit"; + private static final String HEARTBEAT = "afwall_dnsd.heartbeat"; + private static final String MARK_STATUS = "afwall_dnsd_mark.status"; + private static final String BOOT_SCRIPT = "afwall_dnsd_boot.sh"; + private static final String BOOT_LOG = "afwall_dnsd_boot.log"; + private static final String CLEANUP_SCRIPT = "afwall_dnsd_cleanup.sh"; + private static final String CLEANUP_LOG = "afwall_dnsd_cleanup.log"; + private static final String MAGISK_MODULE_ID = "afwall_dnsd"; + private static final String MAGISK_MODULE_DIR = "/data/adb/modules/" + MAGISK_MODULE_ID; + private static final String MAGISK_MODULE_PROP = "module.prop"; + private static final String MAGISK_SERVICE_SCRIPT = "service.sh"; + private static final String MAGISK_UNINSTALL_SCRIPT = "uninstall.sh"; + private static final String MAGISK_MODULE_LOG = "afwall_dnsd_module.log"; + private static final String MAGISK_MODULE_VERSION = "1.3"; + private static final int MAGISK_MODULE_VERSION_CODE = 4; + private static final String MAGISK_SCRIPT_VERSION = "4"; + private static final String SERVICE_LOG_PREFS = "AFWallDnsServiceLogBridge"; + private static final String[] SERVICE_EVENT_LOGS = new String[] { + DAEMON_EVENT_LOG, + SUPERVISOR_LOG, + BOOT_LOG, + CLEANUP_LOG + }; + private static final String CHAIN_V4 = "afwall-dns"; + private static final String CHAIN_V4_PRE = "afwall-dns-pre"; + private static final String CHAIN_V6 = "afwall-dns6"; + private static final String CHAIN_V6_PRE = "afwall-dns6-pre"; + private static final String CHAIN_FILTER = "afwall-dns-out"; + private static final String NFT_TABLE_V4 = "afwall_dns"; + private static final String NFT_TABLE_V6 = "afwall_dns6"; + private static final String NFT_OUTPUT = "output"; + private static final String NFT_PREROUTING = "prerouting"; + private static final String DAEMON_SOCKET_MARK = "0xaf053"; + private static final String PRIVATE_DNS_MODE = "private_dns_mode"; + private static final String PRIVATE_DNS_SPECIFIER = "private_dns_specifier"; + private static final int DEFAULT_PORT = 5354; + private static final int MAX_SERVICE_LOG_READ = 8192; + private static final int MAX_SERVICE_LOG_LINES = 30; + private static final int MAX_SERVICE_LOG_LINE_CHARS = 240; + private static final int MAX_DIAGNOSTIC_LOG_TAIL = 4096; + private static final int DNS_QTYPE_A = 1; + private static final String UNREADABLE_SERVICE_LOG_PREFIX = "unreadable_"; + public static final int RULE_ALLOW_EXACT = 1; + public static final int RULE_ALLOW_SUFFIX = 2; + public static final int RULE_BLOCK_EXACT = 3; + public static final int RULE_BLOCK_SUFFIX = 4; + public static final int RULE_TEMP_ALLOW = 5; + public static final int RULE_TEMP_BLOCK = 6; + public static final int RULE_APP_ALLOW_EXACT = 7; + public static final int RULE_APP_BLOCK_EXACT = 8; + public static final int RULE_APP_ALLOW_SUFFIX = 9; + public static final int RULE_APP_BLOCK_SUFFIX = 10; + private static final long TEMP_RULE_DURATION_SECONDS = 15L * 60L; + + private DnsHijackManager() { + } + + public static void appendApplyCommands(Context context, List commands, boolean ipv6) { + appendPurgeRules(commands, ipv6); + if (!G.enableDnsHijack()) { + if (!ipv6) { + appendStopCommand(context, commands); + appendRemoveBootPersistenceCommand(commands); + appendRemoveLifecycleCleanupCommand(commands); + } + return; + } + + // Do not queue NAT redirects unless the daemon and supervisor are ready; otherwise DNS + // capture would turn a preparation failure into device-wide DNS loss. + if (!prepareDaemon(context)) { + ApplicationErrorLog.add(context, "DNS hijacker was enabled but daemon files could not be prepared; redirect rules were not queued"); + return; + } + + if (!ipv6) { + ApplicationErrorLog.add(context, "DNS hijacker enabled; daemon start, daemon filter bypass, and DNS redirect rules queued"); + logRedirectPolicy(context, "DNS redirect policy queued"); + commands.add("#LITERAL# " + buildRepairServiceEventLogFilesCommand(context)); + appendLegacySupervisorStopCommand(context, commands, true); + commands.add("#LITERAL# " + shellQuote(supervisorPath(context)) + " restart"); + commands.add("#LITERAL# " + buildSupervisorReadinessCheckCommand(context)); + appendBootPersistenceCommand(context, commands); + } else { + commands.add("#LITERAL# " + buildSupervisorReadinessCheckCommand(context)); + } + appendRedirectRules(context, commands, ipv6); + commands.add("#LITERAL# " + buildNftFallbackRestoreCommand(context, ipv6)); + commands.add("#LITERAL# " + buildRedirectInstallVerificationCommand(context, ipv6)); + } + + public static void appendPurgeCommands(Context context, List commands, boolean ipv6) { + appendPurgeRules(commands, ipv6); + if (!ipv6) { + commands.add("#LITERAL# " + buildNftPurgeCommand()); + } + if (!ipv6) { + appendStopCommand(context, commands); + appendRemoveBootPersistenceCommand(commands); + appendRemoveLifecycleCleanupCommand(commands); + } + } + + public static void applyDnsProtectionPreference(Context context, boolean enabled, + RootCommand.Callback callback) { + if (context == null) { + return; + } + if (enabled) { + if (!prepareDaemon(context)) { + failSupervisorAction(context, callback, + "DNS protection enable requested but daemon files could not be prepared"); + return; + } + runLifecycleCommands(context, + buildRootRepairCommands(context), + "DNS protection enable queued: daemon start, daemon filter bypass, redirect reinstall, boot persistence sync", + "DNS protection enable completed", + "DNS protection enable failed", + callback); + return; + } + + if (clearEnableMarkerBeforeRoot(context, "DNS protection disable")) { + requestDaemonFailOpen(context, "DNS protection disable"); + } + runLifecycleCommands(context, + buildRootRemovalCommands(context), + "DNS protection disable queued: redirect teardown, daemon stop, boot persistence removal", + "DNS protection disable completed", + "DNS protection disable failed", + callback); + } + + public static void emergencyCleanupDnsProtection(Context context, + RootCommand.Callback callback) { + if (context == null) { + return; + } + if (clearEnableMarkerBeforeRoot(context, "DNS emergency cleanup")) { + requestDaemonFailOpen(context, "DNS emergency cleanup"); + } + runLifecycleCommands(context, + buildRootEmergencyCleanupCommands(context), + "DNS emergency cleanup queued: direct redirect teardown, daemon stop, and Magisk module removal", + "DNS emergency cleanup completed", + "DNS emergency cleanup failed", + callback); + } + + public static void updateBootPersistence(Context context, RootCommand.Callback callback) { + if (context == null) { + return; + } + List commands = new ArrayList<>(); + if (G.dnsHijackBootPersistence() && G.enableDnsHijack()) { + if (!prepareDaemon(context)) { + failSupervisorAction(context, callback, + "DNS boot persistence enable requested but daemon files could not be prepared"); + return; + } + commands.add(buildInstallBootPersistenceCommand(workDir(context), true)); + runLifecycleCommands(context, + commands, + "DNS boot persistence install queued", + "DNS boot persistence install completed", + "DNS boot persistence install failed", + callback); + return; + } + + commands.add(buildRemoveBootPersistenceCommand(!G.enableDnsHijack())); + runLifecycleCommands(context, + commands, + "DNS boot persistence removal queued", + "DNS boot persistence removal completed", + "DNS boot persistence removal failed", + callback); + } + + public static void requestReload(Context context) { + if (context == null) { + return; + } + if (prepareDaemon(context)) { + String response = queryControl(context, "reload"); + syncServiceLogsToAppLog(context); + if (response.startsWith("ok reload")) { + ApplicationErrorLog.add(context, "DNS daemon control reload completed: " + + compactControlResponse(response)); + restoreRedirectsAfterReload(context); + return; + } + ApplicationErrorLog.add(context, + "DNS daemon control reload unavailable; falling back to supervisor reload: " + + compactControlResponse(response)); + } + runSupervisorAction(context, "reload", null); + } + + private static void restoreRedirectsAfterReload(Context context) { + if (context == null || !G.enableDnsHijack()) { + return; + } + List commands = new ArrayList<>(); + commands.add(buildRepairServiceEventLogFilesCommand(context)); + logRedirectPolicy(context, "DNS redirect policy reload restore queued"); + appendRootRedirectRepairCommands(context, commands, false); + if (G.enableIPv6()) { + appendRootRedirectRepairCommands(context, commands, true); + } else { + appendDirectPurgeRules(context, commands, true); + commands.add(buildNftFamilyPurgeCommand(true)); + } + ApplicationErrorLog.add(context, "DNS redirect reload restore queued"); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_apply) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + if (state.exitCode == 0) { + ApplicationErrorLog.add(context, "DNS redirect reload restore completed"); + } else { + ApplicationErrorLog.add(context, + "DNS redirect reload restore failed" + rootFailureSuffix(state)); + } + syncServiceLogsToAppLog(context); + } + }) + .run(context.getApplicationContext(), commands); + } + + public static String validateDnsConfiguration(Context context) { + if (context == null) { + return "validation unavailable: missing context\n"; + } + String response = queryControl(context, "validate"); + syncServiceLogsToAppLog(context); + if (isDnsValidationRejected(response)) { + ApplicationErrorLog.add(context, "DNS daemon validation rejected config: " + + compactControlResponse(response)); + } else if (response.startsWith("validate=1")) { + ApplicationErrorLog.add(context, "DNS daemon validation accepted config: " + + compactControlResponse(response)); + } else { + ApplicationErrorLog.add(context, "DNS daemon validation unavailable: " + + compactControlResponse(response)); + } + return response; + } + + public static boolean isDnsValidationRejected(String response) { + if (response == null) { + return false; + } + return response.startsWith("validate=0") || response.contains("\nvalidate=0") + || response.contains("status=config_rejected") + || response.contains("status=allocation_failed"); + } + + public static String runDaemonMaintenanceAction(Context context, String action) { + if (context == null) { + return "maintenance unavailable: missing context\n"; + } + String command = normalizeDaemonMaintenanceAction(action); + if (command == null) { + ApplicationErrorLog.add(context, "DNS daemon maintenance action rejected: " + action); + return "maintenance unavailable: invalid action\n"; + } + String response = queryControl(context, command); + syncServiceLogsToAppLog(context); + if (response.startsWith("ok ")) { + ApplicationErrorLog.add(context, "DNS daemon maintenance completed: " + + compactControlResponse(response)); + } else { + ApplicationErrorLog.add(context, "DNS daemon maintenance failed: " + + compactControlResponse(response)); + } + return response; + } + + public static String runControlAuthSelfTest(Context context) { + if (context == null) { + return "control_auth_self_test=0\nreason=missing_context\n"; + } + StringBuilder out = new StringBuilder(); + File socketFile = new File(workDir(context), SOCKET); + out.append("control_auth_self_test=1\n"); + out.append("socket_present=").append(socketFile.exists()).append('\n'); + out.append("app_uid=").append(context.getApplicationInfo().uid).append('\n'); + String validate = queryControl(context, "validate"); + Map validateValues = parseKeyValueLines(validate); + out.append("authorized_control="); + if ("1".equals(validateValues.get("validate"))) { + out.append("ok\n"); + } else { + out.append("failed\n"); + } + appendSelfTestValue(out, validateValues, "control_socket_configured"); + appendSelfTestValue(out, validateValues, "control_socket_uid"); + appendSelfTestValue(out, validateValues, "control_auth_configured"); + appendSelfTestValue(out, validateValues, "control_peer_uid_enforced"); + appendSelfTestValue(out, validateValues, "control_adb_debug_enabled"); + String badTokenResponse = queryControl(context, "status", buildInvalidControlToken(context)); + boolean badTokenRejected = badTokenResponse.trim().startsWith("error unauthorized"); + out.append("bad_token_rejected=").append(badTokenRejected ? "1" : "0").append('\n'); + if (!badTokenRejected) { + out.append("bad_token_response=") + .append(compactControlResponse(badTokenResponse)).append('\n'); + } + syncServiceLogsToAppLog(context); + if (badTokenRejected + && "1".equals(validateValues.get("control_auth_configured")) + && "1".equals(validateValues.get("control_peer_uid_enforced"))) { + ApplicationErrorLog.add(context, "DNS control auth self-test passed"); + } else { + ApplicationErrorLog.add(context, "DNS control auth self-test failed: " + + compactControlResponse(out.toString())); + } + return out.toString(); + } + + public static void runSupervisorAction(Context context, String action, RootCommand.Callback callback) { + if (context == null) { + return; + } + String safeAction = normalizeSupervisorAction(action); + if (safeAction == null) { + failSupervisorAction(context, callback, "DNS hijacker supervisor action was invalid: " + action); + return; + } + if (!"stop".equals(safeAction) && !prepareDaemon(context)) { + failSupervisorAction(context, callback, "DNS hijacker " + safeAction + " requested but daemon files could not be prepared"); + return; + } + File supervisor = new File(workDir(context), SUPERVISOR); + if (!supervisor.exists()) { + failSupervisorAction(context, callback, "DNS hijacker " + safeAction + " requested but supervisor script is missing"); + return; + } + List commands = new ArrayList<>(); + commands.add(buildRepairServiceEventLogFilesCommand(context)); + commands.add(shellQuote(supervisor.getAbsolutePath()) + " " + safeAction); + ApplicationErrorLog.add(context, "DNS hijacker supervisor action queued: " + safeAction); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_apply) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + syncServiceLogsToAppLog(context); + if (callback != null) { + callback.cbFunc(state); + } + } + }) + .run(context.getApplicationContext(), commands); + } + + public static void repairDnsProtection(Context context, RootCommand.Callback callback) { + if (context == null) { + return; + } + if (!G.enableDnsHijack()) { + failSupervisorAction(context, callback, "DNS hijacker repair requested while DNS capture is disabled"); + return; + } + if (!prepareDaemon(context)) { + failSupervisorAction(context, callback, "DNS hijacker repair requested but daemon files could not be prepared"); + return; + } + + List commands = buildRootRepairCommands(context); + ApplicationErrorLog.add(context, "DNS hijacker repair queued: daemon start, daemon filter bypass, and DNS redirect reinstall"); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_apply) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + syncServiceLogsToAppLog(context); + if (callback != null) { + callback.cbFunc(state); + } + } + }) + .run(context.getApplicationContext(), commands); + } + + public static void pauseDnsProtection(Context context, RootCommand.Callback callback) { + if (context == null) { + return; + } + boolean previousEnabled = G.enableDnsHijack(); + G.enableDnsHijack(false); + Api.setRulesUpToDate(false); + boolean localStopArmed = clearEnableMarkerBeforeRoot(context, "DNS protection pause"); + if (localStopArmed) { + requestDaemonFailOpen(context, "DNS protection pause"); + } + List commands = buildRootRemovalCommands(context); + ApplicationErrorLog.add(context, "DNS protection pause queued: redirect teardown and daemon stop"); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_apply) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + if (state.exitCode != 0 && !localStopArmed) { + G.enableDnsHijack(previousEnabled); + Api.setRulesUpToDate(false); + ApplicationErrorLog.add(context, "DNS protection pause failed; restored previous enabled setting"); + } else if (state.exitCode != 0) { + ApplicationErrorLog.add(context, + "DNS protection pause root cleanup failed after enable marker was cleared; leaving DNS protection disabled for fail-open recovery"); + } + syncServiceLogsToAppLog(context); + if (callback != null) { + callback.cbFunc(state); + } + } + }) + .run(context.getApplicationContext(), commands); + } + + public static void resumeDnsProtection(Context context, RootCommand.Callback callback) { + if (context == null) { + return; + } + boolean previousEnabled = G.enableDnsHijack(); + G.enableDnsHijack(true); + Api.setRulesUpToDate(false); + if (!prepareDaemon(context)) { + G.enableDnsHijack(previousEnabled); + failSupervisorAction(context, callback, "DNS protection resume requested but daemon files could not be prepared"); + return; + } + + List commands = buildRootRepairCommands(context); + ApplicationErrorLog.add(context, "DNS protection resume queued: daemon start, daemon filter bypass, and DNS redirect reinstall"); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_apply) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + if (state.exitCode != 0) { + G.enableDnsHijack(previousEnabled); + Api.setRulesUpToDate(false); + ApplicationErrorLog.add(context, "DNS protection resume failed; restored previous enabled setting"); + } + syncServiceLogsToAppLog(context); + if (callback != null) { + callback.cbFunc(state); + } + } + }) + .run(context.getApplicationContext(), commands); + } + + public static String collectLocalDiagnostics(Context context) { + syncServiceLogsToAppLog(context); + StringBuilder out = new StringBuilder(); + File dir = workDir(context); + File daemon = new File(dir, DAEMON_NAME); + File supervisor = new File(dir, SUPERVISOR); + File config = new File(dir, CONF); + File controlToken = new File(dir, CONTROL_TOKEN); + File pid = new File(dir, PID); + File socket = new File(dir, SOCKET); + File queryLog = new File(dir, QUERY_LOG); + File daemonEventLog = new File(dir, DAEMON_EVENT_LOG); + File supervisorLog = new File(dir, SUPERVISOR_LOG); + File supervisorPid = new File(dir, SUPERVISOR_PID); + File restartCount = new File(dir, RESTART_COUNT); + File lastExit = new File(dir, LAST_EXIT); + File heartbeat = new File(dir, HEARTBEAT); + File markStatus = new File(dir, MARK_STATUS); + File bootScript = new File(dir, BOOT_SCRIPT); + File bootLog = new File(dir, BOOT_LOG); + File cleanupScript = new File(dir, CLEANUP_SCRIPT); + File cleanupLog = new File(dir, CLEANUP_LOG); + File moduleProp = new File(dir, MAGISK_MODULE_PROP); + File moduleService = new File(dir, MAGISK_SERVICE_SCRIPT); + File moduleUninstall = new File(dir, MAGISK_UNINSTALL_SCRIPT); + + out.append("enabled_pref=").append(G.enableDnsHijack()).append('\n'); + out.append("work_dir_storage=").append(workDirStorageLabel(context)).append('\n'); + out.append("active_profile=").append(G.activeDnsHijackPolicyProfile()).append('\n'); + out.append("profile_dns_overrides_enabled=").append(G.dnsHijackUseProfilePolicy()).append('\n'); + out.append("active_profile_dns_override_saved=") + .append(G.activeDnsHijackProfilePolicySaved()).append('\n'); + out.append("effective_dns_policy_source=").append(effectiveDnsPolicySource()).append('\n'); + out.append("blocklist_storage=").append(G.dnsHijackBlocklistDirectoryName("dnsd_blocklists")) + .append('\n'); + out.append("boot_persistence_pref=").append(G.dnsHijackBootPersistence()).append('\n'); + out.append("adb_debug_control_pref=").append(G.dnsHijackAdbDebugControl()).append('\n'); + out.append("adb_debug_control_tcp_port=") + .append(G.dnsHijackAdbDebugControl() + ? debugControlTcpPort(G.dnsHijackPort(DEFAULT_PORT)) + : 0) + .append('\n'); + out.append("expected_magisk_script_version=").append(MAGISK_SCRIPT_VERSION).append('\n'); + out.append("expected_magisk_module_version=").append(MAGISK_MODULE_VERSION).append('\n'); + out.append("boot_restore_ipv6_enabled=").append(G.enableIPv6()).append('\n'); + out.append("port=").append(G.dnsHijackPort(DEFAULT_PORT)).append('\n'); + appendManualRecoveryNotes(out); + out.append("\n[android dns compatibility]\n"); + appendAndroidDnsCompatibility(context, out); + out.append('\n'); + out.append("fail_open=").append(G.dnsHijackFailOpen()).append('\n'); + out.append("strict_mode=").append(G.dnsHijackStrictMode()).append('\n'); + out.append("safe_search=").append(G.dnsHijackSafeSearch()).append('\n'); + out.append("dnssec_request=").append(G.dnsHijackDnssecRequest()).append('\n'); + out.append("dnssec_auth_required=").append(G.dnsHijackDnssecAuthRequired()).append('\n'); + out.append("timeout_ms=").append(G.dnsHijackTimeoutMs()).append('\n'); + out.append("cache_size=").append(G.dnsHijackCacheSize()).append('\n'); + out.append("stale_cache_seconds=").append(G.dnsHijackStaleCacheSeconds()).append('\n'); + out.append("persist_cache=").append(G.dnsHijackPersistCache()).append('\n'); + out.append("query_logging=").append(G.dnsHijackQueryLogging()).append('\n'); + out.append("persist_query_logs=").append(G.dnsHijackPersistQueryLogs()).append('\n'); + out.append("bootstrap_upstream_entries=").append(countLines(G.dnsHijackBootstrapUpstreams())).append('\n'); + out.append("split_upstream_entries=").append(countLines(G.dnsHijackSplitUpstreams())).append('\n'); + out.append("capture_uid_entries=").append(parseUidList(G.dnsHijackCaptureUids()).size()).append('\n'); + out.append("bypass_uid_entries=").append(parseUidList(G.dnsHijackBypassUids()).size()).append('\n'); + out.append("capture_interface_entries=") + .append(parseInterfaceList(G.dnsHijackCaptureInterfaces()).size()).append('\n'); + out.append("bypass_interface_entries=") + .append(parseInterfaceList(G.dnsHijackBypassInterfaces()).size()).append('\n'); + out.append("nft_table_v4=").append(NFT_TABLE_V4).append('\n'); + out.append("nft_table_v6=").append(NFT_TABLE_V6).append('\n'); + out.append("scheduled_blocklist_updates=").append(G.dnsHijackScheduledBlocklistUpdates()).append('\n'); + out.append("blocklist_update_interval_hours=") + .append(G.dnsHijackBlocklistUpdateIntervalHours()).append('\n'); + out.append("app_allow_exact_entries=").append(countLines(G.dnsHijackAppAllowExact())).append('\n'); + out.append("app_block_exact_entries=").append(countLines(G.dnsHijackAppBlockExact())).append('\n'); + out.append("app_allow_suffix_entries=").append(countLines(G.dnsHijackAppAllowSuffix())).append('\n'); + out.append("app_block_suffix_entries=").append(countLines(G.dnsHijackAppBlockSuffix())).append('\n'); + out.append("network_allow_entries=").append(countLines(G.dnsHijackNetworkAllow())).append('\n'); + out.append("network_block_entries=").append(countLines(G.dnsHijackNetworkBlock())).append('\n'); + out.append("temporary_allow_entries=").append(countLines(G.dnsHijackTempAllow())).append('\n'); + out.append("temporary_block_entries=").append(countLines(G.dnsHijackTempBlock())).append('\n'); + out.append("\n[blocklists]\n"); + out.append(DnsBlocklistManager.getSummary(context)).append('\n'); + appendFileInfo(out, "work_dir", dir); + appendFileInfo(out, "daemon", daemon); + appendFileInfo(out, "supervisor", supervisor); + appendFileInfo(out, "config", config); + appendFileInfo(out, "control_token", controlToken); + appendFileInfo(out, "pid", pid); + appendFileInfo(out, "control_socket", socket); + appendFileInfo(out, "query_log", queryLog); + appendFileInfo(out, "daemon_event_log", daemonEventLog); + appendFileInfo(out, "supervisor_log", supervisorLog); + appendFileInfo(out, "supervisor_pid", supervisorPid); + appendFileInfo(out, "restart_count", restartCount); + appendFileInfo(out, "last_exit", lastExit); + appendFileInfo(out, "heartbeat", heartbeat); + appendFileInfo(out, "mark_status", markStatus); + appendFileInfo(out, "boot_script", bootScript); + appendFileInfo(out, "boot_log", bootLog); + appendFileInfo(out, "cleanup_script", cleanupScript); + appendFileInfo(out, "cleanup_log", cleanupLog); + appendFileInfo(out, "magisk_module_prop", moduleProp); + appendFileInfo(out, "magisk_service_script", moduleService); + appendFileInfo(out, "magisk_uninstall_script", moduleUninstall); + + out.append("\n[supervisor metadata]\n"); + appendSmallFileValue(out, "watchdog_pid", supervisorPid); + appendSmallFileValue(out, "restart_count", restartCount); + appendSmallFileValue(out, "last_exit", lastExit); + appendSmallFileValue(out, "heartbeat", heartbeat); + appendSmallFileValue(out, "mark_status", markStatus); + + out.append("\n[service event log bridge]\n"); + appendServiceLogBridgeStatus(context, out, "daemon", daemonEventLog); + appendServiceLogBridgeStatus(context, out, "supervisor", supervisorLog); + appendServiceLogBridgeStatus(context, out, "boot", bootLog); + appendServiceLogBridgeStatus(context, out, "cleanup", cleanupLog); + out.append("\n[recent service events]\n"); + appendTailFileValue(out, "daemon_recent", daemonEventLog, MAX_DIAGNOSTIC_LOG_TAIL); + appendTailFileValue(out, "supervisor_recent", supervisorLog, MAX_DIAGNOSTIC_LOG_TAIL); + appendTailFileValue(out, "boot_recent", bootLog, MAX_DIAGNOSTIC_LOG_TAIL); + appendTailFileValue(out, "cleanup_recent", cleanupLog, MAX_DIAGNOSTIC_LOG_TAIL); + + out.append("\n[control status]\n"); + out.append(queryControl(context, "status")); + out.append("\n[control health]\n"); + out.append(queryControl(context, "health")); + out.append("\n[control validate]\n"); + out.append(queryControl(context, "validate")); + out.append("\n[recent queries]\n"); + String logs = queryControl(context, "logs"); + out.append(logs.trim().isEmpty() ? "no daemon query logs reported\n" : logs); + return out.toString(); + } + + public static List getRecentQueries(Context context) { + return parseQueryEntries(queryControl(context, "logs")); + } + + private static void appendManualRecoveryNotes(StringBuilder out) { + if (out == null) { + return; + } + out.append("\n[manual recovery]\n"); + out.append("module_id=").append(MAGISK_MODULE_ID).append('\n'); + out.append("module_dir=").append(MAGISK_MODULE_DIR).append('\n'); + out.append("module_log=/data/local/tmp/").append(MAGISK_MODULE_LOG).append('\n'); + out.append("root_shell_notes=If AFWall cannot open, disable the module from Magisk or recovery, then reboot.\n"); + out.append("root_shell_commands=\n"); + out.append(" MOD=").append(MAGISK_MODULE_DIR).append('\n'); + out.append(" [ -x \"$MOD/").append(MAGISK_UNINSTALL_SCRIPT) + .append("\" ] && \"$MOD/").append(MAGISK_UNINSTALL_SCRIPT).append("\"\n"); + out.append(" touch \"$MOD/disable\" \"$MOD/remove\" 2>/dev/null || true\n"); + out.append(" iptables -D OUTPUT -m mark --mark ").append(DAEMON_SOCKET_MARK) + .append(" -j ACCEPT 2>/dev/null || true\n"); + out.append(" ip6tables -D OUTPUT -m mark --mark ").append(DAEMON_SOCKET_MARK) + .append(" -j ACCEPT 2>/dev/null || true\n"); + out.append(" iptables -D OUTPUT -j ").append(CHAIN_FILTER).append(" 2>/dev/null || true\n"); + out.append(" iptables -F ").append(CHAIN_FILTER).append(" 2>/dev/null || true\n"); + out.append(" iptables -X ").append(CHAIN_FILTER).append(" 2>/dev/null || true\n"); + out.append(" ip6tables -D OUTPUT -j ").append(CHAIN_FILTER).append(" 2>/dev/null || true\n"); + out.append(" ip6tables -F ").append(CHAIN_FILTER).append(" 2>/dev/null || true\n"); + out.append(" ip6tables -X ").append(CHAIN_FILTER).append(" 2>/dev/null || true\n"); + appendManualRecoveryFamilyCommands(out, "iptables", CHAIN_V4, CHAIN_V4_PRE); + appendManualRecoveryFamilyCommands(out, "ip6tables", CHAIN_V6, CHAIN_V6_PRE); + out.append(" nft delete table ip ").append(NFT_TABLE_V4).append(" 2>/dev/null || true\n"); + out.append(" nft delete table ip6 ").append(NFT_TABLE_V6).append(" 2>/dev/null || true\n"); + out.append(" reboot\n"); + } + + private static void appendManualRecoveryFamilyCommands(StringBuilder out, String tool, + String chain, String preChain) { + out.append(" ").append(tool).append(" -t nat -D OUTPUT -p udp --dport 53 -j ") + .append(chain).append(" 2>/dev/null || true\n"); + out.append(" ").append(tool).append(" -t nat -D OUTPUT -p tcp --dport 53 -j ") + .append(chain).append(" 2>/dev/null || true\n"); + out.append(" ").append(tool).append(" -t nat -D PREROUTING -p udp --dport 53 -j ") + .append(preChain).append(" 2>/dev/null || true\n"); + out.append(" ").append(tool).append(" -t nat -D PREROUTING -p tcp --dport 53 -j ") + .append(preChain).append(" 2>/dev/null || true\n"); + out.append(" ").append(tool).append(" -t nat -F ").append(chain) + .append(" 2>/dev/null || true\n"); + out.append(" ").append(tool).append(" -t nat -F ").append(preChain) + .append(" 2>/dev/null || true\n"); + out.append(" ").append(tool).append(" -t nat -X ").append(chain) + .append(" 2>/dev/null || true\n"); + out.append(" ").append(tool).append(" -t nat -X ").append(preChain) + .append(" 2>/dev/null || true\n"); + } + + public static String getRecentServiceEvents(Context context) { + if (context == null) { + return ""; + } + syncServiceLogsToAppLog(context); + File dir = workDir(context); + StringBuilder out = new StringBuilder(); + appendRecentServiceEvents(out, "daemon", new File(dir, DAEMON_EVENT_LOG)); + appendRecentServiceEvents(out, "supervisor", new File(dir, SUPERVISOR_LOG)); + appendRecentServiceEvents(out, "boot", new File(dir, BOOT_LOG)); + appendRecentServiceEvents(out, "cleanup", new File(dir, CLEANUP_LOG)); + return out.toString().trim(); + } + + public static DnsDashboardSnapshot getDashboardSnapshot(Context context) { + syncServiceLogsToAppLog(context); + String status = queryControl(context, "status"); + String health = queryControl(context, "health"); + Map statusValues = parseKeyValueLines(status); + Map healthValues = parseKeyValueLines(health); + Map blocklistValues = parseKeyValueLines(DnsBlocklistManager.getSummary(context)); + + boolean enabled = G.enableDnsHijack(); + boolean running = "1".equals(firstValue(statusValues, healthValues, "running")) + || status.contains("running=1") || health.contains("running=1"); + long queries = parseLong(firstValue(statusValues, healthValues, "queries"), 0L); + long blocked = parseLong(firstValue(statusValues, healthValues, "blocked"), 0L); + long queriesToday = parseLong(firstValue(statusValues, healthValues, "queries_today"), queries); + long blockedToday = parseLong(firstValue(statusValues, healthValues, "blocked_today"), blocked); + long allowedToday = parseLong(firstValue(statusValues, healthValues, "allowed_today"), 0L); + long reloads = parseLong(firstValue(statusValues, healthValues, "reloads"), 0L); + long reloadFailures = parseLong(firstValue(statusValues, healthValues, "reload_failures"), 0L); + long uptime = parseLong(firstValue(statusValues, healthValues, "uptime"), 0L); + long cacheSize = parseLong(firstValue(statusValues, healthValues, "cache_size"), 0L); + long cacheEntries = parseLong(firstValue(statusValues, healthValues, "cache_entries"), 0L); + long cacheHits = parseLong(firstValue(statusValues, healthValues, "cache_hits"), 0L); + long cacheMisses = parseLong(firstValue(statusValues, healthValues, "cache_misses"), 0L); + long cacheHitRatePpm = parseLong(firstValue(statusValues, healthValues, "cache_hit_rate_ppm"), -1L); + long cacheStaleHits = parseLong(firstValue(statusValues, healthValues, "cache_stale_hits"), 0L); + long avgLatency = parseLong(firstValue(statusValues, healthValues, "avg_latency_ms"), -1L); + long maxLatency = parseLong(firstValue(statusValues, healthValues, "max_latency_ms"), -1L); + long upstreamAvgLatency = parseLong(firstValue(statusValues, healthValues, + "upstream_avg_latency_ms"), -1L); + long upstreamRequests = parseLong(firstValue(statusValues, healthValues, + "upstream_requests"), 0L); + long upstreamSuccesses = parseLong(firstValue(statusValues, healthValues, + "upstream_successes"), 0L); + long upstreamFailures = parseLong(firstValue(statusValues, healthValues, + "upstream_failures"), 0L); + long upstreamBackoffActive = parseLong(firstValue(statusValues, healthValues, + "upstream_backoff_active"), 0L); + long compiledUpstreams = parseLong(firstValue(statusValues, healthValues, + "compiled_upstream_addresses"), 0L); + long reusableUdpSockets = parseLong(firstValue(statusValues, healthValues, + "reusable_udp_upstream_sockets"), 0L); + long socketMarkSupported = parseLong(firstValue(statusValues, healthValues, + "socket_mark_supported"), -1L); + long socketMarkFailures = parseLong(firstValue(statusValues, healthValues, + "socket_mark_failures"), 0L); + long failOpenControlSupported = parseLong(firstValue(statusValues, healthValues, + "fail_open_control_supported"), -1L); + long adbDebugControl = parseLong(firstValue(statusValues, healthValues, + "control_adb_debug_enabled"), G.dnsHijackAdbDebugControl() ? 1L : 0L); + long controlTcpPort = parseLong(firstValue(statusValues, healthValues, + "control_tcp_port"), G.dnsHijackAdbDebugControl() + ? debugControlTcpPort(G.dnsHijackPort(DEFAULT_PORT)) : 0L); + long controlTcpListener = parseLong(firstValue(statusValues, healthValues, + "control_tcp_listener"), 0L); + long cleanupIptablesSafe = parseLong(firstValue(statusValues, healthValues, + "cleanup_iptables_safe"), -1L); + long cleanupIp6tablesSafe = parseLong(firstValue(statusValues, healthValues, + "cleanup_ip6tables_safe"), -1L); + long memoryRssKb = parseLong(firstValue(statusValues, healthValues, "memory_rss_kb"), -1L); + long memoryHwmKb = parseLong(firstValue(statusValues, healthValues, "memory_hwm_kb"), -1L); + long cpuTotalMs = parseLong(firstValue(statusValues, healthValues, "cpu_total_ms"), -1L); + long logRingEntries = parseLong(firstValue(statusValues, healthValues, + "log_ring_entries"), 0L); + long logUnflushedEntries = parseLong(firstValue(statusValues, healthValues, + "log_unflushed_entries"), 0L); + long ruleCount = parseLong(firstValue(healthValues, statusValues, "rules_total"), -1L); + if (ruleCount < 0L) { + ruleCount = sumDashboardRuleCounts(statusValues, healthValues); + } + boolean ipv6Expected = G.enableIPv6(); + boolean udpListener = "1".equals(firstValue(statusValues, healthValues, "udp_listener")); + boolean tcpListener = "1".equals(firstValue(statusValues, healthValues, "tcp_listener")); + boolean udpListenerV4 = listenerFamilyReady(statusValues, healthValues, + "udp_listener_v4", "udp_listener"); + boolean udpListenerV6 = listenerFamilyReady(statusValues, healthValues, + "udp_listener_v6", "udp_listener"); + boolean tcpListenerV4 = listenerFamilyReady(statusValues, healthValues, + "tcp_listener_v4", "tcp_listener"); + boolean tcpListenerV6 = listenerFamilyReady(statusValues, healthValues, + "tcp_listener_v6", "tcp_listener"); + boolean controlListener = "1".equals(firstValue(statusValues, healthValues, "control_listener")); + boolean privateDnsBypass = androidPrivateDnsMayBypass(context); + boolean rootUidBypass = false; + long upstreamLatency = parseLong(firstValue(healthValues, statusValues, "upstream_probe_ms"), -1L); + String upstreamProbe = firstValue(healthValues, statusValues, "upstream_probe"); + boolean listenersReady = controlListener && udpListener && tcpListener + && udpListenerV4 && tcpListenerV4 + && (!ipv6Expected || (udpListenerV6 && tcpListenerV6)); + boolean upstreamHealthy = upstreamLatency >= 0L && "ok".equalsIgnoreCase(upstreamProbe); + String powerStatus = androidPowerDashboardLine(context); + String restartCount = readSmallFileValue(new File(workDir(context), RESTART_COUNT), "0"); + String profile = G.activeDnsHijackPolicyProfile(); + if (profile == null || profile.trim().isEmpty()) { + profile = "global"; + } + String profilePolicyLabel = " | Policy: " + effectiveDnsPolicySource(); + String blockPercent = queriesToday <= 0L + ? "0%" + : String.format(Locale.US, "%.1f%%", (blockedToday * 100.0d) / queriesToday); + String protection; + if (!enabled) { + protection = "DNS protection disabled"; + } else if (running) { + protection = "DNS protection active"; + } else { + protection = "DNS protection enabled, daemon unavailable"; + } + + String statusLine = protection + " | " + queriesToday + " queries today | " + + blockPercent + " blocked"; + if (enabled && privateDnsBypass) { + statusLine += " | Private DNS may bypass"; + } + String blocklistUpdated = blocklistValues.containsKey("updated") + ? blocklistValues.get("updated") + : "never"; + String upstream = upstreamProbe == null || upstreamProbe.trim().isEmpty() + ? "unknown" + : upstreamProbe; + if (upstreamLatency >= 0L) { + upstream += " " + upstreamLatency + "ms"; + } + String cacheLine = cacheSize <= 0L + ? "Cache: disabled" + : "Cache: " + cacheEntries + "/" + cacheSize + + " | Hit rate: " + cacheHitRate(cacheHitRatePpm, cacheHits, cacheMisses) + + " | Stale hits: " + cacheStaleHits; + String latencyLine = "Latency: avg " + formatMillis(avgLatency) + + " | upstream avg " + formatMillis(upstreamAvgLatency) + + " | max " + formatMillis(maxLatency); + String upstreamLine = "Upstream: " + upstream + + " | Requests: " + upstreamSuccesses + "/" + upstreamRequests + + " ok | Failures: " + upstreamFailures + + " | Backoff: " + upstreamBackoffActive + + "\nResolvers: compiled " + compiledUpstreams + + " | UDP sockets: " + reusableUdpSockets + + " | " + upstreamRuntimeSummary(statusValues, healthValues); + String systemLine = "System: uptime " + formatDuration(uptime) + + " | RSS " + formatKilobytes(memoryRssKb) + + " | HWM " + formatKilobytes(memoryHwmKb) + + " | CPU " + formatMillis(cpuTotalMs); + String rulesLine = "Rules: " + ruleCount + + " | Log ring: " + logRingEntries + + " | Pending log writes: " + logUnflushedEntries; + String routingLine = "DNS routing: daemon mark " + DAEMON_SOCKET_MARK + + " " + daemonMarkSupportLabel(socketMarkSupported) + + " | Mark failures: " + socketMarkFailures; + boolean failOpenCleanupArmed = failOpenControlSupported == 1L + && cleanupIptablesSafe == 1L + && (!ipv6Expected || cleanupIp6tablesSafe == 1L); + String failOpenLine = "Fail-open cleanup: " + + (failOpenCleanupArmed ? "armed" : "degraded") + + " | control " + yesNoUnknown(failOpenControlSupported) + + " | IPv4 tool " + yesNoUnknown(cleanupIptablesSafe) + + " | IPv6 tool " + listenerFamilyLabel(cleanupIp6tablesSafe == 1L, ipv6Expected); + String controlLine = "Control socket: app-only" + + (adbDebugControl == 1L ? " + ADB diagnostics" : "") + + " | token auth required"; + if (adbDebugControl == 1L && controlTcpPort > 0L) { + controlLine += " | TCP 127.0.0.1:" + controlTcpPort + " " + + listenerLabel(controlTcpListener == 1L); + } + String routingScopeLine = preroutingRedirectExpected() + ? "DNS scope: local and forwarded port-53 capture" + : "DNS scope: UID-scoped app-owned port-53 sockets; Android system resolver traffic may use a system UID"; + String details = "Daemon: " + (running ? "running" : "stopped") + + " | Redirect setting: " + (enabled ? "enabled" : "disabled") + + " | Profile: " + profile + + profilePolicyLabel + + privateDnsDashboardLine(context) + + "\n" + routingLine + + "\n" + controlLine + + "\n" + failOpenLine + + "\n" + routingScopeLine + + "\nBlocklist updated: " + blocklistUpdated + + "\nToday: " + allowedToday + " allowed | " + blockedToday + " blocked" + + "\nTotal: " + queries + " queries | Restarts: " + restartCount + + " | Reloads: " + reloads + + " | Reload failures: " + reloadFailures + + "\n" + cacheLine + + "\n" + latencyLine + + "\n" + upstreamLine + + "\n" + systemLine + + "\n" + powerStatus + + "\n" + rulesLine + + "\nListeners: UDP v4 " + listenerLabel(udpListenerV4) + + " | UDP v6 " + listenerFamilyLabel(udpListenerV6, ipv6Expected) + + " | TCP v4 " + listenerLabel(tcpListenerV4) + + " | TCP v6 " + listenerFamilyLabel(tcpListenerV6, ipv6Expected) + + " | Control " + listenerLabel(controlListener); + return new DnsDashboardSnapshot(statusLine, details, enabled, running, + listenersReady, upstreamHealthy, privateDnsBypass, rootUidBypass, powerStatus); + } + + private static String effectiveDnsPolicySource() { + if (!G.dnsHijackUseProfilePolicy()) { + return "global"; + } + return G.activeDnsHijackProfilePolicySaved() + ? "active_profile_override" + : "global_fallback_no_active_profile_override"; + } + + public static boolean androidPrivateDnsMayBypass(Context context) { + return privateDnsModeCanBypass(readAndroidPrivateDnsMode(context)); + } + + public static String androidPrivateDnsMode(Context context) { + return readAndroidPrivateDnsMode(context); + } + + public static String androidPrivateDnsSpecifier(Context context) { + return readAndroidPrivateDnsSpecifier(context); + } + + public static String androidPrivateDnsWarning(Context context) { + String mode = readAndroidPrivateDnsMode(context); + if (!privateDnsModeCanBypass(mode)) { + return null; + } + String specifier = readAndroidPrivateDnsSpecifier(context); + String provider = specifier.isEmpty() ? "" : " (" + specifier + ")"; + return "Android Private DNS is " + mode + provider + + ". Root DNS capture redirects UDP/TCP port 53, so Private DNS can bypass it."; + } + + public static void syncServiceLogsToAppLog(Context context) { + if (context == null) { + return; + } + try { + File dir = workDir(context); + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(SERVICE_LOG_PREFS, Context.MODE_PRIVATE); + syncServiceLogFile(context, prefs, new File(dir, DAEMON_EVENT_LOG), "daemon"); + syncServiceLogFile(context, prefs, new File(dir, SUPERVISOR_LOG), "supervisor"); + syncServiceLogFile(context, prefs, new File(dir, BOOT_LOG), "boot"); + syncServiceLogFile(context, prefs, new File(dir, CLEANUP_LOG), "cleanup"); + } catch (RuntimeException e) { + ApplicationErrorLog.add(context, "DNS service log bridge failed: " + e.getMessage()); + } + } + + private static void syncServiceLogFile(Context context, SharedPreferences prefs, File file, + String label) { + if (context == null || prefs == null || file == null || !file.exists()) { + return; + } + String key = serviceLogOffsetKey(label); + if (!file.canRead()) { + recordUnreadableServiceLog(context, prefs, label); + return; + } + long length = file.length(); + long offset = prefs.getLong(key, -1L); + if (length <= 0L) { + prefs.edit() + .putLong(key, 0L) + .putBoolean(UNREADABLE_SERVICE_LOG_PREFIX + label, false) + .apply(); + return; + } + if (offset < 0L) { + offset = Math.max(0L, length - MAX_SERVICE_LOG_READ); + } else if (offset > length) { + offset = 0L; + } + if (length <= offset) { + return; + } + long readStart = offset; + if (length - readStart > MAX_SERVICE_LOG_READ) { + readStart = length - MAX_SERVICE_LOG_READ; + ApplicationErrorLog.add(context, "DNS service " + label + + " log advanced while app was closed; older service events were skipped"); + } + String chunk = readFileRange(file, readStart, length - readStart); + if (chunk != null) { + bridgeServiceLogLines(context, label, chunk); + prefs.edit() + .putLong(key, length) + .putBoolean(UNREADABLE_SERVICE_LOG_PREFIX + label, false) + .apply(); + return; + } + recordUnreadableServiceLog(context, prefs, label); + } + + private static void recordUnreadableServiceLog(Context context, SharedPreferences prefs, + String label) { + if (!prefs.getBoolean(UNREADABLE_SERVICE_LOG_PREFIX + label, false)) { + ApplicationErrorLog.add(context, "DNS service " + label + + " log could not be read; root log file permissions may need repair"); + Log.w(TAG, "DNS service " + label + " log could not be read by the app"); + } + prefs.edit().putBoolean(UNREADABLE_SERVICE_LOG_PREFIX + label, true).apply(); + } + + private static void bridgeServiceLogLines(Context context, String label, String chunk) { + String[] lines = chunk.split("\\r?\\n"); + List cleaned = new ArrayList<>(); + for (String line : lines) { + String clean = sanitizeServiceLogLine(line); + if (!clean.isEmpty()) { + cleaned.add(clean); + } + } + int start = Math.max(0, cleaned.size() - MAX_SERVICE_LOG_LINES); + for (int i = start; i < cleaned.size(); i++) { + String bridgedLine = "DNS service " + label + ": " + cleaned.get(i); + Log.i(TAG, bridgedLine); + ApplicationErrorLog.add(context, bridgedLine); + } + } + + private static String sanitizeServiceLogLine(String line) { + String clean = line == null ? "" : line.trim().replace('\r', ' ').replace('\n', ' '); + clean = clean.replaceAll("\\s+", " "); + clean = formatServiceEventTimestamp(clean); + if (clean.length() > MAX_SERVICE_LOG_LINE_CHARS) { + clean = clean.substring(0, MAX_SERVICE_LOG_LINE_CHARS); + } + return clean; + } + + private static String formatServiceEventTimestamp(String clean) { + if (clean == null || clean.isEmpty()) { + return ""; + } + int separator = clean.indexOf(' '); + if (separator <= 0 || separator >= clean.length() - 1) { + return clean; + } + String firstToken = clean.substring(0, separator); + for (int i = 0; i < firstToken.length(); i++) { + if (!Character.isDigit(firstToken.charAt(i))) { + return clean; + } + } + try { + long epochSeconds = Long.parseLong(firstToken); + String timestamp = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) + .format(new Date(epochSeconds * 1000L)); + return timestamp + " " + clean.substring(separator + 1); + } catch (NumberFormatException e) { + return clean; + } + } + + private static void appendServiceLogBridgeStatus(Context context, StringBuilder out, + String label, File file) { + SharedPreferences prefs = context.getApplicationContext() + .getSharedPreferences(SERVICE_LOG_PREFS, Context.MODE_PRIVATE); + long offset = prefs.getLong(serviceLogOffsetKey(label), -1L); + out.append(label) + .append("_log_exists=").append(file != null && file.exists()) + .append(" readable=").append(file != null && file.canRead()) + .append(" size=").append(file == null || !file.exists() ? 0L : file.length()) + .append(" bridged_offset=").append(offset) + .append('\n'); + } + + private static String serviceLogOffsetKey(String label) { + return "offset_" + label; + } + + private static void appendTailFileValue(StringBuilder out, String label, File file, int maxBytes) { + out.append(label).append("=\n"); + if (file == null || !file.exists()) { + out.append("missing\n"); + return; + } + long length = file.length(); + if (length <= 0L) { + out.append("empty\n"); + return; + } + long readStart = Math.max(0L, length - Math.max(1, maxBytes)); + String value = readFileRange(file, readStart, length - readStart); + if (value == null || value.trim().isEmpty()) { + out.append("unreadable or empty\n"); + return; + } + out.append(value.trim()).append('\n'); + } + + private static void appendRecentServiceEvents(StringBuilder out, String label, File file) { + if (out == null || label == null || file == null || !file.exists() || file.length() <= 0L) { + return; + } + long length = file.length(); + long readStart = Math.max(0L, length - MAX_DIAGNOSTIC_LOG_TAIL); + String value = readFileRange(file, readStart, length - readStart); + if (value == null || value.trim().isEmpty()) { + return; + } + String[] lines = value.split("\\r?\\n"); + List cleaned = new ArrayList<>(); + for (String line : lines) { + String clean = sanitizeServiceLogLine(line); + if (!clean.isEmpty()) { + cleaned.add(clean); + } + } + int start = Math.max(0, cleaned.size() - MAX_SERVICE_LOG_LINES); + for (int i = start; i < cleaned.size(); i++) { + if (out.length() > 0) { + out.append('\n'); + } + out.append(label).append(": ").append(cleaned.get(i)); + } + } + + private static String readFileRange(File file, long offset, long bytes) { + if (file == null || bytes <= 0L) { + return ""; + } + int length = (int) Math.min(bytes, Integer.MAX_VALUE); + byte[] buffer = new byte[length]; + try (RandomAccessFile randomAccessFile = new RandomAccessFile(file, "r")) { + randomAccessFile.seek(Math.max(0L, offset)); + int read = randomAccessFile.read(buffer); + if (read <= 0) { + return ""; + } + return new String(buffer, 0, read, StandardCharsets.UTF_8); + } catch (IOException | RuntimeException e) { + return null; + } + } + + private static String privateDnsDashboardLine(Context context) { + String warning = androidPrivateDnsWarning(context); + if (warning != null) { + return "\nAndroid Private DNS warning: " + warning; + } + return "\nAndroid Private DNS: " + readAndroidPrivateDnsMode(context); + } + + private static void appendAndroidDnsCompatibility(Context context, StringBuilder out) { + boolean uidScopedCapture = !parseUidList(G.dnsHijackCaptureUids()).isEmpty(); + out.append("capture_scope=").append(uidScopedCapture + ? "udp_tcp_port_53_output_uid_scoped_except_daemon_mark" + : "udp_tcp_port_53_output_and_prerouting_except_daemon_mark").append('\n'); + out.append("uid_scoped_prerouting_capture=").append(uidScopedCapture + ? "disabled_forwarded_packets_do_not_expose_app_uid" + : "enabled").append('\n'); + out.append("uid_scoped_android_resolver_warning=").append(uidScopedCapture + ? "system_resolver_packets_may_use_system_uid_use_empty_uid_scope_for_full_capture" + : "none").append('\n'); + out.append("encrypted_dns_note=Private DNS/DoT on 853 and in-app DoH are not port-53 DNS and can bypass NAT capture\n"); + out.append("daemon_control_auth=token_required\n"); + out.append("daemon_control_socket_uid=").append(context.getApplicationInfo().uid).append('\n'); + out.append("daemon_control_adb_debug_enabled=") + .append(G.dnsHijackAdbDebugControl()).append('\n'); + out.append("daemon_control_adb_debug_note=when enabled, use adb shell su -c with the app-owned token file; diagnostics never print the token\n"); + out.append("daemon_control_adb_debug_tcp=") + .append(G.dnsHijackAdbDebugControl() + ? "127.0.0.1:" + debugControlTcpPort(G.dnsHijackPort(DEFAULT_PORT)) + : "disabled") + .append('\n'); + out.append("daemon_socket_mark=").append(DAEMON_SOCKET_MARK).append('\n'); + out.append("daemon_mark_output_bypass=enabled_to_prevent_daemon_upstream_recursion\n"); + out.append("daemon_mark_filter_bypass=enabled_for_daemon_upstream_packets\n"); + out.append("root_uid_output_bypass=fallback_only_when_mark_match_is_unavailable\n"); + out.append("root_uid_filter_bypass=fallback_only_when_daemon_socket_mark_is_unavailable\n"); + out.append("root_uid_capture_warning=").append(rootUidBypassWarning()).append('\n'); + out.append("private_dns_mode=").append(readAndroidPrivateDnsMode(context)).append('\n'); + String specifier = readAndroidPrivateDnsSpecifier(context); + out.append("private_dns_specifier=") + .append(specifier.isEmpty() ? "none" : specifier).append('\n'); + String warning = androidPrivateDnsWarning(context); + out.append("private_dns_capture_warning=") + .append(warning == null ? "none" : warning).append('\n'); + out.append("android_app_battery_optimized=").append(androidAppBatteryOptimized(context)).append('\n'); + out.append("root_daemon_storage=").append(workDirStorageLabel(context)).append('\n'); + out.append("root_daemon_power_scope=outside_android_app_process\n"); + out.append("root_watchdog_scope=supervisor_script_restarts_daemon_when_heartbeat_stales\n"); + out.append("root_watchdog_fail_open=removes_dns_redirects_until_daemon_is_ready\n"); + } + + private static String androidAppBatteryOptimized(Context context) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return "unsupported"; + } + try { + return String.valueOf(Api.batteryOptimized(context)); + } catch (RuntimeException e) { + return "unknown"; + } + } + + private static String androidPowerDashboardLine(Context context) { + String optimized = androidAppBatteryOptimized(context); + if ("true".equals(optimized)) { + return "Power: root daemon watchdog runs outside app power limits and fails open while restarting; scheduled app updates may be delayed"; + } + if ("false".equals(optimized)) { + return "Power: root daemon watchdog runs outside app power limits and fails open while restarting; app updates are not battery-optimized"; + } + return "Power: root daemon watchdog runs outside app power limits and fails open while restarting; app update power state " + optimized; + } + + private static String rootUidBypassWarning() { + return "UID 0 OUTPUT DNS is only used as a compatibility fallback if mark matching is unavailable; " + + "that fallback can let root-owned system DNS bypass capture"; + } + + private static String daemonMarkSupportLabel(long supported) { + if (supported == 1L) { + return "supported"; + } + if (supported == 0L) { + return "unavailable"; + } + return "unknown"; + } + + private static String yesNoUnknown(long value) { + if (value == 1L) { + return "ready"; + } + if (value == 0L) { + return "missing"; + } + return "unknown"; + } + + private static String readAndroidPrivateDnsMode(Context context) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return "unsupported"; + } + try { + String value = Settings.Global.getString(context.getContentResolver(), PRIVATE_DNS_MODE); + return value == null || value.trim().isEmpty() + ? "unknown" + : value.trim().toLowerCase(Locale.US); + } catch (RuntimeException e) { + return "unknown"; + } + } + + private static String readAndroidPrivateDnsSpecifier(Context context) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return ""; + } + try { + String value = Settings.Global.getString(context.getContentResolver(), PRIVATE_DNS_SPECIFIER); + return value == null ? "" : value.trim(); + } catch (RuntimeException e) { + return ""; + } + } + + private static boolean privateDnsModeCanBypass(String mode) { + if (mode == null) { + return false; + } + String normalized = mode.trim().toLowerCase(Locale.US); + return "opportunistic".equals(normalized) || "hostname".equals(normalized); + } + + public static List getHistoricalQueries(Context context, String filter) { + String cleanFilter = sanitizeHistoryFilter(filter); + String command = cleanFilter.isEmpty() ? "history" : "history " + cleanFilter; + return parseQueryEntries(queryControl(context, command)); + } + + public static String benchmarkUpstreams(Context context) { + String daemonResult = queryControl(context, "benchmark"); + if (daemonResult.startsWith("benchmark=1")) { + ApplicationErrorLog.add(context, "DNS upstream benchmark completed through daemon control socket"); + return daemonResult; + } + + ApplicationErrorLog.add(context, "DNS upstream benchmark falling back to app UDP probes: " + + daemonResult.trim()); + return benchmarkUpstreamsDirect(); + } + + public static String runCaptureProbe(Context context) { + if (context == null) { + return "capture_probe=unavailable\nreason=missing_context\n"; + } + if (!G.enableDnsHijack()) { + return logCaptureProbeResult(context, + "capture_probe=disabled\nreason=dns_protection_disabled\n"); + } + + int appUid = android.os.Process.myUid(); + List captureUids = parseUidList(G.dnsHijackCaptureUids()); + List bypassUids = parseUidList(G.dnsHijackBypassUids()); + if (bypassUids.contains(appUid)) { + return logCaptureProbeResult(context, + "capture_probe=skipped\nreason=app_uid_bypassed\napp_uid=" + appUid + "\n"); + } + if (!captureUids.isEmpty() && !captureUids.contains(appUid)) { + return logCaptureProbeResult(context, + "capture_probe=skipped\nreason=app_uid_not_in_capture_scope\napp_uid=" + + appUid + "\n"); + } + + String beforeRaw = queryControl(context, "status"); + Map beforeValues = parseKeyValueLines(beforeRaw); + if (!"1".equals(beforeValues.get("running"))) { + return logCaptureProbeResult(context, + "capture_probe=unavailable\nreason=daemon_not_running\ncontrol_status=" + + safeLogValue(compactControlResponse(beforeRaw)) + "\n"); + } + + long beforeQueries = parseLong(beforeValues.get("queries"), -1L); + String domain = "afwall-capture-" + Long.toHexString(System.currentTimeMillis()) + + ".example.com"; + byte[] query = buildDnsLookupQuery(domain, DNS_QTYPE_A); + int timeoutMs = Math.min(Math.max(G.dnsHijackTimeoutMs(), 500), 2000); + ProbeResult udpResult = query == null + ? new ProbeResult("build_error", -1, -1) + : probeUdpUpstreamDirect(new UpstreamTarget("1.1.1.1", 53, "udp"), + timeoutMs, query); + sleepQuietly(250L); + + String afterRaw = queryControl(context, "status"); + Map afterValues = parseKeyValueLines(afterRaw); + long afterQueries = parseLong(afterValues.get("queries"), -1L); + boolean observedByCounter = beforeQueries >= 0L && afterQueries > beforeQueries; + String afterLogsRaw = queryControl(context, "logs"); + boolean observedDomain = queryLogsContainDomain(afterLogsRaw, domain); + boolean observedByDaemon = observedDomain || observedByCounter; + boolean dnsResponseReceived = udpResult.bytes > 0; + String probeStatus = observedDomain + ? (dnsResponseReceived ? "captured" : "captured_no_response") + : observedByCounter + ? (dnsResponseReceived ? "captured_counter_only" : "captured_counter_only_no_response") + : dnsResponseReceived ? "bypassed_or_not_counted" : "not_observed"; + + StringBuilder out = new StringBuilder(); + out.append("capture_probe=").append(probeStatus).append('\n'); + out.append("probe_scope=app_process_udp_output_port_53\n"); + out.append("android_system_resolver_probe=not_tested_may_use_system_uid\n"); + out.append("forwarded_prerouting_probe=not_tested_requires_external_or_tethered_client\n"); + out.append("encrypted_dns_probe=not_tested_private_dns_dot_doh_can_bypass_port_53\n"); + out.append("app_uid=").append(appUid).append('\n'); + out.append("probe_target=udp://1.1.1.1:53\n"); + out.append("test_domain=").append(domain).append('\n'); + out.append("daemon_observed_domain=").append(observedDomain).append('\n'); + out.append("dns_response_received=").append(dnsResponseReceived).append('\n'); + out.append("daemon_queries_before=").append(beforeQueries).append('\n'); + out.append("daemon_queries_after=").append(afterQueries).append('\n'); + out.append("daemon_query_logging=") + .append(emptyFallback(afterValues.get("query_logging"), "unknown")).append('\n'); + out.append("udp_probe_status=").append(udpResult.status).append('\n'); + out.append("udp_probe_bytes=").append(udpResult.bytes).append('\n'); + out.append("udp_probe_rcode=").append(udpResult.rcode).append('\n'); + out.append("timeout_ms=").append(timeoutMs).append('\n'); + if (observedByCounter && !observedDomain) { + out.append("proof_note=daemon query counter increased, but recent logs did not include the test domain\n"); + } + if (!observedDomain && "0".equals(afterValues.get("query_logging"))) { + out.append("proof_note=query logging is disabled, so exact-domain capture proof is unavailable\n"); + } + if (!captureUids.isEmpty()) { + out.append("uid_scope_note=this probe uses an app-owned UDP socket; ") + .append("Android system resolver traffic may use a system UID\n"); + } + if (!parseInterfaceList(G.dnsHijackCaptureInterfaces()).isEmpty() + || !parseInterfaceList(G.dnsHijackBypassInterfaces()).isEmpty()) { + out.append("scope_note=interface capture or bypass rules are configured; ") + .append("this probe validates the app process route only\n"); + } + if (!observedByDaemon) { + out.append("action_hint=repair DNS protection, then retry; if scoped capture is enabled, ") + .append("test with an app in the captured scope\n"); + } else if (!dnsResponseReceived) { + out.append("action_hint=capture was observed, but no DNS response returned; ") + .append("check upstream DNS, fail-open/strict settings, and service events\n"); + } + return logCaptureProbeResult(context, out.toString()); + } + + private static boolean queryLogsContainDomain(String raw, String domain) { + if (domain == null || domain.trim().isEmpty()) { + return false; + } + String normalized = normalizeDomain(domain); + if (normalized.isEmpty()) { + return false; + } + List entries = parseQueryEntries(raw); + for (QueryEntry entry : entries) { + if (entry != null && normalized.equals(entry.domain)) { + return true; + } + } + return false; + } + + private static String logCaptureProbeResult(Context context, String result) { + ApplicationErrorLog.add(context, "DNS capture probe result: " + + compactControlResponse(result)); + return result; + } + + private static void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + public static boolean addRuleFromQuery(Context context, QueryEntry entry, int action) { + if (entry == null || !entry.hasDomain()) { + return false; + } + String domain = entry.domain; + boolean added; + switch (action) { + case RULE_ALLOW_EXACT: + added = G.appendDnsHijackAllowExact(domain); + break; + case RULE_ALLOW_SUFFIX: + added = G.appendDnsHijackAllowSuffix(domain); + break; + case RULE_BLOCK_EXACT: + added = G.appendDnsHijackBlockExact(domain); + break; + case RULE_BLOCK_SUFFIX: + added = G.appendDnsHijackBlockSuffix(domain); + break; + case RULE_TEMP_ALLOW: + added = G.appendDnsHijackTempAllow(domain, temporaryRuleExpiresAt()); + break; + case RULE_TEMP_BLOCK: + added = G.appendDnsHijackTempBlock(domain, temporaryRuleExpiresAt()); + break; + case RULE_APP_ALLOW_EXACT: + added = G.appendDnsHijackAppAllowExact(parseQueryUid(entry), domain); + break; + case RULE_APP_BLOCK_EXACT: + added = G.appendDnsHijackAppBlockExact(parseQueryUid(entry), domain); + break; + case RULE_APP_ALLOW_SUFFIX: + added = G.appendDnsHijackAppAllowSuffix(parseQueryUid(entry), domain); + break; + case RULE_APP_BLOCK_SUFFIX: + added = G.appendDnsHijackAppBlockSuffix(parseQueryUid(entry), domain); + break; + default: + return false; + } + if (added) { + ApplicationErrorLog.add(context, "DNS query action added rule for " + domain + + " uid=" + parseQueryUid(entry) + " action=" + action); + requestReload(context); + } + return added; + } + + public static List buildRootDiagnosticsCommands(Context context) { + List commands = new ArrayList<>(); + String supervisor = shellQuote(supervisorPath(context)); + String iptables = shellQuote(Api.getBinaryPath(context, false)); + String ip6tables = shellQuote(Api.getBinaryPath(context, true)); + + commands.add("echo '[supervisor status]'"); + commands.add("if [ -x " + supervisor + " ]; then " + supervisor + + " status 2>&1 || true; else echo 'supervisor missing'; fi"); + commands.add("echo '[DNS redirect status]'"); + commands.addAll(buildRootRedirectStatusCommands(context)); + commands.add("echo '[pid]'"); + commands.add("cat " + shellQuote(new File(workDir(context), PID).getAbsolutePath()) + " 2>&1 || true"); + commands.add("echo '[IPv4 DNS NAT OUTPUT]'"); + commands.add(iptables + " -t nat -S OUTPUT 2>&1 | grep 'afwall-dns' || true"); + commands.add("echo '[IPv4 DNS filter daemon bypass]'"); + commands.add(iptables + " -S OUTPUT 2>&1 | grep " + shellQuote(DAEMON_SOCKET_MARK) + + " || true"); + commands.add("echo '[IPv4 DNS NAT chains]'"); + commands.add(iptables + " -t nat -S " + CHAIN_V4 + " 2>&1 || true"); + commands.add(iptables + " -t nat -S " + CHAIN_V4_PRE + " 2>&1 || true"); + commands.add("echo '[IPv6 DNS NAT OUTPUT]'"); + commands.add(ip6tables + " -t nat -S OUTPUT 2>&1 | grep 'afwall-dns6' || true"); + commands.add("echo '[IPv6 DNS filter daemon bypass]'"); + commands.add(ip6tables + " -S OUTPUT 2>&1 | grep " + shellQuote(DAEMON_SOCKET_MARK) + + " || true"); + commands.add("echo '[IPv6 DNS NAT chains]'"); + commands.add(ip6tables + " -t nat -S " + CHAIN_V6 + " 2>&1 || true"); + commands.add(ip6tables + " -t nat -S " + CHAIN_V6_PRE + " 2>&1 || true"); + commands.add("echo '[nft DNS redirect tables]'"); + commands.add("if command -v nft >/dev/null 2>&1; then " + + "nft list table ip " + NFT_TABLE_V4 + " 2>&1 || true; " + + "nft list table ip6 " + NFT_TABLE_V6 + " 2>&1 || true; " + + "else echo 'nft missing'; fi"); + commands.addAll(buildMagiskModuleStatusCommands(context)); + commands.add("echo '[DNS fallback stale cleanup logs]'"); + commands.add(buildFallbackRootLogTailCommand(BOOT_LOG)); + commands.add(buildFallbackRootLogTailCommand(CLEANUP_LOG)); + return commands; + } + + public static List buildMagiskModuleStatusCommands(Context context) { + List commands = new ArrayList<>(); + File dir = workDir(context); + commands.add("echo '[DNS Magisk module]'"); + commands.add("echo 'expected_script_version=" + MAGISK_SCRIPT_VERSION + "'"); + commands.add("PKG=" + shellQuote(context.getPackageName()) + "; " + + "if pm path \"$PKG\" >/dev/null 2>&1; then " + + "echo 'app_package=installed'; " + + "else echo 'app_package=missing'; fi"); + commands.add("MOD=" + shellQuote(MAGISK_MODULE_DIR) + "; " + + "if [ -d \"$MOD\" ]; then " + + "echo 'magisk_module=installed'; " + + "[ -f \"$MOD/disable\" ] && echo 'magisk_module_disabled=1' || echo 'magisk_module_disabled=0'; " + + "[ -f \"$MOD/remove\" ] && echo 'magisk_module_remove_pending=1' || echo 'magisk_module_remove_pending=0'; " + + "if [ -f \"$MOD/" + MAGISK_SERVICE_SCRIPT + "\" ]; then " + + "grep '^AFWALL_DNS_MODULE_SCRIPT_VERSION=' \"$MOD/" + MAGISK_SERVICE_SCRIPT + "\" 2>/dev/null " + + "| sed 's/^AFWALL_DNS_MODULE_SCRIPT_VERSION=/installed_service_script_version=/' || true; " + + "else echo 'installed_service_script_missing=1'; fi; " + + "if [ -f \"$MOD/" + MAGISK_UNINSTALL_SCRIPT + "\" ]; then " + + "grep '^AFWALL_DNS_MODULE_SCRIPT_VERSION=' \"$MOD/" + MAGISK_UNINSTALL_SCRIPT + "\" 2>/dev/null " + + "| sed 's/^AFWALL_DNS_MODULE_SCRIPT_VERSION=/installed_uninstall_script_version=/' || true; " + + "else echo 'installed_uninstall_script_missing=1'; fi; " + + "ls -la \"$MOD\" 2>&1; " + + "else echo 'magisk_module=missing'; fi"); + commands.add("echo '[AFWall prepared module files]'"); + commands.add("SERVICE=" + shellQuote(new File(dir, MAGISK_SERVICE_SCRIPT).getAbsolutePath()) + "; " + + "UNINSTALL=" + shellQuote(new File(dir, MAGISK_UNINSTALL_SCRIPT).getAbsolutePath()) + "; " + + "PROP=" + shellQuote(new File(dir, MAGISK_MODULE_PROP).getAbsolutePath()) + "; " + + "for f in \"$PROP\" \"$SERVICE\" \"$UNINSTALL\"; do " + + "if [ -e \"$f\" ]; then ls -l \"$f\"; else echo \"$f missing\"; fi; done; " + + "if [ -f \"$SERVICE\" ]; then " + + "grep '^AFWALL_DNS_MODULE_SCRIPT_VERSION=' \"$SERVICE\" 2>/dev/null " + + "| sed 's/^AFWALL_DNS_MODULE_SCRIPT_VERSION=/prepared_service_script_version=/' || true; " + + "else echo 'prepared_service_script_missing=1'; fi; " + + "if [ -f \"$UNINSTALL\" ]; then " + + "grep '^AFWALL_DNS_MODULE_SCRIPT_VERSION=' \"$UNINSTALL\" 2>/dev/null " + + "| sed 's/^AFWALL_DNS_MODULE_SCRIPT_VERSION=/prepared_uninstall_script_version=/' || true; " + + "else echo 'prepared_uninstall_script_missing=1'; fi"); + commands.add("echo '[DNS control socket permissions]'"); + commands.add("SOCK=" + shellQuote(new File(dir, SOCKET).getAbsolutePath()) + "; " + + "if [ -S \"$SOCK\" ]; then ls -l \"$SOCK\" 2>&1; " + + "else echo 'control_socket=missing'; fi"); + commands.add("echo '[DNS module log]'"); + commands.add(buildFallbackRootLogTailCommand(MAGISK_MODULE_LOG)); + commands.add("echo '[legacy DNS root startup hooks]'"); + commands.add("found=0; for f in /data/adb/service.d/" + BOOT_SCRIPT + + " /su/su.d/" + BOOT_SCRIPT + + " /system/su.d/" + BOOT_SCRIPT + + " /system/etc/init.d/" + BOOT_SCRIPT + + " /data/adb/service.d/" + CLEANUP_SCRIPT + + " /su/su.d/" + CLEANUP_SCRIPT + + " /system/su.d/" + CLEANUP_SCRIPT + + " /system/etc/init.d/" + CLEANUP_SCRIPT + + "; do if [ -f \"$f\" ]; then ls -l \"$f\"; found=1; fi; done; " + + "[ \"$found\" = 1 ] || echo 'no legacy root startup hooks installed'"); + return commands; + } + + private static String buildFallbackRootLogTailCommand(String logName) { + String path = "/data/local/tmp/" + logName; + return "if [ -f " + shellQuote(path) + " ]; then echo " + shellQuote(path) + + "; tail -n 30 " + shellQuote(path) + " 2>/dev/null || cat " + + shellQuote(path) + " 2>/dev/null; else echo " + shellQuote(path) + + " missing; fi"; + } + + public static List buildRootRedirectStatusCommands(Context context) { + List commands = new ArrayList<>(); + String iptables = shellQuote(Api.getBinaryPath(context, false)); + String ip6tables = shellQuote(Api.getBinaryPath(context, true)); + appendRedirectStatusCommands(commands, "dns_redirect_ipv4", iptables, + CHAIN_V4, CHAIN_V4_PRE, "ip", NFT_TABLE_V4); + appendRedirectStatusCommands(commands, "dns_redirect_ipv6", ip6tables, + CHAIN_V6, CHAIN_V6_PRE, "ip6", NFT_TABLE_V6); + return commands; + } + + public static String formatRootRedirectStatus(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return "Redirect rules: root check failed"; + } + Map values = parseKeyValueLines(raw); + if (values.isEmpty()) { + return "Redirect rules: root check failed"; + } + String ipv4 = redirectFamilyStatus(values, "dns_redirect_ipv4"); + String ipv6 = G.enableIPv6() + ? redirectFamilyStatus(values, "dns_redirect_ipv6") + : "disabled"; + return "Redirect rules: IPv4 " + ipv4 + " | IPv6 " + ipv6; + } + + public static String formatDashboardReadiness(DnsDashboardSnapshot snapshot, String rootStatusRaw) { + if (snapshot == null || !snapshot.enabled) { + return "Readiness: disabled"; + } + List blockers = new ArrayList<>(); + List warnings = new ArrayList<>(); + if (!snapshot.daemonRunning) { + blockers.add("daemon unavailable"); + } + if (!snapshot.listenersReady) { + blockers.add("listener missing"); + } + if (redirectStatusMissingDaemonFilterBypass(rootStatusRaw)) { + blockers.add("daemon upstream filter bypass missing"); + } else if (!isRootRedirectStatusHealthy(rootStatusRaw)) { + blockers.add("redirect rules missing"); + } + if (!snapshot.upstreamProbeHealthy) { + warnings.add("upstream probe failed"); + } + if (snapshot.privateDnsMayBypass) { + warnings.add("Android Private DNS may bypass capture"); + } + if (redirectStatusUsesUidFallback(rootStatusRaw)) { + warnings.add("daemon mark unavailable; UID 0 DNS may bypass capture"); + } else if (snapshot.rootUidMayBypass) { + warnings.add("root/system DNS may bypass capture"); + } + if (!blockers.isEmpty()) { + return "Readiness: repair needed (" + joinLabels(blockers) + ")"; + } + if (!warnings.isEmpty()) { + return "Readiness: usable with warning (" + joinLabels(warnings) + ")"; + } + return preroutingRedirectExpected() + ? "Readiness: ready for local and forwarded UDP/TCP port-53 capture" + : "Readiness: ready for UID-scoped app-owned UDP/TCP port-53 capture"; + } + + private static boolean redirectStatusUsesUidFallback(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return false; + } + Map values = parseKeyValueLines(raw); + if (values.isEmpty()) { + return false; + } + if (redirectFamilyUsesUidFallback(values, "dns_redirect_ipv4")) { + return true; + } + return G.enableIPv6() && redirectFamilyUsesUidFallback(values, "dns_redirect_ipv6"); + } + + private static boolean redirectStatusMissingDaemonFilterBypass(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return false; + } + Map values = parseKeyValueLines(raw); + if (values.isEmpty()) { + return false; + } + if (redirectFamilyMissingDaemonFilterBypass(values, "dns_redirect_ipv4")) { + return true; + } + return G.enableIPv6() + && redirectFamilyMissingDaemonFilterBypass(values, "dns_redirect_ipv6"); + } + + public static boolean isRootRedirectStatusHealthy(String raw) { + if (!G.enableDnsHijack()) { + return true; + } + if (raw == null || raw.trim().isEmpty()) { + return false; + } + Map values = parseKeyValueLines(raw); + if (values.isEmpty()) { + return false; + } + if (!redirectFamilyHealthy(values, "dns_redirect_ipv4")) { + return false; + } + return !G.enableIPv6() || redirectFamilyHealthy(values, "dns_redirect_ipv6"); + } + + private static void appendRedirectStatusCommands(List commands, String prefix, + String iptables, String chain, + String preChain, String nftFamily, + String nftTable) { + int port = G.dnsHijackPort(DEFAULT_PORT); + commands.add(buildChainStatusCommand(prefix + "_chain", iptables, chain)); + commands.add(buildChainStatusCommand(prefix + "_pre_chain", iptables, preChain)); + commands.add(buildRuleStatusCommand(prefix + "_output_udp", iptables, + "OUTPUT", "udp", chain)); + commands.add(buildRuleStatusCommand(prefix + "_output_tcp", iptables, + "OUTPUT", "tcp", chain)); + commands.add(buildRuleStatusCommand(prefix + "_prerouting_udp", iptables, + "PREROUTING", "udp", preChain)); + commands.add(buildRuleStatusCommand(prefix + "_prerouting_tcp", iptables, + "PREROUTING", "tcp", preChain)); + commands.add(buildDaemonMarkReturnStatusCommand(prefix + "_output_daemon_mark_return", + iptables, chain)); + commands.add(buildUid0ReturnStatusCommand(prefix + "_output_uid0_return", + iptables, chain)); + commands.add(buildDaemonMarkFilterAcceptStatusCommand( + prefix + "_filter_daemon_mark_accept", iptables)); + commands.add(buildUid0FilterAcceptStatusCommand( + prefix + "_filter_uid0_dns_accept", iptables)); + commands.add(buildRedirectTargetStatusCommand(prefix + "_chain_udp_redirect", + iptables, chain, "udp", port)); + commands.add(buildRedirectTargetStatusCommand(prefix + "_chain_tcp_redirect", + iptables, chain, "tcp", port)); + commands.add(buildRedirectTargetStatusCommand(prefix + "_pre_chain_udp_redirect", + iptables, preChain, "udp", port)); + commands.add(buildRedirectTargetStatusCommand(prefix + "_pre_chain_tcp_redirect", + iptables, preChain, "tcp", port)); + commands.add(buildNftTableStatusCommand(prefix + "_nft_table", nftFamily, nftTable)); + commands.add(buildNftDaemonMarkReturnStatusCommand( + prefix + "_nft_output_daemon_mark_return", nftFamily, nftTable)); + commands.add(buildNftUid0ReturnStatusCommand(prefix + "_nft_output_uid0_return", + nftFamily, nftTable)); + commands.add(buildNftRedirectStatusCommand(prefix + "_nft_output_udp", + nftFamily, nftTable, NFT_OUTPUT, "udp", port)); + commands.add(buildNftRedirectStatusCommand(prefix + "_nft_output_tcp", + nftFamily, nftTable, NFT_OUTPUT, "tcp", port)); + commands.add(buildNftRedirectStatusCommand(prefix + "_nft_prerouting_udp", + nftFamily, nftTable, NFT_PREROUTING, "udp", port)); + commands.add(buildNftRedirectStatusCommand(prefix + "_nft_prerouting_tcp", + nftFamily, nftTable, NFT_PREROUTING, "tcp", port)); + } + + private static String buildChainStatusCommand(String key, String iptables, String chain) { + return "if " + iptables + " -t nat -S " + shellQuote(chain) + + " >/dev/null 2>&1; then echo " + key + "=1; else echo " + + key + "=0; fi"; + } + + private static String buildRuleStatusCommand(String key, String iptables, String parentChain, + String protocol, String targetChain) { + String pattern = "-p " + protocol + " .*--dport 53.*-j " + targetChain; + return "if " + iptables + " -t nat -S " + shellQuote(parentChain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildDaemonMarkReturnStatusCommand(String key, String iptables, + String chain) { + return "if " + buildIptablesDaemonMarkReturnCheck(iptables, chain) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildUid0ReturnStatusCommand(String key, String iptables, + String chain) { + return "if " + buildIptablesUid0ReturnCheck(iptables, chain) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildDaemonMarkFilterAcceptStatusCommand(String key, String iptables) { + return "if " + buildIptablesDaemonMarkFilterAcceptCheck(iptables) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildUid0FilterAcceptStatusCommand(String key, String iptables) { + return "if " + buildIptablesUid0FilterAcceptCheck(iptables) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildRedirectTargetStatusCommand(String key, String iptables, String chain, + String protocol, int port) { + String pattern = "-p " + protocol + " .*--dport 53.*-j REDIRECT.*--to-ports " + port; + return "if " + iptables + " -t nat -S " + shellQuote(chain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildIptablesRedirectHealthyCondition(String iptables, String chain, + String preChain, int port) { + List checks = new ArrayList<>(); + checks.add(buildIptablesRuleCheck(iptables, "OUTPUT", "udp", chain)); + checks.add(buildIptablesRuleCheck(iptables, "OUTPUT", "tcp", chain)); + if (preroutingRedirectExpected()) { + checks.add(buildIptablesRuleCheck(iptables, "PREROUTING", "udp", preChain)); + checks.add(buildIptablesRuleCheck(iptables, "PREROUTING", "tcp", preChain)); + } + checks.add(buildIptablesRecursionGuardCheck(iptables, chain)); + checks.add(buildIptablesDaemonPathFilterCheck(iptables, chain)); + checks.add(buildIptablesRedirectTargetCheck(iptables, chain, "udp", port)); + checks.add(buildIptablesRedirectTargetCheck(iptables, chain, "tcp", port)); + if (preroutingRedirectExpected()) { + checks.add(buildIptablesRedirectTargetCheck(iptables, preChain, "udp", port)); + checks.add(buildIptablesRedirectTargetCheck(iptables, preChain, "tcp", port)); + } + return joinShellChecks(checks); + } + + private static String buildRedirectInstallVerificationCommand(Context context, boolean ipv6) { + String iptables = shellQuote(Api.getBinaryPath(context, ipv6)); + String chain = ipv6 ? CHAIN_V6 : CHAIN_V4; + String preChain = ipv6 ? CHAIN_V6_PRE : CHAIN_V4_PRE; + String family = ipv6 ? "ip6" : "ip"; + String table = ipv6 ? NFT_TABLE_V6 : NFT_TABLE_V4; + int port = G.dnsHijackPort(DEFAULT_PORT); + String iptablesReady = buildIptablesRedirectHealthyCondition(iptables, chain, preChain, port); + String nftReady = buildNftRedirectHealthyCondition(family, table, port, iptables); + String label = ipv6 ? "IPv6" : "IPv4"; + return "if ( " + iptablesReady + " ) || ( " + nftReady + " ); then true; else " + + "echo 'DNS redirect install verification failed for " + label + "'; false; fi; #"; + } + + private static String buildIptablesRuleCheck(String iptables, String parentChain, + String protocol, String targetChain) { + String pattern = "-p " + protocol + " .*--dport 53.*-j " + targetChain; + return iptables + " -t nat -S " + shellQuote(parentChain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern); + } + + private static String buildIptablesRecursionGuardCheck(String iptables, String chain) { + return "( " + buildIptablesDaemonMarkReturnCheck(iptables, chain) + + " || " + buildIptablesUid0ReturnCheck(iptables, chain) + " )"; + } + + private static String buildIptablesDaemonPathFilterCheck(String iptables, String chain) { + return "( ( " + buildIptablesDaemonMarkReturnCheck(iptables, chain) + + " && " + buildIptablesDaemonMarkFilterAcceptCheck(iptables) + + " ) || ( " + buildIptablesUid0ReturnCheck(iptables, chain) + + " && " + buildIptablesUid0FilterAcceptCheck(iptables) + " ) )"; + } + + private static String buildIptablesDaemonMarkReturnCheck(String iptables, String chain) { + String pattern = "-m mark .*--mark " + DAEMON_SOCKET_MARK + ".*-j RETURN"; + return iptables + " -t nat -S " + shellQuote(chain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern); + } + + private static String buildIptablesDaemonMarkFilterAcceptCheck(String iptables) { + String pattern = "-m mark .*--mark " + DAEMON_SOCKET_MARK + ".*-j ACCEPT"; + return "( " + iptables + " -S OUTPUT 2>/dev/null | grep -q -- " + + shellQuote(pattern) + + " || " + iptables + " -S " + shellQuote(CHAIN_FILTER) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern) + " )"; + } + + private static String buildIptablesUid0FilterAcceptCheck(String iptables) { + List checks = new ArrayList<>(); + for (Integer port : dnsUpstreamPortsForFilterFallback()) { + checks.add(buildIptablesUid0FilterAcceptRuleCheck(iptables, "udp", port)); + checks.add(buildIptablesUid0FilterAcceptRuleCheck(iptables, "tcp", port)); + } + return "( " + joinShellChecks(checks) + " )"; + } + + private static String buildIptablesUid0FilterAcceptRuleCheck(String iptables, + String protocol, + int port) { + String lineCheck = "grep -- '--uid-owner 0' | grep -- '-p " + protocol + + "' | grep -- '--dport " + port + "' | grep -q -- '-j ACCEPT'"; + return "( " + iptables + " -S OUTPUT 2>/dev/null | " + lineCheck + + " || " + iptables + " -S " + shellQuote(CHAIN_FILTER) + + " 2>/dev/null | " + lineCheck + " )"; + } + + private static String buildIptablesUid0ReturnCheck(String iptables, String chain) { + String pattern = "-m owner .*--uid-owner 0.*-j RETURN"; + return iptables + " -t nat -S " + shellQuote(chain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern); + } + + private static String buildIptablesRedirectTargetCheck(String iptables, String chain, + String protocol, int port) { + String pattern = "-p " + protocol + " .*--dport 53.*-j REDIRECT.*--to-ports " + port; + return iptables + " -t nat -S " + shellQuote(chain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern); + } + + private static String buildNftRedirectHealthyCondition(String family, String table, int port, + String iptables) { + List checks = new ArrayList<>(); + checks.add("command -v nft >/dev/null 2>&1"); + checks.add("nft list table " + shellQuote(family) + " " + shellQuote(table) + + " >/dev/null 2>&1"); + checks.add("( ( " + buildNftDaemonMarkReturnCheck(family, table) + + " && " + buildIptablesDaemonMarkFilterAcceptCheck(iptables) + + " ) || ( " + buildNftUid0ReturnCheck(family, table) + + " && " + buildIptablesUid0FilterAcceptCheck(iptables) + " ) )"); + checks.add(buildNftRedirectCheck(family, table, NFT_OUTPUT, "udp", port)); + checks.add(buildNftRedirectCheck(family, table, NFT_OUTPUT, "tcp", port)); + if (preroutingRedirectExpected()) { + checks.add(buildNftRedirectCheck(family, table, NFT_PREROUTING, "udp", port)); + checks.add(buildNftRedirectCheck(family, table, NFT_PREROUTING, "tcp", port)); + } + return joinShellChecks(checks); + } + + private static String buildNftRedirectCheck(String family, String table, String chain, + String protocol, int port) { + String pattern = protocol + " dport 53.*redirect to :" + port; + return "nft list chain " + shellQuote(family) + " " + shellQuote(table) + + " " + shellQuote(chain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern); + } + + private static String buildNftDaemonMarkReturnCheck(String family, String table) { + String pattern = "meta mark .*return"; + return "nft list chain " + shellQuote(family) + " " + shellQuote(table) + + " " + shellQuote(NFT_OUTPUT) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern); + } + + private static String buildNftUid0ReturnCheck(String family, String table) { + String pattern = "meta skuid 0.*return"; + return "nft list chain " + shellQuote(family) + " " + shellQuote(table) + + " " + shellQuote(NFT_OUTPUT) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern); + } + + private static String joinShellChecks(List checks) { + StringBuilder command = new StringBuilder(); + for (String check : checks) { + if (check == null || check.trim().isEmpty()) { + continue; + } + if (command.length() > 0) { + command.append(" && "); + } + command.append(check); + } + return command.length() == 0 ? "false" : command.toString(); + } + + private static String buildNftTableStatusCommand(String key, String family, String table) { + return "if command -v nft >/dev/null 2>&1 && nft list table " + + shellQuote(family) + " " + shellQuote(table) + + " >/dev/null 2>&1; then echo " + key + "=1; else echo " + + key + "=0; fi"; + } + + private static String buildNftDaemonMarkReturnStatusCommand(String key, String family, + String table) { + return "if command -v nft >/dev/null 2>&1 && " + + buildNftDaemonMarkReturnCheck(family, table) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildNftUid0ReturnStatusCommand(String key, String family, + String table) { + return "if command -v nft >/dev/null 2>&1 && " + + buildNftUid0ReturnCheck(family, table) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String buildNftRedirectStatusCommand(String key, String family, String table, + String chain, String protocol, int port) { + String pattern = protocol + " dport 53.*redirect to :" + port; + return "if command -v nft >/dev/null 2>&1 && nft list chain " + + shellQuote(family) + " " + shellQuote(table) + " " + shellQuote(chain) + + " 2>/dev/null | grep -q -- " + shellQuote(pattern) + + "; then echo " + key + "=1; else echo " + key + "=0; fi"; + } + + private static String redirectFamilyStatus(Map values, String prefix) { + boolean nftTable = "1".equals(values.get(prefix + "_nft_table")); + int hookScore = redirectFamilyHookScore(values, prefix); + int targetScore = redirectFamilyTargetScore(values, prefix); + int nftInstalled = redirectFamilyNftScore(values, prefix); + int targetExpected = expectedRedirectTargetScore(); + int nftExpected = expectedNftRedirectScore(); + boolean iptablesInstalled = hookScore >= expectedRedirectHookScore() + && targetScore >= targetExpected + && redirectFamilyIptablesDaemonPathReady(values, prefix); + boolean nftReady = nftInstalled >= nftExpected && redirectFamilyNftDaemonPathReady(values, prefix); + String suffix = redirectFamilyUsesUidFallback(values, prefix) ? " (UID fallback)" : ""; + if (!preroutingRedirectExpected()) { + suffix += " (UID-scoped app-owned capture)"; + } + String filterSuffix = redirectFamilyMissingDaemonFilterBypass(values, prefix) + ? " (daemon filter bypass missing)" : ""; + if (nftReady && iptablesInstalled) { + return "installed (iptables+nft)" + suffix; + } + if (nftReady) { + return "installed (nft)" + suffix; + } + if (iptablesInstalled) { + return "installed" + suffix; + } + if (hookScore > 0 || targetScore > 0 || nftInstalled > 0 || nftTable) { + return "partial" + filterSuffix; + } + return "missing"; + } + + private static boolean redirectFamilyHealthy(Map values, String prefix) { + return (redirectFamilyHookScore(values, prefix) >= expectedRedirectHookScore() + && redirectFamilyTargetScore(values, prefix) >= expectedRedirectTargetScore() + && redirectFamilyIptablesDaemonPathReady(values, prefix)) + || (redirectFamilyNftScore(values, prefix) >= expectedNftRedirectScore() + && redirectFamilyNftDaemonPathReady(values, prefix)); + } + + private static int expectedRedirectHookScore() { + return preroutingRedirectExpected() ? 6 : 3; + } + + private static int expectedRedirectTargetScore() { + return preroutingRedirectExpected() ? 4 : 2; + } + + private static int expectedNftRedirectScore() { + return preroutingRedirectExpected() ? 4 : 2; + } + + private static boolean preroutingRedirectExpected() { + return parseUidList(G.dnsHijackCaptureUids()).isEmpty(); + } + + private static boolean redirectFamilyIptablesRecursionGuard(Map values, + String prefix) { + return "1".equals(values.get(prefix + "_output_daemon_mark_return")) + || "1".equals(values.get(prefix + "_output_uid0_return")); + } + + private static boolean redirectFamilyIptablesDaemonPathReady(Map values, + String prefix) { + return ("1".equals(values.get(prefix + "_output_daemon_mark_return")) + && redirectFamilyDaemonFilterBypass(values, prefix)) + || ("1".equals(values.get(prefix + "_output_uid0_return")) + && redirectFamilyUid0FilterBypass(values, prefix)); + } + + private static boolean redirectFamilyNftRecursionGuard(Map values, + String prefix) { + return "1".equals(values.get(prefix + "_nft_output_daemon_mark_return")) + || "1".equals(values.get(prefix + "_nft_output_uid0_return")); + } + + private static boolean redirectFamilyNftDaemonPathReady(Map values, + String prefix) { + return ("1".equals(values.get(prefix + "_nft_output_daemon_mark_return")) + && redirectFamilyDaemonFilterBypass(values, prefix)) + || ("1".equals(values.get(prefix + "_nft_output_uid0_return")) + && redirectFamilyUid0FilterBypass(values, prefix)); + } + + private static boolean redirectFamilyUsesUidFallback(Map values, String prefix) { + return "1".equals(values.get(prefix + "_output_uid0_return")) + || "1".equals(values.get(prefix + "_nft_output_uid0_return")); + } + + private static boolean redirectFamilyMissingDaemonFilterBypass(Map values, + String prefix) { + boolean markGuard = "1".equals(values.get(prefix + "_output_daemon_mark_return")) + || "1".equals(values.get(prefix + "_nft_output_daemon_mark_return")); + boolean uidGuard = "1".equals(values.get(prefix + "_output_uid0_return")) + || "1".equals(values.get(prefix + "_nft_output_uid0_return")); + return (markGuard && !redirectFamilyDaemonFilterBypass(values, prefix)) + || (uidGuard && !redirectFamilyUid0FilterBypass(values, prefix)); + } + + private static boolean redirectFamilyDaemonFilterBypass(Map values, + String prefix) { + return "1".equals(values.get(prefix + "_filter_daemon_mark_accept")); + } + + private static boolean redirectFamilyUid0FilterBypass(Map values, + String prefix) { + return "1".equals(values.get(prefix + "_filter_uid0_dns_accept")); + } + + private static int redirectFamilyHookScore(Map values, String prefix) { + String[] keys = new String[] { + "_chain", + "_pre_chain", + "_output_udp", + "_output_tcp", + "_prerouting_udp", + "_prerouting_tcp" + }; + int score = 0; + for (String suffix : keys) { + if ("1".equals(values.get(prefix + suffix))) { + score++; + } + } + return score; + } + + private static int redirectFamilyTargetScore(Map values, String prefix) { + String[] keys = new String[] { + "_chain_udp_redirect", + "_chain_tcp_redirect", + "_pre_chain_udp_redirect", + "_pre_chain_tcp_redirect" + }; + int score = 0; + for (String suffix : keys) { + if ("1".equals(values.get(prefix + suffix))) { + score++; + } + } + return score; + } + + private static int redirectFamilyNftScore(Map values, String prefix) { + String[] keys = new String[] { + "_nft_output_udp", + "_nft_output_tcp", + "_nft_prerouting_udp", + "_nft_prerouting_tcp" + }; + int score = 0; + for (String suffix : keys) { + if ("1".equals(values.get(prefix + suffix))) { + score++; + } + } + return score; + } + + private static void failSupervisorAction(Context context, RootCommand.Callback callback, String message) { + ApplicationErrorLog.add(context, message); + if (callback == null) { + return; + } + RootCommand state = new RootCommand(); + state.exitCode = 1; + state.res = new StringBuilder(message).append('\n'); + callback.cbFunc(state); + } + + private static void runLifecycleCommands(Context context, List commands, + String queuedMessage, String successMessage, + String failureMessage, RootCommand.Callback callback) { + ApplicationErrorLog.add(context, queuedMessage); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_apply) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + if (state.exitCode == 0) { + ApplicationErrorLog.add(context, successMessage); + } else { + ApplicationErrorLog.add(context, + failureMessage + rootFailureSuffix(state)); + } + syncServiceLogsToAppLog(context); + if (callback != null) { + callback.cbFunc(state); + } + } + }) + .run(context.getApplicationContext(), commands); + } + + private static String rootFailureSuffix(RootCommand state) { + if (state == null) { + return ""; + } + StringBuilder detail = new StringBuilder(); + if (state.lastCommand != null && !state.lastCommand.trim().isEmpty()) { + detail.append(": command=").append(state.lastCommand.trim()); + } + String output = state.lastCommandResult == null + ? "" + : state.lastCommandResult.toString().trim().replace('\n', ' '); + if (!output.isEmpty()) { + if (output.length() > 240) { + output = output.substring(0, 240); + } + detail.append(" output=").append(output); + } + return detail.toString(); + } + + private static String queryControl(Context context, String command) { + return queryControl(context, command, null); + } + + private static String queryControl(Context context, String command, String overrideToken) { + return queryControl(context, command, overrideToken, 1500); + } + + private static String queryControl(Context context, String command, int timeoutMs) { + return queryControl(context, command, null, timeoutMs); + } + + private static String queryControl(Context context, String command, String overrideToken, + int timeoutMs) { + File socketFile = new File(workDir(context), SOCKET); + if (!socketFile.exists()) { + return "control socket missing\n"; + } + String token = overrideToken == null ? controlToken(context) : overrideToken; + if (token.isEmpty()) { + return "control token unavailable\n"; + } + try (LocalSocket socket = new LocalSocket()) { + socket.setSoTimeout(timeoutMs); + socket.connect(new LocalSocketAddress(socketFile.getAbsolutePath(), LocalSocketAddress.Namespace.FILESYSTEM)); + OutputStream output = socket.getOutputStream(); + output.write(("token " + token + "\n" + command + "\n").getBytes(StandardCharsets.UTF_8)); + output.flush(); + socket.shutdownOutput(); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + InputStream input = socket.getInputStream(); + byte[] chunk = new byte[4096]; + int read; + while ((read = input.read(chunk)) != -1) { + buffer.write(chunk, 0, read); + } + String result = buffer.toString("UTF-8"); + return result.trim().isEmpty() ? "empty response\n" : result; + } catch (IOException e) { + return "control socket error: " + e.getMessage() + "\n"; + } + } + + private static String buildInvalidControlToken(Context context) { + String zero = "0000000000000000000000000000000000000000000000000000000000000000"; + String actual = controlToken(context); + if (!zero.equals(actual)) { + return zero; + } + return "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"; + } + + private static void appendSelfTestValue(StringBuilder out, Map values, + String key) { + if (out == null || values == null || key == null) { + return; + } + String value = values.get(key); + out.append(key).append('=').append(value == null ? "missing" : value).append('\n'); + } + + private static String controlToken(Context context) { + if (context == null) { + return ""; + } + File tokenFile = new File(workDir(context), CONTROL_TOKEN); + String existing = readSmallFileValue(tokenFile, "").trim(); + if (isValidControlToken(existing)) { + setOwnerOnly(tokenFile); + return existing; + } + byte[] tokenBytes = new byte[32]; + new SecureRandom().nextBytes(tokenBytes); + String generated = toHex(tokenBytes); + try { + writeText(tokenFile, generated + "\n"); + setOwnerOnly(tokenFile); + return generated; + } catch (IOException | RuntimeException e) { + ApplicationErrorLog.add(context, "DNS control token could not be written: " + + e.getMessage()); + return ""; + } + } + + private static void setOwnerOnly(File file) { + if (file == null) { + return; + } + file.setReadable(false, false); + file.setWritable(false, false); + file.setExecutable(false, false); + file.setReadable(true, true); + file.setWritable(true, true); + } + + private static boolean isValidControlToken(String token) { + if (token == null || token.length() < 64 || token.length() > 96) { + return false; + } + for (int i = 0; i < token.length(); i++) { + char c = token.charAt(i); + boolean hex = (c >= '0' && c <= '9') + || (c >= 'a' && c <= 'f') + || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + } + return true; + } + + private static String toHex(byte[] bytes) { + char[] out = new char[bytes.length * 2]; + char[] alphabet = "0123456789abcdef".toCharArray(); + for (int i = 0; i < bytes.length; i++) { + int value = bytes[i] & 0xff; + out[i * 2] = alphabet[value >>> 4]; + out[i * 2 + 1] = alphabet[value & 0x0f]; + } + return new String(out); + } + + private static String compactControlResponse(String response) { + String compact = response == null ? "" : response.trim().replace('\n', ' '); + if (compact.length() > 240) { + compact = compact.substring(0, 240); + } + return compact.isEmpty() ? "empty response" : compact; + } + + private static List parseQueryEntries(String raw) { + List entries = new ArrayList<>(); + if (raw == null) { + return entries; + } + String[] lines = raw.split("\\r?\\n"); + for (String line : lines) { + QueryEntry entry = QueryEntry.parse(line); + if (entry != null) { + entries.add(entry); + } + } + return entries; + } + + private static Map parseKeyValueLines(String raw) { + Map values = new HashMap<>(); + if (raw == null) { + return values; + } + String[] lines = raw.split("\\r?\\n"); + for (String line : lines) { + if (line == null) { + continue; + } + int separator = line.indexOf('='); + if (separator <= 0) { + continue; + } + String key = line.substring(0, separator).trim(); + String value = line.substring(separator + 1).trim(); + if (!key.isEmpty()) { + values.put(key, value); + } + } + return values; + } + + private static Map parseExtras(String[] parts, int startIndex) { + Map values = new HashMap<>(); + if (parts == null || parts.length <= startIndex) { + return values; + } + for (int i = startIndex; i < parts.length; i++) { + String part = parts[i]; + if (part == null) { + continue; + } + int separator = part.indexOf('='); + if (separator <= 0 || separator >= part.length() - 1) { + continue; + } + values.put(part.substring(0, separator), part.substring(separator + 1)); + } + return values; + } + + private static String firstValue(Map preferred, + Map fallback, + String key) { + String value = preferred.get(key); + if (value == null || value.trim().isEmpty()) { + value = fallback.get(key); + } + return value == null ? "" : value.trim(); + } + + private static long parseLong(String value, long fallback) { + if (value == null || value.trim().isEmpty()) { + return fallback; + } + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return fallback; + } + } + + private static String cacheHitRate(long ppm, long hits, long misses) { + if (ppm >= 0L) { + return String.format(Locale.US, "%.1f%%", ppm / 10000.0d); + } + long total = hits + misses; + if (total <= 0L) { + return "0%"; + } + return String.format(Locale.US, "%.1f%%", (hits * 100.0d) / total); + } + + private static String formatMillis(long value) { + return value < 0L ? "n/a" : value + "ms"; + } + + private static String formatKilobytes(long value) { + if (value < 0L) { + return "n/a"; + } + if (value < 1024L) { + return value + " KB"; + } + return String.format(Locale.US, "%.1f MB", value / 1024.0d); + } + + private static String formatDuration(long seconds) { + if (seconds <= 0L) { + return "0s"; + } + long days = seconds / 86400L; + seconds %= 86400L; + long hours = seconds / 3600L; + seconds %= 3600L; + long minutes = seconds / 60L; + seconds %= 60L; + if (days > 0L) { + return days + "d " + hours + "h"; + } + if (hours > 0L) { + return hours + "h " + minutes + "m"; + } + if (minutes > 0L) { + return minutes + "m " + seconds + "s"; + } + return seconds + "s"; + } + + private static long sumDashboardRuleCounts(Map preferred, + Map fallback) { + String[] keys = new String[] { + "rules_exact_allow", + "rules_suffix_allow", + "rules_exact_block", + "rules_suffix_block", + "rules_app_exact_allow", + "rules_app_exact_block", + "rules_app_suffix_allow", + "rules_app_suffix_block", + "rules_network_allow", + "rules_network_block", + "rules_regex_allow", + "rules_regex_block", + "rules_temp_allow", + "rules_temp_block" + }; + long total = 0L; + for (String key : keys) { + total += parseLong(firstValue(preferred, fallback, key), 0L); + } + return total; + } + + private static String upstreamRuntimeSummary(Map preferred, + Map fallback) { + List summaries = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + String raw = firstValue(preferred, fallback, "upstream_runtime[" + i + "]"); + if (!raw.isEmpty()) { + summaries.add(formatUpstreamRuntime(raw)); + } + } + if (summaries.isEmpty()) { + return "runtime unavailable"; + } + return joinWithSeparator(summaries, " | "); + } + + private static String formatUpstreamRuntime(String raw) { + String endpoint = ""; + String protocol = tokenValue(raw, "protocol", ""); + String requests = tokenValue(raw, "requests", "0"); + String successes = tokenValue(raw, "successes", "0"); + String failures = tokenValue(raw, "failures", "0"); + String averageLatency = tokenValue(raw, "avg_latency_ms", "n/a"); + String backoffRemaining = tokenValue(raw, "backoff_remaining", "0"); + String[] parts = raw.trim().split("\\s+"); + for (String part : parts) { + if (part.startsWith("upstream=")) { + endpoint = part.substring("upstream=".length()); + break; + } + if (!part.contains("=") && endpoint.isEmpty()) { + endpoint = part; + } + } + if (endpoint.isEmpty()) { + endpoint = "upstream"; + } + if (protocol.isEmpty()) { + protocol = "auto"; + } + String backoff = "0".equals(backoffRemaining) + ? "" + : " backoff=" + backoffRemaining + "s"; + return endpoint + " " + protocol + + " ok=" + successes + "/" + requests + + " fail=" + failures + + " avg=" + formatRuntimeLatency(averageLatency) + + backoff; + } + + private static String formatRuntimeLatency(String value) { + return "n/a".equals(value) ? value : value + "ms"; + } + + private static String tokenValue(String raw, String key, String fallback) { + if (raw == null || key == null) { + return fallback; + } + String prefix = key + "="; + String[] parts = raw.trim().split("\\s+"); + for (String part : parts) { + if (part.startsWith(prefix)) { + String value = part.substring(prefix.length()).trim(); + return value.isEmpty() ? fallback : value; + } + } + return fallback; + } + + private static String joinWithSeparator(List values, String separator) { + StringBuilder out = new StringBuilder(); + for (String value : values) { + if (value == null || value.trim().isEmpty()) { + continue; + } + if (out.length() > 0) { + out.append(separator); + } + out.append(value.trim()); + } + return out.toString(); + } + + private static String emptyFallback(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value.trim(); + } + + private static String qtypeName(String value) { + long numeric = parseLong(value, -1L); + if (numeric == 1L) { + return "A"; + } + if (numeric == 2L) { + return "NS"; + } + if (numeric == 5L) { + return "CNAME"; + } + if (numeric == 15L) { + return "MX"; + } + if (numeric == 16L) { + return "TXT"; + } + if (numeric == 28L) { + return "AAAA"; + } + if (numeric == 33L) { + return "SRV"; + } + if (numeric == 65L) { + return "HTTPS"; + } + return numeric > 0L ? String.valueOf(numeric) : "unknown"; + } + + private static String sanitizeHistoryFilter(String filter) { + if (filter == null) { + return ""; + } + String clean = filter.trim().replace('\n', ' ').replace('\r', ' '); + clean = clean.replaceAll("\\s+", " "); + if (clean.length() > 80) { + clean = clean.substring(0, 80); + } + return clean; + } + + private static String benchmarkUpstreamsDirect() { + StringBuilder out = new StringBuilder(); + List targets = parseUpstreamTargets(G.dnsHijackUpstreams()); + int timeoutMs = Math.min(Math.max(G.dnsHijackTimeoutMs(), 250), 1000); + byte[] query = buildBenchmarkQuery(); + + out.append("benchmark=1\n"); + out.append("source=app_direct_udp\n"); + out.append("upstreams=").append(targets.size()).append('\n'); + out.append("timeout_ms=").append(timeoutMs).append('\n'); + out.append("dnssec_request=").append(G.dnsHijackDnssecRequest() ? 1 : 0).append('\n'); + out.append("dnssec_auth_required=") + .append(G.dnsHijackDnssecAuthRequired() ? 1 : 0).append('\n'); + out.append("probe_dnssec=").append(dnssecProbeRequired() ? 1 : 0).append('\n'); + if (targets.isEmpty()) { + out.append("error=no_upstreams_configured\n"); + return out.toString(); + } + + for (int i = 0; i < targets.size(); i++) { + UpstreamTarget target = targets.get(i); + long start = System.nanoTime(); + ProbeResult result = probeUpstreamDirect(target, timeoutMs, query); + int latencyMs = (int) ((System.nanoTime() - start) / 1000000L); + out.append("upstream[").append(i).append("]=") + .append(target.host).append(':').append(target.port) + .append(" protocol=").append(target.protocol) + .append(" status=").append(result.status) + .append(" latency_ms=").append(latencyMs) + .append(" rcode=").append(result.rcode) + .append(" bytes=").append(result.bytes) + .append('\n'); + } + return out.toString(); + } + + private static ProbeResult probeUpstreamDirect(UpstreamTarget target, int timeoutMs, byte[] query) { + if ("tcp".equals(target.protocol)) { + return probeTcpUpstreamDirect(target, timeoutMs, query); + } + return probeUdpUpstreamDirect(target, timeoutMs, query); + } + + private static ProbeResult probeUdpUpstreamDirect(UpstreamTarget target, int timeoutMs, byte[] query) { + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(timeoutMs); + InetAddress address = InetAddress.getByName(target.host); + DatagramPacket request = new DatagramPacket(query, query.length, address, target.port); + socket.send(request); + byte[] response = new byte[dnssecProbeRequired() ? 4096 : 512]; + DatagramPacket reply = new DatagramPacket(response, response.length); + socket.receive(reply); + int bytes = reply.getLength(); + if (G.dnsHijackDnssecAuthRequired() && !responseAuthenticated(response, bytes)) { + return new ProbeResult("unauthenticated", bytes, bytes >= 4 ? response[3] & 0x0f : -1); + } + return new ProbeResult("ok", bytes, bytes >= 4 ? response[3] & 0x0f : -1); + } catch (SocketTimeoutException e) { + return new ProbeResult("timeout", -1, -1); + } catch (IOException | RuntimeException e) { + return new ProbeResult("error", -1, -1); + } + } + + private static ProbeResult probeTcpUpstreamDirect(UpstreamTarget target, int timeoutMs, byte[] query) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(InetAddress.getByName(target.host), target.port), timeoutMs); + socket.setSoTimeout(timeoutMs); + OutputStream output = socket.getOutputStream(); + output.write((query.length >> 8) & 0xff); + output.write(query.length & 0xff); + output.write(query); + output.flush(); + InputStream input = socket.getInputStream(); + int high = input.read(); + int low = input.read(); + if (high < 0 || low < 0) { + return new ProbeResult("error", -1, -1); + } + int expected = (high << 8) | low; + if (expected <= 0 || expected > 4096) { + return new ProbeResult("error", -1, -1); + } + byte[] response = new byte[expected]; + int read = readFully(input, response, expected); + if (read == expected && G.dnsHijackDnssecAuthRequired() + && !responseAuthenticated(response, read)) { + return new ProbeResult("unauthenticated", read, read >= 4 ? response[3] & 0x0f : -1); + } + return new ProbeResult(read == expected ? "ok" : "error", + read, read >= 4 ? response[3] & 0x0f : -1); + } catch (SocketTimeoutException e) { + return new ProbeResult("timeout", -1, -1); + } catch (IOException | RuntimeException e) { + return new ProbeResult("error", -1, -1); + } + } + + private static int readFully(InputStream input, byte[] response, int expected) throws IOException { + int offset = 0; + while (offset < expected) { + int read = input.read(response, offset, expected - offset); + if (read < 0) { + break; + } + offset += read; + } + return offset; + } + + private static byte[] buildBenchmarkQuery() { + byte[] query = new byte[] { + 0x42, 0x53, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x07, 'e', 'x', 'a', 'm', 'p', 'l', 'e', + 0x03, 'c', 'o', 'm', + 0x00, + 0x00, 0x01, + 0x00, 0x01 + }; + int id = (int) (System.currentTimeMillis() & 0xffff); + query[0] = (byte) ((id >> 8) & 0xff); + query[1] = (byte) (id & 0xff); + if (!dnssecProbeRequired()) { + return query; + } + byte[] dnssecQuery = new byte[query.length + 11]; + System.arraycopy(query, 0, dnssecQuery, 0, query.length); + dnssecQuery[10] = 0x00; + dnssecQuery[11] = 0x01; + int pos = query.length; + dnssecQuery[pos++] = 0x00; + dnssecQuery[pos++] = 0x00; + dnssecQuery[pos++] = 0x29; + dnssecQuery[pos++] = 0x10; + dnssecQuery[pos++] = 0x00; + dnssecQuery[pos++] = 0x00; + dnssecQuery[pos++] = 0x00; + dnssecQuery[pos++] = (byte) 0x80; + dnssecQuery[pos++] = 0x00; + dnssecQuery[pos++] = 0x00; + dnssecQuery[pos] = 0x00; + return dnssecQuery; + } + + private static boolean dnssecProbeRequired() { + return G.dnsHijackDnssecRequest() || G.dnsHijackDnssecAuthRequired(); + } + + private static boolean responseAuthenticated(byte[] response, int length) { + return response != null && length >= 4 && (response[3] & 0x20) != 0; + } + + private static List parseUpstreamTargets(String raw) { + List targets = new ArrayList<>(); + if (raw == null) { + return targets; + } + String[] lines = raw.split("[\\r\\n,]+"); + for (String line : lines) { + UpstreamTarget target = UpstreamTarget.parse(line); + if (target != null) { + targets.add(target); + } + } + return targets; + } + + private static List dnsUpstreamPortsForFilterFallback() { + List ports = new ArrayList<>(); + addUpstreamPorts(ports, G.dnsHijackUpstreams()); + String split = G.dnsHijackSplitUpstreams(); + if (split != null) { + String[] lines = split.split("\\r?\\n"); + for (String line : lines) { + String value = line == null ? "" : line.trim(); + int separator = findSplitSeparator(value); + if (value.isEmpty() || value.startsWith("#") || separator <= 0) { + continue; + } + addUpstreamPorts(ports, value.substring(separator + 1)); + } + } + if (ports.isEmpty()) { + ports.add(53); + } + return ports; + } + + private static void addUpstreamPorts(List ports, String raw) { + for (UpstreamTarget target : parseUpstreamTargets(raw)) { + addUpstreamPort(ports, target.port); + } + } + + private static void addUpstreamPort(List ports, int port) { + if (port > 0 && port <= 65535 && !ports.contains(port)) { + ports.add(port); + } + } + + private static void appendFileInfo(StringBuilder out, String label, File file) { + out.append(label).append('=').append(file.getAbsolutePath()); + out.append(" exists=").append(file.exists()); + if (file.exists()) { + out.append(" size=").append(file.length()); + out.append(" canExecute=").append(file.canExecute()); + } + out.append('\n'); + } + + private static void appendSmallFileValue(StringBuilder out, String label, File file) { + out.append(label).append('='); + if (!file.exists()) { + out.append("missing\n"); + return; + } + try (FileInputStream input = new FileInputStream(file)) { + byte[] buffer = new byte[256]; + int read = input.read(buffer); + if (read <= 0) { + out.append("empty\n"); + } else { + out.append(new String(buffer, 0, read, StandardCharsets.UTF_8).trim()).append('\n'); + } + } catch (IOException e) { + out.append("unreadable: ").append(e.getMessage()).append('\n'); + } + } + + private static String readSmallFileValue(File file, String fallback) { + if (file == null || !file.exists()) { + return fallback; + } + try (FileInputStream input = new FileInputStream(file)) { + byte[] buffer = new byte[256]; + int read = input.read(buffer); + if (read <= 0) { + return fallback; + } + String value = new String(buffer, 0, read, StandardCharsets.UTF_8).trim(); + return value.isEmpty() ? fallback : value; + } catch (IOException e) { + return fallback; + } + } + + private static String listenerLabel(boolean ready) { + return ready ? "ready" : "missing"; + } + + private static String listenerFamilyLabel(boolean ready, boolean expected) { + if (!expected) { + return "disabled"; + } + return listenerLabel(ready); + } + + private static boolean listenerFamilyReady(Map primaryValues, + Map fallbackValues, + String familyKey, String aggregateKey) { + String familyValue = firstValue(primaryValues, fallbackValues, familyKey); + if (familyValue != null && !familyValue.trim().isEmpty()) { + return "1".equals(familyValue); + } + return "1".equals(firstValue(primaryValues, fallbackValues, aggregateKey)); + } + + private static String normalizeSupervisorAction(String action) { + if ("start".equals(action) || "stop".equals(action) + || "restart".equals(action) || "reload".equals(action) + || "status".equals(action)) { + return action; + } + return null; + } + + private static String normalizeDaemonMaintenanceAction(String action) { + if ("flush_cache".equals(action) + || "flush_logs".equals(action) + || "clear_logs".equals(action)) { + return action; + } + return null; + } + + private static long temporaryRuleExpiresAt() { + return (System.currentTimeMillis() / 1000L) + TEMP_RULE_DURATION_SECONDS; + } + + private static int countLines(String raw) { + int count = 0; + if (raw == null || raw.trim().isEmpty()) { + return 0; + } + String[] lines = raw.split("\\r?\\n"); + for (String line : lines) { + if (line != null && !line.trim().isEmpty()) { + count++; + } + } + return count; + } + + private static String joinLabels(List labels) { + StringBuilder joined = new StringBuilder(); + for (String label : labels) { + if (label == null || label.trim().isEmpty()) { + continue; + } + if (joined.length() > 0) { + joined.append(", "); + } + joined.append(label.trim()); + } + return joined.toString(); + } + + private static int parseQueryUid(QueryEntry entry) { + if (entry == null || entry.uid == null) { + return -1; + } + try { + return Integer.parseInt(entry.uid.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static void logRedirectPolicy(Context context, String prefix) { + int captureCount = parseUidList(G.dnsHijackCaptureUids()).size(); + int bypassCount = parseUidList(G.dnsHijackBypassUids()).size(); + int captureInterfaceCount = parseInterfaceList(G.dnsHijackCaptureInterfaces()).size(); + int bypassInterfaceCount = parseInterfaceList(G.dnsHijackBypassInterfaces()).size(); + if (captureCount > 0 || bypassCount > 0 + || captureInterfaceCount > 0 || bypassInterfaceCount > 0) { + ApplicationErrorLog.add(context, prefix + ": capture_uids=" + captureCount + + " bypass_uids=" + bypassCount + + " capture_interfaces=" + captureInterfaceCount + + " bypass_interfaces=" + bypassInterfaceCount + + (captureCount > 0 + ? " prerouting_capture=disabled_uid_scope" + : "")); + } + if (captureCount > 0) { + ApplicationErrorLog.add(context, prefix + + ": UID capture scope active; PREROUTING capture disabled because forwarded packets do not expose app UID"); + ApplicationErrorLog.add(context, prefix + + ": Android system resolver packets may use a system UID; leave DNS capture UIDs empty for full Android resolver capture"); + } + } + + private static boolean appendOutputPolicyRules(List commands, String appendCommand, int port) { + return appendOutputPolicyRules(commands, appendCommand, port, false); + } + + private static boolean appendOutputPolicyRules(List commands, String appendCommand, + int port, boolean tolerant) { + List bypassUids = parseUidList(G.dnsHijackBypassUids()); + List captureUids = parseUidList(G.dnsHijackCaptureUids()); + List bypassInterfaces = parseInterfaceList(G.dnsHijackBypassInterfaces()); + List captureInterfaces = parseInterfaceList(G.dnsHijackCaptureInterfaces()); + for (String iface : bypassInterfaces) { + appendIptablesPolicyCommand(commands, appendCommand + " -o " + iface + " -j RETURN", + tolerant); + } + for (Integer uid : bypassUids) { + appendIptablesPolicyCommand(commands, + appendCommand + " -m owner --uid-owner " + uid + " -j RETURN", tolerant); + } + if (captureUids.isEmpty() && captureInterfaces.isEmpty()) { + return false; + } + if (captureUids.isEmpty()) { + for (String iface : captureInterfaces) { + appendOutputRedirect(commands, appendCommand, port, null, iface, tolerant); + } + } else if (captureInterfaces.isEmpty()) { + for (Integer uid : captureUids) { + if (!bypassUids.contains(uid)) { + appendOutputRedirect(commands, appendCommand, port, uid, null, tolerant); + } + } + } else { + for (String iface : captureInterfaces) { + if (bypassInterfaces.contains(iface)) { + continue; + } + for (Integer uid : captureUids) { + if (!bypassUids.contains(uid)) { + appendOutputRedirect(commands, appendCommand, port, uid, iface, tolerant); + } + } + } + } + appendIptablesPolicyCommand(commands, appendCommand + " -j RETURN", tolerant); + return true; + } + + private static void appendOutputRedirect(List commands, String appendCommand, + int port, Integer uid, String iface) { + appendOutputRedirect(commands, appendCommand, port, uid, iface, false); + } + + private static void appendOutputRedirect(List commands, String appendCommand, + int port, Integer uid, String iface, + boolean tolerant) { + String matcher = ""; + if (iface != null) { + matcher += " -o " + iface; + } + if (uid != null) { + matcher += " -m owner --uid-owner " + uid; + } + appendIptablesPolicyCommand(commands, + appendCommand + matcher + " -p udp --dport 53 -j REDIRECT --to-ports " + port, + tolerant); + appendIptablesPolicyCommand(commands, + appendCommand + matcher + " -p tcp --dport 53 -j REDIRECT --to-ports " + port, + tolerant); + } + + private static boolean appendPreroutingPolicyRules(List commands, String appendCommand, int port) { + return appendPreroutingPolicyRules(commands, appendCommand, port, false); + } + + private static boolean appendPreroutingPolicyRules(List commands, String appendCommand, + int port, boolean tolerant) { + List captureUids = parseUidList(G.dnsHijackCaptureUids()); + List bypassInterfaces = parseInterfaceList(G.dnsHijackBypassInterfaces()); + List captureInterfaces = parseInterfaceList(G.dnsHijackCaptureInterfaces()); + for (String iface : bypassInterfaces) { + appendIptablesPolicyCommand(commands, appendCommand + " -i " + iface + " -j RETURN", + tolerant); + } + if (!captureUids.isEmpty()) { + appendIptablesPolicyCommand(commands, appendCommand + " -j RETURN", tolerant); + return true; + } + if (captureInterfaces.isEmpty()) { + return false; + } + for (String iface : captureInterfaces) { + if (bypassInterfaces.contains(iface)) { + continue; + } + appendIptablesPolicyCommand(commands, + appendCommand + " -i " + iface + + " -p udp --dport 53 -j REDIRECT --to-ports " + port, + tolerant); + appendIptablesPolicyCommand(commands, + appendCommand + " -i " + iface + + " -p tcp --dport 53 -j REDIRECT --to-ports " + port, + tolerant); + } + appendIptablesPolicyCommand(commands, appendCommand + " -j RETURN", tolerant); + return true; + } + + private static void appendIptablesPolicyCommand(List commands, String command, + boolean tolerant) { + commands.add(tolerant ? command + " >/dev/null 2>&1 || true" : command); + } + + private static String buildNftFallbackRestoreCommand(Context context, boolean ipv6) { + String iptables = shellQuote(Api.getBinaryPath(context, ipv6)); + String chain = ipv6 ? CHAIN_V6 : CHAIN_V4; + String preChain = ipv6 ? CHAIN_V6_PRE : CHAIN_V4_PRE; + String family = ipv6 ? "ip6" : "ip"; + String table = ipv6 ? NFT_TABLE_V6 : NFT_TABLE_V4; + int port = G.dnsHijackPort(DEFAULT_PORT); + String markStatus = shellQuote(new File(workDir(context), MARK_STATUS).getAbsolutePath()); + return "if command -v nft >/dev/null 2>&1; then if ! ( " + + buildIptablesRedirectHealthyCondition(iptables, chain, preChain, port) + + " ); then " + buildNftRuntimeRestoreCommands(family, table, + String.valueOf(port), markStatus) + + "else nft delete table " + family + " " + table + + " >/dev/null 2>&1 || true; fi; fi; true"; + } + + private static String buildNftPurgeCommand() { + return "if command -v nft >/dev/null 2>&1; then " + + buildNftFamilyPurgeCommand(false) + + buildNftFamilyPurgeCommand(true) + + "fi; true"; + } + + private static String buildNftFamilyPurgeCommand(boolean ipv6) { + String family = ipv6 ? "ip6" : "ip"; + String table = ipv6 ? NFT_TABLE_V6 : NFT_TABLE_V4; + return "nft delete table " + family + " " + table + " >/dev/null 2>&1 || true; "; + } + + private static String buildNftRuntimeRestoreCommands(String family, String table, + String portValue, + String markStatusShellPath) { + return "if [ -r " + markStatusShellPath + " ] && grep -q '^supported ' " + + markStatusShellPath + "; then " + + buildNftRestoreCommands(family, table, portValue, false) + + "else echo 'DNS nftables fallback using UID 0 daemon bypass because daemon mark is unavailable'; " + + buildNftRestoreCommands(family, table, portValue, true) + + "fi; "; + } + + private static String buildNftRestoreCommands(String family, String table, String portValue, + boolean uid0DaemonFallback) { + List nftCommands = new ArrayList<>(); + appendNftCommand(nftCommands, "add table " + family + " " + table); + appendNftCommand(nftCommands, "add chain " + family + " " + table + " " + NFT_OUTPUT + + " { type nat hook output priority dstnat; policy accept; }"); + appendNftCommand(nftCommands, "add chain " + family + " " + table + " " + NFT_PREROUTING + + " { type nat hook prerouting priority dstnat; policy accept; }"); + appendNftOutputRules(nftCommands, family, table, portValue, uid0DaemonFallback); + appendNftPreroutingRules(nftCommands, family, table, portValue); + + StringBuilder command = new StringBuilder(); + command.append("nft delete table ").append(family).append(' ').append(table) + .append(" >/dev/null 2>&1 || true; "); + appendNftScriptCommand(command, nftCommands); + command.append("true; "); + return command.toString(); + } + + private static void appendNftCommand(List commands, String nftArgs) { + commands.add(nftArgs); + } + + private static void appendNftScriptCommand(StringBuilder command, List nftCommands) { + // nft chain definitions include shell-significant braces and semicolons, so feed nft a script. + command.append("printf '%s\\n'"); + for (String nftCommand : nftCommands) { + command.append(' ').append(shellQuote(nftCommand)); + } + command.append(" | nft -f - && "); + } + + private static void appendNftOutputRules(List command, String family, + String table, String portValue, + boolean uid0DaemonFallback) { + List bypassUids = parseUidList(G.dnsHijackBypassUids()); + List captureUids = parseUidList(G.dnsHijackCaptureUids()); + List bypassInterfaces = parseInterfaceList(G.dnsHijackBypassInterfaces()); + List captureInterfaces = parseInterfaceList(G.dnsHijackCaptureInterfaces()); + appendNftRule(command, family, table, NFT_OUTPUT, "oifname " + nftString("lo") + " return"); + if (uid0DaemonFallback) { + appendNftRule(command, family, table, NFT_OUTPUT, "meta skuid 0 return"); + } else { + appendNftRule(command, family, table, NFT_OUTPUT, + "meta mark " + DAEMON_SOCKET_MARK + " return"); + } + for (String iface : bypassInterfaces) { + appendNftRule(command, family, table, NFT_OUTPUT, + "oifname " + nftString(nftInterfacePattern(iface)) + " return"); + } + for (Integer uid : bypassUids) { + appendNftRule(command, family, table, NFT_OUTPUT, "meta skuid " + uid + " return"); + } + if (captureUids.isEmpty() && captureInterfaces.isEmpty()) { + appendNftRedirect(command, family, table, NFT_OUTPUT, "", portValue); + return; + } + if (captureUids.isEmpty()) { + for (String iface : captureInterfaces) { + appendNftRedirect(command, family, table, NFT_OUTPUT, + "oifname " + nftString(nftInterfacePattern(iface)), portValue); + } + } else if (captureInterfaces.isEmpty()) { + for (Integer uid : captureUids) { + if (!bypassUids.contains(uid)) { + appendNftRedirect(command, family, table, NFT_OUTPUT, + "meta skuid " + uid, portValue); + } + } + } else { + for (String iface : captureInterfaces) { + if (bypassInterfaces.contains(iface)) { + continue; + } + for (Integer uid : captureUids) { + if (!bypassUids.contains(uid)) { + appendNftRedirect(command, family, table, NFT_OUTPUT, + "oifname " + nftString(nftInterfacePattern(iface)) + + " meta skuid " + uid, portValue); + } + } + } + } + } + + private static void appendNftPreroutingRules(List command, String family, + String table, String portValue) { + List captureUids = parseUidList(G.dnsHijackCaptureUids()); + List bypassInterfaces = parseInterfaceList(G.dnsHijackBypassInterfaces()); + List captureInterfaces = parseInterfaceList(G.dnsHijackCaptureInterfaces()); + appendNftRule(command, family, table, NFT_PREROUTING, + "iifname " + nftString("lo") + " return"); + for (String iface : bypassInterfaces) { + appendNftRule(command, family, table, NFT_PREROUTING, + "iifname " + nftString(nftInterfacePattern(iface)) + " return"); + } + if (!captureUids.isEmpty()) { + appendNftRule(command, family, table, NFT_PREROUTING, "return"); + return; + } + if (captureInterfaces.isEmpty()) { + appendNftRedirect(command, family, table, NFT_PREROUTING, "", portValue); + return; + } + for (String iface : captureInterfaces) { + if (bypassInterfaces.contains(iface)) { + continue; + } + appendNftRedirect(command, family, table, NFT_PREROUTING, + "iifname " + nftString(nftInterfacePattern(iface)), portValue); + } + } + + private static void appendNftRedirect(List command, String family, String table, + String chain, String matcher, String portValue) { + String prefix = matcher == null || matcher.isEmpty() ? "" : matcher + " "; + appendNftRule(command, family, table, chain, + prefix + "udp dport 53 redirect to :" + portValue); + appendNftRule(command, family, table, chain, + prefix + "tcp dport 53 redirect to :" + portValue); + } + + private static void appendNftRule(List command, String family, String table, + String chain, String rule) { + appendNftCommand(command, "add rule " + family + " " + table + " " + chain + " " + rule); + } + + private static String nftInterfacePattern(String iface) { + return iface != null && iface.endsWith("+") + ? iface.substring(0, iface.length() - 1) + "*" + : iface; + } + + private static String nftString(String value) { + return "\"" + value + "\""; + } + + private static String buildBootDaemonFilterBypass(String tool) { + return " " + tool + " -D OUTPUT -m mark --mark \"$DAEMON_MARK\" -j ACCEPT >/dev/null 2>&1 || true\n" + + " " + tool + " -D OUTPUT -j \"$FILTER\" >/dev/null 2>&1 || true\n" + + " " + tool + " -N \"$FILTER\" >/dev/null 2>&1 || true\n" + + " " + tool + " -F \"$FILTER\" >/dev/null 2>&1 || true\n" + + " if [ -r \"$MARK_STATUS\" ] && grep -q '^supported ' \"$MARK_STATUS\"; then\n" + + " " + tool + " -A \"$FILTER\" -m mark --mark \"$DAEMON_MARK\" -j ACCEPT >> \"$LOG\" 2>&1 || log_msg 'DNS daemon mark filter bypass install failed'\n" + + " else\n" + + " log_msg 'DNS daemon mark unavailable; allowing UID 0 DNS upstream fallback through filter'\n" + + buildBootUid0FilterFallbackRules(tool) + + " fi\n" + + " if ! " + tool + " -I OUTPUT 1 -j \"$FILTER\" >> \"$LOG\" 2>&1; then\n" + + " log_msg 'DNS daemon filter bypass hook install failed; upstream DNS may need root allowed'\n" + + " fi\n"; + } + + private static String buildBootUid0FilterFallbackRules(String tool) { + StringBuilder script = new StringBuilder(); + for (Integer port : dnsUpstreamPortsForFilterFallback()) { + script.append(" ").append(tool) + .append(" -A \"$FILTER\" -m owner --uid-owner 0 -p udp --dport ") + .append(port) + .append(" -j ACCEPT >> \"$LOG\" 2>&1 || log_msg 'DNS UID 0 UDP filter fallback install failed for port ") + .append(port).append("'\n"); + script.append(" ").append(tool) + .append(" -A \"$FILTER\" -m owner --uid-owner 0 -p tcp --dport ") + .append(port) + .append(" -j ACCEPT >> \"$LOG\" 2>&1 || log_msg 'DNS UID 0 TCP filter fallback install failed for port ") + .append(port).append("'\n"); + } + return script.toString(); + } + + private static String buildBootDaemonRecursionBypass(String tool, String chainVariable) { + return " if [ -r \"$MARK_STATUS\" ] && grep -q '^supported ' \"$MARK_STATUS\"; then\n" + + " if ! " + tool + " -t nat -A \"" + chainVariable + + "\" -m mark --mark \"$DAEMON_MARK\" -j RETURN >> \"$LOG\" 2>&1; then\n" + + " log_msg 'DNS daemon mark matcher unavailable; falling back to UID 0 OUTPUT bypass'\n" + + " " + tool + " -t nat -A \"" + chainVariable + + "\" -m owner --uid-owner 0 -j RETURN >> \"$LOG\" 2>&1\n" + + " fi\n" + + " else\n" + + " log_msg 'DNS daemon mark status unavailable; falling back to UID 0 OUTPUT bypass'\n" + + " " + tool + " -t nat -A \"" + chainVariable + + "\" -m owner --uid-owner 0 -j RETURN >> \"$LOG\" 2>&1\n" + + " fi\n"; + } + + private static String buildBootOutputRedirectRules(String tool, String chainVariable) { + StringBuilder script = new StringBuilder(); + List bypassUids = parseUidList(G.dnsHijackBypassUids()); + List captureUids = parseUidList(G.dnsHijackCaptureUids()); + List bypassInterfaces = parseInterfaceList(G.dnsHijackBypassInterfaces()); + List captureInterfaces = parseInterfaceList(G.dnsHijackCaptureInterfaces()); + for (String iface : bypassInterfaces) { + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -o ") + .append(iface).append(" -j RETURN >> \"$LOG\" 2>&1\n"); + } + for (Integer uid : bypassUids) { + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -m owner --uid-owner ") + .append(uid).append(" -j RETURN >> \"$LOG\" 2>&1\n"); + } + if (captureUids.isEmpty() && captureInterfaces.isEmpty()) { + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable) + .append("\" -p udp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable) + .append("\" -p tcp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + return script.toString(); + } + if (captureUids.isEmpty()) { + for (String iface : captureInterfaces) { + appendBootOutputRedirect(script, tool, chainVariable, null, iface); + } + } else if (captureInterfaces.isEmpty()) { + for (Integer uid : captureUids) { + if (!bypassUids.contains(uid)) { + appendBootOutputRedirect(script, tool, chainVariable, uid, null); + } + } + } else { + for (String iface : captureInterfaces) { + if (bypassInterfaces.contains(iface)) { + continue; + } + for (Integer uid : captureUids) { + if (!bypassUids.contains(uid)) { + appendBootOutputRedirect(script, tool, chainVariable, uid, iface); + } + } + } + } + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -j RETURN >> \"$LOG\" 2>&1\n"); + return script.toString(); + } + + private static void appendBootOutputRedirect(StringBuilder script, String tool, + String chainVariable, Integer uid, String iface) { + String matcher = ""; + if (iface != null) { + matcher += " -o " + iface; + } + if (uid != null) { + matcher += " -m owner --uid-owner " + uid; + } + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\"").append(matcher) + .append(" -p udp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\"").append(matcher) + .append(" -p tcp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + } + + private static String buildBootPreroutingRedirectRules(String tool, String chainVariable) { + StringBuilder script = new StringBuilder(); + List captureUids = parseUidList(G.dnsHijackCaptureUids()); + List bypassInterfaces = parseInterfaceList(G.dnsHijackBypassInterfaces()); + List captureInterfaces = parseInterfaceList(G.dnsHijackCaptureInterfaces()); + for (String iface : bypassInterfaces) { + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -i ") + .append(iface).append(" -j RETURN >> \"$LOG\" 2>&1\n"); + } + if (!captureUids.isEmpty()) { + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -j RETURN >> \"$LOG\" 2>&1\n"); + return script.toString(); + } + if (captureInterfaces.isEmpty()) { + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable) + .append("\" -p udp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable) + .append("\" -p tcp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + return script.toString(); + } + for (String iface : captureInterfaces) { + if (bypassInterfaces.contains(iface)) { + continue; + } + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -i ").append(iface) + .append(" -p udp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -i ").append(iface) + .append(" -p tcp --dport 53 -j REDIRECT --to-ports \"$PORT\" >> \"$LOG\" 2>&1\n"); + } + script.append(" ").append(tool).append(" -t nat -A \"") + .append(chainVariable).append("\" -j RETURN >> \"$LOG\" 2>&1\n"); + return script.toString(); + } + + private static List parseUidList(String raw) { + List uids = new ArrayList<>(); + if (raw == null || raw.trim().isEmpty()) { + return uids; + } + String[] lines = raw.split("\\r?\\n"); + for (String line : lines) { + if (line == null) { + continue; + } + int comment = line.indexOf('#'); + String clean = comment >= 0 ? line.substring(0, comment) : line; + String[] tokens = clean.trim().split("[\\s,|]+"); + for (String token : tokens) { + if (token == null || token.trim().isEmpty()) { + continue; + } + try { + long parsed = Long.parseLong(token.trim()); + if (parsed > 0 && parsed <= Integer.MAX_VALUE) { + Integer uid = (int) parsed; + if (!uids.contains(uid)) { + uids.add(uid); + } + } + } catch (NumberFormatException ignored) { + } + } + } + return uids; + } + + private static List parseInterfaceList(String raw) { + List interfaces = new ArrayList<>(); + if (raw == null || raw.trim().isEmpty()) { + return interfaces; + } + String[] lines = raw.split("\\r?\\n"); + for (String line : lines) { + if (line == null) { + continue; + } + int comment = line.indexOf('#'); + String clean = comment >= 0 ? line.substring(0, comment) : line; + String[] tokens = clean.trim().split("[\\s,|]+"); + for (String token : tokens) { + String iface = token == null ? "" : token.trim(); + if (iface.matches("[A-Za-z0-9_.:-]{1,31}\\+?") + && !interfaces.contains(iface)) { + interfaces.add(iface); + } + } + } + return interfaces; + } + + public static final class QueryEntry { + public final long timestamp; + public final String action; + public final String domain; + public final String latency; + public final String transport; + public final String qtype; + public final String result; + public final String rule; + public final String upstream; + public final String source; + public final String uid; + + private QueryEntry(long timestamp, String action, String domain, String latency, + String transport, String qtype, String result, String rule, + String upstream, String source, String uid) { + this.timestamp = timestamp; + this.action = action; + this.domain = domain; + this.latency = latency; + this.transport = emptyFallback(transport, "unknown"); + this.qtype = qtypeName(emptyFallback(qtype, "0")); + this.result = emptyFallback(result, action); + this.rule = emptyFallback(rule, action); + this.upstream = emptyFallback(upstream, "unknown"); + this.source = emptyFallback(source, "unknown"); + this.uid = emptyFallback(uid, "-1"); + } + + private static QueryEntry parse(String line) { + if (line == null) { + return null; + } + String[] parts = line.trim().split("\\s+"); + if (parts.length < 4) { + return null; + } + try { + long timestamp = Long.parseLong(parts[0]); + String action = parts[1]; + String domain = normalizeDomain(parts[2]); + if (domain.isEmpty()) { + return null; + } + Map extras = parseExtras(parts, 4); + return new QueryEntry(timestamp, action, domain, parts[3], + extras.get("transport"), extras.get("qtype"), + extras.get("result"), extras.get("rule"), + extras.get("upstream"), extras.get("source"), + extras.get("uid")); + } catch (NumberFormatException e) { + return null; + } + } + + public boolean hasDomain() { + return domain != null && !domain.isEmpty() && !"unknown".equals(domain); + } + + public String displayLine() { + return timestamp + " " + action + " " + domain + " " + latency + + " transport=" + transport + + " source=" + source + + " uid=" + uid + + " qtype=" + qtype + + " result=" + result + + " rule=" + rule + + " upstream=" + upstream; + } + } + + public static final class DnsDashboardSnapshot { + public final String statusLine; + public final String detailLine; + public final boolean enabled; + public final boolean daemonRunning; + public final boolean listenersReady; + public final boolean upstreamProbeHealthy; + public final boolean privateDnsMayBypass; + public final boolean rootUidMayBypass; + public final String powerLine; + + private DnsDashboardSnapshot(String statusLine, String detailLine, boolean enabled, + boolean daemonRunning, boolean listenersReady, + boolean upstreamProbeHealthy, boolean privateDnsMayBypass, + boolean rootUidMayBypass, String powerLine) { + this.statusLine = statusLine; + this.detailLine = detailLine; + this.enabled = enabled; + this.daemonRunning = daemonRunning; + this.listenersReady = listenersReady; + this.upstreamProbeHealthy = upstreamProbeHealthy; + this.privateDnsMayBypass = privateDnsMayBypass; + this.rootUidMayBypass = rootUidMayBypass; + this.powerLine = powerLine; + } + } + + private static final class ProbeResult { + private final String status; + private final int bytes; + private final int rcode; + + private ProbeResult(String status, int bytes, int rcode) { + this.status = status; + this.bytes = bytes; + this.rcode = rcode; + } + } + + private static final class UpstreamTarget { + private final String host; + private final int port; + private final String protocol; + + private UpstreamTarget(String host, int port, String protocol) { + this.host = host; + this.port = port; + this.protocol = protocol; + } + + private static UpstreamTarget parse(String raw) { + if (raw == null) { + return null; + } + String value = raw.trim(); + String host = value; + int port = 53; + String protocol = "auto"; + if (value.isEmpty() || value.startsWith("#")) { + return null; + } + if (value.regionMatches(true, 0, "udp://", 0, 6)) { + protocol = "udp"; + value = value.substring(6).trim(); + host = value; + } else if (value.regionMatches(true, 0, "tcp://", 0, 6)) { + protocol = "tcp"; + value = value.substring(6).trim(); + host = value; + } else if (value.contains("://")) { + return null; + } + if (value.startsWith("[") && value.contains("]")) { + int end = value.indexOf(']'); + host = value.substring(1, end); + if (end + 2 < value.length() && value.charAt(end + 1) == ':') { + port = parsePort(value.substring(end + 2), port); + } + } else { + int firstColon = value.indexOf(':'); + int lastColon = value.lastIndexOf(':'); + if (firstColon > 0 && firstColon == lastColon) { + host = value.substring(0, firstColon); + port = parsePort(value.substring(firstColon + 1), port); + } + } + host = host.trim().toLowerCase(Locale.US); + if (host.isEmpty() || host.contains("/") || host.contains("\\") + || host.contains(" ") || host.contains("\t")) { + return null; + } + return new UpstreamTarget(host, port, protocol); + } + + private boolean hasLiteralHost() { + return isIpv4Literal(host) || host.contains(":"); + } + + private String toConfigValue() { + String prefix = "tcp".equals(protocol) ? "tcp://" + : "udp".equals(protocol) ? "udp://" : ""; + String formattedHost = host.contains(":") && !host.startsWith("[") + ? "[" + host + "]" : host; + return prefix + formattedHost + ":" + port; + } + + private static int parsePort(String raw, int fallback) { + try { + int parsed = Integer.parseInt(raw.trim()); + return parsed > 0 && parsed <= 65535 ? parsed : fallback; + } catch (NumberFormatException e) { + return fallback; + } + } + + private static boolean isIpv4Literal(String value) { + if (value == null) { + return false; + } + String[] parts = value.split("\\."); + if (parts.length != 4) { + return false; + } + for (String part : parts) { + try { + int parsed = Integer.parseInt(part); + if (parsed < 0 || parsed > 255) { + return false; + } + } catch (NumberFormatException e) { + return false; + } + } + return true; + } + } + + private static String normalizeDomain(String raw) { + if (raw == null) { + return ""; + } + String domain = raw.trim().toLowerCase(Locale.US); + domain = domain.replaceFirst("^\\*\\.", ""); + domain = domain.replaceFirst("^\\.", ""); + domain = domain.replaceFirst("\\.$", ""); + if (!domain.matches("^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")) { + return ""; + } + return domain; + } + + private static void appendBootPersistenceCommand(Context context, List commands) { + if (!G.dnsHijackBootPersistence()) { + // Boot wrapper removal must not run uninstall cleanup while DNS capture is enabled. + appendRemoveBootPersistenceCommand(commands, false); + return; + } + commands.add("#LITERAL# " + buildInstallBootPersistenceCommand(workDir(context), false)); + ApplicationErrorLog.add(context, + "DNS hijacker Magisk boot module install queued as best effort during rule apply"); + } + + private static String buildInstallBootPersistenceCommand(File moduleSourceDir) { + return buildInstallBootPersistenceCommand(moduleSourceDir, true); + } + + private static String buildInstallBootPersistenceCommand(File moduleSourceDir, + boolean required) { + String source = shellQuote(moduleSourceDir.getAbsolutePath()); + String moduleDir = shellQuote(MAGISK_MODULE_DIR); + return "SRC=" + source + "; " + + "MOD=" + moduleDir + "; " + + "if [ -d /data/adb/modules ]; then " + + "if mkdir -p \"$MOD\" 2>/dev/null " + + "&& cp \"$SRC/" + MAGISK_MODULE_PROP + "\" \"$MOD/" + MAGISK_MODULE_PROP + "\" 2>/dev/null " + + "&& cp \"$SRC/" + MAGISK_SERVICE_SCRIPT + "\" \"$MOD/" + MAGISK_SERVICE_SCRIPT + "\" 2>/dev/null " + + "&& cp \"$SRC/" + MAGISK_UNINSTALL_SCRIPT + "\" \"$MOD/" + MAGISK_UNINSTALL_SCRIPT + "\" 2>/dev/null " + + "&& chmod 644 \"$MOD/" + MAGISK_MODULE_PROP + "\" 2>/dev/null " + + "&& chmod 755 \"$MOD/" + MAGISK_SERVICE_SCRIPT + "\" \"$MOD/" + MAGISK_UNINSTALL_SCRIPT + "\" 2>/dev/null " + + "&& rm -f \"$MOD/disable\" \"$MOD/remove\" 2>/dev/null " + + "&& touch \"$MOD/update\" 2>/dev/null; then " + + "echo 'DNS Magisk module installed at " + MAGISK_MODULE_DIR + "'; " + + buildRemoveLegacyRootScriptsCommand() + + " else echo 'DNS Magisk module install failed at " + MAGISK_MODULE_DIR + "'; " + + (required ? "false" : "true") + "; fi; " + + "else echo 'DNS Magisk module install failed: /data/adb/modules not found'; " + + (required ? "false" : "true") + "; fi"; + } + + private static String buildRemoveBootPersistenceCommand() { + return buildRemoveBootPersistenceCommand(true); + } + + private static String buildRemoveBootPersistenceCommand(boolean cleanupActiveService) { + return "MOD=" + shellQuote(MAGISK_MODULE_DIR) + "; " + + "if [ -d \"$MOD\" ]; then " + + (cleanupActiveService + ? "if [ -x \"$MOD/" + MAGISK_UNINSTALL_SCRIPT + "\" ]; then " + + "\"$MOD/" + MAGISK_UNINSTALL_SCRIPT + "\" " + + ">> /data/local/tmp/" + MAGISK_MODULE_LOG + " 2>&1 || true; fi; " + : "echo 'DNS Magisk module wrapper removal requested; active DNS service left running'; ") + + "if [ -f \"$MOD/" + MAGISK_MODULE_PROP + "\" ] " + + "&& grep -q '^id=" + MAGISK_MODULE_ID + "$' \"$MOD/" + MAGISK_MODULE_PROP + "\"; then " + + "rm -f \"$MOD/" + MAGISK_MODULE_PROP + "\" " + + "\"$MOD/" + MAGISK_SERVICE_SCRIPT + "\" " + + "\"$MOD/" + MAGISK_UNINSTALL_SCRIPT + "\" " + + "\"$MOD/update\" 2>/dev/null || true; " + + "rmdir \"$MOD\" 2>/dev/null || true; fi; " + + "if [ -d \"$MOD\" ]; then " + + "touch \"$MOD/disable\" \"$MOD/remove\" 2>/dev/null || true; " + + "echo 'DNS Magisk module removal scheduled'; " + + "else echo 'DNS Magisk module removed'; fi; " + + "fi; " + + buildRemoveLegacyRootScriptsCommand(); + } + + private static String buildRemoveLifecycleCleanupCommand() { + return buildRemoveRootScriptCommand(CLEANUP_SCRIPT); + } + + private static String buildRemoveLegacyRootScriptsCommand() { + return buildRemoveRootScriptCommand(BOOT_SCRIPT) + "; " + + buildRemoveRootScriptCommand(CLEANUP_SCRIPT); + } + + private static String buildRemoveRootScriptCommand(String scriptName) { + return "for FILE in /data/adb/service.d/" + scriptName + + " /su/su.d/" + scriptName + + " /system/su.d/" + scriptName + + " /system/etc/init.d/" + scriptName + + "; do [ -e \"$FILE\" ] && rm -f \"$FILE\" 2>/dev/null; done; true"; + } + + private static String buildRepairServiceEventLogFilesCommand(Context context) { + File dir = workDir(context); + StringBuilder command = new StringBuilder(); + command.append("DIR=").append(shellQuote(dir.getAbsolutePath())).append("; "); + command.append("if [ -d \"$DIR\" ]; then "); + command.append("chmod 700 \"$DIR\" 2>/dev/null || true; "); + for (String logName : SERVICE_EVENT_LOGS) { + command.append("LOG=\"$DIR/").append(logName).append("\"; ") + .append("touch \"$LOG\" 2>/dev/null || true; ") + .append("chmod 644 \"$LOG\" 2>/dev/null || true; "); + } + command.append("fi; true"); + return command.toString(); + } + + private static String buildSupervisorReadinessCheckCommand(Context context) { + String supervisor = shellQuote(supervisorPath(context)); + return "STATUS=$(" + supervisor + " status 2>&1); " + + "if echo \"$STATUS\" | grep -q '^readiness=ready$'; then true; else " + + "echo 'DNS supervisor readiness check failed'; " + + "echo \"$STATUS\"; false; fi"; + } + + private static void appendRemoveBootPersistenceCommand(List commands) { + appendRemoveBootPersistenceCommand(commands, true); + } + + private static void appendRemoveBootPersistenceCommand(List commands, + boolean cleanupActiveService) { + commands.add("#LITERAL# " + buildRemoveBootPersistenceCommand(cleanupActiveService)); + } + + private static void appendRemoveLifecycleCleanupCommand(List commands) { + commands.add("#LITERAL# " + buildRemoveLifecycleCleanupCommand()); + } + + private static List buildRootRepairCommands(Context context) { + List commands = new ArrayList<>(); + commands.add(buildRepairServiceEventLogFilesCommand(context)); + appendLegacySupervisorStopCommand(context, commands, false); + commands.add(shellQuote(supervisorPath(context)) + " restart"); + commands.add(buildSupervisorReadinessCheckCommand(context)); + logRedirectPolicy(context, "DNS redirect policy repair queued"); + appendRootRedirectRepairCommands(context, commands, false); + if (G.enableIPv6()) { + appendRootRedirectRepairCommands(context, commands, true); + } else { + appendDirectPurgeRules(context, commands, true); + commands.add(buildNftFamilyPurgeCommand(true)); + } + if (G.dnsHijackBootPersistence()) { + commands.add(buildInstallBootPersistenceCommand(workDir(context), false)); + ApplicationErrorLog.add(context, + "DNS Magisk boot module install queued as best effort during DNS protection repair"); + } else { + // Repair keeps the live daemon path active even when boot restore is disabled. + commands.add(buildRemoveBootPersistenceCommand(false)); + } + return commands; + } + + private static List buildRootRemovalCommands(Context context) { + List commands = new ArrayList<>(); + // These commands are executed directly through RootCommand, not through Api.iptablesCommands. + // Keep them fully-qualified so preference toggles and dashboard pause actually remove root state. + commands.add(buildRepairServiceEventLogFilesCommand(context)); + commands.add(buildDirectDaemonStateCleanupCommand(context)); + appendDirectPurgeRules(context, commands, false); + appendDirectPurgeRules(context, commands, true); + commands.add(buildNftPurgeCommand()); + appendDirectStopCommand(context, commands); + commands.add(buildRemoveBootPersistenceCommand()); + commands.add(buildRemoveLifecycleCleanupCommand()); + return commands; + } + + private static List buildRootEmergencyCleanupCommands(Context context) { + List commands = buildRootRemovalCommands(context); + commands.add("echo 'DNS emergency cleanup finished'"); + return commands; + } + + private static void requestDaemonFailOpen(Context context, String reason) { + if (context == null) { + return; + } + String response = queryControl(context, "fail_open", 5000); + syncServiceLogsToAppLog(context); + if (response.startsWith("ok fail_open")) { + ApplicationErrorLog.add(context, + "DNS daemon fail-open control completed before " + reason + ": " + + compactControlResponse(response)); + return; + } + ApplicationErrorLog.add(context, + "DNS daemon fail-open control unavailable before " + reason + ": " + + compactControlResponse(response)); + } + + private static boolean clearEnableMarkerBeforeRoot(Context context, String reason) { + if (context == null) { + return false; + } + // Root cleanup may be unavailable after the user's su grant is revoked; the app-owned + // marker is the supervisor's no-root fail-open signal. + List dirs = new ArrayList<>(); + dirs.add(workDir(context)); + File legacyDir = legacyCredentialProtectedWorkDir(context); + if (legacyDir != null) { + dirs.add(legacyDir); + } + + boolean removed = false; + boolean failed = false; + for (File dir : dirs) { + File marker = new File(dir, ENABLED_MARKER); + if (!marker.exists()) { + continue; + } + if (marker.delete()) { + removed = true; + } else { + failed = true; + ApplicationErrorLog.add(context, + "DNS enable marker could not be cleared before " + reason + + ": " + marker.getAbsolutePath()); + } + } + if (failed) { + ApplicationErrorLog.add(context, + "DNS enable marker cleanup was incomplete before " + reason + + "; root cleanup will try again"); + return false; + } + if (removed) { + ApplicationErrorLog.add(context, + "DNS enable marker cleared before " + reason + + "; supervisor can fail open if root cleanup is unavailable"); + } else { + ApplicationErrorLog.add(context, + "DNS enable marker already absent before " + reason); + } + return true; + } + + private static String buildDirectDaemonStateCleanupCommand(Context context) { + List dirs = new ArrayList<>(); + dirs.add(workDir(context)); + File legacyDir = legacyCredentialProtectedWorkDir(context); + if (legacyDir != null) { + dirs.add(legacyDir); + } + + StringBuilder command = new StringBuilder(); + command.append("stop_named_daemon() { "); + command.append("signal=\"$1\"; "); + command.append("if command -v pidof >/dev/null 2>&1; then "); + command.append("for p in $(pidof ").append(DAEMON_NAME) + .append(" 2>/dev/null); do kill \"$signal\" \"$p\" 2>/dev/null || true; done; "); + command.append("fi; "); + command.append("for proc in /proc/[0-9]*; do "); + command.append("[ -r \"$proc/comm\" ] || continue; "); + command.append("name=$(cat \"$proc/comm\" 2>/dev/null || true); "); + command.append("[ \"$name\" = \"").append(DAEMON_NAME).append("\" ] || continue; "); + command.append("pid=${proc#/proc/}; "); + command.append("kill \"$signal\" \"$pid\" 2>/dev/null || true; "); + command.append("done; "); + command.append("}; "); + command.append("for DIR in"); + for (File dir : dirs) { + command.append(' ').append(shellQuote(dir.getAbsolutePath())); + } + command.append("; do "); + command.append("[ -d \"$DIR\" ] || continue; "); + command.append("rm -f \"$DIR/").append(ENABLED_MARKER).append("\" 2>/dev/null || true; "); + command.append("for PIDFILE in \"$DIR/").append(SUPERVISOR_PID) + .append("\" \"$DIR/").append(PID).append("\"; do "); + command.append("[ -f \"$PIDFILE\" ] || continue; "); + command.append("pid=$(cat \"$PIDFILE\" 2>/dev/null || true); "); + command.append("case \"$pid\" in ''|*[!0-9]*) ;; *) kill -TERM \"$pid\" 2>/dev/null || true ;; esac; "); + command.append("done; "); + command.append("done; "); + command.append("stop_named_daemon -TERM; "); + command.append("sleep 1; "); + command.append("for DIR in"); + for (File dir : dirs) { + command.append(' ').append(shellQuote(dir.getAbsolutePath())); + } + command.append("; do "); + command.append("[ -d \"$DIR\" ] || continue; "); + command.append("for PIDFILE in \"$DIR/").append(SUPERVISOR_PID) + .append("\" \"$DIR/").append(PID).append("\"; do "); + command.append("[ -f \"$PIDFILE\" ] || continue; "); + command.append("pid=$(cat \"$PIDFILE\" 2>/dev/null || true); "); + command.append("case \"$pid\" in ''|*[!0-9]*) ;; *) kill -KILL \"$pid\" 2>/dev/null || true ;; esac; "); + command.append("done; "); + command.append("rm -f \"$DIR/").append(SUPERVISOR_PID) + .append("\" \"$DIR/").append(PID) + .append("\" \"$DIR/").append(HEARTBEAT) + .append("\" \"$DIR/").append(SOCKET) + .append("\" 2>/dev/null || true; "); + command.append("done; "); + command.append("stop_named_daemon -KILL; "); + command.append("true"); + return command.toString(); + } + + private static void appendDirectPurgeRules(Context context, List commands, boolean ipv6) { + String iptables = shellQuote(Api.getBinaryPath(context, ipv6)); + String chain = ipv6 ? CHAIN_V6 : CHAIN_V4; + String preChain = ipv6 ? CHAIN_V6_PRE : CHAIN_V4_PRE; + + appendDirectDaemonFilterBypassPurge(commands, iptables); + appendDirectIptables(commands, iptables, + "-t nat -D OUTPUT -p udp --dport 53 -j " + chain); + appendDirectIptables(commands, iptables, + "-t nat -D OUTPUT -p tcp --dport 53 -j " + chain); + appendDirectIptables(commands, iptables, + "-t nat -D PREROUTING -p udp --dport 53 -j " + preChain); + appendDirectIptables(commands, iptables, + "-t nat -D PREROUTING -p tcp --dport 53 -j " + preChain); + appendDirectIptables(commands, iptables, "-t nat -F " + chain); + appendDirectIptables(commands, iptables, "-t nat -F " + preChain); + appendDirectIptables(commands, iptables, "-t nat -X " + chain); + appendDirectIptables(commands, iptables, "-t nat -X " + preChain); + } + + private static void appendDirectIptables(List commands, String iptables, String args) { + commands.add(iptables + " " + args + " >/dev/null 2>&1 || true"); + } + + private static String daemonMarkFilterBypassArgs() { + return "-m mark --mark " + DAEMON_SOCKET_MARK + " -j ACCEPT"; + } + + private static void appendDaemonFilterBypass(Context context, List commands) { + appendDaemonFilterBypassPurge(commands); + // The root daemon runs outside AFWall's app UID. Its upstream sockets must pass the + // filter table, and the fallback must follow the same mark-vs-UID path as NAT recursion. + commands.add("#LITERAL# " + buildDaemonFilterBypassInstallCommand("\"$IPTABLES\"", + new File(workDir(context), MARK_STATUS).getAbsolutePath())); + } + + private static void appendDaemonFilterBypassPurge(List commands) { + commands.add("#NOCHK# -D OUTPUT " + daemonMarkFilterBypassArgs()); + commands.add("#NOCHK# -D OUTPUT -j " + CHAIN_FILTER); + commands.add("#NOCHK# -F " + CHAIN_FILTER); + commands.add("#NOCHK# -X " + CHAIN_FILTER); + } + + private static void appendDirectDaemonFilterBypass(Context context, List commands, + String iptables) { + appendDirectDaemonFilterBypassPurge(commands, iptables); + commands.add(buildDaemonFilterBypassInstallCommand(iptables, + new File(workDir(context), MARK_STATUS).getAbsolutePath())); + } + + private static void appendDirectDaemonFilterBypassPurge(List commands, String iptables) { + appendTolerantIptables(commands, iptables, "-D OUTPUT " + daemonMarkFilterBypassArgs()); + appendTolerantIptables(commands, iptables, "-D OUTPUT -j " + CHAIN_FILTER); + appendTolerantIptables(commands, iptables, "-F " + CHAIN_FILTER); + appendTolerantIptables(commands, iptables, "-X " + CHAIN_FILTER); + } + + private static String buildDaemonFilterBypassInstallCommand(String iptables, + String markStatusPath) { + String status = shellQuote(markStatusPath); + StringBuilder command = new StringBuilder(); + command.append("ok=1; "); + command.append(iptables).append(" -D OUTPUT -j ").append(CHAIN_FILTER) + .append(" >/dev/null 2>&1 || true; "); + command.append(iptables).append(" -N ").append(CHAIN_FILTER) + .append(" >/dev/null 2>&1 || true; "); + command.append(iptables).append(" -F ").append(CHAIN_FILTER) + .append(" >/dev/null 2>&1 || true; "); + command.append("if [ -r ").append(status) + .append(" ] && grep -q '^supported ' ").append(status).append("; then "); + command.append(iptables).append(" -A ").append(CHAIN_FILTER).append(' ') + .append(daemonMarkFilterBypassArgs()).append(" || ok=0; "); + command.append("else echo 'DNS daemon mark unavailable; allowing UID 0 DNS upstream fallback through filter'; "); + appendUid0FilterFallbackRules(command, iptables, CHAIN_FILTER); + command.append("fi; "); + command.append(iptables).append(" -I OUTPUT 1 -j ").append(CHAIN_FILTER) + .append(" || ok=0; "); + command.append("[ \"$ok\" = 1 ]"); + return command.toString(); + } + + private static void appendUid0FilterFallbackRules(StringBuilder command, String iptables, + String chain) { + for (Integer port : dnsUpstreamPortsForFilterFallback()) { + command.append(iptables).append(" -A ").append(chain) + .append(" -m owner --uid-owner 0 -p udp --dport ") + .append(port).append(" -j ACCEPT || ok=0; "); + command.append(iptables).append(" -A ").append(chain) + .append(" -m owner --uid-owner 0 -p tcp --dport ") + .append(port).append(" -j ACCEPT || ok=0; "); + } + } + + private static void appendDaemonRecursionBypass(Context context, List commands, + String iptables, String chain) { + commands.add(buildDaemonRecursionBypassCommand( + iptables, chain, new File(workDir(context), MARK_STATUS).getAbsolutePath())); + } + + private static String buildDaemonRecursionBypassCommand(String iptables, String chain, + String markStatusPath) { + String status = shellQuote(markStatusPath); + return "( if [ -r " + status + " ] && grep -q '^supported ' " + status + "; then " + + iptables + " -t nat -A " + chain + " -m mark --mark " + + DAEMON_SOCKET_MARK + " -j RETURN >/dev/null 2>&1 || " + + iptables + " -t nat -A " + chain + + " -m owner --uid-owner 0 -j RETURN; else " + + iptables + " -t nat -A " + chain + + " -m owner --uid-owner 0 -j RETURN; fi )"; + } + + private static String buildApplyDaemonRecursionBypassCommand(Context context, String chain) { + return buildDaemonRecursionBypassCommand("\"$IPTABLES\"", chain, + new File(workDir(context), MARK_STATUS).getAbsolutePath()) + " #"; + } + + private static void appendTolerantIptables(List commands, String iptables, String args) { + commands.add(iptables + " " + args + " >/dev/null 2>&1 || true"); + } + + private static void appendDirectStopCommand(Context context, List commands) { + if (context == null) { + return; + } + appendSupervisorStopCommand(commands, new File(workDir(context), SUPERVISOR)); + appendLegacySupervisorStopCommand(context, commands, false); + } + + private static void appendLegacySupervisorStopCommand(Context context, List commands, + boolean literal) { + File legacyDir = legacyCredentialProtectedWorkDir(context); + if (legacyDir != null) { + appendSupervisorStopCommand(commands, new File(legacyDir, SUPERVISOR), literal); + } + } + + private static void appendSupervisorStopCommand(List commands, File supervisor) { + appendSupervisorStopCommand(commands, supervisor, false); + } + + private static void appendSupervisorStopCommand(List commands, File supervisor, + boolean literal) { + if (supervisor.exists()) { + commands.add((literal ? "#LITERAL# " : "") + + shellQuote(supervisor.getAbsolutePath()) + " stop || true"); + } + } + + private static void appendRootRedirectRepairCommands(Context context, List commands, boolean ipv6) { + String iptables = shellQuote(Api.getBinaryPath(context, ipv6)); + int port = G.dnsHijackPort(DEFAULT_PORT); + String chain = ipv6 ? CHAIN_V6 : CHAIN_V4; + String preChain = ipv6 ? CHAIN_V6_PRE : CHAIN_V4_PRE; + + appendDirectDaemonFilterBypass(context, commands, iptables); + appendTolerantIptables(commands, iptables, "-t nat -D OUTPUT -p udp --dport 53 -j " + chain); + appendTolerantIptables(commands, iptables, "-t nat -D OUTPUT -p tcp --dport 53 -j " + chain); + appendTolerantIptables(commands, iptables, "-t nat -D PREROUTING -p udp --dport 53 -j " + preChain); + appendTolerantIptables(commands, iptables, "-t nat -D PREROUTING -p tcp --dport 53 -j " + preChain); + appendTolerantIptables(commands, iptables, "-t nat -N " + chain); + appendTolerantIptables(commands, iptables, "-t nat -N " + preChain); + appendTolerantIptables(commands, iptables, "-t nat -F " + chain); + appendTolerantIptables(commands, iptables, "-t nat -F " + preChain); + appendTolerantIptables(commands, iptables, "-t nat -A " + chain + " -o lo -j RETURN"); + appendDaemonRecursionBypass(context, commands, iptables, chain); + if (!appendOutputPolicyRules(commands, iptables + " -t nat -A " + chain, port, true)) { + appendTolerantIptables(commands, iptables, + "-t nat -A " + chain + " -p udp --dport 53 -j REDIRECT --to-ports " + port); + appendTolerantIptables(commands, iptables, + "-t nat -A " + chain + " -p tcp --dport 53 -j REDIRECT --to-ports " + port); + } + appendTolerantIptables(commands, iptables, "-t nat -A " + preChain + " -i lo -j RETURN"); + if (!appendPreroutingPolicyRules(commands, iptables + " -t nat -A " + preChain, + port, true)) { + appendTolerantIptables(commands, iptables, + "-t nat -A " + preChain + " -p udp --dport 53 -j REDIRECT --to-ports " + port); + appendTolerantIptables(commands, iptables, + "-t nat -A " + preChain + " -p tcp --dport 53 -j REDIRECT --to-ports " + port); + } + appendTolerantIptables(commands, iptables, "-t nat -I OUTPUT 1 -p udp --dport 53 -j " + chain); + appendTolerantIptables(commands, iptables, "-t nat -I OUTPUT 1 -p tcp --dport 53 -j " + chain); + appendTolerantIptables(commands, iptables, + "-t nat -I PREROUTING 1 -p udp --dport 53 -j " + preChain); + appendTolerantIptables(commands, iptables, + "-t nat -I PREROUTING 1 -p tcp --dport 53 -j " + preChain); + commands.add(buildNftFallbackRestoreCommand(context, ipv6)); + commands.add(buildRedirectInstallVerificationCommand(context, ipv6)); + } + + private static void appendRedirectRules(Context context, List commands, boolean ipv6) { + int port = G.dnsHijackPort(DEFAULT_PORT); + String chain = ipv6 ? CHAIN_V6 : CHAIN_V4; + String preChain = ipv6 ? CHAIN_V6_PRE : CHAIN_V4_PRE; + + commands.add("#NOCHK# -t nat -N " + chain); + commands.add("#NOCHK# -t nat -N " + preChain); + commands.add("#NOCHK# -t nat -F " + chain); + commands.add("#NOCHK# -t nat -F " + preChain); + + appendDaemonFilterBypass(context, commands); + commands.add("#NOCHK# -t nat -A " + chain + " -o lo -j RETURN"); + commands.add("#LITERAL# " + buildApplyDaemonRecursionBypassCommand(context, chain)); + if (!appendOutputPolicyRules(commands, "#NOCHK# -t nat -A " + chain, port)) { + commands.add("#NOCHK# -t nat -A " + chain + " -p udp --dport 53 -j REDIRECT --to-ports " + port); + commands.add("#NOCHK# -t nat -A " + chain + " -p tcp --dport 53 -j REDIRECT --to-ports " + port); + } + + commands.add("#NOCHK# -t nat -A " + preChain + " -i lo -j RETURN"); + if (!appendPreroutingPolicyRules(commands, "#NOCHK# -t nat -A " + preChain, port)) { + commands.add("#NOCHK# -t nat -A " + preChain + " -p udp --dport 53 -j REDIRECT --to-ports " + port); + commands.add("#NOCHK# -t nat -A " + preChain + " -p tcp --dport 53 -j REDIRECT --to-ports " + port); + } + + commands.add("#NOCHK# -t nat -I OUTPUT 1 -p udp --dport 53 -j " + chain); + commands.add("#NOCHK# -t nat -I OUTPUT 1 -p tcp --dport 53 -j " + chain); + commands.add("#NOCHK# -t nat -I PREROUTING 1 -p udp --dport 53 -j " + preChain); + commands.add("#NOCHK# -t nat -I PREROUTING 1 -p tcp --dport 53 -j " + preChain); + } + + private static void appendPurgeRules(List commands, boolean ipv6) { + String chain = ipv6 ? CHAIN_V6 : CHAIN_V4; + String preChain = ipv6 ? CHAIN_V6_PRE : CHAIN_V4_PRE; + + appendDaemonFilterBypassPurge(commands); + commands.add("#NOCHK# -t nat -D OUTPUT -p udp --dport 53 -j " + chain); + commands.add("#NOCHK# -t nat -D OUTPUT -p tcp --dport 53 -j " + chain); + commands.add("#NOCHK# -t nat -D PREROUTING -p udp --dport 53 -j " + preChain); + commands.add("#NOCHK# -t nat -D PREROUTING -p tcp --dport 53 -j " + preChain); + commands.add("#NOCHK# -t nat -F " + chain); + commands.add("#NOCHK# -t nat -F " + preChain); + commands.add("#NOCHK# -t nat -X " + chain); + commands.add("#NOCHK# -t nat -X " + preChain); + } + + private static void appendStopCommand(Context context, List commands) { + if (context == null) { + return; + } + File supervisor = new File(workDir(context), SUPERVISOR); + if (supervisor.exists()) { + commands.add("#LITERAL# " + shellQuote(supervisor.getAbsolutePath()) + " stop || true"); + } + } + + private static boolean prepareDaemon(Context context) { + if (context == null) { + return false; + } + + try { + File dir = workDir(context); + if (!dir.exists() && !dir.mkdirs()) { + throw new IOException("Unable to create " + dir.getAbsolutePath()); + } + ensureLocalServiceEventLogFiles(dir); + + File daemon = new File(dir, DAEMON_NAME); + copyDaemonAsset(context, daemon); + if (!daemon.setExecutable(true, false)) { + Log.w(TAG, "Unable to mark daemon executable from app context; root start will chmod it"); + } + + String token = controlToken(context); + if (token.isEmpty()) { + throw new IOException("Unable to create DNS control token"); + } + File config = new File(dir, CONF); + writeText(config, buildConfig(context, token)); + setOwnerOnly(config); + File supervisor = new File(dir, SUPERVISOR); + writeText(supervisor, buildSupervisorScript(context, dir, daemon)); + if (!supervisor.setExecutable(true, false)) { + Log.w(TAG, "Unable to mark supervisor executable from app context; root start will chmod it"); + } + File bootScript = new File(dir, BOOT_SCRIPT); + writeText(bootScript, buildBootScript(context, dir)); + if (!bootScript.setExecutable(true, false)) { + Log.w(TAG, "Unable to mark DNS boot script executable from app context; root install will chmod it"); + } + File cleanupScript = new File(dir, CLEANUP_SCRIPT); + writeText(cleanupScript, buildLifecycleCleanupScript(context, dir)); + if (!cleanupScript.setExecutable(true, false)) { + Log.w(TAG, "Unable to mark DNS cleanup script executable from app context; root install will chmod it"); + } + writeText(new File(dir, MAGISK_MODULE_PROP), buildMagiskModuleProp()); + File moduleService = new File(dir, MAGISK_SERVICE_SCRIPT); + writeText(moduleService, buildMagiskServiceScript(context, dir)); + if (!moduleService.setExecutable(true, false)) { + Log.w(TAG, "Unable to mark DNS Magisk service script executable from app context; root install will chmod it"); + } + File moduleUninstall = new File(dir, MAGISK_UNINSTALL_SCRIPT); + writeText(moduleUninstall, buildMagiskUninstallScript(dir)); + if (!moduleUninstall.setExecutable(true, false)) { + Log.w(TAG, "Unable to mark DNS Magisk uninstall script executable from app context; root install will chmod it"); + } + return true; + } catch (IOException e) { + Log.e(TAG, "Unable to prepare DNS daemon", e); + ApplicationErrorLog.add(context, "Unable to prepare DNS hijacker daemon: " + e.getMessage()); + return false; + } + } + + private static void ensureLocalServiceEventLogFiles(File dir) throws IOException { + if (dir == null) { + return; + } + for (String logName : SERVICE_EVENT_LOGS) { + File logFile = new File(dir, logName); + if (!logFile.exists() && !logFile.createNewFile()) { + throw new IOException("Unable to create " + logFile.getAbsolutePath()); + } + // Root appends lifecycle events, but AFWall must be able to read them back later. + logFile.setReadable(true, false); + } + } + + private static void copyDaemonAsset(Context context, File target) throws IOException { + String abi = selectAbi(); + String asset = "dnsd/" + abi + "/" + DAEMON_NAME; + try (InputStream input = context.getAssets().open(asset); + FileOutputStream output = new FileOutputStream(target, false)) { + byte[] buffer = new byte[8192]; + int read; + while ((read = input.read(buffer)) != -1) { + output.write(buffer, 0, read); + } + } + } + + private static String buildMagiskModuleProp() { + return "id=" + MAGISK_MODULE_ID + "\n" + + "name=AFWall DNS Service\n" + + "version=" + MAGISK_MODULE_VERSION + "\n" + + "versionCode=" + MAGISK_MODULE_VERSION_CODE + "\n" + + "author=AFWall+\n" + + "description=Runs AFWall DNS protection through a Magisk service wrapper with fail-open cleanup.\n"; + } + + private static String buildMagiskServiceScript(Context context, File dir) { + String boot = new File(dir, BOOT_SCRIPT).getAbsolutePath(); + String cleanup = new File(dir, CLEANUP_SCRIPT).getAbsolutePath(); + String marker = new File(dir, ENABLED_MARKER).getAbsolutePath(); + String packageName = context.getPackageName(); + return "#!/system/bin/sh\n" + + "AFWALL_DNS_MODULE_SCRIPT_VERSION=" + MAGISK_SCRIPT_VERSION + "\n" + + "MODDIR=${0%/*}\n" + + "PACKAGE=" + shellQuote(packageName) + "\n" + + "BOOT=" + shellQuote(boot) + "\n" + + "CLEANUP=" + shellQuote(cleanup) + "\n" + + "MARKER=" + shellQuote(marker) + "\n" + + "LOG=/data/local/tmp/" + MAGISK_MODULE_LOG + "\n" + + "log_msg() { echo \"$(date +%s) $*\" >> \"$LOG\" 2>/dev/null || true; chmod 644 \"$LOG\" 2>/dev/null || true; }\n" + + "app_installed() { pm path \"$PACKAGE\" >/dev/null 2>&1; }\n" + + buildMagiskEmbeddedCleanupScript() + + "log_msg 'AFWall DNS Magisk service starting'\n" + + "if app_installed && [ -x \"$BOOT\" ] && [ -f \"$MARKER\" ]; then\n" + + " \"$BOOT\" >> \"$LOG\" 2>&1\n" + + " exit $?\n" + + "fi\n" + + "log_msg 'AFWall package, enable marker, or boot script missing; cleaning DNS state and scheduling module removal'\n" + + "rm -f \"$MARKER\" 2>/dev/null || true\n" + + "if [ -x \"$CLEANUP\" ]; then \"$CLEANUP\" >> \"$LOG\" 2>&1; else cleanup_service_state; fi\n" + + "touch \"$MODDIR/disable\" \"$MODDIR/remove\" 2>/dev/null || true\n" + + "exit 0\n"; + } + + private static String buildMagiskUninstallScript(File dir) { + String cleanup = new File(dir, CLEANUP_SCRIPT).getAbsolutePath(); + String marker = new File(dir, ENABLED_MARKER).getAbsolutePath(); + return "#!/system/bin/sh\n" + + "AFWALL_DNS_MODULE_SCRIPT_VERSION=" + MAGISK_SCRIPT_VERSION + "\n" + + "CLEANUP=" + shellQuote(cleanup) + "\n" + + "MARKER=" + shellQuote(marker) + "\n" + + "LOG=/data/local/tmp/" + MAGISK_MODULE_LOG + "\n" + + "log_msg() { echo \"$(date +%s) $*\" >> \"$LOG\" 2>/dev/null || true; chmod 644 \"$LOG\" 2>/dev/null || true; }\n" + + buildMagiskEmbeddedCleanupScript() + + "log_msg 'AFWall DNS Magisk module uninstall cleanup starting'\n" + + "if [ -x \"$CLEANUP\" ]; then \"$CLEANUP\" >> \"$LOG\" 2>&1; else cleanup_service_state; fi\n" + + "log_msg 'AFWall DNS Magisk module uninstall cleanup complete'\n"; + } + + private static String buildMagiskEmbeddedCleanupScript() { + return "ipt() { if command -v iptables >/dev/null 2>&1; then iptables \"$@\"; else return 0; fi; }\n" + + "ip6t() { if command -v ip6tables >/dev/null 2>&1; then ip6tables \"$@\"; else return 0; fi; }\n" + + "kill_daemon_processes() {\n" + + " signal=\"$1\"\n" + + " if command -v pidof >/dev/null 2>&1; then\n" + + " for p in $(pidof " + DAEMON_NAME + " 2>/dev/null); do kill \"$signal\" \"$p\" 2>/dev/null || true; done\n" + + " fi\n" + + " for proc in /proc/[0-9]*; do\n" + + " [ -r \"$proc/comm\" ] || continue\n" + + " name=$(cat \"$proc/comm\" 2>/dev/null || true)\n" + + " [ \"$name\" = \"" + DAEMON_NAME + "\" ] || continue\n" + + " pid=${proc#/proc/}\n" + + " kill \"$signal\" \"$pid\" 2>/dev/null || true\n" + + " done\n" + + "}\n" + + "stop_daemon() {\n" + + " log_msg 'Magisk wrapper stopping stale AFWall DNS daemon processes'\n" + + " kill_daemon_processes -TERM\n" + + " sleep 1\n" + + " kill_daemon_processes -KILL\n" + + "}\n" + + "cleanup_redirects() {\n" + + " log_msg 'Magisk wrapper removing stale AFWall DNS redirect rules'\n" + + " ipt -D OUTPUT -m mark --mark " + DAEMON_SOCKET_MARK + " -j ACCEPT >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -m mark --mark " + DAEMON_SOCKET_MARK + " -j ACCEPT >/dev/null 2>&1 || true\n" + + " ipt -D OUTPUT -j " + CHAIN_FILTER + " >/dev/null 2>&1 || true\n" + + " ipt -F " + CHAIN_FILTER + " >/dev/null 2>&1 || true\n" + + " ipt -X " + CHAIN_FILTER + " >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -j " + CHAIN_FILTER + " >/dev/null 2>&1 || true\n" + + " ip6t -F " + CHAIN_FILTER + " >/dev/null 2>&1 || true\n" + + " ip6t -X " + CHAIN_FILTER + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p udp --dport 53 -j " + CHAIN_V4 + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p tcp --dport 53 -j " + CHAIN_V4 + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p udp --dport 53 -j " + CHAIN_V4_PRE + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p tcp --dport 53 -j " + CHAIN_V4_PRE + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -F " + CHAIN_V4 + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -F " + CHAIN_V4_PRE + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -X " + CHAIN_V4 + " >/dev/null 2>&1 || true\n" + + " ipt -t nat -X " + CHAIN_V4_PRE + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p udp --dport 53 -j " + CHAIN_V6 + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p tcp --dport 53 -j " + CHAIN_V6 + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p udp --dport 53 -j " + CHAIN_V6_PRE + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p tcp --dport 53 -j " + CHAIN_V6_PRE + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F " + CHAIN_V6 + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F " + CHAIN_V6_PRE + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X " + CHAIN_V6 + " >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X " + CHAIN_V6_PRE + " >/dev/null 2>&1 || true\n" + + " if command -v nft >/dev/null 2>&1; then nft delete table ip " + NFT_TABLE_V4 + " >/dev/null 2>&1 || true; nft delete table ip6 " + NFT_TABLE_V6 + " >/dev/null 2>&1 || true; fi\n" + + "}\n" + + "remove_root_copies() {\n" + + " log_msg 'Magisk wrapper removing legacy AFWall DNS startup hooks'\n" + + " for FILE in /data/adb/service.d/" + BOOT_SCRIPT + + " /su/su.d/" + BOOT_SCRIPT + + " /system/su.d/" + BOOT_SCRIPT + + " /system/etc/init.d/" + BOOT_SCRIPT + + " /data/adb/service.d/" + CLEANUP_SCRIPT + + " /su/su.d/" + CLEANUP_SCRIPT + + " /system/su.d/" + CLEANUP_SCRIPT + + " /system/etc/init.d/" + CLEANUP_SCRIPT + + "; do [ -e \"$FILE\" ] && rm -f \"$FILE\" 2>/dev/null || true; done\n" + + "}\n" + + "cleanup_service_state() {\n" + + " if [ -n \"${MARKER:-}\" ]; then rm -f \"$MARKER\" 2>/dev/null || true; fi\n" + + " stop_daemon\n" + + " cleanup_redirects\n" + + " remove_root_copies\n" + + "}\n"; + } + + private static String buildConfig(Context context, String controlToken) { + File dir = workDir(context); + StringBuilder config = new StringBuilder(); + G.pruneExpiredDnsHijackTemporaryRules(); + config.append("port=").append(G.dnsHijackPort(DEFAULT_PORT)).append('\n'); + config.append("control_socket=").append(new File(dir, SOCKET).getAbsolutePath()).append('\n'); + config.append("control_socket_uid=").append(context.getApplicationInfo().uid).append('\n'); + config.append("control_token=").append(controlToken).append('\n'); + config.append("control_adb_debug=").append(G.dnsHijackAdbDebugControl() ? "1" : "0") + .append('\n'); + config.append("control_tcp_port=").append(G.dnsHijackAdbDebugControl() + ? debugControlTcpPort(G.dnsHijackPort(DEFAULT_PORT)) + : 0).append('\n'); + config.append("pid_file=").append(new File(dir, PID).getAbsolutePath()).append('\n'); + config.append("heartbeat_file=").append(new File(dir, HEARTBEAT).getAbsolutePath()).append('\n'); + config.append("mark_status_file=").append(new File(dir, MARK_STATUS).getAbsolutePath()).append('\n'); + config.append("iptables_path=").append(Api.getBinaryPath(context, false)).append('\n'); + config.append("ip6tables_path=").append(Api.getBinaryPath(context, true)).append('\n'); + config.append("event_log_file=") + .append(new File(dir, DAEMON_EVENT_LOG).getAbsolutePath()).append('\n'); + config.append("log_file=").append(new File(dir, QUERY_LOG).getAbsolutePath()).append('\n'); + config.append("cache_file=").append(new File(dir, "cache.snapshot").getAbsolutePath()).append('\n'); + config.append("fail_open=").append(G.dnsHijackFailOpen() ? "1" : "0").append('\n'); + config.append("strict_mode=").append(G.dnsHijackStrictMode() ? "1" : "0").append('\n'); + config.append("timeout_ms=").append(G.dnsHijackTimeoutMs()).append('\n'); + config.append("cache_size=").append(G.dnsHijackCacheSize()).append('\n'); + config.append("stale_cache_seconds=").append(G.dnsHijackStaleCacheSeconds()).append('\n'); + config.append("persist_cache=").append(G.dnsHijackPersistCache() ? "1" : "0").append('\n'); + config.append("query_logging=").append(G.dnsHijackQueryLogging() ? "1" : "0").append('\n'); + config.append("persist_query_logs=").append(G.dnsHijackPersistQueryLogs() ? "1" : "0").append('\n'); + config.append("dnssec_request=").append(G.dnsHijackDnssecRequest() ? "1" : "0").append('\n'); + config.append("dnssec_auth_required=") + .append(G.dnsHijackDnssecAuthRequired() ? "1" : "0").append('\n'); + appendSafeSearchConfigEntries(context, config); + + appendResolvedUpstreamConfigEntries(context, config, "upstream", G.dnsHijackUpstreams()); + appendResolvedSplitUpstreamConfigEntries(context, config, G.dnsHijackSplitUpstreams()); + appendConfigEntries(config, "allow_exact", G.dnsHijackAllowExact()); + appendConfigEntries(config, "allow_suffix", G.dnsHijackAllowSuffix()); + appendConfigEntries(config, "block_exact", G.dnsHijackBlockExact()); + appendConfigEntries(config, "block_suffix", G.dnsHijackBlockSuffix()); + appendConfigEntries(config, "app_allow_exact", G.dnsHijackAppAllowExact()); + appendConfigEntries(config, "app_block_exact", G.dnsHijackAppBlockExact()); + appendConfigEntries(config, "app_allow_suffix", G.dnsHijackAppAllowSuffix()); + appendConfigEntries(config, "app_block_suffix", G.dnsHijackAppBlockSuffix()); + appendConfigEntries(config, "network_allow", G.dnsHijackNetworkAllow()); + appendConfigEntries(config, "network_block", G.dnsHijackNetworkBlock()); + appendConfigEntries(config, "allow_regex", G.dnsHijackAllowRegex()); + appendConfigEntries(config, "block_regex", G.dnsHijackBlockRegex()); + appendConfigEntries(config, "temp_allow", G.dnsHijackTempAllow()); + appendConfigEntries(config, "temp_block", G.dnsHijackTempBlock()); + appendConfigFile(config, "block_exact_file", DnsBlocklistManager.exactBlockFile(context)); + appendConfigFile(config, "block_suffix_file", DnsBlocklistManager.suffixBlockFile(context)); + + return config.toString(); + } + + private static int debugControlTcpPort(int dnsPort) { + return dnsPort >= 65535 ? dnsPort - 1 : dnsPort + 1; + } + + private static void appendSafeSearchConfigEntries(Context context, StringBuilder config) { + boolean enabled = G.dnsHijackSafeSearch(); + config.append("safe_search=").append(enabled ? "1" : "0").append('\n'); + if (!enabled) { + return; + } + appendSafeSearchProvider(context, config, "google", "forcesafesearch.google.com", + "216.239.38.120", "2001:4860:4802:32::78"); + appendSafeSearchProvider(context, config, "youtube", "restrict.youtube.com", + "216.239.38.120", "2001:4860:4802:32::78"); + appendSafeSearchProvider(context, config, "bing", "strict.bing.com", + "204.79.197.220"); + appendSafeSearchProvider(context, config, "duckduckgo", "safe.duckduckgo.com"); + } + + private static void appendSafeSearchProvider(Context context, StringBuilder config, + String provider, String targetHost, + String... fallbackAddresses) { + List addresses = resolveWithBootstrap(context, targetHost); + if (addresses.isEmpty()) { + for (String fallback : fallbackAddresses) { + if (fallback != null && !fallback.trim().isEmpty() && !addresses.contains(fallback)) { + addresses.add(fallback); + } + } + } + if (addresses.isEmpty()) { + ApplicationErrorLog.add(context, "DNS SafeSearch target could not be resolved: " + targetHost); + return; + } + for (String address : addresses) { + config.append("safe_search_address=").append(provider).append('|') + .append(address).append('\n'); + } + } + + private static void appendConfigFile(StringBuilder config, String key, File file) { + if (file.exists() && file.length() > 0) { + config.append(key).append('=').append(file.getAbsolutePath()).append('\n'); + } + } + + private static void appendConfigEntries(StringBuilder config, String key, String raw) { + if (raw == null) { + return; + } + String[] lines = raw.split("[\\r\\n,]+"); + for (String line : lines) { + String value = line == null ? "" : line.trim(); + if (value.isEmpty() || value.startsWith("#")) { + continue; + } + config.append(key).append('=').append(value.toLowerCase(Locale.US)).append('\n'); + } + } + + private static void appendResolvedUpstreamConfigEntries(Context context, StringBuilder config, + String key, String raw) { + if (raw == null) { + return; + } + String[] lines = raw.split("[\\r\\n,]+"); + for (String line : lines) { + String value = line == null ? "" : line.trim(); + if (value.isEmpty() || value.startsWith("#")) { + continue; + } + for (String resolved : resolveUpstreamValue(context, value)) { + config.append(key).append('=').append(resolved).append('\n'); + } + } + } + + private static void appendResolvedSplitUpstreamConfigEntries(Context context, StringBuilder config, + String raw) { + if (raw == null) { + return; + } + String[] lines = raw.split("\\r?\\n"); + for (String line : lines) { + String value = line == null ? "" : line.trim(); + int separator = findSplitSeparator(value); + if (value.isEmpty() || value.startsWith("#") || separator <= 0) { + continue; + } + String suffix = value.substring(0, separator).trim().toLowerCase(Locale.US); + String upstream = value.substring(separator + 1).trim(); + if (upstream.isEmpty()) { + continue; + } + for (String resolved : resolveUpstreamValue(context, upstream)) { + config.append("split_upstream=").append(suffix).append('=') + .append(resolved).append('\n'); + } + } + } + + private static int findSplitSeparator(String value) { + int separator = value.indexOf('|'); + if (separator < 0) { + separator = value.indexOf('='); + } + if (separator < 0) { + for (int i = 0; i < value.length(); i++) { + if (Character.isWhitespace(value.charAt(i))) { + return i; + } + } + } + return separator; + } + + private static List resolveUpstreamValue(Context context, String value) { + List resolvedValues = new ArrayList<>(); + UpstreamTarget target = UpstreamTarget.parse(value); + if (target == null) { + ApplicationErrorLog.add(context, "DNS upstream entry ignored because it is invalid or unsupported: " + + safeLogValue(value)); + return resolvedValues; + } + if (target.hasLiteralHost()) { + resolvedValues.add(target.toConfigValue()); + return resolvedValues; + } + + List addresses = resolveWithBootstrap(context, target.host); + if (addresses.isEmpty()) { + ApplicationErrorLog.add(context, "DNS bootstrap resolution failed for upstream " + target.host); + resolvedValues.add(target.toConfigValue()); + return resolvedValues; + } + for (String address : addresses) { + resolvedValues.add(new UpstreamTarget(address, target.port, target.protocol).toConfigValue()); + } + return resolvedValues; + } + + private static List resolveWithBootstrap(Context context, String host) { + List addresses = new ArrayList<>(); + List bootstrapTargets = parseUpstreamTargets(G.dnsHijackBootstrapUpstreams()); + if (bootstrapTargets.isEmpty()) { + bootstrapTargets = parseUpstreamTargets("1.1.1.1:53\n8.8.8.8:53"); + } + for (UpstreamTarget bootstrap : bootstrapTargets) { + if (!bootstrap.hasLiteralHost()) { + continue; + } + addResolvedAddresses(addresses, queryBootstrap(bootstrap, host, 1), 1); + addResolvedAddresses(addresses, queryBootstrap(bootstrap, host, 28), 28); + if (!addresses.isEmpty()) { + ApplicationErrorLog.add(context, "DNS bootstrap resolved " + host + + " using " + bootstrap.toConfigValue() + " addresses=" + addresses.size()); + break; + } + } + return addresses; + } + + private static byte[] queryBootstrap(UpstreamTarget bootstrap, String host, int qtype) { + byte[] query = buildDnsLookupQuery(host, qtype); + if (query == null) { + return null; + } + if ("tcp".equals(bootstrap.protocol)) { + return queryBootstrapTcp(bootstrap, query); + } + return queryBootstrapUdp(bootstrap, query); + } + + private static byte[] queryBootstrapUdp(UpstreamTarget bootstrap, byte[] query) { + int timeoutMs = Math.min(Math.max(G.dnsHijackTimeoutMs(), 250), 1500); + try (DatagramSocket socket = new DatagramSocket()) { + socket.setSoTimeout(timeoutMs); + InetAddress address = InetAddress.getByName(bootstrap.host); + DatagramPacket request = new DatagramPacket(query, query.length, address, bootstrap.port); + socket.send(request); + byte[] response = new byte[1500]; + DatagramPacket reply = new DatagramPacket(response, response.length); + socket.receive(reply); + byte[] copy = new byte[reply.getLength()]; + System.arraycopy(response, 0, copy, 0, reply.getLength()); + return copy; + } catch (IOException | RuntimeException e) { + return null; + } + } + + private static byte[] queryBootstrapTcp(UpstreamTarget bootstrap, byte[] query) { + int timeoutMs = Math.min(Math.max(G.dnsHijackTimeoutMs(), 250), 1500); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(InetAddress.getByName(bootstrap.host), bootstrap.port), timeoutMs); + socket.setSoTimeout(timeoutMs); + OutputStream output = socket.getOutputStream(); + output.write((query.length >> 8) & 0xff); + output.write(query.length & 0xff); + output.write(query); + output.flush(); + InputStream input = socket.getInputStream(); + int high = input.read(); + int low = input.read(); + if (high < 0 || low < 0) { + return null; + } + int expected = (high << 8) | low; + if (expected <= 0 || expected > 4096) { + return null; + } + byte[] response = new byte[expected]; + int read = readFully(input, response, expected); + return read == expected ? response : null; + } catch (IOException | RuntimeException e) { + return null; + } + } + + private static byte[] buildDnsLookupQuery(String host, int qtype) { + String clean = host == null ? "" : host.trim().toLowerCase(Locale.US); + if (clean.isEmpty()) { + return null; + } + ByteArrayOutputStream query = new ByteArrayOutputStream(); + int id = (int) (System.currentTimeMillis() & 0xffff); + query.write((id >> 8) & 0xff); + query.write(id & 0xff); + query.write(0x01); + query.write(0x00); + query.write(0x00); + query.write(0x01); + query.write(0x00); + query.write(0x00); + query.write(0x00); + query.write(0x00); + query.write(0x00); + query.write(0x00); + String[] labels = clean.split("\\."); + for (String label : labels) { + if (label.isEmpty() || label.length() > 63) { + return null; + } + byte[] labelBytes = label.getBytes(StandardCharsets.US_ASCII); + query.write(labelBytes.length); + query.write(labelBytes, 0, labelBytes.length); + } + query.write(0x00); + query.write((qtype >> 8) & 0xff); + query.write(qtype & 0xff); + query.write(0x00); + query.write(0x01); + return query.toByteArray(); + } + + private static String safeLogValue(String raw) { + String clean = raw == null ? "" : raw.trim().replace('\n', ' ').replace('\r', ' '); + clean = clean.replaceAll("\\s+", " "); + if (clean.length() > 120) { + clean = clean.substring(0, 120); + } + return clean.isEmpty() ? "empty" : clean; + } + + private static void addResolvedAddresses(List addresses, byte[] response, int qtype) { + if (response == null || response.length < 12) { + return; + } + int qdCount = readU16(response, 4); + int anCount = readU16(response, 6); + int offset = 12; + for (int i = 0; i < qdCount; i++) { + offset = skipDnsName(response, offset); + if (offset < 0 || offset + 4 > response.length) { + return; + } + offset += 4; + } + for (int i = 0; i < anCount; i++) { + offset = skipDnsName(response, offset); + if (offset < 0 || offset + 10 > response.length) { + return; + } + int type = readU16(response, offset); + int clazz = readU16(response, offset + 2); + int rdLength = readU16(response, offset + 8); + offset += 10; + if (offset + rdLength > response.length) { + return; + } + if (clazz == 1 && type == qtype && (rdLength == 4 || rdLength == 16)) { + byte[] raw = new byte[rdLength]; + System.arraycopy(response, offset, raw, 0, rdLength); + try { + String address = InetAddress.getByAddress(raw).getHostAddress(); + if (!addresses.contains(address)) { + addresses.add(address); + } + } catch (IOException ignored) { + } + } + offset += rdLength; + } + } + + private static int skipDnsName(byte[] packet, int offset) { + int jumps = 0; + while (offset >= 0 && offset < packet.length && jumps < 128) { + int length = packet[offset] & 0xff; + if (length == 0) { + return offset + 1; + } + if ((length & 0xc0) == 0xc0) { + return offset + 2; + } + offset += length + 1; + jumps++; + } + return -1; + } + + private static int readU16(byte[] packet, int offset) { + if (offset < 0 || offset + 1 >= packet.length) { + return 0; + } + return ((packet[offset] & 0xff) << 8) | (packet[offset + 1] & 0xff); + } + + private static String buildBootNftFallbackRestore(boolean ipv6, int port) { + String tool = ipv6 ? "ip6t" : "ipt"; + String chainVariable = ipv6 ? "$CHAIN6" : "$CHAIN4"; + String preChainVariable = ipv6 ? "$PRE6" : "$PRE4"; + String family = ipv6 ? "ip6" : "ip"; + String table = ipv6 ? NFT_TABLE_V6 : NFT_TABLE_V4; + return " if command -v nft >/dev/null 2>&1; then\n" + + " if ! ( " + buildBootIptablesRedirectHealthyCondition(tool, + chainVariable, preChainVariable, port) + " ); then\n" + + " log_msg 'DNS nftables fallback restore starting for " + family + "'\n" + + " ( " + buildNftRuntimeRestoreCommands(family, table, + String.valueOf(port), "\"$MARK_STATUS\"") + " ) >> \"$LOG\" 2>&1\n" + + " else\n" + + " nft delete table " + family + " " + table + " >/dev/null 2>&1 || true\n" + + " fi\n" + + " fi\n"; + } + + private static String buildBootIptablesRedirectHealthyCondition(String tool, String chainVariable, + String preChainVariable, + int port) { + List checks = new ArrayList<>(); + checks.add(buildBootIptablesRuleCheck(tool, "OUTPUT", "udp", chainVariable)); + checks.add(buildBootIptablesRuleCheck(tool, "OUTPUT", "tcp", chainVariable)); + if (preroutingRedirectExpected()) { + checks.add(buildBootIptablesRuleCheck(tool, "PREROUTING", "udp", preChainVariable)); + checks.add(buildBootIptablesRuleCheck(tool, "PREROUTING", "tcp", preChainVariable)); + } + checks.add(buildBootIptablesRecursionGuardCheck(tool, chainVariable)); + checks.add(buildBootIptablesDaemonPathFilterCheck(tool, chainVariable)); + checks.add(buildBootIptablesRedirectTargetCheck(tool, chainVariable, "udp", port)); + checks.add(buildBootIptablesRedirectTargetCheck(tool, chainVariable, "tcp", port)); + if (preroutingRedirectExpected()) { + checks.add(buildBootIptablesRedirectTargetCheck(tool, preChainVariable, "udp", port)); + checks.add(buildBootIptablesRedirectTargetCheck(tool, preChainVariable, "tcp", port)); + } + return joinShellChecks(checks); + } + + private static String buildBootIptablesRuleCheck(String tool, String parentChain, + String protocol, String targetChainVariable) { + return tool + " -t nat -S " + parentChain + + " 2>/dev/null | grep -q -- \"-p " + protocol + + " .*--dport 53.*-j " + targetChainVariable + "\""; + } + + private static String buildBootIptablesRecursionGuardCheck(String tool, + String chainVariable) { + return "( " + buildBootIptablesDaemonMarkReturnCheck(tool, chainVariable) + + " || " + buildBootIptablesUid0ReturnCheck(tool, chainVariable) + " )"; + } + + private static String buildBootIptablesDaemonPathFilterCheck(String tool, + String chainVariable) { + return "( ( " + buildBootIptablesDaemonMarkReturnCheck(tool, chainVariable) + + " && " + buildBootIptablesDaemonMarkFilterAcceptCheck(tool) + + " ) || " + buildBootIptablesUid0ReturnCheck(tool, chainVariable) + " )"; + } + + private static String buildBootIptablesDaemonMarkReturnCheck(String tool, + String chainVariable) { + return tool + " -t nat -S \"" + chainVariable + "\"" + + " 2>/dev/null | grep -q -- \"-m mark .*--mark " + + DAEMON_SOCKET_MARK + ".*-j RETURN\""; + } + + private static String buildBootIptablesDaemonMarkFilterAcceptCheck(String tool) { + return tool + " -S OUTPUT" + + " 2>/dev/null | grep -q -- \"-m mark .*--mark " + + DAEMON_SOCKET_MARK + ".*-j ACCEPT\""; + } + + private static String buildBootIptablesUid0ReturnCheck(String tool, String chainVariable) { + return tool + " -t nat -S \"" + chainVariable + "\"" + + " 2>/dev/null | grep -q -- \"-m owner .*--uid-owner 0.*-j RETURN\""; + } + + private static String buildBootIptablesRedirectTargetCheck(String tool, String chainVariable, + String protocol, int port) { + return tool + " -t nat -S \"" + chainVariable + "\"" + + " 2>/dev/null | grep -q -- \"-p " + protocol + + " .*--dport 53.*-j REDIRECT.*--to-ports " + port + "\""; + } + + private static String buildLifecycleCleanupScript(Context context, File dir) { + String supervisor = new File(dir, SUPERVISOR).getAbsolutePath(); + String marker = new File(dir, ENABLED_MARKER).getAbsolutePath(); + String pid = new File(dir, PID).getAbsolutePath(); + String supervisorPid = new File(dir, SUPERVISOR_PID).getAbsolutePath(); + String appLog = new File(dir, CLEANUP_LOG).getAbsolutePath(); + String iptables = Api.getBinaryPath(context, false); + String ip6tables = Api.getBinaryPath(context, true); + String packageName = context.getPackageName(); + + return "#!/system/bin/sh\n" + + "AFWALL_DNS_MODULE_SCRIPT_VERSION=" + MAGISK_SCRIPT_VERSION + "\n" + + "PATH=/system/bin:/system/xbin:/vendor/bin:/sbin:/su/bin:/data/adb/magisk:$PATH\n" + + "PACKAGE=" + shellQuote(packageName) + "\n" + + "DIR=" + shellQuote(dir.getAbsolutePath()) + "\n" + + "SUPERVISOR=" + shellQuote(supervisor) + "\n" + + "MARKER=" + shellQuote(marker) + "\n" + + "PID_FILE=" + shellQuote(pid) + "\n" + + "SUP_PID=" + shellQuote(supervisorPid) + "\n" + + "APP_LOG=" + shellQuote(appLog) + "\n" + + "FALLBACK_LOG=/data/local/tmp/" + CLEANUP_LOG + "\n" + + "IPTABLES=" + shellQuote(iptables) + "\n" + + "IP6TABLES=" + shellQuote(ip6tables) + "\n" + + "CHAIN4=" + CHAIN_V4 + "\n" + + "PRE4=" + CHAIN_V4_PRE + "\n" + + "CHAIN6=" + CHAIN_V6 + "\n" + + "PRE6=" + CHAIN_V6_PRE + "\n" + + "FILTER=" + CHAIN_FILTER + "\n" + + "if [ -d \"$DIR\" ]; then LOG=\"$APP_LOG\"; else LOG=\"$FALLBACK_LOG\"; fi\n" + + "log_msg() {\n" + + " echo \"$(date +%s) $*\" >> \"$LOG\" 2>/dev/null || true\n" + + " chmod 644 \"$LOG\" 2>/dev/null || true\n" + + "}\n" + + "app_installed() {\n" + + " pm path \"$PACKAGE\" >/dev/null 2>&1\n" + + "}\n" + + "ipt() {\n" + + " if [ -x \"$IPTABLES\" ]; then \"$IPTABLES\" \"$@\"; elif command -v iptables >/dev/null 2>&1; then iptables \"$@\"; else return 0; fi\n" + + "}\n" + + "ip6t() {\n" + + " if [ -x \"$IP6TABLES\" ]; then \"$IP6TABLES\" \"$@\"; elif command -v ip6tables >/dev/null 2>&1; then ip6tables \"$@\"; else return 0; fi\n" + + "}\n" + + "cleanup_redirects() {\n" + + " log_msg 'cleanup guard removing stale DNS redirect rules'\n" + + " ipt -D OUTPUT -m mark --mark " + DAEMON_SOCKET_MARK + " -j ACCEPT >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -m mark --mark " + DAEMON_SOCKET_MARK + " -j ACCEPT >/dev/null 2>&1 || true\n" + + " ipt -D OUTPUT -j \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -F \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -X \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -j \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -F \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -X \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -F \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -F \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -X \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -X \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X \"$PRE6\" >/dev/null 2>&1 || true\n" + + " if command -v nft >/dev/null 2>&1; then nft delete table ip " + NFT_TABLE_V4 + " >/dev/null 2>&1 || true; nft delete table ip6 " + NFT_TABLE_V6 + " >/dev/null 2>&1 || true; fi\n" + + "}\n" + + "stop_daemon() {\n" + + " if [ -x \"$SUPERVISOR\" ]; then \"$SUPERVISOR\" stop >> \"$LOG\" 2>&1 || true; fi\n" + + " if [ -f \"$PID_FILE\" ]; then kill -TERM \"$(cat \"$PID_FILE\")\" 2>/dev/null || true; fi\n" + + " if [ -f \"$SUP_PID\" ]; then kill -TERM \"$(cat \"$SUP_PID\")\" 2>/dev/null || true; fi\n" + + " if command -v pidof >/dev/null 2>&1; then for p in $(pidof " + DAEMON_NAME + " 2>/dev/null); do kill -TERM \"$p\" 2>/dev/null || true; done; fi\n" + + "}\n" + + "remove_root_copies() {\n" + + " for FILE in /data/adb/service.d/" + BOOT_SCRIPT + + " /su/su.d/" + BOOT_SCRIPT + + " /system/su.d/" + BOOT_SCRIPT + + " /system/etc/init.d/" + BOOT_SCRIPT + + " /data/adb/service.d/" + CLEANUP_SCRIPT + + " /su/su.d/" + CLEANUP_SCRIPT + + " /system/su.d/" + CLEANUP_SCRIPT + + " /system/etc/init.d/" + CLEANUP_SCRIPT + + "; do [ -e \"$FILE\" ] && rm -f \"$FILE\" 2>/dev/null || true; done\n" + + "}\n" + + "# Android does not guarantee that app code runs during its own uninstall.\n" + + "# This guard lets root clean stale DNS capture state on the next startup.\n" + + "if app_installed && [ -d \"$DIR\" ] && [ -x \"$SUPERVISOR\" ] && [ -f \"$MARKER\" ]; then\n" + + " log_msg 'cleanup guard found active service marker; leaving DNS service installed'\n" + + " exit 0\n" + + "fi\n" + + "log_msg 'cleanup guard found missing app package or stale service marker; removing DNS service state'\n" + + "rm -f \"$MARKER\" 2>/dev/null || true\n" + + "stop_daemon\n" + + "cleanup_redirects\n" + + "remove_root_copies\n" + + "log_msg 'cleanup guard complete'\n"; + } + + private static String buildBootScript(Context context, File dir) { + String supervisor = new File(dir, SUPERVISOR).getAbsolutePath(); + String marker = new File(dir, ENABLED_MARKER).getAbsolutePath(); + String appLog = new File(dir, BOOT_LOG).getAbsolutePath(); + String markStatus = new File(dir, MARK_STATUS).getAbsolutePath(); + String iptables = Api.getBinaryPath(context, false); + String ip6tables = Api.getBinaryPath(context, true); + int port = G.dnsHijackPort(DEFAULT_PORT); + String ipv6Enabled = G.enableIPv6() ? "1" : "0"; + String packageName = context.getPackageName(); + + return "#!/system/bin/sh\n" + + "AFWALL_DNS_MODULE_SCRIPT_VERSION=" + MAGISK_SCRIPT_VERSION + "\n" + + "PATH=/system/bin:/system/xbin:/vendor/bin:/sbin:/su/bin:/data/adb/magisk:$PATH\n" + + "PACKAGE=" + shellQuote(packageName) + "\n" + + "DIR=" + shellQuote(dir.getAbsolutePath()) + "\n" + + "SUPERVISOR=" + shellQuote(supervisor) + "\n" + + "MARKER=" + shellQuote(marker) + "\n" + + "IPTABLES=" + shellQuote(iptables) + "\n" + + "IP6TABLES=" + shellQuote(ip6tables) + "\n" + + "PORT=" + port + "\n" + + "DAEMON_MARK=" + DAEMON_SOCKET_MARK + "\n" + + "MARK_STATUS=" + shellQuote(markStatus) + "\n" + + "IPV6_ENABLED=" + ipv6Enabled + "\n" + + "APP_LOG=" + shellQuote(appLog) + "\n" + + "FALLBACK_LOG=/data/local/tmp/" + BOOT_LOG + "\n" + + "CHAIN4=" + CHAIN_V4 + "\n" + + "PRE4=" + CHAIN_V4_PRE + "\n" + + "CHAIN6=" + CHAIN_V6 + "\n" + + "PRE6=" + CHAIN_V6_PRE + "\n" + + "FILTER=" + CHAIN_FILTER + "\n" + + "if [ -d \"$DIR\" ]; then LOG=\"$APP_LOG\"; else LOG=\"$FALLBACK_LOG\"; fi\n" + + "log_msg() {\n" + + " echo \"$(date +%s) $*\" >> \"$LOG\" 2>/dev/null || true\n" + + " chmod 644 \"$LOG\" 2>/dev/null || true\n" + + "}\n" + + "app_installed() {\n" + + " pm path \"$PACKAGE\" >/dev/null 2>&1\n" + + "}\n" + + "ipt() {\n" + + " if [ -x \"$IPTABLES\" ]; then \"$IPTABLES\" \"$@\"; else iptables \"$@\"; fi\n" + + "}\n" + + "ip6t() {\n" + + " if [ -x \"$IP6TABLES\" ]; then \"$IP6TABLES\" \"$@\"; elif command -v ip6tables >/dev/null 2>&1; then ip6tables \"$@\"; else return 0; fi\n" + + "}\n" + + "cleanup_redirects() {\n" + + " log_msg 'DNS boot cleanup removing stale redirect rules'\n" + + " ipt -D OUTPUT -m mark --mark \"$DAEMON_MARK\" -j ACCEPT >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -m mark --mark \"$DAEMON_MARK\" -j ACCEPT >/dev/null 2>&1 || true\n" + + " ipt -D OUTPUT -j \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -F \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -X \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -j \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -F \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -X \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -F \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -F \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -X \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -X \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X \"$PRE6\" >/dev/null 2>&1 || true\n" + + " if command -v nft >/dev/null 2>&1; then nft delete table ip " + NFT_TABLE_V4 + " >/dev/null 2>&1 || true; nft delete table ip6 " + NFT_TABLE_V6 + " >/dev/null 2>&1 || true; fi\n" + + "}\n" + + "remove_boot_copy() {\n" + + " for FILE in /data/adb/service.d/" + BOOT_SCRIPT + + " /su/su.d/" + BOOT_SCRIPT + + " /system/su.d/" + BOOT_SCRIPT + + " /system/etc/init.d/" + BOOT_SCRIPT + + " /data/adb/service.d/" + CLEANUP_SCRIPT + + " /su/su.d/" + CLEANUP_SCRIPT + + " /system/su.d/" + CLEANUP_SCRIPT + + " /system/etc/init.d/" + CLEANUP_SCRIPT + + "; do [ -e \"$FILE\" ] && rm -f \"$FILE\" 2>/dev/null || true; done\n" + + "}\n" + + "cleanup_stale_install() {\n" + + " log_msg 'DNS boot script found missing app package or app-owned service files; self-cleaning'\n" + + " rm -f \"$MARKER\" 2>/dev/null || true\n" + + " cleanup_redirects\n" + + " remove_boot_copy\n" + + "}\n" + + "restore_v4() {\n" + + buildBootDaemonFilterBypass("ipt") + + " ipt -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -N \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -N \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -F \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -F \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -A \"$CHAIN4\" -o lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootDaemonRecursionBypass("ipt", "$CHAIN4") + + buildBootOutputRedirectRules("ipt", "$CHAIN4") + + " ipt -t nat -A \"$PRE4\" -i lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootPreroutingRedirectRules("ipt", "$PRE4") + + " ipt -t nat -I OUTPUT 1 -p udp --dport 53 -j \"$CHAIN4\" >> \"$LOG\" 2>&1\n" + + " ipt -t nat -I OUTPUT 1 -p tcp --dport 53 -j \"$CHAIN4\" >> \"$LOG\" 2>&1\n" + + " ipt -t nat -I PREROUTING 1 -p udp --dport 53 -j \"$PRE4\" >> \"$LOG\" 2>&1\n" + + " ipt -t nat -I PREROUTING 1 -p tcp --dport 53 -j \"$PRE4\" >> \"$LOG\" 2>&1\n" + + buildBootNftFallbackRestore(false, port) + + "}\n" + + "restore_v6() {\n" + + buildBootDaemonFilterBypass("ip6t") + + " ip6t -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -N \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -N \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -F \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -F \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -A \"$CHAIN6\" -o lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootDaemonRecursionBypass("ip6t", "$CHAIN6") + + buildBootOutputRedirectRules("ip6t", "$CHAIN6") + + " ip6t -t nat -A \"$PRE6\" -i lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootPreroutingRedirectRules("ip6t", "$PRE6") + + " ip6t -t nat -I OUTPUT 1 -p udp --dport 53 -j \"$CHAIN6\" >> \"$LOG\" 2>&1\n" + + " ip6t -t nat -I OUTPUT 1 -p tcp --dport 53 -j \"$CHAIN6\" >> \"$LOG\" 2>&1\n" + + " ip6t -t nat -I PREROUTING 1 -p udp --dport 53 -j \"$PRE6\" >> \"$LOG\" 2>&1\n" + + " ip6t -t nat -I PREROUTING 1 -p tcp --dport 53 -j \"$PRE6\" >> \"$LOG\" 2>&1\n" + + buildBootNftFallbackRestore(true, port) + + "}\n" + + "log_msg 'DNS boot restore starting'\n" + + "sleep 15\n" + + "if ! app_installed || [ ! -d \"$DIR\" ] || [ ! -x \"$SUPERVISOR\" ] || [ ! -f \"$MARKER\" ]; then cleanup_stale_install; exit 0; fi\n" + + "if \"$SUPERVISOR\" start >> \"$LOG\" 2>&1; then\n" + + " log_msg 'DNS boot restore daemon ready; refreshing redirect rules'\n" + + " restore_v4\n" + + " if [ \"$IPV6_ENABLED\" = 1 ]; then restore_v6; else log_msg 'DNS boot restore skipped IPv6 redirects because IPv6 is disabled'; fi\n" + + " log_msg 'DNS boot restore complete'\n" + + "else\n" + + " log_msg 'DNS boot restore daemon not ready; leaving redirects removed'\n" + + " cleanup_redirects\n" + + " exit 1\n" + + "fi\n"; + } + + private static String buildSupervisorScript(Context context, File dir, File daemon) { + String marker = new File(dir, ENABLED_MARKER).getAbsolutePath(); + String config = new File(dir, CONF).getAbsolutePath(); + String pid = new File(dir, PID).getAbsolutePath(); + String socket = new File(dir, SOCKET).getAbsolutePath(); + String heartbeat = new File(dir, HEARTBEAT).getAbsolutePath(); + String daemonEventLog = new File(dir, DAEMON_EVENT_LOG).getAbsolutePath(); + String log = new File(dir, SUPERVISOR_LOG).getAbsolutePath(); + String supervisorPid = new File(dir, SUPERVISOR_PID).getAbsolutePath(); + String restartCount = new File(dir, RESTART_COUNT).getAbsolutePath(); + String lastExit = new File(dir, LAST_EXIT).getAbsolutePath(); + String markStatus = new File(dir, MARK_STATUS).getAbsolutePath(); + String iptables = Api.getBinaryPath(context, false); + String ip6tables = Api.getBinaryPath(context, true); + int port = G.dnsHijackPort(DEFAULT_PORT); + String ipv6Enabled = G.enableIPv6() ? "1" : "0"; + String packageName = context.getPackageName(); + + return "#!/system/bin/sh\n" + + "AFWALL_DNS_MODULE_SCRIPT_VERSION=" + MAGISK_SCRIPT_VERSION + "\n" + + "PATH=/system/bin:/system/xbin:/vendor/bin:/sbin:/su/bin:/data/adb/magisk:$PATH\n" + + "PACKAGE=" + shellQuote(packageName) + "\n" + + "DIR=" + shellQuote(dir.getAbsolutePath()) + "\n" + + "DAEMON=" + shellQuote(daemon.getAbsolutePath()) + "\n" + + "CONF=" + shellQuote(config) + "\n" + + "PID=" + shellQuote(pid) + "\n" + + "SOCKET=" + shellQuote(socket) + "\n" + + "HEARTBEAT=" + shellQuote(heartbeat) + "\n" + + "DAEMON_LOG=" + shellQuote(daemonEventLog) + "\n" + + "SUP_PID=" + shellQuote(supervisorPid) + "\n" + + "MARKER=" + shellQuote(marker) + "\n" + + "LOG=" + shellQuote(log) + "\n" + + "RESTARTS=" + shellQuote(restartCount) + "\n" + + "LAST_EXIT=" + shellQuote(lastExit) + "\n" + + "IPTABLES=" + shellQuote(iptables) + "\n" + + "IP6TABLES=" + shellQuote(ip6tables) + "\n" + + "PORT=" + port + "\n" + + "DAEMON_MARK=" + DAEMON_SOCKET_MARK + "\n" + + "MARK_STATUS=" + shellQuote(markStatus) + "\n" + + "IPV6_ENABLED=" + ipv6Enabled + "\n" + + "CHAIN4=" + CHAIN_V4 + "\n" + + "PRE4=" + CHAIN_V4_PRE + "\n" + + "CHAIN6=" + CHAIN_V6 + "\n" + + "PRE6=" + CHAIN_V6_PRE + "\n" + + "FILTER=" + CHAIN_FILTER + "\n" + + "log_msg() {\n" + + " echo \"$(date +%s) $*\" >> \"$LOG\" 2>/dev/null\n" + + " chmod 644 \"$LOG\" 2>/dev/null || true\n" + + "}\n" + + "daemon_log_msg() {\n" + + " echo \"$(date +%s) supervisor $*\" >> \"$DAEMON_LOG\" 2>/dev/null\n" + + " chmod 644 \"$DAEMON_LOG\" 2>/dev/null || true\n" + + "}\n" + + "app_installed() {\n" + + " pm path \"$PACKAGE\" >/dev/null 2>&1\n" + + "}\n" + + "ipt() {\n" + + " if [ -x \"$IPTABLES\" ]; then \"$IPTABLES\" \"$@\"; elif command -v iptables >/dev/null 2>&1; then iptables \"$@\"; else return 0; fi\n" + + "}\n" + + "ip6t() {\n" + + " if [ -x \"$IP6TABLES\" ]; then \"$IP6TABLES\" \"$@\"; elif command -v ip6tables >/dev/null 2>&1; then ip6tables \"$@\"; else return 0; fi\n" + + "}\n" + + "cleanup_redirects() {\n" + + " log_msg 'removing DNS redirect rules'\n" + + " ipt -D OUTPUT -m mark --mark " + DAEMON_SOCKET_MARK + " -j ACCEPT >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -m mark --mark " + DAEMON_SOCKET_MARK + " -j ACCEPT >/dev/null 2>&1 || true\n" + + " ipt -D OUTPUT -j \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -F \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -X \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -D OUTPUT -j \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -F \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ip6t -X \"$FILTER\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -F \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -F \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -X \"$CHAIN4\" >/dev/null 2>&1 || true\n" + + " ipt -t nat -X \"$PRE4\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -F \"$PRE6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X \"$CHAIN6\" >/dev/null 2>&1 || true\n" + + " ip6t -t nat -X \"$PRE6\" >/dev/null 2>&1 || true\n" + + " if command -v nft >/dev/null 2>&1; then nft delete table ip " + NFT_TABLE_V4 + " >/dev/null 2>&1 || true; nft delete table ip6 " + NFT_TABLE_V6 + " >/dev/null 2>&1 || true; fi\n" + + "}\n" + + "restore_v4() {\n" + + buildBootDaemonFilterBypass("ipt") + + " ipt -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -N \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -N \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -F \"$CHAIN4\" >/dev/null 2>&1\n" + + " ipt -t nat -F \"$PRE4\" >/dev/null 2>&1\n" + + " ipt -t nat -A \"$CHAIN4\" -o lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootDaemonRecursionBypass("ipt", "$CHAIN4") + + buildBootOutputRedirectRules("ipt", "$CHAIN4") + + " ipt -t nat -A \"$PRE4\" -i lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootPreroutingRedirectRules("ipt", "$PRE4") + + " ipt -t nat -I OUTPUT 1 -p udp --dport 53 -j \"$CHAIN4\" >> \"$LOG\" 2>&1\n" + + " ipt -t nat -I OUTPUT 1 -p tcp --dport 53 -j \"$CHAIN4\" >> \"$LOG\" 2>&1\n" + + " ipt -t nat -I PREROUTING 1 -p udp --dport 53 -j \"$PRE4\" >> \"$LOG\" 2>&1\n" + + " ipt -t nat -I PREROUTING 1 -p tcp --dport 53 -j \"$PRE4\" >> \"$LOG\" 2>&1\n" + + buildBootNftFallbackRestore(false, port) + + "}\n" + + "restore_v6() {\n" + + buildBootDaemonFilterBypass("ip6t") + + " ip6t -t nat -D OUTPUT -p udp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -D OUTPUT -p tcp --dport 53 -j \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -D PREROUTING -p udp --dport 53 -j \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -D PREROUTING -p tcp --dport 53 -j \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -N \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -N \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -F \"$CHAIN6\" >/dev/null 2>&1\n" + + " ip6t -t nat -F \"$PRE6\" >/dev/null 2>&1\n" + + " ip6t -t nat -A \"$CHAIN6\" -o lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootDaemonRecursionBypass("ip6t", "$CHAIN6") + + buildBootOutputRedirectRules("ip6t", "$CHAIN6") + + " ip6t -t nat -A \"$PRE6\" -i lo -j RETURN >> \"$LOG\" 2>&1\n" + + buildBootPreroutingRedirectRules("ip6t", "$PRE6") + + " ip6t -t nat -I OUTPUT 1 -p udp --dport 53 -j \"$CHAIN6\" >> \"$LOG\" 2>&1\n" + + " ip6t -t nat -I OUTPUT 1 -p tcp --dport 53 -j \"$CHAIN6\" >> \"$LOG\" 2>&1\n" + + " ip6t -t nat -I PREROUTING 1 -p udp --dport 53 -j \"$PRE6\" >> \"$LOG\" 2>&1\n" + + " ip6t -t nat -I PREROUTING 1 -p tcp --dport 53 -j \"$PRE6\" >> \"$LOG\" 2>&1\n" + + buildBootNftFallbackRestore(true, port) + + "}\n" + + "restore_redirects() {\n" + + " log_msg 'restoring DNS redirect rules after daemon ready'\n" + + " restore_v4\n" + + " if [ \"$IPV6_ENABLED\" = 1 ]; then restore_v6; else log_msg 'DNS watchdog skipped IPv6 redirects because IPv6 is disabled'; fi\n" + + "}\n" + + "is_running() {\n" + + " [ -f \"$PID\" ] && kill -0 \"$(cat \"$PID\")\" 2>/dev/null\n" + + "}\n" + + "heartbeat_ok() {\n" + + " [ -f \"$HEARTBEAT\" ] || return 1\n" + + " beat=$(cat \"$HEARTBEAT\" 2>/dev/null || echo 0)\n" + + " case \"$beat\" in *[!0-9]*|'') return 1 ;; esac\n" + + " now=$(date +%s 2>/dev/null || echo 0)\n" + + " case \"$now\" in *[!0-9]*|'') return 1 ;; esac\n" + + " age=$((now - beat))\n" + + " [ \"$age\" -ge 0 ] && [ \"$age\" -le 15 ]\n" + + "}\n" + + "# PID alone is not readiness; DNS redirects need listeners and a fresh event-loop heartbeat.\n" + + "daemon_ready() {\n" + + " is_running && [ -S \"$SOCKET\" ] && heartbeat_ok\n" + + "}\n" + + "wait_ready() {\n" + + " tries=0\n" + + " while [ \"$tries\" -lt 8 ]; do\n" + + " if daemon_ready; then return 0; fi\n" + + " sleep 1\n" + + " tries=$((tries + 1))\n" + + " done\n" + + " return 1\n" + + "}\n" + + "supervisor_running() {\n" + + " [ -f \"$SUP_PID\" ] && kill -0 \"$(cat \"$SUP_PID\")\" 2>/dev/null\n" + + "}\n" + + "increment_restarts() {\n" + + " count=$(cat \"$RESTARTS\" 2>/dev/null || echo 0)\n" + + " case \"$count\" in *[!0-9]*|'') count=0 ;; esac\n" + + " count=$((count + 1))\n" + + " echo \"$count\" > \"$RESTARTS\" 2>/dev/null\n" + + "}\n" + + "watch_loop() {\n" + + " trap 'if [ -f \"$PID\" ]; then kill -TERM \"$(cat \"$PID\")\" 2>/dev/null || true; fi; rm -f \"$SUP_PID\"; exit 0' TERM INT\n" + + " log_msg 'watchdog started'\n" + + " while [ -f \"$MARKER\" ] && app_installed; do\n" + + " if [ ! -x \"$DAEMON\" ]; then\n" + + " echo \"$(date +%s) missing_daemon\" > \"$LAST_EXIT\" 2>/dev/null\n" + + " log_msg 'daemon binary missing or not executable'\n" + + " daemon_log_msg 'daemon binary missing or not executable'\n" + + " cleanup_redirects\n" + + " sleep 5\n" + + " continue\n" + + " fi\n" + + " rm -f \"$PID\" \"$SOCKET\" \"$HEARTBEAT\" 2>/dev/null || true\n" + + " daemon_log_msg 'daemon launch requested'\n" + + " \"$DAEMON\" --config \"$CONF\" >> \"$DAEMON_LOG\" 2>&1 &\n" + + " daemon_pid=$!\n" + + " if ! wait_ready; then\n" + + " echo \"$(date +%s) start_not_ready\" > \"$LAST_EXIT\" 2>/dev/null\n" + + " log_msg 'daemon did not become ready; restarting'\n" + + " daemon_log_msg 'daemon did not become ready; restarting'\n" + + " kill -TERM \"$daemon_pid\" 2>/dev/null || true\n" + + " if [ -f \"$PID\" ]; then kill -TERM \"$(cat \"$PID\")\" 2>/dev/null || true; fi\n" + + " wait \"$daemon_pid\" 2>/dev/null || true\n" + + " cleanup_redirects\n" + + " increment_restarts\n" + + " sleep 2\n" + + " continue\n" + + " fi\n" + + " restore_redirects\n" + + " while [ -f \"$MARKER\" ] && app_installed; do\n" + + " if ! kill -0 \"$daemon_pid\" 2>/dev/null; then\n" + + " wait \"$daemon_pid\" 2>/dev/null\n" + + " exit_code=$?\n" + + " echo \"$(date +%s) exit=$exit_code\" > \"$LAST_EXIT\" 2>/dev/null\n" + + " log_msg \"daemon exited with $exit_code; restarting\"\n" + + " daemon_log_msg \"daemon exited with $exit_code; restarting\"\n" + + " cleanup_redirects\n" + + " increment_restarts\n" + + " break\n" + + " fi\n" + + " if ! daemon_ready; then\n" + + " echo \"$(date +%s) heartbeat_stale\" > \"$LAST_EXIT\" 2>/dev/null\n" + + " log_msg 'daemon heartbeat stale; restarting'\n" + + " daemon_log_msg 'daemon heartbeat stale; restarting'\n" + + " kill -TERM \"$daemon_pid\" 2>/dev/null || true\n" + + " if [ -f \"$PID\" ]; then kill -TERM \"$(cat \"$PID\")\" 2>/dev/null || true; fi\n" + + " sleep 2\n" + + " kill -KILL \"$daemon_pid\" 2>/dev/null || true\n" + + " wait \"$daemon_pid\" 2>/dev/null || true\n" + + " cleanup_redirects\n" + + " increment_restarts\n" + + " break\n" + + " fi\n" + + " sleep 5\n" + + " done\n" + + " if [ ! -f \"$MARKER\" ] || ! app_installed; then\n" + + " log_msg 'app package or enable marker missing; stopping DNS daemon'\n" + + " daemon_log_msg 'app package or enable marker missing; stopping DNS daemon'\n" + + " kill -TERM \"$daemon_pid\" 2>/dev/null || true\n" + + " wait \"$daemon_pid\" 2>/dev/null || true\n" + + " else\n" + + " sleep 2\n" + + " fi\n" + + " done\n" + + " cleanup_redirects\n" + + " log_msg 'watchdog stopped'\n" + + " rm -f \"$SUP_PID\"\n" + + "}\n" + + "start_daemon() {\n" + + " if ! app_installed; then\n" + + " log_msg 'app package missing; refusing to start DNS daemon'\n" + + " daemon_log_msg 'app package missing; refusing to start DNS daemon'\n" + + " cleanup_redirects\n" + + " exit 1\n" + + " fi\n" + + " if [ ! -d \"$DIR\" ]; then\n" + + " log_msg 'app service directory missing; refusing to start DNS daemon'\n" + + " daemon_log_msg 'app service directory missing; refusing to start DNS daemon'\n" + + " cleanup_redirects\n" + + " exit 1\n" + + " fi\n" + + " chmod 700 \"$DIR\" 2>/dev/null || true\n" + + " chmod 755 \"$DAEMON\" 2>/dev/null || true\n" + + " touch \"$DAEMON_LOG\" 2>/dev/null || true\n" + + " chmod 644 \"$DAEMON_LOG\" 2>/dev/null || true\n" + + " touch \"$MARKER\"\n" + + " if daemon_ready; then restore_redirects; exit 0; fi\n" + + " if ! is_running; then rm -f \"$PID\" \"$SOCKET\" \"$HEARTBEAT\" 2>/dev/null || true; fi\n" + + " if supervisor_running; then\n" + + " if wait_ready; then restore_redirects; exit 0; fi\n" + + " log_msg 'watchdog already running but daemon is not ready'\n" + + " exit 1\n" + + " fi\n" + + " ( watch_loop ) >/dev/null 2>&1 &\n" + + " echo \"$!\" > \"$SUP_PID\" 2>/dev/null\n" + + " if wait_ready; then restore_redirects; exit 0; fi\n" + + " echo \"$(date +%s) start_not_ready\" > \"$LAST_EXIT\" 2>/dev/null\n" + + " log_msg 'daemon did not become ready after start request'\n" + + " daemon_log_msg 'daemon did not become ready after start request'\n" + + " exit 1\n" + + "}\n" + + "stop_daemon() {\n" + + " rm -f \"$MARKER\"\n" + + " if [ -f \"$PID\" ]; then kill -TERM \"$(cat \"$PID\")\" 2>/dev/null || true; fi\n" + + " if [ -f \"$SUP_PID\" ]; then kill -TERM \"$(cat \"$SUP_PID\")\" 2>/dev/null || true; fi\n" + + " rm -f \"$SUP_PID\" \"$HEARTBEAT\"\n" + + "}\n" + + "case \"$1\" in\n" + + " start) start_daemon ;;\n" + + " stop) stop_daemon; cleanup_redirects; exit 0 ;;\n" + + " restart) stop_daemon; start_daemon ;;\n" + + " reload) if ! app_installed; then cleanup_redirects; exit 1; fi; if daemon_ready; then kill -HUP \"$(cat \"$PID\")\" 2>/dev/null; restore_redirects; else start_daemon; fi ;;\n" + + " status) \n" + + " if app_installed; then echo app_package=installed; else echo app_package=missing; fi\n" + + " if is_running; then echo \"daemon=running pid=$(cat \"$PID\")\"; else echo daemon=stopped; fi\n" + + " if [ -S \"$SOCKET\" ]; then echo control_socket=ready; else echo control_socket=missing; fi\n" + + " if heartbeat_ok; then echo heartbeat=fresh; else echo heartbeat=stale; fi\n" + + " echo \"heartbeat_value=$(cat \"$HEARTBEAT\" 2>/dev/null || echo none)\"\n" + + " if daemon_ready; then echo readiness=ready; else echo readiness=not_ready; fi\n" + + " if supervisor_running; then echo \"watchdog=running pid=$(cat \"$SUP_PID\")\"; else echo watchdog=stopped; fi\n" + + " echo \"restart_count=$(cat \"$RESTARTS\" 2>/dev/null || echo 0)\"\n" + + " echo \"last_exit=$(cat \"$LAST_EXIT\" 2>/dev/null || echo none)\"\n" + + " if app_installed; then\n" + + " if daemon_ready || supervisor_running; then exit 0; fi\n" + + " fi\n" + + " exit 1 ;;\n" + + " *) echo \"usage: $0 {start|stop|restart|reload|status}\"; exit 2 ;;\n" + + "esac\n"; + } + + private static File workDir(Context context) { + Context appContext = context.getApplicationContext(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + Context deviceContext = appContext.createDeviceProtectedStorageContext(); + if (deviceContext != null) { + // Root boot scripts can run before credential-protected app data is unlocked. + // Keep the daemon, config, control socket, and event logs in device-protected + // storage so boot restore and fail-open cleanup do not depend on user unlock. + return deviceContext.getDir(WORK_DIR, Context.MODE_PRIVATE); + } + } + return appContext.getDir(WORK_DIR, Context.MODE_PRIVATE); + } + + private static File legacyCredentialProtectedWorkDir(Context context) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return null; + } + Context appContext = context.getApplicationContext(); + File legacy = new File(appContext.getApplicationInfo().dataDir, "app_" + WORK_DIR); + if (samePath(legacy, workDir(context))) { + return null; + } + return legacy; + } + + private static boolean samePath(File first, File second) { + return first != null && second != null + && first.getAbsolutePath().equals(second.getAbsolutePath()); + } + + private static String workDirStorageLabel(Context context) { + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + return "credential_protected"; + } + return legacyCredentialProtectedWorkDir(context) == null + ? "credential_protected" + : "device_protected"; + } + + private static String supervisorPath(Context context) { + return new File(workDir(context), SUPERVISOR).getAbsolutePath(); + } + + private static void writeText(File file, String text) throws IOException { + try (FileOutputStream output = new FileOutputStream(file, false)) { + output.write(text.getBytes(StandardCharsets.UTF_8)); + } + } + + private static String selectAbi() { + String[] abis = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + ? Build.SUPPORTED_ABIS + : new String[]{Build.CPU_ABI}; + for (String abi : abis) { + if ("arm64-v8a".equals(abi) || "armeabi-v7a".equals(abi) + || "x86_64".equals(abi) || "x86".equals(abi)) { + return abi; + } + } + return "armeabi-v7a"; + } + + private static String shellQuote(String value) { + if (value == null) { + return "''"; + } + return "'" + value.replace("'", "'\"'\"'") + "'"; + } +} diff --git a/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java b/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java index 71d5e2161..e6b3c03fb 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java +++ b/app/src/main/java/dev/ukanth/ufirewall/preferences/RulesPreferenceFragment.java @@ -1,28 +1,51 @@ package dev.ukanth.ufirewall.preferences; import android.app.Activity; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.ActivityNotFoundException; import android.content.Context; +import android.content.Intent; import android.content.SharedPreferences; +import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; import android.preference.CheckBoxPreference; import android.preference.Preference; import android.preference.PreferenceFragment; import android.preference.SwitchPreference; +import android.provider.Settings; +import android.widget.Toast; + +import com.afollestad.materialdialogs.MaterialDialog; import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; import dev.ukanth.ufirewall.Api; import dev.ukanth.ufirewall.R; +import dev.ukanth.ufirewall.broadcast.DnsBlocklistUpdateReceiver; +import dev.ukanth.ufirewall.dns.DnsBlocklistManager; +import dev.ukanth.ufirewall.dns.DnsHijackManager; import dev.ukanth.ufirewall.service.RootCommand; +import dev.ukanth.ufirewall.util.ApplicationErrorLog; import dev.ukanth.ufirewall.util.G; public class RulesPreferenceFragment extends PreferenceFragment implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final int REQUEST_DNS_BLOCKLIST_FILE = 5301; + private static final String ACTION_PRIVATE_DNS_SETTINGS = "android.settings.PRIVATE_DNS_SETTINGS"; + private static final String ACTION_INTERNET_CONNECTIVITY_PANEL = + "android.settings.panel.action.INTERNET_CONNECTIVITY"; private Context ctx; + private boolean suppressDnsLifecycle; @Override @@ -45,6 +68,151 @@ public void onCreate(Bundle savedInstanceState) { CheckBoxPreference roamPreference = (CheckBoxPreference) findPreference("enableRoam"); roamPreference.setEnabled(true); } + + wireDnsBlocklistActions(); + wireDnsServiceActions(); + } + + private void wireDnsServiceActions() { + Preference repairService = findPreference("dnsHijackRepairService"); + if (repairService != null) { + repairService.setOnPreferenceClickListener(preference -> { + repairDnsService(); + return true; + }); + } + + Preference validateConfig = findPreference("dnsHijackValidateConfig"); + if (validateConfig != null) { + validateConfig.setOnPreferenceClickListener(preference -> { + runDnsConfigValidation(); + return true; + }); + } + + Preference moduleStatus = findPreference("dnsHijackModuleStatus"); + if (moduleStatus != null) { + moduleStatus.setOnPreferenceClickListener(preference -> { + showDnsModuleStatus(); + return true; + }); + } + + Preference removeService = findPreference("dnsHijackRemoveService"); + if (removeService != null) { + removeService.setOnPreferenceClickListener(preference -> { + confirmRemoveDnsService(); + return true; + }); + } + + Preference emergencyCleanup = findPreference("dnsHijackEmergencyCleanup"); + if (emergencyCleanup != null) { + emergencyCleanup.setOnPreferenceClickListener(preference -> { + confirmEmergencyDnsCleanup(); + return true; + }); + } + + Preference powerSettings = findPreference("dnsHijackPowerSettings"); + if (powerSettings != null) { + powerSettings.setOnPreferenceClickListener(preference -> { + openDnsPowerSettings(); + return true; + }); + } + + Preference privateDnsSettings = findPreference("dnsHijackPrivateDnsSettings"); + if (privateDnsSettings != null) { + privateDnsSettings.setOnPreferenceClickListener(preference -> { + openDnsPrivateDnsSettings(); + return true; + }); + } + updateDnsPowerSettingsSummary(); + updateDnsPrivateDnsSettingsSummary(); + } + + private void wireDnsBlocklistActions() { + Preference importBlocklist = findPreference("dnsHijackImportBlocklist"); + if (importBlocklist != null) { + importBlocklist.setOnPreferenceClickListener(preference -> { + openDnsBlocklistPicker(); + return true; + }); + } + + Preference pasteBlocklist = findPreference("dnsHijackPasteBlocklist"); + if (pasteBlocklist != null) { + pasteBlocklist.setOnPreferenceClickListener(preference -> { + showDnsBlocklistPasteDialog(); + return true; + }); + } + + Preference blocklistPresets = findPreference("dnsHijackBlocklistPresets"); + if (blocklistPresets != null) { + blocklistPresets.setOnPreferenceClickListener(preference -> { + showDnsBlocklistPresetDialog(); + return true; + }); + } + + Preference updateBlocklists = findPreference("dnsHijackUpdateBlocklists"); + if (updateBlocklists != null) { + updateBlocklists.setOnPreferenceClickListener(preference -> { + runDnsBlocklistUpdate(); + return true; + }); + } + + Preference benchmarkUpstreams = findPreference("dnsHijackBenchmarkUpstreams"); + if (benchmarkUpstreams != null) { + benchmarkUpstreams.setOnPreferenceClickListener(preference -> { + runDnsUpstreamBenchmark(); + return true; + }); + } + + Preference upstreamProviders = findPreference("dnsHijackUpstreamProviders"); + if (upstreamProviders != null) { + upstreamProviders.setOnPreferenceClickListener(preference -> { + showDnsUpstreamProviderDialog(); + return true; + }); + } + + Preference rollbackBlocklist = findPreference("dnsHijackRollbackBlocklist"); + if (rollbackBlocklist != null) { + rollbackBlocklist.setOnPreferenceClickListener(preference -> { + runDnsBlocklistRollback(); + return true; + }); + } + + Preference saveProfilePolicy = findPreference("dnsHijackSaveProfilePolicy"); + if (saveProfilePolicy != null) { + saveProfilePolicy.setOnPreferenceClickListener(preference -> { + saveDnsProfilePolicy(); + return true; + }); + } + + Preference clearProfilePolicy = findPreference("dnsHijackClearProfilePolicy"); + if (clearProfilePolicy != null) { + clearProfilePolicy.setOnPreferenceClickListener(preference -> { + clearDnsProfilePolicy(); + return true; + }); + } + + Preference profilePresets = findPreference("dnsHijackProfilePresets"); + if (profilePresets != null) { + profilePresets.setOnPreferenceClickListener(preference -> { + showDnsProfilePresetDialog(); + return true; + }); + } } private void updateRuleStatus() { @@ -120,6 +288,8 @@ public void cbFunc(RootCommand state) { } getPreferenceScreen().removeAll(); addPreferencesFromResource(R.xml.rules_preferences); + wireDnsBlocklistActions(); + wireDnsServiceActions(); } } })); @@ -139,11 +309,326 @@ public void onAttach(Activity activity) { } } + private void openDnsBlocklistPicker() { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("text/*"); + try { + startActivityForResult(intent, REQUEST_DNS_BLOCKLIST_FILE); + } catch (ActivityNotFoundException e) { + Intent fallback = new Intent(Intent.ACTION_GET_CONTENT); + fallback.addCategory(Intent.CATEGORY_OPENABLE); + fallback.setType("*/*"); + startActivityForResult(fallback, REQUEST_DNS_BLOCKLIST_FILE); + } + } + + private void runDnsBlocklistUpdate() { + runBlocklistTask(() -> DnsBlocklistManager.updateFromConfiguredUrls(ctx)); + } + + private void showDnsBlocklistPasteDialog() { + if (getActivity() == null) { + return; + } + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_paste_blocklist_title) + .input(getString(R.string.dns_hijack_paste_blocklist_hint), + getClipboardText(), (dialog, input) -> { + String text = input == null ? "" : input.toString(); + runBlocklistTask(() -> DnsBlocklistManager.importFromText(ctx, text)); + }) + .positiveText(R.string.imports) + .negativeText(R.string.Cancel) + .show(); + } + + private String getClipboardText() { + if (ctx == null) { + return ""; + } + ClipboardManager clipboard = (ClipboardManager) ctx.getSystemService(Context.CLIPBOARD_SERVICE); + if (clipboard == null || !clipboard.hasPrimaryClip() || clipboard.getPrimaryClip() == null + || clipboard.getPrimaryClip().getItemCount() == 0) { + return ""; + } + ClipData.Item item = clipboard.getPrimaryClip().getItemAt(0); + CharSequence text = item == null ? null : item.coerceToText(ctx); + return text == null ? "" : text.toString(); + } + + private void showDnsBlocklistPresetDialog() { + if (getActivity() == null) { + return; + } + String[] names = getResources().getStringArray(R.array.dns_hijack_blocklist_preset_names); + String[] urls = getResources().getStringArray(R.array.dns_hijack_blocklist_preset_urls); + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_blocklist_presets_title) + .content(R.string.dns_hijack_blocklist_presets_dialog_summary) + .items(names) + .itemsCallbackMultiChoice(selectedPresetIndices(urls), (dialog, which, text) -> { + int added = 0; + int existing = 0; + for (int index : which) { + if (index < 0 || index >= urls.length) { + continue; + } + if (G.appendDnsHijackBlocklistUrl(urls[index])) { + added++; + } else { + existing++; + } + } + DnsBlocklistUpdateReceiver.scheduleOrCancel(ctx); + showDnsBlocklistPresetResult(added, existing); + return true; + }) + .positiveText(R.string.add) + .negativeText(R.string.Cancel) + .show(); + } + + private Integer[] selectedPresetIndices(String[] urls) { + ArrayList selected = new ArrayList<>(); + String configured = G.dnsHijackBlocklistUrls(); + if (configured == null || configured.trim().isEmpty()) { + return null; + } + for (int i = 0; i < urls.length; i++) { + if (containsConfiguredBlocklistUrl(configured, urls[i])) { + selected.add(i); + } + } + return selected.isEmpty() ? null : selected.toArray(new Integer[0]); + } + + private boolean containsConfiguredBlocklistUrl(String configured, String url) { + String normalizedUrl = url == null ? "" : url.trim().toLowerCase(Locale.US); + if (normalizedUrl.isEmpty()) { + return false; + } + String[] entries = configured.split("[\\r\\n,]+"); + for (String entry : entries) { + String normalizedEntry = entry == null ? "" : entry.trim().toLowerCase(Locale.US); + if (normalizedUrl.equals(normalizedEntry)) { + return true; + } + } + return false; + } + + private void showDnsBlocklistPresetResult(int added, int existing) { + if (getActivity() == null) { + return; + } + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_blocklist_presets_title) + .content(getString(R.string.dns_hijack_blocklist_presets_result, + added, existing)) + .positiveText(R.string.OK) + .show(); + } + + private void showDnsProfilePresetDialog() { + if (getActivity() == null) { + return; + } + String[] names = getResources().getStringArray(R.array.dns_hijack_profile_preset_names); + String[] values = getResources().getStringArray(R.array.dns_hijack_profile_preset_values); + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_profile_presets_title) + .items(names) + .itemsCallback((dialog, view, which, text) -> { + if (which >= 0 && which < values.length) { + applyDnsProfilePreset(values[which], text == null ? "" : text.toString()); + } + }) + .negativeText(R.string.Cancel) + .show(); + } + + private void applyDnsProfilePreset(String presetId, String presetName) { + if (ctx == null) { + return; + } + boolean success; + boolean profileCopy = true; + suppressDnsLifecycle = true; + try { + success = G.applyDnsHijackProfilePreset(presetId); + if (success && G.dnsHijackUseProfilePolicy()) { + profileCopy = G.saveActiveDnsHijackProfilePolicy() + && DnsBlocklistManager.copyGlobalBlocklistToActiveProfile(ctx); + } + } finally { + suppressDnsLifecycle = false; + } + if (!success || !profileCopy) { + ApplicationErrorLog.add(ctx, "DNS profile preset failed: " + presetId); + Api.toast(ctx, getString(R.string.dns_hijack_profile_preset_failed)); + return; + } + Api.setRulesUpToDate(false); + DnsBlocklistUpdateReceiver.scheduleOrCancel(ctx); + ApplicationErrorLog.add(ctx, "DNS profile preset applied: " + presetId + + (G.dnsHijackUseProfilePolicy() ? " with active profile policy" : " globally")); + if (G.enableDnsHijack()) { + reinstallDnsRedirectsAfterSettingsChange("dnsProfilePreset:" + presetId, + getString(R.string.dns_hijack_profile_preset_applied, presetName), + getString(R.string.dns_hijack_redirect_settings_failed)); + } else { + Api.toast(ctx, getString(R.string.dns_hijack_profile_preset_applied, presetName)); + } + } + + private void runDnsBlocklistRollback() { + runBlocklistTask(() -> DnsBlocklistManager.restorePrevious(ctx)); + } + + private void runDnsUpstreamBenchmark() { + new Thread(() -> { + String result = DnsHijackManager.benchmarkUpstreams(ctx); + new Handler(Looper.getMainLooper()).post(() -> showDnsBenchmarkResult(result)); + }).start(); + } + + private void showDnsUpstreamProviderDialog() { + if (getActivity() == null) { + return; + } + String[] names = getResources().getStringArray(R.array.dns_hijack_upstream_provider_names); + String[] values = getResources().getStringArray(R.array.dns_hijack_upstream_provider_values); + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_upstream_providers_title) + .items(names) + .itemsCallback((dialog, view, which, text) -> { + if (which >= 0 && which < values.length) { + applyDnsUpstreamProvider(values[which], text == null ? "" : text.toString()); + } + }) + .negativeText(R.string.Cancel) + .show(); + } + + private void applyDnsUpstreamProvider(String providerId, String providerName) { + if (ctx == null) { + return; + } + boolean savedNewProfilePolicy = true; + boolean applied = G.applyDnsHijackUpstreamProvider(providerId); + if (applied && G.dnsHijackUseProfilePolicy() + && !G.activeDnsHijackProfilePolicySaved()) { + savedNewProfilePolicy = G.saveActiveDnsHijackProfilePolicy(); + } + if (!applied || !savedNewProfilePolicy) { + ApplicationErrorLog.add(ctx, "DNS upstream provider preset failed: " + providerId); + Api.toast(ctx, getString(R.string.dns_hijack_upstream_provider_failed)); + return; + } + Api.setRulesUpToDate(false); + if (G.enableDnsHijack()) { + DnsHijackManager.requestReload(ctx); + } + ApplicationErrorLog.add(ctx, "DNS upstream provider preset applied: " + providerId + + (G.dnsHijackUseProfilePolicy() ? " with active profile policy" : " globally")); + Api.toast(ctx, getString(R.string.dns_hijack_upstream_provider_applied, providerName)); + } + + private void showDnsBenchmarkResult(String result) { + if (getActivity() == null) { + return; + } + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_benchmark_upstreams_title) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.dns_hijack_benchmark_empty) + : result) + .positiveText(R.string.OK) + .show(); + } + + private void saveDnsProfilePolicy() { + boolean saved = G.saveActiveDnsHijackProfilePolicy() + && DnsBlocklistManager.copyGlobalBlocklistToActiveProfile(ctx); + handleDnsProfilePolicyResult(saved, + R.string.dns_hijack_profile_policy_saved); + } + + private void clearDnsProfilePolicy() { + handleDnsProfilePolicyResult(G.clearActiveDnsHijackProfilePolicy(), + R.string.dns_hijack_profile_policy_cleared); + } + + private void handleDnsProfilePolicyResult(boolean success, int successMessage) { + if (ctx == null) { + return; + } + if (success) { + Api.setRulesUpToDate(false); + DnsBlocklistUpdateReceiver.scheduleOrCancel(ctx); + if (G.enableDnsHijack()) { + reinstallDnsRedirectsAfterSettingsChange("dnsProfilePolicy", + getString(successMessage), + getString(R.string.dns_hijack_redirect_settings_failed)); + } else { + Api.toast(ctx, getString(successMessage)); + } + } else { + Api.toast(ctx, getString(R.string.dns_hijack_profile_policy_failed)); + } + } + + private void runBlocklistTask(BlocklistTask task) { + new Thread(() -> { + DnsBlocklistManager.Result result = task.run(); + new Handler(Looper.getMainLooper()).post(() -> handleBlocklistResult(result)); + }).start(); + } + + private void handleBlocklistResult(DnsBlocklistManager.Result result) { + if (getActivity() == null || result == null) { + return; + } + if (!result.failed) { + Api.setRulesUpToDate(false); + if (G.enableDnsHijack()) { + DnsHijackManager.requestReload(ctx); + } + DnsBlocklistUpdateReceiver.scheduleOrCancel(ctx); + Api.toast(ctx, getString(R.string.dns_hijack_blocklist_active)); + } else { + Api.toast(ctx, getString(R.string.dns_hijack_blocklist_failed)); + } + new MaterialDialog.Builder(getActivity()) + .title(result.failed ? R.string.dns_hijack_blocklist_failed : R.string.dns_hijack_blocklist_active) + .content(result.summary()) + .positiveText(R.string.OK) + .show(); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_DNS_BLOCKLIST_FILE && resultCode == Activity.RESULT_OK && data != null) { + Uri uri = data.getData(); + if (uri != null) { + runBlocklistTask(() -> DnsBlocklistManager.importFromUri(ctx, uri)); + } + } + } + + private interface BlocklistTask { + DnsBlocklistManager.Result run(); + } + @Override public void onResume() { super.onResume(); getPreferenceManager().getSharedPreferences() .registerOnSharedPreferenceChangeListener(this); + updateDnsPowerSettingsSummary(); + updateDnsPrivateDnsSettingsSummary(); } @@ -174,6 +659,14 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, enableTor.setChecked(false); CheckBoxPreference enableCustomRules = (CheckBoxPreference) findPreference("enableCustomRules"); enableCustomRules.setChecked(false); + CheckBoxPreference enableDnsHijack = (CheckBoxPreference) findPreference("enableDnsHijack"); + if (enableDnsHijack != null) { + enableDnsHijack.setChecked(false); + } + CheckBoxPreference dnsBootPersistence = (CheckBoxPreference) findPreference("dnsHijackBootPersistence"); + if (dnsBootPersistence != null) { + dnsBootPersistence.setChecked(false); + } G.enableRoam(false); G.enableLAN(false); @@ -181,6 +674,8 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, G.enableTether(false); G.enableTor(false); G.enableCustomRules(false); + G.enableDnsHijack(false); + G.dnsHijackBootPersistence(false); } } @@ -306,5 +801,524 @@ public void cbFunc(RootCommand state) { allow.setChecked(false); } + if (key.equals("enableIPv6") && G.enableDnsHijack() && !suppressDnsLifecycle) { + reinstallDnsRedirectsAfterSettingsChange(key); + } + + if (isDnsHijackPreference(key)) { + Api.setRulesUpToDate(false); + if (isDnsHijackBlocklistSchedulePreference(key)) { + DnsBlocklistUpdateReceiver.scheduleOrCancel(ctx); + } + if (suppressDnsLifecycle) { + return; + } + if (key.equals("enableDnsHijack")) { + handleDnsHijackEnableChanged(); + return; + } + if (key.equals("dnsHijackBootPersistence")) { + handleDnsBootPersistenceChanged(); + return; + } + if (isDnsHijackRedirectPreference(key) && G.enableDnsHijack()) { + reinstallDnsRedirectsAfterSettingsChange(key); + return; + } + if (shouldReloadDnsHijackPreference(key) && G.enableDnsHijack()) { + DnsHijackManager.requestReload(ctx); + } + } + + } + + private void handleDnsHijackEnableChanged() { + if (ctx == null) { + return; + } + final boolean enabled = G.enableDnsHijack(); + DnsHijackManager.applyDnsProtectionPreference(ctx, enabled, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + new Handler(Looper.getMainLooper()).post(() -> { + if (state.exitCode == 0) { + if (!enabled) { + setDnsBootPersistenceChecked(false); + } + Api.toast(ctx, ctx.getString(enabled + ? R.string.dns_hijack_enable_complete + : R.string.dns_hijack_disable_complete)); + if (enabled) { + warnIfPrivateDnsMayBypass(); + } + } else { + if (enabled) { + setDnsHijackChecked(false); + } else { + setDnsBootPersistenceChecked(false); + ApplicationErrorLog.add(ctx, + "DNS disable root cleanup failed; leaving DNS disabled for fail-open recovery"); + } + Api.toast(ctx, ctx.getString(enabled + ? R.string.dns_hijack_enable_failed + : R.string.dns_hijack_disable_failed)); + } + }); + } + }); + } + + private void handleDnsBootPersistenceChanged() { + if (ctx == null) { + return; + } + final boolean enabled = G.dnsHijackBootPersistence(); + DnsHijackManager.updateBootPersistence(ctx, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + new Handler(Looper.getMainLooper()).post(() -> { + if (state.exitCode == 0) { + Api.toast(ctx, ctx.getString(enabled + ? R.string.dns_hijack_boot_persistence_installed + : R.string.dns_hijack_boot_persistence_removed)); + } else { + setDnsBootPersistenceChecked(!enabled); + Api.toast(ctx, ctx.getString(R.string.dns_hijack_boot_persistence_failed)); + } + }); + } + }); + } + + private void repairDnsService() { + if (ctx == null) { + return; + } + Api.setRulesUpToDate(false); + DnsHijackManager.applyDnsProtectionPreference(ctx, true, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + new Handler(Looper.getMainLooper()).post(() -> { + if (state.exitCode == 0) { + setDnsHijackChecked(true); + Api.toast(ctx, ctx.getString(R.string.dns_hijack_enable_complete)); + warnIfPrivateDnsMayBypass(); + } else { + Api.toast(ctx, ctx.getString(R.string.dns_hijack_enable_failed)); + } + }); + } + }); + } + + private void runDnsConfigValidation() { + if (ctx == null || !canShowPreferenceDialogs()) { + return; + } + new Thread(() -> { + String result = DnsHijackManager.validateDnsConfiguration(ctx); + new Handler(Looper.getMainLooper()).post(() -> { + if (!canShowPreferenceDialogs()) { + return; + } + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_validate_config_title) + .content(result == null || result.trim().isEmpty() + ? getString(R.string.no_data_available) + : result) + .positiveText(R.string.OK) + .show(); + }); + }).start(); + } + + private void showDnsModuleStatus() { + if (ctx == null || !canShowPreferenceDialogs()) { + return; + } + final Context appContext = ctx.getApplicationContext(); + final List commands = DnsHijackManager.buildMagiskModuleStatusCommands(appContext); + ApplicationErrorLog.add(appContext, "DNS Magisk module status requested from rules settings"); + Api.toast(appContext, appContext.getString(R.string.dns_hijack_module_status_queued)); + new RootCommand() + .setLogging(true) + .setReopenShell(true) + .setFailureToast(R.string.error_su) + .setCallback(new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + new Handler(Looper.getMainLooper()).post(() -> { + if (!canShowPreferenceDialogs()) { + return; + } + String output = state.res == null ? "" : state.res.toString().trim(); + if (state.exitCode == 0) { + ApplicationErrorLog.add(appContext, "DNS Magisk module status completed"); + } else { + ApplicationErrorLog.add(appContext, + "DNS Magisk module status failed with exit " + state.exitCode); + } + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_module_status_title) + .content(output.isEmpty() + ? getString(R.string.no_data_available) + : output) + .positiveText(R.string.OK) + .show(); + }); + } + }) + .run(appContext, commands); + } + + private boolean canShowPreferenceDialogs() { + Activity activity = getActivity(); + return isAdded() + && activity != null + && !activity.isFinishing() + && (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1 + || !activity.isDestroyed()); + } + + private void reinstallDnsRedirectsAfterSettingsChange(String key) { + if (ctx == null) { + return; + } + reinstallDnsRedirectsAfterSettingsChange(key, + ctx.getString(R.string.dns_hijack_redirect_settings_applied), + ctx.getString(R.string.dns_hijack_redirect_settings_failed)); + } + + private void reinstallDnsRedirectsAfterSettingsChange(String key, String successToast, + String failureToast) { + if (ctx == null) { + return; + } + Api.setRulesUpToDate(false); + ApplicationErrorLog.add(ctx, "DNS redirect setting changed; reinstalling service path: " + key); + DnsHijackManager.repairDnsProtection(ctx, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + new Handler(Looper.getMainLooper()).post(() -> { + if (state.exitCode == 0) { + Api.toast(ctx, successToast); + warnIfPrivateDnsMayBypass(); + } else { + Api.toast(ctx, failureToast); + } + }); + } + }); + } + + private void warnIfPrivateDnsMayBypass() { + if (ctx == null) { + return; + } + String warning = DnsHijackManager.androidPrivateDnsWarning(ctx); + if (warning == null) { + return; + } + ApplicationErrorLog.add(ctx, warning); + Api.toast(ctx, getString(R.string.dns_hijack_private_dns_warning), Toast.LENGTH_LONG); + } + + private void confirmRemoveDnsService() { + if (ctx == null || getActivity() == null) { + return; + } + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_remove_service_title) + .content(R.string.dns_hijack_remove_service_confirm) + .positiveText(R.string.OK) + .negativeText(android.R.string.cancel) + .onPositive((dialog, which) -> removeDnsService()) + .show(); + } + + private void removeDnsService() { + if (ctx == null) { + return; + } + setDnsHijackChecked(false); + setDnsBootPersistenceChecked(false); + Api.setRulesUpToDate(false); + DnsBlocklistUpdateReceiver.scheduleOrCancel(ctx); + DnsHijackManager.applyDnsProtectionPreference(ctx, false, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + new Handler(Looper.getMainLooper()).post(() -> { + if (state.exitCode == 0) { + Api.toast(ctx, ctx.getString(R.string.dns_hijack_disable_complete)); + } else { + ApplicationErrorLog.add(ctx, + "DNS service removal root cleanup failed; leaving DNS disabled for fail-open recovery"); + Api.toast(ctx, ctx.getString(R.string.dns_hijack_disable_failed)); + } + }); + } + }); + } + + private void confirmEmergencyDnsCleanup() { + if (ctx == null || getActivity() == null) { + return; + } + new MaterialDialog.Builder(getActivity()) + .title(R.string.dns_hijack_emergency_cleanup_title) + .content(R.string.dns_hijack_emergency_cleanup_confirm) + .positiveText(R.string.OK) + .negativeText(android.R.string.cancel) + .onPositive((dialog, which) -> emergencyDnsCleanup()) + .show(); + } + + private void emergencyDnsCleanup() { + if (ctx == null) { + return; + } + ApplicationErrorLog.add(ctx, "DNS emergency cleanup requested from Rules preferences"); + DnsHijackManager.emergencyCleanupDnsProtection(ctx, new RootCommand.Callback() { + @Override + public void cbFunc(RootCommand state) { + new Handler(Looper.getMainLooper()).post(() -> { + if (state.exitCode == 0) { + setDnsHijackChecked(false); + setDnsBootPersistenceChecked(false); + Api.setRulesUpToDate(false); + DnsBlocklistUpdateReceiver.scheduleOrCancel(ctx); + Api.toast(ctx, ctx.getString(R.string.dns_hijack_emergency_cleanup_complete)); + } else { + Api.toast(ctx, ctx.getString(R.string.dns_hijack_emergency_cleanup_failed)); + } + }); + } + }); + } + + private void openDnsPowerSettings() { + if (ctx == null) { + return; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + ApplicationErrorLog.add(ctx, + "DNS power settings requested but Android battery optimization controls are unavailable"); + Api.toast(ctx, getString(R.string.dns_hijack_power_settings_summary_unsupported)); + return; + } + + String powerState = dnsPowerStateForLog(); + Intent intent = new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); + if (intent.resolveActivity(ctx.getPackageManager()) != null) { + ApplicationErrorLog.add(ctx, "DNS power settings opened; app_battery_optimized=" + powerState); + Api.toast(ctx, getString(R.string.dns_hijack_power_settings_opening)); + startActivity(intent); + return; + } + + Intent fallback = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + fallback.setData(Uri.parse("package:" + ctx.getPackageName())); + if (fallback.resolveActivity(ctx.getPackageManager()) != null) { + ApplicationErrorLog.add(ctx, "DNS app power details opened; app_battery_optimized=" + powerState); + Api.toast(ctx, getString(R.string.dns_hijack_power_settings_opening)); + startActivity(fallback); + return; + } + + ApplicationErrorLog.add(ctx, "DNS power settings unavailable; app_battery_optimized=" + powerState); + Api.toast(ctx, getString(R.string.dns_hijack_power_settings_unavailable)); + } + + private void openDnsPrivateDnsSettings() { + if (ctx == null) { + return; + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + ApplicationErrorLog.add(ctx, + "DNS Private DNS settings requested but Android Private DNS controls are unavailable"); + Api.toast(ctx, getString(R.string.dns_hijack_private_dns_settings_summary_unsupported)); + return; + } + + String privateDnsState = dnsPrivateDnsStateForLog(); + if (startDnsSettingsActivity(new Intent(ACTION_PRIVATE_DNS_SETTINGS))) { + ApplicationErrorLog.add(ctx, "DNS Private DNS settings opened; " + privateDnsState); + Api.toast(ctx, getString(R.string.dns_hijack_private_dns_settings_opening)); + return; + } + if (startDnsSettingsActivity(new Intent(ACTION_INTERNET_CONNECTIVITY_PANEL))) { + ApplicationErrorLog.add(ctx, "DNS Internet connectivity panel opened; " + privateDnsState); + Api.toast(ctx, getString(R.string.dns_hijack_private_dns_settings_opening)); + return; + } + if (startDnsSettingsActivity(new Intent(Settings.ACTION_WIRELESS_SETTINGS))) { + ApplicationErrorLog.add(ctx, "DNS wireless settings opened for Private DNS review; " + + privateDnsState); + Api.toast(ctx, getString(R.string.dns_hijack_private_dns_settings_opening)); + return; + } + + ApplicationErrorLog.add(ctx, "DNS Private DNS settings unavailable; " + privateDnsState); + Api.toast(ctx, getString(R.string.dns_hijack_private_dns_settings_unavailable)); + } + + private boolean startDnsSettingsActivity(Intent intent) { + if (intent == null || ctx == null || intent.resolveActivity(ctx.getPackageManager()) == null) { + return false; + } + startActivity(intent); + return true; + } + + private void updateDnsPowerSettingsSummary() { + Preference powerSettings = findPreference("dnsHijackPowerSettings"); + if (powerSettings == null) { + return; + } + if (ctx == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + powerSettings.setSummary(R.string.dns_hijack_power_settings_summary_unsupported); + return; + } + try { + powerSettings.setSummary(Api.batteryOptimized(ctx) + ? R.string.dns_hijack_power_settings_summary_optimized + : R.string.dns_hijack_power_settings_summary_unrestricted); + } catch (RuntimeException e) { + powerSettings.setSummary(R.string.dns_hijack_power_settings_summary_unknown); + } + } + + private void updateDnsPrivateDnsSettingsSummary() { + Preference privateDnsSettings = findPreference("dnsHijackPrivateDnsSettings"); + if (privateDnsSettings == null) { + return; + } + if (ctx == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + privateDnsSettings.setSummary(R.string.dns_hijack_private_dns_settings_summary_unsupported); + return; + } + String mode = DnsHijackManager.androidPrivateDnsMode(ctx); + if ("unknown".equals(mode)) { + privateDnsSettings.setSummary(R.string.dns_hijack_private_dns_settings_summary_unknown); + } else if (DnsHijackManager.androidPrivateDnsMayBypass(ctx)) { + privateDnsSettings.setSummary(R.string.dns_hijack_private_dns_settings_summary_bypass); + } else { + privateDnsSettings.setSummary(R.string.dns_hijack_private_dns_settings_summary_clear); + } + } + + private String dnsPowerStateForLog() { + if (ctx == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return "unsupported"; + } + try { + return String.valueOf(Api.batteryOptimized(ctx)); + } catch (RuntimeException e) { + return "unknown"; + } + } + + private String dnsPrivateDnsStateForLog() { + if (ctx == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return "private_dns_mode=unsupported"; + } + String mode = DnsHijackManager.androidPrivateDnsMode(ctx); + String specifier = DnsHijackManager.androidPrivateDnsSpecifier(ctx); + boolean bypass = DnsHijackManager.androidPrivateDnsMayBypass(ctx); + return "private_dns_mode=" + mode + + " specifier=" + (specifier.isEmpty() ? "none" : specifier) + + " bypass=" + bypass; + } + + private void setDnsHijackChecked(boolean enabled) { + suppressDnsLifecycle = true; + G.enableDnsHijack(enabled); + CheckBoxPreference enableDnsHijack = (CheckBoxPreference) findPreference("enableDnsHijack"); + if (enableDnsHijack != null) { + enableDnsHijack.setChecked(enabled); + } + suppressDnsLifecycle = false; + } + + private void setDnsBootPersistenceChecked(boolean enabled) { + suppressDnsLifecycle = true; + G.dnsHijackBootPersistence(enabled); + CheckBoxPreference dnsBootPersistence = (CheckBoxPreference) findPreference("dnsHijackBootPersistence"); + if (dnsBootPersistence != null) { + dnsBootPersistence.setChecked(enabled); + } + suppressDnsLifecycle = false; + } + + private boolean isDnsHijackPreference(String key) { + return key != null && (key.equals("enableDnsHijack") + || key.equals("dnsHijackPort") + || key.equals("dnsHijackUpstreams") + || key.equals("dnsHijackBootstrapUpstreams") + || key.equals("dnsHijackSplitUpstreams") + || key.equals("dnsHijackCaptureUids") + || key.equals("dnsHijackBypassUids") + || key.equals("dnsHijackCaptureInterfaces") + || key.equals("dnsHijackBypassInterfaces") + || key.equals("dnsHijackFailOpen") + || key.equals("dnsHijackStrictMode") + || key.equals("dnsHijackSafeSearch") + || key.equals("dnsHijackDnssecRequest") + || key.equals("dnsHijackDnssecAuthRequired") + || key.equals("dnsHijackBootPersistence") + || key.equals("dnsHijackAdbDebugControl") + || key.equals("dnsHijackTimeoutMs") + || key.equals("dnsHijackCacheSize") + || key.equals("dnsHijackStaleCacheSeconds") + || key.equals("dnsHijackPersistCache") + || key.equals("dnsHijackQueryLogging") + || key.equals("dnsHijackPersistQueryLogs") + || key.equals("dnsHijackAllowExact") + || key.equals("dnsHijackAllowSuffix") + || key.equals("dnsHijackBlockExact") + || key.equals("dnsHijackBlockSuffix") + || key.equals("dnsHijackAppAllowExact") + || key.equals("dnsHijackAppBlockExact") + || key.equals("dnsHijackAppAllowSuffix") + || key.equals("dnsHijackAppBlockSuffix") + || key.equals("dnsHijackNetworkAllow") + || key.equals("dnsHijackNetworkBlock") + || key.equals("dnsHijackAllowRegex") + || key.equals("dnsHijackBlockRegex") + || key.equals("dnsHijackBlocklistUrls") + || key.equals("dnsHijackScheduledBlocklistUpdates") + || key.equals("dnsHijackBlocklistUpdateIntervalHours") + || key.equals("dnsHijackUseProfilePolicy")); + } + + private boolean isDnsHijackBlocklistSchedulePreference(String key) { + return key != null && (key.equals("enableDnsHijack") + || key.equals("dnsHijackBlocklistUrls") + || key.equals("dnsHijackScheduledBlocklistUpdates") + || key.equals("dnsHijackBlocklistUpdateIntervalHours") + || key.equals("dnsHijackUseProfilePolicy")); + } + + private boolean isDnsHijackRedirectPreference(String key) { + return key != null && (key.equals("dnsHijackPort") + || key.equals("dnsHijackCaptureUids") + || key.equals("dnsHijackBypassUids") + || key.equals("dnsHijackCaptureInterfaces") + || key.equals("dnsHijackBypassInterfaces") + || key.equals("dnsHijackUseProfilePolicy")); + } + + private boolean shouldReloadDnsHijackPreference(String key) { + return key != null + && !key.equals("enableDnsHijack") + && !key.equals("dnsHijackPort") + && !key.equals("dnsHijackBootPersistence") + && !key.equals("dnsHijackCaptureUids") + && !key.equals("dnsHijackBypassUids") + && !key.equals("dnsHijackCaptureInterfaces") + && !key.equals("dnsHijackBypassInterfaces") + && !key.equals("dnsHijackBlocklistUrls") + && !key.equals("dnsHijackScheduledBlocklistUpdates") + && !key.equals("dnsHijackBlocklistUpdateIntervalHours"); } } diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java index 6660dd24c..c845a57a3 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService.java @@ -81,6 +81,9 @@ private void complete(final RootCommand state, int exitCode) { } state.exitCode = exitCode; state.done = true; + if (exitCode != 0) { + ApplicationErrorLog.add(mContext, buildRootCommandFailureMessage(state, exitCode)); + } if (state.cb != null) { state.cb.cbFunc(state); } @@ -194,16 +197,8 @@ private void processCommands(final RootCommand state) { } rootSession.addCommand(command, 0, (Shell.OnCommandResultListener2) (commandCode, exitCode, output, STDERR)-> { - ListIterator iter = output.listIterator(); - while (iter.hasNext()) { - String line = iter.next(); - if (line != null && !line.equals("")) { - if (state.res != null) { - state.res.append(line).append("\n"); - } - state.lastCommandResult.append(line).append("\n"); - } - } + appendCommandOutput(state, output, false); + appendCommandOutput(state, STDERR, true); // Special handling for exit code 126 (command not executable) - fallback to system iptables if (exitCode == 126 && shouldFallbackToSystem(state)) { Log.w(TAG, "Built-in iptables failed with exit 126, attempting fallback to system iptables"); @@ -261,6 +256,44 @@ private void processCommands(final RootCommand state) { } } + private void appendCommandOutput(final RootCommand state, List lines, boolean stderr) { + if (state == null || lines == null) { + return; + } + ListIterator iter = lines.listIterator(); + while (iter.hasNext()) { + String line = iter.next(); + if (line == null || line.equals("")) { + continue; + } + String formatted = stderr ? "stderr: " + line : line; + if (state.res != null) { + state.res.append(formatted).append("\n"); + } + if (state.lastCommandResult != null) { + state.lastCommandResult.append(formatted).append("\n"); + } + } + } + + private String buildRootCommandFailureMessage(final RootCommand state, int exitCode) { + String command = state != null && state.lastCommand != null + ? state.lastCommand + : "root shell command"; + StringBuilder message = new StringBuilder("Root command failed"); + message.append(" (exit ").append(exitCode).append("): ").append(command); + if (state != null && state.lastCommandResult != null) { + String result = state.lastCommandResult.toString().trim().replace('\n', ' '); + if (!result.isEmpty()) { + if (result.length() > 240) { + result = result.substring(0, 240); + } + message.append(" output=").append(result); + } + } + return message.toString(); + } + private void sendUpdate(final RootCommand state2) { new Thread(() -> { Intent broadcastIntent = new Intent(); diff --git a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService2.java b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService2.java index f2e284fbb..4d0548abf 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService2.java +++ b/app/src/main/java/dev/ukanth/ufirewall/service/RootShellService2.java @@ -81,6 +81,9 @@ private void complete(final RootCommand state, int exitCode) { } state.exitCode = exitCode; state.done = true; + if (exitCode != 0) { + ApplicationErrorLog.add(mContext, buildRootCommandFailureMessage(state, exitCode)); + } if (state.cb != null) { state.cb.cbFunc(state); } @@ -191,16 +194,8 @@ private void processCommands(final RootCommand state) { } rootSession2.addCommand(command, 0, (Shell.OnCommandResultListener2) (commandCode, exitCode, output, STDERR) -> { - ListIterator iter = output.listIterator(); - while (iter.hasNext()) { - String line = iter.next(); - if (line != null && !line.equals("")) { - if (state.res != null) { - state.res.append(line).append("\n"); - } - state.lastCommandResult.append(line).append("\n"); - } - } + appendCommandOutput(state, output, false); + appendCommandOutput(state, STDERR, true); // Special handling for exit code 126 (command not executable) - fallback to system iptables if (exitCode == 126 && shouldFallbackToSystem(state)) { Log.w(TAG, "Built-in iptables failed with exit 126, attempting fallback to system iptables"); @@ -252,6 +247,44 @@ private void processCommands(final RootCommand state) { } } + private void appendCommandOutput(final RootCommand state, List lines, boolean stderr) { + if (state == null || lines == null) { + return; + } + ListIterator iter = lines.listIterator(); + while (iter.hasNext()) { + String line = iter.next(); + if (line == null || line.equals("")) { + continue; + } + String formatted = stderr ? "stderr: " + line : line; + if (state.res != null) { + state.res.append(formatted).append("\n"); + } + if (state.lastCommandResult != null) { + state.lastCommandResult.append(formatted).append("\n"); + } + } + } + + private String buildRootCommandFailureMessage(final RootCommand state, int exitCode) { + String command = state != null && state.lastCommand != null + ? state.lastCommand + : "root shell command"; + StringBuilder message = new StringBuilder("Root command failed"); + message.append(" (exit ").append(exitCode).append("): ").append(command); + if (state != null && state.lastCommandResult != null) { + String result = state.lastCommandResult.toString().trim().replace('\n', ' '); + if (!result.isEmpty()) { + if (result.length() > 240) { + result = result.substring(0, 240); + } + message.append(" output=").append(result); + } + } + return message.toString(); + } + private void sendUpdate(final RootCommand state2) { new Thread(() -> { Intent broadcastIntent = new Intent(); diff --git a/app/src/main/java/dev/ukanth/ufirewall/util/G.java b/app/src/main/java/dev/ukanth/ufirewall/util/G.java index d92f666a0..c661379e1 100644 --- a/app/src/main/java/dev/ukanth/ufirewall/util/G.java +++ b/app/src/main/java/dev/ukanth/ufirewall/util/G.java @@ -51,6 +51,7 @@ import java.io.File; import java.util.ArrayList; import java.util.Arrays; +import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.regex.Matcher; @@ -170,6 +171,84 @@ public static Context getContext() { private static final String COPIED_OLD_EXPORTS = "copyOldExports"; private static final String SYSTEM_FILE_PICKER = "useSystemFilePicker"; private static final String ZIP_LOG_REPORTS = "zipLogReports"; + private static final String ENABLE_DNS_HIJACK = "enableDnsHijack"; + private static final String DNS_HIJACK_PORT = "dnsHijackPort"; + private static final String DNS_HIJACK_UPSTREAMS = "dnsHijackUpstreams"; + private static final String DNS_HIJACK_BOOTSTRAP_UPSTREAMS = "dnsHijackBootstrapUpstreams"; + private static final String DNS_HIJACK_SPLIT_UPSTREAMS = "dnsHijackSplitUpstreams"; + private static final String DNS_HIJACK_CAPTURE_UIDS = "dnsHijackCaptureUids"; + private static final String DNS_HIJACK_BYPASS_UIDS = "dnsHijackBypassUids"; + private static final String DNS_HIJACK_CAPTURE_INTERFACES = "dnsHijackCaptureInterfaces"; + private static final String DNS_HIJACK_BYPASS_INTERFACES = "dnsHijackBypassInterfaces"; + private static final String DNS_HIJACK_FAIL_OPEN = "dnsHijackFailOpen"; + private static final String DNS_HIJACK_STRICT_MODE = "dnsHijackStrictMode"; + private static final String DNS_HIJACK_SAFE_SEARCH = "dnsHijackSafeSearch"; + private static final String DNS_HIJACK_DNSSEC_REQUEST = "dnsHijackDnssecRequest"; + private static final String DNS_HIJACK_DNSSEC_AUTH_REQUIRED = "dnsHijackDnssecAuthRequired"; + private static final String DNS_HIJACK_BOOT_PERSISTENCE = "dnsHijackBootPersistence"; + private static final String DNS_HIJACK_ADB_DEBUG_CONTROL = "dnsHijackAdbDebugControl"; + private static final String DNS_HIJACK_TIMEOUT_MS = "dnsHijackTimeoutMs"; + private static final String DNS_HIJACK_CACHE_SIZE = "dnsHijackCacheSize"; + private static final String DNS_HIJACK_STALE_CACHE_SECONDS = "dnsHijackStaleCacheSeconds"; + private static final String DNS_HIJACK_PERSIST_CACHE = "dnsHijackPersistCache"; + private static final String DNS_HIJACK_QUERY_LOGGING = "dnsHijackQueryLogging"; + private static final String DNS_HIJACK_PERSIST_QUERY_LOGS = "dnsHijackPersistQueryLogs"; + private static final String DNS_HIJACK_ALLOW_EXACT = "dnsHijackAllowExact"; + private static final String DNS_HIJACK_ALLOW_SUFFIX = "dnsHijackAllowSuffix"; + private static final String DNS_HIJACK_BLOCK_EXACT = "dnsHijackBlockExact"; + private static final String DNS_HIJACK_BLOCK_SUFFIX = "dnsHijackBlockSuffix"; + private static final String DNS_HIJACK_APP_ALLOW_EXACT = "dnsHijackAppAllowExact"; + private static final String DNS_HIJACK_APP_BLOCK_EXACT = "dnsHijackAppBlockExact"; + private static final String DNS_HIJACK_APP_ALLOW_SUFFIX = "dnsHijackAppAllowSuffix"; + private static final String DNS_HIJACK_APP_BLOCK_SUFFIX = "dnsHijackAppBlockSuffix"; + private static final String DNS_HIJACK_NETWORK_ALLOW = "dnsHijackNetworkAllow"; + private static final String DNS_HIJACK_NETWORK_BLOCK = "dnsHijackNetworkBlock"; + private static final String DNS_HIJACK_ALLOW_REGEX = "dnsHijackAllowRegex"; + private static final String DNS_HIJACK_BLOCK_REGEX = "dnsHijackBlockRegex"; + private static final String DNS_HIJACK_TEMP_ALLOW = "dnsHijackTempAllow"; + private static final String DNS_HIJACK_TEMP_BLOCK = "dnsHijackTempBlock"; + private static final String DNS_HIJACK_BLOCKLIST_URLS = "dnsHijackBlocklistUrls"; + private static final String DNS_HIJACK_SCHEDULED_BLOCKLIST_UPDATES = "dnsHijackScheduledBlocklistUpdates"; + private static final String DNS_HIJACK_BLOCKLIST_UPDATE_INTERVAL_HOURS = "dnsHijackBlocklistUpdateIntervalHours"; + private static final String DNS_HIJACK_USE_PROFILE_POLICY = "dnsHijackUseProfilePolicy"; + private static final String DNS_HIJACK_PROFILE_POLICY_SAVED = "dnsHijackProfilePolicySaved"; + private static final String[] DNS_HIJACK_PROFILE_POLICY_KEYS = new String[] { + DNS_HIJACK_UPSTREAMS, + DNS_HIJACK_BOOTSTRAP_UPSTREAMS, + DNS_HIJACK_SPLIT_UPSTREAMS, + DNS_HIJACK_CAPTURE_UIDS, + DNS_HIJACK_BYPASS_UIDS, + DNS_HIJACK_CAPTURE_INTERFACES, + DNS_HIJACK_BYPASS_INTERFACES, + DNS_HIJACK_FAIL_OPEN, + DNS_HIJACK_STRICT_MODE, + DNS_HIJACK_SAFE_SEARCH, + DNS_HIJACK_DNSSEC_REQUEST, + DNS_HIJACK_DNSSEC_AUTH_REQUIRED, + DNS_HIJACK_TIMEOUT_MS, + DNS_HIJACK_CACHE_SIZE, + DNS_HIJACK_STALE_CACHE_SECONDS, + DNS_HIJACK_PERSIST_CACHE, + DNS_HIJACK_QUERY_LOGGING, + DNS_HIJACK_PERSIST_QUERY_LOGS, + DNS_HIJACK_ALLOW_EXACT, + DNS_HIJACK_ALLOW_SUFFIX, + DNS_HIJACK_BLOCK_EXACT, + DNS_HIJACK_BLOCK_SUFFIX, + DNS_HIJACK_APP_ALLOW_EXACT, + DNS_HIJACK_APP_BLOCK_EXACT, + DNS_HIJACK_APP_ALLOW_SUFFIX, + DNS_HIJACK_APP_BLOCK_SUFFIX, + DNS_HIJACK_NETWORK_ALLOW, + DNS_HIJACK_NETWORK_BLOCK, + DNS_HIJACK_ALLOW_REGEX, + DNS_HIJACK_BLOCK_REGEX, + DNS_HIJACK_TEMP_ALLOW, + DNS_HIJACK_TEMP_BLOCK, + DNS_HIJACK_BLOCKLIST_URLS, + DNS_HIJACK_SCHEDULED_BLOCKLIST_UPDATES, + DNS_HIJACK_BLOCKLIST_UPDATE_INTERVAL_HOURS + }; private static final String SHOW_ALL_APPS = "showAllApps"; @@ -255,6 +334,675 @@ public static boolean zipLogReports() { return gPrefs.getBoolean(ZIP_LOG_REPORTS, false); } + public static boolean enableDnsHijack() { + return gPrefs.getBoolean(ENABLE_DNS_HIJACK, false); + } + + public static boolean enableDnsHijack(boolean val) { + gPrefs.edit().putBoolean(ENABLE_DNS_HIJACK, val).commit(); + return val; + } + + public static int dnsHijackPort(int fallback) { + return readIntPreference(DNS_HIJACK_PORT, fallback, 1024, 65535); + } + + public static String dnsHijackUpstreams() { + return dnsPolicyPrefs().getString(DNS_HIJACK_UPSTREAMS, "1.1.1.1:53\n8.8.8.8:53"); + } + + public static String dnsHijackBootstrapUpstreams() { + return dnsPolicyPrefs().getString(DNS_HIJACK_BOOTSTRAP_UPSTREAMS, "1.1.1.1:53\n8.8.8.8:53"); + } + + public static String dnsHijackSplitUpstreams() { + return dnsPolicyPrefs().getString(DNS_HIJACK_SPLIT_UPSTREAMS, ""); + } + + public static boolean applyDnsHijackUpstreamProvider(String providerId) { + if (providerId == null) { + return false; + } + String provider = providerId.trim().toLowerCase(java.util.Locale.US); + String upstreams; + String bootstrapUpstreams; + if ("cloudflare-udp".equals(provider)) { + upstreams = "udp://1.1.1.1:53\nudp://1.0.0.1:53"; + bootstrapUpstreams = upstreams; + } else if ("cloudflare-tcp".equals(provider)) { + upstreams = "tcp://1.1.1.1:53\ntcp://1.0.0.1:53"; + bootstrapUpstreams = "udp://1.1.1.1:53\nudp://1.0.0.1:53"; + } else if ("quad9-udp".equals(provider)) { + upstreams = "udp://9.9.9.9:53\nudp://149.112.112.112:53"; + bootstrapUpstreams = upstreams; + } else if ("quad9-tcp".equals(provider)) { + upstreams = "tcp://9.9.9.9:53\ntcp://149.112.112.112:53"; + bootstrapUpstreams = "udp://9.9.9.9:53\nudp://149.112.112.112:53"; + } else if ("google-udp".equals(provider)) { + upstreams = "udp://8.8.8.8:53\nudp://8.8.4.4:53"; + bootstrapUpstreams = upstreams; + } else if ("adguard-udp".equals(provider)) { + upstreams = "udp://94.140.14.14:53\nudp://94.140.15.15:53"; + bootstrapUpstreams = "udp://1.1.1.1:53\nudp://8.8.8.8:53"; + } else if ("cleanbrowsing-family-udp".equals(provider)) { + upstreams = "udp://185.228.168.168:53\nudp://185.228.169.168:53"; + bootstrapUpstreams = "udp://1.1.1.1:53\nudp://8.8.8.8:53"; + } else { + return false; + } + return dnsWritablePolicyPrefs().edit() + .putString(DNS_HIJACK_UPSTREAMS, upstreams) + .putString(DNS_HIJACK_BOOTSTRAP_UPSTREAMS, bootstrapUpstreams) + .commit(); + } + + public static String dnsHijackCaptureUids() { + return dnsPolicyPrefs().getString(DNS_HIJACK_CAPTURE_UIDS, ""); + } + + public static String dnsHijackBypassUids() { + return dnsPolicyPrefs().getString(DNS_HIJACK_BYPASS_UIDS, ""); + } + + public static boolean dnsHijackUidCaptured(int uid) { + if (uid < 0) { + return false; + } + LinkedHashSet bypassUids = readUidSet(dnsPolicyPrefs(), DNS_HIJACK_BYPASS_UIDS); + if (bypassUids.contains(uid)) { + return false; + } + LinkedHashSet captureUids = readUidSet(dnsPolicyPrefs(), DNS_HIJACK_CAPTURE_UIDS); + return captureUids.isEmpty() || captureUids.contains(uid); + } + + public static boolean dnsHijackUidCaptured(int uid, boolean captured) { + if (uid < 0) { + return false; + } + SharedPreferences prefs = dnsWritablePolicyPrefs(); + LinkedHashSet captureUids = readUidSet(prefs, DNS_HIJACK_CAPTURE_UIDS); + LinkedHashSet bypassUids = readUidSet(prefs, DNS_HIJACK_BYPASS_UIDS); + boolean changed = false; + if (captured) { + changed |= bypassUids.remove(uid); + if (!captureUids.isEmpty()) { + changed |= captureUids.add(uid); + } + } else { + changed |= captureUids.remove(uid); + changed |= bypassUids.add(uid); + } + if (changed) { + prefs.edit() + .putString(DNS_HIJACK_CAPTURE_UIDS, joinUidSet(captureUids)) + .putString(DNS_HIJACK_BYPASS_UIDS, joinUidSet(bypassUids)) + .commit(); + } + return changed; + } + + public static String dnsHijackCaptureInterfaces() { + return dnsPolicyPrefs().getString(DNS_HIJACK_CAPTURE_INTERFACES, ""); + } + + public static String dnsHijackBypassInterfaces() { + return dnsPolicyPrefs().getString(DNS_HIJACK_BYPASS_INTERFACES, ""); + } + + public static boolean dnsHijackFailOpen() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_FAIL_OPEN, true); + } + + public static boolean dnsHijackStrictMode() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_STRICT_MODE, false); + } + + public static boolean dnsHijackSafeSearch() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_SAFE_SEARCH, false); + } + + public static boolean dnsHijackDnssecRequest() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_DNSSEC_REQUEST, false); + } + + public static boolean dnsHijackDnssecAuthRequired() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_DNSSEC_AUTH_REQUIRED, false); + } + + public static boolean dnsHijackBootPersistence() { + return gPrefs.getBoolean(DNS_HIJACK_BOOT_PERSISTENCE, false); + } + + public static boolean dnsHijackBootPersistence(boolean val) { + gPrefs.edit().putBoolean(DNS_HIJACK_BOOT_PERSISTENCE, val).commit(); + return val; + } + + public static boolean dnsHijackAdbDebugControl() { + return gPrefs.getBoolean(DNS_HIJACK_ADB_DEBUG_CONTROL, false); + } + + public static int dnsHijackTimeoutMs() { + return readIntPreference(dnsPolicyPrefs(), DNS_HIJACK_TIMEOUT_MS, 2500, 250, 10000); + } + + public static int dnsHijackCacheSize() { + return readIntPreference(dnsPolicyPrefs(), DNS_HIJACK_CACHE_SIZE, 1024, 0, 4096); + } + + public static int dnsHijackStaleCacheSeconds() { + return readIntPreference(dnsPolicyPrefs(), DNS_HIJACK_STALE_CACHE_SECONDS, 300, 0, 86400); + } + + public static boolean dnsHijackPersistCache() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_PERSIST_CACHE, false); + } + + public static boolean dnsHijackQueryLogging() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_QUERY_LOGGING, true); + } + + public static boolean dnsHijackPersistQueryLogs() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_PERSIST_QUERY_LOGS, true); + } + + public static String dnsHijackAllowExact() { + return dnsPolicyPrefs().getString(DNS_HIJACK_ALLOW_EXACT, ""); + } + + public static String dnsHijackAllowSuffix() { + return dnsPolicyPrefs().getString(DNS_HIJACK_ALLOW_SUFFIX, ""); + } + + public static String dnsHijackBlockExact() { + return dnsPolicyPrefs().getString(DNS_HIJACK_BLOCK_EXACT, ""); + } + + public static String dnsHijackBlockSuffix() { + return dnsPolicyPrefs().getString(DNS_HIJACK_BLOCK_SUFFIX, ""); + } + + public static String dnsHijackAppAllowExact() { + return dnsPolicyPrefs().getString(DNS_HIJACK_APP_ALLOW_EXACT, ""); + } + + public static String dnsHijackAppBlockExact() { + return dnsPolicyPrefs().getString(DNS_HIJACK_APP_BLOCK_EXACT, ""); + } + + public static String dnsHijackAppAllowSuffix() { + return dnsPolicyPrefs().getString(DNS_HIJACK_APP_ALLOW_SUFFIX, ""); + } + + public static String dnsHijackAppBlockSuffix() { + return dnsPolicyPrefs().getString(DNS_HIJACK_APP_BLOCK_SUFFIX, ""); + } + + public static String dnsHijackNetworkAllow() { + return dnsPolicyPrefs().getString(DNS_HIJACK_NETWORK_ALLOW, ""); + } + + public static String dnsHijackNetworkBlock() { + return dnsPolicyPrefs().getString(DNS_HIJACK_NETWORK_BLOCK, ""); + } + + public static String dnsHijackAllowRegex() { + return dnsPolicyPrefs().getString(DNS_HIJACK_ALLOW_REGEX, ""); + } + + public static String dnsHijackBlockRegex() { + return dnsPolicyPrefs().getString(DNS_HIJACK_BLOCK_REGEX, ""); + } + + public static String dnsHijackTempAllow() { + return dnsPolicyPrefs().getString(DNS_HIJACK_TEMP_ALLOW, ""); + } + + public static String dnsHijackTempBlock() { + return dnsPolicyPrefs().getString(DNS_HIJACK_TEMP_BLOCK, ""); + } + + public static String dnsHijackBlocklistUrls() { + return dnsPolicyPrefs().getString(DNS_HIJACK_BLOCKLIST_URLS, ""); + } + + public static boolean appendDnsHijackBlocklistUrl(String url) { + return appendLinePreference(dnsPolicyPrefs(), DNS_HIJACK_BLOCKLIST_URLS, url); + } + + public static boolean dnsHijackScheduledBlocklistUpdates() { + return dnsPolicyPrefs().getBoolean(DNS_HIJACK_SCHEDULED_BLOCKLIST_UPDATES, false); + } + + public static int dnsHijackBlocklistUpdateIntervalHours() { + return readIntPreference(dnsPolicyPrefs(), DNS_HIJACK_BLOCKLIST_UPDATE_INTERVAL_HOURS, 24, 1, 720); + } + + public static boolean dnsHijackUseProfilePolicy() { + return gPrefs.getBoolean(DNS_HIJACK_USE_PROFILE_POLICY, false); + } + + public static boolean activeDnsHijackProfilePolicySaved() { + SharedPreferences profilePrefs = activeDnsProfilePrefs(); + return profilePrefs != null + && profilePrefs.getBoolean(DNS_HIJACK_PROFILE_POLICY_SAVED, false); + } + + public static String activeDnsHijackPolicyProfile() { + if (!enableMultiProfile()) { + return Api.DEFAULT_PREFS_NAME; + } + return storedProfile(); + } + + public static boolean saveActiveDnsHijackProfilePolicy() { + SharedPreferences profilePrefs = activeDnsProfilePrefs(); + if (profilePrefs == null) { + return false; + } + SharedPreferences.Editor editor = profilePrefs.edit(); + for (String key : DNS_HIJACK_PROFILE_POLICY_KEYS) { + copyPreferenceValue(gPrefs, editor, key); + } + editor.putBoolean(DNS_HIJACK_PROFILE_POLICY_SAVED, true); + return editor.commit(); + } + + public static boolean clearActiveDnsHijackProfilePolicy() { + SharedPreferences profilePrefs = activeDnsProfilePrefs(); + if (profilePrefs == null) { + return false; + } + SharedPreferences.Editor editor = profilePrefs.edit(); + for (String key : DNS_HIJACK_PROFILE_POLICY_KEYS) { + editor.remove(key); + } + editor.remove(DNS_HIJACK_PROFILE_POLICY_SAVED); + return editor.commit(); + } + + public static boolean applyDnsHijackProfilePreset(String presetId) { + if (gPrefs == null || presetId == null) { + return false; + } + String preset = presetId.trim().toLowerCase(java.util.Locale.US); + SharedPreferences.Editor editor = gPrefs.edit(); + if ("default".equals(preset)) { + putDnsHijackPreset(editor, + "1.1.1.1:53\n8.8.8.8:53", + "1.1.1.1:53\n8.8.8.8:53", + true, false, false, false, false, + "2500", "1024", "300", + false, true, true, + "", false, "24"); + } else if ("strict".equals(preset)) { + putDnsHijackPreset(editor, + "9.9.9.9:53\n1.1.1.1:53", + "9.9.9.9:53\n1.1.1.1:53", + false, true, true, true, false, + "2500", "2048", "0", + true, true, true, + "https://raw.githubusercontent.com/stevenblack/hosts/master/hosts\n" + + "https://small.oisd.nl/\n" + + "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/light.txt", + true, "12"); + } else if ("kids".equals(preset)) { + putDnsHijackPreset(editor, + "1.1.1.3:53\n1.0.0.3:53", + "1.1.1.1:53\n8.8.8.8:53", + false, true, true, false, false, + "2500", "2048", "0", + true, true, true, + "https://raw.githubusercontent.com/stevenblack/hosts/master/hosts\n" + + "https://small.oisd.nl/\n" + + "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/light.txt", + true, "12"); + } else if ("work".equals(preset)) { + putDnsHijackPreset(editor, + "9.9.9.9:53\n1.1.1.1:53", + "9.9.9.9:53\n1.1.1.1:53", + true, false, false, true, false, + "2500", "1024", "300", + false, true, true, + "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/light.txt", + true, "24"); + } else if ("gaming".equals(preset)) { + putDnsHijackPreset(editor, + "1.1.1.1:53\n8.8.8.8:53", + "1.1.1.1:53\n8.8.8.8:53", + true, false, false, false, false, + "1500", "2048", "600", + true, false, false, + "", false, "24"); + } else { + return false; + } + return editor.commit(); + } + + public static String dnsHijackBlocklistDirectoryName(String defaultName) { + if (!usingActiveDnsProfilePolicy()) { + return defaultName; + } + String profile = activeDnsHijackPolicyProfile(); + String safeProfile = profile == null ? Api.DEFAULT_PREFS_NAME : profile; + safeProfile = safeProfile.replaceAll("[^A-Za-z0-9_.-]", "_"); + if (safeProfile.length() == 0) { + safeProfile = Api.DEFAULT_PREFS_NAME; + } + return defaultName + "_" + safeProfile; + } + + private static void putDnsHijackPreset(SharedPreferences.Editor editor, + String upstreams, + String bootstrapUpstreams, + boolean failOpen, + boolean strictMode, + boolean safeSearch, + boolean dnssecRequest, + boolean dnssecAuthRequired, + String timeoutMs, + String cacheSize, + String staleCacheSeconds, + boolean persistCache, + boolean queryLogging, + boolean persistQueryLogs, + String blocklistUrls, + boolean scheduledBlocklistUpdates, + String updateIntervalHours) { + // Presets seed the exact policy keys consumed by config generation, avoiding a second + // profile implementation that could drift from daemon behavior. + editor.putString(DNS_HIJACK_UPSTREAMS, upstreams); + editor.putString(DNS_HIJACK_BOOTSTRAP_UPSTREAMS, bootstrapUpstreams); + editor.putString(DNS_HIJACK_SPLIT_UPSTREAMS, ""); + editor.putString(DNS_HIJACK_CAPTURE_UIDS, ""); + editor.putString(DNS_HIJACK_BYPASS_UIDS, ""); + editor.putString(DNS_HIJACK_CAPTURE_INTERFACES, ""); + editor.putString(DNS_HIJACK_BYPASS_INTERFACES, ""); + editor.putBoolean(DNS_HIJACK_FAIL_OPEN, failOpen); + editor.putBoolean(DNS_HIJACK_STRICT_MODE, strictMode); + editor.putBoolean(DNS_HIJACK_SAFE_SEARCH, safeSearch); + editor.putBoolean(DNS_HIJACK_DNSSEC_REQUEST, dnssecRequest); + editor.putBoolean(DNS_HIJACK_DNSSEC_AUTH_REQUIRED, dnssecAuthRequired); + editor.putString(DNS_HIJACK_TIMEOUT_MS, timeoutMs); + editor.putString(DNS_HIJACK_CACHE_SIZE, cacheSize); + editor.putString(DNS_HIJACK_STALE_CACHE_SECONDS, staleCacheSeconds); + editor.putBoolean(DNS_HIJACK_PERSIST_CACHE, persistCache); + editor.putBoolean(DNS_HIJACK_QUERY_LOGGING, queryLogging); + editor.putBoolean(DNS_HIJACK_PERSIST_QUERY_LOGS, persistQueryLogs); + editor.putString(DNS_HIJACK_ALLOW_EXACT, ""); + editor.putString(DNS_HIJACK_ALLOW_SUFFIX, ""); + editor.putString(DNS_HIJACK_BLOCK_EXACT, ""); + editor.putString(DNS_HIJACK_BLOCK_SUFFIX, ""); + editor.putString(DNS_HIJACK_APP_ALLOW_EXACT, ""); + editor.putString(DNS_HIJACK_APP_BLOCK_EXACT, ""); + editor.putString(DNS_HIJACK_APP_ALLOW_SUFFIX, ""); + editor.putString(DNS_HIJACK_APP_BLOCK_SUFFIX, ""); + editor.putString(DNS_HIJACK_NETWORK_ALLOW, ""); + editor.putString(DNS_HIJACK_NETWORK_BLOCK, ""); + editor.putString(DNS_HIJACK_ALLOW_REGEX, ""); + editor.putString(DNS_HIJACK_BLOCK_REGEX, ""); + editor.putString(DNS_HIJACK_TEMP_ALLOW, ""); + editor.putString(DNS_HIJACK_TEMP_BLOCK, ""); + editor.putString(DNS_HIJACK_BLOCKLIST_URLS, blocklistUrls); + editor.putBoolean(DNS_HIJACK_SCHEDULED_BLOCKLIST_UPDATES, scheduledBlocklistUpdates); + editor.putString(DNS_HIJACK_BLOCKLIST_UPDATE_INTERVAL_HOURS, updateIntervalHours); + } + + public static boolean appendDnsHijackAllowExact(String domain) { + return appendLinePreference(dnsWritablePolicyPrefs(), DNS_HIJACK_ALLOW_EXACT, domain); + } + + public static boolean appendDnsHijackAllowSuffix(String domain) { + return appendLinePreference(dnsWritablePolicyPrefs(), DNS_HIJACK_ALLOW_SUFFIX, domain); + } + + public static boolean appendDnsHijackBlockExact(String domain) { + return appendLinePreference(dnsWritablePolicyPrefs(), DNS_HIJACK_BLOCK_EXACT, domain); + } + + public static boolean appendDnsHijackBlockSuffix(String domain) { + return appendLinePreference(dnsWritablePolicyPrefs(), DNS_HIJACK_BLOCK_SUFFIX, domain); + } + + public static boolean appendDnsHijackAppAllowExact(int uid, String domain) { + return appendUidDomainPreference(dnsWritablePolicyPrefs(), DNS_HIJACK_APP_ALLOW_EXACT, uid, domain); + } + + public static boolean appendDnsHijackAppBlockExact(int uid, String domain) { + return appendUidDomainPreference(dnsWritablePolicyPrefs(), DNS_HIJACK_APP_BLOCK_EXACT, uid, domain); + } + + public static boolean appendDnsHijackAppAllowSuffix(int uid, String domain) { + return appendUidDomainPreference(dnsWritablePolicyPrefs(), DNS_HIJACK_APP_ALLOW_SUFFIX, uid, domain); + } + + public static boolean appendDnsHijackAppBlockSuffix(int uid, String domain) { + return appendUidDomainPreference(dnsWritablePolicyPrefs(), DNS_HIJACK_APP_BLOCK_SUFFIX, uid, domain); + } + + public static boolean appendDnsHijackTempAllow(String domain, long expiresAtSeconds) { + return appendTemporaryLinePreference(dnsWritablePolicyPrefs(), DNS_HIJACK_TEMP_ALLOW, domain, expiresAtSeconds); + } + + public static boolean appendDnsHijackTempBlock(String domain, long expiresAtSeconds) { + return appendTemporaryLinePreference(dnsWritablePolicyPrefs(), DNS_HIJACK_TEMP_BLOCK, domain, expiresAtSeconds); + } + + public static void pruneExpiredDnsHijackTemporaryRules() { + SharedPreferences prefs = dnsWritablePolicyPrefs(); + pruneExpiredTemporaryPreference(prefs, DNS_HIJACK_TEMP_ALLOW); + pruneExpiredTemporaryPreference(prefs, DNS_HIJACK_TEMP_BLOCK); + } + + private static SharedPreferences dnsPolicyPrefs() { + return usingActiveDnsProfilePolicy() ? activeDnsProfilePrefs() : gPrefs; + } + + private static SharedPreferences dnsWritablePolicyPrefs() { + return usingActiveDnsProfilePolicy() ? activeDnsProfilePrefs() : gPrefs; + } + + private static boolean usingActiveDnsProfilePolicy() { + return dnsHijackUseProfilePolicy() && activeDnsHijackProfilePolicySaved(); + } + + private static SharedPreferences activeDnsProfilePrefs() { + if (ctx == null) { + return pPrefs; + } + if (pPrefs == null) { + reloadPrefs(); + } + return pPrefs; + } + + @SuppressWarnings("unchecked") + private static void copyPreferenceValue(SharedPreferences source, SharedPreferences.Editor target, + String key) { + if (source == null || !source.contains(key)) { + target.remove(key); + return; + } + Object value = source.getAll().get(key); + if (value instanceof Boolean) { + target.putBoolean(key, (Boolean) value); + } else if (value instanceof Integer) { + target.putInt(key, (Integer) value); + } else if (value instanceof Long) { + target.putLong(key, (Long) value); + } else if (value instanceof Float) { + target.putFloat(key, (Float) value); + } else if (value instanceof Set) { + target.putStringSet(key, (Set) value); + } else if (value != null) { + target.putString(key, String.valueOf(value)); + } + } + + private static boolean appendLinePreference(SharedPreferences prefs, String key, String rawValue) { + if (rawValue == null) { + return false; + } + String value = rawValue.trim().toLowerCase(java.util.Locale.US); + if (value.isEmpty()) { + return false; + } + LinkedHashSet values = new LinkedHashSet<>(); + String existing = prefs.getString(key, ""); + if (existing != null) { + String[] lines = existing.split("[\\r\\n,]+"); + for (String line : lines) { + String existingValue = line == null ? "" : line.trim().toLowerCase(java.util.Locale.US); + if (!existingValue.isEmpty()) { + values.add(existingValue); + } + } + } + boolean added = values.add(value); + if (added) { + prefs.edit().putString(key, android.text.TextUtils.join("\n", values)).commit(); + } + return added; + } + + private static boolean appendTemporaryLinePreference(SharedPreferences prefs, String key, + String rawValue, long expiresAtSeconds) { + if (rawValue == null || expiresAtSeconds <= (System.currentTimeMillis() / 1000L)) { + return false; + } + String value = rawValue.trim().toLowerCase(java.util.Locale.US); + if (value.isEmpty()) { + return false; + } + LinkedHashSet values = new LinkedHashSet<>(); + String prefix = value + "|"; + String existing = prefs.getString(key, ""); + boolean changed = false; + if (existing != null) { + String[] lines = existing.split("[\\r\\n]+"); + long now = System.currentTimeMillis() / 1000L; + for (String line : lines) { + String existingValue = line == null ? "" : line.trim().toLowerCase(java.util.Locale.US); + if (existingValue.isEmpty() || isExpiredTemporaryLine(existingValue, now)) { + changed = true; + continue; + } + if (existingValue.startsWith(prefix)) { + changed = true; + continue; + } + values.add(existingValue); + } + } + String entry = value + "|" + expiresAtSeconds; + boolean added = values.add(entry); + if (added || changed) { + prefs.edit().putString(key, android.text.TextUtils.join("\n", values)).commit(); + } + return added || changed; + } + + private static boolean appendUidDomainPreference(SharedPreferences prefs, String key, + int uid, String rawDomain) { + if (uid < 0 || rawDomain == null) { + return false; + } + String domain = rawDomain.trim().toLowerCase(java.util.Locale.US); + if (domain.isEmpty()) { + return false; + } + return appendLinePreference(prefs, key, uid + "|" + domain); + } + + private static LinkedHashSet readUidSet(SharedPreferences prefs, String key) { + LinkedHashSet values = new LinkedHashSet<>(); + String existing = prefs.getString(key, ""); + if (existing == null || existing.trim().isEmpty()) { + return values; + } + String[] tokens = existing.split("[\\r\\n,]+"); + for (String token : tokens) { + if (token == null) { + continue; + } + String value = token.trim(); + if (value.isEmpty()) { + continue; + } + try { + int uid = Integer.parseInt(value); + if (uid >= 0) { + values.add(uid); + } + } catch (NumberFormatException ignored) { + } + } + return values; + } + + private static String joinUidSet(LinkedHashSet values) { + List out = new ArrayList<>(); + for (Integer value : values) { + if (value != null && value >= 0) { + out.add(String.valueOf(value)); + } + } + return android.text.TextUtils.join("\n", out); + } + + private static void pruneExpiredTemporaryPreference(SharedPreferences prefs, String key) { + String existing = prefs.getString(key, ""); + if (existing == null || existing.trim().isEmpty()) { + return; + } + LinkedHashSet values = new LinkedHashSet<>(); + boolean changed = false; + long now = System.currentTimeMillis() / 1000L; + String[] lines = existing.split("[\\r\\n]+"); + for (String line : lines) { + String value = line == null ? "" : line.trim().toLowerCase(java.util.Locale.US); + if (value.isEmpty() || isExpiredTemporaryLine(value, now)) { + changed = true; + continue; + } + values.add(value); + } + if (changed) { + prefs.edit().putString(key, android.text.TextUtils.join("\n", values)).commit(); + } + } + + private static boolean isExpiredTemporaryLine(String value, long nowSeconds) { + int separator = value.lastIndexOf('|'); + if (separator <= 0 || separator + 1 >= value.length()) { + return true; + } + try { + return Long.parseLong(value.substring(separator + 1)) <= nowSeconds; + } catch (NumberFormatException e) { + return true; + } + } + + private static int readIntPreference(String key, int fallback, int min, int max) { + return readIntPreference(gPrefs, key, fallback, min, max); + } + + private static int readIntPreference(SharedPreferences prefs, String key, int fallback, int min, int max) { + String value = prefs.getString(key, String.valueOf(fallback)); + try { + int parsed = Integer.parseInt(value); + if (parsed < min || parsed > max) { + return fallback; + } + return parsed; + } catch (NumberFormatException e) { + return fallback; + } + } + public static boolean hasCopyOld() { return gPrefs.getBoolean(COPIED_OLD_EXPORTS, false); diff --git a/app/src/main/res/layout/activity_dns_queries.xml b/app/src/main/res/layout/activity_dns_queries.xml new file mode 100644 index 000000000..93b8612b7 --- /dev/null +++ b/app/src/main/res/layout/activity_dns_queries.xml @@ -0,0 +1,35 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/log_hub.xml b/app/src/main/res/layout/log_hub.xml index a85234001..620511ead 100644 --- a/app/src/main/res/layout/log_hub.xml +++ b/app/src/main/res/layout/log_hub.xml @@ -25,6 +25,183 @@ android:orientation="vertical" android:padding="12dp"> + + + + + + + + + + + + + + + + + +