diff --git a/client/android/xray/src/main/kotlin/Xray.kt b/client/android/xray/src/main/kotlin/Xray.kt index 462005951..e2e73fabf 100644 --- a/client/android/xray/src/main/kotlin/Xray.kt +++ b/client/android/xray/src/main/kotlin/Xray.kt @@ -4,6 +4,9 @@ import android.content.Context import android.net.VpnService.Builder import java.io.File import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import java.util.UUID import go.Seq import org.amnezia.vpn.protocol.BadConfigException import org.amnezia.vpn.protocol.Protocol @@ -19,11 +22,32 @@ import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.net.InetNetwork import org.amnezia.vpn.util.net.ip import org.amnezia.vpn.util.net.parseInetAddress +import org.json.JSONArray import org.json.JSONObject private const val TAG = "Xray" private const val LIBXRAY_TAG = "libXray" +private fun findSocksInboundIndex(inbounds: JSONArray): Int { + for (i in 0 until inbounds.length()) { + val o = inbounds.optJSONObject(i) ?: continue + if (o.optString("protocol").equals("socks", ignoreCase = true)) { + return i + } + } + return -1 +} + +private fun acquireFreeLocalPort(): Int { + try { + ServerSocket(0, 1, InetAddress.getByName("127.0.0.1")).use { return it.localPort } + } catch (e: Exception) { + throw VpnStartException( + "Failed to acquire free TCP port on 127.0.0.1 for SOCKS inbound: ${e.message}" + ) + } +} + class Xray : Protocol() { private var isRunning: Boolean = false @@ -56,6 +80,10 @@ class Xray : Protocol() { val xrayJsonConfig = config.optJSONObject("xray_config_data") ?: config.optJSONObject("ssxray_config_data") ?: throw BadConfigException("config_data not found") + + // Inject SOCKS5 auth before starting xray. Re-uses existing credentials if present. + ensureInboundAuth(xrayJsonConfig) + val xrayConfig = parseConfig(config, xrayJsonConfig) (xrayJsonConfig.optJSONObject("log") ?: JSONObject().also { xrayJsonConfig.put("log", it) }) @@ -97,9 +125,22 @@ class Xray : Protocol() { if (it.isNotBlank()) setMtu(it.toInt()) } - val socksConfig = xrayJsonConfig.getJSONArray("inbounds")[0] as JSONObject + val inbounds = xrayJsonConfig.getJSONArray("inbounds") + val socksIdx = findSocksInboundIndex(inbounds) + if (socksIdx < 0) { + throw BadConfigException("socks inbound not found") + } + val socksConfig = inbounds.getJSONObject(socksIdx) socksConfig.getInt("port").let { setSocksPort(it) } + val socksSettings = socksConfig.optJSONObject("settings") + val accounts = socksSettings?.optJSONArray("accounts") + if (accounts != null && accounts.length() > 0) { + val account = accounts.getJSONObject(0) + setSocksUser(account.optString("user")) + setSocksPass(account.optString("pass")) + } + configSplitTunneling(config) configAppSplitTunneling(config) } @@ -162,9 +203,10 @@ class Xray : Protocol() { } private fun runTun2Socks(config: XrayConfig, fd: Int) { + val proxyUrl = "socks5://${config.socksUser}:${config.socksPass}@127.0.0.1:${config.socksPort}" val tun2SocksConfig = Tun2SocksConfig().apply { mtu = config.mtu.toLong() - proxy = "socks5://127.0.0.1:${config.socksPort}" + proxy = proxyUrl device = "fd://$fd" logLevel = "warn" } @@ -173,6 +215,37 @@ class Xray : Protocol() { } } + // Ensures SOCKS5 auth is present on the socks inbound settings. + // Re-uses existing credentials if already configured; otherwise generates random ones. + private fun ensureInboundAuth(xrayConfig: JSONObject) { + val inbounds = xrayConfig.optJSONArray("inbounds") ?: return + val socksIdx = findSocksInboundIndex(inbounds) + if (socksIdx < 0) return + + val inbound = inbounds.getJSONObject(socksIdx) + inbound.put("port", acquireFreeLocalPort()) + val settings = inbound.optJSONObject("settings") ?: JSONObject().also { inbound.put("settings", it) } + val accounts = settings.optJSONArray("accounts") + if (accounts != null && accounts.length() > 0) { + val account = accounts.getJSONObject(0) + if (account.optString("user").isNotEmpty() && account.optString("pass").isNotEmpty()) { + // Ensure auth mode is enforced even for imported configs that had accounts + // but auth: "noauth" (or no auth field). + settings.put("auth", "password") + inbound.put("settings", settings) + inbounds.put(socksIdx, inbound) + return + } + } + + val user = UUID.randomUUID().toString().replace("-", "").substring(0, 16) + val pass = UUID.randomUUID().toString().replace("-", "") + settings.put("auth", "password") + settings.put("accounts", JSONArray().put(JSONObject().put("user", user).put("pass", pass))) + inbound.put("settings", settings) + inbounds.put(socksIdx, inbound) + } + companion object { val instance: Xray by lazy { Xray() } } diff --git a/client/android/xray/src/main/kotlin/XrayConfig.kt b/client/android/xray/src/main/kotlin/XrayConfig.kt index 821a1c2f6..d4c00f0f3 100644 --- a/client/android/xray/src/main/kotlin/XrayConfig.kt +++ b/client/android/xray/src/main/kotlin/XrayConfig.kt @@ -9,12 +9,16 @@ private const val XRAY_DEFAULT_MAX_MEMORY: Long = 50 shl 20 // 50 MB class XrayConfig protected constructor( protocolConfigBuilder: ProtocolConfig.Builder, val socksPort: Int, + val socksUser: String, + val socksPass: String, val maxMemory: Long, ) : ProtocolConfig(protocolConfigBuilder) { protected constructor(builder: Builder) : this( builder, builder.socksPort, + builder.socksUser, + builder.socksPass, builder.maxMemory ) @@ -22,6 +26,12 @@ class XrayConfig protected constructor( internal var socksPort: Int = 0 private set + internal var socksUser: String = "" + private set + + internal var socksPass: String = "" + private set + internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY private set @@ -29,6 +39,10 @@ class XrayConfig protected constructor( fun setSocksPort(port: Int) = apply { socksPort = port } + fun setSocksUser(user: String) = apply { socksUser = user } + + fun setSocksPass(pass: String) = apply { socksPass = pass } + fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory } override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) } diff --git a/client/core/serialization/inbound.cpp b/client/core/serialization/inbound.cpp index 35eeb5330..2e16e1ab3 100644 --- a/client/core/serialization/inbound.cpp +++ b/client/core/serialization/inbound.cpp @@ -1,6 +1,11 @@ #include +#include #include #include +#include +#include +#include +#include #include "3rd/QJsonStruct/QJsonIO.hpp" #include "transfer.h" #include "serialization.h" @@ -14,25 +19,125 @@ namespace amnezia::serialization::inbounds // "port": 10808, // "protocol": "socks", // "settings": { +// "auth": "password", +// "accounts": [{"user": "...", "pass": "..."}], // "udp": true // } // } //], const static QString listen = "127.0.0.1"; -const static int port = 10808; +const static int defaultPort = 10808; const static QString protocol = "socks"; +static int indexOfSocksInbound(const QJsonArray &inbounds) +{ + for (int i = 0; i < inbounds.size(); ++i) { + const QString p = inbounds.at(i).toObject().value(QLatin1String("protocol")).toString(); + if (p.compare(QLatin1String("socks"), Qt::CaseInsensitive) == 0) + return i; + } + return -1; +} + +// Ask the OS for a free TCP port on loopback (same stack as inbound "listen": "127.0.0.1"). +static int acquireFreeLocalPort() +{ + QTcpServer probe; + if (!probe.listen(QHostAddress(QStringLiteral("127.0.0.1")), 0)) { + throw std::runtime_error( + "Failed to bind a local TCP port on 127.0.0.1 for SOCKS inbound " + "(QTcpServer::listen failed; possible permission or OS network error)."); + } + return static_cast(probe.serverPort()); +} + +// Generates a hex string of `byteCount` random bytes (URL-safe, no special chars). +static QString generateRandomHex(int byteCount) +{ + if (byteCount <= 0) + return {}; + // fillRange writes full quint32 words; size the buffer to a multiple of 4 bytes to avoid + // overrunning a short buffer when byteCount is not divisible by 4. + const int numUint32 = (byteCount + int(sizeof(quint32)) - 1) / int(sizeof(quint32)); + QByteArray buf(numUint32 * int(sizeof(quint32)), '\0'); + QRandomGenerator::system()->fillRange(reinterpret_cast(buf.data()), numUint32); + return QString::fromLatin1(buf.left(byteCount).toHex()); +} + QJsonObject GenerateInboundEntry() { QJsonObject root; QJsonIO::SetValue(root, listen, "listen"); - QJsonIO::SetValue(root, port, "port"); + QJsonIO::SetValue(root, defaultPort, "port"); QJsonIO::SetValue(root, protocol, "protocol"); QJsonIO::SetValue(root, true, "settings", "udp"); return root; } +InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig) +{ + InboundCredentials creds; + creds.port = defaultPort; + + const QJsonArray inbounds = xrayConfig.value("inbounds").toArray(); + const int socksIdx = indexOfSocksInbound(inbounds); + if (socksIdx < 0) + return creds; + + const QJsonObject inbound = inbounds.at(socksIdx).toObject(); + creds.port = inbound.value("port").toInt(defaultPort); + + const QJsonObject settings = inbound.value("settings").toObject(); + const QJsonArray accounts = settings.value("accounts").toArray(); + if (accounts.isEmpty()) + return creds; + + const QJsonObject account = accounts.first().toObject(); + creds.username = account.value("user").toString(); + creds.password = account.value("pass").toString(); + return creds; +} + +InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig) +{ + QJsonArray inbounds = xrayConfig.value("inbounds").toArray(); + const int socksIdx = indexOfSocksInbound(inbounds); + if (socksIdx < 0) + return GetInboundCredentials(xrayConfig); // no SOCKS inbound to patch + + QJsonObject inbound = inbounds.at(socksIdx).toObject(); + InboundCredentials creds; + creds.port = acquireFreeLocalPort(); + inbound["port"] = creds.port; + + QJsonObject settings = inbound.value("settings").toObject(); + const QJsonArray accounts = settings.value("accounts").toArray(); + if (!accounts.isEmpty()) { + const QJsonObject account = accounts.first().toObject(); + creds.username = account.value("user").toString(); + creds.password = account.value("pass").toString(); + } + + if (creds.username.isEmpty() || creds.password.isEmpty()) { + // Generate fresh credentials for this session (never persisted) + creds.username = generateRandomHex(8); // 16 hex chars + creds.password = generateRandomHex(16); // 32 hex chars + QJsonObject account; + account["user"] = creds.username; + account["pass"] = creds.password; + settings["accounts"] = QJsonArray{ account }; + } + + // Always ensure auth mode is enforced, even for imported configs that had + // accounts but auth: "noauth" (or no auth field at all). + settings["auth"] = QStringLiteral("password"); + inbound["settings"] = settings; + inbounds[socksIdx] = inbound; + xrayConfig["inbounds"] = inbounds; + + return creds; +} } // namespace amnezia::serialization::inbounds diff --git a/client/core/serialization/serialization.h b/client/core/serialization/serialization.h index ec5f4f5c7..d311ec331 100644 --- a/client/core/serialization/serialization.h +++ b/client/core/serialization/serialization.h @@ -60,7 +60,24 @@ namespace amnezia::serialization namespace inbounds { + struct InboundCredentials { + QString username; + QString password; + int port; + }; + QJsonObject GenerateInboundEntry(); + + // Reads existing SOCKS5 auth from the first inbound with protocol "socks" + // (.settings.accounts[0]). Returns empty username/password if none. + InboundCredentials GetInboundCredentials(const QJsonObject &xrayConfig); + + // Ensures SOCKS5 auth is present on the inbound whose protocol is "socks". + // Re-uses existing credentials if already set; otherwise generates random ones + // and writes them into the config. Assigns a free loopback TCP port each session + // (OS-assigned). Throws std::runtime_error if a SOCKS inbound exists but binding + // a local port on 127.0.0.1 fails (e.g. permissions or OS error). + InboundCredentials EnsureInboundAuth(QJsonObject &xrayConfig); } } diff --git a/client/platforms/ios/PacketTunnelProvider+Xray.swift b/client/platforms/ios/PacketTunnelProvider+Xray.swift index 4d3d723ca..0b46b5ce8 100644 --- a/client/platforms/ios/PacketTunnelProvider+Xray.swift +++ b/client/platforms/ios/PacketTunnelProvider+Xray.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation import NetworkExtension @@ -6,6 +7,7 @@ enum XrayErrors: Error { case xrayConfigIsWrong case cantSaveXrayConfig case cantParseListenAndPort + case cantAcquireLocalPort case cantSaveHevSocksConfig } @@ -21,6 +23,42 @@ extension Constants { } extension PacketTunnelProvider { + /// TCP port chosen by the OS on IPv6 loopback (::1), matching inbound listen address. + private func acquireFreeLocalPort() throws -> Int { + let fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP) + guard fd != -1 else { + throw XrayErrors.cantAcquireLocalPort + } + defer { close(fd) } + var reuse: Int32 = 1 + _ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, socklen_t(MemoryLayout.size)) + var addr = sockaddr_in6() + addr.sin6_len = UInt8(MemoryLayout.size) + addr.sin6_family = sa_family_t(AF_INET6) + addr.sin6_port = in_port_t(0).bigEndian + addr.sin6_addr = in6addr_loopback + addr.sin6_scope_id = 0 + let bindResult = withUnsafePointer(to: &addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { p in + bind(fd, p, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + throw XrayErrors.cantAcquireLocalPort + } + var bound = sockaddr_in6() + var len = socklen_t(MemoryLayout.size) + let gr = withUnsafeMutablePointer(to: &bound) { p in + p.withMemoryRebound(to: sockaddr.self, capacity: 1) { bp in + getsockname(fd, bp, &len) + } + } + guard gr == 0 else { + throw XrayErrors.cantAcquireLocalPort + } + return Int(bound.sin6_port.byteSwapped) + } + private func applyXraySplitTunnel(_ xrayConfig: XrayConfig, settings: NEPacketTunnelNetworkSettings) { guard let splitTunnelType = xrayConfig.splitTunnelType else { @@ -129,14 +167,11 @@ extension PacketTunnelProvider { return } - let port = 10808 + let port = try acquireFreeLocalPort() let address = "::1" - if var inboundsArray = jsonDict["inbounds"] as? [[String: Any]], !inboundsArray.isEmpty { - inboundsArray[0]["port"] = port - inboundsArray[0]["listen"] = address - jsonDict["inbounds"] = inboundsArray - } + // Extract existing SOCKS5 credentials or generate new ones per session. + let socksCredentials = ensureInboundAuth(jsonDict: &jsonDict, port: port, address: address) let updatedData = try JSONSerialization.data(withJSONObject: jsonDict, options: []) @@ -159,6 +194,8 @@ extension PacketTunnelProvider { self?.setupAndRunTun2socks(configData: updatedData, address: address, port: port, + username: socksCredentials.username, + password: socksCredentials.password, completionHandler: completionHandler) } } @@ -183,6 +220,62 @@ extension PacketTunnelProvider { } } + private struct SocksCredentials { + let username: String + let password: String + } + + private func indexOfSocksInbound(in inboundsArray: [[String: Any]]) -> Int? { + for (i, inbound) in inboundsArray.enumerated() { + guard let proto = inbound["protocol"] as? String else { continue } + if proto.caseInsensitiveCompare("socks") == .orderedSame { + return i + } + } + return nil + } + + // Returns existing SOCKS5 credentials from the inbound config, or generates and injects + // new random ones. Also sets port and address on the socks inbound entry. + private func ensureInboundAuth(jsonDict: inout [String: Any], port: Int, address: String) -> SocksCredentials { + var inboundsArray = jsonDict["inbounds"] as? [[String: Any]] ?? [] + + if let socksIdx = indexOfSocksInbound(in: inboundsArray) { + var inbound = inboundsArray[socksIdx] + inbound["port"] = port + inbound["listen"] = address + + var settings = inbound["settings"] as? [String: Any] ?? [:] + if let accounts = settings["accounts"] as? [[String: Any]], + let first = accounts.first, + let user = first["user"] as? String, !user.isEmpty, + let pass = first["pass"] as? String, !pass.isEmpty { + // Re-use existing credentials, but always enforce auth mode in case the + // imported config had accounts but auth: "noauth" (or no auth field). + settings["auth"] = "password" + inbound["settings"] = settings + inboundsArray[socksIdx] = inbound + jsonDict["inbounds"] = inboundsArray + return SocksCredentials(username: user, password: pass) + } + + // Generate new random credentials for this session + let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16) + let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + settings["auth"] = "password" + settings["accounts"] = [["user": String(user), "pass": pass]] + inbound["settings"] = settings + inboundsArray[socksIdx] = inbound + jsonDict["inbounds"] = inboundsArray + return SocksCredentials(username: String(user), password: pass) + } + + // Fallback: no socks inbound — generate credentials but can't inject + let user = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased().prefix(16) + let pass = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + return SocksCredentials(username: String(user), password: pass) + } + private func setupAndStartXray(configData: Data, completionHandler: @escaping (Error?) -> Void) { let path = Constants.cachesDirectory.appendingPathComponent("config.json", isDirectory: false).path @@ -214,6 +307,8 @@ extension PacketTunnelProvider { private func setupAndRunTun2socks(configData: Data, address: String, port: Int, + username: String, + password: String, completionHandler: @escaping (Error?) -> Void) { let config = """ tunnel: @@ -221,6 +316,8 @@ extension PacketTunnelProvider { socks5: port: \(port) address: \(address) + username: \(username) + password: \(password) udp: 'udp' misc: task-stack-size: 20480 diff --git a/client/protocols/xrayprotocol.cpp b/client/protocols/xrayprotocol.cpp index 50bf829a7..016501ad4 100755 --- a/client/protocols/xrayprotocol.cpp +++ b/client/protocols/xrayprotocol.cpp @@ -1,6 +1,7 @@ #include "xrayprotocol.h" #include "core/ipcclient.h" +#include "core/serialization/serialization.h" #include "ipc.h" #include "utilities.h" #include "core/networkUtilities.h" @@ -14,6 +15,8 @@ #include #include +#include + #ifdef Q_OS_MACOS static const QString tunName = "utun22"; #else @@ -53,6 +56,19 @@ ErrorCode XrayProtocol::start() { qDebug() << "XrayProtocol::start()"; + // Inject SOCKS5 auth into the inbound before starting xray. + // Re-uses existing credentials if the config already has them (e.g. imported config). + amnezia::serialization::inbounds::InboundCredentials creds; + try { + creds = amnezia::serialization::inbounds::EnsureInboundAuth(m_xrayConfig); + } catch (const std::exception &e) { + qCritical() << "EnsureInboundAuth failed:" << e.what(); + return ErrorCode::InternalError; + } + m_socksUser = creds.username; + m_socksPassword = creds.password; + m_socksPort = creds.port; + return IpcClient::withInterface([&](QSharedPointer iface) { auto xrayStart = iface->xrayStart(QJsonDocument(m_xrayConfig).toJson()); if (!xrayStart.waitForFinished() || !xrayStart.returnValue()) { @@ -121,8 +137,11 @@ ErrorCode XrayProtocol::startTun2Socks() return ErrorCode::AmneziaServiceConnectionFailed; } + const QString proxyUrl = QString("socks5://%1:%2@127.0.0.1:%3") + .arg(m_socksUser, m_socksPassword, QString::number(m_socksPort)); + m_tun2socksProcess->setProgram(PermittedProcess::Tun2Socks); - m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", "socks5://127.0.0.1:10808" }); + m_tun2socksProcess->setArguments({"-device", QString("tun://%1").arg(tunName), "-proxy", proxyUrl}); connect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, [this]() { auto readAllStandardOutput = m_tun2socksProcess->readAllStandardOutput(); @@ -136,7 +155,7 @@ ErrorCode XrayProtocol::startTun2Socks() if (!line.contains("[TCP]") && !line.contains("[UDP]")) qDebug() << "[tun2socks]:" << line; - if (line.contains("[STACK] tun://") && line.contains("<-> socks5://127.0.0.1")) { + if (line.contains("[STACK] tun://") && line.contains("<-> socks5://")) { disconnect(m_tun2socksProcess.data(), &IpcProcessInterfaceReplica::readyReadStandardOutput, this, nullptr); if (ErrorCode res = setupRouting(); res != ErrorCode::NoError) { diff --git a/client/protocols/xrayprotocol.h b/client/protocols/xrayprotocol.h index bccb844a2..0abd237eb 100644 --- a/client/protocols/xrayprotocol.h +++ b/client/protocols/xrayprotocol.h @@ -26,6 +26,10 @@ private: QList m_dnsServers; QString m_remoteAddress; + QString m_socksUser; + QString m_socksPassword; + int m_socksPort = 10808; + QSharedPointer m_tun2socksProcess; };