@@ -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+
227251data 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
608650fun WhiteDnsSettings.normalizedConnectionProfiles (): List <ConnectionProfile > {
@@ -1458,8 +1500,10 @@ private enum class ResolverTextEntryType {
14581500}
14591501
14601502private const val DefaultResolverPort = 53
1503+ private const val MaxRecommendedResolvers = 256
14611504
14621505private 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
14641508private 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+
14901552private 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+
15001698fun 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