From 411e4a9cb3df471845c8afb5e5cfd412076f3708 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Mon, 22 Jun 2026 17:24:11 +0000 Subject: [PATCH 1/2] feat(network): single-point DNS injection via Clean Architecture slice (PR A) Introduce org.iiab.controller.network slice (domain + data + JVM tests) and wire it as the SINGLE DNS injection point: - domain: DnsConfig (mixed primary/secondary, each IPv4 or IPv6), DnsValidator (fail-closed), ports DnsConfigRepository + ResolvConfWriter, use cases Get/Save/ApplyDns. Pure JVM, unit-tested. - data: PrefsDnsConfigRepository (new prefs keys; custom-vs-defaults for the 'Setup DNS' toggle) + FileResolvConfWriter (overwrites resolv.conf so the UI can change DNS dynamically; guards etc/). - wiring: PRootEngine.executeInContainer applies the effective DNS into the rootfs before every proot launch, so the scattered hardcoded writes in DeployFragment (fast-install, restore, bootstrap) + its helper are removed. MainActivity reset/clean-base uses libproot directly and keeps its inline write for now (the one documented exception). UI (Setup DNS panel) is PR B. --- .../org/iiab/controller/DeployFragment.java | 46 +--------- .../java/org/iiab/controller/PRootEngine.java | 17 ++++ .../network/data/FileResolvConfWriter.java | 58 +++++++++++++ .../data/PrefsDnsConfigRepository.java | 67 +++++++++++++++ .../network/domain/ApplyDnsUseCase.java | 32 +++++++ .../controller/network/domain/DnsConfig.java | 65 +++++++++++++++ .../network/domain/DnsConfigRepository.java | 35 ++++++++ .../network/domain/DnsValidator.java | 83 +++++++++++++++++++ .../network/domain/GetDnsConfigUseCase.java | 23 +++++ .../network/domain/ResolvConfWriter.java | 27 ++++++ .../network/domain/SaveDnsConfigUseCase.java | 31 +++++++ .../network/domain/ApplyDnsUseCaseTest.java | 45 ++++++++++ .../network/domain/DnsConfigTest.java | 43 ++++++++++ .../network/domain/DnsValidatorTest.java | 56 +++++++++++++ .../domain/SaveDnsConfigUseCaseTest.java | 38 +++++++++ 15 files changed, 624 insertions(+), 42 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/network/data/FileResolvConfWriter.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/data/PrefsDnsConfigRepository.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/domain/ApplyDnsUseCase.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfig.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfigRepository.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/domain/GetDnsConfigUseCase.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/domain/ResolvConfWriter.java create mode 100644 controller/app/src/main/java/org/iiab/controller/network/domain/SaveDnsConfigUseCase.java create mode 100644 controller/app/src/test/java/org/iiab/controller/network/domain/ApplyDnsUseCaseTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/network/domain/DnsConfigTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java create mode 100644 controller/app/src/test/java/org/iiab/controller/network/domain/SaveDnsConfigUseCaseTest.java diff --git a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java index 37233a2..35f1622 100644 --- a/controller/app/src/main/java/org/iiab/controller/DeployFragment.java +++ b/controller/app/src/main/java/org/iiab/controller/DeployFragment.java @@ -1096,9 +1096,8 @@ public void onComplete(String destDir) { } } - // Freshly fast-installed rootfs ships without resolv.conf; give it - // DNS before the companion-data (maps/kiwix) steps that need network. - ensureRuntimeNetworkConfig(debianRootfs); + // DNS is written at the single chokepoint (PRootEngine.executeInContainer), + // so the companion-data proot steps below get a working resolv.conf for free. if (chkCompanionData.isChecked()) { editLocalVarsForMaps(debianRootfs, safeTier); @@ -1307,16 +1306,8 @@ public void onComplete(String downloadPath) { // 4. BOOTSTRAP IIAB mainAct.runOnUiThread(() -> btnAdvancedReset.setText(getString(R.string.install_status_bootstrapping))); - File resolvConf = new File(debianRootfs, "etc/resolv.conf"); - if (resolvConf.exists()) resolvConf.delete(); - java.io.FileOutputStream fos = new java.io.FileOutputStream(resolvConf); - fos.write("nameserver 1.1.1.1\nnameserver 8.8.8.8\n".getBytes()); - fos.close(); - - File hostsFile = new File(debianRootfs, "etc/hosts"); - java.io.FileOutputStream fosH = new java.io.FileOutputStream(hostsFile); - fosH.write("127.0.0.1 localhost\n".getBytes()); - fosH.close(); + // DNS is written at the chokepoint (PRootEngine) before the + // bootstrap proot run below; no inline write needed here. if (prootEngine == null) prootEngine = new PRootEngine(); @@ -2147,8 +2138,6 @@ private void refreshRestoreButtonLogic() { tarExtractor.startExtraction(requireContext(), backupFile.getAbsolutePath(), iiabRootDir.getAbsolutePath(), new TarExtractor.ExtractionListener() { @Override public void onComplete(String destDir) { - // Restored artifact ships without resolv.conf; the app owns runtime DNS. - ensureRuntimeNetworkConfig(new File(iiabRootDir, "installed-rootfs/iiab")); mainAct.runOnUiThread(() -> { isRestoring = false; disableSystemProtection(); @@ -2175,33 +2164,6 @@ public void onError(String error) { } } - // Runtime network config for the proot guest: proot has no resolver daemon, and our build - // artifact ships WITHOUT /etc/resolv.conf on purpose (the app is the single owner of runtime - // network state). Mirrors the reset path. Called from fast-install and restore. - // TODO [tech-debt]: imperative side-effect inside DeployFragment (god-object); not Clean - // Architecture. Fold into the rootfs domain/data slice during the strangler refactor. - private void ensureRuntimeNetworkConfig(File debianRootfs) { - try { - File etc = new File(debianRootfs, "etc"); - if (!etc.isDirectory()) { - Log.w(TAG, "ensureRuntimeNetworkConfig: missing " + etc.getAbsolutePath() + " - skipping DNS write"); - return; - } - File resolvConf = new File(etc, "resolv.conf"); - if (resolvConf.exists()) resolvConf.delete(); - try (java.io.FileOutputStream fos = new java.io.FileOutputStream(resolvConf)) { - fos.write("nameserver 1.1.1.1\nnameserver 8.8.8.8\n".getBytes()); - } - File hostsFile = new File(etc, "hosts"); - try (java.io.FileOutputStream fosH = new java.io.FileOutputStream(hostsFile)) { - fosH.write("127.0.0.1 localhost\n".getBytes()); - } - Log.i(TAG, "ensureRuntimeNetworkConfig: wrote resolv.conf + hosts under " + etc.getAbsolutePath()); - } catch (Exception e) { - Log.w(TAG, "ensureRuntimeNetworkConfig failed", e); - } - } - private void importBackupSafely(Uri sourceUri) { isImporting = true; updateDynamicButtons(); diff --git a/controller/app/src/main/java/org/iiab/controller/PRootEngine.java b/controller/app/src/main/java/org/iiab/controller/PRootEngine.java index 0e5ce99..c7ec999 100644 --- a/controller/app/src/main/java/org/iiab/controller/PRootEngine.java +++ b/controller/app/src/main/java/org/iiab/controller/PRootEngine.java @@ -20,6 +20,10 @@ import java.util.ArrayList; import java.util.List; +import org.iiab.controller.network.data.FileResolvConfWriter; +import org.iiab.controller.network.data.PrefsDnsConfigRepository; +import org.iiab.controller.network.domain.ApplyDnsUseCase; + public class PRootEngine { private static final String TAG = "IIAB-PRootEngine"; private Process currentProcess; @@ -44,6 +48,19 @@ public void executeInContainer(Context context, String rootfsDir, String command throw new Exception("libproot.so not found in native library directory!"); } + // Single DNS injection point: every proot launch passes through here, so we write + // the effective DNS (user's custom config when enabled, else defaults) into the + // guest's resolv.conf BEFORE launching. Replaces the scattered writes that used to + // live in DeployFragment / MainActivity. + try { + new ApplyDnsUseCase( + new PrefsDnsConfigRepository(context), + new FileResolvConfWriter() + ).execute(new File(rootfsDir)); + } catch (Exception dnsEx) { + Log.w(TAG, "DNS apply skipped: " + dnsEx.getMessage()); + } + // ========================================================= // THE W^X TROJAN HORSE: FAKE TERMUX PREFIX // ========================================================= diff --git a/controller/app/src/main/java/org/iiab/controller/network/data/FileResolvConfWriter.java b/controller/app/src/main/java/org/iiab/controller/network/data/FileResolvConfWriter.java new file mode 100644 index 0000000..5c8fa44 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/data/FileResolvConfWriter.java @@ -0,0 +1,58 @@ +/* + * ============================================================================ + * Name : FileResolvConfWriter.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Writes resolv.conf (+ minimal hosts) into a rootfs; migrates the #25 helper logic. + * ============================================================================ + */ +package org.iiab.controller.network.data; + +import android.util.Log; + +import org.iiab.controller.network.domain.DnsConfig; +import org.iiab.controller.network.domain.ResolvConfWriter; + +import java.io.File; +import java.io.FileOutputStream; +import java.nio.charset.StandardCharsets; + +/** + * {@link ResolvConfWriter} that writes {@code etc/resolv.conf} (and a minimal + * {@code etc/hosts} if absent) into a guest rootfs. This is the single home for the + * DNS-write logic previously duplicated in {@code DeployFragment}/{@code MainActivity}. + * Guards on {@code etc/} existence and never throws. + */ +public final class FileResolvConfWriter implements ResolvConfWriter { + + private static final String TAG = "IIAB-DNS"; + + @Override + public boolean write(DnsConfig config, File rootfsDir) { + try { + File etc = new File(rootfsDir, "etc"); + if (!etc.isDirectory()) { + Log.w(TAG, "resolv.conf skipped: missing " + etc.getAbsolutePath()); + return false; + } + File resolv = new File(etc, "resolv.conf"); + if (resolv.exists() && !resolv.delete()) { + Log.w(TAG, "could not remove old resolv.conf at " + resolv.getAbsolutePath()); + } + try (FileOutputStream fos = new FileOutputStream(resolv)) { + fos.write(config.toResolvConf().getBytes(StandardCharsets.UTF_8)); + } + File hosts = new File(etc, "hosts"); + if (!hosts.exists()) { + try (FileOutputStream fos = new FileOutputStream(hosts)) { + fos.write("127.0.0.1 localhost\n".getBytes(StandardCharsets.UTF_8)); + } + } + Log.i(TAG, "wrote resolv.conf (" + config + ") under " + etc.getAbsolutePath()); + return true; + } catch (Exception e) { + Log.w(TAG, "FileResolvConfWriter.write failed", e); + return false; + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/data/PrefsDnsConfigRepository.java b/controller/app/src/main/java/org/iiab/controller/network/data/PrefsDnsConfigRepository.java new file mode 100644 index 0000000..cbf165f --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/data/PrefsDnsConfigRepository.java @@ -0,0 +1,67 @@ +/* + * ============================================================================ + * Name : PrefsDnsConfigRepository.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : SharedPreferences-backed DnsConfigRepository (new keys; not the vestigial VPN ones). + * ============================================================================ + */ +package org.iiab.controller.network.data; + +import android.content.Context; +import android.content.SharedPreferences; + +import org.iiab.controller.network.domain.DnsConfig; +import org.iiab.controller.network.domain.DnsConfigRepository; + +/** + * {@link DnsConfigRepository} backed by a dedicated SharedPreferences file. Uses + * fresh keys (NOT the vestigial VPN-era {@code DnsIpv4}/{@code DnsIpv6} in + * {@code Preferences}, which this feature will retire). {@code MODE_MULTI_PROCESS} + * matches the rest of the app. + */ +public final class PrefsDnsConfigRepository implements DnsConfigRepository { + + private static final String PREFS = "NetworkDnsPrefs"; + private static final String K_CUSTOM = "dns.custom.enabled"; + private static final String K_PRIMARY = "dns.primary"; + private static final String K_SECONDARY = "dns.secondary"; + + private final SharedPreferences prefs; + + public PrefsDnsConfigRepository(Context context) { + this.prefs = context.getSharedPreferences(PREFS, Context.MODE_MULTI_PROCESS); + } + + @Override + public DnsConfig loadEffective() { + return isCustomEnabled() ? loadCustom() : DnsConfig.defaults(); + } + + @Override + public DnsConfig loadCustom() { + DnsConfig d = DnsConfig.defaults(); + String primary = prefs.getString(K_PRIMARY, d.primary()); + String secondary = prefs.getString(K_SECONDARY, d.secondary()); + return new DnsConfig(primary, secondary); + } + + @Override + public boolean isCustomEnabled() { + return prefs.getBoolean(K_CUSTOM, false); + } + + @Override + public void saveCustom(DnsConfig config) { + prefs.edit() + .putBoolean(K_CUSTOM, true) + .putString(K_PRIMARY, config.primary()) + .putString(K_SECONDARY, config.secondary()) + .commit(); + } + + @Override + public void disableCustom() { + prefs.edit().putBoolean(K_CUSTOM, false).commit(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/ApplyDnsUseCase.java b/controller/app/src/main/java/org/iiab/controller/network/domain/ApplyDnsUseCase.java new file mode 100644 index 0000000..66a56c5 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/ApplyDnsUseCase.java @@ -0,0 +1,32 @@ +/* + * ============================================================================ + * Name : ApplyDnsUseCase.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Use case: write the effective DNS config into a rootfs (boot-time apply). + * ============================================================================ + */ +package org.iiab.controller.network.domain; + +import java.io.File; + +/** + * Writes the effective DNS config (custom or defaults) into a guest rootfs. Called + * at server boot (and wherever a rootfs is about to run network operations) so the + * proot guest always has a working {@code resolv.conf} without a resolver daemon. + */ +public final class ApplyDnsUseCase { + + private final DnsConfigRepository repository; + private final ResolvConfWriter writer; + + public ApplyDnsUseCase(DnsConfigRepository repository, ResolvConfWriter writer) { + this.repository = repository; + this.writer = writer; + } + + /** @return {@code true} if resolv.conf was written into {@code rootfsDir}. */ + public boolean execute(File rootfsDir) { + return writer.write(repository.loadEffective(), rootfsDir); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfig.java b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfig.java new file mode 100644 index 0000000..eba810e --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfig.java @@ -0,0 +1,65 @@ +/* + * ============================================================================ + * Name : DnsConfig.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : DNS configuration: two mixed slots (primary/secondary), each one IPv4 or IPv6. + * ============================================================================ + */ +package org.iiab.controller.network.domain; + +/** + * Immutable DNS configuration: a {@code primary} and an optional {@code secondary} + * nameserver. Each slot holds a SINGLE address that may be IPv4 or IPv6 + * (mixed allowed in any order); no comma-separated lists. This keeps the model and + * validation simple while covering every combination the user might want. + * + *

Pure domain value object (no Android, no I/O). {@link #defaults()} is the + * preconfigured set the app ships with, so the user never has to configure anything. + */ +public final class DnsConfig { + + private final String primary; + private final String secondary; + + public DnsConfig(String primary, String secondary) { + this.primary = norm(primary); + this.secondary = norm(secondary); + } + + /** Preconfigured defaults: Cloudflare (primary) + Google (secondary), both IPv4. */ + public static DnsConfig defaults() { + return new DnsConfig("1.1.1.1", "8.8.8.8"); + } + + /** The primary nameserver (IPv4 or IPv6); "" if unset. */ + public String primary() { return primary; } + + /** The secondary nameserver (IPv4 or IPv6); "" if unset. */ + public String secondary() { return secondary; } + + public boolean hasSecondary() { return !secondary.isEmpty(); } + + public boolean isEmpty() { return primary.isEmpty() && secondary.isEmpty(); } + + /** Body for {@code /etc/resolv.conf}: one {@code nameserver X} line per non-empty slot. */ + public String toResolvConf() { + StringBuilder sb = new StringBuilder(); + if (!primary.isEmpty()) sb.append("nameserver ").append(primary).append("\n"); + if (!secondary.isEmpty()) sb.append("nameserver ").append(secondary).append("\n"); + return sb.toString(); + } + + private static String norm(String s) { return s == null ? "" : s.trim(); } + + @Override public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof DnsConfig)) return false; + DnsConfig d = (DnsConfig) o; + return primary.equals(d.primary) && secondary.equals(d.secondary); + } + + @Override public int hashCode() { return 31 * primary.hashCode() + secondary.hashCode(); } + + @Override public String toString() { return "DnsConfig{primary=" + primary + ", secondary=" + secondary + "}"; } +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfigRepository.java b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfigRepository.java new file mode 100644 index 0000000..69255ad --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsConfigRepository.java @@ -0,0 +1,35 @@ +/* + * ============================================================================ + * Name : DnsConfigRepository.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain port: persistence of the user's DNS choice. + * ============================================================================ + */ +package org.iiab.controller.network.domain; + +/** + * Domain port for persisting the user's DNS choice. The Data layer backs this with + * SharedPreferences. Semantics support the "Setup DNS" checkbox: + *

+ */ +public interface DnsConfigRepository { + + /** Config to actually apply: saved custom when enabled, otherwise {@link DnsConfig#defaults()}. */ + DnsConfig loadEffective(); + + /** The saved custom config (or {@link DnsConfig#defaults()} if none saved yet). */ + DnsConfig loadCustom(); + + /** Whether the user enabled custom DNS (the "Setup DNS" check). */ + boolean isCustomEnabled(); + + /** Persist {@code config} as the custom config and mark custom DNS enabled. */ + void saveCustom(DnsConfig config); + + /** Turn custom DNS off (revert to defaults). Used on test failure / unchecking. */ + void disableCustom(); +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java new file mode 100644 index 0000000..1556bd0 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java @@ -0,0 +1,83 @@ +/* + * ============================================================================ + * Name : DnsValidator.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Fail-closed validation: each DNS slot is a single valid IPv4 or IPv6 literal. + * ============================================================================ + */ +package org.iiab.controller.network.domain; + +/** + * Pure (framework-free) validation of {@link DnsConfig}. Each slot must be a single + * valid IP literal, IPv4 or IPv6 (mixed allowed). Fail-closed: anything not + * clearly a valid address is rejected. Mirrors {@code sync.domain.SyncCredentialValidator}. + * + *

IPv6 validation is pragmatic (hextet shape + at most one {@code ::}); it does not + * accept embedded IPv4 or zone ids, which are not needed here. + */ +public final class DnsValidator { + + private DnsValidator() { } + + public static final class Result { + public final boolean valid; + public final String reason; + private Result(boolean valid, String reason) { this.valid = valid; this.reason = reason; } + public static Result ok() { return new Result(true, null); } + public static Result fail(String reason) { return new Result(false, reason); } + } + + /** Primary is required and must be a valid IP; secondary is optional but if set must be valid. */ + public static Result validate(DnsConfig config) { + if (config == null) return Result.fail("null config"); + if (config.primary().isEmpty()) return Result.fail("primary DNS is required"); + if (!isValidIp(config.primary())) return Result.fail("invalid primary DNS address"); + if (config.hasSecondary() && !isValidIp(config.secondary())) { + return Result.fail("invalid secondary DNS address"); + } + return Result.ok(); + } + + /** A single address is valid if it parses as IPv4 OR IPv6. */ + public static boolean isValidIp(String s) { + return isValidIpv4(s) || isValidIpv6(s); + } + + public static boolean isValidIpv4(String s) { + if (s == null) return false; + String[] parts = s.split("\\.", -1); + if (parts.length != 4) return false; + for (String part : parts) { + if (part.isEmpty() || part.length() > 3) return false; + for (int i = 0; i < part.length(); i++) { + if (!Character.isDigit(part.charAt(i))) return false; + } + if (part.length() > 1 && part.charAt(0) == '0') return false; + if (Integer.parseInt(part) > 255) return false; + } + return true; + } + + public static boolean isValidIpv6(String s) { + if (s == null || s.isEmpty()) return false; + if (s.contains(":::")) return false; + int firstDouble = s.indexOf("::"); + boolean compressed = firstDouble >= 0; + if (compressed && s.indexOf("::", firstDouble + 1) >= 0) return false; + String[] groups = s.split(":", -1); + int hextets = 0; + for (String g : groups) { + if (g.isEmpty()) continue; + if (g.length() > 4) return false; + for (int i = 0; i < g.length(); i++) { + char c = g.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) return false; + } + hextets++; + } + if (compressed) return hextets <= 7; + return hextets == 8; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/GetDnsConfigUseCase.java b/controller/app/src/main/java/org/iiab/controller/network/domain/GetDnsConfigUseCase.java new file mode 100644 index 0000000..1ab4818 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/GetDnsConfigUseCase.java @@ -0,0 +1,23 @@ +/* + * ============================================================================ + * Name : GetDnsConfigUseCase.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Use case: read the effective DNS config (custom or defaults). + * ============================================================================ + */ +package org.iiab.controller.network.domain; + +/** Returns the DNS config the system should use right now (custom when enabled, else defaults). */ +public final class GetDnsConfigUseCase { + + private final DnsConfigRepository repository; + + public GetDnsConfigUseCase(DnsConfigRepository repository) { + this.repository = repository; + } + + public DnsConfig execute() { + return repository.loadEffective(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/ResolvConfWriter.java b/controller/app/src/main/java/org/iiab/controller/network/domain/ResolvConfWriter.java new file mode 100644 index 0000000..7e860d3 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/ResolvConfWriter.java @@ -0,0 +1,27 @@ +/* + * ============================================================================ + * Name : ResolvConfWriter.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain port: write resolv.conf (+ hosts) into a rootfs. + * ============================================================================ + */ +package org.iiab.controller.network.domain; + +import java.io.File; + +/** + * Domain port that writes a {@link DnsConfig} into a guest rootfs as + * {@code etc/resolv.conf} (plus a minimal {@code etc/hosts}). The Data layer + * implements the actual file I/O and logging. Implementations must never throw. + */ +public interface ResolvConfWriter { + + /** + * Write {@code config} into {@code rootfsDir}/etc. + * + * @return {@code true} if written; {@code false} if skipped (e.g. {@code etc/} missing) + * or on a handled error. + */ + boolean write(DnsConfig config, File rootfsDir); +} diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/SaveDnsConfigUseCase.java b/controller/app/src/main/java/org/iiab/controller/network/domain/SaveDnsConfigUseCase.java new file mode 100644 index 0000000..0a5327f --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/SaveDnsConfigUseCase.java @@ -0,0 +1,31 @@ +/* + * ============================================================================ + * Name : SaveDnsConfigUseCase.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Use case: validate then persist a user-entered DNS config. + * ============================================================================ + */ +package org.iiab.controller.network.domain; + +/** + * Validates a user-entered {@link DnsConfig} and, only if valid, persists it as the + * custom config (enabling custom DNS). Returns the validation {@link DnsValidator.Result} + * so the UI can show why it was rejected. + */ +public final class SaveDnsConfigUseCase { + + private final DnsConfigRepository repository; + + public SaveDnsConfigUseCase(DnsConfigRepository repository) { + this.repository = repository; + } + + public DnsValidator.Result execute(DnsConfig config) { + DnsValidator.Result result = DnsValidator.validate(config); + if (result.valid) { + repository.saveCustom(config); + } + return result; + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/network/domain/ApplyDnsUseCaseTest.java b/controller/app/src/test/java/org/iiab/controller/network/domain/ApplyDnsUseCaseTest.java new file mode 100644 index 0000000..ea11eb5 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/network/domain/ApplyDnsUseCaseTest.java @@ -0,0 +1,45 @@ +package org.iiab.controller.network.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.io.File; + +import org.junit.Test; + +/** Apply writes whatever loadEffective() returns, and propagates the writer result. */ +public class ApplyDnsUseCaseTest { + + private static final class FakeRepo implements DnsConfigRepository { + private final DnsConfig effective; + FakeRepo(DnsConfig effective) { this.effective = effective; } + @Override public DnsConfig loadEffective() { return effective; } + @Override public DnsConfig loadCustom() { return effective; } + @Override public boolean isCustomEnabled() { return false; } + @Override public void saveCustom(DnsConfig c) { } + @Override public void disableCustom() { } + } + + private static final class FakeWriter implements ResolvConfWriter { + DnsConfig got; + File dir; + private final boolean ret; + FakeWriter(boolean ret) { this.ret = ret; } + @Override public boolean write(DnsConfig config, File rootfsDir) { got = config; dir = rootfsDir; return ret; } + } + + @Test public void writesEffectiveConfig() { + DnsConfig eff = DnsConfig.defaults(); + FakeWriter w = new FakeWriter(true); + boolean ok = new ApplyDnsUseCase(new FakeRepo(eff), w).execute(new File("/tmp/rootfs")); + assertTrue(ok); + assertEquals(eff, w.got); + assertEquals(new File("/tmp/rootfs"), w.dir); + } + + @Test public void propagatesWriterFailure() { + boolean ok = new ApplyDnsUseCase(new FakeRepo(DnsConfig.defaults()), new FakeWriter(false)).execute(new File("/x")); + assertFalse(ok); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/network/domain/DnsConfigTest.java b/controller/app/src/test/java/org/iiab/controller/network/domain/DnsConfigTest.java new file mode 100644 index 0000000..bdf206c --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/network/domain/DnsConfigTest.java @@ -0,0 +1,43 @@ +package org.iiab.controller.network.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Pure-JVM tests for the DnsConfig value object. */ +public class DnsConfigTest { + + @Test public void defaultsArePrimaryAndSecondary() { + DnsConfig d = DnsConfig.defaults(); + assertEquals("1.1.1.1", d.primary()); + assertEquals("8.8.8.8", d.secondary()); + assertTrue(d.hasSecondary()); + assertFalse(d.isEmpty()); + } + + @Test public void resolvConfHasOneLinePerSlot() { + assertEquals("nameserver 1.1.1.1\nnameserver 8.8.8.8\n", DnsConfig.defaults().toResolvConf()); + } + + @Test public void primaryOnlyOmitsSecondaryLine() { + DnsConfig d = new DnsConfig("9.9.9.9", ""); + assertFalse(d.hasSecondary()); + assertEquals("nameserver 9.9.9.9\n", d.toResolvConf()); + } + + @Test public void trimsAndTreatsNullAsEmpty() { + DnsConfig d = new DnsConfig(" 1.1.1.1 ", null); + assertEquals("1.1.1.1", d.primary()); + assertEquals("", d.secondary()); + } + + @Test public void equalsByValue() { + assertEquals(DnsConfig.defaults(), new DnsConfig("1.1.1.1", "8.8.8.8")); + } + + @Test public void emptyWhenBothBlank() { + assertTrue(new DnsConfig("", " ").isEmpty()); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java b/controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java new file mode 100644 index 0000000..86faa1e --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java @@ -0,0 +1,56 @@ +package org.iiab.controller.network.domain; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Pure-JVM tests for DNS address validation (IPv4 or IPv6 per slot). */ +public class DnsValidatorTest { + + @Test public void acceptsIpv4() { + assertTrue(DnsValidator.isValidIpv4("8.8.8.8")); + assertTrue(DnsValidator.isValidIpv4("1.1.1.1")); + } + + @Test public void rejectsBadIpv4() { + assertFalse(DnsValidator.isValidIpv4("aaa.bbb.ccc")); + assertFalse(DnsValidator.isValidIpv4("256.0.0.1")); + assertFalse(DnsValidator.isValidIpv4("1.1.1")); + assertFalse(DnsValidator.isValidIpv4("01.1.1.1")); + assertFalse(DnsValidator.isValidIpv4("1.1.1.1.1")); + } + + @Test public void acceptsIpv6() { + assertTrue(DnsValidator.isValidIpv6("2001:4860:4860::8888")); + assertTrue(DnsValidator.isValidIpv6("::1")); + assertTrue(DnsValidator.isValidIpv6("fe80::1")); + } + + @Test public void rejectsBadIpv6() { + assertFalse(DnsValidator.isValidIpv6("xxxx::yyyy")); + assertFalse(DnsValidator.isValidIpv6("2001:::1")); + assertFalse(DnsValidator.isValidIpv6("12345::")); + assertFalse(DnsValidator.isValidIpv6("gggg::")); + } + + @Test public void mixedSlotsAllowedEitherOrder() { + assertTrue(DnsValidator.validate(new DnsConfig("1.1.1.1", "2001:4860:4860::8888")).valid); + assertTrue(DnsValidator.validate(new DnsConfig("2001:4860:4860::8888", "8.8.8.8")).valid); + } + + @Test public void primaryRequired() { + assertFalse(DnsValidator.validate(new DnsConfig("", "8.8.8.8")).valid); + } + + @Test public void secondaryOptional() { + assertTrue(DnsValidator.validate(new DnsConfig("1.1.1.1", "")).valid); + } + + @Test public void garbageRejected() { + DnsValidator.Result r = DnsValidator.validate(new DnsConfig("aaa.bbb.ccc", "xxx.yyy.zzz")); + assertFalse(r.valid); + assertNotNull(r.reason); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/network/domain/SaveDnsConfigUseCaseTest.java b/controller/app/src/test/java/org/iiab/controller/network/domain/SaveDnsConfigUseCaseTest.java new file mode 100644 index 0000000..3b8ba2e --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/network/domain/SaveDnsConfigUseCaseTest.java @@ -0,0 +1,38 @@ +package org.iiab.controller.network.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Save validates first: valid configs are persisted+enabled; invalid ones are not. */ +public class SaveDnsConfigUseCaseTest { + + private static final class FakeRepo implements DnsConfigRepository { + DnsConfig saved; + boolean enabled; + @Override public DnsConfig loadEffective() { return enabled && saved != null ? saved : DnsConfig.defaults(); } + @Override public DnsConfig loadCustom() { return saved != null ? saved : DnsConfig.defaults(); } + @Override public boolean isCustomEnabled() { return enabled; } + @Override public void saveCustom(DnsConfig c) { saved = c; enabled = true; } + @Override public void disableCustom() { enabled = false; } + } + + @Test public void validConfigIsSavedAndEnabled() { + FakeRepo repo = new FakeRepo(); + DnsValidator.Result r = new SaveDnsConfigUseCase(repo).execute(new DnsConfig("1.1.1.1", "8.8.8.8")); + assertTrue(r.valid); + assertEquals(new DnsConfig("1.1.1.1", "8.8.8.8"), repo.saved); + assertTrue(repo.enabled); + } + + @Test public void invalidConfigIsNotSaved() { + FakeRepo repo = new FakeRepo(); + DnsValidator.Result r = new SaveDnsConfigUseCase(repo).execute(new DnsConfig("aaa.bbb.ccc", "xxx.yyy.zzz")); + assertFalse(r.valid); + assertNull(repo.saved); + assertFalse(repo.enabled); + } +} From b3fb0e3ca27f07b8ca8cb2d0cd11aa615f47ef66 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Mon, 22 Jun 2026 21:58:53 +0000 Subject: [PATCH 2/2] fix(network): reject loopback/unspecified DNS (fixes 127.0.0.1 failure) A DNS server can't be the guest itself: 127.0.0.0/8 and ::1 (loopback) and 0.0.0.0 / :: (unspecified) are now rejected by DnsValidator with a clear reason, so the UI shows 'invalid' instead of silently failing/'jumping' when the user enters 127.0.0.1 (it never reaches the probe or gets applied). Unit-tested. --- .../network/domain/DnsValidator.java | 33 ++++++++++++++++--- .../network/domain/DnsValidatorTest.java | 12 +++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java index 1556bd0..e482e33 100644 --- a/controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java +++ b/controller/app/src/main/java/org/iiab/controller/network/domain/DnsValidator.java @@ -28,17 +28,42 @@ public static final class Result { public static Result fail(String reason) { return new Result(false, reason); } } - /** Primary is required and must be a valid IP; secondary is optional but if set must be valid. */ + /** + * Primary is required; secondary optional. Each must be a valid IP that is a USABLE + * DNS server: loopback (127.0.0.0/8, ::1) and unspecified (0.0.0.0, ::) are rejected, + * since a DNS server can't be the guest itself (and probing it would just fail). + */ public static Result validate(DnsConfig config) { if (config == null) return Result.fail("null config"); if (config.primary().isEmpty()) return Result.fail("primary DNS is required"); - if (!isValidIp(config.primary())) return Result.fail("invalid primary DNS address"); - if (config.hasSecondary() && !isValidIp(config.secondary())) { - return Result.fail("invalid secondary DNS address"); + String p = checkServer(config.primary()); + if (p != null) return Result.fail("primary DNS " + p); + if (config.hasSecondary()) { + String sec = checkServer(config.secondary()); + if (sec != null) return Result.fail("secondary DNS " + sec); } return Result.ok(); } + /** @return null if {@code s} is a usable DNS server, otherwise a short reason. */ + private static String checkServer(String s) { + if (!isValidIp(s)) return "is not a valid IP address"; + if (isLoopbackOrUnspecified(s)) return "can't be a loopback or unspecified address"; + return null; + } + + /** A DNS server can't be loopback (the guest itself) or the unspecified address. */ + public static boolean isLoopbackOrUnspecified(String s) { + if (s == null) return false; + String v = s.trim(); + if (isValidIpv4(v)) { + return v.startsWith("127.") || v.equals("0.0.0.0"); + } + String low = v.toLowerCase(); + return low.equals("::1") || low.equals("0:0:0:0:0:0:0:1") + || low.equals("::") || low.equals("0:0:0:0:0:0:0:0"); + } + /** A single address is valid if it parses as IPv4 OR IPv6. */ public static boolean isValidIp(String s) { return isValidIpv4(s) || isValidIpv6(s); diff --git a/controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java b/controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java index 86faa1e..a0eebcf 100644 --- a/controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java +++ b/controller/app/src/test/java/org/iiab/controller/network/domain/DnsValidatorTest.java @@ -48,6 +48,18 @@ public class DnsValidatorTest { assertTrue(DnsValidator.validate(new DnsConfig("1.1.1.1", "")).valid); } + @Test public void rejectsLoopbackAndUnspecified() { + assertTrue(DnsValidator.isLoopbackOrUnspecified("127.0.0.1")); + assertTrue(DnsValidator.isLoopbackOrUnspecified("127.0.0.53")); + assertTrue(DnsValidator.isLoopbackOrUnspecified("0.0.0.0")); + assertTrue(DnsValidator.isLoopbackOrUnspecified("::1")); + assertTrue(DnsValidator.isLoopbackOrUnspecified("::")); + assertFalse(DnsValidator.isLoopbackOrUnspecified("1.1.1.1")); + assertFalse(DnsValidator.isLoopbackOrUnspecified("2001:4860:4860::8888")); + assertFalse(DnsValidator.validate(new DnsConfig("127.0.0.1", "")).valid); + assertFalse(DnsValidator.validate(new DnsConfig("8.8.8.8", "::1")).valid); + } + @Test public void garbageRejected() { DnsValidator.Result r = DnsValidator.validate(new DnsConfig("aaa.bbb.ccc", "xxx.yyy.zzz")); assertFalse(r.valid);