Skip to content

Commit da8da50

Browse files
Add setup readiness checks
Add a pre-connect setup readiness action that checks required profile fields, resolver input, local port availability, LAN proxy safety rules, and advisory server address lookup before the user starts a connection. Expose resolver test parallelism presets for 32, 64, 100, and 128 worker budgets while keeping custom expert values available. Add model tests for setup validation, SOCKS credential normalization, resolver parallelism presets, and multi-domain normalization.
1 parent ed87d51 commit da8da50

5 files changed

Lines changed: 662 additions & 24 deletions

File tree

app/src/main/java/shop/whitedns/client/MainActivity.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ class MainActivity : ComponentActivity() {
123123
ConnectionStatus.CONNECTED -> viewModel.disconnect()
124124
}
125125
},
126+
onTestProfileClick = viewModel::testCurrentProfileReadiness,
126127
onScanFileSelected = viewModel::beginScanFromFile,
127128
onScanDefaultListSelected = viewModel::beginScanFromDefaultResolvers,
128129
onScanStartClick = viewModel::startPreparedScan,

app/src/main/java/shop/whitedns/client/model/WhiteDnsModels.kt

Lines changed: 206 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,30 @@ data class ResolverTextValidation(
224224
get() = normalizedResolvers.isNotEmpty() && invalidEntries.isEmpty()
225225
}
226226

227+
object WhiteDnsValidationSeverity {
228+
const val Fatal = "fatal"
229+
const val Warning = "warning"
230+
}
231+
232+
data class WhiteDnsValidationIssue(
233+
val severity: String,
234+
val field: String,
235+
val message: String,
236+
)
237+
238+
data class WhiteDnsSettingsValidation(
239+
val issues: List<WhiteDnsValidationIssue>,
240+
) {
241+
val fatalIssues: List<WhiteDnsValidationIssue>
242+
get() = issues.filter { it.severity == WhiteDnsValidationSeverity.Fatal }
243+
244+
val warnings: List<WhiteDnsValidationIssue>
245+
get() = issues.filter { it.severity == WhiteDnsValidationSeverity.Warning }
246+
247+
val canConnect: Boolean
248+
get() = fatalIssues.isEmpty()
249+
}
250+
227251
data class WhiteDnsSettings(
228252
val selectedConnectionProfileId: String = ConnectionProfile.DefaultId,
229253
val connectionProfiles: List<ConnectionProfile> = listOf(ConnectionProfile.defaultProfile()),
@@ -513,6 +537,7 @@ data class WhiteDnsUiState(
513537
val connectionStats: ConnectionStats = ConnectionStats(),
514538
val resolverRuntimeState: ResolverRuntimeState = ResolverRuntimeState(),
515539
val connectionProgress: ConnectionProgressState = ConnectionProgressState(),
540+
val profileReadiness: ConnectionVerificationState = ConnectionVerificationState(),
516541
val connectionVerification: ConnectionVerificationState = ConnectionVerificationState(),
517542
val autoTuneTrialResults: List<AutoTuneTrialResult> = emptyList(),
518543
val scanState: WhiteDnsScanState = WhiteDnsScanState(),
@@ -534,12 +559,21 @@ object WhiteDnsOptions {
534559
const val SplitTunnelModeOff = "off"
535560
const val SplitTunnelModeInclude = "include"
536561
const val SplitTunnelModeExclude = "exclude"
562+
const val ResolverTestParallelismCustom = "custom"
537563

538564
val connectionModes = listOf(
539565
Choice("proxy", "Proxy Mode"),
540566
Choice("vpn", "Full VPN"),
541567
)
542568

569+
val resolverTestParallelismPresets = listOf(
570+
Choice("32", "32 - Light"),
571+
Choice("64", "64 - Balanced"),
572+
Choice("100", "100 - Current"),
573+
Choice("128", "128 - Deep"),
574+
Choice(ResolverTestParallelismCustom, "Custom"),
575+
)
576+
543577
val themeModes = listOf(
544578
Choice(WhiteDnsThemeMode.System, "Auto"),
545579
Choice(WhiteDnsThemeMode.Light, "Light"),
@@ -603,6 +637,14 @@ object WhiteDnsOptions {
603637
fun splitTunnelModeLabel(mode: String): String {
604638
return splitTunnelModes.firstOrNull { it.value == mode }?.label ?: "All Apps"
605639
}
640+
641+
fun resolverTestParallelismPreset(value: String): String {
642+
val trimmed = value.trim()
643+
return resolverTestParallelismPresets
644+
.firstOrNull { choice -> choice.value == trimmed }
645+
?.value
646+
?: ResolverTestParallelismCustom
647+
}
606648
}
607649

608650
fun WhiteDnsSettings.normalizedConnectionProfiles(): List<ConnectionProfile> {
@@ -1458,8 +1500,10 @@ private enum class ResolverTextEntryType {
14581500
}
14591501

14601502
private const val DefaultResolverPort = 53
1503+
private const val MaxRecommendedResolvers = 256
14611504

14621505
private val ResolverIpv6Chars = Regex("^[0-9A-Fa-f:.]+$")
1506+
private val ServerDomainLabelRegex = Regex("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
14631507

14641508
private fun normalizeSplitTunnelMode(raw: String): String {
14651509
return when (raw) {
@@ -1487,6 +1531,24 @@ private fun normalizeThemeMode(raw: String): String {
14871531
}
14881532
}
14891533

1534+
fun normalizeServerDomainText(raw: String): String {
1535+
return normalizeServerDomains(raw).joinToString(separator = "\n")
1536+
}
1537+
1538+
fun normalizeServerDomains(raw: String): List<String> {
1539+
return raw
1540+
.replace("[", " ")
1541+
.replace("]", " ")
1542+
.replace("\"", " ")
1543+
.replace("'", " ")
1544+
.split(Regex("[,;\\s]+"))
1545+
.asSequence()
1546+
.map { it.trim().trimEnd('.') }
1547+
.filter(String::isNotEmpty)
1548+
.distinct()
1549+
.toList()
1550+
}
1551+
14901552
private fun normalizePackageNames(raw: List<String>): List<String> {
14911553
return raw
14921554
.asSequence()
@@ -1497,6 +1559,142 @@ private fun normalizePackageNames(raw: List<String>): List<String> {
14971559
.toList()
14981560
}
14991561

1562+
fun validateConnectionSettings(settings: WhiteDnsSettings): WhiteDnsSettingsValidation {
1563+
val normalizedSettings = settings.syncSelectedConnectionProfileFields()
1564+
val runtimeSettings = normalizedSettings.runtimeConnectionSettings()
1565+
val resolvedSettings = runtimeSettings.resolve()
1566+
val connectionProfile = normalizedSettings.selectedConnectionProfile()
1567+
val resolverValidation = validateResolverText(runtimeSettings.resolverText)
1568+
val issues = mutableListOf<WhiteDnsValidationIssue>()
1569+
1570+
fun fatal(field: String, message: String) {
1571+
issues += WhiteDnsValidationIssue(WhiteDnsValidationSeverity.Fatal, field, message)
1572+
}
1573+
1574+
fun warning(field: String, message: String) {
1575+
issues += WhiteDnsValidationIssue(WhiteDnsValidationSeverity.Warning, field, message)
1576+
}
1577+
1578+
val serverDomains = normalizeServerDomains(connectionProfile.customServerDomain)
1579+
if (serverDomains.isEmpty() || connectionProfile.customServerEncryptionKey.isBlank()) {
1580+
fatal("server", "Custom StormDNS domain and encryption key are required")
1581+
} else if (serverDomains.any { !isValidServerDomain(it) }) {
1582+
fatal("server", "One or more custom StormDNS domains are not valid")
1583+
}
1584+
1585+
if (resolverValidation.normalizedResolvers.isEmpty()) {
1586+
fatal("resolvers", "Resolvers are required to connect")
1587+
}
1588+
if (resolverValidation.invalidEntries.isNotEmpty()) {
1589+
fatal("resolvers", "Resolver list contains invalid entries")
1590+
}
1591+
if (resolverValidation.normalizedResolvers.size > MaxRecommendedResolvers) {
1592+
warning("resolvers", "Large resolver lists can slow startup and increase battery use")
1593+
}
1594+
1595+
validatePortText(runtimeSettings.listenPort, "listenPort", "SOCKS listen port") { field, message ->
1596+
fatal(field, message)
1597+
}
1598+
if (runtimeSettings.httpProxyEnabled) {
1599+
validatePortText(runtimeSettings.httpProxyPort, "httpProxyPort", "HTTP proxy port") { field, message ->
1600+
fatal(field, message)
1601+
}
1602+
if (resolvedSettings.httpProxyPort == resolvedSettings.listenPort) {
1603+
fatal("httpProxyPort", "HTTP proxy port must differ from the SOCKS listen port")
1604+
}
1605+
}
1606+
if (runtimeSettings.localDnsEnabled) {
1607+
validatePortText(runtimeSettings.localDnsPort, "localDnsPort", "Local DNS port") { field, message ->
1608+
fatal(field, message)
1609+
}
1610+
}
1611+
if (runtimeSettings.localDnsEnabled && resolvedSettings.localDnsPort == resolvedSettings.listenPort) {
1612+
fatal("localDnsPort", "Local DNS port must differ from the SOCKS listen port")
1613+
}
1614+
if (runtimeSettings.localDnsEnabled && runtimeSettings.httpProxyEnabled &&
1615+
resolvedSettings.localDnsPort == resolvedSettings.httpProxyPort
1616+
) {
1617+
fatal("localDnsPort", "Local DNS port must differ from the HTTP proxy port")
1618+
}
1619+
1620+
if (
1621+
resolvedSettings.connectionMode == "proxy" &&
1622+
isLanReachableListenIp(resolvedSettings.listenIp) &&
1623+
!hasCompleteSocksCredentials(
1624+
enabled = resolvedSettings.socks5Authentication,
1625+
username = resolvedSettings.socksUsername,
1626+
password = resolvedSettings.socksPassword,
1627+
)
1628+
) {
1629+
fatal("socks5Authentication", "LAN-reachable proxy requires a SOCKS5 username and password")
1630+
}
1631+
1632+
if (resolvedSettings.rxTxWorkers > 32 || resolvedSettings.tunnelProcessWorkers > 32) {
1633+
warning("workers", "High worker counts can increase CPU and battery use")
1634+
}
1635+
if (resolvedSettings.mtuTestParallelismResolvers > 128) {
1636+
warning("resolverTestParallelism", "Very high resolver test parallelism can increase upload, CPU, and battery use")
1637+
}
1638+
if (resolvedSettings.txChannelSize > 8192 || resolvedSettings.rxChannelSize > 8192) {
1639+
warning("queues", "Large queues can increase memory use")
1640+
}
1641+
if (resolvedSettings.maxUploadMtu < resolvedSettings.minUploadMtu ||
1642+
resolvedSettings.maxDownloadMtu < resolvedSettings.minDownloadMtu
1643+
) {
1644+
fatal("mtu", "Maximum MTU values must be greater than or equal to minimum MTU values")
1645+
}
1646+
if (resolvedSettings.localHandshakeTimeoutSeconds < 1.0 ||
1647+
resolvedSettings.socksUdpAssociateReadTimeoutSeconds < 1.0 ||
1648+
resolvedSettings.tunnelPacketTimeoutSeconds < 1.0
1649+
) {
1650+
warning("timeouts", "Very short runtime timeouts can cause unstable connections")
1651+
}
1652+
1653+
return WhiteDnsSettingsValidation(issues)
1654+
}
1655+
1656+
private fun validatePortText(
1657+
raw: String,
1658+
field: String,
1659+
label: String,
1660+
fatal: (String, String) -> Unit,
1661+
) {
1662+
val port = raw.trim().toIntOrNull()
1663+
if (port == null || port !in 1..65535) {
1664+
fatal(field, "$label must be between 1 and 65535")
1665+
}
1666+
}
1667+
1668+
private fun isValidServerDomain(raw: String): Boolean {
1669+
val domain = raw.trim().trimEnd('.')
1670+
if (domain.isBlank() || domain.length > 253 || domain.any(Char::isWhitespace)) {
1671+
return false
1672+
}
1673+
if (domain.startsWith("[") && domain.endsWith("]")) {
1674+
return domain.length > 2
1675+
}
1676+
return domain.split('.').all { label ->
1677+
label.isNotBlank() && ServerDomainLabelRegex.matches(label)
1678+
}
1679+
}
1680+
1681+
private fun isLanReachableListenIp(raw: String): Boolean {
1682+
val value = raw.trim()
1683+
return value.isNotBlank() &&
1684+
value != "127.0.0.1" &&
1685+
value != "localhost" &&
1686+
value != "::1" &&
1687+
value != "[::1]"
1688+
}
1689+
1690+
private fun hasCompleteSocksCredentials(
1691+
enabled: Boolean,
1692+
username: String,
1693+
password: String,
1694+
): Boolean {
1695+
return enabled && username.isNotBlank() && password.isNotBlank()
1696+
}
1697+
15001698
fun WhiteDnsSettings.resolve(): ResolvedWhiteDnsSettings {
15011699
fun boundedInt(raw: String, defaultValue: Int, minValue: Int, maxValue: Int): Int {
15021700
return raw.trim().toIntOrNull()?.coerceIn(minValue, maxValue) ?: defaultValue
@@ -1541,6 +1739,11 @@ fun WhiteDnsSettings.resolve(): ResolvedWhiteDnsSettings {
15411739
.coerceAtLeast(resolvedMinUploadMtu)
15421740
val resolvedMaxDownloadMtu = boundedInt(maxDownloadMtu, defaultValue = 3000, minValue = 1, maxValue = 65535)
15431741
.coerceAtLeast(resolvedMinDownloadMtu)
1742+
val resolvedSocksUsername = socksUsername.take(255)
1743+
val resolvedSocksPassword = socksPassword.take(255)
1744+
val resolvedSocks5Authentication = socks5Authentication &&
1745+
resolvedSocksUsername.isNotBlank() &&
1746+
resolvedSocksPassword.isNotBlank()
15441747

15451748
return ResolvedWhiteDnsSettings(
15461749
connectionMode = when (connectionMode) {
@@ -1553,9 +1756,9 @@ fun WhiteDnsSettings.resolve(): ResolvedWhiteDnsSettings {
15531756
listenPort = boundedInt(listenPort, defaultValue = 10886, minValue = 1, maxValue = 65535),
15541757
httpProxyEnabled = httpProxyEnabled,
15551758
httpProxyPort = boundedInt(httpProxyPort, defaultValue = 10887, minValue = 1, maxValue = 65535),
1556-
socks5Authentication = socks5Authentication,
1557-
socksUsername = socksUsername.take(255),
1558-
socksPassword = socksPassword.take(255),
1759+
socks5Authentication = resolvedSocks5Authentication,
1760+
socksUsername = resolvedSocksUsername,
1761+
socksPassword = resolvedSocksPassword,
15591762
balancingStrategy = listOf(1, 2, 3, 4).firstOrNull { it == balancingStrategy } ?: 3,
15601763
uploadDuplication = boundedInt(uploadDuplication, defaultValue = 3, minValue = 1, maxValue = 30),
15611764
downloadDuplication = boundedInt(downloadDuplication, defaultValue = 7, minValue = 1, maxValue = 30),

0 commit comments

Comments
 (0)