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">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
IPv4 Chains
IPv6 Chains
Rules/Connectivity
+ Experimental Root DNS Filtering
+ Capture DNS with root redirect
+ Run the native DNS daemon and redirect UDP/TCP DNS traffic through it when firewall rules are applied.
+ Local DNS daemon port
+ Port used by the local root DNS daemon. Reapply firewall rules after changing this.
+ Upstream DNS servers
+ One upstream per line. Use host:port, udp://host:port, tcp://host:port, or bracketed IPv6.
+ Upstream DNS provider presets
+ Choose a known resolver pair using supported UDP or TCP DNS transports.
+ DNS upstream provider applied: %1$s.
+ Unable to apply DNS upstream provider.
+ Bootstrap DNS servers
+ Resolvers used to resolve hostname-based upstreams before writing daemon config.
+ Benchmark upstream DNS
+ Probe configured upstream resolvers and report per-server latency.
+ No benchmark result was returned.
+ Split DNS routes
+ Route matching domains to dedicated upstreams, one per line, such as corp.example=tcp://10.0.0.53:53.
+ Limit DNS capture to UIDs
+ Optional app UID list for apps that open their own port-53 DNS sockets. Android system resolver traffic may use a system UID, so leave this empty for full DNS capture.
+ Bypass DNS capture for UIDs
+ Optional app UID list that keeps matching apps on the normal resolver path.
+ Limit DNS capture to interfaces
+ Optional network interface patterns to capture, such as wlan+, rmnet+, or tun+. When set, other interfaces bypass DNS filtering.
+ Bypass DNS capture on interfaces
+ Optional network interface patterns that should use the normal resolver path.
+ Fail open
+ Allow upstream DNS when filtering cannot complete instead of blocking the query.
+ Strict allow/block priority
+ When enabled, exact and wildcard block rules can override allow rules.
+ Force SafeSearch
+ Return daemon-local safe search DNS answers for supported search and video providers.
+ Request DNSSEC records
+ Ask upstream resolvers for DNSSEC material with EDNS(0). This forwards DNSSEC data but does not perform local validation.
+ Require DNSSEC authentication
+ Only accept upstream answers with the DNSSEC authenticated-data bit. Requires a validating upstream and is not local validation.
+ Use Magisk DNS module at boot
+ Install or remove the AFWall DNS Magisk module wrapper for boot restore and fail-open cleanup.
+ Allow ADB DNS diagnostics
+ Temporarily allow token-authenticated root access to the DNS daemon control socket and loopback debug port over ADB for testing.
+ Review Android power settings
+ AFWall+ is battery optimized. The root DNS daemon can keep running, but scheduled DNS updates and app log sync may be delayed.
+ AFWall+ is not battery optimized. Scheduled DNS updates and app log sync are less likely to be delayed.
+ Battery optimization controls are not available on this Android version.
+ Android power state could not be read. Open settings to review battery restrictions.
+ Opening Android power settings.
+ Unable to open Android power settings.
+ Review Android Private DNS
+ Private DNS is active and can bypass root DNS capture. Open Android network settings if full DNS capture is required.
+ Private DNS is not currently bypassing UDP/TCP port 53 DNS capture.
+ Private DNS controls are not available on this Android version.
+ Android Private DNS state could not be read. Open network settings to review it.
+ Opening Android Private DNS settings.
+ Unable to open Android Private DNS settings.
+ Install or repair DNS service
+ Reinstall daemon files, restart the watchdog, restore DNS redirects, and refresh the Magisk module wrapper.
+ Validate DNS configuration
+ Check whether the running DNS service accepts the current DNS settings and rule lists.
+ DNS module status
+ Check the Magisk module, control socket permissions, module log, and old startup hooks.
+ DNS module status requested.
+ Remove DNS service
+ Stop the daemon, remove DNS redirect rules, and remove the Magisk module wrapper.
+ Remove the DNS daemon, redirect rules, and Magisk module wrapper?
+ Emergency DNS cleanup
+ Directly remove DNS redirect rules, stop the daemon, and disable the Magisk module wrapper.
+ Run direct DNS recovery cleanup now? This removes DNS redirect rules, stops the daemon, and disables the Magisk module wrapper.
+ Emergency DNS cleanup requested.
+ Emergency DNS cleanup completed.
+ Emergency DNS cleanup failed. Check root access and diagnostics.
+ DNS protection installed and enabled.
+ DNS protection removed and disabled.
+ Unable to enable DNS protection. Check root access and diagnostics.
+ Unable to remove DNS protection. Check root access and diagnostics.
+ DNS redirect settings applied.
+ Unable to apply DNS redirect settings. Check diagnostics.
+ Android Private DNS may bypass DNS capture. Check DNS diagnostics.
+ DNS Magisk module installed.
+ DNS Magisk module removed.
+ Unable to update DNS Magisk module.
+ Upstream timeout
+ DNS upstream timeout in milliseconds.
+ DNS cache size
+ Maximum daemon DNS cache entries. Use 0 to disable caching; maximum is 4096.
+ Stale cache window
+ Seconds the daemon may answer from an expired cache entry after all upstream resolvers fail. Use 0 to disable; maximum is 86400.
+ Restore DNS cache after restart
+ Save fresh daemon cache entries to app-private storage during reload or shutdown and restore matching entries on daemon start.
+ DNS query logging
+ Keep live DNS query rows in the daemon ring buffer for diagnostics and the query viewer.
+ Save DNS query history
+ Write daemon query logs to app-private storage for historical search and export.
+ Use active profile DNS policy
+ When enabled, saved DNS settings and compiled blocklists follow the active firewall profile.
+ Apply DNS profile preset
+ Seed common Default, Strict, Kids, Work, or Gaming DNS policy values into the current settings.
+ Save DNS policy to active profile
+ Copy the current DNS upstreams, rules, blocklist URLs, and update schedule into the active profile.
+ Clear active profile DNS policy
+ Remove the active profile DNS override and fall back to the global DNS settings.
+ DNS profile preset applied: %1$s.
+ Unable to apply DNS profile preset.
+ DNS policy saved for active profile.
+ Active profile DNS policy cleared.
+ Unable to update active profile DNS policy.
+ Exact allow domains
+ Domains that should always be allowed. One entry per line.
+ Wildcard allow suffixes
+ Suffix rules that allow matching domains, such as example.com.
+ Exact block domains
+ Domains that should be blocked exactly. One entry per line.
+ Wildcard block suffixes
+ Suffix rules that block matching subdomains and domains.
+ Per-app exact allow domains
+ UID and domain pairs that should be allowed before global block rules. Use one uid|domain entry per line.
+ Per-app exact block domains
+ UID and domain pairs that should be blocked for one app. Use one uid|domain entry per line.
+ Per-app wildcard allow suffixes
+ UID and suffix pairs that should allow matching subdomains for one app. Use one uid|domain entry per line.
+ Per-app wildcard block suffixes
+ UID and suffix pairs that should block matching subdomains for one app. Use one uid|domain entry per line.
+ Per-network allow CIDRs
+ Source networks allowed before global block rules. Use one IPv4 or IPv6 CIDR per line, such as 192.168.1.0/24 or fd00::/8.
+ Per-network block CIDRs
+ Source networks blocked before global exact and wildcard rules. Use one IPv4 or IPv6 CIDR per line.
+ Regex allow rules
+ Regular expressions that allow matching queries.
+ Regex block rules
+ Regular expressions that block matching queries.
+ Blocklist URLs
+ One remote hosts, domain, Pi-hole, or AdBlock-style list URL per line.
+ Blocklist presets
+ Add maintained DNS blocklist sources to the URL list.
+ Selected presets are added to the configured URL list. Run update to download and activate them.
+ Added %1$d preset URL(s). Already configured: %2$d.
+ Scheduled blocklist updates
+ Periodically download configured DNS blocklist URLs and reload the daemon after a successful compile.
+ Blocklist update interval
+ How often scheduled DNS blocklist updates should run, in hours.
+ Import DNS blocklist file
+ Use the system picker to import a hosts, domain, Pi-hole, AdBlock-style, or JSON backup list.
+ Paste DNS blocklist
+ Paste copied hosts, domain, Pi-hole, AdBlock-style, or JSON backup entries and activate them.
+ Paste blocklist entries here
+ Update DNS blocklists
+ Download configured URLs, compile them, and reload the daemon.
+ Rollback DNS blocklist
+ Restore the previous compiled DNS blocklist files.
+ DNS blocklist activated. Reapply firewall rules if DNS capture is not already running.
+ DNS blocklist update failed.
+ DNS daemon diagnostics
+ Loading DNS daemon diagnostics
+ Repair DNS protection
+ Reload DNS daemon
+ Restart DNS daemon
+ Stop DNS daemon
+ Emergency DNS cleanup
+ Export DNS diagnostics
+ DNS protection repair requested.
+ DNS daemon reload requested.
+ DNS daemon restart requested.
+ DNS daemon stop requested.
+ DNS diagnostics exported.
+ No file picker found. DNS diagnostics copied instead.
+ DNS Protection
+ Loading DNS protection status...
+ Pause protection
+ Resume protection
+ Test DNS capture
+ DNS capture test
+ DNS capture test requested.
+ Service events
+ DNS service events
+ No DNS service events recorded.
+ Validate config
+ DNS config validation
+ DNS config validation requested.
+ Test control auth
+ DNS control auth test
+ DNS control auth test requested.
+ DNS upstream benchmark requested.
+ Flush DNS cache
+ DNS cache flush requested.
+ DNS cache flush
+ Flush query logs
+ DNS query log flush requested.
+ DNS query log flush
+ Clear query logs
+ DNS query log clear requested.
+ Clear DNS query logs
+ Clear the daemon query log ring and saved DNS query history?
+ DNS protection pause requested.
+ DNS protection resume requested.
+ DNS service removal requested.
+ DNS protection is disabled.
+
+
+ - StevenBlack Unified hosts
+ - AdAway hosts
+ - OISD small
+ - Hagezi Light
+
+
+
+ - https://raw.githubusercontent.com/stevenblack/hosts/master/hosts
+ - https://adaway.org/hosts.txt
+ - https://small.oisd.nl/
+ - https://raw.githubusercontent.com/hagezi/dns-blocklists/main/domains/light.txt
+
+
+
+ - Cloudflare UDP
+ - Cloudflare TCP
+ - Quad9 UDP
+ - Quad9 TCP
+ - Google UDP
+ - AdGuard DNS UDP
+ - CleanBrowsing Family UDP
+
+
+
+ - cloudflare-udp
+ - cloudflare-tcp
+ - quad9-udp
+ - quad9-tcp
+ - google-udp
+ - adguard-udp
+ - cleanbrowsing-family-udp
+
+
+
+ - Default
+ - Strict
+ - Kids
+ - Work
+ - Gaming
+
+
+
+ - default
+ - strict
+ - kids
+ - work
+ - gaming
+
+ DNS Queries
+ View recent daemon DNS queries and quickly add allow or block rules.
+ %1$d DNS queries loaded.
+ History
+ Live
+ %1$d historical DNS queries loaded.
+ %1$d historical DNS queries matched \"%2$s\".
+ Domain, action, or latency
+ DNS queries exported.
+ No file picker found. DNS queries copied instead.
+ Allow this domain
+ Allow this domain and subdomains
+ Temporarily allow this domain
+ Enable DNS capture for this app
+ Disable DNS capture for this app
+ Allow for this app
+ Allow this app and subdomains
+ Block this domain
+ Block this domain and subdomains
+ Temporarily block this domain
+ Block for this app
+ Block this app and subdomains
+ View rule
+ DNS decision
+ View app
+ Unable to identify the application for this DNS query.
+ DNS rule added and daemon reload requested.
+ DNS rule already exists.
+ DNS capture enabled for this app.
+ DNS capture disabled for this app.
+ DNS capture already matches this app.
Log
Hide app icons
Improve performance by disabling app icons in the main view
diff --git a/app/src/main/res/xml/rules_preferences.xml b/app/src/main/res/xml/rules_preferences.xml
index 2dbbc56ca..2e78429c4 100644
--- a/app/src/main/res/xml/rules_preferences.xml
+++ b/app/src/main/res/xml/rules_preferences.xml
@@ -69,6 +69,355 @@
android:title="@string/direct_rules_visibility_title" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define MAX_PACKET 4096
+#define MAX_DOMAIN 256
+#define MAX_LABEL 64
+#define INITIAL_RULE_CAPACITY 1024
+#define INITIAL_REGEX_CAPACITY 16
+#define MIN_EXACT_INDEX_SIZE 65536
+#define MAX_EXACT_INDEX_SIZE (1 << 24)
+#define MAX_TEMP_RULES 1024
+#define MAX_NETWORK_RULES 128
+#define MAX_UPSTREAMS 8
+#define MAX_SPLIT_UPSTREAMS 32
+#define DEFAULT_CACHE_SIZE 1024
+#define MAX_CACHE_SIZE 4096
+#define LOG_RING 256
+#define LOG_HISTORY_LIMIT 512
+#define LOG_LINE_MAX 512
+#define DEFAULT_PORT 5354
+#define DEFAULT_TIMEOUT_MS 2500
+#define CLIENT_TIMEOUT_MS 5000
+#define UPSTREAM_BACKOFF_FAILURE_THRESHOLD 2
+#define UPSTREAM_BACKOFF_MIN_SECONDS 2
+#define UPSTREAM_BACKOFF_MAX_SECONDS 60
+#define DEFAULT_POSITIVE_TTL 60
+#define DEFAULT_NEGATIVE_TTL 30
+#define MAX_CACHE_TTL 86400
+#define DEFAULT_STALE_CACHE_SECONDS 300
+#define MAX_STALE_CACHE_SECONDS 86400
+#define CACHE_SNAPSHOT_MAGIC "AFWDNSC1"
+#define CACHE_SNAPSHOT_VERSION 2
+#define DNS_SOCKET_BUFFER_BYTES 262144
+#define UDP_DRAIN_LIMIT 32
+#define UID_UNKNOWN -1
+#define UID_CACHE_SIZE 128
+#define UID_CACHE_TTL 15
+#define AFWALL_DNSD_SOCKET_MARK 0xAF053
+#define AFWALL_DNSD_CHAIN_V4 "afwall-dns"
+#define AFWALL_DNSD_CHAIN_V4_PRE "afwall-dns-pre"
+#define AFWALL_DNSD_CHAIN_V6 "afwall-dns6"
+#define AFWALL_DNSD_CHAIN_V6_PRE "afwall-dns6-pre"
+#define AFWALL_DNSD_CHAIN_FILTER "afwall-dns-out"
+#define AFWALL_DNSD_NFT_TABLE_V4 "afwall_dns"
+#define AFWALL_DNSD_NFT_TABLE_V6 "afwall_dns6"
+#define DNS_QTYPE_A 1
+#define DNS_QTYPE_OPT 41
+#define DNS_QTYPE_AAAA 28
+#define DNS_CLASS_IN 1
+#define DNS_FLAG_AD 0x0020u
+#define DNSSEC_DO_FLAG 0x8000u
+#define DNSSEC_UDP_PAYLOAD_SIZE 4096
+#define SAFE_SEARCH_TTL 300
+
+#ifndef MSG_DONTWAIT
+#define AFWALL_HAS_MSG_DONTWAIT 0
+#define MSG_DONTWAIT 0
+#else
+#define AFWALL_HAS_MSG_DONTWAIT 1
+#endif
+
+#ifndef SO_MARK
+#define SO_MARK 36
+#endif
+
+typedef enum {
+ DECISION_ALLOW = 0,
+ DECISION_BLOCK = 1
+} decision_t;
+
+typedef struct {
+ char value[MAX_DOMAIN];
+} string_rule_t;
+
+typedef struct {
+ string_rule_t *items;
+ int count;
+ int capacity;
+} string_rule_list_t;
+
+typedef struct {
+ int uid;
+ char domain[MAX_DOMAIN];
+} app_exact_rule_t;
+
+typedef struct {
+ app_exact_rule_t *items;
+ int count;
+ int capacity;
+} app_exact_rule_list_t;
+
+typedef struct {
+ int family;
+ uint8_t address[16];
+ int prefix_len;
+} network_rule_t;
+
+typedef struct {
+ regex_t compiled;
+ char pattern[MAX_DOMAIN];
+ bool valid;
+} regex_rule_t;
+
+typedef struct {
+ regex_rule_t *items;
+ int count;
+ int capacity;
+} regex_rule_list_t;
+
+typedef struct {
+ char value[MAX_DOMAIN];
+ uint64_t expires_at;
+} temp_rule_t;
+
+typedef enum {
+ UPSTREAM_PROTO_AUTO = 0,
+ UPSTREAM_PROTO_UDP = 1,
+ UPSTREAM_PROTO_TCP = 2
+} upstream_protocol_t;
+
+typedef struct {
+ char host[128];
+ int port;
+ upstream_protocol_t protocol;
+ struct sockaddr_storage addr;
+ socklen_t addr_len;
+ int udp_fd;
+ uint64_t requests;
+ uint64_t successes;
+ uint64_t failures;
+ uint64_t total_latency_ms;
+ uint64_t max_latency_ms;
+ uint64_t last_used;
+ uint64_t consecutive_failures;
+ uint64_t backoff_until;
+ int last_latency_ms;
+ int last_rcode;
+} upstream_t;
+
+typedef struct {
+ char suffix[MAX_DOMAIN];
+ upstream_t upstream;
+} split_upstream_t;
+
+typedef struct {
+ char ipv4[INET_ADDRSTRLEN];
+ char ipv6[INET6_ADDRSTRLEN];
+} safe_search_target_t;
+
+typedef struct {
+ char label[MAX_LABEL];
+ int first_child;
+ int next_sibling;
+ bool terminal;
+} suffix_trie_node_t;
+
+typedef struct {
+ suffix_trie_node_t *nodes;
+ int count;
+ int capacity;
+} suffix_trie_t;
+
+typedef struct {
+ char label[MAX_LABEL];
+ int first_child;
+ int next_sibling;
+ int *uids;
+ int uid_count;
+ int uid_capacity;
+} app_suffix_trie_node_t;
+
+typedef struct {
+ app_suffix_trie_node_t *nodes;
+ int count;
+ int capacity;
+} app_suffix_trie_t;
+
+typedef struct {
+ int listen_port;
+ int strict_mode;
+ int fail_open;
+ int timeout_ms;
+ int cache_size;
+ int stale_cache_seconds;
+ int persist_cache;
+ int query_logging;
+ int persist_query_logs;
+ int safe_search;
+ int dnssec_request;
+ int dnssec_auth_required;
+ int control_socket_uid;
+ int control_adb_debug;
+ int control_tcp_port;
+ char control_socket[256];
+ char control_token[128];
+ char log_file[256];
+ char event_log_file[256];
+ char cache_file[256];
+ char pid_file[256];
+ char heartbeat_file[256];
+ char mark_status_file[256];
+ char iptables_path[256];
+ char ip6tables_path[256];
+ safe_search_target_t safe_google;
+ safe_search_target_t safe_youtube;
+ safe_search_target_t safe_bing;
+ safe_search_target_t safe_duckduckgo;
+ upstream_t upstreams[MAX_UPSTREAMS];
+ int upstream_count;
+ split_upstream_t split_upstreams[MAX_SPLIT_UPSTREAMS];
+ int split_upstream_count;
+ string_rule_list_t exact_allow;
+ string_rule_list_t suffix_allow;
+ string_rule_list_t exact_block;
+ string_rule_list_t suffix_block;
+ app_exact_rule_list_t app_exact_allow;
+ app_exact_rule_list_t app_exact_block;
+ app_exact_rule_list_t app_suffix_allow;
+ app_exact_rule_list_t app_suffix_block;
+ network_rule_t network_allow[MAX_NETWORK_RULES];
+ network_rule_t network_block[MAX_NETWORK_RULES];
+ int network_allow_count;
+ int network_block_count;
+ int *app_exact_allow_index;
+ int app_exact_allow_index_size;
+ int *app_exact_block_index;
+ int app_exact_block_index_size;
+ int *exact_allow_index;
+ int exact_allow_index_size;
+ int *exact_block_index;
+ int exact_block_index_size;
+ suffix_trie_t suffix_allow_trie;
+ suffix_trie_t suffix_block_trie;
+ app_suffix_trie_t app_suffix_allow_trie;
+ app_suffix_trie_t app_suffix_block_trie;
+ regex_rule_list_t regex_allow;
+ regex_rule_list_t regex_block;
+ temp_rule_t temp_allow[MAX_TEMP_RULES];
+ temp_rule_t temp_block[MAX_TEMP_RULES];
+ int temp_allow_count;
+ int temp_block_count;
+ uint64_t resolver_scope_hash;
+ uint64_t generation;
+} config_t;
+
+typedef struct {
+ uint64_t queries;
+ uint64_t allowed;
+ uint64_t blocked;
+ uint64_t queries_today;
+ uint64_t allowed_today;
+ uint64_t blocked_today;
+ uint64_t day_start_time;
+ uint64_t cache_hits;
+ uint64_t cache_misses;
+ uint64_t cache_stores;
+ uint64_t cache_positive_hits;
+ uint64_t cache_negative_hits;
+ uint64_t cache_positive_stores;
+ uint64_t cache_negative_stores;
+ uint64_t cache_expired;
+ uint64_t cache_evictions;
+ uint64_t cache_ttl_rewrites;
+ uint64_t cache_lru_evictions;
+ uint64_t cache_stale_hits;
+ uint64_t cache_stale_negative_hits;
+ uint64_t cache_stale_expired;
+ uint64_t cache_snapshot_restored;
+ uint64_t cache_snapshot_saved;
+ uint64_t cache_snapshot_load_failures;
+ uint64_t cache_snapshot_save_failures;
+ uint64_t cache_reload_preserved;
+ uint64_t cache_reload_dropped;
+ uint64_t cache_reload_scope_changes;
+ uint64_t udp_queries;
+ uint64_t tcp_queries;
+ uint64_t invalid_queries;
+ uint64_t udp_drain_batches;
+ uint64_t udp_drain_packets;
+ uint64_t tcp_client_timeouts;
+ uint64_t control_client_timeouts;
+ uint64_t fail_open_drops;
+ uint64_t fail_closed_blocks;
+ uint64_t safe_search_rewrites;
+ uint64_t dnssec_queries;
+ uint64_t dnssec_client_queries;
+ uint64_t dnssec_opt_added;
+ uint64_t dnssec_opt_updated;
+ uint64_t dnssec_prepare_failures;
+ uint64_t dnssec_auth_failures;
+ uint64_t upstream_requests;
+ uint64_t upstream_successes;
+ uint64_t upstream_failures;
+ uint64_t upstream_tcp_fallbacks;
+ uint64_t upstream_truncated_responses;
+ uint64_t upstream_udp_socket_reuses;
+ uint64_t upstream_udp_stale_replies;
+ uint64_t upstream_backoff_skips;
+ uint64_t total_latency_ms;
+ uint64_t upstream_latency_ms;
+ uint64_t max_latency_ms;
+ uint64_t reloads;
+ uint64_t reload_failures;
+ uint64_t validations;
+ uint64_t validation_failures;
+ uint64_t uid_lookup_successes;
+ uint64_t uid_lookup_misses;
+ uint64_t uid_cache_hits;
+ uint64_t socket_mark_failures;
+ uint64_t start_time;
+} stats_t;
+
+typedef struct {
+ uint64_t seq;
+ char domain[MAX_DOMAIN];
+ char action[32];
+ char transport[8];
+ char source[64];
+ int uid;
+ char result[16];
+ char rule[32];
+ char upstream[32];
+ uint16_t qtype;
+ int latency_ms;
+ uint64_t timestamp;
+} log_entry_t;
+
+typedef struct {
+ char domain[MAX_DOMAIN];
+ uint16_t qtype;
+ uint8_t response[MAX_PACKET];
+ size_t response_len;
+ time_t cached_at;
+ time_t last_access;
+ time_t expires_at;
+ uint32_t hash;
+ bool used;
+ bool negative;
+ bool dnssec;
+ bool expired_reported;
+} cache_entry_t;
+
+typedef struct {
+ uint16_t port;
+ int family;
+ int tcp;
+ int uid;
+ uint64_t expires_at;
+ bool used;
+} uid_cache_entry_t;
+
+static volatile sig_atomic_t g_running = 1;
+static volatile sig_atomic_t g_reload_requested = 0;
+static config_t g_cfg;
+static stats_t g_stats;
+static log_entry_t g_logs[LOG_RING];
+static int g_log_pos = 0;
+static uint64_t g_log_seq = 0;
+static uint64_t g_log_flushed_seq = 0;
+static pthread_mutex_t g_log_mutex = PTHREAD_MUTEX_INITIALIZER;
+static pthread_mutex_t g_log_flush_mutex = PTHREAD_MUTEX_INITIALIZER;
+static pthread_t g_log_thread;
+static bool g_log_thread_started = false;
+static volatile sig_atomic_t g_log_thread_running = 0;
+static char g_log_file_path[256];
+static char g_event_log_file_path[256];
+static bool g_log_persistence_enabled = true;
+static cache_entry_t *g_cache = NULL;
+static int g_cache_capacity = 0;
+static uid_cache_entry_t g_uid_cache[UID_CACHE_SIZE];
+static int g_uid_cache_pos = 0;
+static char g_config_path[256];
+static int g_udp_listener_ready = 0;
+static int g_tcp_listener_ready = 0;
+static int g_udp_listener_v4_ready = 0;
+static int g_udp_listener_v6_ready = 0;
+static int g_tcp_listener_v4_ready = 0;
+static int g_tcp_listener_v6_ready = 0;
+static int g_control_listener_ready = 0;
+static int g_control_tcp_listener_ready = 0;
+static int g_control_tcp_fd = -1;
+static int g_socket_mark_supported = 0;
+static int g_socket_mark_errno = 0;
+
+static void write_control_response(int fd, const char *fmt, ...);
+static int control_peer_uid_enforced(int expected_uid);
+static bool shell_command_word_safe(const char *value);
+static int cleanup_tool_ready(const char *configured, const char *fallback);
+
+static bool config_bool_value(const char *value) {
+ return value != NULL && atoi(value) != 0;
+}
+
+static bool valid_control_token_value(const char *token) {
+ size_t i;
+ size_t len;
+ if (token == NULL) {
+ return false;
+ }
+ len = strlen(token);
+ if (len < 64 || len > 96) {
+ return false;
+ }
+ for (i = 0; i < len; i++) {
+ if (!isxdigit((unsigned char) token[i])) {
+ return false;
+ }
+ }
+ return true;
+}
+
+static uint64_t now_seconds(void) {
+ return (uint64_t) time(NULL);
+}
+
+static uint64_t local_day_start_seconds(time_t now) {
+ struct tm local_time;
+ if (localtime_r(&now, &local_time) == NULL) {
+ return (uint64_t) (now - (now % 86400));
+ }
+ local_time.tm_hour = 0;
+ local_time.tm_min = 0;
+ local_time.tm_sec = 0;
+ local_time.tm_isdst = -1;
+ return (uint64_t) mktime(&local_time);
+}
+
+static void ensure_daily_stats_current(void) {
+ time_t now = time(NULL);
+ uint64_t day_start = local_day_start_seconds(now);
+ if (g_stats.day_start_time == day_start) {
+ return;
+ }
+ g_stats.day_start_time = day_start;
+ g_stats.queries_today = 0;
+ g_stats.allowed_today = 0;
+ g_stats.blocked_today = 0;
+}
+
+static void record_query_seen(void) {
+ ensure_daily_stats_current();
+ g_stats.queries++;
+ g_stats.queries_today++;
+}
+
+static void record_query_allowed(void) {
+ ensure_daily_stats_current();
+ g_stats.allowed++;
+ g_stats.allowed_today++;
+}
+
+static void record_query_blocked(void) {
+ ensure_daily_stats_current();
+ g_stats.blocked++;
+ g_stats.blocked_today++;
+}
+
+static int elapsed_ms(const struct timeval *start, const struct timeval *end) {
+ long seconds = end->tv_sec - start->tv_sec;
+ long micros = end->tv_usec - start->tv_usec;
+ return (int) ((seconds * 1000L) + (micros / 1000L));
+}
+
+static void trim(char *s) {
+ size_t len;
+ char *p = s;
+ while (*p && isspace((unsigned char) *p)) {
+ p++;
+ }
+ if (p != s) {
+ memmove(s, p, strlen(p) + 1);
+ }
+ len = strlen(s);
+ while (len > 0 && isspace((unsigned char) s[len - 1])) {
+ s[len - 1] = '\0';
+ len--;
+ }
+}
+
+static void lower_ascii(char *s) {
+ while (*s) {
+ *s = (char) tolower((unsigned char) *s);
+ s++;
+ }
+}
+
+static void safe_copy(char *dst, size_t dst_len, const char *src) {
+ if (dst_len == 0) {
+ return;
+ }
+ if (src == NULL) {
+ dst[0] = '\0';
+ return;
+ }
+ strncpy(dst, src, dst_len - 1);
+ dst[dst_len - 1] = '\0';
+}
+
+static void set_event_log_output(const char *path) {
+ safe_copy(g_event_log_file_path, sizeof(g_event_log_file_path),
+ path == NULL ? "" : path);
+}
+
+static void write_event_log(const char *level, const char *fmt, ...) {
+ FILE *fp;
+ va_list ap;
+ if (g_event_log_file_path[0] == '\0' || fmt == NULL) {
+ return;
+ }
+ fp = fopen(g_event_log_file_path, "a");
+ if (fp == NULL) {
+ return;
+ }
+ fprintf(fp, "%llu %s ", (unsigned long long) now_seconds(),
+ level == NULL || level[0] == '\0' ? "info" : level);
+ va_start(ap, fmt);
+ vfprintf(fp, fmt, ap);
+ va_end(ap);
+ fputc('\n', fp);
+ fclose(fp);
+ chmod(g_event_log_file_path, 0644);
+}
+
+static uint32_t hash_domain_cache_key(const char *domain, uint16_t qtype, bool dnssec) {
+ uint32_t h = 2166136261u;
+ const unsigned char *p = (const unsigned char *) domain;
+ while (*p) {
+ h ^= *p++;
+ h *= 16777619u;
+ }
+ h ^= qtype & 0xffu;
+ h *= 16777619u;
+ h ^= (qtype >> 8) & 0xffu;
+ h *= 16777619u;
+ h ^= dnssec ? 1u : 0u;
+ h *= 16777619u;
+ return h;
+}
+
+static uint32_t hash_domain(const char *domain, uint16_t qtype) {
+ return hash_domain_cache_key(domain, qtype, false);
+}
+
+static uint64_t div_u64(uint64_t numerator, uint64_t denominator) {
+ return denominator == 0 ? 0 : numerator / denominator;
+}
+
+static long read_proc_status_kb(const char *key) {
+ FILE *fp;
+ char line[256];
+ char prefix[64];
+ size_t prefix_len;
+ long value = 0;
+ if (key == NULL || key[0] == '\0') {
+ return 0;
+ }
+ snprintf(prefix, sizeof(prefix), "%s:", key);
+ prefix_len = strlen(prefix);
+ fp = fopen("/proc/self/status", "r");
+ if (fp == NULL) {
+ return 0;
+ }
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ if (strncmp(line, prefix, prefix_len) == 0) {
+ char *p = line + prefix_len;
+ while (*p && !isdigit((unsigned char) *p)) {
+ p++;
+ }
+ value = strtol(p, NULL, 10);
+ break;
+ }
+ }
+ fclose(fp);
+ return value < 0 ? 0 : value;
+}
+
+static bool read_proc_cpu_ticks(uint64_t *user_ticks, uint64_t *system_ticks) {
+ FILE *fp;
+ char line[1024];
+ char *end_comm;
+ char *token;
+ int field = 3;
+ uint64_t user = 0;
+ uint64_t system = 0;
+ if (user_ticks != NULL) {
+ *user_ticks = 0;
+ }
+ if (system_ticks != NULL) {
+ *system_ticks = 0;
+ }
+ fp = fopen("/proc/self/stat", "r");
+ if (fp == NULL) {
+ return false;
+ }
+ if (fgets(line, sizeof(line), fp) == NULL) {
+ fclose(fp);
+ return false;
+ }
+ fclose(fp);
+ end_comm = strrchr(line, ')');
+ if (end_comm == NULL || end_comm[1] == '\0') {
+ return false;
+ }
+ token = strtok(end_comm + 2, " ");
+ while (token != NULL) {
+ if (field == 14) {
+ user = strtoull(token, NULL, 10);
+ } else if (field == 15) {
+ system = strtoull(token, NULL, 10);
+ break;
+ }
+ field++;
+ token = strtok(NULL, " ");
+ }
+ if (user_ticks != NULL) {
+ *user_ticks = user;
+ }
+ if (system_ticks != NULL) {
+ *system_ticks = system;
+ }
+ return field >= 15;
+}
+
+static bool ascii_contains_ci(const char *haystack, const char *needle) {
+ size_t needle_len;
+ const char *p;
+ if (needle == NULL || needle[0] == '\0') {
+ return true;
+ }
+ if (haystack == NULL) {
+ return false;
+ }
+ needle_len = strlen(needle);
+ if (needle_len == 0) {
+ return true;
+ }
+ for (p = haystack; *p != '\0'; p++) {
+ size_t i;
+ for (i = 0; i < needle_len; i++) {
+ unsigned char hc = (unsigned char) p[i];
+ unsigned char nc = (unsigned char) needle[i];
+ if (hc == '\0') {
+ return false;
+ }
+ if (tolower(hc) != tolower(nc)) {
+ break;
+ }
+ }
+ if (i == needle_len) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static uint64_t cpu_ticks_to_ms(uint64_t ticks) {
+ long hz = sysconf(_SC_CLK_TCK);
+ if (hz <= 0) {
+ return 0;
+ }
+ return div_u64(ticks * 1000ULL, (uint64_t) hz);
+}
+
+static void record_query_latency(int latency_ms) {
+ uint64_t latency;
+ if (latency_ms < 0) {
+ latency_ms = 0;
+ }
+ latency = (uint64_t) latency_ms;
+ g_stats.total_latency_ms += latency;
+ if (latency > g_stats.max_latency_ms) {
+ g_stats.max_latency_ms = latency;
+ }
+}
+
+static int cache_entry_count(void) {
+ int count = 0;
+ int i;
+ time_t now = time(NULL);
+ if (g_cache == NULL || g_cache_capacity <= 0) {
+ return 0;
+ }
+ for (i = 0; i < g_cache_capacity; i++) {
+ if (g_cache[i].used && g_cache[i].expires_at > now) {
+ count++;
+ }
+ }
+ return count;
+}
+
+static int cache_entry_count_by_type(bool negative) {
+ int count = 0;
+ int i;
+ time_t now = time(NULL);
+ if (g_cache == NULL || g_cache_capacity <= 0) {
+ return 0;
+ }
+ for (i = 0; i < g_cache_capacity; i++) {
+ if (g_cache[i].used && g_cache[i].expires_at > now && g_cache[i].negative == negative) {
+ count++;
+ }
+ }
+ return count;
+}
+
+static int clear_cache_entries(void) {
+ int count = cache_entry_count();
+ if (g_cache != NULL && g_cache_capacity > 0) {
+ memset(g_cache, 0, sizeof(cache_entry_t) * (size_t) g_cache_capacity);
+ }
+ if (g_cfg.cache_file[0] != '\0') {
+ unlink(g_cfg.cache_file);
+ }
+ return count;
+}
+
+static void add_log(const char *domain, const char *action, const char *transport,
+ const char *source, int uid,
+ uint16_t qtype, const char *result, const char *rule,
+ const char *upstream, int latency_ms) {
+ log_entry_t *entry;
+ if (!g_cfg.query_logging) {
+ return;
+ }
+ pthread_mutex_lock(&g_log_mutex);
+ entry = &g_logs[g_log_pos % LOG_RING];
+ entry->seq = ++g_log_seq;
+ safe_copy(entry->domain, sizeof(entry->domain), domain);
+ safe_copy(entry->action, sizeof(entry->action), action);
+ safe_copy(entry->transport, sizeof(entry->transport), transport);
+ safe_copy(entry->source, sizeof(entry->source), source == NULL ? "unknown" : source);
+ entry->uid = uid;
+ safe_copy(entry->result, sizeof(entry->result), result);
+ safe_copy(entry->rule, sizeof(entry->rule), rule);
+ safe_copy(entry->upstream, sizeof(entry->upstream), upstream);
+ entry->qtype = qtype;
+ entry->latency_ms = latency_ms;
+ entry->timestamp = now_seconds();
+ g_log_pos = (g_log_pos + 1) % LOG_RING;
+ pthread_mutex_unlock(&g_log_mutex);
+}
+
+static void set_log_output(const char *path, bool persist) {
+ pthread_mutex_lock(&g_log_mutex);
+ safe_copy(g_log_file_path, sizeof(g_log_file_path), path == NULL ? "" : path);
+ g_log_persistence_enabled = persist;
+ if (!g_log_persistence_enabled) {
+ g_log_flushed_seq = g_log_seq;
+ }
+ pthread_mutex_unlock(&g_log_mutex);
+}
+
+static bool copy_log_file_path(char *out, size_t out_len) {
+ bool has_path;
+ if (out == NULL || out_len == 0) {
+ return false;
+ }
+ pthread_mutex_lock(&g_log_mutex);
+ safe_copy(out, out_len, g_log_file_path);
+ has_path = g_log_persistence_enabled && out[0] != '\0';
+ pthread_mutex_unlock(&g_log_mutex);
+ return has_path;
+}
+
+static void flush_logs(void) {
+ FILE *fp;
+ uint64_t first_seq;
+ uint64_t seq;
+ int i;
+ int pending_count = 0;
+ uint64_t flush_to_seq;
+ log_entry_t pending[LOG_RING];
+ char log_file[sizeof(g_log_file_path)];
+
+ pthread_mutex_lock(&g_log_flush_mutex);
+ pthread_mutex_lock(&g_log_mutex);
+ if (!g_log_persistence_enabled || g_log_file_path[0] == '\0') {
+ g_log_flushed_seq = g_log_seq;
+ pthread_mutex_unlock(&g_log_mutex);
+ pthread_mutex_unlock(&g_log_flush_mutex);
+ return;
+ }
+ if (g_log_flushed_seq == g_log_seq) {
+ pthread_mutex_unlock(&g_log_mutex);
+ pthread_mutex_unlock(&g_log_flush_mutex);
+ return;
+ }
+
+ safe_copy(log_file, sizeof(log_file), g_log_file_path);
+ flush_to_seq = g_log_seq;
+ first_seq = g_log_flushed_seq + 1;
+ if (flush_to_seq >= LOG_RING && first_seq < flush_to_seq - LOG_RING + 1) {
+ first_seq = flush_to_seq - LOG_RING + 1;
+ }
+
+ for (seq = first_seq; seq <= flush_to_seq && pending_count < LOG_RING; seq++) {
+ for (i = 0; i < LOG_RING; i++) {
+ const log_entry_t *entry = &g_logs[i];
+ if (entry->seq == seq) {
+ pending[pending_count++] = *entry;
+ break;
+ }
+ }
+ }
+ pthread_mutex_unlock(&g_log_mutex);
+
+ fp = fopen(log_file, "a");
+ if (fp == NULL) {
+ pthread_mutex_unlock(&g_log_flush_mutex);
+ return;
+ }
+
+ for (i = 0; i < pending_count; i++) {
+ const log_entry_t *entry = &pending[i];
+ fprintf(fp, "%llu %s %s %dms transport=%s source=%s uid=%d qtype=%u result=%s rule=%s upstream=%s\n",
+ (unsigned long long) entry->timestamp,
+ entry->action,
+ entry->domain,
+ entry->latency_ms,
+ entry->transport,
+ entry->source,
+ entry->uid,
+ (unsigned int) entry->qtype,
+ entry->result,
+ entry->rule,
+ entry->upstream);
+ }
+ fclose(fp);
+
+ pthread_mutex_lock(&g_log_mutex);
+ if (g_log_flushed_seq < flush_to_seq) {
+ g_log_flushed_seq = flush_to_seq;
+ }
+ pthread_mutex_unlock(&g_log_mutex);
+ pthread_mutex_unlock(&g_log_flush_mutex);
+}
+
+static void *log_flush_worker(void *arg) {
+ (void) arg;
+ while (g_log_thread_running) {
+ sleep(1);
+ flush_logs();
+ }
+ flush_logs();
+ return NULL;
+}
+
+static void start_log_thread(void) {
+ if (g_log_thread_started) {
+ return;
+ }
+ g_log_thread_running = 1;
+ if (pthread_create(&g_log_thread, NULL, log_flush_worker, NULL) == 0) {
+ g_log_thread_started = true;
+ } else {
+ g_log_thread_running = 0;
+ }
+}
+
+static void stop_log_thread(void) {
+ if (!g_log_thread_started) {
+ flush_logs();
+ return;
+ }
+ g_log_thread_running = 0;
+ pthread_join(g_log_thread, NULL);
+ g_log_thread_started = false;
+}
+
+static void read_log_stats(uint64_t *ring_entries, uint64_t *unflushed_entries) {
+ int i;
+ uint64_t count = 0;
+ uint64_t unflushed = 0;
+ pthread_mutex_lock(&g_log_mutex);
+ for (i = 0; i < LOG_RING; i++) {
+ if (g_logs[i].timestamp != 0) {
+ count++;
+ }
+ }
+ if (g_log_seq > g_log_flushed_seq) {
+ unflushed = g_log_seq - g_log_flushed_seq;
+ if (unflushed > LOG_RING) {
+ unflushed = LOG_RING;
+ }
+ }
+ pthread_mutex_unlock(&g_log_mutex);
+ if (ring_entries != NULL) {
+ *ring_entries = count;
+ }
+ if (unflushed_entries != NULL) {
+ *unflushed_entries = unflushed;
+ }
+}
+
+static void clear_log_ring(void) {
+ pthread_mutex_lock(&g_log_mutex);
+ memset(g_logs, 0, sizeof(g_logs));
+ g_log_pos = 0;
+ g_log_flushed_seq = g_log_seq;
+ pthread_mutex_unlock(&g_log_mutex);
+}
+
+static bool clear_query_logs(void) {
+ FILE *fp;
+ char log_file[sizeof(g_log_file_path)];
+ bool has_file;
+
+ pthread_mutex_lock(&g_log_flush_mutex);
+ pthread_mutex_lock(&g_log_mutex);
+ memset(g_logs, 0, sizeof(g_logs));
+ g_log_pos = 0;
+ g_log_flushed_seq = g_log_seq;
+ safe_copy(log_file, sizeof(log_file), g_log_file_path);
+ has_file = g_log_persistence_enabled && log_file[0] != '\0';
+ pthread_mutex_unlock(&g_log_mutex);
+
+ if (!has_file) {
+ pthread_mutex_unlock(&g_log_flush_mutex);
+ return true;
+ }
+ fp = fopen(log_file, "w");
+ if (fp == NULL) {
+ pthread_mutex_unlock(&g_log_flush_mutex);
+ return false;
+ }
+ fclose(fp);
+ chmod(log_file, 0644);
+ pthread_mutex_unlock(&g_log_flush_mutex);
+ return true;
+}
+
+static void free_regex_rule_list(regex_rule_list_t *list) {
+ int i;
+ if (list == NULL) {
+ return;
+ }
+ for (i = 0; i < list->count; i++) {
+ if (list->items[i].valid) {
+ regfree(&list->items[i].compiled);
+ list->items[i].valid = false;
+ }
+ }
+ free(list->items);
+ list->items = NULL;
+ list->count = 0;
+ list->capacity = 0;
+}
+
+static void free_suffix_trie(suffix_trie_t *trie) {
+ if (trie == NULL) {
+ return;
+ }
+ free(trie->nodes);
+ trie->nodes = NULL;
+ trie->count = 0;
+ trie->capacity = 0;
+}
+
+static void free_app_suffix_trie(app_suffix_trie_t *trie) {
+ int i;
+ if (trie == NULL) {
+ return;
+ }
+ for (i = 0; i < trie->count; i++) {
+ free(trie->nodes[i].uids);
+ trie->nodes[i].uids = NULL;
+ trie->nodes[i].uid_count = 0;
+ trie->nodes[i].uid_capacity = 0;
+ }
+ free(trie->nodes);
+ trie->nodes = NULL;
+ trie->count = 0;
+ trie->capacity = 0;
+}
+
+static void free_string_rule_list(string_rule_list_t *list) {
+ if (list == NULL) {
+ return;
+ }
+ free(list->items);
+ list->items = NULL;
+ list->count = 0;
+ list->capacity = 0;
+}
+
+static void free_app_exact_rule_list(app_exact_rule_list_t *list) {
+ if (list == NULL) {
+ return;
+ }
+ free(list->items);
+ list->items = NULL;
+ list->count = 0;
+ list->capacity = 0;
+}
+
+static void close_upstream_udp_sockets(config_t *cfg) {
+ int i;
+ if (cfg == NULL) {
+ return;
+ }
+ for (i = 0; i < cfg->upstream_count; i++) {
+ if (cfg->upstreams[i].udp_fd >= 0) {
+ close(cfg->upstreams[i].udp_fd);
+ cfg->upstreams[i].udp_fd = -1;
+ }
+ }
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ if (cfg->split_upstreams[i].upstream.udp_fd >= 0) {
+ close(cfg->split_upstreams[i].upstream.udp_fd);
+ cfg->split_upstreams[i].upstream.udp_fd = -1;
+ }
+ }
+}
+
+static void free_config_dynamic(config_t *cfg) {
+ if (cfg == NULL) {
+ return;
+ }
+ close_upstream_udp_sockets(cfg);
+ free_regex_rule_list(&cfg->regex_allow);
+ free_regex_rule_list(&cfg->regex_block);
+ free_string_rule_list(&cfg->exact_allow);
+ free_string_rule_list(&cfg->suffix_allow);
+ free_string_rule_list(&cfg->exact_block);
+ free_string_rule_list(&cfg->suffix_block);
+ free_app_exact_rule_list(&cfg->app_exact_allow);
+ free_app_exact_rule_list(&cfg->app_exact_block);
+ free_app_exact_rule_list(&cfg->app_suffix_allow);
+ free_app_exact_rule_list(&cfg->app_suffix_block);
+ free(cfg->app_exact_allow_index);
+ cfg->app_exact_allow_index = NULL;
+ cfg->app_exact_allow_index_size = 0;
+ free(cfg->app_exact_block_index);
+ cfg->app_exact_block_index = NULL;
+ cfg->app_exact_block_index_size = 0;
+ free(cfg->exact_allow_index);
+ cfg->exact_allow_index = NULL;
+ cfg->exact_allow_index_size = 0;
+ free(cfg->exact_block_index);
+ cfg->exact_block_index = NULL;
+ cfg->exact_block_index_size = 0;
+ free_suffix_trie(&cfg->suffix_allow_trie);
+ free_suffix_trie(&cfg->suffix_block_trie);
+ free_app_suffix_trie(&cfg->app_suffix_allow_trie);
+ free_app_suffix_trie(&cfg->app_suffix_block_trie);
+}
+
+static bool reserve_string_rule_list(string_rule_list_t *list, int needed) {
+ int next_capacity;
+ string_rule_t *items;
+ if (list == NULL || needed < 0) {
+ return false;
+ }
+ if (list->capacity >= needed) {
+ return true;
+ }
+ next_capacity = list->capacity <= 0 ? INITIAL_RULE_CAPACITY : list->capacity;
+ while (next_capacity < needed) {
+ if (next_capacity > INT_MAX / 2) {
+ return false;
+ }
+ next_capacity *= 2;
+ }
+ items = (string_rule_t *) realloc(list->items,
+ (size_t) next_capacity * sizeof(string_rule_t));
+ if (items == NULL) {
+ return false;
+ }
+ memset(items + list->capacity, 0,
+ (size_t) (next_capacity - list->capacity) * sizeof(string_rule_t));
+ list->items = items;
+ list->capacity = next_capacity;
+ return true;
+}
+
+static bool add_string_rule(string_rule_list_t *rules, const char *value) {
+ if (rules == NULL || value == NULL || value[0] == '\0') {
+ return false;
+ }
+ if (!reserve_string_rule_list(rules, rules->count + 1)) {
+ return false;
+ }
+ safe_copy(rules->items[rules->count].value, sizeof(rules->items[rules->count].value), value);
+ lower_ascii(rules->items[rules->count].value);
+ rules->count++;
+ return true;
+}
+
+static bool valid_domain_rule(const char *value);
+
+static bool reserve_app_exact_rule_list(app_exact_rule_list_t *list, int needed) {
+ int next_capacity;
+ app_exact_rule_t *items;
+ if (list == NULL || needed < 0) {
+ return false;
+ }
+ if (list->capacity >= needed) {
+ return true;
+ }
+ next_capacity = list->capacity <= 0 ? INITIAL_REGEX_CAPACITY : list->capacity;
+ while (next_capacity < needed) {
+ if (next_capacity > INT_MAX / 2) {
+ return false;
+ }
+ next_capacity *= 2;
+ }
+ items = (app_exact_rule_t *) realloc(list->items,
+ (size_t) next_capacity * sizeof(app_exact_rule_t));
+ if (items == NULL) {
+ return false;
+ }
+ memset(items + list->capacity, 0,
+ (size_t) (next_capacity - list->capacity) * sizeof(app_exact_rule_t));
+ list->items = items;
+ list->capacity = next_capacity;
+ return true;
+}
+
+static bool add_app_domain_rule(app_exact_rule_list_t *rules, const char *value) {
+ char line[512];
+ char *separator;
+ char *uid_text;
+ char *domain;
+ char *end;
+ long uid;
+ if (rules == NULL || value == NULL || value[0] == '\0') {
+ return false;
+ }
+ safe_copy(line, sizeof(line), value);
+ trim(line);
+ uid_text = line;
+ separator = strchr(line, '|');
+ if (separator == NULL) {
+ separator = strchr(line, '=');
+ }
+ if (separator == NULL) {
+ separator = line;
+ while (*separator && !isspace((unsigned char) *separator) && *separator != ',') {
+ separator++;
+ }
+ }
+ if (separator == NULL || *separator == '\0') {
+ return false;
+ }
+ *separator = '\0';
+ domain = separator + 1;
+ trim(uid_text);
+ trim(domain);
+ lower_ascii(domain);
+ errno = 0;
+ uid = strtol(uid_text, &end, 10);
+ if (errno != 0 || end == uid_text || *end != '\0' || uid < 0 || uid > INT_MAX
+ || !valid_domain_rule(domain)) {
+ return false;
+ }
+ if (!reserve_app_exact_rule_list(rules, rules->count + 1)) {
+ return false;
+ }
+ rules->items[rules->count].uid = (int) uid;
+ safe_copy(rules->items[rules->count].domain,
+ sizeof(rules->items[rules->count].domain), domain);
+ rules->count++;
+ return true;
+}
+
+static bool parse_network_rule(const char *value, network_rule_t *rule) {
+ char text[128];
+ char *slash;
+ char *prefix_text = NULL;
+ char *end;
+ long prefix = -1;
+ struct in_addr addr4;
+ struct in6_addr addr6;
+ int max_prefix;
+ if (value == NULL || rule == NULL || value[0] == '\0') {
+ return false;
+ }
+ memset(rule, 0, sizeof(*rule));
+ safe_copy(text, sizeof(text), value);
+ trim(text);
+ if (text[0] == '\0') {
+ return false;
+ }
+ slash = strchr(text, '/');
+ if (slash != NULL) {
+ *slash = '\0';
+ prefix_text = slash + 1;
+ trim(prefix_text);
+ }
+ trim(text);
+ if (inet_pton(AF_INET, text, &addr4) == 1) {
+ rule->family = AF_INET;
+ memcpy(rule->address, &addr4, sizeof(addr4));
+ max_prefix = 32;
+ } else if (inet_pton(AF_INET6, text, &addr6) == 1) {
+ rule->family = AF_INET6;
+ memcpy(rule->address, &addr6, sizeof(addr6));
+ max_prefix = 128;
+ } else {
+ return false;
+ }
+ if (prefix_text == NULL) {
+ rule->prefix_len = max_prefix;
+ return true;
+ }
+ if (prefix_text[0] == '\0') {
+ return false;
+ }
+ errno = 0;
+ prefix = strtol(prefix_text, &end, 10);
+ if (errno != 0 || end == prefix_text || *end != '\0'
+ || prefix < 0 || prefix > max_prefix) {
+ return false;
+ }
+ rule->prefix_len = (int) prefix;
+ return true;
+}
+
+static bool add_network_rule(network_rule_t *rules, int *count, const char *value) {
+ network_rule_t parsed;
+ if (rules == NULL || count == NULL || value == NULL || value[0] == '\0') {
+ return false;
+ }
+ if (*count < 0 || *count >= MAX_NETWORK_RULES) {
+ return false;
+ }
+ if (!parse_network_rule(value, &parsed)) {
+ return false;
+ }
+ rules[*count] = parsed;
+ (*count)++;
+ return true;
+}
+
+static bool reserve_regex_rule_list(regex_rule_list_t *list, int needed) {
+ int next_capacity;
+ regex_rule_t *items;
+ if (list == NULL || needed < 0) {
+ return false;
+ }
+ if (list->capacity >= needed) {
+ return true;
+ }
+ next_capacity = list->capacity <= 0 ? INITIAL_REGEX_CAPACITY : list->capacity;
+ while (next_capacity < needed) {
+ if (next_capacity > INT_MAX / 2) {
+ return false;
+ }
+ next_capacity *= 2;
+ }
+ items = (regex_rule_t *) realloc(list->items,
+ (size_t) next_capacity * sizeof(regex_rule_t));
+ if (items == NULL) {
+ return false;
+ }
+ memset(items + list->capacity, 0,
+ (size_t) (next_capacity - list->capacity) * sizeof(regex_rule_t));
+ list->items = items;
+ list->capacity = next_capacity;
+ return true;
+}
+
+static bool valid_domain_rule(const char *value) {
+ const char *p = value;
+ size_t label_len = 0;
+ bool dot_seen = false;
+ if (value == NULL || value[0] == '\0' || strlen(value) >= MAX_DOMAIN) {
+ return false;
+ }
+ while (*p) {
+ unsigned char c = (unsigned char) *p;
+ if (c == '.') {
+ if (label_len == 0 || label_len > 63) {
+ return false;
+ }
+ dot_seen = true;
+ label_len = 0;
+ } else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
+ label_len++;
+ } else {
+ return false;
+ }
+ p++;
+ }
+ return dot_seen && label_len > 0 && label_len <= 63;
+}
+
+static bool add_temp_rule(temp_rule_t *rules, int *count, const char *value) {
+ char line[512];
+ char *sep;
+ char *domain;
+ char *expires;
+ uint64_t expires_at;
+ if (*count >= MAX_TEMP_RULES || value == NULL || value[0] == '\0') {
+ return false;
+ }
+ safe_copy(line, sizeof(line), value);
+ sep = strchr(line, '|');
+ if (sep == NULL) {
+ sep = strchr(line, ' ');
+ }
+ if (sep == NULL) {
+ return false;
+ }
+ *sep = '\0';
+ domain = line;
+ expires = sep + 1;
+ trim(domain);
+ trim(expires);
+ lower_ascii(domain);
+ expires_at = (uint64_t) strtoull(expires, NULL, 10);
+ if (!valid_domain_rule(domain) || expires_at <= now_seconds()) {
+ return false;
+ }
+ safe_copy(rules[*count].value, sizeof(rules[*count].value), domain);
+ rules[*count].expires_at = expires_at;
+ (*count)++;
+ return true;
+}
+
+static bool add_regex_rule(regex_rule_list_t *rules, const char *value) {
+ regex_rule_t *rule;
+ if (rules == NULL || value == NULL || value[0] == '\0') {
+ return false;
+ }
+ if (!reserve_regex_rule_list(rules, rules->count + 1)) {
+ return false;
+ }
+ rule = &rules->items[rules->count];
+ memset(rule, 0, sizeof(*rule));
+ safe_copy(rule->pattern, sizeof(rule->pattern), value);
+ /* Compile during reload so malformed regex rules reject the pending generation. */
+ if (regcomp(&rule->compiled, value, REG_EXTENDED | REG_NOSUB | REG_ICASE) != 0) {
+ memset(rule, 0, sizeof(*rule));
+ return false;
+ }
+ rule->valid = true;
+ rules->count++;
+ return true;
+}
+
+static int choose_exact_index_size(int count) {
+ int size = MIN_EXACT_INDEX_SIZE;
+ if (count <= 0) {
+ return size;
+ }
+ if (count > MAX_EXACT_INDEX_SIZE / 2) {
+ return 0;
+ }
+ while (size / 2 < count) {
+ if (size >= MAX_EXACT_INDEX_SIZE / 2) {
+ return count <= MAX_EXACT_INDEX_SIZE / 2 ? MAX_EXACT_INDEX_SIZE : 0;
+ }
+ size *= 2;
+ }
+ return size;
+}
+
+static bool build_exact_index(const string_rule_list_t *rules, int **index_out, int *index_size_out) {
+ int i;
+ int index_size;
+ int *index;
+ if (rules == NULL || index_out == NULL || index_size_out == NULL) {
+ return false;
+ }
+ free(*index_out);
+ *index_out = NULL;
+ *index_size_out = 0;
+ index_size = choose_exact_index_size(rules->count);
+ if (index_size <= 0) {
+ return false;
+ }
+ index = (int *) calloc((size_t) index_size, sizeof(int));
+ if (index == NULL) {
+ return false;
+ }
+ for (i = 0; i < rules->count; i++) {
+ uint32_t slot = hash_domain(rules->items[i].value, 0) % (uint32_t) index_size;
+ uint32_t probe;
+ for (probe = 0; probe < (uint32_t) index_size; probe++) {
+ int existing = index[slot];
+ if (existing == 0) {
+ index[slot] = i + 1;
+ break;
+ }
+ if (existing > 0 && existing <= rules->count
+ && strcmp(rules->items[existing - 1].value, rules->items[i].value) == 0) {
+ break;
+ }
+ slot = (slot + 1) % (uint32_t) index_size;
+ }
+ if (probe == (uint32_t) index_size) {
+ free(index);
+ return false;
+ }
+ }
+ *index_out = index;
+ *index_size_out = index_size;
+ return true;
+}
+
+static bool exact_match_indexed(const string_rule_list_t *rules, const int *index,
+ int index_size, const char *domain) {
+ uint32_t slot;
+ uint32_t probe;
+ if (rules == NULL || index == NULL || index_size <= 0
+ || domain == NULL || domain[0] == '\0') {
+ return false;
+ }
+ slot = hash_domain(domain, 0) % (uint32_t) index_size;
+ for (probe = 0; probe < (uint32_t) index_size; probe++) {
+ int ref = index[slot];
+ if (ref == 0) {
+ return false;
+ }
+ if (ref > 0 && ref <= rules->count
+ && strcmp(rules->items[ref - 1].value, domain) == 0) {
+ return true;
+ }
+ slot = (slot + 1) % (uint32_t) index_size;
+ }
+ return false;
+}
+
+static uint32_t hash_app_exact_key(int uid, const char *domain) {
+ uint32_t h = hash_domain(domain, 0);
+ unsigned int value = (unsigned int) uid;
+ int i;
+ for (i = 0; i < 4; i++) {
+ h ^= (uint8_t) ((value >> (i * 8)) & 0xffu);
+ h *= 16777619u;
+ }
+ return h;
+}
+
+static bool build_app_exact_index(const app_exact_rule_list_t *rules,
+ int **index_out, int *index_size_out) {
+ int i;
+ int index_size;
+ int *index;
+ if (rules == NULL || index_out == NULL || index_size_out == NULL) {
+ return false;
+ }
+ free(*index_out);
+ *index_out = NULL;
+ *index_size_out = 0;
+ if (rules->count <= 0) {
+ return true;
+ }
+ index_size = choose_exact_index_size(rules->count);
+ if (index_size <= 0) {
+ return false;
+ }
+ index = (int *) calloc((size_t) index_size, sizeof(int));
+ if (index == NULL) {
+ return false;
+ }
+ for (i = 0; i < rules->count; i++) {
+ uint32_t slot = hash_app_exact_key(rules->items[i].uid,
+ rules->items[i].domain) % (uint32_t) index_size;
+ uint32_t probe;
+ for (probe = 0; probe < (uint32_t) index_size; probe++) {
+ int existing = index[slot];
+ if (existing == 0) {
+ index[slot] = i + 1;
+ break;
+ }
+ if (existing > 0 && existing <= rules->count
+ && rules->items[existing - 1].uid == rules->items[i].uid
+ && strcmp(rules->items[existing - 1].domain,
+ rules->items[i].domain) == 0) {
+ break;
+ }
+ slot = (slot + 1) % (uint32_t) index_size;
+ }
+ if (probe == (uint32_t) index_size) {
+ free(index);
+ return false;
+ }
+ }
+ *index_out = index;
+ *index_size_out = index_size;
+ return true;
+}
+
+static bool app_exact_match_indexed(const app_exact_rule_list_t *rules, const int *index,
+ int index_size, int uid, const char *domain) {
+ uint32_t slot;
+ uint32_t probe;
+ if (rules == NULL || index == NULL || index_size <= 0 || uid == UID_UNKNOWN
+ || domain == NULL || domain[0] == '\0') {
+ return false;
+ }
+ slot = hash_app_exact_key(uid, domain) % (uint32_t) index_size;
+ for (probe = 0; probe < (uint32_t) index_size; probe++) {
+ int ref = index[slot];
+ if (ref == 0) {
+ return false;
+ }
+ if (ref > 0 && ref <= rules->count
+ && rules->items[ref - 1].uid == uid
+ && strcmp(rules->items[ref - 1].domain, domain) == 0) {
+ return true;
+ }
+ slot = (slot + 1) % (uint32_t) index_size;
+ }
+ return false;
+}
+
+static bool suffix_trie_reserve(suffix_trie_t *trie, int needed) {
+ int next_capacity;
+ suffix_trie_node_t *nodes;
+ if (trie->capacity >= needed) {
+ return true;
+ }
+ next_capacity = trie->capacity > 0 ? trie->capacity : 64;
+ while (next_capacity < needed) {
+ if (next_capacity > INT_MAX / 2) {
+ return false;
+ }
+ next_capacity *= 2;
+ }
+ nodes = (suffix_trie_node_t *) realloc(trie->nodes,
+ (size_t) next_capacity * sizeof(suffix_trie_node_t));
+ if (nodes == NULL) {
+ return false;
+ }
+ memset(nodes + trie->capacity, 0,
+ (size_t) (next_capacity - trie->capacity) * sizeof(suffix_trie_node_t));
+ trie->nodes = nodes;
+ trie->capacity = next_capacity;
+ return true;
+}
+
+static bool suffix_trie_init(suffix_trie_t *trie) {
+ memset(trie, 0, sizeof(*trie));
+ if (!suffix_trie_reserve(trie, 1)) {
+ return false;
+ }
+ trie->count = 1;
+ return true;
+}
+
+static int suffix_trie_find_child(const suffix_trie_t *trie, int parent, const char *label) {
+ int child;
+ if (trie == NULL || trie->nodes == NULL || parent < 0 || parent >= trie->count) {
+ return -1;
+ }
+ for (child = trie->nodes[parent].first_child; child != 0;
+ child = trie->nodes[child].next_sibling) {
+ if (strcmp(trie->nodes[child].label, label) == 0) {
+ return child;
+ }
+ }
+ return -1;
+}
+
+static int suffix_trie_add_child(suffix_trie_t *trie, int parent, const char *label) {
+ int child;
+ if (!suffix_trie_reserve(trie, trie->count + 1)) {
+ return -1;
+ }
+ child = trie->count++;
+ memset(&trie->nodes[child], 0, sizeof(trie->nodes[child]));
+ safe_copy(trie->nodes[child].label, sizeof(trie->nodes[child].label), label);
+ trie->nodes[child].next_sibling = trie->nodes[parent].first_child;
+ trie->nodes[parent].first_child = child;
+ return child;
+}
+
+static bool previous_domain_label(const char *domain, size_t *end, char *label,
+ size_t label_len, bool *done) {
+ size_t start;
+ size_t len;
+ if (domain == NULL || end == NULL || *end == 0 || label == NULL || label_len == 0) {
+ return false;
+ }
+ start = *end;
+ while (start > 0 && domain[start - 1] != '.') {
+ start--;
+ }
+ len = *end - start;
+ if (len == 0 || len >= label_len) {
+ return false;
+ }
+ memcpy(label, domain + start, len);
+ label[len] = '\0';
+ if (start == 0) {
+ *done = true;
+ *end = 0;
+ } else {
+ *done = false;
+ *end = start - 1;
+ }
+ return true;
+}
+
+static bool suffix_trie_insert(suffix_trie_t *trie, const char *suffix) {
+ char label[MAX_LABEL];
+ size_t end;
+ int node = 0;
+ bool done = false;
+ if (trie == NULL || trie->nodes == NULL || suffix == NULL || suffix[0] == '\0') {
+ return false;
+ }
+ end = strlen(suffix);
+ while (!done) {
+ int child;
+ if (!previous_domain_label(suffix, &end, label, sizeof(label), &done)) {
+ return false;
+ }
+ child = suffix_trie_find_child(trie, node, label);
+ if (child < 0) {
+ child = suffix_trie_add_child(trie, node, label);
+ if (child < 0) {
+ return false;
+ }
+ }
+ node = child;
+ }
+ trie->nodes[node].terminal = true;
+ return true;
+}
+
+static bool build_suffix_trie(const string_rule_list_t *rules, suffix_trie_t *trie) {
+ int i;
+ if (rules == NULL) {
+ return false;
+ }
+ free_suffix_trie(trie);
+ if (!suffix_trie_init(trie)) {
+ return false;
+ }
+ for (i = 0; i < rules->count; i++) {
+ if (!suffix_trie_insert(trie, rules->items[i].value)) {
+ free_suffix_trie(trie);
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool suffix_trie_match(const suffix_trie_t *trie, const char *domain) {
+ char label[MAX_LABEL];
+ size_t end;
+ int node = 0;
+ bool done = false;
+ if (trie == NULL || trie->nodes == NULL || domain == NULL || domain[0] == '\0') {
+ return false;
+ }
+ end = strlen(domain);
+ /* Suffix rules used to scan linearly; this keeps the DNS hot path bounded by label depth. */
+ while (!done) {
+ int child;
+ if (!previous_domain_label(domain, &end, label, sizeof(label), &done)) {
+ return false;
+ }
+ child = suffix_trie_find_child(trie, node, label);
+ if (child < 0) {
+ return false;
+ }
+ node = child;
+ if (trie->nodes[node].terminal) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool app_suffix_trie_reserve(app_suffix_trie_t *trie, int needed) {
+ int next_capacity;
+ app_suffix_trie_node_t *nodes;
+ if (trie->capacity >= needed) {
+ return true;
+ }
+ next_capacity = trie->capacity > 0 ? trie->capacity : 64;
+ while (next_capacity < needed) {
+ if (next_capacity > INT_MAX / 2) {
+ return false;
+ }
+ next_capacity *= 2;
+ }
+ nodes = (app_suffix_trie_node_t *) realloc(trie->nodes,
+ (size_t) next_capacity * sizeof(app_suffix_trie_node_t));
+ if (nodes == NULL) {
+ return false;
+ }
+ memset(nodes + trie->capacity, 0,
+ (size_t) (next_capacity - trie->capacity) * sizeof(app_suffix_trie_node_t));
+ trie->nodes = nodes;
+ trie->capacity = next_capacity;
+ return true;
+}
+
+static bool app_suffix_trie_init(app_suffix_trie_t *trie) {
+ memset(trie, 0, sizeof(*trie));
+ if (!app_suffix_trie_reserve(trie, 1)) {
+ return false;
+ }
+ trie->count = 1;
+ return true;
+}
+
+static int app_suffix_trie_find_child(const app_suffix_trie_t *trie, int parent,
+ const char *label) {
+ int child;
+ if (trie == NULL || trie->nodes == NULL || parent < 0 || parent >= trie->count) {
+ return -1;
+ }
+ for (child = trie->nodes[parent].first_child; child != 0;
+ child = trie->nodes[child].next_sibling) {
+ if (strcmp(trie->nodes[child].label, label) == 0) {
+ return child;
+ }
+ }
+ return -1;
+}
+
+static int app_suffix_trie_add_child(app_suffix_trie_t *trie, int parent,
+ const char *label) {
+ int child;
+ if (!app_suffix_trie_reserve(trie, trie->count + 1)) {
+ return -1;
+ }
+ child = trie->count++;
+ memset(&trie->nodes[child], 0, sizeof(trie->nodes[child]));
+ safe_copy(trie->nodes[child].label, sizeof(trie->nodes[child].label), label);
+ trie->nodes[child].next_sibling = trie->nodes[parent].first_child;
+ trie->nodes[parent].first_child = child;
+ return child;
+}
+
+static bool app_suffix_trie_node_add_uid(app_suffix_trie_node_t *node, int uid) {
+ int next_capacity;
+ int *uids;
+ int i;
+ if (node == NULL || uid == UID_UNKNOWN) {
+ return false;
+ }
+ for (i = 0; i < node->uid_count; i++) {
+ if (node->uids[i] == uid) {
+ return true;
+ }
+ }
+ if (node->uid_capacity <= node->uid_count) {
+ if (node->uid_capacity > INT_MAX / 2) {
+ return false;
+ }
+ next_capacity = node->uid_capacity <= 0 ? 4 : node->uid_capacity * 2;
+ if (next_capacity < node->uid_count + 1) {
+ next_capacity = node->uid_count + 1;
+ }
+ uids = (int *) realloc(node->uids, (size_t) next_capacity * sizeof(int));
+ if (uids == NULL) {
+ return false;
+ }
+ node->uids = uids;
+ node->uid_capacity = next_capacity;
+ }
+ node->uids[node->uid_count++] = uid;
+ return true;
+}
+
+static bool app_suffix_trie_node_has_uid(const app_suffix_trie_node_t *node, int uid) {
+ int i;
+ if (node == NULL || uid == UID_UNKNOWN) {
+ return false;
+ }
+ for (i = 0; i < node->uid_count; i++) {
+ if (node->uids[i] == uid) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool app_suffix_trie_insert(app_suffix_trie_t *trie, int uid,
+ const char *suffix) {
+ char label[MAX_LABEL];
+ size_t end;
+ int node = 0;
+ bool done = false;
+ if (trie == NULL || trie->nodes == NULL || uid == UID_UNKNOWN
+ || suffix == NULL || suffix[0] == '\0') {
+ return false;
+ }
+ end = strlen(suffix);
+ while (!done) {
+ int child;
+ if (!previous_domain_label(suffix, &end, label, sizeof(label), &done)) {
+ return false;
+ }
+ child = app_suffix_trie_find_child(trie, node, label);
+ if (child < 0) {
+ child = app_suffix_trie_add_child(trie, node, label);
+ if (child < 0) {
+ return false;
+ }
+ }
+ node = child;
+ }
+ return app_suffix_trie_node_add_uid(&trie->nodes[node], uid);
+}
+
+static bool build_app_suffix_trie(const app_exact_rule_list_t *rules,
+ app_suffix_trie_t *trie) {
+ int i;
+ if (rules == NULL) {
+ return false;
+ }
+ free_app_suffix_trie(trie);
+ if (!app_suffix_trie_init(trie)) {
+ return false;
+ }
+ for (i = 0; i < rules->count; i++) {
+ if (!app_suffix_trie_insert(trie, rules->items[i].uid, rules->items[i].domain)) {
+ free_app_suffix_trie(trie);
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool app_suffix_trie_match(const app_suffix_trie_t *trie, int uid,
+ const char *domain) {
+ char label[MAX_LABEL];
+ size_t end;
+ int node = 0;
+ bool done = false;
+ if (trie == NULL || trie->nodes == NULL || uid == UID_UNKNOWN
+ || domain == NULL || domain[0] == '\0') {
+ return false;
+ }
+ end = strlen(domain);
+ while (!done) {
+ int child;
+ if (!previous_domain_label(domain, &end, label, sizeof(label), &done)) {
+ return false;
+ }
+ child = app_suffix_trie_find_child(trie, node, label);
+ if (child < 0) {
+ return false;
+ }
+ node = child;
+ if (app_suffix_trie_node_has_uid(&trie->nodes[node], uid)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool temp_match(const temp_rule_t *rules, int count, const char *domain, uint64_t now) {
+ int i;
+ for (i = 0; i < count; i++) {
+ if (rules[i].expires_at > now && strcmp(rules[i].value, domain) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool domain_has_suffix(const char *domain, const char *suffix);
+
+static bool domain_has_suffix(const char *domain, const char *suffix) {
+ size_t domain_len;
+ size_t suffix_len;
+ if (domain == NULL || suffix == NULL) {
+ return false;
+ }
+ domain_len = strlen(domain);
+ suffix_len = strlen(suffix);
+ if (suffix_len == 0 || suffix_len > domain_len) {
+ return false;
+ }
+ if (strcmp(domain + domain_len - suffix_len, suffix) != 0) {
+ return false;
+ }
+ return suffix_len == domain_len || domain[domain_len - suffix_len - 1] == '.';
+}
+
+static bool regex_match_rules(const regex_rule_list_t *rules, const char *domain) {
+ int i;
+ if (rules == NULL || domain == NULL) {
+ return false;
+ }
+ for (i = 0; i < rules->count; i++) {
+ if (rules->items[i].valid
+ && regexec(&rules->items[i].compiled, domain, 0, NULL, 0) == 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool peer_address_bytes(const struct sockaddr_storage *peer, int *family, uint8_t address[16]) {
+ if (peer == NULL || family == NULL || address == NULL) {
+ return false;
+ }
+ memset(address, 0, 16);
+ if (peer->ss_family == AF_INET) {
+ const struct sockaddr_in *in = (const struct sockaddr_in *) peer;
+ *family = AF_INET;
+ memcpy(address, &in->sin_addr, sizeof(in->sin_addr));
+ return true;
+ }
+ if (peer->ss_family == AF_INET6) {
+ const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *) peer;
+ if (IN6_IS_ADDR_V4MAPPED(&in6->sin6_addr)) {
+ *family = AF_INET;
+ memcpy(address, &in6->sin6_addr.s6_addr[12], 4);
+ return true;
+ }
+ *family = AF_INET6;
+ memcpy(address, &in6->sin6_addr, sizeof(in6->sin6_addr));
+ return true;
+ }
+ return false;
+}
+
+static bool network_prefix_match(const uint8_t *rule_address, const uint8_t *peer_address,
+ int prefix_len) {
+ int full_bytes;
+ int remaining_bits;
+ uint8_t mask;
+ if (prefix_len < 0 || rule_address == NULL || peer_address == NULL) {
+ return false;
+ }
+ if (prefix_len == 0) {
+ return true;
+ }
+ full_bytes = prefix_len / 8;
+ remaining_bits = prefix_len % 8;
+ if (full_bytes > 0 && memcmp(rule_address, peer_address, (size_t) full_bytes) != 0) {
+ return false;
+ }
+ if (remaining_bits == 0) {
+ return true;
+ }
+ mask = (uint8_t) (0xffu << (8 - remaining_bits));
+ return (rule_address[full_bytes] & mask) == (peer_address[full_bytes] & mask);
+}
+
+static bool network_rules_match(const network_rule_t *rules, int count,
+ const struct sockaddr_storage *peer) {
+ int i;
+ int peer_family;
+ uint8_t peer_address[16];
+ if (rules == NULL || count <= 0
+ || !peer_address_bytes(peer, &peer_family, peer_address)) {
+ return false;
+ }
+ for (i = 0; i < count; i++) {
+ if (rules[i].family == peer_family
+ && network_prefix_match(rules[i].address, peer_address, rules[i].prefix_len)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+static decision_t evaluate_domain(const config_t *cfg, const char *domain, int uid,
+ const struct sockaddr_storage *peer, const char **reason) {
+ uint64_t now = now_seconds();
+ bool temp_allow = temp_match(cfg->temp_allow, cfg->temp_allow_count, domain, now);
+ bool temp_block = temp_match(cfg->temp_block, cfg->temp_block_count, domain, now);
+ bool app_allow = app_exact_match_indexed(&cfg->app_exact_allow,
+ cfg->app_exact_allow_index, cfg->app_exact_allow_index_size, uid, domain);
+ bool app_suffix_allow = app_suffix_trie_match(&cfg->app_suffix_allow_trie, uid, domain);
+ bool network_allow = network_rules_match(cfg->network_allow,
+ cfg->network_allow_count, peer);
+ bool network_block = network_rules_match(cfg->network_block,
+ cfg->network_block_count, peer);
+ bool exact_allow = exact_match_indexed(&cfg->exact_allow, cfg->exact_allow_index,
+ cfg->exact_allow_index_size, domain);
+ bool suffix_allow = suffix_trie_match(&cfg->suffix_allow_trie, domain);
+ bool regex_allow = regex_match_rules(&cfg->regex_allow, domain);
+ bool block = false;
+
+ if (temp_block) {
+ *reason = "temp_block";
+ block = true;
+ } else if (app_exact_match_indexed(&cfg->app_exact_block, cfg->app_exact_block_index,
+ cfg->app_exact_block_index_size, uid, domain)) {
+ *reason = "app_exact_block";
+ block = true;
+ } else if (app_suffix_trie_match(&cfg->app_suffix_block_trie, uid, domain)) {
+ *reason = "app_suffix_block";
+ block = true;
+ } else if (network_block) {
+ *reason = "network_block";
+ block = true;
+ } else if (exact_match_indexed(&cfg->exact_block, cfg->exact_block_index,
+ cfg->exact_block_index_size, domain)) {
+ *reason = "exact_block";
+ block = true;
+ } else if (suffix_trie_match(&cfg->suffix_block_trie, domain)) {
+ *reason = "suffix_block";
+ block = true;
+ } else if (regex_match_rules(&cfg->regex_block, domain)) {
+ *reason = "regex_block";
+ block = true;
+ }
+
+ if (block && cfg->strict_mode) {
+ return DECISION_BLOCK;
+ }
+ if (temp_allow) {
+ *reason = "temp_allow";
+ return DECISION_ALLOW;
+ }
+ if (temp_block) {
+ /* Temporary blocks are local user overrides and must not be bypassed by saved allowlists. */
+ *reason = "temp_block";
+ return DECISION_BLOCK;
+ }
+ if (app_allow) {
+ *reason = "app_exact_allow";
+ return DECISION_ALLOW;
+ }
+ if (app_suffix_allow) {
+ *reason = "app_suffix_allow";
+ return DECISION_ALLOW;
+ }
+ if (network_allow) {
+ *reason = "network_allow";
+ return DECISION_ALLOW;
+ }
+ if (exact_allow) {
+ *reason = "exact_allow";
+ return DECISION_ALLOW;
+ }
+ if (suffix_allow) {
+ *reason = "suffix_allow";
+ return DECISION_ALLOW;
+ }
+ if (regex_allow) {
+ *reason = "regex_allow";
+ return DECISION_ALLOW;
+ }
+ if (block) {
+ return DECISION_BLOCK;
+ }
+ *reason = "upstream";
+ return DECISION_ALLOW;
+}
+
+static bool parse_qname(const uint8_t *packet, size_t len, char *domain, size_t domain_len, uint16_t *qtype) {
+ size_t pos = 12;
+ size_t out = 0;
+ if (len < 12) {
+ return false;
+ }
+ while (pos < len) {
+ uint8_t label_len = packet[pos++];
+ if (label_len == 0) {
+ break;
+ }
+ if ((label_len & 0xc0u) != 0 || label_len > 63 || pos + label_len > len) {
+ return false;
+ }
+ if (out != 0) {
+ if (out + 1 >= domain_len) {
+ return false;
+ }
+ domain[out++] = '.';
+ }
+ if (out + label_len >= domain_len) {
+ return false;
+ }
+ memcpy(domain + out, packet + pos, label_len);
+ out += label_len;
+ pos += label_len;
+ }
+ if (pos + 4 > len || out == 0) {
+ return false;
+ }
+ domain[out] = '\0';
+ lower_ascii(domain);
+ *qtype = (uint16_t) ((packet[pos] << 8) | packet[pos + 1]);
+ return true;
+}
+
+static size_t build_block_response(const uint8_t *query, size_t query_len, uint8_t *out, size_t out_len) {
+ size_t pos = 12;
+ if (query_len < 12 || out_len < query_len) {
+ return 0;
+ }
+ while (pos < query_len && query[pos] != 0) {
+ uint8_t label_len = query[pos];
+ if ((label_len & 0xc0u) != 0 || label_len > 63 || pos + label_len + 1 > query_len) {
+ return 0;
+ }
+ pos += (size_t) label_len + 1;
+ }
+ if (pos + 5 > query_len) {
+ return 0;
+ }
+ memcpy(out, query, pos + 5);
+ out[2] = 0x81;
+ out[3] = 0x83;
+ out[6] = 0x00;
+ out[7] = 0x00;
+ out[8] = 0x00;
+ out[9] = 0x00;
+ out[10] = 0x00;
+ out[11] = 0x00;
+ return pos + 5;
+}
+
+static void write_u16(uint8_t *p, uint16_t value) {
+ p[0] = (uint8_t) ((value >> 8) & 0xffu);
+ p[1] = (uint8_t) (value & 0xffu);
+}
+
+static uint32_t read_u32(const uint8_t *p) {
+ return ((uint32_t) p[0] << 24) | ((uint32_t) p[1] << 16) | ((uint32_t) p[2] << 8) | p[3];
+}
+
+static void write_u32(uint8_t *p, uint32_t value) {
+ p[0] = (uint8_t) ((value >> 24) & 0xffu);
+ p[1] = (uint8_t) ((value >> 16) & 0xffu);
+ p[2] = (uint8_t) ((value >> 8) & 0xffu);
+ p[3] = (uint8_t) (value & 0xffu);
+}
+
+static uint16_t read_u16(const uint8_t *p) {
+ return (uint16_t) (((uint16_t) p[0] << 8) | p[1]);
+}
+
+static size_t build_address_response(const uint8_t *query, size_t query_len,
+ uint8_t *out, size_t out_len,
+ uint16_t qtype, const char *address) {
+ size_t qend = 12;
+ size_t pos;
+ uint8_t raw[16];
+ int family;
+ size_t rdlen;
+ if (query_len < 12 || address == NULL || address[0] == '\0') {
+ return 0;
+ }
+ while (qend < query_len && query[qend] != 0) {
+ uint8_t label_len = query[qend];
+ if ((label_len & 0xc0u) != 0 || label_len > 63 || qend + label_len + 1 > query_len) {
+ return 0;
+ }
+ qend += (size_t) label_len + 1;
+ }
+ if (qend + 5 > query_len) {
+ return 0;
+ }
+ family = qtype == DNS_QTYPE_A ? AF_INET : qtype == DNS_QTYPE_AAAA ? AF_INET6 : AF_UNSPEC;
+ if (family == AF_UNSPEC || inet_pton(family, address, raw) != 1) {
+ return 0;
+ }
+ rdlen = qtype == DNS_QTYPE_A ? 4u : 16u;
+ if (out_len < qend + 5 + 12 + rdlen) {
+ return 0;
+ }
+ memcpy(out, query, qend + 5);
+ out[2] = (uint8_t) (0x80u | (query[2] & 0x01u));
+ out[3] = 0x80;
+ write_u16(out + 6, 1);
+ write_u16(out + 8, 0);
+ write_u16(out + 10, 0);
+ pos = qend + 5;
+ out[pos++] = 0xc0;
+ out[pos++] = 0x0c;
+ write_u16(out + pos, qtype);
+ pos += 2;
+ write_u16(out + pos, DNS_CLASS_IN);
+ pos += 2;
+ write_u32(out + pos, SAFE_SEARCH_TTL);
+ pos += 4;
+ write_u16(out + pos, (uint16_t) rdlen);
+ pos += 2;
+ memcpy(out + pos, raw, rdlen);
+ return pos + rdlen;
+}
+
+static bool domain_equals_or_has_suffix(const char *domain, const char *suffix) {
+ return domain != NULL && suffix != NULL && domain_has_suffix(domain, suffix);
+}
+
+static bool safe_search_google_tld(const char *suffix) {
+ size_t i;
+ size_t dots = 0;
+ size_t len = suffix == NULL ? 0 : strlen(suffix);
+ if (len < 2 || len > 6 || suffix[0] == '.' || suffix[len - 1] == '.') {
+ return false;
+ }
+ for (i = 0; i < len; i++) {
+ if (suffix[i] == '.') {
+ dots++;
+ if (dots > 1 || i == 0 || suffix[i - 1] == '.') {
+ return false;
+ }
+ } else if (!isalpha((unsigned char) suffix[i])) {
+ return false;
+ }
+ }
+ return true;
+}
+
+static bool safe_search_google_domain(const char *domain) {
+ if (domain == NULL) {
+ return false;
+ }
+ if (strncmp(domain, "www.google.", 11) == 0) {
+ return safe_search_google_tld(domain + 11);
+ }
+ if (strncmp(domain, "google.", 7) == 0) {
+ return safe_search_google_tld(domain + 7);
+ }
+ return false;
+}
+
+static const safe_search_target_t *safe_search_target_for_domain(const config_t *cfg,
+ const char *domain) {
+ if (cfg == NULL || domain == NULL || !cfg->safe_search) {
+ return NULL;
+ }
+ if (safe_search_google_domain(domain)) {
+ return &cfg->safe_google;
+ }
+ if (domain_equals_or_has_suffix(domain, "youtube.com")
+ || domain_equals_or_has_suffix(domain, "youtube-nocookie.com")
+ || strcmp(domain, "youtube.googleapis.com") == 0
+ || strcmp(domain, "youtubei.googleapis.com") == 0) {
+ return &cfg->safe_youtube;
+ }
+ if (strcmp(domain, "bing.com") == 0 || strcmp(domain, "www.bing.com") == 0
+ || strcmp(domain, "cn.bing.com") == 0) {
+ return &cfg->safe_bing;
+ }
+ if (strcmp(domain, "duckduckgo.com") == 0 || strcmp(domain, "www.duckduckgo.com") == 0
+ || strcmp(domain, "safe.duckduckgo.com") == 0) {
+ return &cfg->safe_duckduckgo;
+ }
+ return NULL;
+}
+
+static size_t build_safe_search_response(const config_t *cfg, const char *domain,
+ uint16_t qtype, const uint8_t *query,
+ size_t query_len, uint8_t *out, size_t out_len) {
+ const safe_search_target_t *target = safe_search_target_for_domain(cfg, domain);
+ const char *address;
+ if (target == NULL || (qtype != DNS_QTYPE_A && qtype != DNS_QTYPE_AAAA)) {
+ return 0;
+ }
+ address = qtype == DNS_QTYPE_A ? target->ipv4 : target->ipv6;
+ if (address[0] == '\0') {
+ return 0;
+ }
+ return build_address_response(query, query_len, out, out_len, qtype, address);
+}
+
+static size_t skip_name(const uint8_t *packet, size_t len, size_t pos) {
+ while (pos < len) {
+ uint8_t c = packet[pos++];
+ if (c == 0) {
+ return pos;
+ }
+ if ((c & 0xc0u) == 0xc0u) {
+ return pos + 1 <= len ? pos + 1 : len + 1;
+ }
+ if ((c & 0xc0u) != 0 || pos + c > len) {
+ return len + 1;
+ }
+ pos += c;
+ }
+ return len + 1;
+}
+
+static bool read_rr_header(const uint8_t *packet, size_t len, size_t *pos,
+ uint16_t *type, uint32_t *ttl, uint16_t *rdlen,
+ size_t *rdata_pos) {
+ size_t rr_pos = skip_name(packet, len, *pos);
+ if (rr_pos + 10 > len) {
+ return false;
+ }
+ *type = read_u16(packet + rr_pos);
+ *ttl = read_u32(packet + rr_pos + 4);
+ *rdlen = read_u16(packet + rr_pos + 8);
+ *rdata_pos = rr_pos + 10;
+ if (*rdata_pos + *rdlen > len) {
+ return false;
+ }
+ *pos = *rdata_pos + *rdlen;
+ return true;
+}
+
+static bool find_dns_opt_record(const uint8_t *packet, size_t len,
+ bool *has_opt, bool *has_do, size_t *z_flags_pos) {
+ uint16_t qd;
+ uint16_t an;
+ uint16_t ns;
+ uint16_t ar;
+ uint32_t total_rrs;
+ uint32_t i;
+ size_t pos = 12;
+ if (has_opt != NULL) {
+ *has_opt = false;
+ }
+ if (has_do != NULL) {
+ *has_do = false;
+ }
+ if (z_flags_pos != NULL) {
+ *z_flags_pos = 0;
+ }
+ if (packet == NULL || len < 12) {
+ return false;
+ }
+ qd = read_u16(packet + 4);
+ an = read_u16(packet + 6);
+ ns = read_u16(packet + 8);
+ ar = read_u16(packet + 10);
+ for (i = 0; i < qd; i++) {
+ pos = skip_name(packet, len, pos);
+ if (pos + 4 > len) {
+ return false;
+ }
+ pos += 4;
+ }
+ total_rrs = (uint32_t) an + (uint32_t) ns;
+ for (i = 0; i < total_rrs; i++) {
+ uint16_t type;
+ uint16_t rdlen;
+ uint32_t ttl;
+ size_t rdata_pos;
+ if (!read_rr_header(packet, len, &pos, &type, &ttl, &rdlen, &rdata_pos)) {
+ return false;
+ }
+ }
+ for (i = 0; i < ar; i++) {
+ uint16_t type;
+ uint16_t rdlen;
+ size_t rr_pos = skip_name(packet, len, pos);
+ size_t rdata_pos;
+ if (rr_pos + 10 > len) {
+ return false;
+ }
+ type = read_u16(packet + rr_pos);
+ rdlen = read_u16(packet + rr_pos + 8);
+ rdata_pos = rr_pos + 10;
+ if (rdata_pos + rdlen > len) {
+ return false;
+ }
+ if (type == DNS_QTYPE_OPT) {
+ uint16_t z_flags = read_u16(packet + rr_pos + 6);
+ if (has_opt != NULL) {
+ *has_opt = true;
+ }
+ if (has_do != NULL) {
+ *has_do = (z_flags & DNSSEC_DO_FLAG) != 0;
+ }
+ if (z_flags_pos != NULL) {
+ *z_flags_pos = rr_pos + 6;
+ }
+ return true;
+ }
+ pos = rdata_pos + rdlen;
+ }
+ return true;
+}
+
+static bool dns_query_requests_dnssec(const uint8_t *query, size_t query_len) {
+ bool has_do = false;
+ if (!find_dns_opt_record(query, query_len, NULL, &has_do, NULL)) {
+ return false;
+ }
+ return has_do;
+}
+
+static bool prepare_dnssec_query(const uint8_t *query, size_t query_len,
+ uint8_t *out, size_t out_len, size_t *prepared_len,
+ bool *added_opt, bool *updated_opt) {
+ bool has_opt = false;
+ bool has_do = false;
+ size_t z_flags_pos = 0;
+ uint16_t ar;
+ size_t pos;
+ if (prepared_len != NULL) {
+ *prepared_len = 0;
+ }
+ if (added_opt != NULL) {
+ *added_opt = false;
+ }
+ if (updated_opt != NULL) {
+ *updated_opt = false;
+ }
+ if (query == NULL || out == NULL || query_len < 12 || query_len > out_len
+ || !find_dns_opt_record(query, query_len, &has_opt, &has_do, &z_flags_pos)) {
+ return false;
+ }
+ memcpy(out, query, query_len);
+ if (has_do) {
+ if (prepared_len != NULL) {
+ *prepared_len = query_len;
+ }
+ return true;
+ }
+ if (has_opt) {
+ uint16_t z_flags = (uint16_t) (read_u16(out + z_flags_pos) | DNSSEC_DO_FLAG);
+ write_u16(out + z_flags_pos, z_flags);
+ if (updated_opt != NULL) {
+ *updated_opt = true;
+ }
+ if (prepared_len != NULL) {
+ *prepared_len = query_len;
+ }
+ return true;
+ }
+ if (query_len + 11 > out_len) {
+ return false;
+ }
+ ar = read_u16(out + 10);
+ if (ar == UINT16_MAX) {
+ return false;
+ }
+ write_u16(out + 10, (uint16_t) (ar + 1));
+ pos = query_len;
+ out[pos++] = 0;
+ write_u16(out + pos, DNS_QTYPE_OPT);
+ pos += 2;
+ write_u16(out + pos, DNSSEC_UDP_PAYLOAD_SIZE);
+ pos += 2;
+ write_u32(out + pos, DNSSEC_DO_FLAG);
+ pos += 4;
+ write_u16(out + pos, 0);
+ pos += 2;
+ if (added_opt != NULL) {
+ *added_opt = true;
+ }
+ if (prepared_len != NULL) {
+ *prepared_len = pos;
+ }
+ return true;
+}
+
+static uint32_t clamp_cache_ttl(uint32_t ttl, uint32_t fallback) {
+ if (ttl == 0 || ttl == UINT32_MAX) {
+ ttl = fallback;
+ }
+ if (ttl > MAX_CACHE_TTL) {
+ ttl = MAX_CACHE_TTL;
+ }
+ return ttl;
+}
+
+static bool response_is_negative_cache(const uint8_t *response, size_t response_len) {
+ uint16_t flags;
+ uint16_t an;
+ uint16_t rcode;
+ if (response_len < 12) {
+ return false;
+ }
+ flags = read_u16(response + 2);
+ rcode = flags & 0x000fu;
+ an = read_u16(response + 6);
+ return rcode == 3 || (rcode == 0 && an == 0);
+}
+
+static uint32_t extract_cache_ttl(const uint8_t *packet, size_t len, bool negative) {
+ uint16_t qd;
+ uint16_t an;
+ uint16_t ns;
+ size_t pos = 12;
+ uint16_t i;
+ uint32_t min_ttl = UINT32_MAX;
+ if (len < 12) {
+ return 0;
+ }
+ qd = read_u16(packet + 4);
+ an = read_u16(packet + 6);
+ ns = read_u16(packet + 8);
+ for (i = 0; i < qd; i++) {
+ pos = skip_name(packet, len, pos);
+ if (pos + 4 > len) {
+ return 0;
+ }
+ pos += 4;
+ }
+ for (i = 0; i < an; i++) {
+ uint16_t type;
+ uint16_t rdlen;
+ uint32_t ttl;
+ size_t rdata_pos;
+ if (!read_rr_header(packet, len, &pos, &type, &ttl, &rdlen, &rdata_pos)) {
+ return negative ? DEFAULT_NEGATIVE_TTL : 0;
+ }
+ if (!negative && ttl < min_ttl) {
+ min_ttl = ttl;
+ }
+ }
+ if (!negative) {
+ if (min_ttl == 0) {
+ return 0;
+ }
+ return clamp_cache_ttl(min_ttl, DEFAULT_POSITIVE_TTL);
+ }
+ for (i = 0; i < ns; i++) {
+ uint16_t type;
+ uint16_t rdlen;
+ uint32_t ttl;
+ size_t rdata_pos;
+ size_t rpos;
+ if (!read_rr_header(packet, len, &pos, &type, &ttl, &rdlen, &rdata_pos)) {
+ return DEFAULT_NEGATIVE_TTL;
+ }
+ if (type != 6) {
+ continue;
+ }
+ rpos = skip_name(packet, len, rdata_pos);
+ rpos = skip_name(packet, len, rpos);
+ if (rpos + 20 <= rdata_pos + rdlen && rpos + 20 <= len) {
+ uint32_t minimum = read_u32(packet + rpos + 16);
+ uint32_t negative_ttl = ttl < minimum ? ttl : minimum;
+ if (negative_ttl < min_ttl) {
+ min_ttl = negative_ttl;
+ }
+ }
+ }
+ if (min_ttl == 0) {
+ return 0;
+ }
+ return clamp_cache_ttl(min_ttl, DEFAULT_NEGATIVE_TTL);
+}
+
+static void rewrite_cached_response_ttls(uint8_t *packet, size_t len, time_t cached_at,
+ time_t now, uint32_t cache_remaining) {
+ uint16_t qd;
+ uint32_t total_rrs;
+ uint32_t i;
+ size_t pos = 12;
+ uint64_t elapsed = now > cached_at ? (uint64_t) (now - cached_at) : 0;
+ if (packet == NULL || len < 12) {
+ return;
+ }
+ qd = read_u16(packet + 4);
+ total_rrs = (uint32_t) read_u16(packet + 6)
+ + (uint32_t) read_u16(packet + 8)
+ + (uint32_t) read_u16(packet + 10);
+ for (i = 0; i < qd; i++) {
+ pos = skip_name(packet, len, pos);
+ if (pos + 4 > len) {
+ return;
+ }
+ pos += 4;
+ }
+ for (i = 0; i < total_rrs; i++) {
+ size_t rr_pos = skip_name(packet, len, pos);
+ size_t ttl_pos = rr_pos + 4;
+ size_t rdata_pos = rr_pos + 10;
+ uint16_t rdlen;
+ uint32_t ttl;
+ uint32_t adjusted;
+ if (rr_pos + 10 > len) {
+ return;
+ }
+ rdlen = read_u16(packet + rr_pos + 8);
+ if (rdata_pos + rdlen > len) {
+ return;
+ }
+ ttl = read_u32(packet + ttl_pos);
+ adjusted = ttl > elapsed ? ttl - (uint32_t) elapsed : 0;
+ if (adjusted > cache_remaining) {
+ adjusted = cache_remaining;
+ }
+ write_u32(packet + ttl_pos, adjusted);
+ pos = rdata_pos + rdlen;
+ }
+ g_stats.cache_ttl_rewrites++;
+}
+
+static bool cache_lookup(const char *domain, uint16_t qtype, bool dnssec,
+ const uint8_t *query, uint8_t *out, size_t *out_len,
+ bool *negative) {
+ uint32_t h = hash_domain_cache_key(domain, qtype, dnssec);
+ uint32_t start;
+ uint32_t i;
+ time_t now = time(NULL);
+ if (g_cache == NULL || g_cache_capacity <= 0) {
+ return false;
+ }
+ start = h % (uint32_t) g_cache_capacity;
+ for (i = 0; i < (uint32_t) g_cache_capacity; i++) {
+ cache_entry_t *entry = &g_cache[(start + i) % (uint32_t) g_cache_capacity];
+ if (!entry->used) {
+ continue;
+ }
+ if (entry->hash == h && entry->qtype == qtype && entry->dnssec == dnssec
+ && strcmp(entry->domain, domain) == 0) {
+ uint32_t remaining;
+ uint64_t lifetime_remaining;
+ if (entry->expires_at <= now) {
+ if (!entry->expired_reported) {
+ entry->expired_reported = true;
+ g_stats.cache_expired++;
+ }
+ return false;
+ }
+ lifetime_remaining = (uint64_t) (entry->expires_at - now);
+ remaining = lifetime_remaining > (uint64_t) UINT32_MAX
+ ? UINT32_MAX : (uint32_t) lifetime_remaining;
+ memcpy(out, entry->response, entry->response_len);
+ out[0] = query[0];
+ out[1] = query[1];
+ *out_len = entry->response_len;
+ entry->last_access = now;
+ rewrite_cached_response_ttls(out, *out_len, entry->cached_at, now, remaining);
+ if (negative != NULL) {
+ *negative = entry->negative;
+ }
+ return true;
+ }
+ }
+ return false;
+}
+
+static bool cache_lookup_stale(const char *domain, uint16_t qtype, bool dnssec,
+ const uint8_t *query, uint8_t *out, size_t *out_len,
+ bool *negative, int stale_seconds) {
+ uint32_t h = hash_domain_cache_key(domain, qtype, dnssec);
+ uint32_t start;
+ uint32_t i;
+ time_t now = time(NULL);
+ if (g_cache == NULL || g_cache_capacity <= 0 || stale_seconds <= 0) {
+ return false;
+ }
+ start = h % (uint32_t) g_cache_capacity;
+ for (i = 0; i < (uint32_t) g_cache_capacity; i++) {
+ cache_entry_t *entry = &g_cache[(start + i) % (uint32_t) g_cache_capacity];
+ time_t stale_age;
+ if (!entry->used) {
+ continue;
+ }
+ if (entry->hash != h || entry->qtype != qtype || entry->dnssec != dnssec
+ || strcmp(entry->domain, domain) != 0) {
+ continue;
+ }
+ if (entry->expires_at > now) {
+ return false;
+ }
+ stale_age = now - entry->expires_at;
+ if (stale_age < 0 || stale_age > stale_seconds) {
+ entry->used = false;
+ g_stats.cache_stale_expired++;
+ return false;
+ }
+ memcpy(out, entry->response, entry->response_len);
+ out[0] = query[0];
+ out[1] = query[1];
+ *out_len = entry->response_len;
+ entry->last_access = now;
+ rewrite_cached_response_ttls(out, *out_len, entry->cached_at, now, 0);
+ if (negative != NULL) {
+ *negative = entry->negative;
+ }
+ return true;
+ }
+ return false;
+}
+
+static bool response_cacheable(const uint8_t *response, size_t response_len) {
+ uint16_t flags;
+ uint16_t rcode;
+ if (response_len < 12) {
+ return false;
+ }
+ flags = read_u16(response + 2);
+ rcode = flags & 0x000fu;
+ if ((flags & 0x8000u) == 0) {
+ return false;
+ }
+ return rcode == 0 || rcode == 3;
+}
+
+static bool dns_response_authenticated(const uint8_t *response, size_t response_len) {
+ uint16_t flags;
+ if (response_len < 12) {
+ return false;
+ }
+ flags = read_u16(response + 2);
+ return (flags & DNS_FLAG_AD) != 0;
+}
+
+static void cache_store(const char *domain, uint16_t qtype, bool dnssec,
+ const uint8_t *response, size_t response_len) {
+ uint32_t h;
+ uint32_t slot;
+ uint32_t ttl;
+ cache_entry_t *entry;
+ uint32_t i;
+ time_t now = time(NULL);
+ bool evicting = false;
+ bool negative;
+ cache_entry_t *reusable = NULL;
+ cache_entry_t *lru = NULL;
+ if (g_cache == NULL || g_cache_capacity <= 0
+ || response_len < 12 || response_len > MAX_PACKET
+ || !response_cacheable(response, response_len)) {
+ return;
+ }
+ negative = response_is_negative_cache(response, response_len);
+ ttl = extract_cache_ttl(response, response_len, negative);
+ if (ttl == 0) {
+ return;
+ }
+ h = hash_domain_cache_key(domain, qtype, dnssec);
+ slot = h % (uint32_t) g_cache_capacity;
+ entry = NULL;
+ for (i = 0; i < (uint32_t) g_cache_capacity; i++) {
+ cache_entry_t *candidate = &g_cache[(slot + i) % (uint32_t) g_cache_capacity];
+ if (candidate->used && candidate->expires_at <= now) {
+ if (!candidate->expired_reported) {
+ g_stats.cache_expired++;
+ }
+ candidate->used = false;
+ }
+ if (candidate->used && candidate->hash == h
+ && candidate->qtype == qtype
+ && candidate->dnssec == dnssec
+ && strcmp(candidate->domain, domain) == 0) {
+ entry = candidate;
+ break;
+ }
+ if (!candidate->used) {
+ if (reusable == NULL) {
+ reusable = candidate;
+ }
+ continue;
+ }
+ if (lru == NULL || candidate->last_access < lru->last_access) {
+ lru = candidate;
+ }
+ }
+ if (entry == NULL) {
+ entry = reusable != NULL ? reusable : lru != NULL ? lru : &g_cache[slot];
+ evicting = entry->used && entry->expires_at > now;
+ }
+ if (evicting) {
+ g_stats.cache_evictions++;
+ g_stats.cache_lru_evictions++;
+ }
+ memset(entry, 0, sizeof(*entry));
+ safe_copy(entry->domain, sizeof(entry->domain), domain);
+ entry->qtype = qtype;
+ entry->dnssec = dnssec;
+ entry->hash = h;
+ memcpy(entry->response, response, response_len);
+ entry->response_len = response_len;
+ entry->cached_at = now;
+ entry->last_access = now;
+ entry->expires_at = now + ttl;
+ entry->used = true;
+ entry->negative = negative;
+ g_stats.cache_stores++;
+ if (negative) {
+ g_stats.cache_negative_stores++;
+ } else {
+ g_stats.cache_positive_stores++;
+ }
+}
+
+static bool cache_insert_existing(cache_entry_t *cache, int capacity,
+ const cache_entry_t *source, time_t now) {
+ uint32_t slot;
+ uint32_t i;
+ cache_entry_t *entry = NULL;
+ cache_entry_t *reusable = NULL;
+ cache_entry_t *lru = NULL;
+ if (cache == NULL || capacity <= 0 || source == NULL || !source->used
+ || source->expires_at <= now || source->response_len == 0
+ || source->response_len > MAX_PACKET) {
+ return false;
+ }
+ slot = source->hash % (uint32_t) capacity;
+ for (i = 0; i < (uint32_t) capacity; i++) {
+ cache_entry_t *candidate = &cache[(slot + i) % (uint32_t) capacity];
+ if (candidate->used && candidate->expires_at <= now) {
+ candidate->used = false;
+ }
+ if (candidate->used && candidate->hash == source->hash
+ && candidate->qtype == source->qtype
+ && candidate->dnssec == source->dnssec
+ && strcmp(candidate->domain, source->domain) == 0) {
+ entry = candidate;
+ break;
+ }
+ if (!candidate->used) {
+ if (reusable == NULL) {
+ reusable = candidate;
+ }
+ continue;
+ }
+ if (lru == NULL || candidate->last_access < lru->last_access) {
+ lru = candidate;
+ }
+ }
+ if (entry == NULL) {
+ entry = reusable != NULL ? reusable : lru != NULL ? lru : &cache[slot];
+ }
+ *entry = *source;
+ entry->used = true;
+ return true;
+}
+
+static int cache_count_entries_in(const cache_entry_t *cache, int capacity, time_t now) {
+ int i;
+ int count = 0;
+ if (cache == NULL || capacity <= 0) {
+ return 0;
+ }
+ for (i = 0; i < capacity; i++) {
+ if (cache[i].used && cache[i].expires_at > now) {
+ count++;
+ }
+ }
+ return count;
+}
+
+static void migrate_cache_entries(cache_entry_t *old_cache, int old_capacity,
+ cache_entry_t *new_cache, int new_capacity) {
+ int i;
+ int candidates = 0;
+ int preserved;
+ time_t now = time(NULL);
+ if (old_cache == NULL || old_capacity <= 0) {
+ return;
+ }
+ if (new_cache == NULL || new_capacity <= 0) {
+ g_stats.cache_reload_dropped += (uint64_t) cache_count_entries_in(old_cache,
+ old_capacity, now);
+ return;
+ }
+ for (i = 0; i < old_capacity; i++) {
+ if (old_cache[i].used && old_cache[i].expires_at > now) {
+ candidates++;
+ cache_insert_existing(new_cache, new_capacity, &old_cache[i], now);
+ }
+ }
+ preserved = cache_count_entries_in(new_cache, new_capacity, now);
+ g_stats.cache_reload_preserved += (uint64_t) preserved;
+ if (candidates > preserved) {
+ g_stats.cache_reload_dropped += (uint64_t) (candidates - preserved);
+ }
+}
+
+static bool cache_snapshot_enabled(const config_t *cfg) {
+ return cfg != NULL && cfg->persist_cache && cfg->cache_size > 0
+ && cfg->cache_file[0] != '\0';
+}
+
+static bool write_cache_snapshot(const config_t *cfg, const cache_entry_t *cache, int capacity) {
+ FILE *fp;
+ char tmp_path[sizeof(cfg->cache_file) + 8];
+ uint32_t version = CACHE_SNAPSHOT_VERSION;
+ uint32_t count = 0;
+ uint32_t written = 0;
+ uint64_t scope_hash;
+ time_t now = time(NULL);
+ int i;
+ if (!cache_snapshot_enabled(cfg) || cache == NULL || capacity <= 0) {
+ return true;
+ }
+ if (snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", cfg->cache_file) >= (int) sizeof(tmp_path)) {
+ g_stats.cache_snapshot_save_failures++;
+ return false;
+ }
+ for (i = 0; i < capacity; i++) {
+ if (cache[i].used && cache[i].expires_at > now && cache[i].response_len > 0
+ && cache[i].response_len <= MAX_PACKET) {
+ count++;
+ }
+ }
+ fp = fopen(tmp_path, "wb");
+ if (fp == NULL) {
+ g_stats.cache_snapshot_save_failures++;
+ return false;
+ }
+ scope_hash = cfg->resolver_scope_hash;
+ if (fwrite(CACHE_SNAPSHOT_MAGIC, 1, 8, fp) != 8
+ || fwrite(&version, sizeof(version), 1, fp) != 1
+ || fwrite(&scope_hash, sizeof(scope_hash), 1, fp) != 1
+ || fwrite(&count, sizeof(count), 1, fp) != 1) {
+ fclose(fp);
+ unlink(tmp_path);
+ g_stats.cache_snapshot_save_failures++;
+ return false;
+ }
+ for (i = 0; i < capacity; i++) {
+ const cache_entry_t *entry = &cache[i];
+ uint16_t domain_len;
+ uint16_t response_len;
+ uint8_t negative;
+ uint8_t dnssec;
+ int64_t cached_at;
+ int64_t expires_at;
+ if (!entry->used || entry->expires_at <= now || entry->response_len == 0
+ || entry->response_len > MAX_PACKET) {
+ continue;
+ }
+ domain_len = (uint16_t) strlen(entry->domain);
+ response_len = (uint16_t) entry->response_len;
+ negative = entry->negative ? 1 : 0;
+ dnssec = entry->dnssec ? 1 : 0;
+ cached_at = (int64_t) entry->cached_at;
+ expires_at = (int64_t) entry->expires_at;
+ if (domain_len == 0 || domain_len >= MAX_DOMAIN
+ || fwrite(&entry->qtype, sizeof(entry->qtype), 1, fp) != 1
+ || fwrite(&domain_len, sizeof(domain_len), 1, fp) != 1
+ || fwrite(&response_len, sizeof(response_len), 1, fp) != 1
+ || fwrite(&negative, sizeof(negative), 1, fp) != 1
+ || fwrite(&dnssec, sizeof(dnssec), 1, fp) != 1
+ || fwrite(&cached_at, sizeof(cached_at), 1, fp) != 1
+ || fwrite(&expires_at, sizeof(expires_at), 1, fp) != 1
+ || fwrite(entry->domain, 1, domain_len, fp) != domain_len
+ || fwrite(entry->response, 1, response_len, fp) != response_len) {
+ fclose(fp);
+ unlink(tmp_path);
+ g_stats.cache_snapshot_save_failures++;
+ return false;
+ }
+ written++;
+ }
+ if (fclose(fp) != 0 || written != count || rename(tmp_path, cfg->cache_file) != 0) {
+ unlink(tmp_path);
+ g_stats.cache_snapshot_save_failures++;
+ return false;
+ }
+ chmod(cfg->cache_file, 0600);
+ g_stats.cache_snapshot_saved += (uint64_t) written;
+ return true;
+}
+
+static bool load_cache_snapshot(const config_t *cfg, cache_entry_t *cache, int capacity) {
+ FILE *fp;
+ char magic[8];
+ uint32_t version;
+ uint32_t count;
+ uint32_t i;
+ uint64_t scope_hash;
+ time_t now = time(NULL);
+ uint64_t restored = 0;
+ if (!cache_snapshot_enabled(cfg) || cache == NULL || capacity <= 0) {
+ return true;
+ }
+ fp = fopen(cfg->cache_file, "rb");
+ if (fp == NULL) {
+ return true;
+ }
+ if (fread(magic, 1, sizeof(magic), fp) != sizeof(magic)
+ || memcmp(magic, CACHE_SNAPSHOT_MAGIC, sizeof(magic)) != 0
+ || fread(&version, sizeof(version), 1, fp) != 1
+ || fread(&scope_hash, sizeof(scope_hash), 1, fp) != 1
+ || fread(&count, sizeof(count), 1, fp) != 1
+ || version != CACHE_SNAPSHOT_VERSION
+ || count > (uint32_t) MAX_CACHE_SIZE) {
+ fclose(fp);
+ g_stats.cache_snapshot_load_failures++;
+ return false;
+ }
+ if (scope_hash != cfg->resolver_scope_hash) {
+ fclose(fp);
+ return true;
+ }
+ for (i = 0; i < count; i++) {
+ cache_entry_t entry;
+ uint16_t domain_len;
+ uint16_t response_len;
+ uint8_t negative;
+ uint8_t dnssec;
+ int64_t cached_at;
+ int64_t expires_at;
+ memset(&entry, 0, sizeof(entry));
+ if (fread(&entry.qtype, sizeof(entry.qtype), 1, fp) != 1
+ || fread(&domain_len, sizeof(domain_len), 1, fp) != 1
+ || fread(&response_len, sizeof(response_len), 1, fp) != 1
+ || fread(&negative, sizeof(negative), 1, fp) != 1
+ || fread(&dnssec, sizeof(dnssec), 1, fp) != 1
+ || fread(&cached_at, sizeof(cached_at), 1, fp) != 1
+ || fread(&expires_at, sizeof(expires_at), 1, fp) != 1
+ || domain_len == 0 || domain_len >= MAX_DOMAIN
+ || response_len == 0 || response_len > MAX_PACKET
+ || fread(entry.domain, 1, domain_len, fp) != domain_len
+ || fread(entry.response, 1, response_len, fp) != response_len) {
+ fclose(fp);
+ g_stats.cache_snapshot_load_failures++;
+ return false;
+ }
+ entry.domain[domain_len] = '\0';
+ entry.response_len = response_len;
+ entry.cached_at = (time_t) cached_at;
+ entry.last_access = now;
+ entry.expires_at = (time_t) expires_at;
+ entry.dnssec = dnssec != 0;
+ entry.hash = hash_domain_cache_key(entry.domain, entry.qtype, entry.dnssec);
+ entry.used = true;
+ entry.negative = negative != 0;
+ if (entry.expires_at > now && cache_insert_existing(cache, capacity, &entry, now)) {
+ restored++;
+ }
+ }
+ fclose(fp);
+ g_stats.cache_snapshot_restored += restored;
+ return true;
+}
+
+static uint64_t hash_scope_u64(uint64_t hash, uint64_t value) {
+ int i;
+ for (i = 0; i < 8; i++) {
+ hash ^= (uint8_t) ((value >> (i * 8)) & 0xffu);
+ hash *= 1099511628211ULL;
+ }
+ return hash;
+}
+
+static uint64_t hash_scope_string(uint64_t hash, const char *value) {
+ const unsigned char *p = (const unsigned char *) (value == NULL ? "" : value);
+ while (*p) {
+ hash ^= *p++;
+ hash *= 1099511628211ULL;
+ }
+ return hash_scope_u64(hash, 0xffu);
+}
+
+static uint64_t hash_scope_upstream(uint64_t hash, const upstream_t *upstream) {
+ if (upstream == NULL) {
+ return hash_scope_u64(hash, 0);
+ }
+ hash = hash_scope_string(hash, upstream->host);
+ hash = hash_scope_u64(hash, (uint64_t) upstream->port);
+ hash = hash_scope_u64(hash, (uint64_t) upstream->protocol);
+ return hash;
+}
+
+static uint64_t compute_resolver_scope_hash(const config_t *cfg) {
+ int i;
+ uint64_t hash = 1469598103934665603ULL;
+ if (cfg == NULL) {
+ return hash;
+ }
+ hash = hash_scope_u64(hash, (uint64_t) cfg->upstream_count);
+ for (i = 0; i < cfg->upstream_count; i++) {
+ hash = hash_scope_upstream(hash, &cfg->upstreams[i]);
+ }
+ hash = hash_scope_u64(hash, (uint64_t) cfg->dnssec_request);
+ hash = hash_scope_u64(hash, (uint64_t) cfg->dnssec_auth_required);
+ hash = hash_scope_u64(hash, (uint64_t) cfg->split_upstream_count);
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ hash = hash_scope_string(hash, cfg->split_upstreams[i].suffix);
+ hash = hash_scope_upstream(hash, &cfg->split_upstreams[i].upstream);
+ }
+ return hash;
+}
+
+static void compile_upstream_address(upstream_t *upstream) {
+ struct sockaddr_in *addr4;
+ struct sockaddr_in6 *addr6;
+ if (upstream == NULL || upstream->host[0] == '\0') {
+ return;
+ }
+ memset(&upstream->addr, 0, sizeof(upstream->addr));
+ upstream->addr_len = 0;
+ addr4 = (struct sockaddr_in *) &upstream->addr;
+ if (inet_pton(AF_INET, upstream->host, &addr4->sin_addr) == 1) {
+ addr4->sin_family = AF_INET;
+ addr4->sin_port = htons((uint16_t) upstream->port);
+ upstream->addr_len = sizeof(*addr4);
+ return;
+ }
+ addr6 = (struct sockaddr_in6 *) &upstream->addr;
+ if (inet_pton(AF_INET6, upstream->host, &addr6->sin6_addr) == 1) {
+ addr6->sin6_family = AF_INET6;
+ addr6->sin6_port = htons((uint16_t) upstream->port);
+ upstream->addr_len = sizeof(*addr6);
+ }
+}
+
+static void init_upstream_runtime(upstream_t *upstream) {
+ if (upstream != NULL) {
+ upstream->udp_fd = -1;
+ }
+}
+
+static void set_socket_timeout(int fd, int timeout_ms) {
+ struct timeval timeout;
+ timeout.tv_sec = timeout_ms / 1000;
+ timeout.tv_usec = (timeout_ms % 1000) * 1000;
+ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));
+ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout));
+}
+
+static void set_socket_buffers(int fd) {
+ int size = DNS_SOCKET_BUFFER_BYTES;
+ setsockopt(fd, SOL_SOCKET, SO_RCVBUF, &size, sizeof(size));
+ setsockopt(fd, SOL_SOCKET, SO_SNDBUF, &size, sizeof(size));
+}
+
+static bool set_socket_mark(int fd, bool count_failure) {
+ int mark = AFWALL_DNSD_SOCKET_MARK;
+ if (fd < 0) {
+ return false;
+ }
+ if (setsockopt(fd, SOL_SOCKET, SO_MARK, &mark, sizeof(mark)) == 0) {
+ return true;
+ }
+ if (count_failure) {
+ g_stats.socket_mark_failures++;
+ }
+ g_socket_mark_errno = errno;
+ return false;
+}
+
+static void write_socket_mark_status_file(void) {
+ FILE *fp;
+ if (g_cfg.mark_status_file[0] == '\0') {
+ return;
+ }
+ fp = fopen(g_cfg.mark_status_file, "w");
+ if (fp == NULL) {
+ return;
+ }
+ if (g_socket_mark_supported) {
+ fprintf(fp, "supported mark=0x%x failures=%llu\n",
+ AFWALL_DNSD_SOCKET_MARK,
+ (unsigned long long) g_stats.socket_mark_failures);
+ } else {
+ fprintf(fp, "failed mark=0x%x errno=%d failures=%llu\n",
+ AFWALL_DNSD_SOCKET_MARK,
+ g_socket_mark_errno,
+ (unsigned long long) g_stats.socket_mark_failures);
+ }
+ fclose(fp);
+ chmod(g_cfg.mark_status_file, 0644);
+}
+
+static void update_socket_mark_status(void) {
+ int fd = socket(AF_INET, SOCK_DGRAM, 0);
+ if (fd < 0) {
+ g_socket_mark_supported = 0;
+ g_socket_mark_errno = errno;
+ write_socket_mark_status_file();
+ write_event_log("warn", "daemon socket mark probe failed errno=%d failures=%llu",
+ g_socket_mark_errno, (unsigned long long) g_stats.socket_mark_failures);
+ return;
+ }
+ g_socket_mark_supported = set_socket_mark(fd, false) ? 1 : 0;
+ close(fd);
+ write_socket_mark_status_file();
+ if (g_socket_mark_supported) {
+ write_event_log("info", "daemon socket mark supported mark=0x%x failures=%llu",
+ AFWALL_DNSD_SOCKET_MARK,
+ (unsigned long long) g_stats.socket_mark_failures);
+ } else {
+ write_event_log("warn", "daemon socket mark unavailable errno=%d failures=%llu",
+ g_socket_mark_errno, (unsigned long long) g_stats.socket_mark_failures);
+ }
+}
+
+static void mark_upstream_socket(int fd) {
+ set_socket_mark(fd, true);
+}
+
+static bool socket_timed_out(void) {
+ return errno == EAGAIN || errno == EWOULDBLOCK || errno == ETIMEDOUT;
+}
+
+static int connect_upstream(const upstream_t *upstream, int socktype, int timeout_ms) {
+ struct addrinfo hints;
+ struct addrinfo *res = NULL;
+ struct addrinfo *rp;
+ char port[16];
+ int fd = -1;
+ if (upstream == NULL) {
+ return -1;
+ }
+ if (upstream->addr_len > 0) {
+ fd = socket(upstream->addr.ss_family, socktype, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ mark_upstream_socket(fd);
+ set_socket_buffers(fd);
+ set_socket_timeout(fd, timeout_ms);
+ if (connect(fd, (const struct sockaddr *) &upstream->addr, upstream->addr_len) == 0) {
+ return fd;
+ }
+ close(fd);
+ return -1;
+ }
+ snprintf(port, sizeof(port), "%d", upstream->port);
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_family = AF_UNSPEC;
+ hints.ai_socktype = socktype;
+ if (getaddrinfo(upstream->host, port, &hints, &res) != 0) {
+ return -1;
+ }
+ for (rp = res; rp != NULL; rp = rp->ai_next) {
+ fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
+ if (fd < 0) {
+ continue;
+ }
+ mark_upstream_socket(fd);
+ set_socket_buffers(fd);
+ set_socket_timeout(fd, timeout_ms);
+ if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) {
+ break;
+ }
+ close(fd);
+ fd = -1;
+ }
+ freeaddrinfo(res);
+ return fd;
+}
+
+static void prepare_udp_upstream_socket(upstream_t *upstream, int timeout_ms) {
+ if (upstream == NULL || upstream->protocol == UPSTREAM_PROTO_TCP || upstream->addr_len <= 0) {
+ return;
+ }
+ upstream->udp_fd = connect_upstream(upstream, SOCK_DGRAM, timeout_ms);
+}
+
+static void prepare_udp_upstream_sockets(config_t *cfg) {
+ int i;
+ if (cfg == NULL) {
+ return;
+ }
+ for (i = 0; i < cfg->upstream_count; i++) {
+ prepare_udp_upstream_socket(&cfg->upstreams[i], cfg->timeout_ms);
+ }
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ prepare_udp_upstream_socket(&cfg->split_upstreams[i].upstream, cfg->timeout_ms);
+ }
+}
+
+static const char *upstream_protocol_name(upstream_protocol_t protocol) {
+ switch (protocol) {
+ case UPSTREAM_PROTO_UDP:
+ return "udp";
+ case UPSTREAM_PROTO_TCP:
+ return "tcp";
+ case UPSTREAM_PROTO_AUTO:
+ default:
+ return "auto";
+ }
+}
+
+static upstream_t *select_upstreams(config_t *cfg, const char *domain,
+ int *count, const char **route) {
+ int i;
+ int best = -1;
+ size_t best_len = 0;
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ size_t suffix_len = strlen(cfg->split_upstreams[i].suffix);
+ if (suffix_len > best_len && domain_has_suffix(domain, cfg->split_upstreams[i].suffix)) {
+ best = i;
+ best_len = suffix_len;
+ }
+ }
+ if (best >= 0) {
+ *count = 1;
+ if (route != NULL) {
+ *route = "split_upstream";
+ }
+ return &cfg->split_upstreams[best].upstream;
+ }
+ *count = cfg->upstream_count;
+ if (route != NULL) {
+ *route = "upstream";
+ }
+ return cfg->upstreams;
+}
+
+static int compiled_upstream_address_count(const config_t *cfg) {
+ int i;
+ int count = 0;
+ if (cfg == NULL) {
+ return 0;
+ }
+ for (i = 0; i < cfg->upstream_count; i++) {
+ if (cfg->upstreams[i].addr_len > 0) {
+ count++;
+ }
+ }
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ if (cfg->split_upstreams[i].upstream.addr_len > 0) {
+ count++;
+ }
+ }
+ return count;
+}
+
+static int reusable_udp_upstream_socket_count(const config_t *cfg) {
+ int i;
+ int count = 0;
+ if (cfg == NULL) {
+ return 0;
+ }
+ for (i = 0; i < cfg->upstream_count; i++) {
+ if (cfg->upstreams[i].udp_fd >= 0) {
+ count++;
+ }
+ }
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ if (cfg->split_upstreams[i].upstream.udp_fd >= 0) {
+ count++;
+ }
+ }
+ return count;
+}
+
+static int upstream_backoff_active_count(const config_t *cfg) {
+ int i;
+ int count = 0;
+ uint64_t now = now_seconds();
+ if (cfg == NULL) {
+ return 0;
+ }
+ for (i = 0; i < cfg->upstream_count; i++) {
+ if (cfg->upstreams[i].backoff_until > now) {
+ count++;
+ }
+ }
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ if (cfg->split_upstreams[i].upstream.backoff_until > now) {
+ count++;
+ }
+ }
+ return count;
+}
+
+static uint64_t upstream_backoff_seconds(uint64_t failures) {
+ uint64_t seconds = UPSTREAM_BACKOFF_MIN_SECONDS;
+ uint64_t extra_failures;
+ if (failures < UPSTREAM_BACKOFF_FAILURE_THRESHOLD) {
+ return 0;
+ }
+ extra_failures = failures - UPSTREAM_BACKOFF_FAILURE_THRESHOLD;
+ while (extra_failures > 0 && seconds < UPSTREAM_BACKOFF_MAX_SECONDS) {
+ seconds *= 2;
+ extra_failures--;
+ }
+ return seconds > UPSTREAM_BACKOFF_MAX_SECONDS ? UPSTREAM_BACKOFF_MAX_SECONDS : seconds;
+}
+
+static bool upstream_in_backoff(const upstream_t *upstream, uint64_t now) {
+ return upstream != NULL && upstream->backoff_until > now;
+}
+
+static bool should_try_upstream_on_pass(const upstream_t *upstream, int count, int pass,
+ uint64_t now, bool *skipped_backoff) {
+ bool in_backoff;
+ if (upstream == NULL) {
+ return false;
+ }
+ if (count <= 1) {
+ return pass == 0;
+ }
+ in_backoff = upstream_in_backoff(upstream, now);
+ if (pass == 0) {
+ if (in_backoff) {
+ if (skipped_backoff != NULL) {
+ *skipped_backoff = true;
+ }
+ g_stats.upstream_backoff_skips++;
+ return false;
+ }
+ return true;
+ }
+ return in_backoff;
+}
+
+static bool dns_response_truncated(const uint8_t *response, size_t response_len);
+static int response_rcode(const uint8_t *response, size_t response_len);
+static ssize_t forward_tcp_to_upstream(const upstream_t *upstream, int timeout_ms,
+ const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len);
+static ssize_t forward_udp_to_upstream(const upstream_t *upstream, int timeout_ms,
+ const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len);
+
+static void record_upstream_attempt(upstream_t *upstream, ssize_t response_len,
+ int latency_ms, const uint8_t *response) {
+ uint64_t now;
+ if (upstream == NULL) {
+ return;
+ }
+ now = now_seconds();
+ upstream->requests++;
+ upstream->last_used = now;
+ upstream->last_latency_ms = latency_ms;
+ upstream->last_rcode = response_len > 0 ? response_rcode(response, (size_t) response_len) : -1;
+ if (latency_ms > 0) {
+ upstream->total_latency_ms += (uint64_t) latency_ms;
+ if ((uint64_t) latency_ms > upstream->max_latency_ms) {
+ upstream->max_latency_ms = (uint64_t) latency_ms;
+ }
+ }
+ if (response_len > 0) {
+ upstream->successes++;
+ upstream->consecutive_failures = 0;
+ upstream->backoff_until = 0;
+ } else {
+ uint64_t backoff;
+ upstream->failures++;
+ if (upstream->consecutive_failures < UINT64_MAX) {
+ upstream->consecutive_failures++;
+ }
+ backoff = upstream_backoff_seconds(upstream->consecutive_failures);
+ if (backoff > 0) {
+ upstream->backoff_until = now + backoff;
+ }
+ }
+}
+
+static void record_upstream_auth_failure(upstream_t *upstream, const uint8_t *response,
+ size_t response_len) {
+ uint64_t now;
+ uint64_t backoff;
+ if (upstream == NULL) {
+ return;
+ }
+ now = now_seconds();
+ upstream->last_used = now;
+ upstream->last_rcode = response_len > 0 ? response_rcode(response, response_len) : -1;
+ upstream->failures++;
+ if (upstream->consecutive_failures < UINT64_MAX) {
+ upstream->consecutive_failures++;
+ }
+ backoff = upstream_backoff_seconds(upstream->consecutive_failures);
+ if (backoff > 0) {
+ upstream->backoff_until = now + backoff;
+ }
+ g_stats.dnssec_auth_failures++;
+}
+
+static ssize_t attempt_udp_upstream(upstream_t *upstream, int timeout_ms,
+ const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len) {
+ struct timeval start;
+ struct timeval end;
+ ssize_t got;
+ gettimeofday(&start, NULL);
+ got = forward_udp_to_upstream(upstream, timeout_ms, query, query_len,
+ response, response_len);
+ gettimeofday(&end, NULL);
+ record_upstream_attempt(upstream, got, elapsed_ms(&start, &end), response);
+ return got;
+}
+
+static ssize_t attempt_tcp_upstream(upstream_t *upstream, int timeout_ms,
+ const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len) {
+ struct timeval start;
+ struct timeval end;
+ ssize_t got;
+ gettimeofday(&start, NULL);
+ got = forward_tcp_to_upstream(upstream, timeout_ms, query, query_len,
+ response, response_len);
+ gettimeofday(&end, NULL);
+ record_upstream_attempt(upstream, got, elapsed_ms(&start, &end), response);
+ return got;
+}
+
+static ssize_t forward_udp(config_t *cfg, const char *domain, const uint8_t *query,
+ size_t query_len, uint8_t *response, size_t response_len,
+ const char **route, bool require_dnssec_auth) {
+ int i;
+ int pass;
+ int count = 0;
+ uint8_t truncated_response[MAX_PACKET];
+ ssize_t truncated_len = -1;
+ bool split_route = false;
+ uint64_t now = now_seconds();
+ upstream_t *upstreams = select_upstreams(cfg, domain, &count, route);
+ split_route = route != NULL && *route != NULL && strcmp(*route, "split_upstream") == 0;
+ for (pass = 0; pass < 2; pass++) {
+ bool skipped_backoff = false;
+ for (i = 0; i < count; i++) {
+ bool udp_only;
+ ssize_t got;
+ if (!should_try_upstream_on_pass(&upstreams[i], count, pass, now, &skipped_backoff)) {
+ continue;
+ }
+ udp_only = upstreams[i].protocol == UPSTREAM_PROTO_UDP;
+ if (upstreams[i].protocol == UPSTREAM_PROTO_TCP) {
+ got = attempt_tcp_upstream(&upstreams[i], cfg->timeout_ms,
+ query, query_len, response, response_len);
+ if (got > 0) {
+ if (require_dnssec_auth
+ && !dns_response_authenticated(response, (size_t) got)) {
+ record_upstream_auth_failure(&upstreams[i], response, (size_t) got);
+ continue;
+ }
+ if (route != NULL) {
+ *route = split_route ? "split_upstream_tcp" : "upstream_tcp";
+ }
+ return got;
+ }
+ continue;
+ }
+ got = attempt_udp_upstream(&upstreams[i], cfg->timeout_ms,
+ query, query_len, response, response_len);
+ if (got > 0) {
+ if (dns_response_truncated(response, (size_t) got)) {
+ ssize_t tcp_got;
+ g_stats.upstream_truncated_responses++;
+ if (udp_only) {
+ if (route != NULL) {
+ *route = split_route ? "split_upstream_udp" : "upstream_udp";
+ }
+ return got;
+ }
+ if (truncated_len < 0 && (size_t) got <= sizeof(truncated_response)) {
+ memcpy(truncated_response, response, (size_t) got);
+ truncated_len = got;
+ }
+ tcp_got = attempt_tcp_upstream(&upstreams[i], cfg->timeout_ms,
+ query, query_len, response, response_len);
+ if (tcp_got > 0) {
+ if (require_dnssec_auth
+ && !dns_response_authenticated(response, (size_t) tcp_got)) {
+ record_upstream_auth_failure(&upstreams[i], response, (size_t) tcp_got);
+ continue;
+ }
+ g_stats.upstream_tcp_fallbacks++;
+ if (route != NULL) {
+ *route = split_route ? "split_upstream_tcp_fallback" : "upstream_tcp_fallback";
+ }
+ return tcp_got;
+ }
+ continue;
+ }
+ if (require_dnssec_auth
+ && !dns_response_authenticated(response, (size_t) got)) {
+ record_upstream_auth_failure(&upstreams[i], response, (size_t) got);
+ continue;
+ }
+ if (udp_only && route != NULL) {
+ *route = split_route ? "split_upstream_udp" : "upstream_udp";
+ }
+ return got;
+ }
+ }
+ if (!skipped_backoff) {
+ break;
+ }
+ }
+ if (truncated_len > 0) {
+ memcpy(response, truncated_response, (size_t) truncated_len);
+ return truncated_len;
+ }
+ return -1;
+}
+
+static ssize_t forward_udp_to_upstream(const upstream_t *upstream, int timeout_ms,
+ const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len) {
+ int fd;
+ ssize_t got;
+ bool close_fd = false;
+ if (upstream == NULL) {
+ return -1;
+ }
+ fd = upstream->udp_fd;
+ if (fd < 0) {
+ fd = connect_upstream(upstream, SOCK_DGRAM, timeout_ms);
+ close_fd = true;
+ } else {
+ set_socket_timeout(fd, timeout_ms);
+ g_stats.upstream_udp_socket_reuses++;
+ }
+ if (fd < 0) {
+ return -1;
+ }
+ if (send(fd, query, query_len, 0) < 0) {
+ if (close_fd) {
+ close(fd);
+ }
+ return -1;
+ }
+ do {
+ got = recv(fd, response, response_len, 0);
+ if (got < 2 || query_len < 2
+ || (response[0] == query[0] && response[1] == query[1])) {
+ break;
+ }
+ g_stats.upstream_udp_stale_replies++;
+ } while (got > 0);
+ if (got >= 2 && query_len >= 2 && (response[0] != query[0] || response[1] != query[1])) {
+ got = -1;
+ }
+ if (close_fd) {
+ close(fd);
+ }
+ return got;
+}
+
+static ssize_t forward_udp_probe(const config_t *cfg, const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len, int *upstream_index,
+ bool require_dnssec_auth) {
+ int i;
+ int pass;
+ int timeout_ms = cfg->timeout_ms < 1000 ? cfg->timeout_ms : 1000;
+ uint64_t now = now_seconds();
+ if (timeout_ms < 250) {
+ timeout_ms = 250;
+ }
+ if (upstream_index != NULL) {
+ *upstream_index = -1;
+ }
+ for (pass = 0; pass < 2; pass++) {
+ bool skipped_backoff = false;
+ for (i = 0; i < cfg->upstream_count; i++) {
+ ssize_t got;
+ if (!should_try_upstream_on_pass(&cfg->upstreams[i], cfg->upstream_count,
+ pass, now, &skipped_backoff)) {
+ continue;
+ }
+ got = cfg->upstreams[i].protocol == UPSTREAM_PROTO_TCP
+ ? forward_tcp_to_upstream(&cfg->upstreams[i], timeout_ms,
+ query, query_len, response, response_len)
+ : forward_udp_to_upstream(&cfg->upstreams[i], timeout_ms,
+ query, query_len, response, response_len);
+ if (got > 0) {
+ if (require_dnssec_auth
+ && !dns_response_authenticated(response, (size_t) got)) {
+ g_stats.dnssec_auth_failures++;
+ continue;
+ }
+ if (upstream_index != NULL) {
+ *upstream_index = i;
+ }
+ return got;
+ }
+ }
+ if (!skipped_backoff) {
+ break;
+ }
+ }
+ return -1;
+}
+
+static ssize_t forward_udp_probe_one(const upstream_t *upstream, int timeout_ms,
+ const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len,
+ bool require_dnssec_auth) {
+ ssize_t got;
+ if (upstream->protocol == UPSTREAM_PROTO_TCP) {
+ got = forward_tcp_to_upstream(upstream, timeout_ms, query, query_len,
+ response, response_len);
+ } else {
+ got = forward_udp_to_upstream(upstream, timeout_ms, query, query_len,
+ response, response_len);
+ }
+ if (got > 0 && require_dnssec_auth
+ && !dns_response_authenticated(response, (size_t) got)) {
+ g_stats.dnssec_auth_failures++;
+ return -1;
+ }
+ return got;
+}
+
+static bool dns_response_truncated(const uint8_t *response, size_t response_len) {
+ return response_len >= 4 && (response[2] & 0x02u) != 0;
+}
+
+static ssize_t read_full(int fd, uint8_t *buf, size_t len) {
+ size_t got = 0;
+ while (got < len) {
+ ssize_t n = recv(fd, buf + got, len - got, 0);
+ if (n <= 0) {
+ return -1;
+ }
+ got += (size_t) n;
+ }
+ return (ssize_t) got;
+}
+
+static ssize_t forward_tcp_to_upstream(const upstream_t *upstream, int timeout_ms,
+ const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t response_len) {
+ uint8_t lenbuf[2];
+ uint8_t rlenbuf[2];
+ uint16_t rlen;
+ int fd;
+ if (query_len > 65535) {
+ return -1;
+ }
+ lenbuf[0] = (uint8_t) ((query_len >> 8) & 0xffu);
+ lenbuf[1] = (uint8_t) (query_len & 0xffu);
+ fd = connect_upstream(upstream, SOCK_STREAM, timeout_ms);
+ if (fd < 0) {
+ return -1;
+ }
+ if (send(fd, lenbuf, 2, 0) != 2 || send(fd, query, query_len, 0) != (ssize_t) query_len) {
+ close(fd);
+ return -1;
+ }
+ if (read_full(fd, rlenbuf, 2) != 2) {
+ close(fd);
+ return -1;
+ }
+ rlen = (uint16_t) ((rlenbuf[0] << 8) | rlenbuf[1]);
+ if (rlen == 0 || rlen > response_len) {
+ close(fd);
+ return -1;
+ }
+ if (read_full(fd, response, rlen) != rlen) {
+ close(fd);
+ return -1;
+ }
+ close(fd);
+ return rlen;
+}
+
+static ssize_t forward_tcp(config_t *cfg, const char *domain, const uint8_t *query,
+ size_t query_len, uint8_t *response, size_t response_len,
+ const char **route, bool require_dnssec_auth) {
+ int i;
+ int pass;
+ int count = 0;
+ bool split_route = false;
+ uint64_t now = now_seconds();
+ upstream_t *upstreams;
+ if (query_len > 65535) {
+ return -1;
+ }
+ upstreams = select_upstreams(cfg, domain, &count, route);
+ split_route = route != NULL && *route != NULL && strcmp(*route, "split_upstream") == 0;
+ for (pass = 0; pass < 2; pass++) {
+ bool skipped_backoff = false;
+ for (i = 0; i < count; i++) {
+ ssize_t got;
+ if (!should_try_upstream_on_pass(&upstreams[i], count, pass, now, &skipped_backoff)) {
+ continue;
+ }
+ got = upstreams[i].protocol == UPSTREAM_PROTO_UDP
+ ? attempt_udp_upstream(&upstreams[i], cfg->timeout_ms,
+ query, query_len, response, response_len)
+ : attempt_tcp_upstream(&upstreams[i], cfg->timeout_ms,
+ query, query_len, response, response_len);
+ if (got > 0) {
+ if (require_dnssec_auth
+ && !dns_response_authenticated(response, (size_t) got)) {
+ record_upstream_auth_failure(&upstreams[i], response, (size_t) got);
+ continue;
+ }
+ if (route != NULL) {
+ if (upstreams[i].protocol == UPSTREAM_PROTO_UDP) {
+ *route = split_route ? "split_upstream_udp" : "upstream_udp";
+ } else if (upstreams[i].protocol == UPSTREAM_PROTO_TCP) {
+ *route = split_route ? "split_upstream_tcp" : "upstream_tcp";
+ }
+ }
+ return got;
+ }
+ }
+ if (!skipped_backoff) {
+ break;
+ }
+ }
+ return -1;
+}
+
+static void default_config(config_t *cfg) {
+ memset(cfg, 0, sizeof(*cfg));
+ cfg->listen_port = DEFAULT_PORT;
+ cfg->fail_open = 1;
+ cfg->timeout_ms = DEFAULT_TIMEOUT_MS;
+ cfg->cache_size = DEFAULT_CACHE_SIZE;
+ cfg->stale_cache_seconds = DEFAULT_STALE_CACHE_SECONDS;
+ cfg->persist_cache = 0;
+ cfg->query_logging = 1;
+ cfg->persist_query_logs = 1;
+ cfg->dnssec_request = 0;
+ cfg->control_socket_uid = -1;
+ cfg->control_adb_debug = 0;
+ cfg->control_tcp_port = 0;
+ safe_copy(cfg->control_socket, sizeof(cfg->control_socket), "/data/local/tmp/afwall_dnsd.sock");
+ safe_copy(cfg->iptables_path, sizeof(cfg->iptables_path), "iptables");
+ safe_copy(cfg->ip6tables_path, sizeof(cfg->ip6tables_path), "ip6tables");
+ cfg->upstream_count = 1;
+ init_upstream_runtime(&cfg->upstreams[0]);
+ safe_copy(cfg->upstreams[0].host, sizeof(cfg->upstreams[0].host), "1.1.1.1");
+ cfg->upstreams[0].port = 53;
+ cfg->upstreams[0].protocol = UPSTREAM_PROTO_AUTO;
+ compile_upstream_address(&cfg->upstreams[0]);
+}
+
+static bool parse_upstream_value(const char *value, upstream_t *upstream) {
+ char line[256];
+ char *target;
+ char *first_colon;
+ char *last_colon;
+ if (value == NULL || value[0] == '\0' || upstream == NULL) {
+ return false;
+ }
+ memset(upstream, 0, sizeof(*upstream));
+ init_upstream_runtime(upstream);
+ upstream->protocol = UPSTREAM_PROTO_AUTO;
+ upstream->port = 53;
+ safe_copy(line, sizeof(line), value);
+ trim(line);
+ target = line;
+ if (strncmp(target, "udp://", 6) == 0) {
+ upstream->protocol = UPSTREAM_PROTO_UDP;
+ target += 6;
+ } else if (strncmp(target, "tcp://", 6) == 0) {
+ upstream->protocol = UPSTREAM_PROTO_TCP;
+ target += 6;
+ } else if (strstr(target, "://") != NULL) {
+ return false;
+ }
+ trim(target);
+ if (target[0] == '[') {
+ char *end = strchr(target, ']');
+ if (end == NULL || end == target + 1) {
+ return false;
+ }
+ *end = '\0';
+ safe_copy(upstream->host, sizeof(upstream->host), target + 1);
+ if (end[1] == ':' && end[2] != '\0') {
+ upstream->port = atoi(end + 2);
+ }
+ } else {
+ first_colon = strchr(target, ':');
+ last_colon = strrchr(target, ':');
+ if (first_colon != NULL && first_colon == last_colon && first_colon[1] != '\0') {
+ *first_colon = '\0';
+ safe_copy(upstream->host, sizeof(upstream->host), target);
+ upstream->port = atoi(first_colon + 1);
+ } else {
+ safe_copy(upstream->host, sizeof(upstream->host), target);
+ }
+ }
+ trim(upstream->host);
+ if (upstream->port <= 0 || upstream->port > 65535) {
+ upstream->port = 53;
+ }
+ if (upstream->host[0] == '\0') {
+ return false;
+ }
+ compile_upstream_address(upstream);
+ return true;
+}
+
+static bool parse_upstream(config_t *cfg, const char *value) {
+ if (cfg->upstream_count >= MAX_UPSTREAMS) {
+ return false;
+ }
+ if (!parse_upstream_value(value, &cfg->upstreams[cfg->upstream_count])) {
+ return false;
+ }
+ cfg->upstream_count++;
+ return true;
+}
+
+static bool parse_split_upstream(config_t *cfg, const char *value) {
+ char line[512];
+ char *sep;
+ char *suffix;
+ char *upstream;
+ if (cfg->split_upstream_count >= MAX_SPLIT_UPSTREAMS || value == NULL || value[0] == '\0') {
+ return false;
+ }
+ safe_copy(line, sizeof(line), value);
+ sep = strchr(line, '|');
+ if (sep == NULL) {
+ sep = strchr(line, '=');
+ }
+ if (sep == NULL) {
+ sep = strchr(line, ' ');
+ }
+ if (sep == NULL) {
+ return false;
+ }
+ *sep = '\0';
+ suffix = line;
+ upstream = sep + 1;
+ trim(suffix);
+ trim(upstream);
+ lower_ascii(suffix);
+ if (suffix[0] == '*' && suffix[1] == '.') {
+ memmove(suffix, suffix + 2, strlen(suffix + 2) + 1);
+ }
+ while (suffix[0] == '.') {
+ memmove(suffix, suffix + 1, strlen(suffix));
+ }
+ if (!valid_domain_rule(suffix)) {
+ return false;
+ }
+ safe_copy(cfg->split_upstreams[cfg->split_upstream_count].suffix,
+ sizeof(cfg->split_upstreams[cfg->split_upstream_count].suffix), suffix);
+ if (!parse_upstream_value(upstream, &cfg->split_upstreams[cfg->split_upstream_count].upstream)) {
+ return false;
+ }
+ cfg->split_upstream_count++;
+ return true;
+}
+
+static safe_search_target_t *safe_search_target_by_name(config_t *cfg, const char *provider) {
+ if (cfg == NULL || provider == NULL) {
+ return NULL;
+ }
+ if (strcmp(provider, "google") == 0) {
+ return &cfg->safe_google;
+ }
+ if (strcmp(provider, "youtube") == 0) {
+ return &cfg->safe_youtube;
+ }
+ if (strcmp(provider, "bing") == 0) {
+ return &cfg->safe_bing;
+ }
+ if (strcmp(provider, "duckduckgo") == 0) {
+ return &cfg->safe_duckduckgo;
+ }
+ return NULL;
+}
+
+static void parse_safe_search_address(config_t *cfg, const char *value) {
+ char line[256];
+ char *sep;
+ char *provider;
+ char *address;
+ safe_search_target_t *target;
+ struct in_addr addr4;
+ struct in6_addr addr6;
+ if (cfg == NULL || value == NULL || value[0] == '\0') {
+ return;
+ }
+ safe_copy(line, sizeof(line), value);
+ sep = strchr(line, '|');
+ if (sep == NULL) {
+ sep = strchr(line, '=');
+ }
+ if (sep == NULL) {
+ return;
+ }
+ *sep = '\0';
+ provider = line;
+ address = sep + 1;
+ trim(provider);
+ trim(address);
+ lower_ascii(provider);
+ target = safe_search_target_by_name(cfg, provider);
+ if (target == NULL || address[0] == '\0') {
+ return;
+ }
+ if (inet_pton(AF_INET, address, &addr4) == 1) {
+ safe_copy(target->ipv4, sizeof(target->ipv4), address);
+ } else if (inet_pton(AF_INET6, address, &addr6) == 1) {
+ safe_copy(target->ipv6, sizeof(target->ipv6), address);
+ }
+}
+
+static bool load_string_rule_file(string_rule_list_t *rules, const char *path);
+static bool load_regex_rule_file(regex_rule_list_t *rules, const char *path);
+
+static bool load_config(const char *path, config_t *new_cfg) {
+ FILE *fp;
+ char line[1024];
+ bool upstream_configured = false;
+ default_config(new_cfg);
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return false;
+ }
+ new_cfg->upstream_count = 0;
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char *eq;
+ char *key = line;
+ char *value;
+ trim(line);
+ if (line[0] == '\0' || line[0] == '#') {
+ continue;
+ }
+ eq = strchr(line, '=');
+ if (eq == NULL) {
+ continue;
+ }
+ *eq = '\0';
+ value = eq + 1;
+ trim(key);
+ trim(value);
+ if (strcmp(key, "port") == 0) {
+ int port = atoi(value);
+ if (port > 0 && port <= 65535) {
+ new_cfg->listen_port = port;
+ }
+ } else if (strcmp(key, "control_socket") == 0) {
+ safe_copy(new_cfg->control_socket, sizeof(new_cfg->control_socket), value);
+ } else if (strcmp(key, "control_socket_uid") == 0) {
+ int uid = atoi(value);
+ new_cfg->control_socket_uid = uid >= 0 ? uid : -1;
+ } else if (strcmp(key, "control_token") == 0) {
+ safe_copy(new_cfg->control_token, sizeof(new_cfg->control_token), value);
+ } else if (strcmp(key, "control_adb_debug") == 0) {
+ new_cfg->control_adb_debug = atoi(value) != 0;
+ } else if (strcmp(key, "control_tcp_port") == 0) {
+ int port = atoi(value);
+ new_cfg->control_tcp_port = port > 0 && port <= 65535 ? port : 0;
+ } else if (strcmp(key, "event_log_file") == 0) {
+ safe_copy(new_cfg->event_log_file, sizeof(new_cfg->event_log_file), value);
+ } else if (strcmp(key, "log_file") == 0) {
+ safe_copy(new_cfg->log_file, sizeof(new_cfg->log_file), value);
+ } else if (strcmp(key, "cache_file") == 0) {
+ safe_copy(new_cfg->cache_file, sizeof(new_cfg->cache_file), value);
+ } else if (strcmp(key, "pid_file") == 0) {
+ safe_copy(new_cfg->pid_file, sizeof(new_cfg->pid_file), value);
+ } else if (strcmp(key, "heartbeat_file") == 0) {
+ safe_copy(new_cfg->heartbeat_file, sizeof(new_cfg->heartbeat_file), value);
+ } else if (strcmp(key, "mark_status_file") == 0) {
+ safe_copy(new_cfg->mark_status_file, sizeof(new_cfg->mark_status_file), value);
+ } else if (strcmp(key, "iptables_path") == 0) {
+ safe_copy(new_cfg->iptables_path, sizeof(new_cfg->iptables_path), value);
+ } else if (strcmp(key, "ip6tables_path") == 0) {
+ safe_copy(new_cfg->ip6tables_path, sizeof(new_cfg->ip6tables_path), value);
+ } else if (strcmp(key, "fail_open") == 0) {
+ new_cfg->fail_open = atoi(value) != 0;
+ } else if (strcmp(key, "strict_mode") == 0) {
+ new_cfg->strict_mode = atoi(value) != 0;
+ } else if (strcmp(key, "timeout_ms") == 0) {
+ int timeout = atoi(value);
+ if (timeout >= 250 && timeout <= 10000) {
+ new_cfg->timeout_ms = timeout;
+ }
+ } else if (strcmp(key, "cache_size") == 0) {
+ int cache_size = atoi(value);
+ if (cache_size >= 0 && cache_size <= MAX_CACHE_SIZE) {
+ new_cfg->cache_size = cache_size;
+ }
+ } else if (strcmp(key, "stale_cache_seconds") == 0) {
+ int stale_seconds = atoi(value);
+ if (stale_seconds >= 0 && stale_seconds <= MAX_STALE_CACHE_SECONDS) {
+ new_cfg->stale_cache_seconds = stale_seconds;
+ }
+ } else if (strcmp(key, "persist_cache") == 0) {
+ new_cfg->persist_cache = config_bool_value(value);
+ } else if (strcmp(key, "query_logging") == 0) {
+ new_cfg->query_logging = config_bool_value(value);
+ } else if (strcmp(key, "persist_query_logs") == 0) {
+ new_cfg->persist_query_logs = config_bool_value(value);
+ } else if (strcmp(key, "safe_search") == 0) {
+ new_cfg->safe_search = config_bool_value(value);
+ } else if (strcmp(key, "dnssec_request") == 0) {
+ new_cfg->dnssec_request = config_bool_value(value);
+ } else if (strcmp(key, "dnssec_auth_required") == 0) {
+ new_cfg->dnssec_auth_required = config_bool_value(value);
+ } else if (strcmp(key, "safe_search_address") == 0) {
+ parse_safe_search_address(new_cfg, value);
+ } else if (strcmp(key, "upstream") == 0) {
+ /*
+ * Upstream entries define resolver behavior. Reject malformed or
+ * over-limit entries during reload so the daemon keeps the last
+ * working generation instead of silently falling back to defaults.
+ */
+ upstream_configured = true;
+ if (!parse_upstream(new_cfg, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "split_upstream") == 0) {
+ /*
+ * Split DNS routes are policy, not hints. Dropping a bad route
+ * during reload would send matching domains through the wrong
+ * resolver until the next successful configuration write.
+ */
+ if (!parse_split_upstream(new_cfg, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "allow_exact") == 0) {
+ if (!add_string_rule(&new_cfg->exact_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "allow_exact_file") == 0) {
+ if (!load_string_rule_file(&new_cfg->exact_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "allow_suffix") == 0) {
+ if (!add_string_rule(&new_cfg->suffix_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "allow_suffix_file") == 0) {
+ if (!load_string_rule_file(&new_cfg->suffix_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "block_exact") == 0) {
+ if (!add_string_rule(&new_cfg->exact_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "block_exact_file") == 0) {
+ if (!load_string_rule_file(&new_cfg->exact_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "block_suffix") == 0) {
+ if (!add_string_rule(&new_cfg->suffix_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "block_suffix_file") == 0) {
+ if (!load_string_rule_file(&new_cfg->suffix_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "app_allow_exact") == 0) {
+ if (!add_app_domain_rule(&new_cfg->app_exact_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "app_block_exact") == 0) {
+ if (!add_app_domain_rule(&new_cfg->app_exact_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "app_allow_suffix") == 0) {
+ if (!add_app_domain_rule(&new_cfg->app_suffix_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "app_block_suffix") == 0) {
+ if (!add_app_domain_rule(&new_cfg->app_suffix_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "network_allow") == 0) {
+ if (!add_network_rule(new_cfg->network_allow,
+ &new_cfg->network_allow_count, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "network_block") == 0) {
+ if (!add_network_rule(new_cfg->network_block,
+ &new_cfg->network_block_count, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "allow_regex") == 0) {
+ if (!add_regex_rule(&new_cfg->regex_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "allow_regex_file") == 0) {
+ if (!load_regex_rule_file(&new_cfg->regex_allow, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "block_regex") == 0) {
+ if (!add_regex_rule(&new_cfg->regex_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "block_regex_file") == 0) {
+ if (!load_regex_rule_file(&new_cfg->regex_block, value)) {
+ fclose(fp);
+ return false;
+ }
+ } else if (strcmp(key, "temp_allow") == 0) {
+ add_temp_rule(new_cfg->temp_allow, &new_cfg->temp_allow_count, value);
+ } else if (strcmp(key, "temp_block") == 0) {
+ add_temp_rule(new_cfg->temp_block, &new_cfg->temp_block_count, value);
+ }
+ }
+ fclose(fp);
+ /*
+ * The control socket can reload policy and mutate temporary DNS rules.
+ * Treat app-owned socket permissions and the per-install token as required
+ * config, not best-effort hardening, so stale or hand-edited configs cannot
+ * expose daemon control to other local apps.
+ */
+ if (new_cfg->control_socket[0] == '\0'
+ || new_cfg->control_socket_uid < 0
+ || !valid_control_token_value(new_cfg->control_token)) {
+ write_event_log("error",
+ "configuration rejected: control socket auth invalid socket=%d uid=%d token=%d",
+ new_cfg->control_socket[0] != '\0' ? 1 : 0,
+ new_cfg->control_socket_uid >= 0 ? 1 : 0,
+ valid_control_token_value(new_cfg->control_token) ? 1 : 0);
+ return false;
+ }
+ if (new_cfg->upstream_count == 0) {
+ if (upstream_configured) {
+ return false;
+ }
+ safe_copy(new_cfg->upstreams[0].host, sizeof(new_cfg->upstreams[0].host), "1.1.1.1");
+ new_cfg->upstreams[0].port = 53;
+ new_cfg->upstreams[0].protocol = UPSTREAM_PROTO_AUTO;
+ compile_upstream_address(&new_cfg->upstreams[0]);
+ new_cfg->upstream_count = 1;
+ }
+ if (!build_exact_index(&new_cfg->exact_allow, &new_cfg->exact_allow_index,
+ &new_cfg->exact_allow_index_size)
+ || !build_exact_index(&new_cfg->exact_block, &new_cfg->exact_block_index,
+ &new_cfg->exact_block_index_size)
+ || !build_app_exact_index(&new_cfg->app_exact_allow,
+ &new_cfg->app_exact_allow_index, &new_cfg->app_exact_allow_index_size)
+ || !build_app_exact_index(&new_cfg->app_exact_block,
+ &new_cfg->app_exact_block_index, &new_cfg->app_exact_block_index_size)
+ || !build_suffix_trie(&new_cfg->suffix_allow, &new_cfg->suffix_allow_trie)
+ || !build_suffix_trie(&new_cfg->suffix_block, &new_cfg->suffix_block_trie)
+ || !build_app_suffix_trie(&new_cfg->app_suffix_allow,
+ &new_cfg->app_suffix_allow_trie)
+ || !build_app_suffix_trie(&new_cfg->app_suffix_block,
+ &new_cfg->app_suffix_block_trie)) {
+ return false;
+ }
+ prepare_udp_upstream_sockets(new_cfg);
+ new_cfg->resolver_scope_hash = compute_resolver_scope_hash(new_cfg);
+ new_cfg->generation = g_cfg.generation + 1;
+ return true;
+}
+
+static void prime_event_log_from_config(const char *path) {
+ FILE *fp;
+ char line[1024];
+ if (path == NULL || path[0] == '\0') {
+ return;
+ }
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return;
+ }
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char *eq;
+ char *key = line;
+ char *value;
+ trim(line);
+ if (line[0] == '\0' || line[0] == '#') {
+ continue;
+ }
+ eq = strchr(line, '=');
+ if (eq == NULL) {
+ continue;
+ }
+ *eq = '\0';
+ value = eq + 1;
+ trim(key);
+ trim(value);
+ if (strcmp(key, "event_log_file") == 0) {
+ set_event_log_output(value);
+ break;
+ }
+ }
+ fclose(fp);
+}
+
+static bool load_string_rule_file(string_rule_list_t *rules, const char *path) {
+ FILE *fp;
+ char line[1024];
+ if (path == NULL || path[0] == '\0') {
+ return true;
+ }
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ /*
+ * Referenced rule files are part of the compiled rule database. If one
+ * disappears between staging and reload, reject the pending generation
+ * instead of silently activating a weaker empty rule set.
+ */
+ return false;
+ }
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ trim(line);
+ if (line[0] == '\0' || line[0] == '#') {
+ continue;
+ }
+ if (!add_string_rule(rules, line)) {
+ fclose(fp);
+ return false;
+ }
+ }
+ fclose(fp);
+ return true;
+}
+
+static bool load_regex_rule_file(regex_rule_list_t *rules, const char *path) {
+ FILE *fp;
+ char line[1024];
+ if (path == NULL || path[0] == '\0') {
+ return true;
+ }
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ /*
+ * Regex file entries are explicit config dependencies and should obey
+ * the same atomic reload contract as exact and suffix rule files.
+ */
+ return false;
+ }
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ trim(line);
+ if (line[0] == '\0' || line[0] == '#') {
+ continue;
+ }
+ if (!add_regex_rule(rules, line)) {
+ fclose(fp);
+ return false;
+ }
+ }
+ fclose(fp);
+ return true;
+}
+
+static bool reload_config(void) {
+ config_t *next = (config_t *) calloc(1, sizeof(config_t));
+ cache_entry_t *next_cache = NULL;
+ bool migrated_cache = false;
+ if (next == NULL) {
+ g_stats.reload_failures++;
+ write_event_log("error", "configuration reload allocation failed failures=%llu",
+ (unsigned long long) g_stats.reload_failures);
+ return false;
+ }
+ if (!load_config(g_config_path, next)) {
+ free_config_dynamic(next);
+ free(next);
+ g_stats.reload_failures++;
+ write_event_log("error", "configuration reload rejected path=%s failures=%llu",
+ g_config_path, (unsigned long long) g_stats.reload_failures);
+ return false;
+ }
+ if (next->cache_size > 0) {
+ next_cache = (cache_entry_t *) calloc((size_t) next->cache_size, sizeof(cache_entry_t));
+ if (next_cache == NULL) {
+ free_config_dynamic(next);
+ free(next);
+ g_stats.reload_failures++;
+ write_event_log("error", "configuration reload cache allocation failed size=%d failures=%llu",
+ next->cache_size, (unsigned long long) g_stats.reload_failures);
+ return false;
+ }
+ }
+ if (g_cache != NULL && g_cache_capacity > 0) {
+ if (g_cfg.resolver_scope_hash == next->resolver_scope_hash) {
+ migrate_cache_entries(g_cache, g_cache_capacity, next_cache, next->cache_size);
+ migrated_cache = true;
+ } else {
+ g_stats.cache_reload_scope_changes++;
+ g_stats.cache_reload_dropped += (uint64_t) cache_count_entries_in(g_cache,
+ g_cache_capacity, time(NULL));
+ }
+ }
+ if (!migrated_cache && next_cache != NULL && next->cache_size > 0) {
+ load_cache_snapshot(next, next_cache, next->cache_size);
+ }
+ if (next->persist_cache) {
+ write_cache_snapshot(&g_cfg, g_cache, g_cache_capacity);
+ } else {
+ if (g_cfg.cache_file[0] != '\0') {
+ unlink(g_cfg.cache_file);
+ }
+ if (next->cache_file[0] != '\0') {
+ unlink(next->cache_file);
+ }
+ }
+ free_config_dynamic(&g_cfg);
+ g_cfg = *next;
+ free(next);
+ free(g_cache);
+ g_cache = next_cache;
+ g_cache_capacity = g_cfg.cache_size;
+ set_event_log_output(g_cfg.event_log_file);
+ set_log_output(g_cfg.log_file, g_cfg.persist_query_logs != 0);
+ update_socket_mark_status();
+ if (!g_cfg.query_logging) {
+ clear_log_ring();
+ }
+ g_stats.reloads++;
+ write_event_log("info",
+ "configuration active generation=%llu port=%d upstreams=%d split_upstreams=%d query_logging=%d persist_query_logs=%d",
+ (unsigned long long) g_cfg.generation,
+ g_cfg.listen_port,
+ g_cfg.upstream_count,
+ g_cfg.split_upstream_count,
+ g_cfg.query_logging,
+ g_cfg.persist_query_logs);
+ return true;
+}
+
+static void write_validate_response(int client) {
+ config_t *candidate = (config_t *) calloc(1, sizeof(config_t));
+ int candidate_compiled_upstreams;
+ int candidate_reusable_udp_sockets;
+ int active_cache_entries;
+ uint64_t log_ring_entries;
+ uint64_t log_unflushed_entries;
+ g_stats.validations++;
+ if (candidate == NULL) {
+ g_stats.validation_failures++;
+ write_event_log("error", "configuration validation allocation failed failures=%llu",
+ (unsigned long long) g_stats.validation_failures);
+ write_control_response(client,
+ "validate=0\nstatus=allocation_failed\nactive_generation=%llu\n"
+ "validations=%llu\nvalidation_failures=%llu\n",
+ (unsigned long long) g_cfg.generation,
+ (unsigned long long) g_stats.validations,
+ (unsigned long long) g_stats.validation_failures);
+ return;
+ }
+ if (!load_config(g_config_path, candidate)) {
+ free_config_dynamic(candidate);
+ free(candidate);
+ g_stats.validation_failures++;
+ write_event_log("error", "configuration validation rejected active_generation=%llu failures=%llu",
+ (unsigned long long) g_cfg.generation,
+ (unsigned long long) g_stats.validation_failures);
+ write_control_response(client,
+ "validate=0\nstatus=config_rejected\nactive_generation=%llu\n"
+ "validations=%llu\nvalidation_failures=%llu\n",
+ (unsigned long long) g_cfg.generation,
+ (unsigned long long) g_stats.validations,
+ (unsigned long long) g_stats.validation_failures);
+ return;
+ }
+ candidate_compiled_upstreams = compiled_upstream_address_count(candidate);
+ candidate_reusable_udp_sockets = reusable_udp_upstream_socket_count(candidate);
+ active_cache_entries = cache_entry_count();
+ read_log_stats(&log_ring_entries, &log_unflushed_entries);
+ write_event_log("info", "configuration validation accepted candidate_generation=%llu",
+ (unsigned long long) candidate->generation);
+ write_control_response(client,
+ "validate=1\nstatus=ok\nactive_generation=%llu\ncandidate_generation=%llu\n"
+ "validations=%llu\nvalidation_failures=%llu\n"
+ "runtime_ready=%d\nudp_listener=%d\ntcp_listener=%d\ncontrol_listener=%d\n"
+ "udp_listener_v4=%d\nudp_listener_v6=%d\n"
+ "tcp_listener_v4=%d\ntcp_listener_v6=%d\n"
+ "control_socket_configured=%d\ncontrol_socket_uid=%d\ncontrol_auth_configured=%d\n"
+ "control_peer_uid_enforced=%d\ncontrol_adb_debug_enabled=%d\n"
+ "control_tcp_port=%d\ncontrol_tcp_listener=%d\n"
+ "fail_open_control_supported=1\n"
+ "cleanup_iptables_safe=%d\ncleanup_ip6tables_safe=%d\n"
+ "pid_file_configured=%d\nheartbeat_file_configured=%d\n"
+ "mark_status_file_configured=%d\n"
+ "daemon_socket_mark=0x%x\nsocket_mark_supported=%d\n"
+ "socket_mark_errno=%d\nsocket_mark_failures=%llu\n"
+ "upstreams=%d\nsplit_upstreams=%d\n"
+ "compiled_upstream_addresses=%d\nreusable_udp_upstream_sockets=%d\n"
+ "rules_exact_allow=%d\nrules_suffix_allow=%d\n"
+ "rules_exact_block=%d\nrules_suffix_block=%d\n"
+ "rules_app_exact_allow=%d\nrules_app_exact_block=%d\n"
+ "rules_app_suffix_allow=%d\nrules_app_suffix_block=%d\n"
+ "rules_network_allow=%d\nrules_network_block=%d\n"
+ "rules_regex_allow=%d\nrules_regex_block=%d\n"
+ "rules_temp_allow=%d\nrules_temp_block=%d\n"
+ "exact_allow_index_size=%d\nexact_block_index_size=%d\n"
+ "app_exact_allow_index_size=%d\napp_exact_block_index_size=%d\n"
+ "suffix_allow_trie_nodes=%d\nsuffix_block_trie_nodes=%d\n"
+ "app_suffix_allow_trie_nodes=%d\napp_suffix_block_trie_nodes=%d\n"
+ "cache_size=%d\nstale_cache_seconds=%d\npersist_cache=%d\n"
+ "active_cache_capacity=%d\nactive_cache_entries=%d\n"
+ "dnssec_request=%d\ndnssec_auth_required=%d\n"
+ "query_logging=%d\npersist_query_logs=%d\n"
+ "log_writer_thread=%d\nlog_ring_entries=%llu\nlog_unflushed_entries=%llu\n"
+ "cache_file_configured=%d\nresolver_scope_hash=%llu\n",
+ (unsigned long long) g_cfg.generation,
+ (unsigned long long) candidate->generation,
+ (unsigned long long) g_stats.validations,
+ (unsigned long long) g_stats.validation_failures,
+ (g_udp_listener_ready && g_tcp_listener_ready && g_control_listener_ready) ? 1 : 0,
+ g_udp_listener_ready,
+ g_tcp_listener_ready,
+ g_control_listener_ready,
+ g_udp_listener_v4_ready,
+ g_udp_listener_v6_ready,
+ g_tcp_listener_v4_ready,
+ g_tcp_listener_v6_ready,
+ candidate->control_socket[0] != '\0' ? 1 : 0,
+ candidate->control_socket_uid,
+ valid_control_token_value(candidate->control_token) ? 1 : 0,
+ control_peer_uid_enforced(candidate->control_socket_uid),
+ candidate->control_adb_debug,
+ candidate->control_tcp_port,
+ g_control_tcp_listener_ready,
+ cleanup_tool_ready(candidate->iptables_path, "iptables"),
+ cleanup_tool_ready(candidate->ip6tables_path, "ip6tables"),
+ candidate->pid_file[0] != '\0' ? 1 : 0,
+ candidate->heartbeat_file[0] != '\0' ? 1 : 0,
+ candidate->mark_status_file[0] != '\0' ? 1 : 0,
+ AFWALL_DNSD_SOCKET_MARK,
+ g_socket_mark_supported,
+ g_socket_mark_errno,
+ (unsigned long long) g_stats.socket_mark_failures,
+ candidate->upstream_count,
+ candidate->split_upstream_count,
+ candidate_compiled_upstreams,
+ candidate_reusable_udp_sockets,
+ candidate->exact_allow.count,
+ candidate->suffix_allow.count,
+ candidate->exact_block.count,
+ candidate->suffix_block.count,
+ candidate->app_exact_allow.count,
+ candidate->app_exact_block.count,
+ candidate->app_suffix_allow.count,
+ candidate->app_suffix_block.count,
+ candidate->network_allow_count,
+ candidate->network_block_count,
+ candidate->regex_allow.count,
+ candidate->regex_block.count,
+ candidate->temp_allow_count,
+ candidate->temp_block_count,
+ candidate->exact_allow_index_size,
+ candidate->exact_block_index_size,
+ candidate->app_exact_allow_index_size,
+ candidate->app_exact_block_index_size,
+ candidate->suffix_allow_trie.count,
+ candidate->suffix_block_trie.count,
+ candidate->app_suffix_allow_trie.count,
+ candidate->app_suffix_block_trie.count,
+ candidate->cache_size,
+ candidate->stale_cache_seconds,
+ candidate->persist_cache,
+ g_cache_capacity,
+ active_cache_entries,
+ candidate->dnssec_request,
+ candidate->dnssec_auth_required,
+ candidate->query_logging,
+ candidate->persist_query_logs,
+ g_log_thread_started ? 1 : 0,
+ (unsigned long long) log_ring_entries,
+ (unsigned long long) log_unflushed_entries,
+ candidate->cache_file[0] != '\0' ? 1 : 0,
+ (unsigned long long) candidate->resolver_scope_hash);
+ free_config_dynamic(candidate);
+ free(candidate);
+}
+
+static void write_pid_file(void) {
+ FILE *fp;
+ if (g_cfg.pid_file[0] == '\0') {
+ return;
+ }
+ fp = fopen(g_cfg.pid_file, "w");
+ if (fp != NULL) {
+ fprintf(fp, "%ld\n", (long) getpid());
+ fclose(fp);
+ }
+}
+
+static void write_heartbeat_file(void) {
+ FILE *fp;
+ if (g_cfg.heartbeat_file[0] == '\0') {
+ return;
+ }
+ fp = fopen(g_cfg.heartbeat_file, "w");
+ if (fp != NULL) {
+ fprintf(fp, "%llu\n", (unsigned long long) now_seconds());
+ fclose(fp);
+ }
+}
+
+static int create_udp_socket4(int port) {
+ int fd;
+ int on = 1;
+ struct sockaddr_in addr;
+ fd = socket(AF_INET, SOCK_DGRAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+ set_socket_buffers(fd);
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_addr.s_addr = htonl(INADDR_ANY);
+ addr.sin_port = htons((uint16_t) port);
+ if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+static int create_udp_socket6(int port) {
+ int fd;
+ int on = 1;
+ struct sockaddr_in6 addr6;
+ fd = socket(AF_INET6, SOCK_DGRAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &on, sizeof(on));
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+ set_socket_buffers(fd);
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_addr = in6addr_any;
+ addr6.sin6_port = htons((uint16_t) port);
+ if (bind(fd, (struct sockaddr *) &addr6, sizeof(addr6)) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+static int create_tcp_socket4(int port) {
+ int fd;
+ int on = 1;
+ struct sockaddr_in addr;
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+ set_socket_buffers(fd);
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_addr.s_addr = htonl(INADDR_ANY);
+ addr.sin_port = htons((uint16_t) port);
+ if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) != 0 || listen(fd, 16) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+static int create_tcp_socket6(int port) {
+ int fd;
+ int on = 1;
+ struct sockaddr_in6 addr6;
+ fd = socket(AF_INET6, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &on, sizeof(on));
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+ set_socket_buffers(fd);
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_addr = in6addr_any;
+ addr6.sin6_port = htons((uint16_t) port);
+ if (bind(fd, (struct sockaddr *) &addr6, sizeof(addr6)) != 0 || listen(fd, 16) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+static int create_control_socket(const char *path, int owner_uid) {
+ int fd;
+ struct sockaddr_un addr;
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+ fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ unlink(path);
+ memset(&addr, 0, sizeof(addr));
+ addr.sun_family = AF_UNIX;
+ safe_copy(addr.sun_path, sizeof(addr.sun_path), path);
+ if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) != 0 || listen(fd, 4) != 0) {
+ close(fd);
+ return -1;
+ }
+ if (owner_uid >= 0) {
+ if (chown(path, (uid_t) owner_uid, (gid_t) owner_uid) != 0) {
+ write_event_log("warn", "control socket owner update failed path=%s uid=%d errno=%d",
+ path, owner_uid, errno);
+ }
+ }
+ if (chmod(path, 0600) != 0) {
+ write_event_log("warn", "control socket permission update failed path=%s errno=%d",
+ path, errno);
+ }
+ return fd;
+}
+
+static int create_control_tcp_socket(int port) {
+ int fd;
+ int on = 1;
+ struct sockaddr_in addr;
+ if (port <= 0 || port > 65535) {
+ return -1;
+ }
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
+ addr.sin_port = htons((uint16_t) port);
+ if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) != 0 || listen(fd, 4) != 0) {
+ close(fd);
+ return -1;
+ }
+ return fd;
+}
+
+static void refresh_control_tcp_listener(void) {
+ if (g_control_tcp_fd >= 0) {
+ close(g_control_tcp_fd);
+ g_control_tcp_fd = -1;
+ g_control_tcp_listener_ready = 0;
+ }
+ if (!g_cfg.control_adb_debug || g_cfg.control_tcp_port <= 0) {
+ return;
+ }
+ g_control_tcp_fd = create_control_tcp_socket(g_cfg.control_tcp_port);
+ if (g_control_tcp_fd < 0) {
+ write_event_log("warn",
+ "ADB debug TCP control listener unavailable port=%d errno=%d",
+ g_cfg.control_tcp_port, errno);
+ return;
+ }
+ g_control_tcp_listener_ready = 1;
+ write_event_log("info", "ADB debug TCP control listener ready port=%d",
+ g_cfg.control_tcp_port);
+}
+
+static bool control_peer_authorized(int client, const config_t *cfg) {
+#ifdef SO_PEERCRED
+ struct ucred cred;
+ socklen_t cred_len = sizeof(cred);
+ int expected_uid;
+ if (client < 0 || cfg == NULL || cfg->control_socket_uid < 0) {
+ return false;
+ }
+ expected_uid = cfg->control_socket_uid;
+ memset(&cred, 0, sizeof(cred));
+ if (getsockopt(client, SOL_SOCKET, SO_PEERCRED, &cred, &cred_len) != 0) {
+ write_event_log("warn", "control peer credential lookup failed errno=%d", errno);
+ return false;
+ }
+ if ((int) cred.uid == expected_uid) {
+ return true;
+ }
+ if (cfg->control_adb_debug && (int) cred.uid == 0) {
+ write_event_log("warn",
+ "ADB debug control peer accepted uid=%d pid=%d expected_uid=%d",
+ (int) cred.uid, (int) cred.pid, expected_uid);
+ return true;
+ }
+ write_event_log("warn",
+ "control command rejected from uid=%d pid=%d expected_uid=%d",
+ (int) cred.uid, (int) cred.pid, expected_uid);
+ return false;
+#else
+ (void) client;
+ (void) cfg;
+ write_event_log("error", "control peer credential lookup unavailable");
+ return false;
+#endif
+}
+
+static int control_peer_uid_enforced(int expected_uid) {
+#ifdef SO_PEERCRED
+ return expected_uid >= 0 ? 1 : 0;
+#else
+ (void) expected_uid;
+ return 0;
+#endif
+}
+
+static bool shell_command_word_safe(const char *value) {
+ const unsigned char *p = (const unsigned char *) value;
+ if (value == NULL || value[0] == '\0') {
+ return false;
+ }
+ while (*p != '\0') {
+ if (!(isalnum(*p) || *p == '/' || *p == '.' || *p == '_' || *p == '-')) {
+ return false;
+ }
+ p++;
+ }
+ return true;
+}
+
+static bool shell_command_available(const char *tool) {
+ char cmd[512];
+ if (!shell_command_word_safe(tool)) {
+ return false;
+ }
+ if (strchr(tool, '/') != NULL) {
+ return access(tool, X_OK) == 0;
+ }
+ snprintf(cmd, sizeof(cmd), "command -v %s >/dev/null 2>&1", tool);
+ return system(cmd) == 0;
+}
+
+static const char *cleanup_tool_path(const char *configured, const char *fallback) {
+ if (shell_command_available(configured)) {
+ return configured;
+ }
+ return fallback;
+}
+
+static int cleanup_tool_ready(const char *configured, const char *fallback) {
+ const char *tool = cleanup_tool_path(configured, fallback);
+ return shell_command_available(tool) ? 1 : 0;
+}
+
+static int run_cleanup_command(const char *tool, const char *args) {
+ char cmd[768];
+ if (!shell_command_available(tool) || args == NULL || args[0] == '\0') {
+ return 0;
+ }
+ snprintf(cmd, sizeof(cmd), "%s %s >/dev/null 2>&1 || true", tool, args);
+ (void) system(cmd);
+ return 1;
+}
+
+static int cleanup_redirect_family(const char *tool, const char *chain, const char *pre_chain) {
+ char args[256];
+ int commands = 0;
+ snprintf(args, sizeof(args), "-t nat -D OUTPUT -p udp --dport 53 -j %s", chain);
+ commands += run_cleanup_command(tool, args);
+ snprintf(args, sizeof(args), "-t nat -D OUTPUT -p tcp --dport 53 -j %s", chain);
+ commands += run_cleanup_command(tool, args);
+ snprintf(args, sizeof(args), "-t nat -D PREROUTING -p udp --dport 53 -j %s", pre_chain);
+ commands += run_cleanup_command(tool, args);
+ snprintf(args, sizeof(args), "-t nat -D PREROUTING -p tcp --dport 53 -j %s", pre_chain);
+ commands += run_cleanup_command(tool, args);
+ snprintf(args, sizeof(args), "-t nat -F %s", chain);
+ commands += run_cleanup_command(tool, args);
+ snprintf(args, sizeof(args), "-t nat -F %s", pre_chain);
+ commands += run_cleanup_command(tool, args);
+ snprintf(args, sizeof(args), "-t nat -X %s", chain);
+ commands += run_cleanup_command(tool, args);
+ snprintf(args, sizeof(args), "-t nat -X %s", pre_chain);
+ commands += run_cleanup_command(tool, args);
+ return commands;
+}
+
+static int cleanup_dns_redirects_from_daemon(void) {
+ char args[256];
+ const char *iptables = cleanup_tool_path(g_cfg.iptables_path, "iptables");
+ const char *ip6tables = cleanup_tool_path(g_cfg.ip6tables_path, "ip6tables");
+ int commands = 0;
+ snprintf(args, sizeof(args), "-D OUTPUT -m mark --mark 0x%x -j ACCEPT",
+ AFWALL_DNSD_SOCKET_MARK);
+ commands += run_cleanup_command(iptables, args);
+ commands += run_cleanup_command(ip6tables, args);
+ snprintf(args, sizeof(args), "-D OUTPUT -j %s", AFWALL_DNSD_CHAIN_FILTER);
+ commands += run_cleanup_command(iptables, args);
+ commands += run_cleanup_command(ip6tables, args);
+ snprintf(args, sizeof(args), "-F %s", AFWALL_DNSD_CHAIN_FILTER);
+ commands += run_cleanup_command(iptables, args);
+ commands += run_cleanup_command(ip6tables, args);
+ snprintf(args, sizeof(args), "-X %s", AFWALL_DNSD_CHAIN_FILTER);
+ commands += run_cleanup_command(iptables, args);
+ commands += run_cleanup_command(ip6tables, args);
+ commands += cleanup_redirect_family(iptables,
+ AFWALL_DNSD_CHAIN_V4, AFWALL_DNSD_CHAIN_V4_PRE);
+ commands += cleanup_redirect_family(ip6tables,
+ AFWALL_DNSD_CHAIN_V6, AFWALL_DNSD_CHAIN_V6_PRE);
+ snprintf(args, sizeof(args), "delete table ip %s", AFWALL_DNSD_NFT_TABLE_V4);
+ commands += run_cleanup_command("nft", args);
+ snprintf(args, sizeof(args), "delete table ip6 %s", AFWALL_DNSD_NFT_TABLE_V6);
+ commands += run_cleanup_command("nft", args);
+ return commands;
+}
+
+static void finish_dns_query(const char *domain, const char *action, const char *transport,
+ const char *source, int uid, uint16_t qtype,
+ const char *result, const char *rule,
+ const char *upstream, const struct timeval *start) {
+ struct timeval end;
+ int latency_ms;
+ gettimeofday(&end, NULL);
+ latency_ms = elapsed_ms(start, &end);
+ record_query_latency(latency_ms);
+ add_log(domain, action, transport, source, uid, qtype, result, rule, upstream, latency_ms);
+}
+
+static void handle_dns_query(config_t *cfg, const uint8_t *query, size_t query_len,
+ uint8_t *response, size_t *response_len, const char **action_out,
+ int tcp, const char *source, int uid,
+ const struct sockaddr_storage *peer) {
+ char domain[MAX_DOMAIN];
+ uint16_t qtype = 0;
+ const char *reason = "parse";
+ ssize_t forwarded;
+ const char *route = "upstream";
+ struct timeval start;
+ struct timeval upstream_start;
+ struct timeval upstream_end;
+ int upstream_latency_ms;
+ bool cache_negative = false;
+ bool client_dnssec;
+ bool upstream_dnssec;
+ bool require_dnssec_auth;
+ uint8_t dnssec_query[MAX_PACKET];
+ const uint8_t *forward_query = query;
+ size_t forward_query_len = query_len;
+ const char *transport = tcp ? "tcp" : "udp";
+ gettimeofday(&start, NULL);
+ record_query_seen();
+ if (tcp) {
+ g_stats.tcp_queries++;
+ } else {
+ g_stats.udp_queries++;
+ }
+ if (!parse_qname(query, query_len, domain, sizeof(domain), &qtype)) {
+ *response_len = build_block_response(query, query_len, response, MAX_PACKET);
+ *action_out = "invalid";
+ g_stats.invalid_queries++;
+ record_query_blocked();
+ finish_dns_query("unknown", "invalid", transport, source, uid, qtype,
+ "block", "parse", "none", &start);
+ return;
+ }
+ client_dnssec = dns_query_requests_dnssec(query, query_len);
+ upstream_dnssec = client_dnssec || cfg->dnssec_request || cfg->dnssec_auth_required;
+ require_dnssec_auth = cfg->dnssec_auth_required != 0;
+ if (client_dnssec) {
+ g_stats.dnssec_client_queries++;
+ }
+ if (upstream_dnssec) {
+ g_stats.dnssec_queries++;
+ }
+ if (evaluate_domain(cfg, domain, uid, peer, &reason) == DECISION_BLOCK) {
+ *response_len = build_block_response(query, query_len, response, MAX_PACKET);
+ record_query_blocked();
+ *action_out = reason;
+ finish_dns_query(domain, reason, transport, source, uid, qtype,
+ "block", reason, "none", &start);
+ return;
+ }
+ *response_len = build_safe_search_response(cfg, domain, qtype, query, query_len,
+ response, MAX_PACKET);
+ if (*response_len > 0) {
+ record_query_allowed();
+ g_stats.safe_search_rewrites++;
+ *action_out = "safe_search";
+ finish_dns_query(domain, *action_out, transport, source, uid, qtype,
+ "allow", "safe_search", "local", &start);
+ return;
+ }
+ if ((cfg->dnssec_request || cfg->dnssec_auth_required) && !client_dnssec) {
+ size_t prepared_len = 0;
+ bool added_opt = false;
+ bool updated_opt = false;
+ if (prepare_dnssec_query(query, query_len, dnssec_query, sizeof(dnssec_query),
+ &prepared_len, &added_opt, &updated_opt)) {
+ forward_query = dnssec_query;
+ forward_query_len = prepared_len;
+ if (added_opt) {
+ g_stats.dnssec_opt_added++;
+ }
+ if (updated_opt) {
+ g_stats.dnssec_opt_updated++;
+ }
+ } else {
+ g_stats.dnssec_prepare_failures++;
+ upstream_dnssec = false;
+ }
+ }
+ if (cache_lookup(domain, qtype, upstream_dnssec, query, response, response_len,
+ &cache_negative)) {
+ if (require_dnssec_auth && !dns_response_authenticated(response, *response_len)) {
+ g_stats.dnssec_auth_failures++;
+ } else {
+ g_stats.cache_hits++;
+ if (cache_negative) {
+ g_stats.cache_negative_hits++;
+ } else {
+ g_stats.cache_positive_hits++;
+ }
+ record_query_allowed();
+ *action_out = cache_negative ? "cache_negative" : "cache";
+ finish_dns_query(domain, *action_out, transport, source, uid, qtype,
+ "allow", *action_out, "cache", &start);
+ return;
+ }
+ }
+ g_stats.cache_misses++;
+ g_stats.upstream_requests++;
+ gettimeofday(&upstream_start, NULL);
+ forwarded = tcp
+ ? forward_tcp(cfg, domain, forward_query, forward_query_len,
+ response, MAX_PACKET, &route, require_dnssec_auth)
+ : forward_udp(cfg, domain, forward_query, forward_query_len,
+ response, MAX_PACKET, &route, require_dnssec_auth);
+ gettimeofday(&upstream_end, NULL);
+ upstream_latency_ms = elapsed_ms(&upstream_start, &upstream_end);
+ if (upstream_latency_ms > 0) {
+ g_stats.upstream_latency_ms += (uint64_t) upstream_latency_ms;
+ }
+ if (forwarded > 0) {
+ *response_len = (size_t) forwarded;
+ if (!dns_response_truncated(response, *response_len)) {
+ cache_store(domain, qtype, upstream_dnssec, response, *response_len);
+ }
+ record_query_allowed();
+ g_stats.upstream_successes++;
+ *action_out = route;
+ finish_dns_query(domain, route, transport, source, uid, qtype,
+ "allow", "upstream", route, &start);
+ return;
+ }
+ g_stats.upstream_failures++;
+ if (cache_lookup_stale(domain, qtype, upstream_dnssec, query, response, response_len,
+ &cache_negative, cfg->stale_cache_seconds)) {
+ if (require_dnssec_auth && !dns_response_authenticated(response, *response_len)) {
+ g_stats.dnssec_auth_failures++;
+ } else {
+ record_query_allowed();
+ g_stats.cache_stale_hits++;
+ if (cache_negative) {
+ g_stats.cache_stale_negative_hits++;
+ }
+ *action_out = cache_negative ? "cache_stale_negative" : "cache_stale";
+ finish_dns_query(domain, *action_out, transport, source, uid, qtype,
+ "allow", *action_out, "stale_cache", &start);
+ return;
+ }
+ }
+ if (cfg->fail_open) {
+ *response_len = 0;
+ *action_out = "upstream_failed";
+ finish_dns_query(domain, *action_out, transport, source, uid, qtype,
+ "fail_open", "upstream_failed", route, &start);
+ g_stats.fail_open_drops++;
+ } else {
+ *response_len = build_block_response(query, query_len, response, MAX_PACKET);
+ record_query_blocked();
+ *action_out = "fail_closed";
+ g_stats.fail_closed_blocks++;
+ finish_dns_query(domain, *action_out, transport, source, uid, qtype,
+ "block", "fail_closed", route, &start);
+ }
+}
+
+static uint16_t sockaddr_port(const struct sockaddr_storage *addr) {
+ if (addr == NULL) {
+ return 0;
+ }
+ if (addr->ss_family == AF_INET) {
+ return ntohs(((const struct sockaddr_in *) addr)->sin_port);
+ }
+ if (addr->ss_family == AF_INET6) {
+ return ntohs(((const struct sockaddr_in6 *) addr)->sin6_port);
+ }
+ return 0;
+}
+
+static bool lookup_uid_cache(uint16_t port, int family, int tcp, int *uid) {
+ int i;
+ uint64_t now = now_seconds();
+ for (i = 0; i < UID_CACHE_SIZE; i++) {
+ uid_cache_entry_t *entry = &g_uid_cache[i];
+ if (!entry->used || entry->expires_at < now) {
+ continue;
+ }
+ if (entry->port == port && entry->family == family && entry->tcp == tcp) {
+ *uid = entry->uid;
+ g_stats.uid_cache_hits++;
+ return true;
+ }
+ }
+ return false;
+}
+
+static void store_uid_cache(uint16_t port, int family, int tcp, int uid) {
+ uid_cache_entry_t *entry = &g_uid_cache[g_uid_cache_pos % UID_CACHE_SIZE];
+ g_uid_cache_pos = (g_uid_cache_pos + 1) % UID_CACHE_SIZE;
+ entry->port = port;
+ entry->family = family;
+ entry->tcp = tcp;
+ entry->uid = uid;
+ entry->expires_at = now_seconds() + UID_CACHE_TTL;
+ entry->used = true;
+}
+
+static int lookup_uid_in_proc_file(const char *path, uint16_t port) {
+ FILE *fp;
+ char line[512];
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return UID_UNKNOWN;
+ }
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char local[96];
+ char remote[96];
+ char state[8];
+ char *colon;
+ unsigned int local_port;
+ unsigned int uid;
+ if (sscanf(line, " %*d: %95s %95s %7s %*s %*s %*s %u",
+ local, remote, state, &uid) != 4) {
+ continue;
+ }
+ (void) remote;
+ (void) state;
+ colon = strchr(local, ':');
+ if (colon == NULL) {
+ continue;
+ }
+ if (sscanf(colon + 1, "%x", &local_port) != 1) {
+ continue;
+ }
+ if (local_port == port) {
+ fclose(fp);
+ return (int) uid;
+ }
+ }
+ fclose(fp);
+ return UID_UNKNOWN;
+}
+
+static int lookup_query_uid(const struct sockaddr_storage *peer, int tcp) {
+ uint16_t port = sockaddr_port(peer);
+ int family = peer == NULL ? AF_UNSPEC : peer->ss_family;
+ int uid;
+ if (port == 0) {
+ g_stats.uid_lookup_misses++;
+ return UID_UNKNOWN;
+ }
+ if (lookup_uid_cache(port, family, tcp, &uid)) {
+ return uid;
+ }
+ if (tcp) {
+ uid = lookup_uid_in_proc_file("/proc/net/tcp", port);
+ if (uid == UID_UNKNOWN) {
+ uid = lookup_uid_in_proc_file("/proc/net/tcp6", port);
+ }
+ } else {
+ uid = lookup_uid_in_proc_file("/proc/net/udp", port);
+ if (uid == UID_UNKNOWN) {
+ uid = lookup_uid_in_proc_file("/proc/net/udp6", port);
+ }
+ }
+ store_uid_cache(port, family, tcp, uid);
+ if (uid == UID_UNKNOWN) {
+ g_stats.uid_lookup_misses++;
+ } else {
+ g_stats.uid_lookup_successes++;
+ }
+ return uid;
+}
+
+static void format_sockaddr_endpoint(const struct sockaddr_storage *addr, char *out, size_t out_len) {
+ char host[INET6_ADDRSTRLEN];
+ uint16_t port;
+ if (out == NULL || out_len == 0) {
+ return;
+ }
+ safe_copy(out, out_len, "unknown");
+ if (addr == NULL) {
+ return;
+ }
+ if (addr->ss_family == AF_INET) {
+ const struct sockaddr_in *in = (const struct sockaddr_in *) addr;
+ if (inet_ntop(AF_INET, &in->sin_addr, host, sizeof(host)) == NULL) {
+ return;
+ }
+ port = ntohs(in->sin_port);
+ snprintf(out, out_len, "%s:%u", host, (unsigned int) port);
+ } else if (addr->ss_family == AF_INET6) {
+ const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *) addr;
+ if (inet_ntop(AF_INET6, &in6->sin6_addr, host, sizeof(host)) == NULL) {
+ return;
+ }
+ port = ntohs(in6->sin6_port);
+ snprintf(out, out_len, "[%s]:%u", host, (unsigned int) port);
+ }
+}
+
+static bool handle_udp(int fd, int flags) {
+ uint8_t query[MAX_PACKET];
+ uint8_t response[MAX_PACKET];
+ struct sockaddr_storage peer;
+ socklen_t peer_len = sizeof(peer);
+ char source[64];
+ int uid;
+ ssize_t got;
+ size_t response_len = 0;
+ const char *action = "none";
+ struct timeval start;
+ struct timeval end;
+ got = recvfrom(fd, query, sizeof(query), flags, (struct sockaddr *) &peer, &peer_len);
+ if (got <= 0) {
+ return false;
+ }
+ format_sockaddr_endpoint(&peer, source, sizeof(source));
+ uid = lookup_query_uid(&peer, 0);
+ gettimeofday(&start, NULL);
+ handle_dns_query(&g_cfg, query, (size_t) got, response, &response_len, &action,
+ 0, source, uid, &peer);
+ gettimeofday(&end, NULL);
+ (void) action;
+ if (response_len > 0) {
+ sendto(fd, response, response_len, 0, (struct sockaddr *) &peer, peer_len);
+ }
+ if (response_len == 0 && !g_cfg.fail_open) {
+ add_log("unknown", "no_response", "udp", source, uid, 0,
+ "block", "no_response", "none", elapsed_ms(&start, &end));
+ }
+ return true;
+}
+
+static void handle_tcp_client(int client, const char *source, int uid,
+ const struct sockaddr_storage *peer) {
+ uint8_t lenbuf[2];
+ uint8_t query[MAX_PACKET];
+ uint8_t response[MAX_PACKET + 2];
+ uint16_t qlen;
+ size_t response_len = 0;
+ const char *action = "none";
+ set_socket_timeout(client, CLIENT_TIMEOUT_MS);
+ if (read_full(client, lenbuf, 2) != 2) {
+ if (socket_timed_out()) {
+ g_stats.tcp_client_timeouts++;
+ }
+ return;
+ }
+ qlen = (uint16_t) ((lenbuf[0] << 8) | lenbuf[1]);
+ if (qlen == 0 || qlen > MAX_PACKET) {
+ return;
+ }
+ if (read_full(client, query, qlen) != qlen) {
+ if (socket_timed_out()) {
+ g_stats.tcp_client_timeouts++;
+ }
+ return;
+ }
+ handle_dns_query(&g_cfg, query, qlen, response + 2, &response_len, &action,
+ 1, source, uid, peer);
+ (void) action;
+ if (response_len > 0) {
+ response[0] = (uint8_t) ((response_len >> 8) & 0xffu);
+ response[1] = (uint8_t) (response_len & 0xffu);
+ send(client, response, response_len + 2, 0);
+ }
+}
+
+static void handle_tcp(int fd) {
+ struct sockaddr_storage peer;
+ socklen_t peer_len = sizeof(peer);
+ char source[64];
+ int uid;
+ int client = accept(fd, (struct sockaddr *) &peer, &peer_len);
+ if (client >= 0) {
+ format_sockaddr_endpoint(&peer, source, sizeof(source));
+ uid = lookup_query_uid(&peer, 1);
+ handle_tcp_client(client, source, uid, &peer);
+ close(client);
+ }
+}
+
+static void write_control_response(int fd, const char *fmt, ...) {
+ char buf[4096];
+ va_list ap;
+ int n;
+ va_start(ap, fmt);
+ n = vsnprintf(buf, sizeof(buf), fmt, ap);
+ va_end(ap);
+ if (n > 0) {
+ send(fd, buf, (size_t) n, 0);
+ }
+}
+
+static size_t build_health_query(uint8_t *out, size_t out_len) {
+ uint16_t id = (uint16_t) ((now_seconds() ^ (uint64_t) getpid()) & 0xffffu);
+ const uint8_t qname[] = {
+ 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e',
+ 3, 'c', 'o', 'm',
+ 0
+ };
+ size_t pos = 12;
+ if (out_len < pos + sizeof(qname) + 4) {
+ return 0;
+ }
+ memset(out, 0, out_len);
+ out[0] = (uint8_t) ((id >> 8) & 0xffu);
+ out[1] = (uint8_t) (id & 0xffu);
+ out[2] = 0x01;
+ out[5] = 0x01;
+ memcpy(out + pos, qname, sizeof(qname));
+ pos += sizeof(qname);
+ out[pos++] = 0x00;
+ out[pos++] = 0x01;
+ out[pos++] = 0x00;
+ out[pos++] = 0x01;
+ return pos;
+}
+
+static size_t build_probe_query(uint8_t *out, size_t out_len, bool *dnssec_prepared) {
+ size_t query_len = build_health_query(out, out_len);
+ if (dnssec_prepared != NULL) {
+ *dnssec_prepared = false;
+ }
+ if (query_len == 0 || (!g_cfg.dnssec_request && !g_cfg.dnssec_auth_required)) {
+ return query_len;
+ }
+ if (!prepare_dnssec_query(out, query_len, out, out_len, &query_len, NULL, NULL)) {
+ return 0;
+ }
+ if (dnssec_prepared != NULL) {
+ *dnssec_prepared = true;
+ }
+ return query_len;
+}
+
+static int response_rcode(const uint8_t *response, size_t response_len) {
+ if (response_len < 4) {
+ return -1;
+ }
+ return response[3] & 0x0f;
+}
+
+static void write_upstream_runtime_line(int client, const char *key, int index,
+ const char *suffix, const upstream_t *upstream) {
+ uint64_t now = now_seconds();
+ uint64_t backoff_remaining;
+ if (upstream == NULL) {
+ return;
+ }
+ backoff_remaining = upstream->backoff_until > now ? upstream->backoff_until - now : 0;
+ if (suffix != NULL && suffix[0] != '\0') {
+ write_control_response(client,
+ "%s[%d]=suffix=%s upstream=%s:%d protocol=%s requests=%llu successes=%llu "
+ "failures=%llu avg_latency_ms=%llu max_latency_ms=%llu "
+ "last_latency_ms=%d last_rcode=%d last_used=%llu "
+ "consecutive_failures=%llu backoff_until=%llu backoff_remaining=%llu\n",
+ key,
+ index,
+ suffix,
+ upstream->host,
+ upstream->port,
+ upstream_protocol_name(upstream->protocol),
+ (unsigned long long) upstream->requests,
+ (unsigned long long) upstream->successes,
+ (unsigned long long) upstream->failures,
+ (unsigned long long) div_u64(upstream->total_latency_ms, upstream->requests),
+ (unsigned long long) upstream->max_latency_ms,
+ upstream->last_latency_ms,
+ upstream->last_rcode,
+ (unsigned long long) upstream->last_used,
+ (unsigned long long) upstream->consecutive_failures,
+ (unsigned long long) upstream->backoff_until,
+ (unsigned long long) backoff_remaining);
+ return;
+ }
+ write_control_response(client,
+ "%s[%d]=%s:%d protocol=%s requests=%llu successes=%llu failures=%llu "
+ "avg_latency_ms=%llu max_latency_ms=%llu last_latency_ms=%d "
+ "last_rcode=%d last_used=%llu consecutive_failures=%llu "
+ "backoff_until=%llu backoff_remaining=%llu\n",
+ key,
+ index,
+ upstream->host,
+ upstream->port,
+ upstream_protocol_name(upstream->protocol),
+ (unsigned long long) upstream->requests,
+ (unsigned long long) upstream->successes,
+ (unsigned long long) upstream->failures,
+ (unsigned long long) div_u64(upstream->total_latency_ms, upstream->requests),
+ (unsigned long long) upstream->max_latency_ms,
+ upstream->last_latency_ms,
+ upstream->last_rcode,
+ (unsigned long long) upstream->last_used,
+ (unsigned long long) upstream->consecutive_failures,
+ (unsigned long long) upstream->backoff_until,
+ (unsigned long long) backoff_remaining);
+}
+
+static void write_upstream_runtime_stats(int client, const config_t *cfg) {
+ int i;
+ if (cfg == NULL) {
+ return;
+ }
+ for (i = 0; i < cfg->upstream_count; i++) {
+ write_upstream_runtime_line(client, "upstream_runtime", i, NULL, &cfg->upstreams[i]);
+ }
+ for (i = 0; i < cfg->split_upstream_count; i++) {
+ write_upstream_runtime_line(client, "split_upstream_runtime", i,
+ cfg->split_upstreams[i].suffix, &cfg->split_upstreams[i].upstream);
+ }
+}
+
+static void write_health_response(int client) {
+ uint8_t query[MAX_PACKET];
+ uint8_t response[MAX_PACKET];
+ struct timeval start;
+ struct timeval end;
+ size_t query_len;
+ ssize_t response_len;
+ int latency_ms;
+ int upstream_index = -1;
+ int rcode;
+ bool probe_dnssec = false;
+ long memory_rss_kb = read_proc_status_kb("VmRSS");
+ long memory_hwm_kb = read_proc_status_kb("VmHWM");
+ uint64_t cpu_user_ticks;
+ uint64_t cpu_system_ticks;
+ uint64_t cpu_user_ms;
+ uint64_t cpu_system_ms;
+ uint64_t cpu_total_ms;
+ uint64_t log_ring_entries;
+ uint64_t log_unflushed_entries;
+
+ read_proc_cpu_ticks(&cpu_user_ticks, &cpu_system_ticks);
+ cpu_user_ms = cpu_ticks_to_ms(cpu_user_ticks);
+ cpu_system_ms = cpu_ticks_to_ms(cpu_system_ticks);
+ cpu_total_ms = cpu_user_ms + cpu_system_ms;
+ ensure_daily_stats_current();
+ read_log_stats(&log_ring_entries, &log_unflushed_entries);
+ query_len = build_probe_query(query, sizeof(query), &probe_dnssec);
+ gettimeofday(&start, NULL);
+ response_len = query_len == 0
+ ? -1
+ : forward_udp_probe(&g_cfg, query, query_len, response, sizeof(response),
+ &upstream_index, g_cfg.dnssec_auth_required != 0);
+ gettimeofday(&end, NULL);
+ latency_ms = elapsed_ms(&start, &end);
+ rcode = response_len > 0 ? response_rcode(response, (size_t) response_len) : -1;
+
+ write_control_response(client,
+ "health=1\nrunning=1\npid=%ld\nuptime=%llu\nlisten_port=%d\n"
+ "udp_listener=%d\ntcp_listener=%d\ncontrol_listener=%d\n"
+ "udp_listener_v4=%d\nudp_listener_v6=%d\n"
+ "tcp_listener_v4=%d\ntcp_listener_v6=%d\n"
+ "control_socket_uid=%d\ncontrol_peer_uid_enforced=%d\n"
+ "control_adb_debug_enabled=%d\n"
+ "control_tcp_port=%d\ncontrol_tcp_listener=%d\n"
+ "fail_open_control_supported=1\n"
+ "cleanup_iptables_safe=%d\ncleanup_ip6tables_safe=%d\n"
+ "generation=%llu\nupstreams=%d\nsplit_upstreams=%d\nupstream_probe=%s\n"
+ "compiled_upstream_addresses=%d\nreusable_udp_upstream_sockets=%d\n"
+ "upstream_backoff_active=%d\n"
+ "resolver_scope_hash=%llu\n"
+ "daemon_socket_mark=0x%x\nsocket_mark_supported=%d\n"
+ "socket_mark_errno=%d\nsocket_mark_failures=%llu\n"
+ "reloads=%llu\nreload_failures=%llu\nvalidations=%llu\nvalidation_failures=%llu\n"
+ "upstream_probe_ms=%d\nupstream_probe_index=%d\nupstream_probe_rcode=%d\n"
+ "upstream_probe_dnssec=%d\nupstream_probe_dnssec_auth_required=%d\n"
+ "queries=%llu\nblocked=%llu\nallowed=%llu\n"
+ "queries_today=%llu\nblocked_today=%llu\nallowed_today=%llu\n"
+ "stats_day_start=%llu\ncache_size=%d\ncache_entries=%d\n"
+ "socket_buffer_bytes=%d\nudp_drain_limit=%d\n"
+ "udp_drain_batches=%llu\nudp_drain_packets=%llu\n"
+ "cache_positive_entries=%d\ncache_negative_entries=%d\n"
+ "cache_positive_hits=%llu\ncache_negative_hits=%llu\n"
+ "cache_positive_stores=%llu\ncache_negative_stores=%llu\n"
+ "stale_cache_seconds=%d\ncache_stale_hits=%llu\n"
+ "cache_stale_negative_hits=%llu\ncache_stale_expired=%llu\n"
+ "persist_cache=%d\ncache_snapshot_restored=%llu\ncache_snapshot_saved=%llu\n"
+ "cache_snapshot_load_failures=%llu\ncache_snapshot_save_failures=%llu\n"
+ "cache_ttl_rewrites=%llu\ncache_lru_evictions=%llu\n"
+ "cache_reload_preserved=%llu\ncache_reload_dropped=%llu\n"
+ "cache_reload_scope_changes=%llu\n"
+ "upstream_tcp_fallbacks=%llu\nupstream_truncated_responses=%llu\n"
+ "upstream_udp_socket_reuses=%llu\nupstream_udp_stale_replies=%llu\n"
+ "upstream_backoff_skips=%llu\n"
+ "tcp_client_timeouts=%llu\ncontrol_client_timeouts=%llu\n"
+ "uid_lookup_successes=%llu\nuid_lookup_misses=%llu\nuid_cache_hits=%llu\n"
+ "memory_rss_kb=%ld\nmemory_hwm_kb=%ld\ncpu_user_ms=%llu\n"
+ "cpu_system_ms=%llu\ncpu_total_ms=%llu\n"
+ "query_logging=%d\npersist_query_logs=%d\n"
+ "safe_search=%d\nsafe_search_rewrites=%llu\n"
+ "dnssec_request=%d\ndnssec_auth_required=%d\n"
+ "dnssec_queries=%llu\ndnssec_client_queries=%llu\n"
+ "dnssec_opt_added=%llu\ndnssec_opt_updated=%llu\n"
+ "dnssec_prepare_failures=%llu\ndnssec_auth_failures=%llu\n"
+ "log_writer_thread=%d\nlog_ring_entries=%llu\nlog_unflushed_entries=%llu\n"
+ "exact_allow_index_size=%d\nexact_block_index_size=%d\n"
+ "app_exact_allow_index_size=%d\napp_exact_block_index_size=%d\n"
+ "suffix_allow_trie_nodes=%d\nsuffix_block_trie_nodes=%d\n"
+ "app_suffix_allow_trie_nodes=%d\napp_suffix_block_trie_nodes=%d\n"
+ "rules_app_exact_allow=%d\nrules_app_exact_block=%d\n"
+ "rules_app_suffix_allow=%d\nrules_app_suffix_block=%d\n"
+ "rules_network_allow=%d\nrules_network_block=%d\n"
+ "rules_total=%d\n",
+ (long) getpid(),
+ (unsigned long long) (now_seconds() - g_stats.start_time),
+ g_cfg.listen_port,
+ g_udp_listener_ready,
+ g_tcp_listener_ready,
+ g_control_listener_ready,
+ g_udp_listener_v4_ready,
+ g_udp_listener_v6_ready,
+ g_tcp_listener_v4_ready,
+ g_tcp_listener_v6_ready,
+ g_cfg.control_socket_uid,
+ control_peer_uid_enforced(g_cfg.control_socket_uid),
+ g_cfg.control_adb_debug,
+ g_cfg.control_tcp_port,
+ g_control_tcp_listener_ready,
+ cleanup_tool_ready(g_cfg.iptables_path, "iptables"),
+ cleanup_tool_ready(g_cfg.ip6tables_path, "ip6tables"),
+ (unsigned long long) g_cfg.generation,
+ g_cfg.upstream_count,
+ g_cfg.split_upstream_count,
+ response_len > 0 ? "ok" : "fail",
+ compiled_upstream_address_count(&g_cfg),
+ reusable_udp_upstream_socket_count(&g_cfg),
+ upstream_backoff_active_count(&g_cfg),
+ (unsigned long long) g_cfg.resolver_scope_hash,
+ AFWALL_DNSD_SOCKET_MARK,
+ g_socket_mark_supported,
+ g_socket_mark_errno,
+ (unsigned long long) g_stats.socket_mark_failures,
+ (unsigned long long) g_stats.reloads,
+ (unsigned long long) g_stats.reload_failures,
+ (unsigned long long) g_stats.validations,
+ (unsigned long long) g_stats.validation_failures,
+ latency_ms,
+ upstream_index,
+ rcode,
+ probe_dnssec ? 1 : 0,
+ g_cfg.dnssec_auth_required,
+ (unsigned long long) g_stats.queries,
+ (unsigned long long) g_stats.blocked,
+ (unsigned long long) g_stats.allowed,
+ (unsigned long long) g_stats.queries_today,
+ (unsigned long long) g_stats.blocked_today,
+ (unsigned long long) g_stats.allowed_today,
+ (unsigned long long) g_stats.day_start_time,
+ g_cfg.cache_size,
+ cache_entry_count(),
+ DNS_SOCKET_BUFFER_BYTES,
+ UDP_DRAIN_LIMIT,
+ (unsigned long long) g_stats.udp_drain_batches,
+ (unsigned long long) g_stats.udp_drain_packets,
+ cache_entry_count_by_type(false),
+ cache_entry_count_by_type(true),
+ (unsigned long long) g_stats.cache_positive_hits,
+ (unsigned long long) g_stats.cache_negative_hits,
+ (unsigned long long) g_stats.cache_positive_stores,
+ (unsigned long long) g_stats.cache_negative_stores,
+ g_cfg.stale_cache_seconds,
+ (unsigned long long) g_stats.cache_stale_hits,
+ (unsigned long long) g_stats.cache_stale_negative_hits,
+ (unsigned long long) g_stats.cache_stale_expired,
+ g_cfg.persist_cache,
+ (unsigned long long) g_stats.cache_snapshot_restored,
+ (unsigned long long) g_stats.cache_snapshot_saved,
+ (unsigned long long) g_stats.cache_snapshot_load_failures,
+ (unsigned long long) g_stats.cache_snapshot_save_failures,
+ (unsigned long long) g_stats.cache_ttl_rewrites,
+ (unsigned long long) g_stats.cache_lru_evictions,
+ (unsigned long long) g_stats.cache_reload_preserved,
+ (unsigned long long) g_stats.cache_reload_dropped,
+ (unsigned long long) g_stats.cache_reload_scope_changes,
+ (unsigned long long) g_stats.upstream_tcp_fallbacks,
+ (unsigned long long) g_stats.upstream_truncated_responses,
+ (unsigned long long) g_stats.upstream_udp_socket_reuses,
+ (unsigned long long) g_stats.upstream_udp_stale_replies,
+ (unsigned long long) g_stats.upstream_backoff_skips,
+ (unsigned long long) g_stats.tcp_client_timeouts,
+ (unsigned long long) g_stats.control_client_timeouts,
+ (unsigned long long) g_stats.uid_lookup_successes,
+ (unsigned long long) g_stats.uid_lookup_misses,
+ (unsigned long long) g_stats.uid_cache_hits,
+ memory_rss_kb,
+ memory_hwm_kb,
+ (unsigned long long) cpu_user_ms,
+ (unsigned long long) cpu_system_ms,
+ (unsigned long long) cpu_total_ms,
+ g_cfg.query_logging,
+ g_cfg.persist_query_logs,
+ g_cfg.safe_search,
+ (unsigned long long) g_stats.safe_search_rewrites,
+ g_cfg.dnssec_request,
+ g_cfg.dnssec_auth_required,
+ (unsigned long long) g_stats.dnssec_queries,
+ (unsigned long long) g_stats.dnssec_client_queries,
+ (unsigned long long) g_stats.dnssec_opt_added,
+ (unsigned long long) g_stats.dnssec_opt_updated,
+ (unsigned long long) g_stats.dnssec_prepare_failures,
+ (unsigned long long) g_stats.dnssec_auth_failures,
+ g_log_thread_started ? 1 : 0,
+ (unsigned long long) log_ring_entries,
+ (unsigned long long) log_unflushed_entries,
+ g_cfg.exact_allow_index_size,
+ g_cfg.exact_block_index_size,
+ g_cfg.app_exact_allow_index_size,
+ g_cfg.app_exact_block_index_size,
+ g_cfg.suffix_allow_trie.count,
+ g_cfg.suffix_block_trie.count,
+ g_cfg.app_suffix_allow_trie.count,
+ g_cfg.app_suffix_block_trie.count,
+ g_cfg.app_exact_allow.count,
+ g_cfg.app_exact_block.count,
+ g_cfg.app_suffix_allow.count,
+ g_cfg.app_suffix_block.count,
+ g_cfg.network_allow_count,
+ g_cfg.network_block_count,
+ g_cfg.exact_allow.count + g_cfg.suffix_allow.count
+ + g_cfg.exact_block.count + g_cfg.suffix_block.count
+ + g_cfg.regex_allow.count + g_cfg.regex_block.count
+ + g_cfg.temp_allow_count + g_cfg.temp_block_count
+ + g_cfg.app_exact_allow.count + g_cfg.app_exact_block.count
+ + g_cfg.app_suffix_allow.count + g_cfg.app_suffix_block.count
+ + g_cfg.network_allow_count + g_cfg.network_block_count);
+ write_upstream_runtime_stats(client, &g_cfg);
+}
+
+static void write_benchmark_response(int client) {
+ uint8_t query[MAX_PACKET];
+ size_t query_len;
+ int timeout_ms;
+ int i;
+ bool probe_dnssec = false;
+
+ query_len = build_probe_query(query, sizeof(query), &probe_dnssec);
+ timeout_ms = g_cfg.timeout_ms < 1000 ? g_cfg.timeout_ms : 1000;
+ if (timeout_ms < 250) {
+ timeout_ms = 250;
+ }
+
+ write_control_response(client,
+ "benchmark=1\nupstreams=%d\ntimeout_ms=%d\ndnssec_request=%d\n"
+ "dnssec_auth_required=%d\nprobe_dnssec=%d\n",
+ g_cfg.upstream_count, timeout_ms, g_cfg.dnssec_request,
+ g_cfg.dnssec_auth_required, probe_dnssec ? 1 : 0);
+ if (query_len == 0) {
+ write_control_response(client, "error=unable_to_build_query\n");
+ return;
+ }
+
+ for (i = 0; i < g_cfg.upstream_count; i++) {
+ uint8_t response[MAX_PACKET];
+ struct timeval start;
+ struct timeval end;
+ ssize_t response_len;
+ int latency_ms;
+ int rcode;
+
+ gettimeofday(&start, NULL);
+ response_len = forward_udp_probe_one(&g_cfg.upstreams[i], timeout_ms, query, query_len,
+ response, sizeof(response), g_cfg.dnssec_auth_required != 0);
+ gettimeofday(&end, NULL);
+ latency_ms = elapsed_ms(&start, &end);
+ rcode = response_len > 0 ? response_rcode(response, (size_t) response_len) : -1;
+
+ write_control_response(client,
+ "upstream[%d]=%s:%d protocol=%s status=%s latency_ms=%d rcode=%d bytes=%ld\n",
+ i,
+ g_cfg.upstreams[i].host,
+ g_cfg.upstreams[i].port,
+ upstream_protocol_name(g_cfg.upstreams[i].protocol),
+ response_len > 0 ? "ok" : "fail",
+ latency_ms,
+ rcode,
+ (long) response_len);
+ }
+}
+
+static void write_history_response(int client, const char *filter) {
+ FILE *fp;
+ char line[LOG_LINE_MAX];
+ char log_file[sizeof(g_log_file_path)];
+ char (*matches)[LOG_LINE_MAX];
+ int count = 0;
+ int pos = 0;
+ int i;
+
+ flush_logs();
+ if (!copy_log_file_path(log_file, sizeof(log_file))) {
+ return;
+ }
+ fp = fopen(log_file, "r");
+ if (fp == NULL) {
+ return;
+ }
+ matches = (char (*)[LOG_LINE_MAX]) calloc(LOG_HISTORY_LIMIT, LOG_LINE_MAX);
+ if (matches == NULL) {
+ fclose(fp);
+ return;
+ }
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ trim(line);
+ if (line[0] == '\0' || !ascii_contains_ci(line, filter)) {
+ continue;
+ }
+ safe_copy(matches[pos], LOG_LINE_MAX, line);
+ pos = (pos + 1) % LOG_HISTORY_LIMIT;
+ if (count < LOG_HISTORY_LIMIT) {
+ count++;
+ }
+ }
+ fclose(fp);
+
+ for (i = 0; i < count; i++) {
+ int idx = (pos - count + i + LOG_HISTORY_LIMIT) % LOG_HISTORY_LIMIT;
+ write_control_response(client, "%s\n", matches[idx]);
+ }
+ free(matches);
+}
+
+static void handle_control(int fd, bool require_peer_auth) {
+ int client = accept(fd, NULL, NULL);
+ char request[512];
+ char cmd[256];
+ char *payload;
+ char *newline;
+ ssize_t n;
+ if (client < 0) {
+ return;
+ }
+ if (require_peer_auth) {
+ if (!control_peer_authorized(client, &g_cfg)) {
+ write_control_response(client, "error unauthorized\n");
+ close(client);
+ return;
+ }
+ } else if (!g_cfg.control_adb_debug) {
+ write_control_response(client, "error unauthorized\n");
+ write_event_log("warn", "TCP control peer rejected because ADB diagnostics are disabled");
+ close(client);
+ return;
+ } else {
+ write_event_log("warn", "ADB debug TCP control peer accepted on loopback port=%d",
+ g_cfg.control_tcp_port);
+ }
+ set_socket_timeout(client, CLIENT_TIMEOUT_MS);
+ n = recv(client, request, sizeof(request) - 1, 0);
+ if (n <= 0) {
+ if (socket_timed_out()) {
+ g_stats.control_client_timeouts++;
+ }
+ close(client);
+ return;
+ }
+ request[n] = '\0';
+ payload = request;
+ if (g_cfg.control_token[0] != '\0') {
+ newline = strchr(request, '\n');
+ if (newline == NULL) {
+ write_control_response(client, "error unauthorized\n");
+ write_event_log("warn", "control command rejected without token");
+ close(client);
+ return;
+ }
+ *newline = '\0';
+ payload = newline + 1;
+ trim(request);
+ if (strncmp(request, "token ", 6) == 0) {
+ memmove(request, request + 6, strlen(request + 6) + 1);
+ trim(request);
+ }
+ if (strcmp(request, g_cfg.control_token) != 0) {
+ write_control_response(client, "error unauthorized\n");
+ write_event_log("warn", "control command rejected with invalid token");
+ close(client);
+ return;
+ }
+ }
+ safe_copy(cmd, sizeof(cmd), payload);
+ trim(cmd);
+ if (strcmp(cmd, "status") == 0 || strcmp(cmd, "stats") == 0) {
+ long memory_rss_kb = read_proc_status_kb("VmRSS");
+ long memory_hwm_kb = read_proc_status_kb("VmHWM");
+ uint64_t cpu_user_ticks;
+ uint64_t cpu_system_ticks;
+ uint64_t cpu_user_ms;
+ uint64_t cpu_system_ms;
+ uint64_t cpu_total_ms;
+ uint64_t log_ring_entries;
+ uint64_t log_unflushed_entries;
+
+ read_proc_cpu_ticks(&cpu_user_ticks, &cpu_system_ticks);
+ cpu_user_ms = cpu_ticks_to_ms(cpu_user_ticks);
+ cpu_system_ms = cpu_ticks_to_ms(cpu_system_ticks);
+ cpu_total_ms = cpu_user_ms + cpu_system_ms;
+ ensure_daily_stats_current();
+ read_log_stats(&log_ring_entries, &log_unflushed_entries);
+ write_control_response(client,
+ "running=1\npid=%ld\nuptime=%llu\ngeneration=%llu\nlisten_port=%d\n"
+ "udp_listener=%d\ntcp_listener=%d\ncontrol_listener=%d\n"
+ "udp_listener_v4=%d\nudp_listener_v6=%d\n"
+ "tcp_listener_v4=%d\ntcp_listener_v6=%d\n"
+ "control_socket_uid=%d\ncontrol_peer_uid_enforced=%d\n"
+ "control_adb_debug_enabled=%d\n"
+ "control_tcp_port=%d\ncontrol_tcp_listener=%d\n"
+ "fail_open_control_supported=1\n"
+ "cleanup_iptables_safe=%d\ncleanup_ip6tables_safe=%d\n"
+ "queries=%llu\nudp_queries=%llu\ntcp_queries=%llu\ninvalid_queries=%llu\n"
+ "queries_today=%llu\nblocked_today=%llu\nallowed_today=%llu\n"
+ "stats_day_start=%llu\n"
+ "udp_drain_batches=%llu\nudp_drain_packets=%llu\n"
+ "tcp_client_timeouts=%llu\ncontrol_client_timeouts=%llu\n"
+ "allowed=%llu\nblocked=%llu\nfail_open_drops=%llu\nfail_closed_blocks=%llu\n"
+ "memory_rss_kb=%ld\nmemory_hwm_kb=%ld\ncpu_user_ms=%llu\n"
+ "cpu_system_ms=%llu\ncpu_total_ms=%llu\n"
+ "query_logging=%d\npersist_query_logs=%d\n"
+ "safe_search=%d\nsafe_search_rewrites=%llu\n"
+ "dnssec_request=%d\ndnssec_auth_required=%d\n"
+ "dnssec_queries=%llu\ndnssec_client_queries=%llu\n"
+ "dnssec_opt_added=%llu\ndnssec_opt_updated=%llu\n"
+ "dnssec_prepare_failures=%llu\ndnssec_auth_failures=%llu\n"
+ "log_writer_thread=%d\nlog_ring_entries=%llu\nlog_unflushed_entries=%llu\n"
+ "socket_buffer_bytes=%d\nudp_drain_limit=%d\n"
+ "cache_size=%d\ncache_entries=%d\ncache_hits=%llu\ncache_misses=%llu\n"
+ "cache_positive_entries=%d\ncache_negative_entries=%d\n"
+ "cache_positive_hits=%llu\ncache_negative_hits=%llu\n"
+ "cache_hit_rate_ppm=%llu\ncache_stores=%llu\n"
+ "cache_positive_stores=%llu\ncache_negative_stores=%llu\n"
+ "stale_cache_seconds=%d\ncache_stale_hits=%llu\n"
+ "cache_stale_negative_hits=%llu\ncache_stale_expired=%llu\n"
+ "persist_cache=%d\ncache_snapshot_restored=%llu\ncache_snapshot_saved=%llu\n"
+ "cache_snapshot_load_failures=%llu\ncache_snapshot_save_failures=%llu\n"
+ "cache_expired=%llu\ncache_evictions=%llu\ncache_ttl_rewrites=%llu\n"
+ "cache_lru_evictions=%llu\n"
+ "cache_reload_preserved=%llu\ncache_reload_dropped=%llu\n"
+ "cache_reload_scope_changes=%llu\n"
+ "upstream_requests=%llu\nupstream_successes=%llu\nupstream_failures=%llu\n"
+ "upstream_tcp_fallbacks=%llu\nupstream_truncated_responses=%llu\n"
+ "upstream_udp_socket_reuses=%llu\nupstream_udp_stale_replies=%llu\n"
+ "upstream_backoff_skips=%llu\nupstream_backoff_active=%d\n"
+ "uid_lookup_successes=%llu\nuid_lookup_misses=%llu\nuid_cache_hits=%llu\n"
+ "compiled_upstream_addresses=%d\nreusable_udp_upstream_sockets=%d\n"
+ "resolver_scope_hash=%llu\n"
+ "daemon_socket_mark=0x%x\nsocket_mark_supported=%d\n"
+ "socket_mark_errno=%d\nsocket_mark_failures=%llu\n"
+ "avg_latency_ms=%llu\nmax_latency_ms=%llu\nupstream_avg_latency_ms=%llu\n"
+ "reloads=%llu\nreload_failures=%llu\nvalidations=%llu\nvalidation_failures=%llu\n"
+ "exact_allow_index_size=%d\nexact_block_index_size=%d\n"
+ "app_exact_allow_index_size=%d\napp_exact_block_index_size=%d\n"
+ "suffix_allow_trie_nodes=%d\n"
+ "suffix_block_trie_nodes=%d\napp_suffix_allow_trie_nodes=%d\n"
+ "app_suffix_block_trie_nodes=%d\nrules_exact_allow=%d\nrules_suffix_allow=%d\n"
+ "rules_exact_block=%d\nrules_suffix_block=%d\n"
+ "rules_app_exact_allow=%d\nrules_app_exact_block=%d\n"
+ "rules_app_suffix_allow=%d\nrules_app_suffix_block=%d\n"
+ "rules_network_allow=%d\nrules_network_block=%d\n"
+ "rules_regex_allow=%d\nrules_regex_block=%d\n"
+ "rules_temp_allow=%d\nrules_temp_block=%d\nsplit_upstreams=%d\n",
+ (long) getpid(),
+ (unsigned long long) (now_seconds() - g_stats.start_time),
+ (unsigned long long) g_cfg.generation,
+ g_cfg.listen_port,
+ g_udp_listener_ready,
+ g_tcp_listener_ready,
+ g_control_listener_ready,
+ g_udp_listener_v4_ready,
+ g_udp_listener_v6_ready,
+ g_tcp_listener_v4_ready,
+ g_tcp_listener_v6_ready,
+ g_cfg.control_socket_uid,
+ control_peer_uid_enforced(g_cfg.control_socket_uid),
+ g_cfg.control_adb_debug,
+ g_cfg.control_tcp_port,
+ g_control_tcp_listener_ready,
+ cleanup_tool_ready(g_cfg.iptables_path, "iptables"),
+ cleanup_tool_ready(g_cfg.ip6tables_path, "ip6tables"),
+ (unsigned long long) g_stats.queries,
+ (unsigned long long) g_stats.udp_queries,
+ (unsigned long long) g_stats.tcp_queries,
+ (unsigned long long) g_stats.invalid_queries,
+ (unsigned long long) g_stats.queries_today,
+ (unsigned long long) g_stats.blocked_today,
+ (unsigned long long) g_stats.allowed_today,
+ (unsigned long long) g_stats.day_start_time,
+ (unsigned long long) g_stats.udp_drain_batches,
+ (unsigned long long) g_stats.udp_drain_packets,
+ (unsigned long long) g_stats.tcp_client_timeouts,
+ (unsigned long long) g_stats.control_client_timeouts,
+ (unsigned long long) g_stats.allowed,
+ (unsigned long long) g_stats.blocked,
+ (unsigned long long) g_stats.fail_open_drops,
+ (unsigned long long) g_stats.fail_closed_blocks,
+ memory_rss_kb,
+ memory_hwm_kb,
+ (unsigned long long) cpu_user_ms,
+ (unsigned long long) cpu_system_ms,
+ (unsigned long long) cpu_total_ms,
+ g_cfg.query_logging,
+ g_cfg.persist_query_logs,
+ g_cfg.safe_search,
+ (unsigned long long) g_stats.safe_search_rewrites,
+ g_cfg.dnssec_request,
+ g_cfg.dnssec_auth_required,
+ (unsigned long long) g_stats.dnssec_queries,
+ (unsigned long long) g_stats.dnssec_client_queries,
+ (unsigned long long) g_stats.dnssec_opt_added,
+ (unsigned long long) g_stats.dnssec_opt_updated,
+ (unsigned long long) g_stats.dnssec_prepare_failures,
+ (unsigned long long) g_stats.dnssec_auth_failures,
+ g_log_thread_started ? 1 : 0,
+ (unsigned long long) log_ring_entries,
+ (unsigned long long) log_unflushed_entries,
+ DNS_SOCKET_BUFFER_BYTES,
+ UDP_DRAIN_LIMIT,
+ g_cfg.cache_size,
+ cache_entry_count(),
+ (unsigned long long) g_stats.cache_hits,
+ (unsigned long long) g_stats.cache_misses,
+ cache_entry_count_by_type(false),
+ cache_entry_count_by_type(true),
+ (unsigned long long) g_stats.cache_positive_hits,
+ (unsigned long long) g_stats.cache_negative_hits,
+ (unsigned long long) div_u64(g_stats.cache_hits * 1000000ULL,
+ g_stats.cache_hits + g_stats.cache_misses),
+ (unsigned long long) g_stats.cache_stores,
+ (unsigned long long) g_stats.cache_positive_stores,
+ (unsigned long long) g_stats.cache_negative_stores,
+ g_cfg.stale_cache_seconds,
+ (unsigned long long) g_stats.cache_stale_hits,
+ (unsigned long long) g_stats.cache_stale_negative_hits,
+ (unsigned long long) g_stats.cache_stale_expired,
+ g_cfg.persist_cache,
+ (unsigned long long) g_stats.cache_snapshot_restored,
+ (unsigned long long) g_stats.cache_snapshot_saved,
+ (unsigned long long) g_stats.cache_snapshot_load_failures,
+ (unsigned long long) g_stats.cache_snapshot_save_failures,
+ (unsigned long long) g_stats.cache_expired,
+ (unsigned long long) g_stats.cache_evictions,
+ (unsigned long long) g_stats.cache_ttl_rewrites,
+ (unsigned long long) g_stats.cache_lru_evictions,
+ (unsigned long long) g_stats.cache_reload_preserved,
+ (unsigned long long) g_stats.cache_reload_dropped,
+ (unsigned long long) g_stats.cache_reload_scope_changes,
+ (unsigned long long) g_stats.upstream_requests,
+ (unsigned long long) g_stats.upstream_successes,
+ (unsigned long long) g_stats.upstream_failures,
+ (unsigned long long) g_stats.upstream_tcp_fallbacks,
+ (unsigned long long) g_stats.upstream_truncated_responses,
+ (unsigned long long) g_stats.upstream_udp_socket_reuses,
+ (unsigned long long) g_stats.upstream_udp_stale_replies,
+ (unsigned long long) g_stats.upstream_backoff_skips,
+ upstream_backoff_active_count(&g_cfg),
+ (unsigned long long) g_stats.uid_lookup_successes,
+ (unsigned long long) g_stats.uid_lookup_misses,
+ (unsigned long long) g_stats.uid_cache_hits,
+ compiled_upstream_address_count(&g_cfg),
+ reusable_udp_upstream_socket_count(&g_cfg),
+ (unsigned long long) g_cfg.resolver_scope_hash,
+ AFWALL_DNSD_SOCKET_MARK,
+ g_socket_mark_supported,
+ g_socket_mark_errno,
+ (unsigned long long) g_stats.socket_mark_failures,
+ (unsigned long long) div_u64(g_stats.total_latency_ms, g_stats.queries),
+ (unsigned long long) g_stats.max_latency_ms,
+ (unsigned long long) div_u64(g_stats.upstream_latency_ms, g_stats.upstream_requests),
+ (unsigned long long) g_stats.reloads,
+ (unsigned long long) g_stats.reload_failures,
+ (unsigned long long) g_stats.validations,
+ (unsigned long long) g_stats.validation_failures,
+ g_cfg.exact_allow_index_size,
+ g_cfg.exact_block_index_size,
+ g_cfg.app_exact_allow_index_size,
+ g_cfg.app_exact_block_index_size,
+ g_cfg.suffix_allow_trie.count,
+ g_cfg.suffix_block_trie.count,
+ g_cfg.app_suffix_allow_trie.count,
+ g_cfg.app_suffix_block_trie.count,
+ g_cfg.exact_allow.count,
+ g_cfg.suffix_allow.count,
+ g_cfg.exact_block.count,
+ g_cfg.suffix_block.count,
+ g_cfg.app_exact_allow.count,
+ g_cfg.app_exact_block.count,
+ g_cfg.app_suffix_allow.count,
+ g_cfg.app_suffix_block.count,
+ g_cfg.network_allow_count,
+ g_cfg.network_block_count,
+ g_cfg.regex_allow.count,
+ g_cfg.regex_block.count,
+ g_cfg.temp_allow_count,
+ g_cfg.temp_block_count,
+ g_cfg.split_upstream_count);
+ write_upstream_runtime_stats(client, &g_cfg);
+ } else if (strcmp(cmd, "health") == 0) {
+ write_health_response(client);
+ } else if (strcmp(cmd, "benchmark") == 0) {
+ write_benchmark_response(client);
+ } else if (strcmp(cmd, "validate") == 0) {
+ write_validate_response(client);
+ } else if (strcmp(cmd, "reload") == 0) {
+ if (reload_config()) {
+ refresh_control_tcp_listener();
+ write_control_response(client, "ok reload generation=%llu\n", (unsigned long long) g_cfg.generation);
+ } else {
+ write_control_response(client, "error reload active_generation=%llu reload_failures=%llu\n",
+ (unsigned long long) g_cfg.generation,
+ (unsigned long long) g_stats.reload_failures);
+ }
+ } else if (strcmp(cmd, "flush_cache") == 0) {
+ int cleared = clear_cache_entries();
+ write_event_log("info", "control flushed DNS cache entries=%d", cleared);
+ write_control_response(client, "ok flush_cache entries=%d\n", cleared);
+ } else if (strcmp(cmd, "flush_logs") == 0) {
+ uint64_t before_ring;
+ uint64_t before_unflushed;
+ uint64_t after_ring;
+ uint64_t after_unflushed;
+ read_log_stats(&before_ring, &before_unflushed);
+ flush_logs();
+ read_log_stats(&after_ring, &after_unflushed);
+ write_event_log("info",
+ "control flushed DNS query logs pending_before=%llu pending_after=%llu",
+ (unsigned long long) before_unflushed,
+ (unsigned long long) after_unflushed);
+ write_control_response(client,
+ "ok flush_logs ring_entries=%llu pending_before=%llu pending_after=%llu\n",
+ (unsigned long long) after_ring,
+ (unsigned long long) before_unflushed,
+ (unsigned long long) after_unflushed);
+ } else if (strcmp(cmd, "clear_logs") == 0) {
+ uint64_t before_ring;
+ uint64_t before_unflushed;
+ read_log_stats(&before_ring, &before_unflushed);
+ if (clear_query_logs()) {
+ write_event_log("info",
+ "control cleared DNS query logs ring_entries=%llu pending=%llu",
+ (unsigned long long) before_ring,
+ (unsigned long long) before_unflushed);
+ write_control_response(client,
+ "ok clear_logs ring_entries=%llu pending=%llu\n",
+ (unsigned long long) before_ring,
+ (unsigned long long) before_unflushed);
+ } else {
+ write_event_log("error", "control failed to clear DNS query logs");
+ write_control_response(client, "error clear_logs\n");
+ }
+ } else if (strcmp(cmd, "logs") == 0) {
+ int i;
+ int count = 0;
+ log_entry_t entries[LOG_RING];
+ pthread_mutex_lock(&g_log_mutex);
+ for (i = 0; i < LOG_RING; i++) {
+ int idx = (g_log_pos + i) % LOG_RING;
+ if (g_logs[idx].timestamp == 0) {
+ continue;
+ }
+ entries[count++] = g_logs[idx];
+ }
+ pthread_mutex_unlock(&g_log_mutex);
+ for (i = 0; i < count; i++) {
+ write_control_response(client,
+ "%llu %s %s %dms transport=%s source=%s uid=%d qtype=%u result=%s rule=%s upstream=%s\n",
+ (unsigned long long) entries[i].timestamp,
+ entries[i].action,
+ entries[i].domain,
+ entries[i].latency_ms,
+ entries[i].transport,
+ entries[i].source,
+ entries[i].uid,
+ (unsigned int) entries[i].qtype,
+ entries[i].result,
+ entries[i].rule,
+ entries[i].upstream);
+ }
+ } else if (strcmp(cmd, "history") == 0) {
+ write_history_response(client, "");
+ } else if (strncmp(cmd, "history ", 8) == 0) {
+ char *filter = cmd + 8;
+ trim(filter);
+ write_history_response(client, filter);
+ } else if (strcmp(cmd, "stop") == 0) {
+ write_control_response(client, "ok stopping\n");
+ g_running = 0;
+ } else if (strcmp(cmd, "fail_open") == 0) {
+ int cleanup_commands = cleanup_dns_redirects_from_daemon();
+ write_event_log("warn",
+ "control fail-open cleanup requested commands=%d; daemon stopping",
+ cleanup_commands);
+ write_control_response(client,
+ "ok fail_open cleanup_commands=%d stopping=1\n",
+ cleanup_commands);
+ g_running = 0;
+ } else {
+ write_control_response(client, "error unknown_command\n");
+ }
+ close(client);
+}
+
+static void signal_handler(int signo) {
+ if (signo == SIGTERM || signo == SIGINT) {
+ g_running = 0;
+ } else if (signo == SIGHUP) {
+ g_reload_requested = 1;
+ }
+}
+
+static void usage(const char *argv0) {
+ fprintf(stderr, "usage: %s --config \n", argv0);
+}
+
+int main(int argc, char **argv) {
+ int udp4_fd;
+ int udp6_fd;
+ int tcp4_fd;
+ int tcp6_fd;
+ int control_fd;
+ int i;
+ uint64_t last_heartbeat = 0;
+ default_config(&g_cfg);
+ g_stats.start_time = now_seconds();
+ for (i = 1; i < argc; i++) {
+ if (strcmp(argv[i], "--config") == 0 && i + 1 < argc) {
+ safe_copy(g_config_path, sizeof(g_config_path), argv[++i]);
+ } else {
+ usage(argv[0]);
+ return 2;
+ }
+ }
+ if (g_config_path[0] == '\0') {
+ usage(argv[0]);
+ return 2;
+ }
+ prime_event_log_from_config(g_config_path);
+ write_event_log("info", "daemon start requested config=%s", g_config_path);
+ if (!reload_config()) {
+ write_event_log("error", "daemon failed to load config path=%s failures=%llu",
+ g_config_path, (unsigned long long) g_stats.reload_failures);
+ fprintf(stderr, "failed to load config: %s\n", g_config_path);
+ return 1;
+ }
+ signal(SIGTERM, signal_handler);
+ signal(SIGINT, signal_handler);
+ signal(SIGHUP, signal_handler);
+ udp4_fd = create_udp_socket4(g_cfg.listen_port);
+ udp6_fd = create_udp_socket6(g_cfg.listen_port);
+ tcp4_fd = create_tcp_socket4(g_cfg.listen_port);
+ tcp6_fd = create_tcp_socket6(g_cfg.listen_port);
+ control_fd = create_control_socket(g_cfg.control_socket, g_cfg.control_socket_uid);
+ if (udp4_fd < 0 || tcp4_fd < 0 || control_fd < 0) {
+ int listener_errno = errno;
+ if (udp4_fd >= 0) {
+ close(udp4_fd);
+ }
+ if (udp6_fd >= 0) {
+ close(udp6_fd);
+ }
+ if (tcp4_fd >= 0) {
+ close(tcp4_fd);
+ }
+ if (tcp6_fd >= 0) {
+ close(tcp6_fd);
+ }
+ if (control_fd >= 0) {
+ close(control_fd);
+ unlink(g_cfg.control_socket);
+ }
+ unlink(g_cfg.pid_file);
+ if (g_cfg.heartbeat_file[0] != '\0') {
+ unlink(g_cfg.heartbeat_file);
+ }
+ write_event_log("error",
+ "daemon listener setup failed port=%d udp4_fd=%d udp6_fd=%d tcp4_fd=%d tcp6_fd=%d control_fd=%d errno=%d",
+ g_cfg.listen_port, udp4_fd, udp6_fd, tcp4_fd, tcp6_fd, control_fd, listener_errno);
+ fprintf(stderr, "failed to create listeners on port %d\n", g_cfg.listen_port);
+ return 1;
+ }
+ refresh_control_tcp_listener();
+ g_udp_listener_v4_ready = udp4_fd >= 0 ? 1 : 0;
+ g_udp_listener_v6_ready = udp6_fd >= 0 ? 1 : 0;
+ g_tcp_listener_v4_ready = tcp4_fd >= 0 ? 1 : 0;
+ g_tcp_listener_v6_ready = tcp6_fd >= 0 ? 1 : 0;
+ g_udp_listener_ready = g_udp_listener_v4_ready;
+ g_tcp_listener_ready = g_tcp_listener_v4_ready;
+ g_control_listener_ready = 1;
+ g_control_tcp_listener_ready = g_control_tcp_fd >= 0 ? 1 : 0;
+ /* Publish the PID only after listeners exist so supervisors do not accept a half-start. */
+ write_pid_file();
+ write_heartbeat_file();
+ last_heartbeat = now_seconds();
+ start_log_thread();
+ write_event_log("info",
+ "daemon listeners ready port=%d udp4=%d udp6=%d tcp4=%d tcp6=%d control=1 control_tcp=%d control_tcp_port=%d pid=%ld",
+ g_cfg.listen_port,
+ g_udp_listener_v4_ready,
+ g_udp_listener_v6_ready,
+ g_tcp_listener_v4_ready,
+ g_tcp_listener_v6_ready,
+ g_control_tcp_listener_ready,
+ g_cfg.control_tcp_port,
+ (long) getpid());
+ while (g_running) {
+ fd_set readfds;
+ struct timeval timeout;
+ uint64_t heartbeat_now;
+ int maxfd = control_fd;
+ int ready_control_tcp_fd = -1;
+ bool control_ready;
+ bool control_tcp_ready;
+ int ready;
+ if (g_reload_requested) {
+ g_reload_requested = 0;
+ if (!reload_config()) {
+ write_event_log("error", "daemon reload signal failed active_generation=%llu failures=%llu",
+ (unsigned long long) g_cfg.generation,
+ (unsigned long long) g_stats.reload_failures);
+ } else {
+ refresh_control_tcp_listener();
+ }
+ }
+ FD_ZERO(&readfds);
+ FD_SET(udp4_fd, &readfds);
+ if (udp6_fd >= 0) {
+ FD_SET(udp6_fd, &readfds);
+ }
+ FD_SET(tcp4_fd, &readfds);
+ if (tcp6_fd >= 0) {
+ FD_SET(tcp6_fd, &readfds);
+ }
+ FD_SET(control_fd, &readfds);
+ if (g_control_tcp_fd >= 0) {
+ FD_SET(g_control_tcp_fd, &readfds);
+ }
+ timeout.tv_sec = 1;
+ timeout.tv_usec = 0;
+ if (udp4_fd > maxfd) {
+ maxfd = udp4_fd;
+ }
+ if (udp6_fd > maxfd) {
+ maxfd = udp6_fd;
+ }
+ if (tcp4_fd > maxfd) {
+ maxfd = tcp4_fd;
+ }
+ if (tcp6_fd > maxfd) {
+ maxfd = tcp6_fd;
+ }
+ if (g_control_tcp_fd > maxfd) {
+ maxfd = g_control_tcp_fd;
+ }
+ ready = select(maxfd + 1, &readfds, NULL, NULL, &timeout);
+ if (ready < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ control_ready = FD_ISSET(control_fd, &readfds);
+ if (g_control_tcp_fd >= 0 && FD_ISSET(g_control_tcp_fd, &readfds)) {
+ ready_control_tcp_fd = g_control_tcp_fd;
+ control_tcp_ready = true;
+ } else {
+ control_tcp_ready = false;
+ }
+ if (FD_ISSET(udp4_fd, &readfds)) {
+ int drained = 0;
+ int limit = AFWALL_HAS_MSG_DONTWAIT ? UDP_DRAIN_LIMIT : 1;
+ /* Drain a bounded UDP burst so queued DNS packets are not left behind under load. */
+ while (drained < limit && handle_udp(udp4_fd, MSG_DONTWAIT)) {
+ drained++;
+ }
+ if (drained > 1) {
+ g_stats.udp_drain_batches++;
+ g_stats.udp_drain_packets += (uint64_t) drained;
+ }
+ }
+ if (udp6_fd >= 0 && FD_ISSET(udp6_fd, &readfds)) {
+ int drained = 0;
+ int limit = AFWALL_HAS_MSG_DONTWAIT ? UDP_DRAIN_LIMIT : 1;
+ while (drained < limit && handle_udp(udp6_fd, MSG_DONTWAIT)) {
+ drained++;
+ }
+ if (drained > 1) {
+ g_stats.udp_drain_batches++;
+ g_stats.udp_drain_packets += (uint64_t) drained;
+ }
+ }
+ if (FD_ISSET(tcp4_fd, &readfds)) {
+ handle_tcp(tcp4_fd);
+ }
+ if (tcp6_fd >= 0 && FD_ISSET(tcp6_fd, &readfds)) {
+ handle_tcp(tcp6_fd);
+ }
+ if (control_ready) {
+ handle_control(control_fd, true);
+ }
+ if (control_tcp_ready && g_control_tcp_fd == ready_control_tcp_fd) {
+ handle_control(ready_control_tcp_fd, false);
+ }
+ if (!g_log_thread_started) {
+ flush_logs();
+ }
+ heartbeat_now = now_seconds();
+ if (heartbeat_now != last_heartbeat) {
+ write_heartbeat_file();
+ last_heartbeat = heartbeat_now;
+ }
+ }
+ stop_log_thread();
+ write_cache_snapshot(&g_cfg, g_cache, g_cache_capacity);
+ g_udp_listener_ready = 0;
+ g_tcp_listener_ready = 0;
+ g_udp_listener_v4_ready = 0;
+ g_udp_listener_v6_ready = 0;
+ g_tcp_listener_v4_ready = 0;
+ g_tcp_listener_v6_ready = 0;
+ g_control_listener_ready = 0;
+ g_control_tcp_listener_ready = 0;
+ close(udp4_fd);
+ if (udp6_fd >= 0) {
+ close(udp6_fd);
+ }
+ close(tcp4_fd);
+ if (tcp6_fd >= 0) {
+ close(tcp6_fd);
+ }
+ close(control_fd);
+ if (g_control_tcp_fd >= 0) {
+ close(g_control_tcp_fd);
+ g_control_tcp_fd = -1;
+ }
+ unlink(g_cfg.control_socket);
+ unlink(g_cfg.pid_file);
+ if (g_cfg.heartbeat_file[0] != '\0') {
+ unlink(g_cfg.heartbeat_file);
+ }
+ write_event_log("info", "daemon shutdown complete");
+ free_config_dynamic(&g_cfg);
+ return 0;
+}
diff --git a/scripts/adb-dns-control.sh b/scripts/adb-dns-control.sh
new file mode 100755
index 000000000..fc357daa9
--- /dev/null
+++ b/scripts/adb-dns-control.sh
@@ -0,0 +1,302 @@
+#!/usr/bin/env bash
+#
+# Send token-authenticated DNS daemon control commands over ADB.
+#
+# AFWall must have "Allow ADB DNS diagnostics" enabled first. The helper reads
+# the daemon token on-device through root and never prints the token locally.
+
+set -euo pipefail
+
+ADB="${ADB:-adb}"
+PACKAGE="${PACKAGE:-dev.ukanth.ufirewall}"
+WORK_DIR="${WORK_DIR:-}"
+ALLOW_DESTRUCTIVE=0
+
+usage() {
+ cat < [arguments...]
+
+Commands:
+ status | stats Daemon runtime counters and control state.
+ health Runtime health and upstream probe.
+ validate Validate current daemon configuration.
+ benchmark Benchmark configured upstream resolvers.
+ logs Recent in-memory query log entries.
+ history [filter] Persisted query log history, optionally filtered.
+ reload Reload daemon config from AFWall files.
+ flush_cache Clear daemon DNS cache.
+ flush_logs Flush pending query logs to disk.
+ clear_logs Clear query history. Requires --allow-destructive.
+ stop Stop daemon. Requires --allow-destructive.
+ fail_open Remove DNS redirects and stop daemon. Requires --allow-destructive.
+
+Environment:
+ ADB adb executable, default: adb
+ PACKAGE AFWall package name, default: dev.ukanth.ufirewall
+ WORK_DIR DNS daemon work dir override
+
+The helper prefers the daemon Unix control socket and falls back to the
+loopback TCP control port when AFWall's ADB DNS diagnostics setting is active.
+EOF
+}
+
+die() {
+ printf 'error: %s\n' "$*" >&2
+ exit 1
+}
+
+shell_quote() {
+ local value="$1"
+ value=${value//\'/\'\"\'\"\'}
+ printf "'%s'" "$value"
+}
+
+adb_su() {
+ "$ADB" shell su -c "$1"
+}
+
+single_device_check() {
+ local count
+ count=$("$ADB" devices | awk 'NR > 1 && $2 == "device" { count++ } END { print count + 0 }')
+ if [ "$count" -ne 1 ]; then
+ "$ADB" devices >&2 || true
+ die "expected exactly one attached adb device, found $count"
+ fi
+}
+
+root_test() {
+ adb_su "test $*" >/dev/null 2>&1
+}
+
+detect_work_dir() {
+ local candidate
+ if [ -n "$WORK_DIR" ]; then
+ if root_test "-d $(shell_quote "$WORK_DIR")"; then
+ printf '%s\n' "$WORK_DIR"
+ return 0
+ fi
+ return 1
+ fi
+ for candidate in \
+ "/data/user_de/0/$PACKAGE/app_dnsd" \
+ "/data/data/$PACKAGE/app_dnsd"; do
+ if root_test "-d $(shell_quote "$candidate")"; then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+find_unix_nc() {
+ if adb_su "toybox nc --help 2>&1 | grep -q -- '-U'"; then
+ printf '%s\n' 'toybox nc'
+ return 0
+ fi
+ if adb_su "nc -h 2>&1 | grep -q -- '-U'"; then
+ printf '%s\n' 'nc'
+ return 0
+ fi
+ return 1
+}
+
+find_tcp_nc() {
+ if adb_su "toybox nc --help >/dev/null 2>&1"; then
+ printf '%s\n' 'toybox nc'
+ return 0
+ fi
+ if adb_su "command -v nc >/dev/null 2>&1"; then
+ printf '%s\n' 'nc'
+ return 0
+ fi
+ return 1
+}
+
+read_control_tcp_port() {
+ local work_dir="$1"
+ local config="$work_dir/afwall_dnsd.conf"
+ local value
+ value=$(adb_su "sed -n 's/^control_tcp_port=//p' $(shell_quote "$config") | head -n 1" \
+ 2>/dev/null | tr -d '\r' || true)
+ case "$value" in
+ ''|*[!0-9]*)
+ return 1
+ ;;
+ esac
+ if [ "$value" -le 0 ] || [ "$value" -gt 65535 ]; then
+ return 1
+ fi
+ printf '%s\n' "$value"
+}
+
+send_control_unix() {
+ local work_dir="$1"
+ local nc_cmd="$2"
+ local control_command="$3"
+ local socket="$work_dir/afwall_dnsd.sock"
+ local token="$work_dir/afwall_dnsd.control"
+
+ adb_su "TOKEN=\$(cat $(shell_quote "$token") 2>/dev/null || true); \
+if [ -z \"\$TOKEN\" ]; then echo 'probe_error=token_unreadable'; exit 1; fi; \
+printf 'token %s\n%s\n' \"\$TOKEN\" $(shell_quote "$control_command") \
+| $nc_cmd -U $(shell_quote "$socket") 2>&1" | tr -d '\r'
+}
+
+send_control_tcp() {
+ local work_dir="$1"
+ local nc_cmd="$2"
+ local port="$3"
+ local control_command="$4"
+ local token="$work_dir/afwall_dnsd.control"
+
+ adb_su "TOKEN=\$(cat $(shell_quote "$token") 2>/dev/null || true); \
+if [ -z \"\$TOKEN\" ]; then echo 'probe_error=token_unreadable'; exit 1; fi; \
+printf 'token %s\n%s\n' \"\$TOKEN\" $(shell_quote "$control_command") \
+| $nc_cmd 127.0.0.1 $(shell_quote "$port") 2>&1" | tr -d '\r'
+}
+
+status_allows_debug() {
+ local status="$1"
+ printf '%s\n' "$status" | grep -q '^running=1' \
+ && printf '%s\n' "$status" | grep -q '^control_adb_debug_enabled=1$'
+}
+
+send_selected_control() {
+ local control_command="$1"
+ case "$TRANSPORT" in
+ unix)
+ send_control_unix "$WORK_DIR" "$NC_CMD" "$control_command"
+ ;;
+ tcp)
+ send_control_tcp "$WORK_DIR" "$NC_CMD" "$TCP_PORT" "$control_command"
+ ;;
+ *)
+ die "internal error: no selected control transport"
+ ;;
+ esac
+}
+
+validate_control_command() {
+ local command="$1"
+ case "$command" in
+ status|stats|health|validate|benchmark|logs|history|reload|flush_cache|flush_logs|clear_logs|stop|fail_open)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+is_destructive_command() {
+ local command="$1"
+ case "$command" in
+ clear_logs|stop|fail_open)
+ return 0
+ ;;
+ *)
+ return 1
+ ;;
+ esac
+}
+
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --adb)
+ ADB="$2"
+ shift 2
+ ;;
+ --package)
+ PACKAGE="$2"
+ shift 2
+ ;;
+ --work-dir)
+ WORK_DIR="$2"
+ shift 2
+ ;;
+ --allow-destructive)
+ ALLOW_DESTRUCTIVE=1
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ -*)
+ die "unknown option: $1"
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+if [ "$#" -lt 1 ]; then
+ usage >&2
+ exit 2
+fi
+
+COMMAND="$1"
+shift
+if ! validate_control_command "$COMMAND"; then
+ die "unsupported daemon command: $COMMAND"
+fi
+if is_destructive_command "$COMMAND" && [ "$ALLOW_DESTRUCTIVE" -ne 1 ]; then
+ die "$COMMAND requires --allow-destructive"
+fi
+
+CONTROL_COMMAND="$COMMAND"
+for arg in "$@"; do
+ case "$arg" in
+ *$'\n'*|*$'\r'*)
+ die "command arguments cannot contain newlines"
+ ;;
+ esac
+ CONTROL_COMMAND="$CONTROL_COMMAND $arg"
+done
+
+single_device_check
+if ! adb_su "id" 2>/dev/null | grep -q 'uid=0'; then
+ die "root shell unavailable through adb su"
+fi
+
+WORK_DIR="$(detect_work_dir)" || die "AFWall DNS work dir not found; enable DNS protection and start the service first"
+TRANSPORT=""
+NC_CMD=""
+TCP_PORT=""
+STATUS=""
+
+if NC_CANDIDATE="$(find_unix_nc)"; then
+ STATUS="$(send_control_unix "$WORK_DIR" "$NC_CANDIDATE" status || true)"
+ if status_allows_debug "$STATUS"; then
+ TRANSPORT="unix"
+ NC_CMD="$NC_CANDIDATE"
+ fi
+fi
+
+if [ -z "$TRANSPORT" ]; then
+ if TCP_PORT="$(read_control_tcp_port "$WORK_DIR")" && NC_CANDIDATE="$(find_tcp_nc)"; then
+ STATUS="$(send_control_tcp "$WORK_DIR" "$NC_CANDIDATE" "$TCP_PORT" status || true)"
+ if status_allows_debug "$STATUS"; then
+ TRANSPORT="tcp"
+ NC_CMD="$NC_CANDIDATE"
+ fi
+ fi
+fi
+
+if [ -z "$TRANSPORT" ]; then
+ if printf '%s\n' "$STATUS" | grep -q '^error unauthorized'; then
+ die "daemon rejected root control; enable AFWall's ADB DNS diagnostics setting and reload/repair DNS"
+ fi
+ die "no usable daemon control transport found; enable ADB DNS diagnostics and ensure Unix nc -U or loopback TCP nc is available"
+fi
+
+if [ "$COMMAND" = "status" ] || [ "$COMMAND" = "stats" ]; then
+ printf '%s\n' "$STATUS"
+else
+ send_selected_control "$CONTROL_COMMAND"
+fi
diff --git a/scripts/validate-dns-magisk-runtime.sh b/scripts/validate-dns-magisk-runtime.sh
new file mode 100755
index 000000000..4ca5ed9f4
--- /dev/null
+++ b/scripts/validate-dns-magisk-runtime.sh
@@ -0,0 +1,1213 @@
+#!/usr/bin/env bash
+#
+# Runtime validation for AFWall root DNS Magisk integration.
+#
+# Baseline checks are evidence-oriented and do not toggle AFWall preferences.
+# Optional recovery probes intentionally alter root daemon state, then verify
+# fail-open cleanup and restore the service. Enable DNS protection and Magisk
+# boot persistence from AFWall first, then run this against a rooted device.
+
+set -euo pipefail
+
+ADB="${ADB:-adb}"
+PACKAGE="${PACKAGE:-dev.ukanth.ufirewall}"
+APK="${APK:-app/build/outputs/apk/debug/app-debug.apk}"
+MODULE_DIR="${MODULE_DIR:-/data/adb/modules/afwall_dnsd}"
+OUT_DIR="${OUT_DIR:-runtime-validation-logs/dns-magisk-$(date +%Y%m%d-%H%M%S)}"
+EXPECTED_SCRIPT_VERSION="${EXPECTED_SCRIPT_VERSION:-4}"
+INSTALL_APK=0
+REBOOT_CHECK=0
+DAEMON_RESTART_CHECK=0
+ORPHAN_SERVICE_CHECK=0
+STALE_MARKER_CHECK=0
+EMBEDDED_CLEANUP_CHECK=0
+DIRECT_CLEANUP_CHECK=0
+DAEMON_FAIL_OPEN_CHECK=0
+ADB_DEBUG_CONTROL_CHECK=0
+BOOT_TIMEOUT_SECONDS="${BOOT_TIMEOUT_SECONDS:-180}"
+POST_BOOT_SETTLE_SECONDS="${POST_BOOT_SETTLE_SECONDS:-20}"
+RECOVERY_WAIT_SECONDS="${RECOVERY_WAIT_SECONDS:-45}"
+CURRENT_PHASE="preboot"
+
+failures=0
+warnings=0
+
+usage() {
+ cat <&2
+ usage >&2
+ exit 2
+ ;;
+ esac
+done
+
+mkdir -p "$OUT_DIR"
+
+log() {
+ printf '%s\n' "$*"
+}
+
+pass() {
+ log "PASS: $*"
+}
+
+warn() {
+ warnings=$((warnings + 1))
+ log "WARN: $*"
+}
+
+fail() {
+ failures=$((failures + 1))
+ log "FAIL: $*"
+}
+
+capture() {
+ local name="$1"
+ shift
+ {
+ printf '$'
+ printf ' %q' "$@"
+ printf '\n\n'
+ "$@" 2>&1
+ } > "$OUT_DIR/$CURRENT_PHASE-$name" || true
+}
+
+adb_shell() {
+ "$ADB" shell "$@"
+}
+
+adb_su() {
+ "$ADB" shell su -c "$*"
+}
+
+root_test() {
+ adb_su "test $*" >/dev/null 2>&1
+}
+
+root_cat() {
+ adb_su "cat $*"
+}
+
+shell_quote() {
+ local value="$1"
+ value=${value//\'/\'\"\'\"\'}
+ printf "'%s'" "$value"
+}
+
+single_device_check() {
+ local count
+ count=$("$ADB" devices | awk 'NR > 1 && $2 == "device" { count++ } END { print count + 0 }')
+ if [ "$count" -ne 1 ]; then
+ fail "expected exactly one attached device, found $count"
+ "$ADB" devices | tee "$OUT_DIR/adb-devices.txt" >/dev/null
+ exit 1
+ fi
+ pass "one adb device attached"
+}
+
+install_apk_if_requested() {
+ if [ "$INSTALL_APK" -ne 1 ]; then
+ return
+ fi
+ if [ ! -f "$APK" ]; then
+ fail "APK not found: $APK"
+ exit 1
+ fi
+ capture install-apk.txt "$ADB" install -r "$APK"
+ pass "APK install command completed"
+}
+
+detect_work_dir() {
+ local candidate
+ for candidate in \
+ "/data/user_de/0/$PACKAGE/app_dnsd" \
+ "/data/data/$PACKAGE/app_dnsd"; do
+ if root_test "-d '$candidate'"; then
+ printf '%s\n' "$candidate"
+ return 0
+ fi
+ done
+ return 1
+}
+
+detect_app_uid() {
+ local uid
+ uid=$("$ADB" shell cmd package list packages -U 2>/dev/null \
+ | tr -d '\r' \
+ | awk -v p="package:$PACKAGE" '$1 == p { for (i = 1; i <= NF; i++) if ($i ~ /^uid:/) { sub(/^uid:/, "", $i); print $i; exit } }')
+ if [[ "$uid" =~ ^[0-9]+$ ]]; then
+ printf '%s\n' "$uid"
+ return 0
+ fi
+ uid=$("$ADB" shell dumpsys package "$PACKAGE" 2>/dev/null \
+ | tr -d '\r' \
+ | sed -n 's/.*userId=\([0-9][0-9]*\).*/\1/p' \
+ | head -n 1)
+ if [[ "$uid" =~ ^[0-9]+$ ]]; then
+ printf '%s\n' "$uid"
+ return 0
+ fi
+ return 1
+}
+
+root_command_exists() {
+ adb_su "command -v '$1' >/dev/null 2>&1" >/dev/null 2>&1
+}
+
+read_root_number_file() {
+ local path="$1"
+ adb_su "cat $(shell_quote "$path") 2>/dev/null || true" \
+ | tr -d '\r' \
+ | sed -n 's/^\([0-9][0-9]*\).*/\1/p' \
+ | head -n 1
+}
+
+root_stat_owner_mode() {
+ local path="$1"
+ adb_su "if stat -c '%u %a' $(shell_quote "$path") >/dev/null 2>&1; then stat -c '%u %a' $(shell_quote "$path"); elif toybox stat -c '%u %a' $(shell_quote "$path") >/dev/null 2>&1; then toybox stat -c '%u %a' $(shell_quote "$path"); fi" \
+ | tr -d '\r' \
+ | sed -n 's/^\([0-9][0-9]*\)[[:space:]][[:space:]]*\([0-7][0-7]*\).*$/\1 \2/p' \
+ | head -n 1
+}
+
+mode_has_no_group_world_bits() {
+ local mode="$1"
+ [[ "$mode" =~ ^[0-7]+$ ]] || return 1
+ (( (8#$mode & 077) == 0 ))
+}
+
+daemon_pid() {
+ local work_dir="$1"
+ read_root_number_file "$work_dir/afwall_dnsd.pid"
+}
+
+restart_count() {
+ local work_dir="$1"
+ local count
+ count=$(read_root_number_file "$work_dir/afwall_dnsd_restart_count")
+ printf '%s\n' "${count:-0}"
+}
+
+supervisor_command() {
+ local work_dir="$1"
+ local action="$2"
+ adb_su "$(shell_quote "$work_dir/afwall_dnsd_supervisor.sh") $action 2>&1"
+}
+
+wait_for_supervisor_ready() {
+ local work_dir="$1"
+ local deadline=$((SECONDS + RECOVERY_WAIT_SECONDS))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ if supervisor_command "$work_dir" status | tr -d '\r' | grep -q '^readiness=ready$'; then
+ return 0
+ fi
+ sleep 2
+ done
+ return 1
+}
+
+check_root_and_magisk() {
+ capture root-id.txt "$ADB" shell su -c id
+ if ! adb_su "id" 2>/dev/null | grep -q 'uid=0'; then
+ fail "root shell unavailable through su"
+ exit 1
+ fi
+ pass "root shell available"
+
+ capture magisk-version.txt "$ADB" shell su -c 'magisk -V 2>/dev/null || magisk -v 2>/dev/null || ls -la /data/adb 2>/dev/null'
+ if ! root_test "-d /data/adb/modules"; then
+ fail "Magisk modules directory missing"
+ else
+ pass "Magisk modules directory present"
+ fi
+}
+
+check_module_state() {
+ capture module-listing.txt "$ADB" shell su -c "ls -la '$MODULE_DIR' 2>&1 || true"
+ capture module-prop.txt "$ADB" shell su -c "cat '$MODULE_DIR/module.prop' 2>&1 || true"
+ capture module-service-script-head.txt "$ADB" shell su -c "sed -n '1,80p' '$MODULE_DIR/service.sh' 2>&1 || true"
+ capture module-uninstall-script-head.txt "$ADB" shell su -c "sed -n '1,120p' '$MODULE_DIR/uninstall.sh' 2>&1 || true"
+ if ! root_test "-d '$MODULE_DIR'"; then
+ fail "DNS Magisk module directory missing: $MODULE_DIR"
+ return
+ fi
+ if ! adb_su "grep -q '^id=afwall_dnsd$' '$MODULE_DIR/module.prop'"; then
+ fail "DNS Magisk module id missing or wrong"
+ else
+ pass "DNS Magisk module id verified"
+ fi
+ if root_test "-f '$MODULE_DIR/disable'"; then
+ fail "DNS Magisk module is disabled"
+ else
+ pass "DNS Magisk module is not disabled"
+ fi
+ if root_test "-f '$MODULE_DIR/remove'"; then
+ fail "DNS Magisk module removal is pending"
+ else
+ pass "DNS Magisk module removal is not pending"
+ fi
+ check_module_script_versions
+}
+
+check_module_script_versions() {
+ local script
+ local marker="AFWALL_DNS_MODULE_SCRIPT_VERSION=$EXPECTED_SCRIPT_VERSION"
+ for script in service.sh uninstall.sh; do
+ if ! root_test "-f '$MODULE_DIR/$script'"; then
+ fail "DNS Magisk module script missing: $script"
+ continue
+ fi
+ if adb_su "grep -q '^$marker$' '$MODULE_DIR/$script'"; then
+ pass "DNS Magisk module script version verified: $script"
+ else
+ fail "DNS Magisk module script version mismatch for $script; expected $EXPECTED_SCRIPT_VERSION"
+ fi
+ done
+ if adb_su "grep -q '^MARKER=' '$MODULE_DIR/service.sh'"; then
+ pass "DNS Magisk service script has enable-marker gate"
+ else
+ fail "DNS Magisk service script is missing enable-marker gate"
+ fi
+ if adb_su "grep -q 'cleanup_service_state' '$MODULE_DIR/service.sh'"; then
+ pass "DNS Magisk service script has embedded fallback cleanup"
+ else
+ fail "DNS Magisk service script is missing embedded fallback cleanup"
+ fi
+ if adb_su "grep -q 'remove_root_copies' '$MODULE_DIR/service.sh'"; then
+ pass "DNS Magisk service script removes legacy root startup hooks during fallback cleanup"
+ else
+ fail "DNS Magisk service script is missing legacy root startup hook cleanup"
+ fi
+ if adb_su "grep -q 'rm -f \"\\\$MARKER\"' '$MODULE_DIR/service.sh'"; then
+ pass "DNS Magisk service script clears the enable marker during fallback cleanup"
+ else
+ fail "DNS Magisk service script does not clear the enable marker during fallback cleanup"
+ fi
+ if adb_su "grep -q 'cleanup_service_state' '$MODULE_DIR/uninstall.sh'"; then
+ pass "DNS Magisk uninstall script has embedded fallback cleanup"
+ else
+ fail "DNS Magisk uninstall script is missing embedded fallback cleanup"
+ fi
+ if adb_su "grep -q 'remove_root_copies' '$MODULE_DIR/uninstall.sh'"; then
+ pass "DNS Magisk uninstall script removes legacy root startup hooks during fallback cleanup"
+ else
+ fail "DNS Magisk uninstall script is missing legacy root startup hook cleanup"
+ fi
+ if adb_su "grep -q 'rm -f \"\\\$MARKER\"' '$MODULE_DIR/uninstall.sh'"; then
+ pass "DNS Magisk uninstall script clears the enable marker during fallback cleanup"
+ else
+ fail "DNS Magisk uninstall script does not clear the enable marker during fallback cleanup"
+ fi
+}
+
+check_work_dir_state() {
+ local work_dir="$1"
+ local app_uid
+ capture workdir-listing.txt "$ADB" shell su -c "ls -la '$work_dir' 2>&1 || true"
+ capture service-events.txt "$ADB" shell su -c "tail -n 200 '$work_dir/afwall_dnsd_events.log' 2>&1 || true"
+ capture supervisor-log.txt "$ADB" shell su -c "tail -n 200 '$work_dir/afwall_dnsd_supervisor.log' 2>&1 || true"
+ capture boot-log.txt "$ADB" shell su -c "tail -n 200 '$work_dir/afwall_dnsd_boot.log' 2>&1 || true"
+ capture module-log.txt "$ADB" shell su -c "tail -n 200 /data/local/tmp/afwall_dnsd_module.log 2>&1 || true"
+
+ if root_test "-S '$work_dir/afwall_dnsd.sock'"; then
+ pass "control socket present"
+ else
+ fail "control socket missing"
+ fi
+ if root_test "-s '$work_dir/afwall_dnsd.control'"; then
+ pass "control token file exists and is non-empty"
+ else
+ fail "control token file missing or empty"
+ fi
+ if root_test "-f '$work_dir/afwall_dnsd.enabled'"; then
+ pass "enabled marker present"
+ else
+ fail "enabled marker missing"
+ fi
+ if app_uid=$(detect_app_uid); then
+ check_control_file_permissions "$work_dir" "$app_uid"
+ else
+ fail "unable to detect AFWall app UID for control file permission checks"
+ fi
+}
+
+check_control_path_stat() {
+ local label="$1"
+ local path="$2"
+ local expected_uid="$3"
+ local stat_line
+ local owner
+ local mode
+ stat_line=$(root_stat_owner_mode "$path")
+ printf '%s %s\n' "$path" "${stat_line:-stat_unavailable}" \
+ >> "$OUT_DIR/$CURRENT_PHASE-control-path-permissions.txt"
+ if [ -z "$stat_line" ]; then
+ fail "$label stat unavailable"
+ return
+ fi
+ owner=${stat_line%% *}
+ mode=${stat_line##* }
+ if [ "$owner" = "$expected_uid" ]; then
+ pass "$label is owned by AFWall app UID"
+ else
+ fail "$label owner UID is $owner, expected $expected_uid"
+ fi
+ if mode_has_no_group_world_bits "$mode"; then
+ pass "$label has no group/world permission bits"
+ else
+ fail "$label mode $mode exposes group/world permission bits"
+ fi
+}
+
+check_shell_uid_cannot_read_sensitive_file() {
+ local label="$1"
+ local path="$2"
+ local file_label
+ local response_file
+ local id_file="$OUT_DIR/$CURRENT_PHASE-shell-uid-id.txt"
+ file_label=$(printf '%s\n' "$label" | tr -c 'A-Za-z0-9_' '_')
+ response_file="$OUT_DIR/$CURRENT_PHASE-shell-uid-${file_label}-read.txt"
+ if ! adb_su "su 2000 -c id" > "$id_file" 2>&1; then
+ warn "unable to run shell UID read probe on this device; see $id_file"
+ return
+ fi
+ if adb_su "su 2000 -c $(shell_quote "cat $(shell_quote "$path") >/dev/null")" \
+ > "$response_file" 2>&1; then
+ fail "shell UID can read $label"
+ else
+ pass "shell UID cannot read $label"
+ fi
+}
+
+check_control_file_permissions() {
+ local work_dir="$1"
+ local app_uid="$2"
+ check_control_path_stat "DNS work directory" "$work_dir" "$app_uid"
+ check_control_path_stat "daemon config file" "$work_dir/afwall_dnsd.conf" "$app_uid"
+ check_control_path_stat "control token file" "$work_dir/afwall_dnsd.control" "$app_uid"
+ check_control_path_stat "control socket" "$work_dir/afwall_dnsd.sock" "$app_uid"
+ check_shell_uid_cannot_read_sensitive_file "the daemon config" "$work_dir/afwall_dnsd.conf"
+ check_shell_uid_cannot_read_sensitive_file "the daemon control token" "$work_dir/afwall_dnsd.control"
+}
+
+check_daemon_process() {
+ capture daemon-process.txt "$ADB" shell su -c "ps -A 2>/dev/null | grep afwall_dnsd || true"
+ if adb_su "ps -A 2>/dev/null | grep -q '[a]fwall_dnsd'"; then
+ pass "daemon process is running"
+ else
+ fail "daemon process not found"
+ fi
+}
+
+check_redirect_rules() {
+ capture iptables-dns.txt "$ADB" shell su -c "iptables-save 2>/dev/null | grep -E 'afwall-dns|afwall_dns' || true"
+ capture ip6tables-dns.txt "$ADB" shell su -c "ip6tables-save 2>/dev/null | grep -E 'afwall-dns|afwall_dns' || true"
+ capture nft-dns.txt "$ADB" shell su -c "nft list ruleset 2>/dev/null | grep -E 'afwall_dns|afwall-dns' || true"
+ if adb_su "iptables-save 2>/dev/null | grep -q 'afwall-dns'"; then
+ pass "IPv4 DNS redirect rules are present"
+ else
+ fail "IPv4 DNS redirect rules not found"
+ fi
+ if adb_su "ip6tables-save 2>/dev/null | grep -q 'afwall-dns6'"; then
+ pass "IPv6 DNS redirect rules are present"
+ else
+ warn "IPv6 DNS redirect rules not found; this is expected if IPv6 capture is disabled"
+ fi
+}
+
+check_redirect_rules_absent() {
+ capture iptables-dns-absent.txt "$ADB" shell su -c "iptables-save 2>/dev/null | grep -E 'afwall-dns|afwall_dns' || true"
+ capture ip6tables-dns-absent.txt "$ADB" shell su -c "ip6tables-save 2>/dev/null | grep -E 'afwall-dns|afwall_dns' || true"
+ capture nft-dns-absent.txt "$ADB" shell su -c "nft list ruleset 2>/dev/null | grep -E 'afwall_dns|afwall-dns' || true"
+ if adb_su "iptables-save 2>/dev/null | grep -q 'afwall-dns'"; then
+ fail "IPv4 DNS redirect rules are still present after cleanup"
+ else
+ pass "IPv4 DNS redirect rules are absent after cleanup"
+ fi
+ if adb_su "ip6tables-save 2>/dev/null | grep -q 'afwall-dns6'"; then
+ fail "IPv6 DNS redirect rules are still present after cleanup"
+ else
+ pass "IPv6 DNS redirect rules are absent after cleanup"
+ fi
+ if adb_su "command -v nft >/dev/null 2>&1 && nft list ruleset 2>/dev/null | grep -q 'afwall_dns'"; then
+ fail "nft DNS redirect table/rules are still present after cleanup"
+ else
+ pass "nft DNS redirect table/rules are absent after cleanup"
+ fi
+}
+
+find_unix_nc() {
+ if adb_su "toybox nc --help 2>&1 | grep -q -- '-U'"; then
+ printf '%s\n' 'toybox nc'
+ return 0
+ fi
+ if adb_su "nc -h 2>&1 | grep -q -- '-U'"; then
+ printf '%s\n' 'nc'
+ return 0
+ fi
+ return 1
+}
+
+find_tcp_nc() {
+ if adb_su "toybox nc --help >/dev/null 2>&1"; then
+ printf '%s\n' 'toybox nc'
+ return 0
+ fi
+ if adb_su "command -v nc >/dev/null 2>&1"; then
+ printf '%s\n' 'nc'
+ return 0
+ fi
+ return 1
+}
+
+read_control_tcp_port() {
+ local work_dir="$1"
+ local config="$work_dir/afwall_dnsd.conf"
+ local value
+ value=$(adb_su "sed -n 's/^control_tcp_port=//p' $(shell_quote "$config") | head -n 1" \
+ 2>/dev/null | tr -d '\r' || true)
+ case "$value" in
+ ''|*[!0-9]*)
+ return 1
+ ;;
+ esac
+ if [ "$value" -le 0 ] || [ "$value" -gt 65535 ]; then
+ return 1
+ fi
+ printf '%s\n' "$value"
+}
+
+send_root_control_unix() {
+ local work_dir="$1"
+ local nc_cmd="$2"
+ local control_command="$3"
+ adb_su "TOKEN=\$(cat $(shell_quote "$work_dir/afwall_dnsd.control") 2>/dev/null); printf 'token %s\n%s\n' \"\$TOKEN\" $(shell_quote "$control_command") | $nc_cmd -U $(shell_quote "$work_dir/afwall_dnsd.sock") 2>&1 || true"
+}
+
+send_root_control_tcp() {
+ local work_dir="$1"
+ local nc_cmd="$2"
+ local port="$3"
+ local control_command="$4"
+ adb_su "TOKEN=\$(cat $(shell_quote "$work_dir/afwall_dnsd.control") 2>/dev/null); printf 'token %s\n%s\n' \"\$TOKEN\" $(shell_quote "$control_command") | $nc_cmd 127.0.0.1 $(shell_quote "$port") 2>&1 || true"
+}
+
+check_external_control_rejection() {
+ local work_dir="$1"
+ local nc_cmd
+ local response_file="$OUT_DIR/$CURRENT_PHASE-external-root-control-response.txt"
+ if ! nc_cmd=$(find_unix_nc); then
+ warn "no Unix-domain nc client found on device; external control rejection probe skipped"
+ return
+ fi
+ adb_su "TOKEN=\$(cat '$work_dir/afwall_dnsd.control' 2>/dev/null); printf 'token %s\nstatus\n' \"\$TOKEN\" | $nc_cmd -U '$work_dir/afwall_dnsd.sock' 2>&1 || true" > "$response_file" || true
+ if grep -q '^error unauthorized' "$response_file"; then
+ pass "root-side external control probe was rejected"
+ else
+ fail "root-side external control probe was not rejected; see $response_file"
+ fi
+}
+
+check_adb_debug_control_allowed() {
+ local work_dir="$1"
+ local nc_cmd
+ local tcp_port
+ local transport=""
+ local response_file="$OUT_DIR/$CURRENT_PHASE-adb-debug-root-control-response.txt"
+ local bad_response_file="$OUT_DIR/$CURRENT_PHASE-adb-debug-bad-token-response.txt"
+ if nc_cmd=$(find_unix_nc); then
+ send_root_control_unix "$work_dir" "$nc_cmd" status > "$response_file" || true
+ if grep -q '^running=1' "$response_file" \
+ && grep -q '^control_adb_debug_enabled=1$' "$response_file"; then
+ transport="unix"
+ fi
+ fi
+ if [ -z "$transport" ]; then
+ if tcp_port=$(read_control_tcp_port "$work_dir") && nc_cmd=$(find_tcp_nc); then
+ send_root_control_tcp "$work_dir" "$nc_cmd" "$tcp_port" status > "$response_file" || true
+ if grep -q '^running=1' "$response_file" \
+ && grep -q '^control_adb_debug_enabled=1$' "$response_file"; then
+ transport="tcp"
+ fi
+ fi
+ fi
+ if [ "$transport" = "unix" ]; then
+ pass "root-over-ADB debug control probe succeeded over Unix socket"
+ adb_su "printf 'token %s\nstatus\n' '0000000000000000000000000000000000000000000000000000000000000000' | $nc_cmd -U $(shell_quote "$work_dir/afwall_dnsd.sock") 2>&1 || true" > "$bad_response_file" || true
+ elif [ "$transport" = "tcp" ]; then
+ pass "root-over-ADB debug control probe succeeded over loopback TCP"
+ adb_su "printf 'token %s\nstatus\n' '0000000000000000000000000000000000000000000000000000000000000000' | $nc_cmd 127.0.0.1 $(shell_quote "$tcp_port") 2>&1 || true" > "$bad_response_file" || true
+ else
+ if [ ! -s "$response_file" ]; then
+ echo "probe_error=no_usable_unix_or_tcp_control_transport" > "$response_file"
+ fi
+ fail "root-over-ADB debug control probe failed or setting is disabled; see $response_file"
+ return
+ fi
+ if grep -q '^error unauthorized' "$bad_response_file"; then
+ pass "root-over-ADB debug control still rejects a bad token"
+ else
+ fail "root-over-ADB debug control accepted a bad token; see $bad_response_file"
+ fi
+}
+
+write_control_probe_script() {
+ local local_script="$1"
+ local work_dir="$2"
+ local nc_cmd="$3"
+ local control_command="${4:-status}"
+ {
+ printf '#!/system/bin/sh\n'
+ printf 'WORK_DIR=%s\n' "$(shell_quote "$work_dir")"
+ printf 'NC_CMD=%s\n' "$(shell_quote "$nc_cmd")"
+ printf 'CONTROL_COMMAND=%s\n' "$(shell_quote "$control_command")"
+ printf 'TOKEN=$(cat "$WORK_DIR/afwall_dnsd.control" 2>/dev/null || true)\n'
+ printf 'if [ -z "$TOKEN" ]; then echo "probe_error=token_unreadable"; exit 1; fi\n'
+ printf 'printf '"'"'token %%s\\n%%s\\n'"'"' "$TOKEN" "$CONTROL_COMMAND" | $NC_CMD -U "$WORK_DIR/afwall_dnsd.sock" 2>&1\n'
+ } > "$local_script"
+}
+
+check_app_control_authorized() {
+ local work_dir="$1"
+ local app_uid
+ local nc_cmd
+ local token_check_file="$OUT_DIR/$CURRENT_PHASE-app-uid-token-read.txt"
+ local push_log="$OUT_DIR/$CURRENT_PHASE-app-uid-control-probe-push.txt"
+ local response_file="$OUT_DIR/$CURRENT_PHASE-app-uid-control-response.txt"
+ local local_script="$OUT_DIR/$CURRENT_PHASE-app-uid-control-probe-device.sh"
+ local remote_script="/data/local/tmp/afwall_dns_probe_$$.sh"
+ local token_check
+
+ if ! app_uid=$(detect_app_uid); then
+ fail "unable to detect AFWall app UID for authorized control probe"
+ return
+ fi
+ printf '%s\n' "$app_uid" > "$OUT_DIR/$CURRENT_PHASE-app-uid.txt"
+ pass "AFWall app UID detected: $app_uid"
+
+ token_check="test -r $(shell_quote "$work_dir/afwall_dnsd.control")"
+ if adb_su "su $(shell_quote "$app_uid") -c $(shell_quote "$token_check")" > "$token_check_file" 2>&1; then
+ pass "AFWall app UID can read the daemon control token"
+ else
+ fail "AFWall app UID cannot read the daemon control token; see $token_check_file"
+ return
+ fi
+
+ if ! nc_cmd=$(find_unix_nc); then
+ warn "no Unix-domain nc client found on device; app UID authorized control probe skipped"
+ return
+ fi
+
+ write_control_probe_script "$local_script" "$work_dir" "$nc_cmd"
+ if ! "$ADB" push "$local_script" "$remote_script" > "$push_log" 2>&1; then
+ fail "unable to push app UID control probe script; see $push_log"
+ return
+ fi
+ adb_su "chmod 755 $(shell_quote "$remote_script")" >/dev/null 2>&1 || true
+ adb_su "su $(shell_quote "$app_uid") -c $(shell_quote "$remote_script")" > "$response_file" 2>&1 || true
+ adb_su "rm -f $(shell_quote "$remote_script")" >/dev/null 2>&1 || true
+
+ if grep -q '^running=1' "$response_file" \
+ && grep -q '^control_peer_uid_enforced=1' "$response_file"; then
+ pass "AFWall app UID authorized control probe succeeded"
+ else
+ fail "AFWall app UID authorized control probe failed; see $response_file"
+ fi
+ if grep -q '^fail_open_control_supported=1$' "$response_file"; then
+ pass "daemon advertises authenticated fail-open control support"
+ else
+ fail "daemon did not advertise authenticated fail-open control support; see $response_file"
+ fi
+ if grep -q '^cleanup_iptables_safe=1$' "$response_file" \
+ && grep -q '^cleanup_ip6tables_safe=1$' "$response_file"; then
+ pass "daemon cleanup tools are available for fail-open control cleanup"
+ else
+ fail "daemon cleanup tools are not fully available; see $response_file"
+ fi
+}
+
+check_query_activity() {
+ local work_dir="$1"
+ capture query-log-tail.txt "$ADB" shell su -c "tail -n 200 '$work_dir/afwall_dnsd.log' 2>&1 || true"
+ if root_test "-s '$work_dir/afwall_dnsd.log'"; then
+ pass "DNS query log has entries"
+ else
+ warn "DNS query log is empty; run a DNS lookup on-device and rerun validation"
+ fi
+}
+
+run_daemon_restart_check() {
+ CURRENT_PHASE="daemon-restart"
+ local work_dir
+ local before_pid
+ local after_pid
+ local before_restarts
+ local after_restarts
+ local kill_log="$OUT_DIR/$CURRENT_PHASE-kill-daemon.txt"
+
+ if ! work_dir=$(detect_work_dir); then
+ fail "[$CURRENT_PHASE] AFWall DNS work directory not found"
+ return
+ fi
+ before_pid=$(daemon_pid "$work_dir")
+ before_restarts=$(restart_count "$work_dir")
+ printf 'work_dir=%s\nbefore_pid=%s\nbefore_restarts=%s\n' \
+ "$work_dir" "${before_pid:-missing}" "$before_restarts" \
+ > "$OUT_DIR/$CURRENT_PHASE-before.txt"
+ if [ -z "$before_pid" ]; then
+ fail "[$CURRENT_PHASE] daemon PID missing before restart check"
+ return
+ fi
+
+ adb_su "kill -TERM $(shell_quote "$before_pid")" > "$kill_log" 2>&1 || true
+ if ! wait_for_supervisor_ready "$work_dir"; then
+ capture supervisor-status-after-kill.txt "$ADB" shell su -c "$(shell_quote "$work_dir/afwall_dnsd_supervisor.sh") status 2>&1 || true"
+ fail "[$CURRENT_PHASE] supervisor did not report readiness after daemon kill"
+ return
+ fi
+
+ after_pid=$(daemon_pid "$work_dir")
+ after_restarts=$(restart_count "$work_dir")
+ printf 'work_dir=%s\nafter_pid=%s\nafter_restarts=%s\n' \
+ "$work_dir" "${after_pid:-missing}" "$after_restarts" \
+ > "$OUT_DIR/$CURRENT_PHASE-after.txt"
+ capture supervisor-log-after-kill.txt "$ADB" shell su -c "tail -n 120 '$work_dir/afwall_dnsd_supervisor.log' 2>&1 || true"
+ capture daemon-events-after-kill.txt "$ADB" shell su -c "tail -n 120 '$work_dir/afwall_dnsd_events.log' 2>&1 || true"
+
+ if [ -n "$after_pid" ] && [ "$after_pid" != "$before_pid" ]; then
+ pass "daemon restarted with a new PID after kill"
+ else
+ fail "daemon PID did not change after kill"
+ fi
+ if [ "${after_restarts:-0}" -gt "$before_restarts" ]; then
+ pass "supervisor restart counter increased after daemon kill"
+ else
+ fail "supervisor restart counter did not increase after daemon kill"
+ fi
+ check_redirect_rules
+}
+
+run_daemon_fail_open_check() {
+ CURRENT_PHASE="daemon-fail-open"
+ local work_dir
+ local app_uid
+ local nc_cmd
+ local push_log="$OUT_DIR/$CURRENT_PHASE-app-uid-fail-open-push.txt"
+ local response_file="$OUT_DIR/$CURRENT_PHASE-app-uid-fail-open-response.txt"
+ local local_script="$OUT_DIR/$CURRENT_PHASE-app-uid-fail-open-device.sh"
+ local remote_script="/data/local/tmp/afwall_dns_fail_open_$$.sh"
+ local restore_log="$OUT_DIR/$CURRENT_PHASE-restore.txt"
+
+ if ! work_dir=$(detect_work_dir); then
+ fail "[$CURRENT_PHASE] AFWall DNS work directory not found"
+ return
+ fi
+ if ! app_uid=$(detect_app_uid); then
+ fail "[$CURRENT_PHASE] unable to detect AFWall app UID"
+ return
+ fi
+ if ! nc_cmd=$(find_unix_nc); then
+ warn "no Unix-domain nc client found on device; daemon fail-open control probe skipped"
+ return
+ fi
+
+ if ! wait_for_supervisor_ready "$work_dir"; then
+ capture fail-open-before-status.txt "$ADB" shell su -c "$(shell_quote "$work_dir/afwall_dnsd_supervisor.sh") status 2>&1 || true"
+ fail "[$CURRENT_PHASE] service was not ready before fail-open probe"
+ return
+ fi
+
+ adb_su "rm -f $(shell_quote "$work_dir/afwall_dnsd.enabled")" >/dev/null 2>&1 || true
+ write_control_probe_script "$local_script" "$work_dir" "$nc_cmd" "fail_open"
+ if ! "$ADB" push "$local_script" "$remote_script" > "$push_log" 2>&1; then
+ fail "unable to push app UID fail-open probe script; see $push_log"
+ return
+ fi
+ adb_su "chmod 755 $(shell_quote "$remote_script")" >/dev/null 2>&1 || true
+ adb_su "su $(shell_quote "$app_uid") -c $(shell_quote "$remote_script")" > "$response_file" 2>&1 || true
+ adb_su "rm -f $(shell_quote "$remote_script")" >/dev/null 2>&1 || true
+ sleep 2
+
+ capture fail-open-workdir.txt "$ADB" shell su -c "ls -la '$work_dir' 2>&1 || true"
+ capture fail-open-daemon-events.txt "$ADB" shell su -c "tail -n 160 '$work_dir/afwall_dnsd_events.log' 2>&1 || true"
+ capture fail-open-supervisor-log.txt "$ADB" shell su -c "tail -n 160 '$work_dir/afwall_dnsd_supervisor.log' 2>&1 || true"
+
+ if grep -q '^ok fail_open' "$response_file"; then
+ pass "AFWall app UID fail-open control command succeeded"
+ else
+ fail "AFWall app UID fail-open control command failed; see $response_file"
+ fi
+ if adb_su "test ! -e $(shell_quote "$work_dir/afwall_dnsd.enabled")"; then
+ pass "daemon fail-open probe cleared the enable marker"
+ else
+ fail "daemon fail-open probe left the enable marker behind"
+ fi
+ if adb_su "ps -A 2>/dev/null | grep -q '[a]fwall_dnsd'"; then
+ fail "daemon is still running after authenticated fail-open probe"
+ else
+ pass "daemon stopped after authenticated fail-open probe"
+ fi
+ check_redirect_rules_absent
+
+ supervisor_command "$work_dir" start > "$restore_log" 2>&1 || true
+ if wait_for_supervisor_ready "$work_dir"; then
+ pass "DNS service restored after daemon fail-open probe"
+ check_redirect_rules
+ else
+ fail "DNS service did not restore after daemon fail-open probe; see $restore_log"
+ fi
+}
+
+run_orphan_service_check() {
+ CURRENT_PHASE="orphan-service"
+ local work_dir
+ local probe_dir="/data/local/tmp/afwall_dns_orphan_probe_$$"
+ local fake_package="${PACKAGE}.missing.$$"
+ local prepare_log="$OUT_DIR/$CURRENT_PHASE-prepare.txt"
+ local run_log="$OUT_DIR/$CURRENT_PHASE-run.txt"
+ local restore_log="$OUT_DIR/$CURRENT_PHASE-restore.txt"
+ local prepare_cmd
+
+ if ! work_dir=$(detect_work_dir); then
+ fail "[$CURRENT_PHASE] AFWall DNS work directory not found"
+ return
+ fi
+ prepare_cmd="PROBE=$(shell_quote "$probe_dir"); WORK=$(shell_quote "$work_dir"); MOD=$(shell_quote "$MODULE_DIR"); FAKE=$(shell_quote "$fake_package"); "
+ prepare_cmd+="rm -rf \"\$PROBE\"; mkdir -p \"\$PROBE\" "
+ prepare_cmd+="&& cp \"\$MOD/service.sh\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& cp \"\$WORK/afwall_dnsd_cleanup.sh\" \"\$PROBE/cleanup.sh\" "
+ prepare_cmd+="&& sed -i \"s|^PACKAGE=.*|PACKAGE='\$FAKE'|\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& sed -i \"s|^PACKAGE=.*|PACKAGE='\$FAKE'|\" \"\$PROBE/cleanup.sh\" "
+ prepare_cmd+="&& sed -i \"s|^CLEANUP=.*|CLEANUP='\$PROBE/cleanup.sh'|\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& chmod 755 \"\$PROBE/service.sh\" \"\$PROBE/cleanup.sh\""
+ if ! adb_su "$prepare_cmd" > "$prepare_log" 2>&1; then
+ fail "[$CURRENT_PHASE] unable to prepare orphan service probe; see $prepare_log"
+ return
+ fi
+
+ adb_su "$(shell_quote "$probe_dir/service.sh")" > "$run_log" 2>&1 || true
+ capture orphan-probe-listing.txt "$ADB" shell su -c "ls -la '$probe_dir' 2>&1 || true"
+ capture cleanup-log-after-orphan.txt "$ADB" shell su -c "tail -n 120 '$work_dir/afwall_dnsd_cleanup.log' 2>&1 || true"
+ capture module-log-after-orphan.txt "$ADB" shell su -c "tail -n 120 /data/local/tmp/afwall_dnsd_module.log 2>&1 || true"
+
+ if adb_su "test -f $(shell_quote "$probe_dir/disable") && test -f $(shell_quote "$probe_dir/remove")"; then
+ pass "orphan service probe scheduled module disable/remove in its module directory"
+ else
+ fail "orphan service probe did not schedule module disable/remove"
+ fi
+ if adb_su "ps -A 2>/dev/null | grep -q '[a]fwall_dnsd'"; then
+ fail "daemon is still running after orphan cleanup probe"
+ else
+ pass "daemon stopped after orphan cleanup probe"
+ fi
+ check_redirect_rules_absent
+
+ supervisor_command "$work_dir" start > "$restore_log" 2>&1 || true
+ if wait_for_supervisor_ready "$work_dir"; then
+ pass "DNS service restored after orphan cleanup probe"
+ check_redirect_rules
+ else
+ fail "DNS service did not restore after orphan cleanup probe; see $restore_log"
+ fi
+ adb_su "rm -rf $(shell_quote "$probe_dir")" >/dev/null 2>&1 || true
+}
+
+run_stale_marker_check() {
+ CURRENT_PHASE="stale-marker"
+ local work_dir
+ local probe_dir="/data/local/tmp/afwall_dns_marker_probe_$$"
+ local fake_marker="$probe_dir/missing-enabled-marker"
+ local prepare_log="$OUT_DIR/$CURRENT_PHASE-prepare.txt"
+ local run_log="$OUT_DIR/$CURRENT_PHASE-run.txt"
+ local restore_log="$OUT_DIR/$CURRENT_PHASE-restore.txt"
+ local prepare_cmd
+
+ if ! work_dir=$(detect_work_dir); then
+ fail "[$CURRENT_PHASE] AFWall DNS work directory not found"
+ return
+ fi
+ prepare_cmd="PROBE=$(shell_quote "$probe_dir"); WORK=$(shell_quote "$work_dir"); MOD=$(shell_quote "$MODULE_DIR"); MARKER=$(shell_quote "$fake_marker"); "
+ prepare_cmd+="rm -rf \"\$PROBE\"; mkdir -p \"\$PROBE\" "
+ prepare_cmd+="&& cp \"\$MOD/service.sh\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& cp \"\$WORK/afwall_dnsd_cleanup.sh\" \"\$PROBE/cleanup.sh\" "
+ prepare_cmd+="&& sed -i \"s|^CLEANUP=.*|CLEANUP='\$PROBE/cleanup.sh'|\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& sed -i \"s|^MARKER=.*|MARKER='\$MARKER'|\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& sed -i \"s|^MARKER=.*|MARKER='\$MARKER'|\" \"\$PROBE/cleanup.sh\" "
+ prepare_cmd+="&& chmod 755 \"\$PROBE/service.sh\" \"\$PROBE/cleanup.sh\""
+ if ! adb_su "$prepare_cmd" > "$prepare_log" 2>&1; then
+ fail "[$CURRENT_PHASE] unable to prepare stale marker probe; see $prepare_log"
+ return
+ fi
+
+ adb_su "$(shell_quote "$probe_dir/service.sh")" > "$run_log" 2>&1 || true
+ capture stale-marker-probe-listing.txt "$ADB" shell su -c "ls -la '$probe_dir' 2>&1 || true"
+ capture cleanup-log-after-stale-marker.txt "$ADB" shell su -c "tail -n 120 '$work_dir/afwall_dnsd_cleanup.log' 2>&1 || true"
+ capture module-log-after-stale-marker.txt "$ADB" shell su -c "tail -n 120 /data/local/tmp/afwall_dnsd_module.log 2>&1 || true"
+
+ if adb_su "test -f $(shell_quote "$probe_dir/disable") && test -f $(shell_quote "$probe_dir/remove")"; then
+ pass "stale marker probe scheduled module disable/remove in its module directory"
+ else
+ fail "stale marker probe did not schedule module disable/remove"
+ fi
+ if adb_su "ps -A 2>/dev/null | grep -q '[a]fwall_dnsd'"; then
+ fail "daemon is still running after stale marker cleanup probe"
+ else
+ pass "daemon stopped after stale marker cleanup probe"
+ fi
+ check_redirect_rules_absent
+
+ supervisor_command "$work_dir" start > "$restore_log" 2>&1 || true
+ if wait_for_supervisor_ready "$work_dir"; then
+ pass "DNS service restored after stale marker cleanup probe"
+ check_redirect_rules
+ else
+ fail "DNS service did not restore after stale marker cleanup probe; see $restore_log"
+ fi
+ adb_su "rm -rf $(shell_quote "$probe_dir")" >/dev/null 2>&1 || true
+}
+
+run_embedded_cleanup_check() {
+ CURRENT_PHASE="embedded-cleanup"
+ local work_dir
+ local probe_dir="/data/local/tmp/afwall_dns_embedded_cleanup_probe_$$"
+ local fake_package="${PACKAGE}.missing.$$"
+ local missing_cleanup="$probe_dir/missing-cleanup.sh"
+ local prepare_log="$OUT_DIR/$CURRENT_PHASE-prepare.txt"
+ local run_log="$OUT_DIR/$CURRENT_PHASE-run.txt"
+ local restore_log="$OUT_DIR/$CURRENT_PHASE-restore.txt"
+ local prepare_cmd
+
+ if ! work_dir=$(detect_work_dir); then
+ fail "[$CURRENT_PHASE] AFWall DNS work directory not found"
+ return
+ fi
+ prepare_cmd="PROBE=$(shell_quote "$probe_dir"); MOD=$(shell_quote "$MODULE_DIR"); FAKE=$(shell_quote "$fake_package"); CLEAN=$(shell_quote "$missing_cleanup"); "
+ prepare_cmd+="rm -rf \"\$PROBE\"; mkdir -p \"\$PROBE\" "
+ prepare_cmd+="&& cp \"\$MOD/service.sh\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& sed -i \"s|^PACKAGE=.*|PACKAGE='\$FAKE'|\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& sed -i \"s|^CLEANUP=.*|CLEANUP='\$CLEAN'|\" \"\$PROBE/service.sh\" "
+ prepare_cmd+="&& chmod 755 \"\$PROBE/service.sh\""
+ if ! adb_su "$prepare_cmd" > "$prepare_log" 2>&1; then
+ fail "[$CURRENT_PHASE] unable to prepare embedded cleanup probe; see $prepare_log"
+ return
+ fi
+
+ adb_su "$(shell_quote "$probe_dir/service.sh")" > "$run_log" 2>&1 || true
+ capture embedded-cleanup-probe-listing.txt "$ADB" shell su -c "ls -la '$probe_dir' 2>&1 || true"
+ capture module-log-after-embedded-cleanup.txt "$ADB" shell su -c "tail -n 160 /data/local/tmp/afwall_dnsd_module.log 2>&1 || true"
+
+ if adb_su "test -f $(shell_quote "$probe_dir/disable") && test -f $(shell_quote "$probe_dir/remove")"; then
+ pass "embedded cleanup probe scheduled module disable/remove in its module directory"
+ else
+ fail "embedded cleanup probe did not schedule module disable/remove"
+ fi
+ if grep -q 'cleanup_service_state: not found\|cleanup_service_state: inaccessible' "$run_log"; then
+ fail "embedded cleanup function missing from copied Magisk service"
+ fi
+ if adb_su "ps -A 2>/dev/null | grep -q '[a]fwall_dnsd'"; then
+ fail "daemon is still running after embedded cleanup probe"
+ else
+ pass "daemon stopped after embedded cleanup probe"
+ fi
+ check_redirect_rules_absent
+
+ supervisor_command "$work_dir" start > "$restore_log" 2>&1 || true
+ if wait_for_supervisor_ready "$work_dir"; then
+ pass "DNS service restored after embedded cleanup probe"
+ check_redirect_rules
+ else
+ fail "DNS service did not restore after embedded cleanup probe; see $restore_log"
+ fi
+ adb_su "rm -rf $(shell_quote "$probe_dir")" >/dev/null 2>&1 || true
+}
+
+run_direct_cleanup_check() {
+ CURRENT_PHASE="direct-cleanup"
+ local work_dir
+ local cleanup_log="$OUT_DIR/$CURRENT_PHASE-run.txt"
+ local restore_log="$OUT_DIR/$CURRENT_PHASE-restore.txt"
+ local cleanup_cmd
+
+ if ! work_dir=$(detect_work_dir); then
+ fail "[$CURRENT_PHASE] AFWall DNS work directory not found"
+ return
+ fi
+
+ cleanup_cmd="WORK=$(shell_quote "$work_dir"); "
+ cleanup_cmd+="ipt() { if command -v iptables >/dev/null 2>&1; then iptables \"\$@\"; else return 0; fi; }; "
+ cleanup_cmd+="ip6t() { if command -v ip6tables >/dev/null 2>&1; then ip6tables \"\$@\"; else return 0; fi; }; "
+ cleanup_cmd+="stop_named_daemon() { signal=\"\$1\"; "
+ cleanup_cmd+="if command -v pidof >/dev/null 2>&1; then for p in \$(pidof afwall_dnsd 2>/dev/null); do kill \"\$signal\" \"\$p\" 2>/dev/null || true; done; fi; "
+ cleanup_cmd+="for proc in /proc/[0-9]*; do [ -r \"\$proc/comm\" ] || continue; name=\$(cat \"\$proc/comm\" 2>/dev/null || true); [ \"\$name\" = afwall_dnsd ] || continue; pid=\${proc#/proc/}; kill \"\$signal\" \"\$pid\" 2>/dev/null || true; done; "
+ cleanup_cmd+="}; "
+ cleanup_cmd+="cleanup_redirects() { "
+ cleanup_cmd+="ipt -D OUTPUT -m mark --mark 0xaf053 -j ACCEPT >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -D OUTPUT -m mark --mark 0xaf053 -j ACCEPT >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ipt -D OUTPUT -j afwall-dns-out >/dev/null 2>&1 || true; ipt -F afwall-dns-out >/dev/null 2>&1 || true; ipt -X afwall-dns-out >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -D OUTPUT -j afwall-dns-out >/dev/null 2>&1 || true; ip6t -F afwall-dns-out >/dev/null 2>&1 || true; ip6t -X afwall-dns-out >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ipt -t nat -D OUTPUT -p udp --dport 53 -j afwall-dns >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ipt -t nat -D OUTPUT -p tcp --dport 53 -j afwall-dns >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ipt -t nat -D PREROUTING -p udp --dport 53 -j afwall-dns-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ipt -t nat -D PREROUTING -p tcp --dport 53 -j afwall-dns-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ipt -t nat -F afwall-dns >/dev/null 2>&1 || true; ipt -t nat -F afwall-dns-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ipt -t nat -X afwall-dns >/dev/null 2>&1 || true; ipt -t nat -X afwall-dns-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -t nat -D OUTPUT -p udp --dport 53 -j afwall-dns6 >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -t nat -D OUTPUT -p tcp --dport 53 -j afwall-dns6 >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -t nat -D PREROUTING -p udp --dport 53 -j afwall-dns6-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -t nat -D PREROUTING -p tcp --dport 53 -j afwall-dns6-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -t nat -F afwall-dns6 >/dev/null 2>&1 || true; ip6t -t nat -F afwall-dns6-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="ip6t -t nat -X afwall-dns6 >/dev/null 2>&1 || true; ip6t -t nat -X afwall-dns6-pre >/dev/null 2>&1 || true; "
+ cleanup_cmd+="if command -v nft >/dev/null 2>&1; then nft delete table ip afwall_dns >/dev/null 2>&1 || true; nft delete table ip6 afwall_dns6 >/dev/null 2>&1 || true; fi; "
+ cleanup_cmd+="}; "
+ cleanup_cmd+="rm -f \"\$WORK/afwall_dnsd.enabled\" 2>/dev/null || true; "
+ cleanup_cmd+="for PIDFILE in \"\$WORK/afwall_dnsd_supervisor.pid\" \"\$WORK/afwall_dnsd.pid\"; do [ -f \"\$PIDFILE\" ] || continue; pid=\$(cat \"\$PIDFILE\" 2>/dev/null || true); case \"\$pid\" in ''|*[!0-9]*) ;; *) kill -TERM \"\$pid\" 2>/dev/null || true ;; esac; done; "
+ cleanup_cmd+="stop_named_daemon -TERM; sleep 1; "
+ cleanup_cmd+="for PIDFILE in \"\$WORK/afwall_dnsd_supervisor.pid\" \"\$WORK/afwall_dnsd.pid\"; do [ -f \"\$PIDFILE\" ] || continue; pid=\$(cat \"\$PIDFILE\" 2>/dev/null || true); case \"\$pid\" in ''|*[!0-9]*) ;; *) kill -KILL \"\$pid\" 2>/dev/null || true ;; esac; done; "
+ cleanup_cmd+="stop_named_daemon -KILL; "
+ cleanup_cmd+="rm -f \"\$WORK/afwall_dnsd_supervisor.pid\" \"\$WORK/afwall_dnsd.pid\" \"\$WORK/afwall_dnsd.heartbeat\" \"\$WORK/afwall_dnsd.sock\" 2>/dev/null || true; "
+ cleanup_cmd+="cleanup_redirects; true"
+
+ adb_su "$cleanup_cmd" > "$cleanup_log" 2>&1 || true
+ capture direct-cleanup-workdir.txt "$ADB" shell su -c "ls -la '$work_dir' 2>&1 || true"
+
+ if adb_su "test ! -e $(shell_quote "$work_dir/afwall_dnsd.enabled")"; then
+ pass "direct cleanup cleared the enable marker"
+ else
+ fail "direct cleanup did not clear the enable marker"
+ fi
+ if adb_su "test ! -e $(shell_quote "$work_dir/afwall_dnsd.pid") && test ! -e $(shell_quote "$work_dir/afwall_dnsd_supervisor.pid") && test ! -e $(shell_quote "$work_dir/afwall_dnsd.heartbeat") && test ! -S $(shell_quote "$work_dir/afwall_dnsd.sock")"; then
+ pass "direct cleanup cleared transient daemon state files"
+ else
+ fail "direct cleanup left transient daemon state files behind"
+ fi
+ if adb_su "ps -A 2>/dev/null | grep -q '[a]fwall_dnsd'"; then
+ fail "daemon is still running after direct cleanup probe"
+ else
+ pass "daemon stopped after direct cleanup probe"
+ fi
+ check_redirect_rules_absent
+
+ supervisor_command "$work_dir" start > "$restore_log" 2>&1 || true
+ if wait_for_supervisor_ready "$work_dir"; then
+ pass "DNS service restored after direct cleanup probe"
+ check_redirect_rules
+ else
+ fail "DNS service did not restore after direct cleanup probe; see $restore_log"
+ fi
+}
+
+run_optional_recovery_checks() {
+ if [ "$DAEMON_RESTART_CHECK" -eq 1 ]; then
+ if [ "$failures" -eq 0 ]; then
+ run_daemon_restart_check
+ else
+ warn "skipping daemon restart check because earlier validation had failures"
+ fi
+ fi
+ if [ "$DAEMON_FAIL_OPEN_CHECK" -eq 1 ]; then
+ if [ "$failures" -eq 0 ]; then
+ run_daemon_fail_open_check
+ else
+ warn "skipping daemon fail-open check because earlier validation had failures"
+ fi
+ fi
+ if [ "$STALE_MARKER_CHECK" -eq 1 ]; then
+ if [ "$failures" -eq 0 ]; then
+ run_stale_marker_check
+ else
+ warn "skipping stale marker check because earlier validation had failures"
+ fi
+ fi
+ if [ "$EMBEDDED_CLEANUP_CHECK" -eq 1 ]; then
+ if [ "$failures" -eq 0 ]; then
+ run_embedded_cleanup_check
+ else
+ warn "skipping embedded cleanup check because earlier validation had failures"
+ fi
+ fi
+ if [ "$ORPHAN_SERVICE_CHECK" -eq 1 ]; then
+ if [ "$failures" -eq 0 ]; then
+ run_orphan_service_check
+ else
+ warn "skipping orphan service check because earlier validation had failures"
+ fi
+ fi
+ if [ "$DIRECT_CLEANUP_CHECK" -eq 1 ]; then
+ if [ "$failures" -eq 0 ]; then
+ run_direct_cleanup_check
+ else
+ warn "skipping direct cleanup check because earlier validation had failures"
+ fi
+ fi
+}
+
+run_runtime_checks() {
+ CURRENT_PHASE="$1"
+ local work_dir
+ if ! work_dir=$(detect_work_dir); then
+ fail "[$CURRENT_PHASE] AFWall DNS work directory not found; enable DNS protection and apply rules first"
+ return
+ fi
+ log "[$CURRENT_PHASE] Detected DNS work dir: $work_dir"
+ printf '%s\n' "$work_dir" > "$OUT_DIR/$CURRENT_PHASE-work-dir.txt"
+
+ check_module_state
+ check_work_dir_state "$work_dir"
+ check_daemon_process
+ check_redirect_rules
+ if [ "$ADB_DEBUG_CONTROL_CHECK" -eq 1 ]; then
+ check_adb_debug_control_allowed "$work_dir"
+ else
+ check_external_control_rejection "$work_dir"
+ fi
+ check_app_control_authorized "$work_dir"
+ check_query_activity "$work_dir"
+}
+
+wait_for_boot_completed() {
+ local deadline
+ local boot_completed
+ "$ADB" wait-for-device
+ deadline=$((SECONDS + BOOT_TIMEOUT_SECONDS))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ boot_completed=$("$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)
+ if [ "$boot_completed" = "1" ]; then
+ pass "device reported sys.boot_completed=1"
+ sleep "$POST_BOOT_SETTLE_SECONDS"
+ return
+ fi
+ sleep 2
+ done
+ fail "device did not complete boot within ${BOOT_TIMEOUT_SECONDS}s"
+ exit 1
+}
+
+run_reboot_check() {
+ CURRENT_PHASE="reboot"
+ log "Reboot validation requested; rebooting device now"
+ capture reboot-command.txt "$ADB" reboot
+ wait_for_boot_completed
+ check_root_and_magisk
+ run_runtime_checks postboot
+}
+
+main() {
+ log "Writing runtime evidence to $OUT_DIR"
+ single_device_check
+ install_apk_if_requested
+ check_root_and_magisk
+ run_runtime_checks preboot
+ run_optional_recovery_checks
+
+ if [ "$REBOOT_CHECK" -eq 1 ]; then
+ if [ "$failures" -eq 0 ]; then
+ run_reboot_check
+ else
+ warn "skipping reboot check because preboot validation had failures"
+ fi
+ fi
+
+ log "Validation complete: failures=$failures warnings=$warnings"
+ if [ "$failures" -ne 0 ]; then
+ exit 1
+ fi
+}
+
+main "$@"