I created PacketTunnelProvider
class that contains a Clash VPN server running http(s) proxy on 127.0.0.1:7890(shows HTTP proxy listening at: [::]:7890
log in console).
//
// PacketTunnelProvider.swift
// PacketTunnel
//
// Created by LondonX on 2022/9/13.
//
import NetworkExtension
import ClashKit
class PacketTunnelProvider: NEPacketTunnelProvider {
private var trafficTotalUp: Int64 = 0
private var trafficTotalDown: Int64 = 0
private var trafficUp: Int64 = 0
private var trafficDown: Int64 = 0
private lazy var client = AppClashClient { trafficUp, trafficDown in
self.trafficTotalUp += trafficUp
self.trafficTotalDown += trafficDown
self.trafficUp = trafficUp
self.trafficDown = trafficDown
}
override func startTunnel(options: [String : NSObject]?) async throws {
let isSetup = setupClash()
if (!isSetup) {
throw MyError.runtimeError("Clash Setup failed")
}
let generalJson = String(data: ClashKit.ClashGetConfigGeneral()!, encoding: .utf8)
let general = jsonToDictionary(generalJson)
osLog("startTunnel with config: \(String(describing: (generalJson)))")
let port = general?["port"] as? Int ?? 7890
//192.168.0.29
let host = "127.0.0.1"
try await self.setTunnelNetworkSettings(initTunnelSettings(proxyHost: host, proxyPort: port))
}
override func stopTunnel(with reason: NEProviderStopReason) async {
}
override func handleAppMessage(_ messageData: Data) async -> Data? {
let message = String(data: messageData, encoding: .utf8)
switch(message) {
case "notifyConfigChanged":
_ = setupClash()
break
case "queryTrafficNow":
return "\(trafficUp),\(trafficDown)".data(using: .utf8)
case "queryTrafficTotal":
return "\(trafficTotalUp),\(trafficTotalDown)".data(using: .utf8)
default:
break
}
return nil
}
private func setupClash() -> Bool {
let exIdentifier = Bundle.main.infoDictionary?["CFBundleIdentifier"] as! String
let identifier = exIdentifier.replacingOccurrences(of: ".PacketTunnel", with: "")
let suiteName = "group.\(identifier)"
let userDefaults = UserDefaults(suiteName: suiteName)!
let clashHome = userDefaults.string(forKey: "clash_flt_clashHome")
let clashHomeUrl = resolvePath(clashHome, isDir: true)
let profilePath = userDefaults.string(forKey: "clash_flt_profilePath")
let profileUrl = resolvePath(profilePath, isDir: false)
let countryDBPath = userDefaults.string(forKey: "clash_flt_countryDBPath")
let countryDBUrl = resolvePath(countryDBPath, isDir: false)
let groupName = userDefaults.string(forKey: "clash_flt_groupName")
let proxyName = userDefaults.string(forKey: "clash_flt_proxyName")
osLog("setup with clashHome: \(clashHomeUrl?.path ?? ""), profilePath: \(profileUrl?.path ?? ""), countryDBPath: \(countryDBUrl?.path ?? ""), groupName: \(groupName ?? ""), proxyName: \(proxyName ?? "")")
if(clashHomeUrl == nil ||
profileUrl == nil ||
countryDBUrl == nil ||
groupName == nil ||
proxyName == nil
) {
osLog("\(String(describing: clashHomeUrl)), \(String(describing: profileUrl)), \(String(describing: countryDBUrl)), \(String(describing: groupName)), \(String(describing: proxyName))")
return false
}
let cacheDBUrl = clashHomeUrl!.appendingPathComponent("cache.db")
FileManager.default.createFile(atPath: cacheDBUrl.path, contents: nil)
let fileExists = FileManager.default.fileExists(atPath: profileUrl!.path)
osLog("profileUrl: \(profileUrl!), fileExists: \(fileExists)")
let config = try? String(contentsOfFile: profilePath!)
osLog("config: \(config ?? "")")
if(config == nil) {
return false
}
ClashKit.ClashSetup(clashHomeUrl!.path, config, client)
let data = ClashKit.ClashGetConfigGeneral()
let map = [groupName! : proxyName!]
let json = dictionaryToJson(dic: map)
ClashKit.ClashPatchSelector(json?.data(using: .utf8))
return data != nil
}
private func initTunnelSettings(proxyHost: String, proxyPort: Int) -> NEPacketTunnelNetworkSettings {
let settings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: proxyHost)
settings.mtu = 1440
/* proxy settings */
let proxySettings = NEProxySettings()
proxySettings.httpServer = NEProxyServer(
address: proxyHost,
port: proxyPort
)
proxySettings.httpsServer = NEProxyServer(
address: proxyHost,
port: proxyPort
)
proxySettings.httpEnabled = true
proxySettings.httpsEnabled = true
proxySettings.matchDomains = [""]
let ipv4Settings = NEIPv4Settings(
addresses: ["127.0.0.1"],
subnetMasks: ["255.255.255.255"]
)
settings.ipv4Settings = ipv4Settings
settings.proxySettings = proxySettings
return settings
}
}
class AppClashClient: NSObject, ClashClientProtocol {
private let trafficListener: (_ up: Int64, _ down: Int64) -> Void
init(trafficListener: @escaping (_ up: Int64, _ down: Int64) -> Void) {
self.trafficListener = trafficListener
}
func log(_ level: String?, message: String?) {
osLog("AppClashClient[\(level ?? "")]: \(message ?? "")")
}
func traffic(_ up: Int64, down: Int64) {
trafficListener(up, down)
}
}
private func jsonToDictionary(_ text: String?) -> [String: Any]? {
if (text == nil) {
return nil
}
if let data = text!.data(using: .utf8) {
do {
return try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any]
} catch {
osLog("\(error.localizedDescription)")
}
}
return nil
}
private func dictionaryToJson(dic: Dictionary<String, Any>?) -> String? {
var jsonData: Data? = nil
do {
if let dic = dic {
jsonData = try JSONSerialization.data(withJSONObject: dic, options: .prettyPrinted)
}
} catch {
}
if let jsonData = jsonData {
return String(data: jsonData, encoding: .utf8)
}
return nil
}
enum MyError: Error {
case runtimeError(String)
}
private func resolvePath(_ nonSandboxPath: String?, isDir: Bool) -> URL? {
if (nonSandboxPath == nil) {
return nil
}
return URL(string: nonSandboxPath!)
}
func osLog(_ any: Any?) {
NSLog("[ClashFlt.PacketTunnel]\(any ?? "")")
}
After called startVPNTunnel()
, iPhone showing the VPN logo in status bar, but all request from Safari is stuck until timeout, no log from Clash.
I believe my Clash server is working cause I can set <iPhone's IP>:7890
as another phone's proxy server, and logs like [info]: [TCP] 192.168.31.191:45048 --> i.ytimg.com:443 match DomainSuffix(ytimg.com) using Proxy[proxy node 1]
.
I also tried to run Clash server in another device, and change change the host
param of initTunnelSettings
to that device's ip, start VPN and works.
Looks like some behaviors in initTunnelSettings
blocks the local Clash server of made the request looping.
The problem is my own config
is allow-lan: true
, this will cause the network loop in network extension.
Override config like this will solve the issue.
let configOverride = """
\(config!)
allow-lan: false
"""
ClashKit.ClashSetup(clashHomeUrl!.path, configOverride, client)