diff --git a/Apple/App/App-macOS.entitlements b/Apple/App/App-macOS.entitlements index 53fcbb7..595fe08 100644 --- a/Apple/App/App-macOS.entitlements +++ b/Apple/App/App-macOS.entitlements @@ -15,5 +15,7 @@ $(APP_GROUP_IDENTIFIER) + com.apple.security.network.client + diff --git a/Apple/App/App.xcconfig b/Apple/App/App.xcconfig index 4e42ddc..bfdd25d 100644 --- a/Apple/App/App.xcconfig +++ b/Apple/App/App.xcconfig @@ -15,9 +15,10 @@ EXCLUDED_SOURCE_FILE_NAMES = MainMenu.xib EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*] = INFOPLIST_KEY_LSUIElement[sdk=macosx*] = YES -INFOPLIST_KEY_NSMainNibFile[sdk=macosx*] = MainMenu INFOPLIST_KEY_NSPrincipalClass[sdk=macosx*] = NSApplication INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*] = public.app-category.utilities CODE_SIGN_ENTITLEMENTS = App/App-iOS.entitlements CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = App/App-macOS.entitlements + +SWIFT_INCLUDE_PATHS = $(inherited) $(PROJECT_DIR)/NetworkExtension diff --git a/Apple/App/AppDelegate.swift b/Apple/App/AppDelegate.swift index c3cb4cb..a322943 100644 --- a/Apple/App/AppDelegate.swift +++ b/Apple/App/AppDelegate.swift @@ -1,23 +1,17 @@ #if os(macOS) import AppKit +import BurrowConfiguration +import BurrowCore import BurrowUI import SwiftUI +import libburrow -@main @MainActor class AppDelegate: NSObject, NSApplicationDelegate { private var windowController: NSWindowController? + private let logger = Logger.logger(for: AppDelegate.self) - private let quitItem: NSMenuItem = { - let quitItem = NSMenuItem( - title: "Quit Burrow", - action: #selector(NSApplication.terminate(_:)), - keyEquivalent: "q" - ) - quitItem.target = NSApplication.shared - quitItem.keyEquivalentModifierMask = .command - return quitItem - }() + private let quitItem = AppDelegate.makeQuitItem() private lazy var openItem: NSMenuItem = { let item = NSMenuItem( @@ -61,9 +55,85 @@ class AppDelegate: NSObject, NSApplicationDelegate { }() func applicationDidFinishLaunching(_ notification: Notification) { + installMainMenu() + startDaemon() statusItem.menu = menu } + private func startDaemon() { + do { + libburrow.spawnInProcess( + socketPath: try Constants.socketURL.path(percentEncoded: false), + databasePath: try Constants.databaseURL.path(percentEncoded: false) + ) + } catch { + logger.error("Failed to spawn daemon thread: \(error)") + } + } + + private func installMainMenu() { + let mainMenu = NSMenu(title: "Burrow") + + let appItem = NSMenuItem(title: "Burrow", action: nil, keyEquivalent: "") + let appMenu = NSMenu(title: "Burrow") + + let aboutItem = NSMenuItem( + title: "About Burrow", + action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), + keyEquivalent: "" + ) + aboutItem.target = NSApplication.shared + appMenu.addItem(aboutItem) + appMenu.addItem(.separator()) + appMenu.addItem(Self.makeQuitItem()) + + appItem.submenu = appMenu + mainMenu.addItem(appItem) + + let editItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") + editItem.submenu = makeEditMenu() + mainMenu.addItem(editItem) + + NSApplication.shared.mainMenu = mainMenu + } + + private static func makeQuitItem() -> NSMenuItem { + let quitItem = NSMenuItem( + title: "Quit Burrow", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + quitItem.target = NSApplication.shared + quitItem.keyEquivalentModifierMask = .command + return quitItem + } + + private func makeEditMenu() -> NSMenu { + let editMenu = NSMenu(title: "Edit") + + editMenu.addItem(NSMenuItem(title: "Undo", action: Selector(("undo:")), keyEquivalent: "z")) + editMenu.addItem(NSMenuItem(title: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")) + editMenu.addItem(.separator()) + + editMenu.addItem(NSMenuItem(title: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")) + editMenu.addItem(NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")) + editMenu.addItem(NSMenuItem(title: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")) + + let pastePlainItem = NSMenuItem( + title: "Paste and Match Style", + action: #selector(NSTextView.pasteAsPlainText(_:)), + keyEquivalent: "V" + ) + pastePlainItem.keyEquivalentModifierMask = [.command, .option, .shift] + editMenu.addItem(pastePlainItem) + + editMenu.addItem(NSMenuItem(title: "Delete", action: #selector(NSText.delete(_:)), keyEquivalent: "")) + editMenu.addItem(.separator()) + editMenu.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")) + + return editMenu + } + @objc private func openWindow() { if let window = windowController?.window { @@ -74,9 +144,13 @@ class AppDelegate: NSObject, NSApplicationDelegate { let contentView = BurrowView() let hostingController = NSHostingController(rootView: contentView) + if #available(macOS 13.0, *) { + hostingController.sizingOptions = [] + } let window = NSWindow(contentViewController: hostingController) window.title = "Burrow" window.setContentSize(NSSize(width: 820, height: 720)) + window.contentMinSize = NSSize(width: 640, height: 560) window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable]) window.center() @@ -87,4 +161,19 @@ class AppDelegate: NSObject, NSApplicationDelegate { NSApplication.shared.activate(ignoringOtherApps: true) } } + +@main +enum BurrowApplication { + @MainActor + private static let delegate = AppDelegate() + + @MainActor + static func main() { + let application = NSApplication.shared + application.delegate = delegate + application.setActivationPolicy(.accessory) + application.finishLaunching() + application.run() + } +} #endif diff --git a/Apple/App/MainMenu.xib b/Apple/App/MainMenu.xib index 50ba431..5c7bc1e 100644 --- a/Apple/App/MainMenu.xib +++ b/Apple/App/MainMenu.xib @@ -1,7 +1,8 @@ - + - + + diff --git a/Apple/Burrow.xcodeproj/project.pbxproj b/Apple/Burrow.xcodeproj/project.pbxproj index 83d32e0..6c64803 100644 --- a/Apple/Burrow.xcodeproj/project.pbxproj +++ b/Apple/Burrow.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; - D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.swift */; }; D020F65829E4A697002790F6 /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D020F65729E4A697002790F6 /* PacketTunnelProvider.swift */; }; D020F65D29E4A697002790F6 /* BurrowNetworkExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = D020F65329E4A697002790F6 /* BurrowNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; }; @@ -19,6 +18,7 @@ D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; platformFilters = (macos, ); }; D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D0B1D10F2C436152004B7823 /* AsyncAlgorithms */; }; D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; + D0BCC60C2A09A0C200AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; }; D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -43,20 +43,14 @@ D0D4E5A62C8D9E65007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; D0F7594E2C8DAB6B00126CF3 /* GRPC in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E02C8DA375008A8CEC /* GRPC */; }; - D0FA10012D10200100112233 /* burrow.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10032D10200100112233 /* burrow.pb.swift */; }; - D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10042D10200100112233 /* burrow.grpc.swift */; }; D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; }; D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; }; + D0FA10012D10200100112233 /* Generated/burrow.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10032D10200100112233 /* Generated/burrow.pb.swift */; }; + D0FA10022D10200100112233 /* Generated/burrow.grpc.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */; }; + D11000012F70000100112233 /* BurrowUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D11000042F70000100112233 /* BurrowUITests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - D11000022F70000100112233 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; - proxyType = 1; - remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; - remoteInfo = App; - }; D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; @@ -106,6 +100,13 @@ remoteGlobalIDString = D0D4E5302C8D996F007F820A; remoteInfo = Core; }; + D11000022F70000100112233 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; + proxyType = 1; + remoteGlobalIDString = D05B9F7129E39EEC008CB1F9; + remoteInfo = App; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -138,9 +139,6 @@ /* Begin PBXFileReference section */ D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = ""; }; D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - D11000032F70000100112233 /* BurrowUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BurrowUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - D11000042F70000100112233 /* BurrowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowUITests.swift; sourceTree = ""; }; - D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = ""; }; D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = ""; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -188,18 +186,14 @@ D0D4E58E2C8D9D0A007F820A /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = ""; }; D0D4E58F2C8D9D0A007F820A /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; D0D4E5902C8D9D0A007F820A /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = ""; }; - D0FA10032D10200100112233 /* burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = ""; }; - D0FA10042D10200100112233 /* burrow.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.grpc.swift; sourceTree = ""; }; + D0FA10032D10200100112233 /* Generated/burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = ""; }; + D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.grpc.swift; sourceTree = ""; }; + D11000032F70000100112233 /* BurrowUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = BurrowUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + D11000042F70000100112233 /* BurrowUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BurrowUITests.swift; sourceTree = ""; }; + D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - D11000062F70000100112233 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F65029E4A697002790F6 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -218,6 +212,7 @@ D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */, D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */, D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */, + D0BCC60C2A09A0C200AD070D /* libburrow.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -242,6 +237,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D11000062F70000100112233 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -324,14 +326,6 @@ path = App; sourceTree = ""; }; - D11000072F70000100112233 /* AppUITests */ = { - isa = PBXGroup; - children = ( - D11000042F70000100112233 /* BurrowUITests.swift */, - ); - path = AppUITests; - sourceTree = ""; - }; D0B98FD729FDDB57004E7149 /* libburrow */ = { isa = PBXGroup; children = ( @@ -346,8 +340,8 @@ isa = PBXGroup; children = ( D0D4E4952C8D921A007F820A /* burrow.proto */, - D0FA10032D10200100112233 /* burrow.pb.swift */, - D0FA10042D10200100112233 /* burrow.grpc.swift */, + D0FA10032D10200100112233 /* Generated/burrow.pb.swift */, + D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */, ); path = Client; sourceTree = ""; @@ -401,27 +395,17 @@ path = Constants; sourceTree = ""; }; + D11000072F70000100112233 /* AppUITests */ = { + isa = PBXGroup; + children = ( + D11000042F70000100112233 /* BurrowUITests.swift */, + ); + path = AppUITests; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - D11000082F70000100112233 /* BurrowUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */; - buildPhases = ( - D110000A2F70000100112233 /* Sources */, - D11000062F70000100112233 /* Frameworks */, - D11000092F70000100112233 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - D110000B2F70000100112233 /* PBXTargetDependency */, - ); - name = BurrowUITests; - productName = BurrowUITests; - productReference = D11000032F70000100112233 /* BurrowUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; D020F65229E4A697002790F6 /* NetworkExtension */ = { isa = PBXNativeTarget; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; @@ -527,6 +511,24 @@ productReference = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; productType = "com.apple.product-type.framework"; }; + D11000082F70000100112233 /* BurrowUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */; + buildPhases = ( + D110000A2F70000100112233 /* Sources */, + D11000062F70000100112233 /* Frameworks */, + D11000092F70000100112233 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + D110000B2F70000100112233 /* PBXTargetDependency */, + ); + name = BurrowUITests; + productName = BurrowUITests; + productReference = D11000032F70000100112233 /* BurrowUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -537,19 +539,54 @@ LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1520; TargetAttributes = { - D11000082F70000100112233 = { - CreatedOnToolsVersion = 16.0; - TestTargetID = D05B9F7129E39EEC008CB1F9; - }; D020F65229E4A697002790F6 = { CreatedOnToolsVersion = 14.3; + DevelopmentTeam = 2KPBQHRJ2D; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.ApplicationGroups.Mac = { + enabled = 1; + }; + com.apple.NetworkExtensions = { + enabled = 1; + }; + com.apple.Sandbox = { + enabled = 1; + }; + }; }; D05B9F7129E39EEC008CB1F9 = { CreatedOnToolsVersion = 14.3; + DevelopmentTeam = 2KPBQHRJ2D; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.ApplicationGroups.Mac = { + enabled = 1; + }; + com.apple.ApplicationGroups.iOS = { + enabled = 1; + }; + com.apple.AssociatedDomains = { + enabled = 1; + }; + com.apple.NetworkExtensions = { + enabled = 1; + }; + com.apple.NetworkExtensions.iOS = { + enabled = 1; + }; + com.apple.Sandbox = { + enabled = 1; + }; + }; }; D0D4E5302C8D996F007F820A = { CreatedOnToolsVersion = 16.0; }; + D11000082F70000100112233 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = D05B9F7129E39EEC008CB1F9; + }; }; }; buildConfigurationList = D05B9F6D29E39EEC008CB1F9 /* Build configuration list for PBXProject "Burrow" */; @@ -583,13 +620,6 @@ /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - D11000092F70000100112233 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; D05B9F7029E39EEC008CB1F9 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -605,6 +635,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D11000092F70000100112233 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -653,14 +690,6 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - D110000A2F70000100112233 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - D11000012F70000100112233 /* BurrowUITests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; D020F64F29E4A697002790F6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -682,8 +711,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D0FA10012D10200100112233 /* burrow.pb.swift in Sources */, - D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */, + D0FA10012D10200100112233 /* Generated/burrow.pb.swift in Sources */, + D0FA10022D10200100112233 /* Generated/burrow.grpc.swift in Sources */, D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */, D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */, ); @@ -716,14 +745,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + D110000A2F70000100112233 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D11000012F70000100112233 /* BurrowUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - D110000B2F70000100112233 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = D05B9F7129E39EEC008CB1F9 /* App */; - targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; - }; D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = D020F65229E4A697002790F6 /* NetworkExtension */; @@ -763,27 +795,19 @@ isa = PBXTargetDependency; productRef = D0F759892C8DB34200126CF3 /* GRPC */; }; + D110000B2F70000100112233 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = D05B9F7129E39EEC008CB1F9 /* App */; + targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - D110000C2F70000100112233 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; - buildSettings = { - }; - name = Debug; - }; - D110000D2F70000100112233 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; - buildSettings = { - }; - name = Release; - }; D020F65F29E4A697002790F6 /* Debug */ = { isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Debug; }; @@ -791,6 +815,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Release; }; @@ -798,6 +823,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Debug; }; @@ -805,6 +831,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */; buildSettings = { + DEVELOPMENT_TEAM = 2KPBQHRJ2D; }; name = Release; }; @@ -812,6 +839,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */; buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 2KPBQHRJ2D; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Debug; }; @@ -819,6 +850,10 @@ isa = XCBuildConfiguration; baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */; buildSettings = { + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 2KPBQHRJ2D; + PROVISIONING_PROFILE_SPECIFIER = ""; }; name = Release; }; @@ -864,18 +899,23 @@ }; name = Release; }; + D110000C2F70000100112233 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Debug; + }; + D110000D2F70000100112233 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D11000052F70000100112233 /* UITests.xcconfig */; + buildSettings = { + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - D110000C2F70000100112233 /* Debug */, - D110000D2F70000100112233 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -930,6 +970,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D110000C2F70000100112233 /* Debug */, + D110000D2F70000100112233 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/Apple/Configuration/Constants/Constants.swift b/Apple/Configuration/Constants/Constants.swift index 95d8c78..648ded2 100644 --- a/Apple/Configuration/Constants/Constants.swift +++ b/Apple/Configuration/Constants/Constants.swift @@ -16,6 +16,13 @@ public enum Constants { try groupContainerURL.appending(component: "burrow.sock", directoryHint: .notDirectory) } } + + public static var packetTunnelSocketURL: URL { + get throws { + try groupContainerURL.appending(component: "burrow-packet-tunnel.sock", directoryHint: .notDirectory) + } + } + public static var databaseURL: URL { get throws { try groupContainerURL.appending(component: "burrow.db", directoryHint: .notDirectory) diff --git a/Apple/Configuration/Identity.xcconfig b/Apple/Configuration/Identity.xcconfig index e9ca78d..7318401 100644 --- a/Apple/Configuration/Identity.xcconfig +++ b/Apple/Configuration/Identity.xcconfig @@ -1,2 +1,2 @@ -DEVELOPMENT_TEAM = P6PV2R9443 -APP_BUNDLE_IDENTIFIER = com.hackclub.burrow +DEVELOPMENT_TEAM = 2KPBQHRJ2D +APP_BUNDLE_IDENTIFIER = com.tianruichen.burrow diff --git a/Apple/Core/Client.swift b/Apple/Core/Client.swift index 7d4cfc7..559238f 100644 --- a/Apple/Core/Client.swift +++ b/Apple/Core/Client.swift @@ -108,6 +108,83 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable { public init() {} } +public struct Burrow_ProxySubscriptionImportRequest: Sendable { + public var url: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_ProxySubscriptionNodePreview: Sendable { + public var ordinal: Int32 = 0 + public var name: String = "" + public var `protocol`: String = "" + public var server: String = "" + public var port: Int32 = 0 + public var warnings: [String] = [] + public var runtimeSupported: Bool = false + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_ProxySubscriptionRejectedEntry: Sendable { + public var ordinal: Int32 = 0 + public var reason: String = "" + public var scheme: String = "" + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_ProxySubscriptionPreviewResponse: Sendable { + public var suggestedName: String = "" + public var detectedFormat: String = "" + public var compatibleCount: Int32 = 0 + public var rejectedCount: Int32 = 0 + public var node: [Burrow_ProxySubscriptionNodePreview] = [] + public var rejected: [Burrow_ProxySubscriptionRejectedEntry] = [] + public var warnings: [String] = [] + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_ProxySubscriptionApplyRequest: Sendable { + public var id: Int32 = 0 + public var url: String = "" + public var name: String = "" + public var selectedOrdinal: Int32 = 0 + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_ProxySubscriptionApplyResponse: Sendable { + public var id: Int32 = 0 + public var importedCount: Int32 = 0 + public var selectedOrdinal: Int32 = 0 + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_ProxySubscriptionSelectRequest: Sendable { + public var id: Int32 = 0 + public var selectedOrdinal: Int32 = 0 + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Burrow_ProxySubscriptionSelectResponse: Sendable { + public var id: Int32 = 0 + public var selectedOrdinal: Int32 = 0 + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + public struct Burrow_TunnelPacket: Sendable { public var payload = Data() public var unknownFields = SwiftProtobuf.UnknownStorage() @@ -417,6 +494,239 @@ extension Burrow_TunnelPacket: SwiftProtobuf.Message, SwiftProtobuf._MessageImpl } } +extension Burrow_ProxySubscriptionImportRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionImportRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "url") + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.url) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.url.isEmpty { + try visitor.visitSingularStringField(value: self.url, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_ProxySubscriptionNodePreview: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionNodePreview" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "ordinal"), + 2: .same(proto: "name"), + 3: .same(proto: "protocol"), + 4: .same(proto: "server"), + 5: .same(proto: "port"), + 6: .same(proto: "warnings"), + 7: .standard(proto: "runtime_supported"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularInt32Field(value: &self.ordinal) + case 2: try decoder.decodeSingularStringField(value: &self.name) + case 3: try decoder.decodeSingularStringField(value: &self.`protocol`) + case 4: try decoder.decodeSingularStringField(value: &self.server) + case 5: try decoder.decodeSingularInt32Field(value: &self.port) + case 6: try decoder.decodeRepeatedStringField(value: &self.warnings) + case 7: try decoder.decodeSingularBoolField(value: &self.runtimeSupported) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.ordinal != 0 { try visitor.visitSingularInt32Field(value: self.ordinal, fieldNumber: 1) } + if !self.name.isEmpty { try visitor.visitSingularStringField(value: self.name, fieldNumber: 2) } + if !self.`protocol`.isEmpty { try visitor.visitSingularStringField(value: self.`protocol`, fieldNumber: 3) } + if !self.server.isEmpty { try visitor.visitSingularStringField(value: self.server, fieldNumber: 4) } + if self.port != 0 { try visitor.visitSingularInt32Field(value: self.port, fieldNumber: 5) } + if !self.warnings.isEmpty { try visitor.visitRepeatedStringField(value: self.warnings, fieldNumber: 6) } + if self.runtimeSupported { try visitor.visitSingularBoolField(value: self.runtimeSupported, fieldNumber: 7) } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_ProxySubscriptionRejectedEntry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionRejectedEntry" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "ordinal"), + 2: .same(proto: "reason"), + 3: .same(proto: "scheme"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularInt32Field(value: &self.ordinal) + case 2: try decoder.decodeSingularStringField(value: &self.reason) + case 3: try decoder.decodeSingularStringField(value: &self.scheme) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.ordinal != 0 { try visitor.visitSingularInt32Field(value: self.ordinal, fieldNumber: 1) } + if !self.reason.isEmpty { try visitor.visitSingularStringField(value: self.reason, fieldNumber: 2) } + if !self.scheme.isEmpty { try visitor.visitSingularStringField(value: self.scheme, fieldNumber: 3) } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_ProxySubscriptionPreviewResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionPreviewResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .standard(proto: "suggested_name"), + 2: .standard(proto: "detected_format"), + 3: .standard(proto: "compatible_count"), + 4: .standard(proto: "rejected_count"), + 5: .same(proto: "node"), + 6: .same(proto: "rejected"), + 7: .same(proto: "warnings"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularStringField(value: &self.suggestedName) + case 2: try decoder.decodeSingularStringField(value: &self.detectedFormat) + case 3: try decoder.decodeSingularInt32Field(value: &self.compatibleCount) + case 4: try decoder.decodeSingularInt32Field(value: &self.rejectedCount) + case 5: try decoder.decodeRepeatedMessageField(value: &self.node) + case 6: try decoder.decodeRepeatedMessageField(value: &self.rejected) + case 7: try decoder.decodeRepeatedStringField(value: &self.warnings) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.suggestedName.isEmpty { try visitor.visitSingularStringField(value: self.suggestedName, fieldNumber: 1) } + if !self.detectedFormat.isEmpty { try visitor.visitSingularStringField(value: self.detectedFormat, fieldNumber: 2) } + if self.compatibleCount != 0 { try visitor.visitSingularInt32Field(value: self.compatibleCount, fieldNumber: 3) } + if self.rejectedCount != 0 { try visitor.visitSingularInt32Field(value: self.rejectedCount, fieldNumber: 4) } + if !self.node.isEmpty { try visitor.visitRepeatedMessageField(value: self.node, fieldNumber: 5) } + if !self.rejected.isEmpty { try visitor.visitRepeatedMessageField(value: self.rejected, fieldNumber: 6) } + if !self.warnings.isEmpty { try visitor.visitRepeatedStringField(value: self.warnings, fieldNumber: 7) } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_ProxySubscriptionApplyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionApplyRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "id"), + 2: .same(proto: "url"), + 3: .same(proto: "name"), + 4: .standard(proto: "selected_ordinal"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularInt32Field(value: &self.id) + case 2: try decoder.decodeSingularStringField(value: &self.url) + case 3: try decoder.decodeSingularStringField(value: &self.name) + case 4: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } + if !self.url.isEmpty { try visitor.visitSingularStringField(value: self.url, fieldNumber: 2) } + if !self.name.isEmpty { try visitor.visitSingularStringField(value: self.name, fieldNumber: 3) } + if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 4) } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_ProxySubscriptionApplyResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionApplyResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "id"), + 2: .standard(proto: "imported_count"), + 3: .standard(proto: "selected_ordinal"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularInt32Field(value: &self.id) + case 2: try decoder.decodeSingularInt32Field(value: &self.importedCount) + case 3: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } + if self.importedCount != 0 { try visitor.visitSingularInt32Field(value: self.importedCount, fieldNumber: 2) } + if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 3) } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_ProxySubscriptionSelectRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionSelectRequest" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "id"), + 2: .standard(proto: "selected_ordinal"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularInt32Field(value: &self.id) + case 2: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } + if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 2) } + try unknownFields.traverse(visitor: &visitor) + } +} + +extension Burrow_ProxySubscriptionSelectResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = "burrow.ProxySubscriptionSelectResponse" + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 1: .same(proto: "id"), + 2: .standard(proto: "selected_ordinal"), + ] + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + switch fieldNumber { + case 1: try decoder.decodeSingularInt32Field(value: &self.id) + case 2: try decoder.decodeSingularInt32Field(value: &self.selectedOrdinal) + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.id != 0 { try visitor.visitSingularInt32Field(value: self.id, fieldNumber: 1) } + if self.selectedOrdinal != 0 { try visitor.visitSingularInt32Field(value: self.selectedOrdinal, fieldNumber: 2) } + try unknownFields.traverse(visitor: &visitor) + } +} + public struct TailnetClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions @@ -487,6 +797,52 @@ public struct TailnetClient: Client, GRPCClient { } } +public struct ProxySubscriptionClient: Client, GRPCClient { + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions + + public init(channel: any GRPCChannel) { + self.channel = channel + self.defaultCallOptions = .init() + } + + public func previewImport( + _ request: Burrow_ProxySubscriptionImportRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_ProxySubscriptionPreviewResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.ProxySubscriptions/PreviewImport", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func applyImport( + _ request: Burrow_ProxySubscriptionApplyRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_ProxySubscriptionApplyResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.ProxySubscriptions/ApplyImport", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } + + public func selectNode( + _ request: Burrow_ProxySubscriptionSelectRequest, + callOptions: CallOptions? = nil + ) async throws -> Burrow_ProxySubscriptionSelectResponse { + try await self.performAsyncUnaryCall( + path: "/burrow.ProxySubscriptions/SelectNode", + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: [] + ) + } +} + public struct TunnelPacketClient: Client, GRPCClient { public let channel: GRPCChannel public var defaultCallOptions: CallOptions diff --git a/Apple/Core/Client/Generated/burrow.pb.swift b/Apple/Core/Client/Generated/burrow.pb.swift index fccd769..d235078 100644 --- a/Apple/Core/Client/Generated/burrow.pb.swift +++ b/Apple/Core/Client/Generated/burrow.pb.swift @@ -25,6 +25,7 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case wireGuard // = 0 case tailnet // = 1 + case proxySubscription // = 3 case UNRECOGNIZED(Int) public init() { @@ -35,6 +36,7 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { switch rawValue { case 0: self = .wireGuard case 1: self = .tailnet + case 3: self = .proxySubscription default: self = .UNRECOGNIZED(rawValue) } } @@ -43,6 +45,7 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { switch self { case .wireGuard: return 0 case .tailnet: return 1 + case .proxySubscription: return 3 case .UNRECOGNIZED(let i): return i } } @@ -51,6 +54,7 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable { public static let allCases: [Burrow_NetworkType] = [ .wireGuard, .tailnet, + .proxySubscription, ] } @@ -217,6 +221,8 @@ public struct Burrow_TunnelConfigurationResponse: Sendable { public var routes: [String] = [] + public var excludedRoutes: [String] = [] + public var dnsServers: [String] = [] public var searchDomains: [String] = [] @@ -236,6 +242,7 @@ extension Burrow_NetworkType: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ 0: .same(proto: "WireGuard"), 1: .same(proto: "Tailnet"), + 3: .same(proto: "ProxySubscription"), ] } @@ -544,6 +551,7 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob 4: .standard(proto: "dns_servers"), 5: .standard(proto: "search_domains"), 6: .standard(proto: "include_default_route"), + 7: .standard(proto: "excluded_routes"), ] public mutating func decodeMessage(decoder: inout D) throws { @@ -558,6 +566,7 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }() case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }() case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }() + case 7: try { try decoder.decodeRepeatedStringField(value: &self.excludedRoutes) }() default: break } } @@ -573,6 +582,9 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob if !self.routes.isEmpty { try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3) } + if !self.excludedRoutes.isEmpty { + try visitor.visitRepeatedStringField(value: self.excludedRoutes, fieldNumber: 7) + } if !self.dnsServers.isEmpty { try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4) } @@ -589,6 +601,7 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob if lhs.addresses != rhs.addresses {return false} if lhs.mtu != rhs.mtu {return false} if lhs.routes != rhs.routes {return false} + if lhs.excludedRoutes != rhs.excludedRoutes {return false} if lhs.dnsServers != rhs.dnsServers {return false} if lhs.searchDomains != rhs.searchDomains {return false} if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false} diff --git a/Apple/NetworkExtension/NetworkExtension.xcconfig b/Apple/NetworkExtension/NetworkExtension.xcconfig index 35c7198..a6a2635 100644 --- a/Apple/NetworkExtension/NetworkExtension.xcconfig +++ b/Apple/NetworkExtension/NetworkExtension.xcconfig @@ -9,3 +9,5 @@ CODE_SIGN_ENTITLEMENTS = NetworkExtension/NetworkExtension-iOS.entitlements CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = NetworkExtension/NetworkExtension-macOS.entitlements SWIFT_INCLUDE_PATHS = $(inherited) $(PROJECT_DIR)/NetworkExtension + +OTHER_LDFLAGS = $(inherited) -framework SystemConfiguration diff --git a/Apple/NetworkExtension/PacketTunnelProvider.swift b/Apple/NetworkExtension/PacketTunnelProvider.swift index 3f3d8b4..12f0cd0 100644 --- a/Apple/NetworkExtension/PacketTunnelProvider.swift +++ b/Apple/NetworkExtension/PacketTunnelProvider.swift @@ -28,13 +28,13 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { get throws { try _client.get() } } private let _client: Result = Result { - try TunnelClient.unix(socketURL: Constants.socketURL) + try TunnelClient.unix(socketURL: Constants.packetTunnelSocketURL) } override init() { do { libburrow.spawnInProcess( - socketPath: try Constants.socketURL.path(percentEncoded: false), + socketPath: try Constants.packetTunnelSocketURL.path(percentEncoded: false), databasePath: try Constants.databaseURL.path(percentEncoded: false) ) } catch { @@ -54,6 +54,11 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable { guard let settings = configuration?.settings else { throw Error.missingTunnelConfiguration } + if let configuration { + logger.log( + "Applying tunnel configuration addresses=\(configuration.addresses.joined(separator: ","), privacy: .public) routes=\(configuration.routes.joined(separator: ","), privacy: .public) excludedRoutes=\(configuration.excludedRoutes.joined(separator: ","), privacy: .public) dns=\(configuration.dnsServers.joined(separator: ","), privacy: .public) includeDefaultRoute=\(configuration.includeDefaultRoute, privacy: .public)" + ) + } try await setTunnelNetworkSettings(settings) try startPacketBridge() logger.log("Started tunnel with network settings: \(settings)") @@ -88,15 +93,22 @@ extension PacketTunnelProvider { private func startPacketBridge() throws { stopPacketBridge() - let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL) + let packetClient = TunnelPacketClient.unix(socketURL: try Constants.packetTunnelSocketURL) let call = packetClient.makeTunnelPacketsCall() self.packetCall = call inboundPacketTask = Task { [weak self] in guard let self else { return } + var packetCount = 0 + var byteCount = 0 do { for try await packet in call.responseStream { let payload = packet.payload + packetCount += 1 + byteCount += payload.count + if packetCount == 1 || packetCount % 1024 == 0 { + self.logger.log("Tunnel packet receive progress packets=\(packetCount, privacy: .public) bytes=\(byteCount, privacy: .public) lastBytes=\(payload.count, privacy: .public)") + } self.packetFlow.writePackets( [payload], withProtocols: [Self.protocolNumber(for: payload)] @@ -111,10 +123,17 @@ extension PacketTunnelProvider { outboundPacketTask = Task { [weak self] in guard let self else { return } defer { call.requestStream.finish() } + var packetCount = 0 + var byteCount = 0 do { while !Task.isCancelled { let packets = await self.readPacketsBatch() for (payload, _) in packets { + packetCount += 1 + byteCount += payload.count + if packetCount == 1 || packetCount % 1024 == 0 { + self.logger.log("Tunnel packet send progress packets=\(packetCount, privacy: .public) bytes=\(byteCount, privacy: .public) lastBytes=\(payload.count, privacy: .public)") + } var packet = Burrow_TunnelPacket() packet.payload = payload try await call.requestStream.send(packet) @@ -128,6 +147,7 @@ extension PacketTunnelProvider { } private func stopPacketBridge() { + logger.log("Stopping tunnel packet bridge") inboundPacketTask?.cancel() inboundPacketTask = nil outboundPacketTask?.cancel() @@ -165,6 +185,9 @@ extension Burrow_TunnelConfigurationResponse { let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:)) var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route) var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route) + let parsedExcludedRoutes = excludedRoutes.compactMap(ParsedTunnelRoute.init(rawValue:)) + let excludedIPv4Routes = parsedExcludedRoutes.compactMap(\.ipv4Route) + let excludedIPv6Routes = parsedExcludedRoutes.compactMap(\.ipv6Route) if includeDefaultRoute { ipv4Routes.append(.default()) ipv6Routes.append(.default()) @@ -180,6 +203,9 @@ extension Burrow_TunnelConfigurationResponse { if !ipv4Routes.isEmpty { ipv4Settings.includedRoutes = ipv4Routes } + if !excludedIPv4Routes.isEmpty { + ipv4Settings.excludedRoutes = excludedIPv4Routes + } settings.ipv4Settings = ipv4Settings } if !ipv6Addresses.isEmpty { @@ -190,6 +216,9 @@ extension Burrow_TunnelConfigurationResponse { if !ipv6Routes.isEmpty { ipv6Settings.includedRoutes = ipv6Routes } + if !excludedIPv6Routes.isEmpty { + ipv6Settings.excludedRoutes = excludedIPv6Routes + } settings.ipv6Settings = ipv6Settings } if !dnsServers.isEmpty { diff --git a/Apple/NetworkExtension/libburrow/build-rust.sh b/Apple/NetworkExtension/libburrow/build-rust.sh index 5db2a2b..79a45d6 100755 --- a/Apple/NetworkExtension/libburrow/build-rust.sh +++ b/Apple/NetworkExtension/libburrow/build-rust.sh @@ -3,7 +3,7 @@ # This is a build script. It is run by Xcode as a build step. # The type of build is described in various environment variables set by Xcode. -export PATH="${PATH}:${HOME}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/${USER}/bin" +export PATH="${PATH}:${HOME}/.cargo/bin:${HOME}/.nix-profile/bin:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/${USER}/bin:/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin" if ! [[ -x "$(command -v cargo)" ]]; then echo 'error: Unable to find cargo' @@ -63,13 +63,13 @@ else fi if [[ -x "$(command -v rustup)" ]]; then - CARGO_PATH="$(dirname $(rustup which cargo)):/usr/bin" + CARGO_PATH="$(dirname "$(rustup which cargo)"):/usr/bin" else - CARGO_PATH="$(dirname $(readlink -f $(which cargo))):/usr/bin" + CARGO_PATH="$(dirname "$(readlink -f "$(command -v cargo)")"):/usr/bin" fi -PROTOC=$(readlink -f $(which protoc)) -CARGO_PATH="$(dirname $PROTOC):$CARGO_PATH" +PROTOC="$(readlink -f "$(command -v protoc)")" +CARGO_PATH="$(dirname "$PROTOC"):$CARGO_PATH" # Run cargo without the various environment variables set by Xcode. # Those variables can confuse cargo and the build scripts it runs. diff --git a/Apple/UI/BurrowView.swift b/Apple/UI/BurrowView.swift index e15d3f7..bc9e361 100644 --- a/Apple/UI/BurrowView.swift +++ b/Apple/UI/BurrowView.swift @@ -1,5 +1,6 @@ import BurrowConfiguration import Foundation +import GRPC import SwiftUI #if canImport(AuthenticationServices) import AuthenticationServices @@ -15,6 +16,7 @@ public struct BurrowView: View { @State private var accountStore = NetworkAccountStore() @State private var activeSheet: ConfigurationSheet? @State private var didRunAutomation = false + @State private var expandedProxySubscriptionID: Int32? public var body: some View { NavigationStack { @@ -37,6 +39,9 @@ public struct BurrowView: View { Button("Add WireGuard Network") { activeSheet = .wireGuard } + Button("Import Proxy Subscription") { + activeSheet = .proxySubscription + } Button("Save Tor Account") { activeSheet = .tor } @@ -67,8 +72,22 @@ public struct BurrowView: View { Text(connectionError) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(4) + .truncationMode(.middle) + } + NetworkCarouselView( + networks: networkViewModel.cards, + selectedNetworkID: expandedProxySubscriptionID + ) { network in + guard networkViewModel.proxySubscriptions.contains(where: { $0.id == network.id }) else { + return + } + withAnimation(.snappy) { + expandedProxySubscriptionID = expandedProxySubscriptionID == network.id + ? nil + : network.id + } } - NetworkCarouselView(networks: networkViewModel.cards) } if showsAccountsSection { @@ -119,6 +138,15 @@ public struct BurrowView: View { accountStore: accountStore ) } + .sheet(isPresented: proxySubscriptionSheetBinding) { + ProxySubscriptionManagerView( + networks: networkViewModel.proxySubscriptions, + networkViewModel: networkViewModel, + selectedNetworkID: $expandedProxySubscriptionID + ) + .frame(width: 820, height: 600) + .padding(20) + } .onAppear { runAutomationIfNeeded() } @@ -157,15 +185,29 @@ public struct BurrowView: View { } } + private var proxySubscriptionSheetBinding: Binding { + Binding( + get: { expandedProxySubscriptionID != nil }, + set: { isPresented in + guard !isPresented else { return } + expandedProxySubscriptionID = nil + } + ) + } + @ViewBuilder private func sectionHeader(title: String, detail: String?) -> some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.title2.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) if let detail, !detail.isEmpty { Text(detail) .font(.subheadline) .foregroundStyle(.secondary) + .lineLimit(3) + .truncationMode(.tail) } } } @@ -190,13 +232,14 @@ public struct BurrowView: View { #if os(iOS) !accountStore.accounts.isEmpty #else - true + !accountStore.accounts.isEmpty || networkViewModel.networks.isEmpty #endif } } private enum ConfigurationSheet: String, CaseIterable, Identifiable { case wireGuard + case proxySubscription case tor case tailnet @@ -205,6 +248,7 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { var kind: AccountNetworkKind { switch self { case .wireGuard: .wireGuard + case .proxySubscription: .proxySubscription case .tor: .tor case .tailnet: .tailnet } @@ -214,6 +258,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: "wave.3.right" + case .proxySubscription: + "link.badge.plus" case .tor: "shield.lefthalf.filled.badge.checkmark" case .tailnet: @@ -225,6 +271,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: "WireGuard" + case .proxySubscription: + "Proxy Subscription" case .tor: "Tor" case .tailnet: @@ -236,6 +284,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: "Import a tunnel" + case .proxySubscription: + "Import Trojan or Shadowsocks" case .tor: "Save an Arti profile" case .tailnet: @@ -247,6 +297,8 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable { switch self { case .wireGuard: .blue + case .proxySubscription: + .teal case .tor, .tailnet: kind.accentColor } @@ -286,6 +338,8 @@ private struct AccountDraft { var accountName = "" var identityName = "" var wireGuardConfig = "" + var proxySubscriptionURL = "" + var proxySubscriptionName = "" var discoveryEmail = "" var authority = "" @@ -304,6 +358,8 @@ private struct AccountDraft { switch sheet { case .wireGuard: break + case .proxySubscription: + title = "Proxy Subscription" case .tor: title = "Default Tor" accountName = "default" @@ -338,6 +394,11 @@ private struct ConfigurationSheetView: View { @State private var tailnetLoginError: String? @State private var tailnetLoginSessionID: String? @State private var isStartingTailnetLogin = false + @State private var proxySubscriptionPreview: ProxySubscriptionPreview? + @State private var proxySubscriptionPreviewError: String? + @State private var isPreviewingProxySubscription = false + @State private var selectedProxySubscriptionOrdinal: Int32? + @State private var proxySubscriptionNodeFilter = "" @State private var tailnetPresentedAuthURL: URL? @State private var preserveTailnetLoginSession = false @State private var usesCustomTailnetAuthority = false @@ -374,31 +435,11 @@ private struct ConfigurationSheetView: View { } } - switch sheet { - case .wireGuard: - Section("WireGuard Configuration") { - TextEditor(text: $draft.wireGuardConfig) - .font(.body.monospaced()) - .frame(minHeight: wireGuardEditorHeight) - .contextMenu { - wireGuardContextActions - } - } - case .tor: - Section("Tor Preferences") { - TextField("Virtual Addresses", text: $draft.torAddresses) - TextField("DNS Resolvers", text: $draft.torDNS) - TextField("MTU", text: $draft.torMTU) - TextField("Transparent Listener", text: $draft.torListen) - } - case .tailnet: - tailnetSections - } + configurationSections if let errorMessage { Section { - Text(errorMessage) - .foregroundStyle(.red) + feedbackText(errorMessage, color: .red) } } } @@ -445,7 +486,8 @@ private struct ConfigurationSheetView: View { } } #if os(macOS) - .frame(minWidth: 520, minHeight: 620) + .frame(width: 520) + .frame(minHeight: 620) #endif .safeAreaInset(edge: .bottom) { if showsBottomActionButton { @@ -473,6 +515,12 @@ private struct ConfigurationSheetView: View { await cancelTailnetLoginIfNeeded() } } + .onChange(of: draft.proxySubscriptionURL) { _, _ in + proxySubscriptionPreview = nil + proxySubscriptionPreviewError = nil + selectedProxySubscriptionOrdinal = nil + proxySubscriptionNodeFilter = "" + } .onDisappear { tailnetLoginPollTask?.cancel() tailnetDiscoveryTask?.cancel() @@ -486,6 +534,71 @@ private struct ConfigurationSheetView: View { } } + @ViewBuilder + private var configurationSections: some View { + switch sheet { + case .wireGuard: + wireGuardConfigurationSection + case .proxySubscription: + proxySubscriptionSections + case .tor: + torPreferenceSection + case .tailnet: + tailnetSections + } + } + + private var wireGuardConfigurationSection: some View { + Section("WireGuard Configuration") { + TextEditor(text: $draft.wireGuardConfig) + .font(.body.monospaced()) + .frame(minHeight: wireGuardEditorHeight) + .contextMenu { + wireGuardContextActions + } + } + } + + @ViewBuilder + private var proxySubscriptionSections: some View { + Section("Subscription") { + TextField("Subscription URL", text: $draft.proxySubscriptionURL) + .autocorrectionDisabled() + TextField("Name", text: $draft.proxySubscriptionName) + } + + Section("Preview") { + proxySubscriptionPreviewButton + + if let proxySubscriptionPreview { + proxySubscriptionPreviewView(proxySubscriptionPreview) + } else if let proxySubscriptionPreviewError { + feedbackText(proxySubscriptionPreviewError, color: .red) + } + } + } + + private var proxySubscriptionPreviewButton: some View { + Button { + previewProxySubscription() + } label: { + Label( + isPreviewingProxySubscription ? "Previewing" : "Preview", + systemImage: isPreviewingProxySubscription ? "hourglass" : "eye" + ) + } + .disabled(isPreviewingProxySubscription || normalizedOptional(draft.proxySubscriptionURL) == nil) + } + + private var torPreferenceSection: some View { + Section("Tor Preferences") { + TextField("Virtual Addresses", text: $draft.torAddresses) + TextField("DNS Resolvers", text: $draft.torDNS) + TextField("MTU", text: $draft.torMTU) + TextField("Transparent Listener", text: $draft.torListen) + } + } + @ViewBuilder private var identityFields: some View { TextField("Title", text: $draft.title) @@ -792,6 +905,143 @@ private struct ConfigurationSheetView: View { ) } + private func proxySubscriptionPreviewView(_ preview: ProxySubscriptionPreview) -> some View { + VStack(alignment: .leading, spacing: 8) { + labeledValue("Format", preview.detectedFormat) + labeledValue("Compatible", "\(preview.compatibleCount)") + labeledValue("Runtime Supported", "\(preview.nodes.filter { $0.runtimeSupported }.count)") + labeledValue("Rejected", "\(preview.rejectedCount)") + + let selectableNodes = supportedProxySubscriptionNodes(in: preview) + if !selectableNodes.isEmpty { + TextField("Filter nodes", text: $proxySubscriptionNodeFilter) + .autocorrectionDisabled() + + let filteredNodes = filteredProxySubscriptionNodes(selectableNodes) + proxySubscriptionNodeList(nodes: filteredNodes, preview: preview) + + if !proxySubscriptionNodeFilter.isEmpty && filteredNodes.isEmpty { + feedbackText("No nodes match the current filter.", color: .orange) + } + } else if !preview.nodes.isEmpty { + Text("No previewed nodes are supported by the packet runtime.") + .font(.caption) + .foregroundStyle(.orange) + } + + ForEach(preview.warnings, id: \.self) { warning in + feedbackText(warning, color: .orange) + } + } + } + + private func proxySubscriptionNodeList( + nodes: [ProxySubscriptionNodePreview], + preview: ProxySubscriptionPreview + ) -> some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 6) { + ForEach(nodes) { node in + proxySubscriptionNodeRow( + node: node, + isSelected: selectedProxySubscriptionNode(in: preview)?.ordinal == node.ordinal + ) + } + } + .padding(.vertical, 2) + } + .frame(maxHeight: 260) + } + + private func proxySubscriptionNodeRow( + node: ProxySubscriptionNodePreview, + isSelected: Bool + ) -> some View { + Button { + selectedProxySubscriptionOrdinal = node.ordinal + } label: { + HStack(spacing: 8) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .imageScale(.small) + .frame(width: 16) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(node.name) + .font(.footnote.weight(isSelected ? .semibold : .regular)) + .lineLimit(1) + .truncationMode(.tail) + Text(node.proxyProtocol.uppercased()) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + Capsule() + .fill(.secondary.opacity(0.12)) + ) + } + + Text("\(node.server):\(node.port)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected ? Color.accentColor.opacity(0.14) : Color.secondary.opacity(0.08)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor.opacity(0.45) : Color.clear, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func supportedProxySubscriptionNodes( + in preview: ProxySubscriptionPreview + ) -> [ProxySubscriptionNodePreview] { + preview.nodes.filter { $0.runtimeSupported } + } + + private func filteredProxySubscriptionNodes( + _ nodes: [ProxySubscriptionNodePreview] + ) -> [ProxySubscriptionNodePreview] { + let query = proxySubscriptionNodeFilter.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return nodes } + return nodes.filter { node in + node.name.localizedCaseInsensitiveContains(query) + || node.server.localizedCaseInsensitiveContains(query) + || node.proxyProtocol.localizedCaseInsensitiveContains(query) + } + } + + private func selectedProxySubscriptionNode(in preview: ProxySubscriptionPreview) -> ProxySubscriptionNodePreview? { + let ordinal = selectedProxySubscriptionOrdinal + ?? preview.nodes.first(where: { $0.runtimeSupported })?.ordinal + return preview.nodes.first { $0.ordinal == ordinal } + } + + private func feedbackText(_ message: String, color: Color) -> some View { + Text(message) + .font(.caption) + .foregroundStyle(color) + .lineLimit(6) + .truncationMode(.middle) + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + @ViewBuilder private var bottomActionBar: some View { VStack(spacing: 0) { @@ -827,6 +1077,12 @@ private struct ConfigurationSheetView: View { } .disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + case .proxySubscription: + Button("Preview Subscription") { + previewProxySubscription() + } + .disabled(normalizedOptional(draft.proxySubscriptionURL) == nil) + case .tor: Menu("Presets") { Button("Recommended Tor Defaults") { @@ -883,6 +1139,8 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: .blue + case .proxySubscription: + .teal case .tor, .tailnet: sheet.kind.accentColor } @@ -892,6 +1150,8 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: "Import WireGuard" + case .proxySubscription: + "Import Proxy Subscription" case .tor: "Configure Tor" case .tailnet: @@ -911,6 +1171,8 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard, .tor: return true + case .proxySubscription: + return false case .tailnet: return showsAdvancedTailnetSettings } @@ -928,6 +1190,8 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: return "Add Network" + case .proxySubscription: + return "Import" case .tor: return "Save Account" case .tailnet: @@ -942,7 +1206,7 @@ private struct ConfigurationSheetView: View { return normalizedOptional(draft.authority) == nil } return false - case .wireGuard, .tor: + case .wireGuard, .tor, .proxySubscription: return true } } @@ -951,6 +1215,9 @@ private struct ConfigurationSheetView: View { switch sheet { case .wireGuard: return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + case .proxySubscription: + return normalizedOptional(draft.proxySubscriptionURL) == nil + || proxySubscriptionPreview?.nodes.contains(where: { $0.runtimeSupported }) != true case .tor: return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil case .tailnet: @@ -1025,6 +1292,9 @@ private struct ConfigurationSheetView: View { case .wireGuard: try await submitWireGuard() dismiss() + case .proxySubscription: + try await submitProxySubscription() + dismiss() case .tor: try submitTor() dismiss() @@ -1032,7 +1302,7 @@ private struct ConfigurationSheetView: View { try await submitTailnet() } } catch { - errorMessage = error.localizedDescription + errorMessage = displayError(error) } } } @@ -1062,6 +1332,44 @@ private struct ConfigurationSheetView: View { try accountStore.upsert(record, secret: nil) } + private func submitProxySubscription() async throws { + let url = normalized(draft.proxySubscriptionURL, fallback: "") + let name = normalized(draft.proxySubscriptionName, fallback: "Proxy Subscription") + let selectedOrdinal = selectedProxySubscriptionOrdinal + ?? proxySubscriptionPreview?.nodes.first(where: { $0.runtimeSupported })?.ordinal + _ = try await networkViewModel.importProxySubscription( + url: url, + name: name, + selectedOrdinal: selectedOrdinal + ) + } + + private func previewProxySubscription() { + guard let url = normalizedOptional(draft.proxySubscriptionURL) else { + proxySubscriptionPreviewError = "Enter a subscription URL before previewing." + return + } + isPreviewingProxySubscription = true + proxySubscriptionPreviewError = nil + Task { @MainActor in + defer { isPreviewingProxySubscription = false } + do { + let preview = try await networkViewModel.previewProxySubscription(url: url) + proxySubscriptionPreview = preview + selectedProxySubscriptionOrdinal = preview.nodes.first(where: { $0.runtimeSupported })?.ordinal + proxySubscriptionNodeFilter = "" + if draft.proxySubscriptionName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + draft.proxySubscriptionName = preview.suggestedName + } + } catch { + proxySubscriptionPreview = nil + selectedProxySubscriptionOrdinal = nil + proxySubscriptionNodeFilter = "" + proxySubscriptionPreviewError = displayError(error) + } + } + } + private func submitTor() throws { let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))") let note = [ @@ -1529,6 +1837,8 @@ private struct ConfigurationSheetView: View { .foregroundStyle(.secondary) Text(value) .font(.body.monospaced()) + .lineLimit(3) + .truncationMode(.middle) } } } @@ -1543,9 +1853,11 @@ private struct AccountRowView: View { VStack(alignment: .leading, spacing: 4) { Text(account.title) .font(.headline) + .lineLimit(1) + .truncationMode(.tail) Text(account.kind.title) - .font(.subheadline) - .foregroundStyle(account.kind.accentColor) + .font(.subheadline) + .foregroundStyle(account.kind.accentColor) } Spacer() if hasSecret { @@ -1578,6 +1890,8 @@ private struct AccountRowView: View { Text(note) .font(.footnote) .foregroundStyle(.secondary) + .lineLimit(3) + .truncationMode(.middle) } } .padding() @@ -1596,11 +1910,378 @@ private struct AccountRowView: View { .foregroundStyle(.secondary) Text(value) .font(.body.monospaced()) + .lineLimit(3) + .truncationMode(.middle) + } + } +} + +private struct ProxySubscriptionManagerView: View { + @Environment(\.tunnel) + private var tunnel: any Tunnel + + let networks: [ProxySubscriptionNetwork] + let networkViewModel: NetworkViewModel + @Binding var selectedNetworkID: Int32? + + @State private var nodeFilter = "" + @State private var operationID: String? + @State private var feedback: String? + @State private var errorMessage: String? + @State private var pendingDelete: ProxySubscriptionNetwork? + + var body: some View { + if let network = selectedNetwork { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Proxy Subscription") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Text(network.name) + .font(.title3.weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + if let selectedNode = network.selectedNode { + Text("\(selectedNode.proxyProtocol.uppercased()) \(selectedNode.server):\(selectedNode.port)") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer(minLength: 0) + + HStack(spacing: 8) { + Button { + refresh(network) + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(isBusy) + + Button(role: .destructive) { + pendingDelete = network + } label: { + Label("Remove", systemImage: "trash") + } + .disabled(isBusy) + } + .burrowGlassControl() + .controlSize(.small) + + Button { + withAnimation(.snappy) { + selectedNetworkID = nil + } + } label: { + Image(systemName: "xmark.circle.fill") + .font(.title3) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Close proxy subscription picker") + } + + if networks.count > 1 { + Picker("Subscription", selection: selectedNetworkIDBinding) { + ForEach(networks) { network in + Text(network.name).tag(network.id) + } + } + .pickerStyle(.menu) + } + + HStack(spacing: 16) { + Label(network.detectedFormat, systemImage: "doc.text") + Label("\(network.runtimeSupportedNodes.count) usable of \(network.nodes.count) nodes", systemImage: "circle.grid.2x1") + } + .font(.caption) + .foregroundStyle(.secondary) + + TextField("Filter nodes", text: $nodeFilter) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + + HStack { + Text("Nodes") + .font(.subheadline.weight(.semibold)) + Spacer() + Text("\(filteredNodes(for: network).count) shown") + .font(.caption) + .foregroundStyle(.secondary) + } + + ScrollView { + LazyVGrid(columns: nodeColumns, alignment: .leading, spacing: 10) { + ForEach(filteredNodes(for: network)) { node in + nodeTile(node, in: network) + } + } + .padding(.vertical, 2) + } + .frame(maxHeight: 410) + .scrollIndicators(.visible) + + if let feedback { + Text(feedback) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + .truncationMode(.tail) + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(3) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + ensureSelectedNetwork() + } + .onChange(of: networks) { _, _ in + ensureSelectedNetwork() + } + .confirmationDialog( + "Remove proxy subscription?", + isPresented: Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil } } + ), + presenting: pendingDelete + ) { network in + Button("Remove \(network.name)", role: .destructive) { + delete(network) + } + } + } + } + + private var selectedNetwork: ProxySubscriptionNetwork? { + guard let selectedNetworkID else { + return nil + } + return networks.first(where: { $0.id == selectedNetworkID }) + } + + private var selectedNetworkIDBinding: Binding { + Binding( + get: { selectedNetwork?.id ?? networks.first?.id ?? 0 }, + set: { selectedNetworkID = $0 } + ) + } + + private var isBusy: Bool { + operationID != nil + } + + private var nodeColumns: [GridItem] { + [ + GridItem( + .adaptive(minimum: 245, maximum: 320), + spacing: 10, + alignment: .top + ) + ] + } + + private func ensureSelectedNetwork() { + guard let selectedNetworkID else { + return + } + if !networks.contains(where: { $0.id == selectedNetworkID }) { + self.selectedNetworkID = nil + } + } + + private func filteredNodes(for network: ProxySubscriptionNetwork) -> [ProxySubscriptionNetworkNode] { + let query = nodeFilter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { + return network.nodes + } + return network.nodes.filter { node in + node.name.lowercased().contains(query) + || node.server.lowercased().contains(query) + || node.proxyProtocol.lowercased().contains(query) + } + } + + private func nodeTile( + _ node: ProxySubscriptionNetworkNode, + in network: ProxySubscriptionNetwork + ) -> some View { + let isSelected = network.selectedNode?.ordinal == node.ordinal + return Button { + select(node, in: network) + } label: { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top, spacing: 8) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Color.accentColor : Color.secondary) + .frame(width: 18) + + VStack(alignment: .leading, spacing: 4) { + Text(node.name) + .font(.footnote.weight(isSelected ? .semibold : .regular)) + .lineLimit(1) + .truncationMode(.tail) + + HStack(spacing: 6) { + Text(node.proxyProtocol.uppercased()) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(Capsule().fill(.secondary.opacity(0.12))) + if !node.runtimeSupported { + Text("UNSUPPORTED") + .font(.caption2.weight(.medium)) + .foregroundStyle(.orange) + } + } + } + + Spacer(minLength: 0) + } + + Text("\(node.server):\(node.port)") + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + + if operationID == operationIdentifier(networkID: network.id, ordinal: node.ordinal) { + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + .padding(12) + .frame(minHeight: 94, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(isSelected ? Color.accentColor.opacity(0.10) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(isSelected ? Color.accentColor.opacity(0.65) : Color.secondary.opacity(0.16), lineWidth: 1) + ) + } + .buttonStyle(.plain) + .disabled(isBusy || !node.runtimeSupported) + } + + private func select( + _ node: ProxySubscriptionNetworkNode, + in network: ProxySubscriptionNetwork + ) { + let operationIdentifier = operationIdentifier(networkID: network.id, ordinal: node.ordinal) + operationID = operationIdentifier + feedback = nil + errorMessage = nil + Task { @MainActor in + defer { operationID = nil } + do { + _ = try await networkViewModel.updateProxySubscriptionSelection( + network: network, + selectedOrdinal: node.ordinal + ) + if isTunnelRunning { + _ = try await networkViewModel.updateActiveProxySubscriptionSelection( + network: network, + selectedOrdinal: node.ordinal + ) + feedback = "Selected \(node.name) for the active tunnel" + } else { + feedback = "Selected \(node.name)" + } + } catch { + errorMessage = displayError(error) + } + } + } + + private func refresh(_ network: ProxySubscriptionNetwork) { + operationID = "refresh-\(network.id)" + feedback = nil + errorMessage = nil + Task { @MainActor in + defer { operationID = nil } + do { + _ = try await networkViewModel.refreshProxySubscription(network: network) + if isTunnelRunning { + _ = try await networkViewModel.refreshActiveProxySubscription(network: network) + feedback = "Refreshed \(network.name) for the active tunnel" + } else { + feedback = "Refreshed \(network.name)" + } + } catch { + errorMessage = displayError(error) + } + } + } + + private func delete(_ network: ProxySubscriptionNetwork) { + operationID = "delete-\(network.id)" + feedback = nil + errorMessage = nil + Task { @MainActor in + defer { operationID = nil } + do { + try await networkViewModel.deleteNetwork(id: network.id) + pendingDelete = nil + selectedNetworkID = nil + feedback = nil + } catch { + errorMessage = displayError(error) + } + } + } + + private func operationIdentifier(networkID: Int32, ordinal: Int32) -> String { + "select-\(networkID)-\(ordinal)" + } + + @MainActor + private var isTunnelRunning: Bool { + switch tunnel.status { + case .connected, .reasserting: + true + default: + false + } + } + + private func labeledValue(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.tail) } } } private extension View { + @ViewBuilder + func burrowGlassControl() -> some View { + if #available(macOS 26.0, iOS 26.0, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + @ViewBuilder func burrowLoginField() -> some View { #if os(iOS) @@ -1674,6 +2355,13 @@ private final class TailnetBrowserAuthenticator { } #endif +private func displayError(_ error: Error) -> String { + if let status = error as? GRPCStatus { + return status.message ?? status.description + } + return error.localizedDescription +} + private struct BurrowAutomationConfig { enum Action: String { case tailnetLogin = "tailnet-login" diff --git a/Apple/UI/NetworkCarouselView.swift b/Apple/UI/NetworkCarouselView.swift index e7368db..d109672 100644 --- a/Apple/UI/NetworkCarouselView.swift +++ b/Apple/UI/NetworkCarouselView.swift @@ -2,6 +2,18 @@ import SwiftUI struct NetworkCarouselView: View { var networks: [NetworkCardModel] + var selectedNetworkID: Int32? + var onSelect: ((NetworkCardModel) -> Void)? + + init( + networks: [NetworkCardModel], + selectedNetworkID: Int32? = nil, + onSelect: ((NetworkCardModel) -> Void)? = nil + ) { + self.networks = networks + self.selectedNetworkID = selectedNetworkID + self.onSelect = onSelect + } var body: some View { Group { @@ -29,10 +41,19 @@ struct NetworkCarouselView: View { .frame(maxWidth: .infinity, minHeight: 175) #endif } else { + #if os(macOS) + LazyVStack(alignment: .leading, spacing: 12) { + ForEach(networks) { network in + networkCard(network) + .frame(maxWidth: 560) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + #else ScrollView(.horizontal) { LazyHStack { ForEach(networks) { network in - NetworkView(network: network) + networkCard(network) .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) .scrollTransition(.interactive, axis: .horizontal) { content, phase in content @@ -47,9 +68,33 @@ struct NetworkCarouselView: View { .defaultScrollAnchor(.center) .scrollTargetBehavior(.viewAligned) .containerRelativeFrame(.horizontal) + #endif } } } + + @ViewBuilder + private func networkCard(_ network: NetworkCardModel) -> some View { + if let onSelect { + Button { + onSelect(network) + } label: { + NetworkView(network: network) + .overlay(selectionOverlay(for: network)) + } + .buttonStyle(.plain) + } else { + NetworkView(network: network) + } + } + + private func selectionOverlay(for network: NetworkCardModel) -> some View { + RoundedRectangle(cornerRadius: 10) + .stroke( + selectedNetworkID == network.id ? Color.accentColor : Color.clear, + lineWidth: 3 + ) + } } #if DEBUG diff --git a/Apple/UI/NetworkExtensionTunnel.swift b/Apple/UI/NetworkExtensionTunnel.swift index 23559f3..151e683 100644 --- a/Apple/UI/NetworkExtensionTunnel.swift +++ b/Apple/UI/NetworkExtensionTunnel.swift @@ -98,23 +98,33 @@ public final class NetworkExtensionTunnel: Tunnel { } } - guard managers.isEmpty else { return } - - let manager = NETunnelProviderManager() - manager.localizedDescription = "Burrow" + let manager = managers.first ?? NETunnelProviderManager() + var needsSave = managers.isEmpty + if manager.localizedDescription != "Burrow" { + manager.localizedDescription = "Burrow" + needsSave = true + } let proto = NETunnelProviderProtocol() proto.providerBundleIdentifier = bundleIdentifier proto.serverAddress = "burrow.rs" + proto.proxySettings = nil - manager.protocolConfiguration = proto - try await manager.save() + if !manager.protocolConfiguration.isBurrowEquivalent(to: proto) { + manager.protocolConfiguration = proto + needsSave = true + } + + if needsSave { + try await manager.save() + } } public func start() { Task { - guard let manager = try await NETunnelProviderManager.managers.first else { return } do { + try await configure() + guard let manager = try await NETunnelProviderManager.managers.first else { return } if !manager.isEnabled { manager.isEnabled = true try await manager.save() @@ -149,6 +159,17 @@ public final class NetworkExtensionTunnel: Tunnel { } } +private extension Optional where Wrapped == NEVPNProtocol { + func isBurrowEquivalent(to expected: NETunnelProviderProtocol) -> Bool { + guard let current = self as? NETunnelProviderProtocol else { + return false + } + return current.providerBundleIdentifier == expected.providerBundleIdentifier + && current.serverAddress == expected.serverAddress + && current.proxySettings == nil + } +} + extension NEVPNConnection { fileprivate var tunnelStatus: TunnelStatus { switch status { diff --git a/Apple/UI/Networks/Network.swift b/Apple/UI/Networks/Network.swift index 35bd0e1..4b68430 100644 --- a/Apple/UI/Networks/Network.swift +++ b/Apple/UI/Networks/Network.swift @@ -53,6 +53,456 @@ struct TailnetLoginStatus: Sendable { var health: [String] } +struct ProxySubscriptionNodePreview: Sendable, Identifiable { + var id: Int32 { ordinal } + var ordinal: Int32 + var name: String + var proxyProtocol: String + var server: String + var port: Int32 + var warnings: [String] + var runtimeSupported: Bool +} + +struct ProxySubscriptionPreview: Sendable { + var suggestedName: String + var detectedFormat: String + var compatibleCount: Int32 + var rejectedCount: Int32 + var nodes: [ProxySubscriptionNodePreview] + var warnings: [String] +} + +struct ProxySubscriptionNetwork: Sendable, Identifiable, Hashable { + var id: Int32 + var name: String + var sourceURL: String + var detectedFormat: String + var selectedOrdinal: Int32? + var selectedName: String? + var nodes: [ProxySubscriptionNetworkNode] + var warnings: [String] + + var selectedNode: ProxySubscriptionNetworkNode? { + selectedName + .flatMap { name in nodes.first { $0.name == name && $0.runtimeSupported } } + ?? selectedOrdinal + .flatMap { ordinal in nodes.first { $0.ordinal == ordinal && $0.runtimeSupported } } + ?? nodes.first { $0.runtimeSupported } + ?? nodes.first + } + + var runtimeSupportedNodes: [ProxySubscriptionNetworkNode] { + nodes.filter(\.runtimeSupported) + } + + init?(network: Burrow_Network) { + guard network.type == .proxySubscription, + let payload = try? JSONDecoder().decode(StoredProxySubscriptionPayload.self, from: network.payload) + else { + return nil + } + id = network.id + name = payload.name + sourceURL = payload.source.url + detectedFormat = payload.detectedFormat + selectedOrdinal = payload.selectedOrdinal.map(Int32.init) + selectedName = payload.selectedName + nodes = payload.nodes.map(ProxySubscriptionNetworkNode.init) + warnings = payload.warnings + } +} + +struct ProxySubscriptionNetworkNode: Sendable, Identifiable, Hashable { + var id: Int32 { ordinal } + var ordinal: Int32 + var name: String + var proxyProtocol: String + var server: String + var port: Int32 + var warnings: [String] + var runtimeSupported: Bool + + fileprivate init(stored: StoredProxySubscriptionNode) { + ordinal = Int32(stored.ordinal) + name = stored.name + proxyProtocol = stored.proxyProtocol + server = stored.server + port = Int32(stored.port) + warnings = stored.warnings + runtimeSupported = stored.config.runtimeSupported + } +} + +private struct StoredProxySubscriptionPayload: Decodable { + var name: String + var source: StoredProxySubscriptionSource + var detectedFormat: String + var selectedOrdinal: Int? + var selectedName: String? + var nodes: [StoredProxySubscriptionNode] + var warnings: [String] + + enum CodingKeys: String, CodingKey { + case name + case source + case detectedFormat = "detected_format" + case selectedOrdinal = "selected_ordinal" + case selectedName = "selected_name" + case nodes + case warnings + } +} + +private struct StoredProxySubscriptionSource: Decodable { + var url: String +} + +private struct StoredProxySubscriptionNode: Decodable { + var ordinal: Int + var name: String + var proxyProtocol: String + var server: String + var port: Int + var warnings: [String] + var config: StoredProxySubscriptionNodeConfig + + enum CodingKeys: String, CodingKey { + case ordinal + case name + case proxyProtocol = "protocol" + case server + case port + case warnings + case config + } +} + +private struct StoredProxySubscriptionNodeConfig: Decodable { + var type: String + var network: StoredProxySubscriptionNetworkTransport? + var cipher: String? + var plugin: StoredJSONValue? + var smux: StoredJSONValue? + var udp: Bool? + var udpOverTCP: Bool? + var clientFingerprint: String? + + var runtimeSupported: Bool { + switch type { + case "trojan": + return network?.isTrojanTCP ?? true + case "shadowsocks": + guard Self.supportedShadowsocksSmux(smux) else { + return false + } + return Self.supportedShadowsocksPlugin(plugin, clientFingerprint: clientFingerprint) + && Self.supportedShadowsocksCiphers.contains(cipher?.lowercased() ?? "") + default: + return false + } + } + + enum CodingKeys: String, CodingKey { + case type + case network + case cipher + case plugin + case smux + case udp + case udpOverTCP = "udp_over_tcp" + case clientFingerprint = "client_fingerprint" + } + + private static let supportedShadowsocksCiphers: Set = [ + "none", + "plain", + "table", + "rc4-md5", + "rc4", + "aes-128-ctr", + "aes-192-ctr", + "aes-256-ctr", + "aes-128-cfb", + "aes-128-cfb1", + "aes-128-cfb8", + "aes-128-cfb128", + "aes-192-cfb", + "aes-192-cfb1", + "aes-192-cfb8", + "aes-192-cfb128", + "aes-256-cfb", + "aes-256-cfb1", + "aes-256-cfb8", + "aes-256-cfb128", + "aes-128-ofb", + "aes-192-ofb", + "aes-256-ofb", + "camellia-128-ctr", + "camellia-192-ctr", + "camellia-256-ctr", + "camellia-128-cfb", + "camellia-128-cfb1", + "camellia-128-cfb8", + "camellia-128-cfb128", + "camellia-192-cfb", + "camellia-192-cfb1", + "camellia-192-cfb8", + "camellia-192-cfb128", + "camellia-256-cfb", + "camellia-256-cfb1", + "camellia-256-cfb8", + "camellia-256-cfb128", + "camellia-128-ofb", + "camellia-192-ofb", + "camellia-256-ofb", + "chacha20", + "chacha20-ietf", + "xchacha20", + "aes-128-gcm", + "aead_aes_128_gcm", + "aes-192-gcm", + "aes-256-gcm", + "aead_aes_256_gcm", + "aes-128-ccm", + "aes-192-ccm", + "aes-256-ccm", + "aes-128-gcm-siv", + "aes-256-gcm-siv", + "chacha8-ietf-poly1305", + "chacha20-ietf-poly1305", + "chacha20-poly1305", + "aead_chacha20_poly1305", + "rabbit128-poly1305", + "xchacha8-ietf-poly1305", + "xchacha20-ietf-poly1305", + "sm4-gcm", + "sm4-ccm", + "aegis-128l", + "aegis-256", + "aez-384", + "deoxys-ii-256-128", + "ascon128", + "ascon128a", + "lea-128-gcm", + "lea-192-gcm", + "lea-256-gcm", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-aes-128-ccm", + "2022-blake3-aes-256-ccm", + "2022-blake3-chacha20-poly1305", + "2022-blake3-chacha8-poly1305", + ] + + private static func supportedShadowsocksPlugin( + _ plugin: StoredJSONValue?, + clientFingerprint: String? + ) -> Bool { + guard let plugin else { + return true + } + guard case .object(let object) = plugin, + case .string(let name)? = object["name"] + else { + return false + } + let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let opts: [String: StoredJSONValue] + if case .object(let decodedOpts)? = object["opts"] { + opts = decodedOpts + } else { + opts = [:] + } + if normalizedName == "restls", + let clientFingerprint, + Self.shadowsocksClientFingerprintIsActive(clientFingerprint) + { + return false + } + if normalizedName == "v2ray-plugin" || normalizedName == "gost-plugin" { + guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else { + return false + } + guard mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "websocket" else { + return false + } + return true + } + if normalizedName == "shadow-tls" { + let version = opts["version"]?.stringValue ?? "2" + let normalizedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines) + switch normalizedVersion { + case "1": + return true + case "2", "3": + if let clientFingerprint, + Self.shadowsocksClientFingerprintIsActive(clientFingerprint) + { + return false + } + return true + default: + return false + } + } + if normalizedName == "kcptun" { + guard let smuxVersion = opts["smuxver"]?.stringValue else { + return true + } + guard let version = Double(smuxVersion.trimmingCharacters(in: .whitespacesAndNewlines)) else { + return false + } + return version >= 0 + } + if normalizedName == "restls" { + let versionHint = opts["version-hint"]?.stringValue ?? "" + switch versionHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "tls12", "tls13": + return true + default: + return false + } + } + guard normalizedName == "obfs" else { + return true + } + guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else { + return false + } + switch mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "http", "tls": + return true + default: + return false + } + } + + private static func shadowsocksClientFingerprintIsActive(_ value: String) -> Bool { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return [ + "chrome", + "firefox", + "safari", + "ios", + "android", + "edge", + "360", + "qq", + "random", + "chrome120", + "firefox120", + "safari16", + "chrome_psk", + "chrome_psk_shuffle", + "chrome_padding_psk_shuffle", + "chrome_pq", + "chrome_pq_psk", + "randomized", + ].contains(normalized) + } + + private static func supportedShadowsocksSmux(_ smux: StoredJSONValue?) -> Bool { + guard case .object(let object)? = smux else { + return true + } + guard object["enabled"]?.boolValue ?? false else { + return true + } + let protocolName = object["protocol"]?.stringValue? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "h2mux" + guard protocolName == "h2mux" || protocolName == "smux" || protocolName == "yamux" else { + return false + } + return true + } +} + +private enum StoredProxySubscriptionNetworkTransport: Decodable, Hashable { + case named(String) + case keyed(String) + + var isTrojanTCP: Bool { + switch self { + case .named(let value), .keyed(let value): + return value == "tcp" + } + } + + init(from decoder: Swift.Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self = .named(value) + return + } + let object = try container.decode([String: StoredJSONValue].self) + self = .keyed(object.keys.first ?? "") + } +} + +private enum StoredJSONValue: Decodable, Hashable { + case string(String) + case number(Double) + case bool(Bool) + case object([String: StoredJSONValue]) + case array([StoredJSONValue]) + case null + + var stringValue: String? { + switch self { + case .string(let value): + return value + case .number(let value): + if value.rounded(.towardZero) == value { + return String(Int(value)) + } + return String(value) + case .bool(let value): + return String(value) + default: + return nil + } + } + + var boolValue: Bool? { + switch self { + case .bool(let value): + return value + case .string(let value): + switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "1", "true", "yes", "y": + return true + case "0", "false", "no", "n": + return false + default: + return nil + } + case .number(let value): + return value != 0 + default: + return nil + } + } + + init(from decoder: Swift.Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([StoredJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: StoredJSONValue].self)) + } + } +} + enum TailnetDiscoveryClient { static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { var request = Burrow_TailnetDiscoverRequest() @@ -139,6 +589,76 @@ enum TailnetLoginClient { } } +enum ProxySubscriptionImportClient { + static func preview(url: String, socketURL: URL) async throws -> ProxySubscriptionPreview { + var request = Burrow_ProxySubscriptionImportRequest() + request.url = url + + let response = try await ProxySubscriptionClient.unix(socketURL: socketURL) + .previewImport(request) + return ProxySubscriptionPreview( + suggestedName: response.suggestedName, + detectedFormat: response.detectedFormat, + compatibleCount: response.compatibleCount, + rejectedCount: response.rejectedCount, + nodes: response.node.map { node in + ProxySubscriptionNodePreview( + ordinal: node.ordinal, + name: node.name, + proxyProtocol: node.`protocol`, + server: node.server, + port: node.port, + warnings: node.warnings, + runtimeSupported: node.runtimeSupported + ) + }, + warnings: response.warnings + ) + } + + static func apply( + id: Int32, + url: String, + name: String, + selectedOrdinal: Int32?, + socketURL: URL + ) async throws -> Int32 { + var request = Burrow_ProxySubscriptionApplyRequest() + request.id = id + request.url = url + request.name = name + request.selectedOrdinal = selectedOrdinal ?? -1 + let response = try await ProxySubscriptionClient.unix(socketURL: socketURL) + .applyImport(request) + return response.id + } + + static func selectNode( + id: Int32, + selectedOrdinal: Int32, + socketURL: URL + ) async throws -> Int32 { + var request = Burrow_ProxySubscriptionSelectRequest() + request.id = id + request.selectedOrdinal = selectedOrdinal + let response = try await ProxySubscriptionClient.unix(socketURL: socketURL) + .selectNode(request) + return response.selectedOrdinal + } +} + +private struct DaemonUnavailableError: LocalizedError { + var socketPath: String + + var errorDescription: String? { + "Burrow daemon is not reachable yet. Enable the tunnel or start the daemon, then try again." + } + + var failureReason: String? { + "The daemon socket does not exist at \(socketPath)." + } +} + @Observable @MainActor final class NetworkViewModel: Sendable { @@ -161,6 +681,16 @@ final class NetworkViewModel: Sendable { networks.map(Self.makeCard(for:)) } + var nonProxyCards: [NetworkCardModel] { + networks + .filter { $0.type != .proxySubscription } + .map(Self.makeCard(for:)) + } + + var proxySubscriptions: [ProxySubscriptionNetwork] { + networks.compactMap(ProxySubscriptionNetwork.init) + } + var nextNetworkID: Int32 { (networks.map(\.id).max() ?? 0) + 1 } @@ -173,13 +703,83 @@ final class NetworkViewModel: Sendable { try await addNetwork(type: .tailnet, payload: payload.encoded()) } + func previewProxySubscription(url: String) async throws -> ProxySubscriptionPreview { + let socketURL = try daemonSocketURL() + return try await ProxySubscriptionImportClient.preview(url: url, socketURL: socketURL) + } + + func importProxySubscription(url: String, name: String, selectedOrdinal: Int32?) async throws -> Int32 { + let socketURL = try daemonSocketURL() + let networkID = nextNetworkID + return try await ProxySubscriptionImportClient.apply( + id: networkID, + url: url, + name: name, + selectedOrdinal: selectedOrdinal, + socketURL: socketURL + ) + } + + func updateProxySubscriptionSelection( + network: ProxySubscriptionNetwork, + selectedOrdinal: Int32 + ) async throws -> Int32 { + let socketURL = try daemonSocketURL() + return try await ProxySubscriptionImportClient.selectNode( + id: network.id, + selectedOrdinal: selectedOrdinal, + socketURL: socketURL + ) + } + + func updateActiveProxySubscriptionSelection( + network: ProxySubscriptionNetwork, + selectedOrdinal: Int32 + ) async throws -> Int32 { + let socketURL = try Constants.packetTunnelSocketURL + return try await ProxySubscriptionImportClient.selectNode( + id: network.id, + selectedOrdinal: selectedOrdinal, + socketURL: socketURL + ) + } + + func refreshProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 { + let socketURL = try daemonSocketURL() + return try await ProxySubscriptionImportClient.apply( + id: network.id, + url: network.sourceURL, + name: network.name, + selectedOrdinal: network.selectedNode?.ordinal, + socketURL: socketURL + ) + } + + func refreshActiveProxySubscription(network: ProxySubscriptionNetwork) async throws -> Int32 { + let socketURL = try Constants.packetTunnelSocketURL + return try await ProxySubscriptionImportClient.apply( + id: network.id, + url: network.sourceURL, + name: network.name, + selectedOrdinal: network.selectedNode?.ordinal, + socketURL: socketURL + ) + } + + func deleteNetwork(id: Int32) async throws { + let socketURL = try daemonSocketURL() + var request = Burrow_NetworkDeleteRequest() + request.id = id + _ = try await NetworksClient.unix(socketURL: socketURL).networkDelete(request) + } + func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse { - let socketURL = try socketURLResult.get() + let socketURL = try daemonSocketURL() return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL) } func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus { - let socketURL = try socketURLResult.get() + let socketURL = try daemonSocketURL() return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) } @@ -189,7 +789,7 @@ final class NetworkViewModel: Sendable { hostname: String?, authority: String ) async throws -> TailnetLoginStatus { - let socketURL = try socketURLResult.get() + let socketURL = try daemonSocketURL() return try await TailnetLoginClient.start( accountName: accountName, identityName: identityName, @@ -200,17 +800,17 @@ final class NetworkViewModel: Sendable { } func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus { - let socketURL = try socketURLResult.get() + let socketURL = try daemonSocketURL() return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL) } func cancelTailnetLogin(sessionID: String) async throws { - let socketURL = try socketURLResult.get() + let socketURL = try daemonSocketURL() try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL) } private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { - let socketURL = try socketURLResult.get() + let socketURL = try daemonSocketURL() let networkID = nextNetworkID let request = Burrow_Network.with { $0.id = networkID @@ -223,12 +823,25 @@ final class NetworkViewModel: Sendable { return networkID } + private func daemonSocketURL() throws -> URL { + try Self.daemonSocketURL(from: socketURLResult) + } + + private static func daemonSocketURL(from result: Result) throws -> URL { + let socketURL = try result.get() + let socketPath = socketURL.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: socketPath) else { + throw DaemonUnavailableError(socketPath: socketPath) + } + return socketURL + } + private func startStreaming() { task?.cancel() let socketURLResult = self.socketURLResult task = Task { [weak self] in do { - let socketURL = try socketURLResult.get() + let socketURL = try Self.daemonSocketURL(from: socketURLResult) let client = NetworksClient.unix(socketURL: socketURL) for try await response in client.networkList(.init()) { guard !Task.isCancelled else { return } @@ -254,6 +867,8 @@ final class NetworkViewModel: Sendable { WireGuardCard(network: network).card case .tailnet: TailnetCard(network: network).card + case .proxySubscription: + proxySubscriptionCard(network: network) case .UNRECOGNIZED(let rawValue): unsupportedCard( id: network.id, @@ -269,6 +884,76 @@ final class NetworkViewModel: Sendable { } } + private static func proxySubscriptionCard(network: Burrow_Network) -> NetworkCardModel { + guard let subscription = ProxySubscriptionNetwork(network: network) else { + return unsupportedCard( + id: network.id, + title: "Proxy Subscription", + detail: "Imported proxy subscription." + ) + } + return NetworkCardModel( + id: subscription.id, + backgroundColor: .teal, + label: AnyView( + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Proxy Subscription") + .font(.headline) + .foregroundStyle(.white.opacity(0.85)) + Text(subscription.name) + .font(.title3.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + Spacer() + if let selectedNode = subscription.selectedNode { + VStack(alignment: .leading, spacing: 6) { + Text("Selected node") + .font(.caption.weight(.medium)) + .foregroundStyle(.white.opacity(0.72)) + + HStack(spacing: 8) { + Text(selectedNode.name) + .font(.headline.weight(.semibold)) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(selectedNode.proxyProtocol.uppercased()) + .font(.caption2.weight(.bold)) + .foregroundStyle(.white.opacity(0.78)) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background( + Capsule() + .fill(.white.opacity(0.18)) + ) + } + + Text("\(selectedNode.server):\(selectedNode.port)") + .font(.caption.monospaced()) + .foregroundStyle(.white.opacity(0.78)) + .lineLimit(1) + .truncationMode(.middle) + } + } + Text("\(subscription.runtimeSupportedNodes.count) usable of \(subscription.nodes.count) nodes · \(subscription.detectedFormat)") + .font(.footnote) + .foregroundStyle(.white.opacity(0.78)) + .lineLimit(1) + .truncationMode(.tail) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + ) + ) + } + private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel { NetworkCardModel( id: id, @@ -278,9 +963,13 @@ final class NetworkViewModel: Sendable { Text(title) .font(.title3.weight(.semibold)) .foregroundStyle(.white) + .lineLimit(2) + .truncationMode(.tail) Text(detail) .font(.body) .foregroundStyle(.white.opacity(0.9)) + .lineLimit(4) + .truncationMode(.tail) Spacer() Text("Network #\(id)") .font(.footnote.monospaced()) @@ -358,6 +1047,7 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { case wireGuard + case proxySubscription case tor case tailnet @@ -366,6 +1056,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var title: String { switch self { case .wireGuard: "WireGuard" + case .proxySubscription: "Proxy Subscription" case .tor: "Tor" case .tailnet: "Tailnet" } @@ -374,6 +1065,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var subtitle: String { switch self { case .wireGuard: "Import a tunnel and optional account metadata." + case .proxySubscription: "Import Trojan and Shadowsocks subscription nodes." case .tor: "Store Arti account and identity preferences." case .tailnet: "Save Tailnet authority, identity defaults, and login material." } @@ -382,6 +1074,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var accentColor: Color { switch self { case .wireGuard: .init("WireGuard") + case .proxySubscription: .teal case .tor: .orange case .tailnet: .mint } @@ -390,6 +1083,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var actionTitle: String { switch self { case .wireGuard: "Add Network" + case .proxySubscription: "Import" case .tor: "Save Account" case .tailnet: "Save Account" } @@ -397,7 +1091,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { var availabilityNote: String? { switch self { - case .wireGuard: + case .wireGuard, .proxySubscription: nil case .tor: "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." diff --git a/Cargo.lock b/Cargo.lock index 2950701..7a24f95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -23,10 +14,30 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] +[[package]] +name = "aead" +version = "0.6.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b657e772794c6b04730ea897b66a058ccd866c16d1967da05eeeecec39043fe" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + +[[package]] +name = "aegis" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85687c501b265f8538af0b3245221b56ff5c40acd341ba06f050466516fcf2cd" +dependencies = [ + "cc", + "softaes", +] + [[package]] name = "aes" version = "0.8.4" @@ -34,11 +45,75 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", "zeroize", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "polyval", + "subtle", + "zeroize", +] + +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.3", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -182,10 +257,16 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash 0.5.0", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" @@ -410,6 +491,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "asynk-strim" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52697735bdaac441a29391a9e97102c74c6ef0f9b60a40cf109b1b404e29d2f6" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "atomic" version = "0.5.3" @@ -437,6 +528,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.6.20" @@ -452,7 +565,7 @@ dependencies = [ "http-body 0.4.6", "hyper 0.14.32", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -481,7 +594,7 @@ dependencies = [ "hyper 1.7.0", "hyper-util", "itoa", - "matchit", + "matchit 0.7.3", "memchr", "mime", "percent-encoding", @@ -537,21 +650,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "base16ct" version = "0.2.0" @@ -603,7 +701,7 @@ dependencies = [ "quote", "regex", "rustc-hash 1.1.0", - "shlex", + "shlex 1.3.0", "syn 1.0.109", "which", ] @@ -626,11 +724,29 @@ dependencies = [ "quote", "regex", "rustc-hash 1.1.0", - "shlex", + "shlex 1.3.0", "syn 2.0.106", "which", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex 1.3.0", + "syn 2.0.106", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -639,9 +755,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" @@ -664,6 +780,20 @@ dependencies = [ "digest", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq 0.4.2", + "cpufeatures 0.3.0", +] + [[package]] name = "blanket" version = "0.3.0" @@ -684,6 +814,35 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blowfish" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" +dependencies = [ + "byteorder", + "cipher 0.5.2", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "bstr" version = "1.12.1" @@ -705,7 +864,10 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" name = "burrow" version = "0.1.0" dependencies = [ - "aead", + "aead 0.5.2", + "aegis", + "aes 0.8.4", + "aes-gcm", "anyhow", "argon2", "arti-client", @@ -714,45 +876,70 @@ dependencies = [ "axum 0.7.9", "base64 0.21.7", "blake2", + "blake3", "bytes", "caps", + "ccm", + "chacha20", "chacha20poly1305", "clap", "console", "console-subscriber", + "deoxys", "dotenv", "fehler", "futures", + "h2 0.4.12", "hickory-proto", "hmac", + "http 1.3.1", "hyper-util", "insta", "ip_network", "ip_network_table", "ipnetwork", + "lea", "libc", "libsystemd", "log", + "md-5", + "native-tls", "netstack-smoltcp", "nix 0.27.1", "once_cell", - "parking_lot", + "parking_lot 0.12.4", + "poly1305", "prost 0.13.5", "prost-types 0.13.5", + "rabbit", + "rama-net", + "rama-tls-boring", + "rama-ua", "rand 0.8.5", "rand_core 0.6.4", "reqwest 0.12.23", "ring", "rusqlite", "rust-ini", + "rust_tokio_kcp", + "rustls", + "rustls-pemfile 2.2.0", "schemars 0.8.22", "serde", "serde_json", + "serde_yaml", + "sha2", + "shadowsocks", + "smux_rust", "subtle", "tempfile", "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-snappy", "tokio-stream", "tokio-util", + "tokio_smux", "toml 0.8.23", "tonic 0.12.3", "tonic-build", @@ -764,7 +951,11 @@ dependencies = [ "tracing-oslog", "tracing-subscriber", "tun", + "webpki-roots 0.22.6", + "webpki-roots 0.26.11", "x25519-dalek", + "yamux", + "zears", ] [[package]] @@ -773,6 +964,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" +[[package]] +name = "byte_string" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11aade7a05aa8c3a351cedc44c3fc45806430543382fcc4743a9b757a2a0b4ed" + [[package]] name = "bytemuck" version = "1.25.0" @@ -811,6 +1008,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "camellia" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3264e2574e9ef2b53ce6f536dea83a69ac0bc600b762d1523ff83fe07230ce30" +dependencies = [ + "byteorder", + "cipher 0.4.4", +] + [[package]] name = "caps" version = "0.5.5" @@ -834,15 +1041,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] -name = "cc" -version = "1.2.38" +name = "cast5" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" +checksum = "1ed80521f830bbce4d3a4857d6f2b91a8339bf0b65711fe189ab6f8d7a2f629e" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "ccm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae3c82e4355234767756212c570e29833699ab63e6ffd161887314cc5b43847" +dependencies = [ + "aead 0.5.2", + "cipher 0.4.4", + "ctr", + "subtle", ] [[package]] @@ -873,8 +1101,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", - "cpufeatures", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -883,9 +1111,9 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ - "aead", + "aead 0.5.2", "chacha20", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -929,17 +1157,37 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "cipher" version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", + "crypto-common 0.1.6", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -991,6 +1239,15 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "coarsetime" version = "0.1.37" @@ -1112,12 +1369,39 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const_format" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +dependencies = [ + "const_format_proc_macros", + "konst", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.7.1" @@ -1152,6 +1436,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1161,6 +1451,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1293,13 +1592,43 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1309,7 +1638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest", "fiat-crypto", @@ -1432,6 +1761,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core 0.9.11", +] + [[package]] name = "data-encoding" version = "2.9.0" @@ -1479,6 +1821,17 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "deoxys" +version = "0.2.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cb5efc49a3af2efb079e9c9d1cfad0201ea65c2a6f1a4eb1ede501593d2ddb8" +dependencies = [ + "aead 0.6.0-rc.10", + "aes 0.9.1", + "subtle", +] + [[package]] name = "der" version = "0.7.10" @@ -1595,15 +1948,24 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "des" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a94e407b54f9034d71dd748234cd1e516ced6284009906ae246f177eafe5a" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -1669,12 +2031,38 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dynosaur" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12303417f378f29ba12cb12fc78a9df0d8e16ccb1ad94abf04d48d96bdda532" +dependencies = [ + "dynosaur_derive", +] + +[[package]] +name = "dynosaur_derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b0713d5c1d52e774c5cd7bb8b043d7c0fc4f921abfb678556140bfbe6ab2364" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "ecdsa" version = "0.16.9" @@ -1767,6 +2155,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endian-type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "869b0adbda23651a9c5c0c3d270aac9fcb52e8622a8f2b17e57802d7791962f2" + [[package]] name = "enum-as-inner" version = "0.6.1" @@ -1888,6 +2282,9 @@ name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "fehler" @@ -1951,9 +2348,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixedbitset" @@ -1977,6 +2374,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2001,7 +2410,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", ] [[package]] @@ -2010,6 +2440,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -2034,6 +2470,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fslock" version = "0.2.1" @@ -2139,6 +2581,21 @@ dependencies = [ "slab", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -2203,10 +2660,14 @@ dependencies = [ ] [[package]] -name = "gimli" -version = "0.31.1" +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] [[package]] name = "glob" @@ -2294,6 +2755,9 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2477,6 +2941,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2505,6 +2975,15 @@ dependencies = [ "serde", ] +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "0.14.32" @@ -2566,7 +3045,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.3", ] [[package]] @@ -2803,7 +3282,7 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -2826,6 +3305,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "insta" version = "1.43.2" @@ -2838,6 +3326,15 @@ dependencies = [ "similar", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "inventory" version = "0.3.24" @@ -2847,17 +3344,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] - [[package]] name = "ip_network" version = "0.4.1" @@ -2966,15 +3452,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kcp" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e387a924f42063d380be14857714a2f0c177e44dbeab8895244e5370d8a8e68" +dependencies = [ + "bytes", + "log", + "thiserror 1.0.69", +] + [[package]] name = "keccak" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kqueue" version = "1.1.1" @@ -3001,7 +3513,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -3010,6 +3522,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "lea" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00f03a660d3662be37724af25ce5152a4df89c4ffc3649823e6d1b415c19608a" +dependencies = [ + "cfg-if", + "cipher 0.3.0", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3074,7 +3596,7 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.3", @@ -3143,12 +3665,43 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "pin-utils", + "scoped-tls", + "serde", + "serde_json", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" +dependencies = [ + "hashbrown 0.12.3", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lru_time_cache" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9106e1d747ffd48e6be5bb2d97fa706ed25b144fbee4d5c02eae110cd8d6badd" + [[package]] name = "managed" version = "0.8.0" @@ -3170,6 +3723,28 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "matchit" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8863b587001c1b9a8a4e36008cebc6b3612cb1226fe2de94858e06092687b608" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + [[package]] name = "memchr" version = "2.7.5" @@ -3244,6 +3819,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3271,6 +3856,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot 0.12.4", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "multimap" version = "0.10.1" @@ -3304,12 +3906,21 @@ dependencies = [ "futures", "rand 0.8.5", "smoltcp", - "spin", + "spin 0.9.8", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.26.4" @@ -3329,7 +3940,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "libc", "memoffset 0.9.1", @@ -3341,13 +3952,19 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", "memoffset 0.9.1", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -3379,7 +3996,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "inotify", "kqueue", "libc", @@ -3396,7 +4013,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -3507,7 +4124,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -3520,15 +4137,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -3572,9 +4180,9 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -3698,6 +4306,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.4" @@ -3705,7 +4324,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.11", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -3761,6 +4394,16 @@ dependencies = [ "sha2", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "peeking_take_while" version = "0.1.2" @@ -3934,7 +4577,19 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -3954,7 +4609,7 @@ dependencies = [ "atomic 0.5.3", "crossbeam-queue", "futures", - "parking_lot", + "parking_lot 0.12.4", "pin-project", "static_assertions", "thiserror 1.0.69", @@ -4138,6 +4793,21 @@ dependencies = [ "prost 0.13.5", ] +[[package]] +name = "psl" +version = "2.1.212" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f6415ab1b08394487005d41ae7ee69e69903efa1ace538f3b274db605bf8ec" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "pwd-grp" version = "1.0.2" @@ -4226,12 +4896,280 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rabbit" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7204c97584ac097d9749f4a52f2f96910d988f12303fe3e3b021628f88923f1" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "radium" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b4431027dcd37fc2a73ef740b5f233aa805897935b8bce0195e41bbf9a3289a" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rama-boring" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8c66935959385543d370f6f1485461d520028d4ac9ae852131ec1c9226869b" +dependencies = [ + "bitflags 2.11.1", + "foreign-types 0.5.0", + "libc", + "openssl-macros", + "rama-boring-sys", +] + +[[package]] +name = "rama-boring-sys" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a50c9107d147c768182b5d6494670c0a54eadc99ee3eb24784a5b5504480eed8" +dependencies = [ + "bindgen 0.72.1", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "rama-boring-tokio" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7bccdd6efce0302b0fdd0e8b8ff8fba6202841bdfeeac357be115b9f42dd6b" +dependencies = [ + "rama-boring", + "rama-boring-sys", + "tokio", +] + +[[package]] +name = "rama-core" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b93751ab27c9d151e84c1100057eab3f2a6a1378bc31b62abd416ecb1847658" +dependencies = [ + "ahash 0.8.12", + "asynk-strim", + "bytes", + "futures", + "parking_lot 0.12.4", + "pin-project-lite", + "rama-error", + "rama-macros", + "rama-utils", + "serde", + "serde_json", + "tokio", + "tokio-graceful", + "tokio-util", + "tracing", +] + +[[package]] +name = "rama-error" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c452aba1beb7e29b873ff32f304536164cffcc596e786921aea64e858ff8f40" + +[[package]] +name = "rama-http" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453d60af031e23af2d48995e41b17023f6150044738680508b63671f8d7417dd" +dependencies = [ + "ahash 0.8.12", + "base64 0.22.1", + "bitflags 2.11.1", + "chrono", + "const_format", + "csv", + "http 1.3.1", + "http-range-header", + "httpdate", + "iri-string", + "matchit 0.9.2", + "parking_lot 0.12.4", + "percent-encoding", + "pin-project-lite", + "radix_trie", + "rama-core", + "rama-error", + "rama-http-headers", + "rama-http-types", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_html_form", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "rama-http-headers" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d74fe0cd9bd4440827dc6dc0f504cf66065396532e798891dee2c1b740b2285" +dependencies = [ + "ahash 0.8.12", + "base64 0.22.1", + "chrono", + "const_format", + "httpdate", + "rama-core", + "rama-error", + "rama-http-types", + "rama-macros", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "sha1", +] + +[[package]] +name = "rama-http-types" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dae655a72da5f2b97cfacb67960d8b28c5025e62707b4c8c5f0c5c9843a444" +dependencies = [ + "ahash 0.8.12", + "bytes", + "const_format", + "fnv", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "itoa", + "memchr", + "mime", + "mime_guess", + "nom 8.0.0", + "pin-project-lite", + "rama-core", + "rama-error", + "rama-macros", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio", +] + +[[package]] +name = "rama-macros" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea18a110bcf21e35c5f194168e6914ccea45ffdd0fea51bc4b169fbeafef6428" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "rama-net" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28ee9e1e5d39264414b71f5c33e7fbb66b382c3fac456fe0daad39cf5509933" +dependencies = [ + "ahash 0.8.12", + "const_format", + "flume", + "hex", + "ipnet", + "itertools 0.14.0", + "md5", + "nom 8.0.0", + "parking_lot 0.12.4", + "pin-project-lite", + "psl", + "radix_trie", + "rama-core", + "rama-http-types", + "rama-macros", + "rama-utils", + "serde", + "sha2", + "socket2 0.6.3", + "tokio", +] + +[[package]] +name = "rama-tls-boring" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def3d5d06d3ca3a2d2e4376cf93de0555cd9c7960f085bf77be9562f5c9ace8f" +dependencies = [ + "ahash 0.8.12", + "flume", + "itertools 0.14.0", + "moka", + "parking_lot 0.12.4", + "pin-project-lite", + "rama-boring", + "rama-boring-tokio", + "rama-core", + "rama-net", + "rama-utils", + "schannel", + "tokio", +] + +[[package]] +name = "rama-ua" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7abde8e7b428c80c5948885c1ee0492852a21d91844c8414a0de4a8a1d62262" +dependencies = [ + "ahash 0.8.12", + "itertools 0.14.0", + "rama-core", + "rama-http", + "rama-net", + "rama-utils", + "rand 0.9.2", + "serde", + "serde_html_form", + "serde_json", +] + +[[package]] +name = "rama-utils" +version = "0.3.0-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf28b18ba4a57f8334d7992d3f8020194ea359b246ae6f8f98b8df524c7a14ef" +dependencies = [ + "const_format", + "parking_lot 0.12.4", + "pin-project-lite", + "rama-macros", + "regex", + "serde", + "smallvec", + "smol_str", + "tokio", + "wildcard", +] + [[package]] name = "rand" version = "0.8.5" @@ -4331,13 +5269,22 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -4346,7 +5293,7 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", ] [[package]] @@ -4360,6 +5307,19 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "reed-solomon-erasure" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" +dependencies = [ + "libm", + "lru", + "parking_lot 0.11.2", + "smallvec", + "spin 0.9.8", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -4382,9 +5342,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -4394,9 +5354,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -4433,7 +5393,7 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile", + "rustls-pemfile 1.0.4", "serde", "serde_json", "serde_urlencoded", @@ -4484,7 +5444,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.3", ] [[package]] @@ -4520,6 +5480,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ring-compat" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccce7bae150b815f0811db41b8312fcb74bffa4cab9cee5429ee00f356dd5bd4" +dependencies = [ + "aead 0.5.2", + "ed25519", + "generic-array", + "pkcs8", + "ring", +] + [[package]] name = "rsa" version = "0.9.10" @@ -4557,7 +5530,7 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4578,10 +5551,35 @@ dependencies = [ ] [[package]] -name = "rustc-demangle" -version = "0.1.26" +name = "rust_tokio_kcp" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +checksum = "74fa76f74fbbd4a3c15e3d688e8125e64583c4cd73f75f96ab2ba5b683708b1e" +dependencies = [ + "aes 0.9.1", + "aes-gcm", + "blowfish", + "byte_string", + "byteorder", + "bytes", + "cast5", + "cipher 0.5.2", + "crc32fast", + "des", + "futures-util", + "hybrid-array", + "kcp", + "log", + "pbkdf2 0.12.2", + "rand 0.8.5", + "reed-solomon-erasure", + "salsa20", + "sha1", + "sm4 0.6.0", + "spin 0.9.8", + "tokio", + "twofish", +] [[package]] name = "rustc-hash" @@ -4619,7 +5617,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4632,7 +5630,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.11.0", @@ -4645,6 +5643,8 @@ version = "0.23.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4662,6 +5662,15 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.12.0" @@ -4678,6 +5687,7 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4708,6 +5718,16 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "salsa20" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c" +dependencies = [ + "cfg-if", + "cipher 0.5.2", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4789,12 +5809,29 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sealed" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f968c5ea23d555e670b449c1c5e7b2fc399fdaec1d304a17cd48e288abc107" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "sec1" version = "0.7.3" @@ -4815,7 +5852,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -4838,6 +5875,16 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +[[package]] +name = "sendfd" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b183bfd5b1bc64ab0c1ef3ee06b008a9ef1b68a7d3a99ba566fbfe7a7c6d745b" +dependencies = [ + "libc", + "tokio", +] + [[package]] name = "serde" version = "1.0.228" @@ -4889,6 +5936,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_html_form" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acf96b1d9364968fce46ebb548f1c0e1d7eceae27bdff73865d42e6c7369d94" +dependencies = [ + "form_urlencoded", + "indexmap 2.11.4", + "itoa", + "ryu", + "serde_core", +] + [[package]] name = "serde_ignored" version = "0.1.14" @@ -4984,6 +6044,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.11.4", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -4991,7 +6064,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5002,7 +6075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5013,7 +6086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5027,6 +6100,70 @@ dependencies = [ "keccak", ] +[[package]] +name = "shadowsocks" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "482831bf9d55acf3c98e211b6c852c3dfdf1d1b0d23fdf1d887c5a4b2acad4e4" +dependencies = [ + "aes 0.8.4", + "base64 0.22.1", + "blake3", + "byte_string", + "bytes", + "cfg-if", + "dynosaur", + "futures", + "libc", + "log", + "lru_time_cache", + "percent-encoding", + "pin-project", + "rand 0.9.2", + "sealed", + "sendfd", + "serde", + "serde_json", + "serde_urlencoded", + "shadowsocks-crypto", + "socket2 0.6.3", + "spin 0.10.0", + "thiserror 2.0.16", + "tokio", + "tokio-tfo", + "trait-variant", + "url", + "windows-sys 0.61.0", +] + +[[package]] +name = "shadowsocks-crypto" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d038a3d17586f1c1ab3c1c3b9e4d5ef8fba98fb3890ad740c8487038b2e2ca5" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "aes-gcm", + "aes-gcm-siv", + "blake3", + "bytes", + "camellia", + "ccm", + "cfg-if", + "chacha20", + "chacha20poly1305", + "ctr", + "ghash", + "hkdf", + "md-5", + "rand 0.9.2", + "ring-compat", + "sha1", + "sm4 0.5.1", + "subtle", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -5053,6 +6190,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.6" @@ -5113,11 +6256,42 @@ dependencies = [ "void", ] +[[package]] +name = "sm4" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d7abf5135ffd68fb4b438e1fb246923b80d25eda386d8b798bb4ad3ed00f75f" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "sm4" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2d621883cdeeaee6759d39f4bc8cd314b29db72b6ac3d6714b31422512ee11" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] [[package]] name = "smoltcp" @@ -5134,6 +6308,23 @@ dependencies = [ "managed", ] +[[package]] +name = "smux_rust" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6da4231ce17872ec83fffdc189d64a121e1df79e2c0e08ee6c2d9f12f13f9994" +dependencies = [ + "bytes", + "futures", + "tokio", +] + +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + [[package]] name = "socket2" version = "0.5.10" @@ -5154,6 +6345,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "softaes" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" + [[package]] name = "spin" version = "0.9.8" @@ -5163,6 +6360,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -5191,7 +6397,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ - "cipher", + "cipher 0.4.4", "ssh-encoding", ] @@ -5377,6 +6583,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tap" version = "1.0.1" @@ -5523,22 +6735,33 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", "mio", + "parking_lot 0.12.4", "pin-project-lite", "signal-hook-registry", - "slab", "socket2 0.6.3", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.0", +] + +[[package]] +name = "tokio-graceful" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45740b38b48641855471cd402922e89156bdfbd97b69b45eeff170369cc18c7d" +dependencies = [ + "loom", + "pin-project-lite", + "slab", + "tokio", + "tracing", ] [[package]] @@ -5553,9 +6776,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" dependencies = [ "proc-macro2", "quote", @@ -5582,6 +6805,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-snappy" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb0ab9ee987e7134c0cdfe038b737102433aa8d7e04ad99cf1ec258907af976" +dependencies = [ + "bytes", + "futures", + "pin-project", + "snap", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.17" @@ -5593,6 +6829,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tfo" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ad2c3b3bb958ad992354a7ebc468fc0f7cdc9af4997bf4d3fd3cb28bad36dc" +dependencies = [ + "cfg-if", + "futures", + "libc", + "log", + "once_cell", + "pin-project", + "socket2 0.6.3", + "tokio", + "windows-sys 0.60.2", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5607,6 +6860,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio_smux" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5826746b94900340cfda9e18a29a75986992df653d5941b5e359ca3b667117" +dependencies = [ + "byteorder", + "dashmap", + "log", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "toml" version = "0.8.23" @@ -5831,7 +7097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ab0c79bc92a957e85959cf397a2d8f9c8294c35fa4f65247a9393b20ac95551" dependencies = [ "amplify", - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "caret", "derive-deftly", @@ -6330,7 +7596,7 @@ version = "0.40.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c6989a1c6d06ffd6835e2917edaae4aeef544f8e5fdd68b54cc365f2af523de" dependencies = [ - "aes", + "aes 0.8.4", "base64ct", "ctr", "curve25519-dalek", @@ -6429,7 +7695,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41be8f47f521fc95206d2ba5facac8fb1a5b5b82169bd41ebeecdf46d1e77246" dependencies = [ "async-trait", - "bitflags 2.9.4", + "bitflags 2.11.1", "derive_more", "futures", "humantime", @@ -6458,7 +7724,7 @@ checksum = "ea8bce73d2c78bd78a2a927336ca639cf6bd5d8ad092ebcd0b3fdeaa47dcc77e" dependencies = [ "amplify", "base64ct", - "cipher", + "cipher 0.4.4", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -6533,7 +7799,7 @@ dependencies = [ "bytes", "caret", "cfg-if", - "cipher", + "cipher 0.4.4", "coarsetime", "criterion-cycles-per-byte", "derive-deftly", @@ -6765,7 +8031,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "bytes", "futures-util", "http 1.3.1", @@ -6865,7 +8131,7 @@ dependencies = [ "cfg-if", "fnv", "once_cell", - "parking_lot", + "parking_lot 0.12.4", "tracing-core", "tracing-subscriber", ] @@ -6909,6 +8175,17 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "trait-variant" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6942,6 +8219,15 @@ dependencies = [ "zip", ] +[[package]] +name = "twofish" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0218ef702a91e18ba84dd516edf8df33a9f9fb4431d6ada13e6f9502c3bcb6fc" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "typed-index-collections" version = "3.5.0" @@ -6954,9 +8240,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uncased" @@ -6967,6 +8253,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.19" @@ -7003,10 +8295,16 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -7049,6 +8347,7 @@ version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ + "getrandom 0.3.3", "js-sys", "serde", "wasm-bindgen", @@ -7233,7 +8532,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.11.4", "semver", @@ -7265,6 +8564,34 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +dependencies = [ + "webpki", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.3", +] + [[package]] name = "webpki-roots" version = "1.0.3" @@ -7292,6 +8619,15 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" +[[package]] +name = "wildcard" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9b0540e91e49de3817c314da0dd3bc518093ceacc6ea5327cb0e1eb073e5189" +dependencies = [ + "thiserror 2.0.16", +] + [[package]] name = "winapi" version = "0.3.9" @@ -7800,7 +9136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.9.4", + "bitflags 2.11.1", "indexmap 2.11.4", "log", "serde", @@ -7863,6 +9199,22 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "yamux" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1991f6690292030e31b0144d73f5e8368936c58e45e7068254f7138b23b00672" +dependencies = [ + "futures", + "log", + "nohash-hasher", + "parking_lot 0.12.4", + "pin-project", + "rand 0.9.2", + "static_assertions", + "web-time", +] + [[package]] name = "yoke" version = "0.8.0" @@ -7887,6 +9239,18 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zears" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e54700681e12629b566fc71a9ee538961b451ebf65b7509c2e12f114dd8e5aa" +dependencies = [ + "aes 0.8.4", + "blake2", + "constant_time_eq 0.4.2", + "cpufeatures 0.2.17", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -7987,15 +9351,15 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" dependencies = [ - "aes", + "aes 0.8.4", "byteorder", "bzip2", - "constant_time_eq", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", "flate2", "hmac", - "pbkdf2", + "pbkdf2 0.11.0", "sha1", "time", "zstd 0.11.2+zstd.1.5.2", diff --git a/Scripts/burrow-doctor b/Scripts/burrow-doctor new file mode 100755 index 0000000..a364e59 --- /dev/null +++ b/Scripts/burrow-doctor @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: Scripts/burrow-doctor [--last 10m] + +Print a single stdout diagnostics report for the CURRENT macOS networking state. + +Run this while Burrow is the active VPN but routing/websites are broken, then +redirect stdout to a file before switching to a working VPN: + + Scripts/burrow-doctor --last 10m > burrow-broken.txt + +The report focuses on live VPN/proxy/DNS/route state, Burrow NetworkExtension +state, socket ownership, the selected Burrow node, and recent Burrow logs. +It intentionally does not redact local output, because broken proxy state often +depends on exact subscription and node configuration. +EOF +} + +last="10m" +while [[ $# -gt 0 ]]; do + case "$1" in + --last) + [[ $# -ge 2 ]] || { echo "--last requires a value" >&2; exit 2; } + last="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +group_id="${BURROW_APP_GROUP_ID:-2KPBQHRJ2D.com.tianruichen.burrow}" +group_dir="${BURROW_APP_GROUP_DIR:-$HOME/Library/Group Containers/$group_id}" +db_path="${BURROW_DB_PATH:-$group_dir/burrow.db}" +runtime_log="${BURROW_RUNTIME_LOG:-$group_dir/burrow-runtime.log}" +bundle_id="${BURROW_BUNDLE_ID:-com.tianruichen.burrow}" +extension_process="${BURROW_EXTENSION_PROCESS:-BurrowNetworkExtension}" + +section() { + printf '\n## %s\n\n' "$1" +} + +cmd() { + printf '$' + printf ' %q' "$@" + printf '\n' +} + +run() { + local title="$1" + shift + section "$title" + cmd "$@" + printf '\n' + "$@" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?" +} + +run_zsh() { + local title="$1" + local script="$2" + section "$title" + printf '$ %s\n\n' "$script" + /bin/zsh -lc "$script" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?" +} + +run_python() { + if command -v uv >/dev/null 2>&1; then + uv run python "$@" + elif command -v python3 >/dev/null 2>&1; then + python3 "$@" + else + return 127 + fi +} + +capture() { + "$@" 2>&1 || true +} + +nc_list="$(capture scutil --nc list)" +proxy_state="$(capture scutil --proxy)" +dns_state="$(capture scutil --dns)" +inet_routes="$(capture netstat -rn -f inet)" +proxy_listeners="$(capture /bin/zsh -lc "lsof -nP -iTCP:6152 -iTCP:6153 -iTCP:7890 -iTCP:7891 -iTCP:7897 -iTCP:1080 -iTCP:1087 -sTCP:LISTEN")" + +cat < dict | None: + nodes = payload.get("nodes") or [] + selected_name = payload.get("selected_name") + selected_ordinal = payload.get("selected_ordinal") + for node in nodes: + if selected_name is not None and node.get("name") == selected_name: + return node + if selected_ordinal is not None and node.get("ordinal") == selected_ordinal: + return node + return nodes[0] if nodes else None + + +conn = sqlite3.connect(db_path) +conn.row_factory = sqlite3.Row +row = conn.execute( + "select payload from network where type = 'ProxySubscription' order by idx limit 1" +).fetchone() +if row is None: + print("No ProxySubscription network in database.") + raise SystemExit + +payload_blob = row["payload"] +payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob +payload = json.loads(payload_text) +node = choose_node(payload) +if not node: + print("ProxySubscription has no nodes.") + raise SystemExit + +server = node.get("server") +port = int(node.get("port")) +print(f"selected_proxy_server={server}:{port}") + +addresses = [] +try: + for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(server, port, type=socket.SOCK_STREAM): + host, resolved_port = sockaddr[:2] + key = (host, resolved_port) + if key not in addresses: + addresses.append(key) +except Exception as exc: + print(f"resolve_error={type(exc).__name__}: {exc}") + raise SystemExit + +print("resolved_addresses=" + ",".join(f"{host}:{resolved_port}" for host, resolved_port in addresses)) + +for host, _ in addresses: + print(f"\n$ route -n get {host}") + try: + result = subprocess.run( + ["route", "-n", "get", host], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=5, + ) + print(result.stdout.rstrip()) + print(f"[exit={result.returncode}]") + except Exception as exc: + print(f"route_probe_error={type(exc).__name__}: {exc}") + +for host, resolved_port in addresses: + started = time.monotonic() + try: + with socket.create_connection((host, resolved_port), timeout=5): + elapsed_ms = (time.monotonic() - started) * 1000 + print(f"tcp_connect_ok address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f}") + except Exception as exc: + elapsed_ms = (time.monotonic() - started) * 1000 + print(f"tcp_connect_error address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f} error={type(exc).__name__}: {exc}") +PY +else + echo "DB not found: $db_path" +fi + +run_zsh "Route Probes" "for host in 1.1.1.1 8.8.8.8 google.com; do echo; echo \"\$ route -n get \$host\"; route -n get \"\$host\" 2>&1 || true; done" + +run_zsh "DNS Probes" "if command -v dig >/dev/null 2>&1; then dig +time=3 +tries=1 google.com A; echo; dig +time=3 +tries=1 @1.1.1.1 google.com A; else dscacheutil -q host -a name google.com; fi" + +run_zsh "Scoped Physical DNS Probes" "if command -v dig >/dev/null 2>&1; then servers=\$(scutil --dns | awk 'BEGIN { scoped=0; current=\"\" } /^DNS configuration \\(for scoped queries\\)/ { scoped=1; next } scoped && /^resolver #/ { current=\"\"; next } scoped && /nameserver\\[[0-9]+\\]/ { current=\$3 } scoped && /if_index/ && \$0 !~ /utun/ && current != \"\" { print current; current=\"\" }'); if [[ -z \"\$servers\" ]]; then echo 'No scoped non-utun DNS servers found.'; else for server in \$servers; do echo; echo \"\$ dig +time=3 +tries=1 @\$server google.com A\"; dig +time=3 +tries=1 @\"\$server\" google.com A; done; fi; else echo 'dig not found'; fi" + +run_zsh "TCP Probes" "for target in '1.1.1.1 53' '1.1.1.1 443' 'google.com 443'; do set -- \$target; echo; echo \"\$ nc -vz -w 5 \$1 \$2\"; nc -vz -w 5 \"\$1\" \"\$2\" 2>&1 || true; done" + +run_zsh "HTTP Probes" "for url in 'http://www.gstatic.com/generate_204' 'https://www.google.com/generate_204' 'https://www.cloudflare.com/cdn-cgi/trace'; do echo; echo \"\$ curl -4 -I --connect-timeout 5 --max-time 10 \$url\"; curl -4 -I --connect-timeout 5 --max-time 10 \"\$url\" 2>&1 || true; done" + +run_zsh "Burrow Controlled Proxy Self-Test" "if [[ -x '$repo_root/Scripts/burrow-proxy-selftest' ]]; then '$repo_root/Scripts/burrow-proxy-selftest'; else echo 'Scripts/burrow-proxy-selftest not found or not executable'; fi" + +run_zsh "Burrow Direct Provider Proxy Ladder" "if [[ -x '$repo_root/Scripts/burrow-proxy-ladder' ]]; then '$repo_root/Scripts/burrow-proxy-ladder' --db '$db_path' --resolve-server doh-all --target google.com:80 --http-host google.com --skip-udp --timeout 8; else echo 'Scripts/burrow-proxy-ladder not found or not executable'; fi" + +run_zsh "Burrow Daemon Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-tcp-probe --dns-name google.com --remote 1.1.1.1:80 --timeout-ms 12000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi" + +run_zsh "Burrow Daemon HTTPS Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-https-probe --dns-name news.ycombinator.com --remote 1.1.1.1:443 --timeout-ms 20000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi" + +section "Proxy Subscription Endpoint Status" +if [[ -f "$db_path" ]]; then + run_python - "$db_path" <<'PY' +from __future__ import annotations + +import json +import sqlite3 +import sys +import urllib.error +import urllib.request + +db_path = sys.argv[1] + +conn = sqlite3.connect(db_path) +conn.row_factory = sqlite3.Row +row = conn.execute( + "select payload from network where type = 'ProxySubscription' order by idx limit 1" +).fetchone() +if row is None: + print("No ProxySubscription network in database.") + raise SystemExit + +payload_blob = row["payload"] +payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob +payload = json.loads(payload_text) +url = (payload.get("source") or {}).get("url") +if not url: + print("ProxySubscription has no source URL.") + raise SystemExit + +print("source_url_redacted=yes") +request = urllib.request.Request(url, headers={"User-Agent": "mihomo/1.18.3"}) +try: + with urllib.request.urlopen(request, timeout=20) as response: + body = response.read(1024 * 1024) + print(f"status={response.status}") + for key, value in response.headers.items(): + lower = key.lower() + if ( + lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"} + or "expire" in lower + or "subscription" in lower + ): + print(f"header_{key}={value}") + print(f"body_bytes={len(body)}") +except urllib.error.HTTPError as exc: + print(f"status={exc.code}") + print(f"reason={exc.reason}") + for key, value in exc.headers.items(): + lower = key.lower() + if ( + lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"} + or "expire" in lower + or "subscription" in lower + ): + print(f"header_{key}={value}") + body = exc.read(1000).decode("utf-8", errors="replace").strip() + if body: + print(f"body_prefix={body[:200]}") +except Exception as exc: + print(f"fetch_error={type(exc).__name__}: {exc}") +PY +else + echo "DB not found: $db_path" +fi + +section "Burrow Runtime Log File" +if [[ -f "$runtime_log" ]]; then + cmd tail -400 "$runtime_log" + printf '\n' + tail -400 "$runtime_log" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?" +else + echo "Runtime log not found: $runtime_log" +fi + +section "Recent Burrow NetworkExtension Logs" +cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\"" +printf '\n' +{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\"" 2>&1 \ + || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; } + +section "Recent Burrow Extension Logs" +cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\"" +printf '\n' +{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\"" 2>&1 \ + || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; } + +section "Recent Burrow Errors" +cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")" +printf '\n' +{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")" 2>&1 \ + || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; } diff --git a/Scripts/burrow-proxy-ladder b/Scripts/burrow-proxy-ladder new file mode 100755 index 0000000..9c1da3d --- /dev/null +++ b/Scripts/burrow-proxy-ladder @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +"""Probe a Burrow proxy subscription node without enabling system proxying. + +This script intentionally bypasses the Burrow daemon, Network Extension, +routes, DNS settings, and browsers. It loads the selected proxy node from the +Burrow SQLite database and speaks the provider protocol directly. +""" + +from __future__ import annotations + +import argparse +import hashlib +import ipaddress +import json +import os +import socket +import sqlite3 +import ssl +import struct +import sys +import time +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +DEFAULT_APP_GROUP = "2KPBQHRJ2D.com.tianruichen.burrow" +DEFAULT_DB = ( + Path.home() + / "Library" + / "Group Containers" + / DEFAULT_APP_GROUP + / "burrow.db" +) +DEFAULT_TARGET = "1.1.1.1:80" +DEFAULT_HTTP_HOST = "one.one.one.one" +DEFAULT_DNS_SERVER = "1.1.1.1:53" +DEFAULT_DNS_NAME = "google.com" +DEFAULT_TIMEOUT = 5.0 +DOH_PROVIDERS = { + "doh-google": "https://dns.google/resolve", + "doh-cloudflare": "https://cloudflare-dns.com/dns-query", +} + +TROJAN_CMD_CONNECT = 0x01 +TROJAN_CMD_UDP_ASSOCIATE = 0x03 + + +@dataclass(frozen=True) +class Endpoint: + host: str + port: int + + +@dataclass(frozen=True) +class ProbeResult: + ok: bool + detail: str + + +def parse_endpoint(value: str, default_port: int | None = None) -> Endpoint: + text = value.strip() + if not text: + raise ValueError("endpoint must not be empty") + + if text.startswith("["): + end = text.find("]") + if end == -1: + raise ValueError(f"invalid bracketed IPv6 endpoint: {value}") + host = text[1:end] + rest = text[end + 1 :] + if rest.startswith(":"): + port = int(rest[1:]) + elif default_port is not None: + port = default_port + else: + raise ValueError(f"endpoint is missing a port: {value}") + return Endpoint(host, port) + + if text.count(":") == 1: + host, port_text = text.rsplit(":", 1) + return Endpoint(host, int(port_text)) + + try: + ipaddress.ip_address(text) + except ValueError: + if ":" in text: + if default_port is None: + raise ValueError(f"IPv6 endpoint is missing a port: {value}") + return Endpoint(text, default_port) + if default_port is None: + raise ValueError(f"endpoint is missing a port: {value}") + return Endpoint(text, default_port) + + if default_port is None: + raise ValueError(f"endpoint is missing a port: {value}") + return Endpoint(text, default_port) + + +def encode_socks_addr(endpoint: Endpoint) -> bytes: + try: + ip = ipaddress.ip_address(endpoint.host) + except ValueError: + host = endpoint.host.encode("idna") + if len(host) > 255: + raise ValueError(f"SOCKS domain is too long: {endpoint.host}") + return bytes([3, len(host)]) + host + struct.pack("!H", endpoint.port) + + if ip.version == 4: + return bytes([1]) + ip.packed + struct.pack("!H", endpoint.port) + return bytes([4]) + ip.packed + struct.pack("!H", endpoint.port) + + +def read_exact(sock: ssl.SSLSocket, length: int) -> bytes: + chunks: list[bytes] = [] + remaining = length + while remaining: + chunk = sock.recv(remaining) + if not chunk: + raise EOFError(f"expected {remaining} more bytes") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def read_socks_addr(sock: ssl.SSLSocket) -> Endpoint: + atyp = read_exact(sock, 1)[0] + if atyp == 1: + host = str(ipaddress.IPv4Address(read_exact(sock, 4))) + elif atyp == 4: + host = str(ipaddress.IPv6Address(read_exact(sock, 16))) + elif atyp == 3: + length = read_exact(sock, 1)[0] + host = read_exact(sock, length).decode("idna") + else: + raise ValueError(f"unsupported SOCKS address type in response: {atyp}") + port = struct.unpack("!H", read_exact(sock, 2))[0] + return Endpoint(host, port) + + +def trojan_password_hash(password: str) -> bytes: + return hashlib.sha224(password.encode()).hexdigest().encode() + + +def trojan_header(password: str, command: int, target: Endpoint) -> bytes: + return ( + trojan_password_hash(password) + + b"\r\n" + + bytes([command]) + + encode_socks_addr(target) + + b"\r\n" + ) + + +def trojan_udp_frame(target: Endpoint, payload: bytes) -> bytes: + return encode_socks_addr(target) + struct.pack("!H", len(payload)) + b"\r\n" + payload + + +def build_dns_query(name: str) -> tuple[int, bytes]: + query_id = int(time.time() * 1000) & 0xFFFF + labels = [label for label in name.rstrip(".").split(".") if label] + qname = b"".join(bytes([len(label)]) + label.encode("ascii") for label in labels) + b"\x00" + header = struct.pack("!HHHHHH", query_id, 0x0100, 1, 0, 0, 0) + question = qname + struct.pack("!HH", 1, 1) + return query_id, header + question + + +def parse_dns_response(query_id: int, payload: bytes) -> str: + if len(payload) < 12: + return f"truncated DNS response: {len(payload)} bytes" + response_id, flags, questions, answers, authority, additional = struct.unpack( + "!HHHHHH", payload[:12] + ) + rcode = flags & 0x000F + qr = bool(flags & 0x8000) + id_note = "matching id" if response_id == query_id else f"id mismatch {response_id:#06x}" + return ( + f"{len(payload)} bytes, qr={qr}, rcode={rcode}, answers={answers}, " + f"authority={authority}, additional={additional}, questions={questions}, {id_note}" + ) + + +def load_payload(db_path: Path, network_id: int | None) -> tuple[int, dict[str, Any]]: + if not db_path.exists(): + raise FileNotFoundError(f"Burrow database not found: {db_path}") + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + if network_id is None: + row = conn.execute( + "select id,payload from network where type = 'ProxySubscription' order by idx limit 1" + ).fetchone() + else: + row = conn.execute( + "select id,payload from network where id = ? and type = 'ProxySubscription'", + (network_id,), + ).fetchone() + finally: + conn.close() + if row is None: + selector = "first ProxySubscription" if network_id is None else f"ProxySubscription id={network_id}" + raise LookupError(f"could not find {selector} in {db_path}") + payload_blob = row["payload"] + if isinstance(payload_blob, bytes): + payload_text = payload_blob.decode("utf-8", errors="replace") + else: + payload_text = payload_blob or "{}" + return int(row["id"]), json.loads(payload_text) + + +def choose_node( + payload: dict[str, Any], + node_name: str | None = None, + node_ordinal: int | None = None, +) -> dict[str, Any]: + nodes = payload.get("nodes") or [] + if node_name is not None: + for node in nodes: + if node.get("name") == node_name: + return node + raise LookupError(f"ProxySubscription payload has no node named {node_name!r}") + if node_ordinal is not None: + for node in nodes: + if node.get("ordinal") == node_ordinal: + return node + raise LookupError(f"ProxySubscription payload has no node with ordinal {node_ordinal}") + + selected_name = payload.get("selected_name") + selected_ordinal = payload.get("selected_ordinal") + for node in nodes: + if selected_name is not None and node.get("name") == selected_name: + return node + if selected_ordinal is not None and node.get("ordinal") == selected_ordinal: + return node + if nodes: + return nodes[0] + raise LookupError("ProxySubscription payload has no compatible nodes") + + +def resolved_stream_addresses(endpoint: Endpoint) -> list[tuple[int, tuple[Any, ...]]]: + addresses: list[tuple[int, tuple[Any, ...]]] = [] + seen: set[tuple[Any, ...]] = set() + for family, _, _, _, sockaddr in socket.getaddrinfo( + endpoint.host, endpoint.port, type=socket.SOCK_STREAM + ): + key = (family, sockaddr) + if key not in seen: + seen.add(key) + addresses.append((family, sockaddr)) + return addresses + + +def resolve_doh(host: str, port: int, provider: str, timeout: float) -> list[Endpoint]: + base = DOH_PROVIDERS[provider] + url = base + "?" + urllib.parse.urlencode({"name": host, "type": "A"}) + request = urllib.request.Request( + url, + headers={"Accept": "application/dns-json", "User-Agent": "burrow-proxy-ladder"}, + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + body = json.load(response) + endpoints: list[Endpoint] = [] + for answer in body.get("Answer") or []: + if answer.get("type") != 1: + continue + address = str(answer.get("data") or "") + try: + ipaddress.IPv4Address(address) + except ValueError: + continue + endpoint = Endpoint(address, port) + if endpoint not in endpoints: + endpoints.append(endpoint) + return endpoints + + +def resolve_doh_all(host: str, port: int, timeout: float) -> list[Endpoint]: + endpoints: list[Endpoint] = [] + errors: list[str] = [] + for provider in DOH_PROVIDERS: + try: + provider_endpoints = resolve_doh(host, port, provider, timeout) + except BaseException as exc: + errors.append(f"{provider}={type(exc).__name__}: {exc}") + continue + print( + f"{provider}_answers=" + + ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in provider_endpoints) + ) + for endpoint in provider_endpoints: + if endpoint not in endpoints: + endpoints.append(endpoint) + if errors: + print("doh_warnings=" + "; ".join(errors)) + return endpoints + + +def is_fake_ip(host: str) -> bool: + try: + ip = ipaddress.ip_address(host) + except ValueError: + return False + return ip in ipaddress.ip_network("198.18.0.0/15") + + +def server_hostname(server: str, sni: str | None) -> str | None: + value = sni or server + if not value: + return None + return value + + +def tls_context(config: dict[str, Any]) -> ssl.SSLContext: + skip_verify = bool(config.get("skip_cert_verify")) + if skip_verify: + context = ssl._create_unverified_context() + else: + context = ssl.create_default_context() + + alpn_present = bool(config.get("alpn_present")) + alpn = config.get("alpn") or [] + if not alpn_present: + alpn = ["h2", "http/1.1"] + if alpn: + context.set_alpn_protocols([str(value) for value in alpn]) + return context + + +def connect_tls( + connect_endpoint: Endpoint, + tls_server_name: str, + node: dict[str, Any], + timeout: float, +) -> ssl.SSLSocket: + config = node["config"] + context = tls_context(config) + sni = server_hostname(tls_server_name, config.get("sni")) + last_error: BaseException | None = None + for _family, sockaddr in resolved_stream_addresses(connect_endpoint): + started = time.monotonic() + raw_sock: socket.socket | None = None + try: + raw_sock = socket.create_connection(sockaddr, timeout=timeout) + raw_sock.settimeout(timeout) + tls_sock = context.wrap_socket(raw_sock, server_hostname=sni) + tls_sock.settimeout(timeout) + elapsed_ms = (time.monotonic() - started) * 1000 + peer = sockaddr[0] if isinstance(sockaddr, tuple) else str(sockaddr) + print( + f" tls_connect_ok address={peer}:{connect_endpoint.port} " + f"elapsed_ms={elapsed_ms:.1f} alpn={tls_sock.selected_alpn_protocol()!r}" + ) + return tls_sock + except BaseException as exc: + last_error = exc + elapsed_ms = (time.monotonic() - started) * 1000 + peer = sockaddr[0] if isinstance(sockaddr, tuple) else str(sockaddr) + print( + f" tls_connect_error address={peer}:{connect_endpoint.port} " + f"elapsed_ms={elapsed_ms:.1f} error={type(exc).__name__}: {exc}" + ) + if raw_sock is not None: + raw_sock.close() + assert last_error is not None + raise last_error + + +def probe_trojan_tcp( + connect_endpoint: Endpoint, + tls_server_name: str, + node: dict[str, Any], + target: Endpoint, + http_host: str, + timeout: float, +) -> ProbeResult: + request = ( + f"GET / HTTP/1.1\r\n" + f"Host: {http_host}\r\n" + f"Connection: close\r\n" + f"User-Agent: burrow-proxy-ladder\r\n\r\n" + ).encode() + try: + with connect_tls(connect_endpoint, tls_server_name, node, timeout) as sock: + sock.sendall(trojan_header(node["config"]["password"], TROJAN_CMD_CONNECT, target)) + sock.sendall(request) + data = sock.recv(2048) + except BaseException as exc: + return ProbeResult(False, f"{type(exc).__name__}: {exc}") + if not data: + return ProbeResult(False, "proxy returned EOF before any HTTP response bytes") + first_line = data.splitlines()[0].decode("utf-8", errors="replace") + return ProbeResult(True, f"received {len(data)} bytes; first_line={first_line!r}") + + +def probe_trojan_udp_dns( + connect_endpoint: Endpoint, + tls_server_name: str, + node: dict[str, Any], + dns_server: Endpoint, + dns_name: str, + timeout: float, +) -> ProbeResult: + if node["config"].get("udp") is False: + return ProbeResult(False, "selected Trojan node has udp=false") + query_id, query = build_dns_query(dns_name) + try: + with connect_tls(connect_endpoint, tls_server_name, node, timeout) as sock: + sock.sendall( + trojan_header(node["config"]["password"], TROJAN_CMD_UDP_ASSOCIATE, dns_server) + ) + sock.sendall(trojan_udp_frame(dns_server, query)) + source = read_socks_addr(sock) + payload_len = struct.unpack("!H", read_exact(sock, 2))[0] + delimiter = read_exact(sock, 2) + if delimiter != b"\r\n": + return ProbeResult(False, f"invalid Trojan UDP delimiter: {delimiter!r}") + payload = read_exact(sock, payload_len) + except BaseException as exc: + return ProbeResult(False, f"{type(exc).__name__}: {exc}") + dns_summary = parse_dns_response(query_id, payload) + return ProbeResult( + True, + f"received UDP frame from {source.host}:{source.port}; DNS response {dns_summary}", + ) + + +def print_node_summary(network_id: int, payload: dict[str, Any], node: dict[str, Any]) -> None: + config = node.get("config") or {} + print(f"network_id={network_id}") + print(f"subscription={payload.get('name')!r}") + print(f"selected_node={node.get('name')!r}") + print(f"protocol={node.get('protocol')!r}") + print(f"server={node.get('server')}:{node.get('port')}") + print(f"sni={config.get('sni')!r}") + print(f"skip_cert_verify={bool(config.get('skip_cert_verify'))}") + print(f"alpn_present={bool(config.get('alpn_present'))} alpn={config.get('alpn') or []!r}") + print(f"udp={config.get('udp', True)!r}") + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Directly probe the selected Burrow proxy subscription node without " + "using the daemon, Network Extension, system proxy, routes, or DNS settings." + ) + ) + parser.add_argument("--db", type=Path, default=Path(os.environ.get("BURROW_DB", DEFAULT_DB))) + parser.add_argument("--network-id", type=int) + parser.add_argument("--node-name", help="Probe this subscription node instead of the selected node.") + parser.add_argument("--node-ordinal", type=int, help="Probe this subscription ordinal instead of the selected node.") + parser.add_argument( + "--server-address", + help=( + "Override the proxy connect address, as host or host:port. " + "This bypasses system DNS for the proxy hostname while preserving SNI." + ), + ) + parser.add_argument( + "--resolve-server", + choices=["system", "doh-google", "doh-cloudflare", "doh-all"], + default="system", + help="How to resolve the proxy server when --server-address is not provided.", + ) + parser.add_argument( + "--all-server-addresses", + action="store_true", + help="Probe every resolved proxy server address and pass if any address works.", + ) + parser.add_argument("--target", default=DEFAULT_TARGET, help="TCP HTTP target, host:port") + parser.add_argument("--http-host", default=DEFAULT_HTTP_HOST) + parser.add_argument("--dns-server", default=DEFAULT_DNS_SERVER, help="UDP DNS target, host:port") + parser.add_argument("--dns-name", default=DEFAULT_DNS_NAME) + parser.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT) + parser.add_argument("--skip-udp", action="store_true") + parser.add_argument("--require-udp", action="store_true") + args = parser.parse_args() + + print("Burrow proxy ladder: direct provider/protocol probe") + print("bypasses=daemon,NetworkExtension,system_proxy,routes,system_dns,browser") + print(f"db={args.db}") + + try: + if args.node_name is not None and args.node_ordinal is not None: + raise ValueError("--node-name and --node-ordinal are mutually exclusive") + network_id, payload = load_payload(args.db, args.network_id) + node = choose_node(payload, args.node_name, args.node_ordinal) + print_node_summary(network_id, payload, node) + if node.get("protocol") != "trojan": + print(f"unsupported protocol for this direct probe: {node.get('protocol')!r}") + return 2 + server = Endpoint(str(node["server"]), int(node["port"])) + if args.server_address: + connect_endpoints = [parse_endpoint(args.server_address, default_port=server.port)] + elif args.resolve_server == "system": + system_addresses = resolved_stream_addresses(server) + connect_endpoints = [ + Endpoint(str(sockaddr[0]), int(sockaddr[1])) for _family, sockaddr in system_addresses + ] + if not args.all_server_addresses and connect_endpoints: + connect_endpoints = connect_endpoints[:1] + if not connect_endpoints: + connect_endpoints = [server] + elif args.resolve_server == "doh-all": + connect_endpoints = resolve_doh_all(server.host, server.port, args.timeout) + if not args.all_server_addresses and connect_endpoints: + connect_endpoints = connect_endpoints[:1] + else: + connect_endpoints = resolve_doh(server.host, server.port, args.resolve_server, args.timeout) + print( + f"{args.resolve_server}_answers=" + + ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in connect_endpoints) + ) + if not args.all_server_addresses and connect_endpoints: + connect_endpoints = connect_endpoints[:1] + if not connect_endpoints: + raise LookupError("proxy server resolution returned no usable A records") + target = parse_endpoint(args.target) + dns_server = parse_endpoint(args.dns_server) + except BaseException as exc: + print(f"setup_error={type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + + print( + "connect_endpoints=" + + ",".join(f"{endpoint.host}:{endpoint.port}" for endpoint in connect_endpoints) + ) + if any(endpoint != server for endpoint in connect_endpoints): + print(f"tls_server_name_source={server.host!r} sni_preserved=True") + if any(is_fake_ip(endpoint.host) for endpoint in connect_endpoints): + print( + "warning=proxy hostname resolved into 198.18.0.0/15; " + "rerun with --resolve-server doh-all or --server-address REAL_IP to avoid fake-IP DNS" + ) + + any_tcp_ok = False + any_required_udp_ok = False + for idx, connect_endpoint in enumerate(connect_endpoints, start=1): + if len(connect_endpoints) > 1: + print(f"\n=== proxy server candidate {idx}/{len(connect_endpoints)} ===") + print(f"connect_endpoint={connect_endpoint.host}:{connect_endpoint.port}") + + print("\n[1/2] Trojan TCP CONNECT probe") + print(f"target={target.host}:{target.port} http_host={args.http_host!r}") + tcp_result = probe_trojan_tcp( + connect_endpoint, server.host, node, target, args.http_host, args.timeout + ) + print(("ok: " if tcp_result.ok else "fail: ") + tcp_result.detail) + any_tcp_ok = any_tcp_ok or tcp_result.ok + + udp_result = ProbeResult(True, "skipped") + if not args.skip_udp: + print("\n[2/2] Trojan UDP DNS probe") + print(f"dns_server={dns_server.host}:{dns_server.port} dns_name={args.dns_name!r}") + udp_result = probe_trojan_udp_dns( + connect_endpoint, server.host, node, dns_server, args.dns_name, args.timeout + ) + print(("ok: " if udp_result.ok else "fail: ") + udp_result.detail) + else: + print("\n[2/2] Trojan UDP DNS probe") + print("skipped by --skip-udp") + any_required_udp_ok = any_required_udp_ok or udp_result.ok + + if tcp_result.ok and (not args.require_udp or udp_result.ok): + break + + if not any_tcp_ok: + return 1 + if args.require_udp and not any_required_udp_ok: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/burrow-proxy-selftest b/Scripts/burrow-proxy-selftest new file mode 100755 index 0000000..fd3daaa --- /dev/null +++ b/Scripts/burrow-proxy-selftest @@ -0,0 +1,1417 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: Scripts/burrow-proxy-selftest + +Run a controlled Burrow proxy packet-runtime test without changing system proxy +settings or using the user's Burrow database. The script starts local Trojan and +Shadowsocks servers, starts a Burrow daemon against a temporary database/socket, +imports a temporary ProxySubscription pointed at those servers, then runs the +packet-level proxy TCP, UDP, UDP-over-TCP, and plugin probes through daemon +packet streaming. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +burrow_bin="${BURROW_BIN:-$repo_root/target/debug/burrow}" + +if [[ -z "${BURROW_BIN:-}" ]]; then + (cd "$repo_root" && cargo build -p burrow) +elif [[ ! -x "$burrow_bin" ]]; then + echo "BURROW_BIN is not executable: $burrow_bin" >&2 + exit 2 +fi + +if ! command -v openssl >/dev/null 2>&1; then + echo "openssl is required for the local TLS server certificate" >&2 + exit 2 +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "uv is required for the local Python self-test harness" >&2 + exit 2 +fi + +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-proxy-selftest.XXXXXX")" +daemon_pid="" +server_pid="" +ss_server_pid="" +status=0 + +cleanup() { + if [[ -n "$ss_server_pid" ]]; then + kill "$ss_server_pid" >/dev/null 2>&1 || true + fi + if [[ -n "$server_pid" ]]; then + kill "$server_pid" >/dev/null 2>&1 || true + fi + if [[ -n "$daemon_pid" ]]; then + kill "$daemon_pid" >/dev/null 2>&1 || true + fi + if [[ "${BURROW_SELFTEST_KEEP:-}" == "1" || "$status" -ne 0 ]]; then + echo "preserving self-test artifacts: $tmpdir" >&2 + else + rm -rf "$tmpdir" + fi +} +trap 'status=$?; cleanup' EXIT + +cert="$tmpdir/cert.pem" +key="$tmpdir/key.pem" +socket="$tmpdir/burrow.sock" +port_file="$tmpdir/server.port" +ss_port_file="$tmpdir/shadowsocks-server.port" +server_result="$tmpdir/server-result.json" +ss_server_result="$tmpdir/shadowsocks-server-result.json" +payload="$tmpdir/proxy-payload.json" +daemon_log="$tmpdir/daemon.log" +server_log="$tmpdir/server.log" +ss_server_log="$tmpdir/shadowsocks-server.log" + +openssl req \ + -x509 \ + -newkey rsa:2048 \ + -nodes \ + -subj /CN=localhost \ + -keyout "$key" \ + -out "$cert" \ + -days 1 >/dev/null 2>&1 + +uv run python - "$port_file" "$server_result" "$cert" "$key" >"$server_log" 2>&1 <<'PY' & +from __future__ import annotations + +import hashlib +import json +import socket +import ssl +import struct +import sys + +port_file, result_file, cert_file, key_file = sys.argv[1:] +password = "secret" + + +def read_exact(sock: ssl.SSLSocket, length: int) -> bytes: + chunks: list[bytes] = [] + remaining = length + while remaining: + chunk = sock.recv(remaining) + if not chunk: + raise EOFError(f"expected {remaining} more bytes") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def read_socks_addr(sock: ssl.SSLSocket) -> dict[str, object]: + atyp = read_exact(sock, 1)[0] + if atyp == 1: + host = socket.inet_ntop(socket.AF_INET, read_exact(sock, 4)) + elif atyp == 4: + host = socket.inet_ntop(socket.AF_INET6, read_exact(sock, 16)) + elif atyp == 3: + length = read_exact(sock, 1)[0] + host = read_exact(sock, length).decode("idna") + else: + raise ValueError(f"unsupported SOCKS address type {atyp}") + port = struct.unpack("!H", read_exact(sock, 2))[0] + return {"atyp": atyp, "host": host, "port": port} + + +def read_http_headers(sock: ssl.SSLSocket) -> bytes: + data = bytearray() + while b"\r\n\r\n" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data.extend(chunk) + if len(data) > 65535: + raise ValueError("HTTP request headers exceeded 64 KiB") + return bytes(data) + + +ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +ctx.load_cert_chain(cert_file, key_file) +ctx.set_alpn_protocols(["h2", "http/1.1"]) + +listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +listener.bind(("127.0.0.1", 0)) +listener.listen(1) +port = listener.getsockname()[1] +with open(port_file, "w", encoding="utf-8") as f: + f.write(str(port)) + +raw, peer = listener.accept() +raw.settimeout(10) +result: dict[str, object] = {"peer": str(peer)} +try: + with ctx.wrap_socket(raw, server_side=True) as tls: + tls.settimeout(10) + result["alpn"] = tls.selected_alpn_protocol() + got_hash = read_exact(tls, 56) + expected_hash = hashlib.sha224(password.encode()).hexdigest().encode() + result["password_hash_ok"] = got_hash == expected_hash + if not result["password_hash_ok"]: + raise ValueError("unexpected Trojan password hash") + if read_exact(tls, 2) != b"\r\n": + raise ValueError("missing Trojan password delimiter") + command = read_exact(tls, 1)[0] + result["command"] = command + target = read_socks_addr(tls) + result["target"] = target + if read_exact(tls, 2) != b"\r\n": + raise ValueError("missing Trojan header delimiter") + request = read_http_headers(tls) + result["request_first_line"] = request.splitlines()[0].decode("utf-8", "replace") + body = json.dumps( + { + "ok": True, + "target": target, + "request_first_line": result["request_first_line"], + }, + sort_keys=True, + ).encode() + tls.sendall( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) +finally: + with open(result_file, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True) + listener.close() +PY +server_pid=$! + +for _ in {1..100}; do + if [[ -s "$port_file" ]]; then + break + fi + if ! kill -0 "$server_pid" >/dev/null 2>&1; then + echo "local Trojan server exited before listening" >&2 + cat "$server_log" >&2 || true + exit 1 + fi + sleep 0.05 +done + +if [[ ! -s "$port_file" ]]; then + echo "timed out waiting for local Trojan server" >&2 + cat "$server_log" >&2 || true + exit 1 +fi +server_port="$(cat "$port_file")" + +uv run python - "$ss_port_file" "$ss_server_result" >"$ss_server_log" 2>&1 <<'PY' & +from __future__ import annotations + +import json +import base64 +import socket +import struct +import sys + +port_file, result_file = sys.argv[1:] + + +def read_exact(sock: socket.socket, length: int) -> bytes: + chunks: list[bytes] = [] + remaining = length + while remaining: + chunk = sock.recv(remaining) + if not chunk: + raise EOFError(f"expected {remaining} more bytes") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def read_socks_addr(sock: socket.socket) -> dict[str, object]: + atyp = read_exact(sock, 1)[0] + if atyp == 1: + host = socket.inet_ntop(socket.AF_INET, read_exact(sock, 4)) + elif atyp == 4: + host = socket.inet_ntop(socket.AF_INET6, read_exact(sock, 16)) + elif atyp == 3: + length = read_exact(sock, 1)[0] + host = read_exact(sock, length).decode("idna") + else: + raise ValueError(f"unsupported SOCKS address type {atyp}") + port = struct.unpack("!H", read_exact(sock, 2))[0] + return {"atyp": atyp, "host": host, "port": port} + + +def parse_socks_addr_packet(data: bytes) -> tuple[dict[str, object], int]: + offset = 0 + atyp = data[offset] + offset += 1 + if atyp == 1: + host = socket.inet_ntop(socket.AF_INET, data[offset : offset + 4]) + offset += 4 + elif atyp == 4: + host = socket.inet_ntop(socket.AF_INET6, data[offset : offset + 16]) + offset += 16 + elif atyp == 3: + length = data[offset] + offset += 1 + host = data[offset : offset + length].decode("idna") + offset += length + else: + raise ValueError(f"unsupported SOCKS address type {atyp}") + port = struct.unpack("!H", data[offset : offset + 2])[0] + offset += 2 + return {"atyp": atyp, "host": host, "port": port}, offset + + +def read_uot_addr(sock: socket.socket) -> tuple[dict[str, object], bytes]: + raw = bytearray() + atyp = read_exact(sock, 1)[0] + raw.append(atyp) + if atyp == 0: + addr = read_exact(sock, 4) + raw.extend(addr) + host = socket.inet_ntop(socket.AF_INET, addr) + elif atyp == 1: + addr = read_exact(sock, 16) + raw.extend(addr) + host = socket.inet_ntop(socket.AF_INET6, addr) + elif atyp == 2: + length = read_exact(sock, 1)[0] + raw.append(length) + addr = read_exact(sock, length) + raw.extend(addr) + host = addr.decode("idna") + else: + raise ValueError(f"unsupported UDP-over-TCP address type {atyp}") + port_bytes = read_exact(sock, 2) + raw.extend(port_bytes) + port = struct.unpack("!H", port_bytes)[0] + return {"atyp": atyp, "host": host, "port": port}, bytes(raw) + + +def read_uot_frame(sock: socket.socket) -> tuple[dict[str, object], bytes, bytes]: + target, target_bytes = read_uot_addr(sock) + length_bytes = read_exact(sock, 2) + payload_len = struct.unpack("!H", length_bytes)[0] + payload = read_exact(sock, payload_len) + return target, target_bytes + length_bytes + payload, payload + + +def read_uot_request(sock: socket.socket) -> dict[str, object]: + version = read_exact(sock, 1)[0] + target = read_socks_addr(sock) + return {"version": version, "target": target} + + +def handle_uot_connection( + listener: socket.socket, + result: dict[str, object], + prefix: str, + expect_request: bool, +) -> None: + uot_raw, uot_peer = listener.accept() + uot_raw.settimeout(10) + try: + uot_magic = read_socks_addr(uot_raw) + if expect_request: + result[f"{prefix}_request"] = read_uot_request(uot_raw) + uot_target, uot_frame, uot_payload = read_uot_frame(uot_raw) + result[f"{prefix}_peer"] = str(uot_peer) + result[f"{prefix}_magic_target"] = uot_magic + result[f"{prefix}_target"] = uot_target + result[f"{prefix}_payload"] = uot_payload.decode("utf-8", "replace") + uot_raw.sendall(uot_frame) + finally: + uot_raw.close() + + +def read_http_headers(sock: socket.socket) -> bytes: + return read_http_headers_with_prefix(sock, b"") + + +def read_http_headers_with_prefix(sock: socket.socket, prefix: bytes) -> bytes: + data = bytearray(prefix) + while b"\r\n\r\n" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data.extend(chunk) + if len(data) > 65535: + raise ValueError("HTTP request headers exceeded 64 KiB") + return bytes(data) + + +def read_obfs_http_headers(sock: socket.socket) -> tuple[bytes, bytes]: + data = bytearray() + while b"\r\n\r\n" not in data: + chunk = sock.recv(4096) + if not chunk: + break + data.extend(chunk) + if len(data) > 65535: + raise ValueError("simple-obfs HTTP headers exceeded 64 KiB") + header_end = bytes(data).find(b"\r\n\r\n") + if header_end < 0: + raise ValueError("simple-obfs HTTP headers were incomplete") + header_end += 4 + return bytes(data[:header_end]), bytes(data[header_end:]) + + +def read_simple_obfs_tls_payload(sock: socket.socket) -> tuple[bytes, str | None]: + header = read_exact(sock, 5) + if header[0] != 22: + raise ValueError(f"expected TLS handshake record, got type {header[0]}") + record_len = struct.unpack("!H", header[3:5])[0] + record = read_exact(sock, record_len) + if len(record) < 4 or record[0] != 1: + raise ValueError("expected TLS ClientHello handshake") + body_len = int.from_bytes(record[1:4], "big") + body = record[4 : 4 + body_len] + offset = 0 + offset += 2 # version + offset += 32 # random + session_id_len = body[offset] + offset += 1 + session_id_len + cipher_len = struct.unpack("!H", body[offset : offset + 2])[0] + offset += 2 + cipher_len + compression_len = body[offset] + offset += 1 + compression_len + extension_len = struct.unpack("!H", body[offset : offset + 2])[0] + offset += 2 + end = offset + extension_len + ticket_payload: bytes | None = None + server_name: str | None = None + while offset + 4 <= end: + extension_type = struct.unpack("!H", body[offset : offset + 2])[0] + length = struct.unpack("!H", body[offset + 2 : offset + 4])[0] + offset += 4 + extension = body[offset : offset + length] + offset += length + if extension_type == 0x0023: + ticket_payload = extension + elif extension_type == 0x0000 and len(extension) >= 5: + name_len = struct.unpack("!H", extension[3:5])[0] + if len(extension) >= 5 + name_len: + server_name = extension[5 : 5 + name_len].decode("idna") + if ticket_payload is None: + raise ValueError("simple-obfs TLS ClientHello did not include payload extension") + return ticket_payload, server_name + + +def read_simple_obfs_tls_application_payload(sock: socket.socket) -> bytes: + header = read_exact(sock, 5) + if header[:3] != b"\x17\x03\x03": + raise ValueError(f"expected TLS application-data record, got {header.hex()}") + record_len = struct.unpack("!H", header[3:5])[0] + return read_exact(sock, record_len) + + +def read_simple_obfs_tls_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: + data = bytearray(prefix) + while b"\r\n\r\n" not in data: + data.extend(read_simple_obfs_tls_application_payload(sock)) + if len(data) > 65535: + raise ValueError("simple-obfs TLS plaintext headers exceeded 64 KiB") + return bytes(data) + + +def read_client_websocket_frame(sock: socket.socket) -> bytes: + header = read_exact(sock, 2) + if header[0] & 0x0F != 2: + raise ValueError(f"expected binary websocket frame, got opcode {header[0] & 0x0F}") + if header[1] & 0x80 == 0: + raise ValueError("client websocket frame was not masked") + length = header[1] & 0x7F + if length == 126: + length = struct.unpack("!H", read_exact(sock, 2))[0] + elif length == 127: + length = struct.unpack("!Q", read_exact(sock, 8))[0] + mask = read_exact(sock, 4) + payload = bytearray(read_exact(sock, length)) + for index, byte in enumerate(payload): + payload[index] = byte ^ mask[index % 4] + return bytes(payload) + + +def server_websocket_frame(payload: bytes) -> bytes: + if len(payload) < 126: + return b"\x82" + bytes([len(payload)]) + payload + if len(payload) <= 65535: + return b"\x82\x7e" + struct.pack("!H", len(payload)) + payload + return b"\x82\x7f" + struct.pack("!Q", len(payload)) + payload + + +def read_websocket_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: + data = bytearray(prefix) + while b"\r\n\r\n" not in data: + data.extend(read_client_websocket_frame(sock)) + if len(data) > 65535: + raise ValueError("websocket plaintext headers exceeded 64 KiB") + return bytes(data) + + +def http_header_value(headers: bytes, name: bytes) -> str | None: + name = name.lower() + b":" + for line in headers.splitlines(): + if line.lower().startswith(name): + return line.decode("utf-8", "replace").split(":", 1)[1].strip() + return None + + +def decode_websocket_early_data(value: str | None) -> bytes: + if not value: + raise ValueError("missing websocket early-data protocol header") + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode((value + padding).encode()) + + +def parse_v2ray_mux_client_payload(payload: bytes) -> bytes: + open_len = struct.unpack("!H", payload[:2])[0] + open_frame = payload[2 : 2 + open_len] + if len(open_frame) != open_len or open_frame[:4] != b"\x00\x00\x01\x00": + raise ValueError("v2ray mux open frame was invalid") + offset = 2 + open_len + data_header = payload[offset : offset + 8] + if len(data_header) != 8 or data_header[:6] != b"\x00\x04\x00\x00\x02\x01": + raise ValueError("v2ray mux data frame header was invalid") + data_len = struct.unpack("!H", data_header[6:8])[0] + data_start = offset + 8 + data = payload[data_start : data_start + data_len] + if len(data) != data_len: + raise ValueError("v2ray mux data frame payload was incomplete") + return data + + +def v2ray_mux_server_data_frame(payload: bytes) -> bytes: + if len(payload) > 65535: + raise ValueError("v2ray mux payload is too large") + return b"\x00\x04\x00\x00\x02\x01" + struct.pack("!H", len(payload)) + payload + + +def parse_v2ray_mux_data_payload(payload: bytes) -> bytes: + if len(payload) < 8 or payload[:6] != b"\x00\x04\x00\x00\x02\x01": + raise ValueError("v2ray mux data frame header was invalid") + data_len = struct.unpack("!H", payload[6:8])[0] + data = payload[8 : 8 + data_len] + if len(data) != data_len: + raise ValueError("v2ray mux data frame payload was incomplete") + return data + + +def read_v2ray_mux_plaintext_headers(sock: socket.socket, prefix: bytes) -> bytes: + data = bytearray(prefix) + while b"\r\n\r\n" not in data: + data.extend(parse_v2ray_mux_data_payload(read_client_websocket_frame(sock))) + if len(data) > 65535: + raise ValueError("v2ray mux plaintext headers exceeded 64 KiB") + return bytes(data) + + +listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +listener.bind(("127.0.0.1", 0)) +listener.listen(16) +port = listener.getsockname()[1] +udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +udp.bind(("127.0.0.1", port)) +udp.settimeout(10) +with open(port_file, "w", encoding="utf-8") as f: + f.write(str(port)) + +raw, peer = listener.accept() +raw.settimeout(10) +result: dict[str, object] = {"tcp_peer": str(peer)} +try: + target = read_socks_addr(raw) + result["tcp_target"] = target + request = read_http_headers(raw) + result["tcp_request_first_line"] = request.splitlines()[0].decode("utf-8", "replace") + body = json.dumps( + { + "ok": True, + "target": target, + "request_first_line": result["tcp_request_first_line"], + }, + sort_keys=True, + ).encode() + raw.sendall( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + datagram, udp_peer = udp.recvfrom(65535) + udp_target, used = parse_socks_addr_packet(datagram) + udp_payload = datagram[used:] + result["udp_peer"] = str(udp_peer) + result["udp_target"] = udp_target + result["udp_payload"] = udp_payload.decode("utf-8", "replace") + udp.sendto(datagram[:used] + udp_payload, udp_peer) + handle_uot_connection(listener, result, "uot", False) + handle_uot_connection(listener, result, "uot_v2", True) + obfs_raw, obfs_peer = listener.accept() + obfs_raw.settimeout(10) + try: + obfs_headers, obfs_payload = read_obfs_http_headers(obfs_raw) + obfs_target, used = parse_socks_addr_packet(obfs_payload) + result["obfs_http_peer"] = str(obfs_peer) + result["obfs_http_first_line"] = obfs_headers.splitlines()[0].decode( + "utf-8", + "replace", + ) + result["obfs_http_host"] = next( + ( + line.decode("utf-8", "replace").split(":", 1)[1].strip() + for line in obfs_headers.splitlines() + if line.lower().startswith(b"host:") + ), + None, + ) + result["obfs_http_content_length"] = next( + ( + line.decode("utf-8", "replace").split(":", 1)[1].strip() + for line in obfs_headers.splitlines() + if line.lower().startswith(b"content-length:") + ), + None, + ) + result["obfs_http_target"] = obfs_target + obfs_request = read_http_headers_with_prefix(obfs_raw, obfs_payload[used:]) + result["obfs_http_request_first_line"] = obfs_request.splitlines()[0].decode( + "utf-8", + "replace", + ) + body = json.dumps( + { + "ok": True, + "target": obfs_target, + "request_first_line": result["obfs_http_request_first_line"], + }, + sort_keys=True, + ).encode() + obfs_raw.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n\r\n" + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + finally: + obfs_raw.close() + obfs_tls_raw, obfs_tls_peer = listener.accept() + obfs_tls_raw.settimeout(10) + try: + obfs_tls_payload, obfs_tls_server_name = read_simple_obfs_tls_payload(obfs_tls_raw) + obfs_tls_target, used = parse_socks_addr_packet(obfs_tls_payload) + obfs_tls_request = read_simple_obfs_tls_plaintext_headers( + obfs_tls_raw, + obfs_tls_payload[used:], + ) + result["obfs_tls_peer"] = str(obfs_tls_peer) + result["obfs_tls_server_name"] = obfs_tls_server_name + result["obfs_tls_target"] = obfs_tls_target + result["obfs_tls_request_first_line"] = obfs_tls_request.splitlines()[0].decode( + "utf-8", + "replace", + ) + body = json.dumps( + { + "ok": True, + "target": obfs_tls_target, + "request_first_line": result["obfs_tls_request_first_line"], + }, + sort_keys=True, + ).encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + obfs_tls_raw.sendall(bytes(105) + len(response).to_bytes(2, "big") + response) + finally: + obfs_tls_raw.close() + v2ray_raw, v2ray_peer = listener.accept() + v2ray_raw.settimeout(10) + try: + v2ray_headers = read_http_headers(v2ray_raw) + result["v2ray_http_upgrade_peer"] = str(v2ray_peer) + result["v2ray_http_upgrade_first_line"] = v2ray_headers.splitlines()[0].decode( + "utf-8", + "replace", + ) + result["v2ray_http_upgrade_host"] = next( + ( + line.decode("utf-8", "replace").split(":", 1)[1].strip() + for line in v2ray_headers.splitlines() + if line.lower().startswith(b"host:") + ), + None, + ) + result["v2ray_http_upgrade_has_websocket_key"] = any( + line.lower().startswith(b"sec-websocket-key:") + for line in v2ray_headers.splitlines() + ) + v2ray_raw.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n\r\n" + ) + v2ray_target = read_socks_addr(v2ray_raw) + v2ray_request = read_http_headers(v2ray_raw) + result["v2ray_http_upgrade_target"] = v2ray_target + result["v2ray_http_upgrade_request_first_line"] = ( + v2ray_request.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + body = json.dumps( + { + "ok": True, + "target": v2ray_target, + "request_first_line": result["v2ray_http_upgrade_request_first_line"], + }, + sort_keys=True, + ).encode() + v2ray_raw.sendall( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + finally: + v2ray_raw.close() + v2ray_ws_raw, v2ray_ws_peer = listener.accept() + v2ray_ws_raw.settimeout(10) + try: + v2ray_ws_headers = read_http_headers(v2ray_ws_raw) + result["v2ray_websocket_peer"] = str(v2ray_ws_peer) + result["v2ray_websocket_first_line"] = ( + v2ray_ws_headers.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + result["v2ray_websocket_host"] = next( + ( + line.decode("utf-8", "replace").split(":", 1)[1].strip() + for line in v2ray_ws_headers.splitlines() + if line.lower().startswith(b"host:") + ), + None, + ) + result["v2ray_websocket_has_websocket_key"] = any( + line.lower().startswith(b"sec-websocket-key:") + for line in v2ray_ws_headers.splitlines() + ) + v2ray_ws_raw.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n\r\n" + ) + v2ray_ws_payload = read_client_websocket_frame(v2ray_ws_raw) + v2ray_ws_target, used = parse_socks_addr_packet(v2ray_ws_payload) + v2ray_ws_request = read_websocket_plaintext_headers( + v2ray_ws_raw, + v2ray_ws_payload[used:], + ) + result["v2ray_websocket_target"] = v2ray_ws_target + result["v2ray_websocket_request_first_line"] = ( + v2ray_ws_request.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + body = json.dumps( + { + "ok": True, + "target": v2ray_ws_target, + "request_first_line": result["v2ray_websocket_request_first_line"], + }, + sort_keys=True, + ).encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + v2ray_ws_raw.sendall(server_websocket_frame(response)) + finally: + v2ray_ws_raw.close() + v2ray_mux_raw, v2ray_mux_peer = listener.accept() + v2ray_mux_raw.settimeout(10) + try: + v2ray_mux_headers = read_http_headers(v2ray_mux_raw) + result["v2ray_mux_peer"] = str(v2ray_mux_peer) + result["v2ray_mux_first_line"] = ( + v2ray_mux_headers.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + result["v2ray_mux_host"] = next( + ( + line.decode("utf-8", "replace").split(":", 1)[1].strip() + for line in v2ray_mux_headers.splitlines() + if line.lower().startswith(b"host:") + ), + None, + ) + result["v2ray_mux_has_websocket_key"] = any( + line.lower().startswith(b"sec-websocket-key:") + for line in v2ray_mux_headers.splitlines() + ) + v2ray_mux_raw.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n\r\n" + ) + v2ray_mux_payload = parse_v2ray_mux_client_payload( + read_client_websocket_frame(v2ray_mux_raw), + ) + v2ray_mux_target, used = parse_socks_addr_packet(v2ray_mux_payload) + v2ray_mux_request = read_v2ray_mux_plaintext_headers( + v2ray_mux_raw, + v2ray_mux_payload[used:], + ) + result["v2ray_mux_target"] = v2ray_mux_target + result["v2ray_mux_request_first_line"] = ( + v2ray_mux_request.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + body = json.dumps( + { + "ok": True, + "target": v2ray_mux_target, + "request_first_line": result["v2ray_mux_request_first_line"], + }, + sort_keys=True, + ).encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + v2ray_mux_raw.sendall( + server_websocket_frame(v2ray_mux_server_data_frame(response)), + ) + finally: + v2ray_mux_raw.close() + gost_ws_raw, gost_ws_peer = listener.accept() + gost_ws_raw.settimeout(10) + try: + gost_ws_headers = read_http_headers(gost_ws_raw) + result["gost_websocket_peer"] = str(gost_ws_peer) + result["gost_websocket_first_line"] = ( + gost_ws_headers.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + result["gost_websocket_host"] = next( + ( + line.decode("utf-8", "replace").split(":", 1)[1].strip() + for line in gost_ws_headers.splitlines() + if line.lower().startswith(b"host:") + ), + None, + ) + result["gost_websocket_has_websocket_key"] = any( + line.lower().startswith(b"sec-websocket-key:") + for line in gost_ws_headers.splitlines() + ) + gost_ws_raw.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n\r\n" + ) + gost_ws_payload = read_client_websocket_frame(gost_ws_raw) + gost_ws_target, used = parse_socks_addr_packet(gost_ws_payload) + gost_ws_request = read_websocket_plaintext_headers( + gost_ws_raw, + gost_ws_payload[used:], + ) + result["gost_websocket_target"] = gost_ws_target + result["gost_websocket_request_first_line"] = ( + gost_ws_request.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + body = json.dumps( + { + "ok": True, + "target": gost_ws_target, + "request_first_line": result["gost_websocket_request_first_line"], + }, + sort_keys=True, + ).encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + gost_ws_raw.sendall(server_websocket_frame(response)) + finally: + gost_ws_raw.close() + v2ray_ed_raw, v2ray_ed_peer = listener.accept() + v2ray_ed_raw.settimeout(10) + try: + v2ray_ed_headers = read_http_headers(v2ray_ed_raw) + result["v2ray_early_data_peer"] = str(v2ray_ed_peer) + result["v2ray_early_data_first_line"] = ( + v2ray_ed_headers.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + result["v2ray_early_data_host"] = http_header_value(v2ray_ed_headers, b"host") + result["v2ray_early_data_has_websocket_key"] = ( + http_header_value(v2ray_ed_headers, b"sec-websocket-key") is not None + ) + early_data = decode_websocket_early_data( + http_header_value(v2ray_ed_headers, b"sec-websocket-protocol"), + ) + result["v2ray_early_data_len"] = len(early_data) + v2ray_ed_raw.sendall( + b"HTTP/1.1 101 Switching Protocols\r\n" + b"Upgrade: websocket\r\n" + b"Connection: Upgrade\r\n\r\n" + ) + v2ray_ed_payload = early_data + read_client_websocket_frame(v2ray_ed_raw) + v2ray_ed_target, used = parse_socks_addr_packet(v2ray_ed_payload) + v2ray_ed_request = read_websocket_plaintext_headers( + v2ray_ed_raw, + v2ray_ed_payload[used:], + ) + result["v2ray_early_data_target"] = v2ray_ed_target + result["v2ray_early_data_request_first_line"] = ( + v2ray_ed_request.splitlines()[0].decode( + "utf-8", + "replace", + ) + ) + body = json.dumps( + { + "ok": True, + "target": v2ray_ed_target, + "request_first_line": result["v2ray_early_data_request_first_line"], + }, + sort_keys=True, + ).encode() + response = ( + b"HTTP/1.1 200 OK\r\n" + + f"Content-Length: {len(body)}\r\n".encode() + + b"Connection: close\r\n" + + b"Content-Type: application/json\r\n\r\n" + + body + ) + v2ray_ed_raw.sendall(server_websocket_frame(response)) + finally: + v2ray_ed_raw.close() +finally: + raw.close() + udp.close() + with open(result_file, "w", encoding="utf-8") as f: + json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True) + listener.close() +PY +ss_server_pid=$! + +for _ in {1..100}; do + if [[ -s "$ss_port_file" ]]; then + break + fi + if ! kill -0 "$ss_server_pid" >/dev/null 2>&1; then + echo "local Shadowsocks server exited before listening" >&2 + cat "$ss_server_log" >&2 || true + exit 1 + fi + sleep 0.05 +done + +if [[ ! -s "$ss_port_file" ]]; then + echo "timed out waiting for local Shadowsocks server" >&2 + cat "$ss_server_log" >&2 || true + exit 1 +fi +ss_server_port="$(cat "$ss_port_file")" + +cat >"$payload" <"$daemon_log" 2>&1 +) & +daemon_pid=$! + +for _ in {1..100}; do + if [[ -S "$socket" ]]; then + break + fi + if ! kill -0 "$daemon_pid" >/dev/null 2>&1; then + echo "Burrow daemon exited before creating socket" >&2 + cat "$daemon_log" >&2 || true + exit 1 + fi + sleep 0.05 +done + +if [[ ! -S "$socket" ]]; then + echo "timed out waiting for Burrow daemon socket" >&2 + cat "$daemon_log" >&2 || true + exit 1 +fi + +( + cd "$tmpdir" + BURROW_SOCKET_PATH="$socket" "$burrow_bin" network-add 1 3 "$payload" + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 +) + +wait "$server_pid" +server_pid="" + +echo +echo "Local Trojan server observed:" +cat "$server_result" +echo + +( + cd "$tmpdir" + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 2 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ + --message burrow-shadowsocks-udp-selftest \ + --timeout-ms 8000 \ + 9.9.9.9:5353 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 3 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ + --message burrow-shadowsocks-uot-selftest \ + --timeout-ms 8000 \ + 9.9.9.9:5354 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 4 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ + --message burrow-shadowsocks-uot-v2-selftest \ + --timeout-ms 8000 \ + 9.9.9.9:5355 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 5 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 6 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 7 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 8 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 9 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 10 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-subscription-select 1 11 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 +) + +wait "$ss_server_pid" +ss_server_pid="" + +echo +echo "Local Shadowsocks server observed:" +cat "$ss_server_result" +echo + +uv run python - "$server_result" "$ss_server_result" <<'PY' +from __future__ import annotations + +import json +import sys + +trojan_path, shadowsocks_path = sys.argv[1:] + + +def load(path: str) -> dict[str, object]: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def expect(value: object, expected: object, label: str) -> None: + if value != expected: + raise AssertionError(f"{label}: expected {expected!r}, got {value!r}") + + +def expect_startswith(value: object, prefix: str, label: str) -> None: + if not isinstance(value, str) or not value.startswith(prefix): + raise AssertionError(f"{label}: expected prefix {prefix!r}, got {value!r}") + + +def expect_target(result: dict[str, object], key: str, host: str, port: int) -> None: + target = result.get(key) + if not isinstance(target, dict): + raise AssertionError(f"{key}: missing target object") + expect(target.get("host"), host, f"{key}.host") + expect(target.get("port"), port, f"{key}.port") + + +trojan = load(trojan_path) +expect(trojan.get("password_hash_ok"), True, "trojan password hash") +expect(trojan.get("command"), 1, "trojan tcp command") +expect(trojan.get("request_first_line"), "GET / HTTP/1.1", "trojan request") +expect_target(trojan, "target", "selftest.invalid", 80) + +ss = load(shadowsocks_path) +for key in [ + "tcp_target", + "obfs_http_target", + "obfs_tls_target", + "v2ray_http_upgrade_target", + "v2ray_websocket_target", + "v2ray_mux_target", + "gost_websocket_target", + "v2ray_early_data_target", +]: + expect_target(ss, key, "selftest.invalid", 80) + +expect(ss.get("udp_payload"), "burrow-shadowsocks-udp-selftest", "native UDP payload") +expect_target(ss, "udp_target", "9.9.9.9", 5353) +expect(ss.get("uot_payload"), "burrow-shadowsocks-uot-selftest", "legacy UOT payload") +expect_target(ss, "uot_magic_target", "sp.udp-over-tcp.arpa", 0) +expect_target(ss, "uot_target", "9.9.9.9", 5354) +expect(ss.get("uot_v2_payload"), "burrow-shadowsocks-uot-v2-selftest", "v2 UOT payload") +expect_target(ss, "uot_v2_magic_target", "sp.v2.udp-over-tcp.arpa", 0) +expect_target(ss, "uot_v2_target", "9.9.9.9", 5355) +expect_startswith(ss.get("obfs_http_host"), "front.example:", "simple-obfs HTTP host") +expect(ss.get("obfs_tls_server_name"), "front.example", "simple-obfs TLS SNI") +expect(ss.get("v2ray_http_upgrade_has_websocket_key"), False, "v2ray raw upgrade key") +expect(ss.get("v2ray_websocket_has_websocket_key"), True, "v2ray websocket key") +expect(ss.get("v2ray_mux_has_websocket_key"), True, "v2ray mux websocket key") +expect(ss.get("gost_websocket_has_websocket_key"), True, "gost websocket key") +expect(ss.get("v2ray_early_data_first_line"), "GET /ed?trace=1 HTTP/1.1", "early-data path") +expect(ss.get("v2ray_early_data_len"), 8, "early-data length") + +print("Proxy self-test assertions passed") +PY diff --git a/Scripts/burrow-shadowsocks-singmux-interop b/Scripts/burrow-shadowsocks-singmux-interop new file mode 100755 index 0000000..e02374b --- /dev/null +++ b/Scripts/burrow-shadowsocks-singmux-interop @@ -0,0 +1,504 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: Scripts/burrow-shadowsocks-singmux-interop + +Run controlled Burrow Shadowsocks top-level sing-mux interop tests against the +same github.com/metacubex/sing-mux Go module used by Mihomo. By default this +checks smux, yamux, and h2mux with and without Mihomo's padded sing-mux +preface/framing. The test does not change system proxy settings or use the +user's Burrow database. +EOF +} + +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +burrow_bin="${BURROW_BIN:-$repo_root/target/debug/burrow}" +sing_mux_dir="${SING_MUX_DIR:-/Users/jettchen/go/pkg/mod/github.com/metacubex/sing-mux@v0.3.9}" +sing_dir="${SING_DIR:-/Users/jettchen/go/pkg/mod/github.com/metacubex/sing@v0.5.7}" + +if [[ -z "${BURROW_SINGMUX_INTEROP_SINGLE:-}" ]]; then + for protocol in ${BURROW_SINGMUX_PROTOCOLS:-smux yamux h2mux}; do + for padding in ${BURROW_SINGMUX_PADDING_MODES:-false true}; do + echo "=== Mihomo sing-mux interop: $protocol padding=$padding ===" + BURROW_SINGMUX_INTEROP_SINGLE=1 \ + BURROW_SINGMUX_PROTOCOL="$protocol" \ + BURROW_SINGMUX_PADDING="$padding" \ + "$0" + done + done + exit 0 +fi + +protocol="${BURROW_SINGMUX_PROTOCOL:-smux}" +case "$protocol" in + smux | yamux | h2mux) ;; + *) + echo "unsupported sing-mux protocol: $protocol" >&2 + exit 2 + ;; +esac + +padding="${BURROW_SINGMUX_PADDING:-false}" +case "$padding" in + false | true) ;; + *) + echo "unsupported sing-mux padding mode: $padding" >&2 + exit 2 + ;; +esac + +node_udp="${BURROW_SINGMUX_NODE_UDP:-false}" +case "$node_udp" in + false | true) ;; + *) + echo "unsupported Shadowsocks node udp flag: $node_udp" >&2 + exit 2 + ;; +esac + +if [[ -z "${BURROW_BIN:-}" ]]; then + (cd "$repo_root" && cargo build -p burrow) +elif [[ ! -x "$burrow_bin" ]]; then + echo "BURROW_BIN is not executable: $burrow_bin" >&2 + exit 2 +fi + +if ! command -v go >/dev/null 2>&1; then + echo "go is required for the Mihomo sing-mux interop peer" >&2 + exit 2 +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "uv is required for result assertions" >&2 + exit 2 +fi + +if [[ ! -d "$sing_mux_dir" ]]; then + echo "sing-mux module directory not found: $sing_mux_dir" >&2 + exit 2 +fi + +if [[ ! -d "$sing_dir" ]]; then + echo "sing module directory not found: $sing_dir" >&2 + exit 2 +fi + +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-singmux-interop.XXXXXX")" +daemon_pid="" +server_pid="" +status=0 + +cleanup() { + if [[ -n "$server_pid" ]]; then + kill "$server_pid" >/dev/null 2>&1 || true + fi + if [[ -n "$daemon_pid" ]]; then + kill "$daemon_pid" >/dev/null 2>&1 || true + fi + if [[ "${BURROW_SELFTEST_KEEP:-}" == "1" || "$status" -ne 0 ]]; then + echo "preserving sing-mux interop artifacts: $tmpdir" >&2 + else + rm -rf "$tmpdir" + fi +} +trap 'status=$?; cleanup' EXIT + +socket="$tmpdir/burrow.sock" +port_file="$tmpdir/singmux.port" +result_file="$tmpdir/singmux-result.json" +payload="$tmpdir/proxy-payload.json" +server_log="$tmpdir/singmux-server.log" +daemon_log="$tmpdir/daemon.log" +go_dir="$tmpdir/go-peer" +mkdir -p "$go_dir" + +cat >"$go_dir/go.mod" < $sing_dir +replace github.com/metacubex/sing-mux => $sing_mux_dir +EOF + +cat >"$go_dir/main.go" <<'EOF' +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "os" + "strings" + "sync" + "time" + + mux "github.com/metacubex/sing-mux" + "github.com/metacubex/sing/common/buf" + "github.com/metacubex/sing/common/bufio" + "github.com/metacubex/sing/common/logger" + M "github.com/metacubex/sing/common/metadata" + N "github.com/metacubex/sing/common/network" +) + +type noopLogger struct{} + +func (noopLogger) Trace(args ...any) {} +func (noopLogger) Debug(args ...any) {} +func (noopLogger) Info(args ...any) {} +func (noopLogger) Warn(args ...any) {} +func (noopLogger) Error(args ...any) {} +func (noopLogger) Fatal(args ...any) {} +func (noopLogger) Panic(args ...any) {} +func (noopLogger) TraceContext(context.Context, ...any) {} +func (noopLogger) DebugContext(context.Context, ...any) {} +func (noopLogger) InfoContext(context.Context, ...any) {} +func (noopLogger) WarnContext(context.Context, ...any) {} +func (noopLogger) ErrorContext(context.Context, ...any) {} +func (noopLogger) FatalContext(context.Context, ...any) {} +func (noopLogger) PanicContext(context.Context, ...any) {} + +var _ logger.ContextLogger = noopLogger{} + +type observed struct { + MuxTarget map[string]any `json:"mux_target,omitempty"` + TCPDestination map[string]any `json:"tcp_destination,omitempty"` + TCPFirstLine string `json:"tcp_first_line,omitempty"` + UDPDestination map[string]any `json:"udp_destination,omitempty"` + UDPPayload string `json:"udp_payload,omitempty"` + Protocol string `json:"protocol,omitempty"` + Padding bool `json:"padding,omitempty"` + PacketReplyCount int `json:"packet_reply_count,omitempty"` +} + +type handler struct { + mu sync.Mutex + result observed + done chan struct{} + doneOnce sync.Once +} + +func (h *handler) markDoneIfReady() { + if h.result.TCPFirstLine != "" && h.result.UDPPayload != "" { + h.doneOnce.Do(func() { close(h.done) }) + } +} + +func (h *handler) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error { + defer conn.Close() + request, err := readHTTPHeaders(conn) + if err != nil { + return err + } + firstLine := strings.SplitN(string(request), "\r\n", 2)[0] + body := []byte("burrow sing-mux tcp ok\n") + response := fmt.Sprintf( + "HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n%s", + len(body), + body, + ) + if _, err := conn.Write([]byte(response)); err != nil { + return err + } + h.mu.Lock() + h.result.TCPDestination = socksaddrJSON(metadata.Destination) + h.result.TCPFirstLine = firstLine + h.markDoneIfReady() + h.mu.Unlock() + return nil +} + +func (h *handler) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error { + defer conn.Close() + packet := buf.NewPacket() + defer packet.Release() + destination, err := conn.ReadPacket(packet) + if err != nil { + return err + } + payload := append([]byte(nil), packet.Bytes()...) + if _, err := bufio.WritePacketBuffer(conn, buf.As(payload), destination); err != nil { + return err + } + h.mu.Lock() + h.result.UDPDestination = socksaddrJSON(metadata.Destination) + h.result.UDPPayload = string(payload) + h.result.PacketReplyCount++ + h.markDoneIfReady() + h.mu.Unlock() + return nil +} + +func main() { + portFile := os.Args[1] + resultFile := os.Args[2] + protocol := os.Args[3] + padding := os.Args[4] == "true" + + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + panic(err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + if err := os.WriteFile(portFile, []byte(fmt.Sprint(port)), 0o644); err != nil { + panic(err) + } + + h := &handler{done: make(chan struct{})} + h.result.Protocol = protocol + h.result.Padding = padding + service, err := mux.NewService(mux.ServiceOptions{ + NewStreamContext: func(ctx context.Context, conn net.Conn) context.Context { return ctx }, + Logger: noopLogger{}, + Handler: h, + }) + if err != nil { + panic(err) + } + + raw, err := listener.Accept() + if err != nil { + panic(err) + } + raw.SetDeadline(time.Now().Add(20 * time.Second)) + target, err := readSocksAddr(raw) + if err != nil { + panic(err) + } + h.mu.Lock() + h.result.MuxTarget = target + h.mu.Unlock() + + go func() { + if err := service.NewConnection(context.Background(), raw, M.Metadata{}); err != nil { + fmt.Fprintln(os.Stderr, err) + } + }() + + select { + case <-h.done: + case <-time.After(20 * time.Second): + panic("timed out waiting for sing-mux TCP and UDP probes") + } + + h.mu.Lock() + output, err := json.MarshalIndent(h.result, "", " ") + h.mu.Unlock() + if err != nil { + panic(err) + } + if err := os.WriteFile(resultFile, append(output, '\n'), 0o644); err != nil { + panic(err) + } +} + +func readHTTPHeaders(reader io.Reader) ([]byte, error) { + data := make([]byte, 0, 512) + tmp := make([]byte, 1) + for !strings.Contains(string(data), "\r\n\r\n") { + if _, err := io.ReadFull(reader, tmp); err != nil { + return nil, err + } + data = append(data, tmp[0]) + if len(data) > 65535 { + return nil, fmt.Errorf("HTTP headers exceeded 64 KiB") + } + } + return data, nil +} + +func readSocksAddr(reader io.Reader) (map[string]any, error) { + destination, err := M.SocksaddrSerializer.ReadAddrPort(reader) + if err != nil { + return nil, err + } + return socksaddrJSON(destination), nil +} + +func socksaddrJSON(destination M.Socksaddr) map[string]any { + if destination.IsFqdn() { + return map[string]any{ + "host": destination.Fqdn, + "port": destination.Port, + } + } + return map[string]any{ + "host": destination.Addr.String(), + "port": destination.Port, + } +} + +func (h *handler) NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, metadata M.Metadata) error { + return fmt.Errorf("unexpected packet handler path") +} +EOF + +( + cd "$go_dir" + go mod tidy +) + +( + cd "$go_dir" + go run . "$port_file" "$result_file" "$protocol" "$padding" +) >"$server_log" 2>&1 & +server_pid=$! + +for _ in {1..600}; do + if [[ -s "$port_file" ]]; then + break + fi + if ! kill -0 "$server_pid" >/dev/null 2>&1; then + echo "sing-mux peer exited before listening" >&2 + cat "$server_log" >&2 || true + exit 1 + fi + sleep 0.05 +done + +if [[ ! -s "$port_file" ]]; then + echo "timed out waiting for sing-mux peer" >&2 + cat "$server_log" >&2 || true + exit 1 +fi +server_port="$(cat "$port_file")" + +cat >"$payload" <"$daemon_log" 2>&1 +) & +daemon_pid=$! + +for _ in {1..100}; do + if [[ -S "$socket" ]]; then + break + fi + if ! kill -0 "$daemon_pid" >/dev/null 2>&1; then + echo "Burrow daemon exited before creating socket" >&2 + cat "$daemon_log" >&2 || true + exit 1 + fi + sleep 0.05 +done + +if [[ ! -S "$socket" ]]; then + echo "timed out waiting for Burrow daemon socket" >&2 + cat "$daemon_log" >&2 || true + exit 1 +fi + +( + cd "$tmpdir" + BURROW_SOCKET_PATH="$socket" "$burrow_bin" network-add 1 3 "$payload" + BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \ + --dns-name selftest.invalid \ + --remote 1.1.1.1:80 \ + --timeout-ms 8000 + BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \ + --message burrow-singmux-udp-selftest \ + --timeout-ms 8000 \ + 9.9.9.9:5353 +) + +wait "$server_pid" +server_pid="" + +echo +echo "Mihomo sing-mux peer observed:" +cat "$result_file" +echo + +uv run python - "$result_file" "$protocol" "$padding" <<'PY' +from __future__ import annotations + +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as f: + result = json.load(f) +expected_protocol = sys.argv[2] +expected_padding = sys.argv[3] == "true" + + +def expect(value: object, expected: object, label: str) -> None: + if value != expected: + raise AssertionError(f"{label}: expected {expected!r}, got {value!r}") + + +def expect_target(key: str, host: str, port: int) -> None: + target = result.get(key) + if not isinstance(target, dict): + raise AssertionError(f"{key}: missing target object") + expect(target.get("host"), host, f"{key}.host") + expect(target.get("port"), port, f"{key}.port") + + +expect(result.get("protocol"), expected_protocol, "sing-mux protocol") +expect(result.get("padding", False), expected_padding, "sing-mux padding") +expect_target("mux_target", "sp.mux.sing-box.arpa", 444) +expect_target("tcp_destination", "selftest.invalid", 80) +expect(result.get("tcp_first_line"), "GET / HTTP/1.1", "tcp first line") +expect_target("udp_destination", "9.9.9.9", 5353) +expect(result.get("udp_payload"), "burrow-singmux-udp-selftest", "udp payload") +expect(result.get("packet_reply_count"), 1, "packet reply count") +padding_label = "padded" if expected_padding else "unpadded" +print(f"Mihomo sing-mux {expected_protocol} {padding_label} interop assertions passed") +PY diff --git a/burrow-gtk/Cargo.lock b/burrow-gtk/Cargo.lock index a1a9ebd..f64b325 100644 --- a/burrow-gtk/Cargo.lock +++ b/burrow-gtk/Cargo.lock @@ -36,18 +36,7 @@ dependencies = [ "cfg-if", "cipher", "cpufeatures", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", + "zeroize", ] [[package]] @@ -59,6 +48,76 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "amplify" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f7fb4ac7c881e54a8e7015e399b6112a2a5bc958b6c89ac510840ff20273b31" +dependencies = [ + "amplify_derive", + "amplify_num", + "ascii", + "getrandom 0.2.12", + "getrandom 0.3.3", + "wasm-bindgen", +] + +[[package]] +name = "amplify_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a6309e6b8d89b36b9f959b7a8fa093583b94922a0f6438a24fb08936de4d428" +dependencies = [ + "amplify_syn", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "amplify_num" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afed304556696656d2d71495e1e5f2c4b524a3fb6eb0f2f3778ffc482a40b8a8" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "amplify_syn" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7736fb8d473c0d83098b5bac44df6a561e20470375cd8bcae30516dc889fd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.11" @@ -75,9 +134,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.4" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -113,6 +172,123 @@ version = "1.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash 0.5.0", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "arti-client" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e89842cae6e3bda0fd128a5c66eb3392ed412065dc698c77d9fcc4b77e4159f2" +dependencies = [ + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "educe", + "fs-mistrust", + "futures", + "hostname-validator", + "humantime", + "humantime-serde", + "libc", + "once_cell", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "thiserror 2.0.16", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-chanmgr", + "tor-circmgr", + "tor-config", + "tor-config-path", + "tor-dircommon", + "tor-dirmgr", + "tor-error", + "tor-guardmgr", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.16", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "assert_matches" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" + [[package]] name = "async-channel" version = "2.1.1" @@ -120,12 +296,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ca33f4bc4ed1babef42cad36cc1f51fa88be00420404e5b1e80ab1b18f7678c" dependencies = [ "concurrent-queue", - "event-listener", + "event-listener 4.0.3", "event-listener-strategy", "futures-core", "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5" +dependencies = [ + "flate2", + "futures-core", + "futures-io", + "memchr", + "pin-project-lite", + "xz2", + "zstd 0.13.0", + "zstd-safe 7.0.0", +] + +[[package]] +name = "async-native-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9343dc5acf07e79ff82d0c37899f079db3534d99f189a1837c8e549c99405bec" +dependencies = [ + "futures-util", + "native-tls", + "thiserror 1.0.56", + "url", +] + [[package]] name = "async-stream" version = "0.2.1" @@ -180,6 +384,49 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "async_executors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a982d2f86de6137cc05c9db9a915a19886c97911f9790d04f174cede74be01a5" +dependencies = [ + "blanket", + "futures-core", + "futures-task", + "futures-util", + "pin-project", + "rustc_version", + "tokio", +] + +[[package]] +name = "asynchronous-codec" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -192,6 +439,29 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "aws-lc-rs" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c953fe1ba023e6b7730c0d4b031d06f267f23a46167dcbd40316644b10a17ba" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbfd150b5dbdb988bcc8fb1fe787eb6b7ee6180ca24da683b61ea5405f3d43ff" +dependencies = [ + "bindgen 0.69.5", + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.5" @@ -262,6 +532,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.21.7" @@ -276,9 +552,19 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.6.0" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bincode" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" +dependencies = [ + "serde", + "unty", +] [[package]] name = "bindgen" @@ -325,6 +611,29 @@ dependencies = [ "which", ] +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "syn 2.0.106", + "which", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -333,9 +642,21 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.2" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] [[package]] name = "blake2" @@ -346,6 +667,17 @@ dependencies = [ "digest", ] +[[package]] +name = "blanket" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "block" version = "0.1.6" @@ -361,6 +693,26 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata 0.4.14", + "serde", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -373,11 +725,14 @@ version = "0.1.0" dependencies = [ "aead", "anyhow", + "argon2", + "arti-client", "async-channel", "async-stream 0.2.1", "axum", "base64 0.21.7", "blake2", + "bytes", "caps", "chacha20poly1305", "clap", @@ -385,12 +740,17 @@ dependencies = [ "dotenv", "fehler", "futures", + "hickory-proto", "hmac", "hyper-util", "ip_network", "ip_network_table", + "ipnetwork", + "libc", "libsystemd", "log", + "md-5", + "netstack-smoltcp", "nix 0.27.1", "once_cell", "parking_lot", @@ -402,14 +762,21 @@ dependencies = [ "ring", "rusqlite", "rust-ini", - "schemars", + "rustls", + "schemars 0.8.16", "serde", "serde_json", + "serde_yaml", + "sha2", + "subtle", "tokio", + "tokio-rustls", "tokio-stream", - "toml", + "tokio-util", + "toml 0.8.23", "tonic", "tonic-build", + "tor-rtcompat", "tower", "tracing", "tracing-journald", @@ -417,6 +784,7 @@ dependencies = [ "tracing-oslog", "tracing-subscriber", "tun", + "webpki-roots 0.26.11", "x25519-dalek", ] @@ -429,9 +797,23 @@ dependencies = [ "gettext-rs", "glib-build-tools", "relm4", + "serde", + "serde_json", "tokio", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -501,13 +883,27 @@ dependencies = [ ] [[package]] -name = "cc" -version = "1.0.83" +name = "caret" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ + "find-msvc-tools", "jobserver", "libc", + "shlex", ] [[package]] @@ -565,6 +961,45 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -589,9 +1024,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.4.18" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" dependencies = [ "clap_builder", "clap_derive", @@ -599,23 +1034,23 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.4.18" +version = "4.5.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] name = "clap_derive" -version = "4.4.7" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.106", @@ -623,9 +1058,29 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.6.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] [[package]] name = "colorchoice" @@ -655,6 +1110,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-random" version = "0.1.18" @@ -681,6 +1142,24 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -715,6 +1194,85 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-cycles-per-byte" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5396de42a52e9e5d8f67ef0702dae30451f310a9ba1c3094dcf228f0be0e54bc" +dependencies = [ + "cfg-if", + "criterion", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.19" @@ -727,6 +1285,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -738,6 +1308,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.1" @@ -747,6 +1326,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", + "digest", "fiat-crypto", "platforms", "rustc_version", @@ -766,12 +1346,270 @@ dependencies = [ ] [[package]] -name = "deranged" -version = "0.3.11" +name = "darling" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "defmt" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad" +dependencies = [ + "defmt 1.1.0", +] + +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.16", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "cookie-factory", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", +] + +[[package]] +name = "derive-deftly" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284db66a66f03c3dafbe17360d959eb76b83f77cfe191677e2a7899c0da291f3" +dependencies = [ + "derive-deftly-macros", + "heck 0.5.0", +] + +[[package]] +name = "derive-deftly-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caef6056a5788d05d173cdc3c562ac28ae093828f851f69378b74e4e3d578e41" +dependencies = [ + "heck 0.5.0", + "indexmap 2.11.4", + "itertools 0.14.0", + "proc-macro-crate", + "proc-macro2", + "quote", + "sha3", + "strum", + "syn 2.0.106", + "void", +] + +[[package]] +name = "derive_builder_core_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24c1b715c79be6328caa9a5e1a387a196ea503740f0722ec3dd8f67a9e72314d" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3eae24d595f4d0ecc90a9a5a6d11c2bd8dafe2375ec4a1ec63250e5ade7d228" +dependencies = [ + "derive_builder_macro_fork_arti", +] + +[[package]] +name = "derive_builder_macro_fork_arti" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69887769a2489cd946bf782eb2b1bb2cb7bc88551440c94a765d4f040c08ebf3" +dependencies = [ + "derive_builder_core_fork_arti", + "syn 1.0.109", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.106", + "unicode-xid", ] [[package]] @@ -781,10 +1619,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -801,10 +1681,74 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" [[package]] -name = "dyn-clone" -version = "1.0.16" +name = "downcast-rs" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "545b22097d44f8a9581187cdf93de7a71e4722bf51200cfaba810865b49a495d" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "merlin", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "educe" +version = "0.4.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0042ff8246a363dbe77d2ceedb073339e85a804b9a47636c6e016a9a32c05f" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 1.0.109", +] [[package]] name = "either" @@ -812,6 +1756,25 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -827,6 +1790,64 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "enum-ordinalize" +version = "3.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf1fa3f06bbff1ea5b1a9c7b14aa992a39657db60a2759457328d7e058f49ee" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "enum_dispatch" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" +dependencies = [ + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "enumset" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +dependencies = [ + "enumset_derive", +] + +[[package]] +name = "enumset_derive" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -843,6 +1864,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "etherparse" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8d8a704b617484e9d867a0423cd45f7577f008c4068e2e33378f8d3860a6d73" +dependencies = [ + "arrayvec", +] + [[package]] name = "event-listener" version = "4.0.3" @@ -854,13 +1884,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + [[package]] name = "event-listener-strategy" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "958e4d70b6d5e81971bebec42271ec641e7ff4e170a6fa605f2b8a8b65cb97d3" dependencies = [ - "event-listener", + "event-listener 4.0.3", "pin-project-lite", ] @@ -878,9 +1919,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.0.1" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fehler" @@ -902,6 +1943,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.5" @@ -918,6 +1969,35 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic 0.6.1", + "serde", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -934,6 +2014,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluid-let" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" + [[package]] name = "flume" version = "0.10.14" @@ -944,7 +2030,7 @@ dependencies = [ "futures-sink", "nanorand", "pin-project", - "spin", + "spin 0.9.8", ] [[package]] @@ -953,6 +2039,18 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -970,9 +2068,9 @@ checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] @@ -983,6 +2081,43 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c2141d6d6c8512188a7891b4b01590a45f6dac67afb4f255c4124dbb86d4eaa" +[[package]] +name = "fs-mistrust" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2" +dependencies = [ + "derive_builder_fork_arti", + "dirs", + "libc", + "pwd-grp", + "serde", + "thiserror 2.0.16", + "walkdir", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futures" version = "0.3.30" @@ -1000,9 +2135,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1010,9 +2145,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" @@ -1027,15 +2162,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1044,21 +2179,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1068,7 +2203,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1140,6 +2274,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1164,11 +2299,36 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasi 0.14.7+wasi-0.2.4", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "getset" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +dependencies = [ + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "gettext-rs" version = "0.7.0" @@ -1264,7 +2424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eca5c79337338391f1ab8058d6698125034ce8ef31b72a442437fa6c8580de26" dependencies = [ "anyhow", - "heck", + "heck 0.4.1", "proc-macro-crate", "proc-macro-error", "proc-macro2", @@ -1288,6 +2448,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "glob-match" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985c9503b412198aa4197559e9a318524ebc4519c229bfa05a535828c950b9d" + [[package]] name = "gobject-sys" version = "0.17.10" @@ -1322,6 +2488,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "gsk4" version = "0.6.3" @@ -1448,6 +2625,26 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1459,23 +2656,42 @@ name = "hashbrown" version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "ahash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] [[package]] name = "hashlink" -version = "0.9.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown 0.14.3", + "hashbrown 0.16.1", +] + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", ] [[package]] @@ -1484,12 +2700,52 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "thiserror 2.0.16", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1508,6 +2764,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "hostname-validator" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" + [[package]] name = "http" version = "0.2.11" @@ -1576,6 +2838,22 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + [[package]] name = "hyper" version = "0.14.28" @@ -1593,7 +2871,7 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.10", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1679,20 +2957,149 @@ dependencies = [ "hyper 1.6.0", "libc", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", ] [[package]] -name = "idna" -version = "0.5.0" +name = "iana-time-zone" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -1703,6 +3110,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "serde", ] [[package]] @@ -1712,7 +3120,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +dependencies = [ + "bitflags 2.11.1", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", ] [[package]] @@ -1724,13 +3154,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "io-uring" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.11.1", "cfg-if", "libc", ] @@ -1763,6 +3202,33 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +[[package]] +name = "ipnetwork" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" +dependencies = [ + "serde", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1780,28 +3246,63 @@ checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" [[package]] name = "jobserver" -version = "0.1.27" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c37f63953c4c63420ed5fd3d6d398c719489b9f872b9fa683262f8edd363c7d" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ + "getrandom 0.3.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.80" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +dependencies = [ + "spin 0.5.2", +] [[package]] name = "lazycell" @@ -1809,6 +3310,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libadwaita" version = "0.4.4" @@ -1869,10 +3376,25 @@ dependencies = [ ] [[package]] -name = "libsqlite3-sys" -version = "0.28.0" +name = "libm" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b4103cffefa72eb8428cb6b47d6627161e51c2739fc5e3b734584157bc642a" dependencies = [ "cc", "pkg-config", @@ -1903,6 +3425,12 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c" +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + [[package]] name = "locale_config" version = "0.3.0" @@ -1938,6 +3466,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -1947,6 +3486,12 @@ dependencies = [ "libc", ] +[[package]] +name = "managed" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d" + [[package]] name = "matchers" version = "0.1.0" @@ -1962,12 +3507,31 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.7.1" @@ -1986,6 +3550,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "miette" version = "5.10.0" @@ -2037,6 +3613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" dependencies = [ "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -2074,6 +3651,22 @@ dependencies = [ "tempfile", ] +[[package]] +name = "netstack-smoltcp" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "398691cef792b89eb5d29e6ea6b3999def706b908d355e29815ba8101cf5c4c8" +dependencies = [ + "etherparse", + "futures", + "rand 0.8.5", + "smoltcp", + "spin 0.9.8", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "nix" version = "0.26.4" @@ -2093,7 +3686,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.11.1", "cfg-if", "libc", "memoffset 0.9.0", @@ -2109,6 +3702,47 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonany" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0" + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -2119,6 +3753,90 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "objc" version = "0.2.7" @@ -2139,6 +3857,25 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc_id" version = "0.1.1" @@ -2159,9 +3896,28 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "oneshot-fused-workaround" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" +dependencies = [ + "futures", +] + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opaque-debug" @@ -2175,7 +3931,7 @@ version = "0.10.63" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15c9d69dd87a29568d4d017cfe8ec518706046a05184e5aea92d0af890b803c8" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -2213,6 +3969,21 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -2223,12 +3994,69 @@ dependencies = [ "hashbrown 0.14.3", ] +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + [[package]] name = "overload" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core 0.6.4", + "sha2", +] + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "pango" version = "0.17.10" @@ -2295,6 +4123,23 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pbkdf2" version = "0.11.0" @@ -2303,7 +4148,7 @@ checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ "digest", "hmac", - "password-hash", + "password-hash 0.4.2", "sha2", ] @@ -2314,10 +4159,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" [[package]] -name = "percent-encoding" -version = "2.3.1" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "petgraph" @@ -2329,6 +4183,49 @@ dependencies = [ "indexmap 2.11.4", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.4" @@ -2361,6 +4258,27 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.29" @@ -2373,6 +4291,34 @@ version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626dec3cac7cc0e1577a2ec3fc496277ec2baa084bebad95bb6fdbfae235f84c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "poly1305" version = "0.8.0" @@ -2384,6 +4330,36 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic 0.5.3", + "crossbeam-queue", + "futures", + "parking_lot", + "pin-project", + "static_assertions", + "thiserror 1.0.56", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -2398,14 +4374,34 @@ checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" [[package]] name = "prettyplease" -version = "0.2.16" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41cf62165e97c7f814d2221421dbb9afcbcdb0a88068e5ea206e19951c2cbb5" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", "syn 2.0.106", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "priority-queue" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" +dependencies = [ + "equivalent", + "indexmap 2.11.4", + "serde", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2440,6 +4436,28 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "proc-macro2" version = "1.0.101" @@ -2465,8 +4483,8 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck", - "itertools", + "heck 0.5.0", + "itertools 0.14.0", "log", "multimap", "once_cell", @@ -2486,7 +4504,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.106", @@ -2501,6 +4519,18 @@ dependencies = [ "prost", ] +[[package]] +name = "pwd-grp" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2023f41b5fcb7c30eb5300a5733edfaa9e0e0d502d51b586f65633fd39e40c" +dependencies = [ + "derive-deftly", + "libc", + "paste", + "thiserror 2.0.16", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2514,7 +4544,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.16", "tokio", "tracing", @@ -2551,16 +4581,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.35" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -2571,6 +4601,18 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.8.5" @@ -2630,6 +4672,46 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_jitter" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +dependencies = [ + "libc", + "rand_core 0.9.3", + "winapi", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "rdrand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2640,15 +4722,46 @@ dependencies = [ ] [[package]] -name = "regex" -version = "1.10.3" +name = "redox_users" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.12", + "libredox", + "thiserror 2.0.16", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", - "regex-automata 0.4.4", - "regex-syntax 0.8.2", + "regex-automata 0.4.14", + "regex-syntax 0.8.10", ] [[package]] @@ -2662,13 +4775,13 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.4" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7fa1134405e2ec9353fd416b17f8dacd46c473d7d3fd1cf202706a14eb792a" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", - "regex-syntax 0.8.2", + "regex-syntax 0.8.10", ] [[package]] @@ -2679,9 +4792,9 @@ checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" [[package]] name = "regex-syntax" -version = "0.8.2" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "relm4" @@ -2792,6 +4905,25 @@ dependencies = [ "winreg 0.52.0", ] +[[package]] +name = "retry-error" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c322ea521636c5a3f13685a6266055b2dda7e54e2be35214d7c2a5d0672a5db" +dependencies = [ + "humantime", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.7" @@ -2801,23 +4933,56 @@ dependencies = [ "cc", "getrandom 0.2.12", "libc", - "spin", + "spin 0.9.8", "untrusted", "windows-sys 0.48.0", ] [[package]] -name = "rusqlite" -version = "0.31.0" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "bitflags 2.4.2", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.16", +] + +[[package]] +name = "rusqlite" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1c93dd1c9683b438c392c492109cb702b8090b2bfc8fed6f6e4eb4523f17af3" +dependencies = [ + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", + "sqlite-wasm-rs", + "time", ] [[package]] @@ -2857,13 +5022,22 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "0.38.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" dependencies = [ - "bitflags 2.4.2", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -2876,6 +5050,8 @@ version = "0.23.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -2905,10 +5081,11 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.6" +version = "0.103.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -2926,6 +5103,43 @@ version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" +[[package]] +name = "safelog" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a907e0d82c61b1b06a2030c968eb313dcf432686b77801a26bc4b206f96573" +dependencies = [ + "derive_more", + "educe", + "either", + "fluid-let", + "thiserror 2.0.16", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sanitize-filename" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d" +dependencies = [ + "regex", +] + +[[package]] +name = "saturating-time" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" + [[package]] name = "schannel" version = "0.1.23" @@ -2947,6 +5161,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.16" @@ -2965,6 +5203,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "2.9.2" @@ -2996,28 +5248,38 @@ checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] -name = "serde_core" -version = "1.0.226" +name = "serde-value" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -3036,14 +5298,26 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.111" +name = "serde_ignored" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +checksum = "115dffd5f3853e06e746965a20dcbae6ee747ae30b543d91b0e089668bb07798" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", - "ryu", + "memchr", "serde", + "serde_core", + "zmij", ] [[package]] @@ -3066,6 +5340,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3078,6 +5361,51 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.11.4", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.11.4", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -3111,6 +5439,16 @@ dependencies = [ "digest", ] +[[package]] +name = "sha3" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +dependencies = [ + "digest", + "keccak", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3120,6 +5458,17 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "bstr", + "dirs", + "os_str_bytes", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3135,6 +5484,22 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.9" @@ -3144,6 +5509,29 @@ dependencies = [ "autocfg", ] +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "serde", + "version_check", +] + +[[package]] +name = "slotmap-careful" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" +dependencies = [ + "paste", + "serde", + "slotmap", + "thiserror 2.0.16", + "void", +] + [[package]] name = "smallvec" version = "1.13.1" @@ -3151,13 +5539,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" [[package]] -name = "socket2" -version = "0.4.10" +name = "smoltcp" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb" dependencies = [ - "libc", - "winapi", + "bitflags 1.3.2", + "byteorder", + "cfg-if", + "defmt 0.3.100", + "heapless", + "log", + "managed", ] [[package]] @@ -3170,6 +5563,22 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.8" @@ -3179,6 +5588,70 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" +dependencies = [ + "num-bigint-dig", + "p256", + "p384", + "p521", + "rand_core 0.6.4", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "ssri" version = "9.2.0" @@ -3195,6 +5668,18 @@ dependencies = [ "xxhash-rust", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.10.0" @@ -3202,10 +5687,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] -name = "subtle" -version = "2.5.0" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -3235,6 +5747,31 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "sysinfo" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252800745060e7b9ffb7b2badbd8b31cfa4aa2e61af879d0a3bf2a317c20217d" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows 0.61.3", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -3263,12 +5800,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a2d580ff6a20c55dfb86be5f9c238f67835d0e81cbdea8bf5680e0897320331" dependencies = [ "cfg-expr", - "heck", + "heck 0.4.1", "pkg-config", - "toml", + "toml 0.8.23", "version-compare", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.13" @@ -3346,21 +5889,35 @@ dependencies = [ [[package]] name = "time" -version = "0.3.31" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f657ba42c3f86e7680e53c8cd3af8abbe56b5491790b46e22e19c0d57463583e" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", + "js-sys", + "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", + "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] [[package]] name = "tiny-keccak" @@ -3371,6 +5928,27 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -3450,16 +6028,16 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.10" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", - "tracing", ] [[package]] @@ -3469,11 +6047,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit 0.22.27", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.11.4", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3483,6 +6076,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.19.15" @@ -3490,7 +6092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ "indexmap 2.11.4", - "toml_datetime", + "toml_datetime 0.6.11", "winnow 0.5.34", ] @@ -3502,18 +6104,33 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.11.4", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", "winnow 0.7.13", ] +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", +] + [[package]] name = "toml_write" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.12.3" @@ -3558,6 +6175,959 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "tor-async-utils" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "895c61a46909134501c6815eceeb66c9c80fc494ee89429821b0f05ccf34b4f5" +dependencies = [ + "derive-deftly", + "educe", + "futures", + "oneshot-fused-workaround", + "pin-project", + "postage", + "thiserror 2.0.16", + "void", +] + +[[package]] +name = "tor-basic-utils" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac6e4d7e131b7d69bc85558383cd4ac61e46b4dd0d4ed51632f28fac98cac0c" +dependencies = [ + "derive_more", + "hex", + "itertools 0.14.0", + "libc", + "paste", + "rand 0.9.2", + "rand_chacha 0.9.0", + "serde", + "slab", + "smallvec", + "thiserror 2.0.16", + "weak-table", +] + +[[package]] +name = "tor-bytes" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64454947258e49f238a5f06a06250a0c54598a1c7409898b5c79505e6a99e7af" +dependencies = [ + "bytes", + "derive-deftly", + "digest", + "educe", + "getrandom 0.4.2", + "safelog", + "thiserror 2.0.16", + "tor-error", + "tor-llcrypto", + "zeroize", +] + +[[package]] +name = "tor-cell" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ab0c79bc92a957e85959cf397a2d8f9c8294c35fa4f65247a9393b20ac95551" +dependencies = [ + "amplify", + "bitflags 2.11.1", + "bytes", + "caret", + "derive-deftly", + "derive_more", + "educe", + "itertools 0.14.0", + "paste", + "rand 0.9.2", + "smallvec", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-cert", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-protover", + "tor-units", + "void", +] + +[[package]] +name = "tor-cert" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debc911738298ee801fce4577c36a50c55295b0bb9c5519461b83cc486a1f86e" +dependencies = [ + "caret", + "derive_builder_fork_arti", + "derive_more", + "digest", + "thiserror 2.0.16", + "tor-bytes", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-chanmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af5b7c2f1e16d1304b5185fcbc91ca5c8df991c21be00702f925f055573eea1" +dependencies = [ + "async-trait", + "caret", + "cfg-if", + "derive-deftly", + "derive_more", + "educe", + "futures", + "oneshot-fused-workaround", + "percent-encoding", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "serde_with", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-config", + "tor-error", + "tor-keymgr", + "tor-linkspec", + "tor-llcrypto", + "tor-memquota", + "tor-netdir", + "tor-proto", + "tor-rtcompat", + "tor-socksproto", + "tor-units", + "tracing", + "url", + "void", +] + +[[package]] +name = "tor-checkable" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b13a5b50bb55037f2e81b25dde42f420d57c75154216b8ef989006cea3ebee" +dependencies = [ + "humantime", + "signature", + "thiserror 2.0.16", + "tor-llcrypto", +] + +[[package]] +name = "tor-circmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b878f3f7c6be0c7f130d90b347ada2e7c46519dfbdde8273eae2e5d1792caa87" +dependencies = [ + "amplify", + "async-trait", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "educe", + "futures", + "humantime-serde", + "itertools 0.14.0", + "once_cell", + "oneshot-fused-workaround", + "pin-project", + "rand 0.9.2", + "retry-error", + "safelog", + "serde", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-cell", + "tor-chanmgr", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-linkspec", + "tor-memquota", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", + "void", + "weak-table", +] + +[[package]] +name = "tor-config" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc74a00ab15bb986e3747c6969e40a58a63065d6f99077e7ee2f4657bb8b03" +dependencies = [ + "amplify", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "educe", + "either", + "figment", + "fs-mistrust", + "futures", + "humantime-serde", + "itertools 0.14.0", + "notify", + "paste", + "postage", + "regex", + "serde", + "serde-value", + "serde_ignored", + "strum", + "thiserror 2.0.16", + "toml 0.9.12+spec-1.1.0", + "tor-basic-utils", + "tor-error", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-config-path" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3005ab7b9a26a7271e5adf3dfb4ae18c09a943e32aeccc4f6d1c53a60de74b8d" +dependencies = [ + "directories", + "serde", + "shellexpand", + "thiserror 2.0.16", + "tor-error", + "tor-general-addr", +] + +[[package]] +name = "tor-consdiff" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bfa2b7b71c72830f61c48da4bb3e13191e0c0e1404b9c5168c795e4f5feb4a8" +dependencies = [ + "digest", + "hex", + "thiserror 2.0.16", + "tor-llcrypto", +] + +[[package]] +name = "tor-dirclient" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccd6fac844ac77c33ccdfcb56bf23ff40ebbb821ea708be79a481ec30e8c39c" +dependencies = [ + "async-compression", + "base64ct", + "derive_more", + "futures", + "hex", + "http 1.3.1", + "httparse", + "httpdate", + "itertools 0.14.0", + "memchr", + "thiserror 2.0.16", + "tor-circmgr", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-proto", + "tor-rtcompat", + "tracing", +] + +[[package]] +name = "tor-dircommon" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cf39a3c30321d145a4d60753ae7ef5bb58a66a00ac9e2bfc30bd823faf2a4" +dependencies = [ + "base64ct", + "derive-deftly", + "getset", + "humantime", + "humantime-serde", + "serde", + "tor-basic-utils", + "tor-checkable", + "tor-config", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tracing", +] + +[[package]] +name = "tor-dirmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52919aa9dbb82a354c5b904bef82e91beb702b9f8ad14e6eac4237d6128bf67" +dependencies = [ + "async-trait", + "base64ct", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "event-listener 5.4.1", + "fs-mistrust", + "fslock", + "futures", + "hex", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "memmap2", + "oneshot-fused-workaround", + "paste", + "postage", + "rand 0.9.2", + "rusqlite", + "safelog", + "scopeguard", + "serde", + "serde_json", + "signature", + "static_assertions", + "strum", + "thiserror 2.0.16", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-checkable", + "tor-circmgr", + "tor-config", + "tor-consdiff", + "tor-dirclient", + "tor-dircommon", + "tor-error", + "tor-guardmgr", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-protover", + "tor-rtcompat", + "tracing", +] + +[[package]] +name = "tor-error" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595b005e6f571ac3890a34a00f361200aab781fd0218f2c528c86fc7af088df5" +dependencies = [ + "derive_more", + "futures", + "paste", + "retry-error", + "static_assertions", + "strum", + "thiserror 2.0.16", + "tracing", + "void", +] + +[[package]] +name = "tor-general-addr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727b8c8bc01c1587486055edab5c2cd0d5c960f5bb3fac796fc9911872b8b397" +dependencies = [ + "derive_more", + "thiserror 2.0.16", + "void", +] + +[[package]] +name = "tor-guardmgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d337f465a477c0fb3b2faafa4654d70ff9df3590e57d22707591dddb4e4450c1" +dependencies = [ + "amplify", + "base64ct", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "dyn-clone", + "educe", + "futures", + "humantime", + "humantime-serde", + "itertools 0.14.0", + "num_enum", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.2", + "safelog", + "serde", + "strum", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-dircommon", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdir", + "tor-netdoc", + "tor-persist", + "tor-proto", + "tor-relay-selection", + "tor-rtcompat", + "tor-units", + "tracing", +] + +[[package]] +name = "tor-hscrypto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3693cd43f05cd01ac0aaa060dae5c5e53c4364f89e0d769e33cd629a2fd3118" +dependencies = [ + "data-encoding", + "derive-deftly", + "derive_more", + "digest", + "hex", + "humantime", + "itertools 0.14.0", + "paste", + "rand 0.9.2", + "safelog", + "serde", + "signature", + "subtle", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-error", + "tor-key-forge", + "tor-llcrypto", + "tor-units", + "void", +] + +[[package]] +name = "tor-key-forge" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ade9065ae49cfe2ab020ca9ca9f2b3c5c9b5fc0d8980fa681d8b3a0668e042f" +dependencies = [ + "derive-deftly", + "derive_more", + "downcast-rs", + "paste", + "rand 0.9.2", + "rsa", + "signature", + "ssh-key", + "thiserror 2.0.16", + "tor-bytes", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", +] + +[[package]] +name = "tor-keymgr" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "243c3163d376c4723cd67271fcd6e5d6b498a6865c6b98299640e1be01c38826" +dependencies = [ + "amplify", + "arrayvec", + "cfg-if", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "downcast-rs", + "dyn-clone", + "fs-mistrust", + "glob-match", + "humantime", + "inventory", + "itertools 0.14.0", + "rand 0.9.2", + "safelog", + "serde", + "signature", + "ssh-key", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-config-path", + "tor-error", + "tor-hscrypto", + "tor-key-forge", + "tor-llcrypto", + "tor-persist", + "tracing", + "visibility", + "walkdir", + "zeroize", +] + +[[package]] +name = "tor-linkspec" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f1ea8786900d6fbe4c9f775d341b1ba01bbd1f750d89bd63be78b6b01e1836" +dependencies = [ + "base64ct", + "by_address", + "caret", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "hex", + "itertools 0.14.0", + "safelog", + "serde", + "serde_with", + "strum", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", + "tor-config", + "tor-llcrypto", + "tor-memquota", + "tor-protover", +] + +[[package]] +name = "tor-llcrypto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c6989a1c6d06ffd6835e2917edaae4aeef544f8e5fdd68b54cc365f2af523de" +dependencies = [ + "aes", + "base64ct", + "ctr", + "curve25519-dalek", + "der-parser", + "derive-deftly", + "derive_more", + "digest", + "ed25519-dalek", + "educe", + "getrandom 0.4.2", + "hex", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_core 0.6.4", + "rand_core 0.9.3", + "rand_jitter", + "rdrand", + "rsa", + "safelog", + "serde", + "sha1", + "sha2", + "sha3", + "signature", + "subtle", + "thiserror 2.0.16", + "tor-error", + "tor-memquota-cost", + "visibility", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "tor-log-ratelim" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f1cd642180923d12e3fab5996b4aa2189718da7f465df6eb196ce2b9c70e293" +dependencies = [ + "futures", + "humantime", + "thiserror 2.0.16", + "tor-error", + "tor-rtcompat", + "tracing", + "weak-table", +] + +[[package]] +name = "tor-memquota" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "599daea60fd3272eb72a795d1c593b45bbe15343cbc702340a81db124c06eed5" +dependencies = [ + "cfg-if", + "derive-deftly", + "derive_more", + "dyn-clone", + "educe", + "futures", + "itertools 0.14.0", + "paste", + "pin-project", + "serde", + "slotmap-careful", + "static_assertions", + "sysinfo", + "thiserror 2.0.16", + "tor-async-utils", + "tor-basic-utils", + "tor-config", + "tor-error", + "tor-log-ratelim", + "tor-memquota-cost", + "tor-rtcompat", + "tracing", + "void", +] + +[[package]] +name = "tor-memquota-cost" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd92b07c0fc24e6d8166a5ff45e5b8654e68d89743c46d01889a16ab74c0b578" +dependencies = [ + "derive-deftly", + "itertools 0.14.0", + "paste", + "void", +] + +[[package]] +name = "tor-netdir" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41be8f47f521fc95206d2ba5facac8fb1a5b5b82169bd41ebeecdf46d1e77246" +dependencies = [ + "async-trait", + "bitflags 2.11.1", + "derive_more", + "futures", + "humantime", + "itertools 0.14.0", + "num_enum", + "rand 0.9.2", + "serde", + "strum", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-netdoc", + "tor-protover", + "tor-units", + "tracing", + "typed-index-collections", +] + +[[package]] +name = "tor-netdoc" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea8bce73d2c78bd78a2a927336ca639cf6bd5d8ad092ebcd0b3fdeaa47dcc77e" +dependencies = [ + "amplify", + "base64ct", + "cipher", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enumset", + "hex", + "humantime", + "itertools 0.14.0", + "memchr", + "paste", + "phf", + "saturating-time", + "serde", + "serde_with", + "signature", + "smallvec", + "strum", + "subtle", + "thiserror 2.0.16", + "time", + "tinystr", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-llcrypto", + "tor-protover", + "void", + "zeroize", +] + +[[package]] +name = "tor-persist" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507ab4b6a3d59ed0df5804eeed66dcacde75e3be13d3694216cdfdb666bce625" +dependencies = [ + "derive-deftly", + "derive_more", + "filetime", + "fs-mistrust", + "fslock", + "futures", + "itertools 0.14.0", + "oneshot-fused-workaround", + "paste", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.16", + "time", + "tor-async-utils", + "tor-basic-utils", + "tor-error", + "tracing", + "void", +] + +[[package]] +name = "tor-proto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfc552d535d36539d5782bb02028590bc472d219e49da51a96810725e80ff56" +dependencies = [ + "amplify", + "async-trait", + "asynchronous-codec", + "bitvec", + "bytes", + "caret", + "cfg-if", + "cipher", + "coarsetime", + "criterion-cycles-per-byte", + "derive-deftly", + "derive_builder_fork_arti", + "derive_more", + "digest", + "educe", + "enum_dispatch", + "futures", + "futures-util", + "hkdf", + "hmac", + "itertools 0.14.0", + "nonany", + "oneshot-fused-workaround", + "pin-project", + "postage", + "rand 0.9.2", + "rand_core 0.9.3", + "safelog", + "slotmap-careful", + "smallvec", + "static_assertions", + "subtle", + "sync_wrapper", + "thiserror 2.0.16", + "tokio", + "tokio-util", + "tor-async-utils", + "tor-basic-utils", + "tor-bytes", + "tor-cell", + "tor-cert", + "tor-checkable", + "tor-config", + "tor-error", + "tor-linkspec", + "tor-llcrypto", + "tor-log-ratelim", + "tor-memquota", + "tor-protover", + "tor-relay-crypto", + "tor-rtcompat", + "tor-rtmock", + "tor-units", + "tracing", + "typenum", + "visibility", + "void", + "zeroize", +] + +[[package]] +name = "tor-protover" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aed88527d070c4b7ea4e55a36d2d56d0500e30ca66298b5264f047f7f2f89cfa" +dependencies = [ + "caret", + "paste", + "serde_with", + "thiserror 2.0.16", + "tor-basic-utils", + "tor-bytes", +] + +[[package]] +name = "tor-relay-crypto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e57e9f71b22ae1df63dbccc8e428cb07feec0abd654735109fa563c10bbb90" +dependencies = [ + "derive-deftly", + "derive_more", + "humantime", + "tor-cert", + "tor-checkable", + "tor-error", + "tor-key-forge", + "tor-keymgr", + "tor-llcrypto", + "tor-persist", +] + +[[package]] +name = "tor-relay-selection" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a372072ac9dea7d17e49693cc3f3ae77b3abf8125630516c9f2d622239b1920a" +dependencies = [ + "rand 0.9.2", + "serde", + "tor-basic-utils", + "tor-linkspec", + "tor-netdir", + "tor-netdoc", +] + +[[package]] +name = "tor-rtcompat" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14428b930e59003e801c0c32697c0aeb9b0495ad33ecbe8c6753bdb596233270" +dependencies = [ + "async-native-tls", + "async-trait", + "async_executors", + "asynchronous-codec", + "cfg-if", + "coarsetime", + "derive_more", + "dyn-clone", + "educe", + "futures", + "hex", + "libc", + "native-tls", + "paste", + "pin-project", + "socket2 0.6.3", + "thiserror 2.0.16", + "tokio", + "tokio-util", + "tor-error", + "tor-general-addr", + "tracing", + "void", + "zeroize", +] + +[[package]] +name = "tor-rtmock" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2da91a432cdaee8a93e0bb21b02f3e9c7667832ccbb4b54e00d9c1214638e70" +dependencies = [ + "amplify", + "assert_matches", + "async-trait", + "derive-deftly", + "derive_more", + "educe", + "futures", + "humantime", + "itertools 0.14.0", + "oneshot-fused-workaround", + "pin-project", + "priority-queue", + "slotmap-careful", + "strum", + "thiserror 2.0.16", + "tor-error", + "tor-general-addr", + "tor-rtcompat", + "tracing", + "tracing-test", + "void", +] + +[[package]] +name = "tor-socksproto" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adbc9115a2f506d9bb86ae4446f0ca70eb523dc2f5e900a33582e7c39decc23a" +dependencies = [ + "amplify", + "caret", + "derive-deftly", + "educe", + "safelog", + "subtle", + "thiserror 2.0.16", + "tor-bytes", + "tor-error", +] + +[[package]] +name = "tor-units" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da90e93b4b4aa4ec356ecbe9e19aced36fdd655e94ca459d1915120d873363f0" +dependencies = [ + "derive-deftly", + "derive_more", + "serde", + "thiserror 2.0.16", + "tor-memquota", +] + [[package]] name = "tower" version = "0.4.13" @@ -3592,9 +7162,9 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -3604,9 +7174,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -3615,9 +7185,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -3689,6 +7259,27 @@ dependencies = [ "tracing-log 0.2.0", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.106", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -3710,7 +7301,7 @@ dependencies = [ "log", "nix 0.26.4", "reqwest 0.11.23", - "schemars", + "schemars 0.8.16", "serde", "socket2 0.5.10", "ssri", @@ -3718,10 +7309,20 @@ dependencies = [ "tokio", "tracing", "widestring", - "windows", + "windows 0.48.0", "zip", ] +[[package]] +name = "typed-index-collections" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "898160f1dfd383b4e92e17f0512a7d62f3c51c44937b23b6ffc3a1614a8eaccd" +dependencies = [ + "bincode", + "serde", +] + [[package]] name = "typenum" version = "1.17.0" @@ -3729,10 +7330,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] -name = "unicode-bidi" -version = "0.3.15" +name = "uncased" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] [[package]] name = "unicode-ident" @@ -3741,13 +7345,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" [[package]] -name = "unicode-normalization" -version = "0.1.22" +name = "unicode-segmentation" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" -dependencies = [ - "tinyvec", -] +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -3755,6 +7356,12 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "universal-hash" version = "0.5.1" @@ -3765,6 +7372,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -3772,16 +7385,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] -name = "url" -version = "2.5.0" +name = "unty" +version = "0.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.1" @@ -3821,6 +7447,33 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3851,14 +7504,32 @@ version = "1.0.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.46.0", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasix" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +dependencies = [ + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] name = "wasm-bindgen" -version = "0.2.103" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -3867,20 +7538,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.106", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" version = "0.4.40" @@ -3895,9 +7552,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.103" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3905,26 +7562,66 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.103" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn 2.0.106", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.103" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.11.4", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.11.4", + "semver", +] + +[[package]] +name = "weak-table" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" + [[package]] name = "web-sys" version = "0.3.67" @@ -3997,6 +7694,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -4012,6 +7718,145 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -4030,6 +7875,24 @@ dependencies = [ "windows-targets 0.52.0", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -4060,6 +7923,32 @@ dependencies = [ "windows_x86_64_msvc 0.52.0", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -4072,6 +7961,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -4084,6 +7979,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -4096,6 +7997,18 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -4108,6 +8021,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -4120,6 +8039,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -4132,6 +8057,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -4144,6 +8075,12 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.34" @@ -4162,6 +8099,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "winreg" version = "0.50.0" @@ -4188,6 +8131,109 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.11.4", + "prettyplease", + "syn 2.0.106", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.106", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.11.4", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.11.4", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x25519-dalek" version = "2.0.0" @@ -4206,6 +8252,38 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53be06678ed9e83edb1745eb72efc0bbcd7b5c3c35711a860906aed827a13d61" +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -4226,6 +8304,27 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", + "synstructure", +] + [[package]] name = "zeroize" version = "1.7.0" @@ -4246,6 +8345,40 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zip" version = "0.6.6" @@ -4263,16 +8396,31 @@ dependencies = [ "pbkdf2", "sha1", "time", - "zstd", + "zstd 0.11.2+zstd.1.5.2", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe", + "zstd-safe 5.0.2+zstd.1.5.2", +] + +[[package]] +name = "zstd" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bffb3309596d527cfcba7dfc6ed6052f1d39dfbd7c867aa2e865e4a449c10110" +dependencies = [ + "zstd-safe 7.0.0", ] [[package]] @@ -4285,6 +8433,15 @@ dependencies = [ "zstd-sys", ] +[[package]] +name = "zstd-safe" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43747c7422e2924c11144d5229878b98180ef8b06cca4ab5af37afc8a8d8ea3e" +dependencies = [ + "zstd-sys", +] + [[package]] name = "zstd-sys" version = "2.0.9+zstd.1.5.5" diff --git a/burrow-gtk/src/components/home_screen.rs b/burrow-gtk/src/components/home_screen.rs index 0bfdda2..655cad3 100644 --- a/burrow-gtk/src/components/home_screen.rs +++ b/burrow-gtk/src/components/home_screen.rs @@ -1,6 +1,6 @@ use super::*; use crate::account_store::{self, AccountKind, AccountRecord}; -use std::time::Duration; +use std::{cell::RefCell, rc::Rc, time::Duration}; pub struct HomeScreen { daemon_banner: adw::Banner, @@ -23,6 +23,7 @@ pub enum HomeScreenMsg { OpenWireGuard, OpenTor, OpenTailnet, + OpenProxySubscription, AddWireGuard { title: String, account: String, @@ -52,6 +53,14 @@ pub enum HomeScreenMsg { hostname: Option, tailnet: Option, }, + PreviewProxySubscription { + url: String, + }, + AddProxySubscription { + url: String, + name: String, + selected_ordinal: Option, + }, } #[relm4::component(pub, async)] @@ -234,7 +243,7 @@ impl AsyncComponent for HomeScreen { configure_add_popover(&widgets.add_button, &sender); let refresh_sender = sender.input_sender().clone(); - relm4::spawn(async move { + gtk::glib::MainContext::default().spawn_local(async move { loop { tokio::time::sleep(Duration::from_secs(5)).await; refresh_sender.emit(HomeScreenMsg::Refresh); @@ -272,6 +281,7 @@ impl AsyncComponent for HomeScreen { HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender), HomeScreenMsg::OpenTor => open_tor_window(root, &sender), HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender), + HomeScreenMsg::OpenProxySubscription => open_proxy_subscription_window(root, &sender), HomeScreenMsg::AddWireGuard { title, account, @@ -304,6 +314,13 @@ impl AsyncComponent for HomeScreen { self.add_tailnet(authority, account, identity, hostname, tailnet) .await; } + HomeScreenMsg::PreviewProxySubscription { url } => { + self.preview_proxy_subscription(url).await; + } + HomeScreenMsg::AddProxySubscription { url, name, selected_ordinal } => { + self.add_proxy_subscription(url, name, selected_ordinal) + .await; + } } } } @@ -667,6 +684,85 @@ impl HomeScreen { } } + async fn preview_proxy_subscription(&mut self, url: String) { + let Ok(url) = daemon_api::require_value(&url, "Subscription URL") else { + self.network_status + .set_label("Enter a subscription URL before previewing."); + return; + }; + + self.network_status + .set_label("Previewing proxy subscription..."); + match daemon_api::preview_proxy_subscription(url).await { + Ok(preview) => { + let first_nodes = preview + .nodes + .iter() + .take(3) + .map(|node| { + let warning = if node.warnings.is_empty() { + String::new() + } else { + format!(" - {}", node.warnings.join(", ")) + }; + format!( + "{}: {} {}:{}{}", + node.protocol, node.name, node.server, node.port, warning + ) + }) + .collect::>() + .join("\n"); + let warnings = if preview.warnings.is_empty() { + String::new() + } else { + format!("\n{}", preview.warnings.join("\n")) + }; + self.network_status.set_label(&format!( + "Found {} compatible nodes and {} rejected entries in {}. Suggested name: {}.{}{}", + preview.compatible_count, + preview.rejected_count, + preview.detected_format, + preview.suggested_name, + warnings, + if first_nodes.is_empty() { + String::new() + } else { + format!("\n{first_nodes}") + } + )); + } + Err(error) => self + .network_status + .set_label(&format!("Proxy subscription preview failed: {error}")), + } + } + + async fn add_proxy_subscription( + &mut self, + url: String, + name: String, + selected_ordinal: Option, + ) { + let Ok(url) = daemon_api::require_value(&url, "Subscription URL") else { + self.network_status + .set_label("Enter a subscription URL before importing."); + return; + }; + + self.network_status + .set_label("Importing proxy subscription..."); + match daemon_api::add_proxy_subscription(url, name, selected_ordinal).await { + Ok(id) => { + self.network_status + .set_label(&format!("Imported proxy subscription network #{id}.")); + self.refresh().await; + } + Err(error) => self + .network_status + .set_label(&format!("Unable to import proxy subscription: {error}")), + } + } + fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) { self.tailnet_session_id = Some(status.session_id.clone()); self.tailnet_running = status.running; @@ -735,6 +831,10 @@ fn configure_add_popover(button: >k::MenuButton, sender: &AsyncComponentSender for (label, msg) in [ ("Add WireGuard Network", HomeScreenMsg::OpenWireGuard), + ( + "Import Proxy Subscription", + HomeScreenMsg::OpenProxySubscription, + ), ("Save Tor Account", HomeScreenMsg::OpenTor), ("Add Tailnet Account", HomeScreenMsg::OpenTailnet), ] { @@ -755,6 +855,7 @@ fn msg_from_template(msg: &HomeScreenMsg) -> HomeScreenMsg { HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard, HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor, HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet, + HomeScreenMsg::OpenProxySubscription => HomeScreenMsg::OpenProxySubscription, _ => unreachable!(), } } @@ -1091,6 +1192,169 @@ fn open_tailnet_window(root: >k::ScrolledWindow, sender: &AsyncComponentSender window.present(); } +fn open_proxy_subscription_window( + root: >k::ScrolledWindow, + sender: &AsyncComponentSender, +) { + let window = sheet_window(root, "Proxy Subscription", 560, 520); + let content = sheet_content( + &window, + "Import Proxy Subscription", + "Import Trojan and Shadowsocks nodes through the Burrow daemon.", + ); + + let url = gtk::Entry::new(); + url.set_placeholder_text(Some("Subscription URL")); + let name = gtk::Entry::new(); + name.set_placeholder_text(Some("Name")); + let node_list = gtk::ListBox::new(); + node_list.set_selection_mode(gtk::SelectionMode::Single); + node_list.set_sensitive(false); + node_list.add_css_class("boxed-list"); + let node_scroll = gtk::ScrolledWindow::builder() + .min_content_height(220) + .vexpand(true) + .child(&node_list) + .build(); + let preview_status = gtk::Label::new(Some("Preview the subscription to choose a node.")); + preview_status.add_css_class("dim-label"); + preview_status.set_xalign(0.0); + preview_status.set_wrap(true); + let selected_ordinal = Rc::new(RefCell::new(None::)); + let node_ordinals = Rc::new(RefCell::new(Vec::::new())); + + content.append(§ion_label("Subscription")); + content.append(&url); + content.append(&name); + content.append(§ion_label("Node")); + content.append(&node_scroll); + content.append(&preview_status); + + let actions = gtk::Box::new(gtk::Orientation::Horizontal, 8); + let preview = gtk::Button::with_label("Preview"); + let import = gtk::Button::with_label("Import"); + import.add_css_class("suggested-action"); + actions.append(&preview); + actions.append(&import); + content.append(&actions); + + let url_for_preview = url.clone(); + let name_for_preview = name.clone(); + let list_for_preview = node_list.clone(); + let status_for_preview = preview_status.clone(); + let selected_for_preview = selected_ordinal.clone(); + let ordinals_for_preview = node_ordinals.clone(); + preview.connect_clicked(move |_| { + let url = url_for_preview.text().to_string(); + let name = name_for_preview.clone(); + let list = list_for_preview.clone(); + let status = status_for_preview.clone(); + let selected = selected_for_preview.clone(); + let ordinals = ordinals_for_preview.clone(); + status.set_label("Previewing proxy subscription..."); + while let Some(child) = list.first_child() { + list.remove(&child); + } + list.set_sensitive(false); + *selected.borrow_mut() = None; + ordinals.borrow_mut().clear(); + gtk::glib::MainContext::default().spawn_local(async move { + match daemon_api::preview_proxy_subscription(url).await { + Ok(preview) => { + if name.text().trim().is_empty() { + name.set_text(&preview.suggested_name); + } + for node in preview.nodes.iter().filter(|node| node.runtime_supported) { + ordinals.borrow_mut().push(node.ordinal); + let row = gtk::ListBoxRow::new(); + row.set_selectable(true); + row.set_activatable(true); + + let row_content = gtk::Box::new(gtk::Orientation::Vertical, 2); + row_content.set_margin_top(8); + row_content.set_margin_bottom(8); + row_content.set_margin_start(10); + row_content.set_margin_end(10); + + let title = gtk::Label::new(Some(&format!( + "{} {}", + node.protocol.to_uppercase(), + node.name + ))); + title.set_xalign(0.0); + title.set_ellipsize(gtk::pango::EllipsizeMode::End); + let endpoint = gtk::Label::new(Some(&format!("{}:{}", node.server, node.port))); + endpoint.add_css_class("dim-label"); + endpoint.set_xalign(0.0); + endpoint.set_ellipsize(gtk::pango::EllipsizeMode::Middle); + + row_content.append(&title); + row_content.append(&endpoint); + row.set_child(Some(&row_content)); + list.append(&row); + } + if let Some(first) = preview.nodes.iter().find(|node| node.runtime_supported) { + if let Some(row) = list.row_at_index(0) { + list.select_row(Some(&row)); + } + list.set_sensitive(true); + *selected.borrow_mut() = Some(first.ordinal); + } + let unsupported = preview + .nodes + .iter() + .filter(|node| !node.runtime_supported) + .count(); + let supported = preview.nodes.len().saturating_sub(unsupported); + let warnings = if preview.warnings.is_empty() { + String::new() + } else { + format!(" {}", preview.warnings.join(" ")) + }; + status.set_label(&format!( + "Found {} runtime-supported nodes, {} unsupported nodes, and {} rejected entries in {}.{}", + supported, + unsupported, + preview.rejected_count, + preview.detected_format, + warnings + )); + } + Err(error) => { + status.set_label(&format!("Proxy subscription preview failed: {error}")); + } + } + }); + }); + + let selected_for_list = selected_ordinal.clone(); + let ordinals_for_list = node_ordinals.clone(); + node_list.connect_row_selected(move |_, row| { + *selected_for_list.borrow_mut() = row.and_then(|row| { + let index = row.index(); + if index < 0 { + return None; + } + ordinals_for_list.borrow().get(index as usize).copied() + }); + }); + + let input = sender.input_sender().clone(); + let window_for_click = window.clone(); + let selected_for_import = selected_ordinal.clone(); + import.connect_clicked(move |_| { + input.emit(HomeScreenMsg::AddProxySubscription { + url: url.text().to_string(), + name: name.text().to_string(), + selected_ordinal: *selected_for_import.borrow(), + }); + window_for_click.close(); + }); + + window.set_child(Some(&content)); + window.present(); +} + fn sheet_window(root: >k::ScrolledWindow, title: &str, width: i32, height: i32) -> gtk::Window { let window = gtk::Window::builder() .title(title) diff --git a/burrow-gtk/src/daemon_api.rs b/burrow-gtk/src/daemon_api.rs index 4ff8bf5..1a163bf 100644 --- a/burrow-gtk/src/daemon_api.rs +++ b/burrow-gtk/src/daemon_api.rs @@ -4,7 +4,9 @@ use burrow::{ grpc_defs::{ Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest, TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest, + ProxySubscriptionApplyRequest, ProxySubscriptionImportRequest, }, + proxy_subscription::ProxySubscriptionPayload, BurrowClient, }; use std::{path::PathBuf, sync::OnceLock}; @@ -54,6 +56,27 @@ pub struct TailnetLoginStatus { pub health: Vec, } +#[derive(Debug, Clone)] +pub struct ProxySubscriptionPreview { + pub suggested_name: String, + pub detected_format: String, + pub compatible_count: i32, + pub rejected_count: i32, + pub nodes: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone)] +pub struct ProxyNodePreview { + pub ordinal: i32, + pub name: String, + pub protocol: String, + pub server: String, + pub port: i32, + pub warnings: Vec, + pub runtime_supported: bool, +} + pub fn default_tailnet_authority() -> &'static str { MANAGED_TAILSCALE_AUTHORITY } @@ -216,6 +239,63 @@ pub async fn add_tailnet( add_network(NetworkType::Tailnet, payload).await } +pub async fn preview_proxy_subscription(url: String) -> Result { + let mut client = BurrowClient::from_uds().await?; + let response = timeout( + Duration::from_secs(20), + client + .proxy_subscription_client + .preview_import(ProxySubscriptionImportRequest { url }), + ) + .await + .context("timed out previewing proxy subscription")?? + .into_inner(); + + Ok(ProxySubscriptionPreview { + suggested_name: response.suggested_name, + detected_format: response.detected_format, + compatible_count: response.compatible_count, + rejected_count: response.rejected_count, + nodes: response + .node + .into_iter() + .map(|node| ProxyNodePreview { + ordinal: node.ordinal, + name: node.name, + protocol: node.protocol, + server: node.server, + port: node.port, + warnings: node.warnings, + runtime_supported: node.runtime_supported, + }) + .collect(), + warnings: response.warnings, + }) +} + +pub async fn add_proxy_subscription( + url: String, + name: String, + selected_ordinal: Option, +) -> Result { + let id = next_network_id().await?; + let mut client = BurrowClient::from_uds().await?; + timeout( + Duration::from_secs(25), + client + .proxy_subscription_client + .apply_import(ProxySubscriptionApplyRequest { + id, + url, + name, + selected_ordinal: selected_ordinal.unwrap_or(-1), + }), + ) + .await + .context("timed out importing proxy subscription")??; + Ok(id) +} + pub async fn discover_tailnet(email: String) -> Result { let mut client = BurrowClient::from_uds().await?; let response = timeout( @@ -328,6 +408,7 @@ fn summarize_network(network: &Network) -> NetworkSummary { match network.r#type() { NetworkType::WireGuard => summarize_wireguard(network), NetworkType::Tailnet => summarize_tailnet(network), + NetworkType::ProxySubscription => summarize_proxy_subscription(network), } } @@ -372,6 +453,21 @@ fn summarize_tailnet(network: &Network) -> NetworkSummary { } } +fn summarize_proxy_subscription(network: &Network) -> NetworkSummary { + match serde_json::from_slice::(&network.payload) { + Ok(payload) => NetworkSummary { + id: network.id, + title: payload.name.clone(), + detail: burrow::proxy_subscription::summarize_payload(&payload), + }, + Err(error) => NetworkSummary { + id: network.id, + title: "Proxy Subscription".to_owned(), + detail: format!("Unable to read proxy subscription payload: {error}"), + }, + } +} + fn decode_tailnet_status( response: burrow::grpc_defs::TailnetLoginStatusResponse, ) -> TailnetLoginStatus { diff --git a/burrow/Cargo.toml b/burrow/Cargo.toml index 22f3d25..a7d5cea 100644 --- a/burrow/Cargo.toml +++ b/burrow/Cargo.toml @@ -31,12 +31,24 @@ tracing-subscriber = { version = "0.3", features = ["std", "env-filter"] } log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1.0" +serde_yaml = "0.9" blake2 = "0.10" +blake3 = "1" +chacha20 = "0.9" chacha20poly1305 = "0.10" rand = "0.8" bytes = "1" rand_core = "0.6" aead = "0.5" +aegis = { version = "0.9.9", default-features = false, features = ["pure-rust"] } +aes = "0.8" +aes-gcm = "0.10" +ccm = "0.5" +deoxys = { version = "0.2.0-rc.3", default-features = false } +lea = "0.5.4" +poly1305 = "0.8" +rabbit = "0.5" +zears = "0.2.1" x25519-dalek = { version = "2.0", features = [ "reusable_secrets", "static_secrets", @@ -44,6 +56,7 @@ x25519-dalek = { version = "2.0", features = [ ring = "0.17" parking_lot = "0.12" hmac = "0.12" +md-5 = "0.10" base64 = "0.21" fehler = "1.0" ip_network_table = "0.2" @@ -57,6 +70,7 @@ arti-client = "0.40.0" hickory-proto = "0.25.2" netstack-smoltcp = "0.2.1" tokio-util = { version = "0.7.18", features = ["compat"] } +tokio_smux = "0.2" tor-rtcompat = "0.40.0" console-subscriber = { version = "0.2.0", optional = true } console = "0.15.8" @@ -78,6 +92,48 @@ hyper-util = "0.1.6" toml = "0.8.15" rust-ini = "0.21.0" subtle = "2.6" +rustls = { version = "0.23", default-features = false, features = [ + "aws_lc_rs", + "logging", + "std", + "tls12", +] } +rustls-pemfile = "2" +sha2 = "0.10" +tokio-rustls = "0.26" +rama-tls-boring = { version = "0.3.0-alpha.4", default-features = false, optional = true } +rama-ua = { version = "0.3.0-alpha.4", default-features = false, optional = true, features = [ + "embed-profiles", + "tls", +] } +rama-net = { version = "0.3.0-alpha.4", default-features = false, optional = true, features = ["tls"] } +webpki-roots = "0.26" +webpki-roots-legacy = { package = "webpki-roots", version = "0.22" } +rust_tokio_kcp = { version = "0.2.5", default-features = false, features = [ + "aes", + "tea", + "xtea", + "simple_xor", + "blowfish", + "cast5", + "triple_des", + "twofish", + "salsa20", + "sm4", + "aes-gcm", +] } +smux_rust = "0.2.1" +tokio-snappy = "0.2.0" +shadowsocks = { version = "1.24.0", default-features = false, features = [ + "aead-cipher", + "aead-cipher-extra", + "aead-cipher-2022", + "aead-cipher-2022-extra", + "stream-cipher", +] } +yamux = "0.13.10" +h2 = "0.4.12" +http = "1.3.1" [target.'cfg(target_os = "linux")'.dependencies] caps = "0.5" @@ -87,8 +143,11 @@ nix = { version = "0.27", features = ["fs", "socket", "uio"] } tracing-journald = "0.3" [target.'cfg(target_vendor = "apple")'.dependencies] +libc = "0.2" +native-tls = { version = "0.2", features = ["alpn"] } nix = { version = "0.27" } rusqlite = { version = "0.38.0", features = ["bundled", "blob"] } +tokio-native-tls = "0.3" [target.'cfg(target_os = "macos")'.dependencies] tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" } @@ -109,6 +168,11 @@ pre_uninstall_script = "../package/rpm/pre_uninstall" [features] tokio-console = ["dep:console-subscriber"] bundled = ["rusqlite/bundled"] +boring-browser-fingerprints = [ + "dep:rama-tls-boring", + "dep:rama-ua", + "dep:rama-net", +] [build-dependencies] diff --git a/burrow/src/auth/server/mod.rs b/burrow/src/auth/server/mod.rs index fdffce3..d14e9a3 100644 --- a/burrow/src/auth/server/mod.rs +++ b/burrow/src/auth/server/mod.rs @@ -147,7 +147,10 @@ pub fn build_router(config: AuthServerConfig) -> Router { .route("/v1/control/map", post(control_map)) .route("/v1/tailnet/discover", get(tailnet_discover)) .route("/v1/tailscale/login/start", post(tailscale_login_start)) - .route("/v1/tailscale/login/:session_id", get(tailscale_login_status)) + .route( + "/v1/tailscale/login/:session_id", + get(tailscale_login_status), + ) .with_state(AppState { config, tailscale: tailscale::TailscaleBridgeManager::default(), @@ -247,7 +250,12 @@ async fn tailscale_login_status( .await .map_err(internal_error)? .map(Json) - .ok_or_else(|| (StatusCode::NOT_FOUND, "unknown tailscale login session".to_owned())) + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + "unknown tailscale login session".to_owned(), + ) + }) } async fn healthz() -> impl IntoResponse { @@ -419,10 +427,7 @@ mod tests { async fn tailnet_discover_requires_email() -> Result<()> { let app = build_router(AuthServerConfig::default()); let response = app - .oneshot( - Request::get("/v1/tailnet/discover?email=") - .body(Body::empty())?, - ) + .oneshot(Request::get("/v1/tailnet/discover?email=").body(Body::empty())?) .await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); Ok(()) diff --git a/burrow/src/auth/server/tailscale.rs b/burrow/src/auth/server/tailscale.rs index d08c807..85bb1f2 100644 --- a/burrow/src/auth/server/tailscale.rs +++ b/burrow/src/auth/server/tailscale.rs @@ -291,7 +291,10 @@ impl TailscaleHelperProcess { } async fn shutdown_with_client(&self, client: &Client) -> Result<()> { - let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await; + let _ = client + .post(format!("{}/shutdown", self.listen_url)) + .send() + .await; for _ in 0..10 { let mut child = self.child.lock().await; @@ -402,7 +405,9 @@ fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Res } pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf { - state_root().join(session_dir_name(request)).join("packet.sock") + state_root() + .join(session_dir_name(request)) + .join("packet.sock") } pub(crate) fn state_root() -> PathBuf { @@ -504,7 +509,10 @@ mod tests { control_url: None, packet_socket: None, }; - assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed"); + assert_eq!( + session_dir_name(&request), + "default-apple-tailscale-managed" + ); assert_eq!(default_hostname(&request), "burrow-apple"); let custom_request = TailscaleLoginStartRequest { diff --git a/burrow/src/control/discovery.rs b/burrow/src/control/discovery.rs index d044a62..b86aedb 100644 --- a/burrow/src/control/discovery.rs +++ b/burrow/src/control/discovery.rs @@ -92,8 +92,13 @@ pub async fn probe_tailnet_authority(authority: &str) -> Result Result Ok(None), - status => Err(anyhow!("tailnet well-known lookup failed with HTTP {status}")), + status => Err(anyhow!( + "tailnet well-known lookup failed with HTTP {status}" + )), } } -async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Result> { +async fn discover_webfinger( + client: &Client, + email: &str, + base_url: &Url, +) -> Result> { let mut url = base_url .join(WEBFINGER_PATH) .context("failed to build webfinger URL")?; @@ -222,7 +233,9 @@ async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Res .filter(|href| !href.trim().is_empty())) } StatusCode::NOT_FOUND => Ok(None), - status => Err(anyhow!("tailnet webfinger lookup failed with HTTP {status}")), + status => Err(anyhow!( + "tailnet webfinger lookup failed with HTTP {status}" + )), } } @@ -274,7 +287,9 @@ mod tests { #[test] fn detects_managed_tailscale_authority() { assert!(is_managed_tailscale_authority("controlplane.tailscale.com")); - assert!(is_managed_tailscale_authority("https://controlplane.tailscale.com/")); + assert!(is_managed_tailscale_authority( + "https://controlplane.tailscale.com/" + )); assert!(!is_managed_tailscale_authority("https://ts.burrow.net")); } diff --git a/burrow/src/daemon/apple.rs b/burrow/src/daemon/apple.rs index f369ea9..45a2047 100644 --- a/burrow/src/daemon/apple.rs +++ b/burrow/src/daemon/apple.rs @@ -1,3 +1,5 @@ +#[cfg(unix)] +use std::os::unix::net::UnixStream; use std::{ ffi::{c_char, CStr}, path::PathBuf, @@ -34,12 +36,17 @@ pub unsafe extern "C" fn spawn_in_process(path: *const c_char, db_path: *const c } pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Option) { + configure_in_process_logging(db_path_buf.as_ref()); crate::tracing::initialize(); let _guard = BURROW_SPAWN_LOCK.lock().unwrap(); if BURROW_READY.get().is_some() { return; } + if socket_is_reachable(path_buf.as_ref()) { + let _ = BURROW_READY.set(()); + return; + } let notify = Arc::new(Notify::new()); let handle = BURROW_HANDLE.get_or_init(|| { @@ -74,3 +81,28 @@ pub fn spawn_in_process_with_paths(path_buf: Option, db_path_buf: Optio handle.block_on(async move { receiver.notified().await }); let _ = BURROW_READY.set(()); } + +fn configure_in_process_logging(db_path_buf: Option<&PathBuf>) { + if std::env::var_os("BURROW_LOG_FILE").is_none() { + if let Some(log_dir) = db_path_buf.and_then(|path| path.parent()) { + std::env::set_var("BURROW_LOG_FILE", log_dir.join("burrow-runtime.log")); + } + } + + if std::env::var_os("BURROW_LOG").is_none() && std::env::var_os("RUST_LOG").is_none() { + std::env::set_var( + "BURROW_LOG", + "burrow=debug,h2=warn,hyper=warn,tower=warn,tonic=warn", + ); + } +} + +#[cfg(unix)] +fn socket_is_reachable(path: Option<&PathBuf>) -> bool { + path.is_some_and(|path| UnixStream::connect(path).is_ok()) +} + +#[cfg(not(unix))] +fn socket_is_reachable(_path: Option<&PathBuf>) -> bool { + false +} diff --git a/burrow/src/daemon/instance.rs b/burrow/src/daemon/instance.rs index 9b2e138..b1d5084 100644 --- a/burrow/src/daemon/instance.rs +++ b/burrow/src/daemon/instance.rs @@ -8,16 +8,20 @@ use rusqlite::Connection; use tokio::sync::{mpsc, watch, RwLock}; use tokio_stream::wrappers::ReceiverStream; use tonic::{Request, Response, Status as RspStatus}; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use tun::tokio::TunInterface; use super::{ rpc::grpc_defs::{ - networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel, - Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, - State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse, - TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket, - TunnelStatusResponse, + networks_server::Networks, proxy_subscriptions_server::ProxySubscriptions, + tailnet_control_server::TailnetControl, tunnel_server::Tunnel, Empty, Network, + NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, NetworkType, + ProxySubscriptionApplyRequest, ProxySubscriptionApplyResponse, + ProxySubscriptionImportRequest, ProxySubscriptionNodePreview, + ProxySubscriptionPreviewResponse, ProxySubscriptionRejectedEntry, + ProxySubscriptionSelectRequest, ProxySubscriptionSelectResponse, State as RPCTunnelState, + TailnetDiscoverRequest, TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse, + TunnelConfigurationResponse, TunnelPacket, TunnelStatusResponse, }, runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel}, }; @@ -28,7 +32,11 @@ use crate::{ }, control::discovery, daemon::rpc::ServerConfig, - database::{add_network, delete_network, get_connection, list_networks, reorder_network}, + database::{ + add_network, delete_network, get_connection, list_networks, proxy_subscription_payload, + reorder_network, select_proxy_subscription_node, upsert_network, + }, + proxy_subscription, }; #[derive(Debug, Clone)] @@ -89,9 +97,7 @@ impl DaemonRPCServer { async fn current_tunnel_configuration(&self) -> Result { let config = { let active = self.active_tunnel.read().await; - active - .as_ref() - .map(|tunnel| tunnel.server_config().clone()) + active.as_ref().map(|tunnel| tunnel.server_config().clone()) }; let config = match config { Some(config) => config, @@ -104,6 +110,22 @@ impl DaemonRPCServer { Ok(configuration_rsp(config)) } + async fn update_active_proxy_subscription_runtime( + &self, + id: i32, + payload: &proxy_subscription::ProxySubscriptionPayload, + ) -> Result<(), RspStatus> { + let mut active = self.active_tunnel.write().await; + let Some(active) = active.as_mut() else { + return Ok(()); + }; + active + .apply_proxy_subscription_payload(id, payload) + .await + .map_err(proc_err)?; + Ok(()) + } + async fn stop_active_tunnel(&self) -> Result { let current = { self.active_tunnel.write().await.take() }; let Some(current) = current else { @@ -218,9 +240,7 @@ impl Tunnel for DaemonRPCServer { return Err(RspStatus::failed_precondition("no active tunnel")); }; active.packet_stream().ok_or_else(|| { - RspStatus::failed_precondition( - "active tunnel does not support packet streaming", - ) + RspStatus::failed_precondition("active tunnel does not support packet streaming") })? }; @@ -373,6 +393,98 @@ impl Networks for DaemonRPCServer { } } +#[tonic::async_trait] +impl ProxySubscriptions for DaemonRPCServer { + async fn preview_import( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let body = proxy_subscription::fetch_subscription(&request.url) + .await + .map_err(proc_err)?; + let preview = proxy_subscription::parse_subscription_body(&body, Some(&request.url)); + Ok(Response::new(proxy_subscription_preview_rsp(preview))) + } + + async fn apply_import( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let body = proxy_subscription::fetch_subscription(&request.url) + .await + .map_err(proc_err)?; + let preview = proxy_subscription::parse_subscription_body(&body, Some(&request.url)); + if preview.nodes.is_empty() { + return Err(RspStatus::invalid_argument( + "subscription contains no compatible Trojan or Shadowsocks nodes", + )); + } + let conn = self.get_connection()?; + let previous_payload = proxy_subscription_payload(&conn, request.id).map_err(proc_err)?; + let selected_ordinal = if let Some(previous_payload) = previous_payload.as_ref() { + let previous_name = selected_proxy_node_name(previous_payload); + supported_selected_name(&preview.nodes, previous_name.as_deref()) + .or_else(|| first_supported_ordinal(&preview.nodes)) + } else { + supported_selected_ordinal(&preview.nodes, request.selected_ordinal) + .or_else(|| first_supported_ordinal(&preview.nodes)) + }; + let payload = proxy_subscription::build_payload( + preview, + &request.url, + (!request.name.trim().is_empty()).then_some(request.name), + selected_ordinal, + ); + crate::proxy_runtime::proxy_runtime_config(&payload) + .map_err(|err| RspStatus::invalid_argument(err.to_string()))?; + let imported_count = payload.nodes.len() as i32; + let selected_ordinal = payload + .selected_ordinal + .or_else(|| payload.nodes.first().map(|node| node.ordinal)) + .unwrap_or_default() as i32; + let network = Network { + id: request.id, + r#type: NetworkType::ProxySubscription.into(), + payload: serde_json::to_vec_pretty(&payload).map_err(proc_err)?, + }; + upsert_network(&conn, &network).map_err(proc_err)?; + self.update_active_proxy_subscription_runtime(request.id, &payload) + .await?; + self.notify_network_update().await?; + #[cfg(not(target_vendor = "apple"))] + self.reconcile_runtime().await?; + Ok(Response::new(ProxySubscriptionApplyResponse { + id: request.id, + imported_count, + selected_ordinal, + })) + } + + async fn select_node( + &self, + request: Request, + ) -> Result, RspStatus> { + let request = request.into_inner(); + let conn = self.get_connection()?; + let selected_ordinal = + select_proxy_subscription_node(&conn, request.id, request.selected_ordinal) + .map_err(proc_err)?; + if let Some(payload) = proxy_subscription_payload(&conn, request.id).map_err(proc_err)? { + self.update_active_proxy_subscription_runtime(request.id, &payload) + .await?; + } + self.notify_network_update().await?; + #[cfg(not(target_vendor = "apple"))] + self.reconcile_runtime().await?; + Ok(Response::new(ProxySubscriptionSelectResponse { + id: request.id, + selected_ordinal, + })) + } +} + #[tonic::async_trait] impl TailnetControl for DaemonRPCServer { async fn discover( @@ -500,7 +612,9 @@ impl TailnetControl for DaemonRPCServer { } fn proc_err(err: impl ToString) -> RspStatus { - RspStatus::internal(err.to_string()) + let message = err.to_string(); + error!(error = %message, "daemon RPC failed"); + RspStatus::internal(message) } fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { @@ -508,6 +622,7 @@ fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { addresses: config.address, mtu: config.mtu.unwrap_or(1000), routes: config.routes, + excluded_routes: config.excluded_routes, dns_servers: config.dns_servers, search_domains: config.search_domains, include_default_route: config.include_default_route, @@ -521,6 +636,107 @@ fn status_rsp(state: RunState) -> TunnelStatusResponse { } } +fn proxy_subscription_preview_rsp( + preview: proxy_subscription::ProxySubscriptionPreview, +) -> ProxySubscriptionPreviewResponse { + ProxySubscriptionPreviewResponse { + suggested_name: preview.suggested_name, + detected_format: preview.detected_format.as_str().to_owned(), + compatible_count: preview.nodes.len() as i32, + rejected_count: preview.rejected.len() as i32, + node: preview + .nodes + .into_iter() + .map(|node| { + let runtime_error = crate::proxy_runtime::runtime_support_error(&node); + let mut warnings = node.warnings; + if let Some(error) = runtime_error.as_deref() { + warnings.push(error.to_owned()); + } + ProxySubscriptionNodePreview { + ordinal: node.ordinal as i32, + name: node.name, + protocol: node.protocol.as_str().to_owned(), + server: node.server, + port: node.port.into(), + warnings, + runtime_supported: runtime_error.is_none(), + } + }) + .collect(), + rejected: preview + .rejected + .into_iter() + .map(|entry| ProxySubscriptionRejectedEntry { + ordinal: entry.ordinal as i32, + reason: entry.reason, + scheme: entry.scheme.unwrap_or_default(), + }) + .collect(), + warnings: preview.warnings, + } +} + +fn supported_selected_ordinal( + nodes: &[proxy_subscription::ProxyNode], + selected_ordinal: i32, +) -> Option { + if selected_ordinal < 0 { + return None; + } + let selected_ordinal = selected_ordinal as usize; + nodes + .iter() + .find(|node| { + node.ordinal == selected_ordinal + && crate::proxy_runtime::runtime_support_error(node).is_none() + }) + .map(|node| node.ordinal) +} + +fn supported_selected_name( + nodes: &[proxy_subscription::ProxyNode], + selected_name: Option<&str>, +) -> Option { + let selected_name = selected_name?; + nodes + .iter() + .find(|node| { + node.name == selected_name + && crate::proxy_runtime::runtime_support_error(node).is_none() + }) + .map(|node| node.ordinal) +} + +fn first_supported_ordinal(nodes: &[proxy_subscription::ProxyNode]) -> Option { + nodes + .iter() + .find(|node| crate::proxy_runtime::runtime_support_error(node).is_none()) + .map(|node| node.ordinal) +} + +fn selected_proxy_node_name( + payload: &proxy_subscription::ProxySubscriptionPayload, +) -> Option { + payload + .selected_name + .clone() + .or_else(|| { + payload.selected_ordinal.and_then(|ordinal| { + payload + .nodes + .iter() + .find(|node| node.ordinal == ordinal) + .map(|node| node.name.clone()) + }) + }) + .or_else(|| { + crate::proxy_runtime::selected_node(payload) + .ok() + .map(|node| node.name.clone()) + }) +} + fn tailnet_login_rsp( session_id: String, status: TailscaleLoginStatus, @@ -538,3 +754,54 @@ fn tailnet_login_rsp( health: status.health, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supported_selected_name_survives_subscription_reorder() { + let preview = proxy_subscription::parse_subscription_body( + r#" +proxies: + - name: fallback + type: ss + server: fallback.example.net + port: 8388 + cipher: aes-128-gcm + password: secret + - name: selected + type: ss + server: selected.example.net + port: 8388 + cipher: aes-128-gcm + password: secret +"#, + Some("https://example.com/sub"), + ); + + assert_eq!( + supported_selected_name(&preview.nodes, Some("selected")), + Some(1) + ); + } + + #[test] + fn selected_proxy_node_name_reads_legacy_ordinal_payloads() { + let preview = proxy_subscription::parse_subscription_body( + "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388#SS%20Node\n", + Some("https://example.com/sub"), + ); + let payload = proxy_subscription::build_payload( + preview, + "https://example.com/sub", + Some("Proxy Subscription".to_owned()), + Some(0), + ); + + assert_eq!( + selected_proxy_node_name(&payload).as_deref(), + Some("SS Node") + ); + } +} diff --git a/burrow/src/daemon/mod.rs b/burrow/src/daemon/mod.rs index 724e3bb..ccb9b86 100644 --- a/burrow/src/daemon/mod.rs +++ b/burrow/src/daemon/mod.rs @@ -1,4 +1,4 @@ -use std::{path::Path, sync::Arc}; +use std::{io, os::unix::net::UnixStream as StdUnixStream, path::Path, sync::Arc}; pub mod apple; mod instance; @@ -17,8 +17,8 @@ use tracing::info; use crate::{ daemon::rpc::grpc_defs::{ - networks_server::NetworksServer, tailnet_control_server::TailnetControlServer, - tunnel_server::TunnelServer, + networks_server::NetworksServer, proxy_subscriptions_server::ProxySubscriptionsServer, + tailnet_control_server::TailnetControlServer, tunnel_server::TunnelServer, }, database::get_connection, }; @@ -33,16 +33,23 @@ pub async fn daemon_main( let spp = socket_path.clone(); let tmp = get_socket_path(); let sock_path = spp.unwrap_or(Path::new(tmp.as_str())); - if sock_path.exists() { - std::fs::remove_file(sock_path)?; - } - let uds = UnixListener::bind(sock_path)?; + let uds = match bind_daemon_socket(sock_path)? { + Some(uds) => uds, + None => { + if let Some(n) = notify_ready { + n.notify_one(); + } + info!(path = %sock_path.display(), "daemon socket already served by another process"); + return Ok(()); + } + }; let serve_job = tokio::spawn(async move { let uds_stream = UnixListenerStream::new(uds); let tailnet_server = burrow_server.clone(); let _srv = Server::builder() .add_service(TunnelServer::new(burrow_server.clone())) - .add_service(NetworksServer::new(burrow_server)) + .add_service(NetworksServer::new(burrow_server.clone())) + .add_service(ProxySubscriptionsServer::new(burrow_server.clone())) .add_service(TailnetControlServer::new(tailnet_server)) .serve_with_incoming(uds_stream) .await?; @@ -60,6 +67,24 @@ pub async fn daemon_main( .map_err(|e| e.into()) } +fn bind_daemon_socket(sock_path: &Path) -> Result> { + if let Some(parent) = sock_path.parent() { + std::fs::create_dir_all(parent)?; + } + + match UnixListener::bind(sock_path) { + Ok(listener) => Ok(Some(listener)), + Err(error) if error.kind() == io::ErrorKind::AddrInUse => { + if StdUnixStream::connect(sock_path).is_ok() { + return Ok(None); + } + std::fs::remove_file(sock_path)?; + Ok(Some(UnixListener::bind(sock_path)?)) + } + Err(error) => Err(error.into()), + } +} + #[cfg(test)] mod tests { use std::{ diff --git a/burrow/src/daemon/rpc/client.rs b/burrow/src/daemon/rpc/client.rs index aa84c64..1f3cfa2 100644 --- a/burrow/src/daemon/rpc/client.rs +++ b/burrow/src/daemon/rpc/client.rs @@ -6,13 +6,14 @@ use tonic::transport::{Endpoint, Uri}; use tower::service_fn; use super::grpc_defs::{ - networks_client::NetworksClient, tailnet_control_client::TailnetControlClient, - tunnel_client::TunnelClient, + networks_client::NetworksClient, proxy_subscriptions_client::ProxySubscriptionsClient, + tailnet_control_client::TailnetControlClient, tunnel_client::TunnelClient, }; use crate::daemon::get_socket_path; pub struct BurrowClient { pub networks_client: NetworksClient, + pub proxy_subscription_client: ProxySubscriptionsClient, pub tailnet_client: TailnetControlClient, pub tunnel_client: TunnelClient, } @@ -35,10 +36,12 @@ impl BurrowClient { })) .await?; let nw_client = NetworksClient::new(channel.clone()); + let proxy_subscription_client = ProxySubscriptionsClient::new(channel.clone()); let tailnet_client = TailnetControlClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone()); Ok(BurrowClient { networks_client: nw_client, + proxy_subscription_client, tailnet_client, tunnel_client: tun_client, }) diff --git a/burrow/src/daemon/rpc/response.rs b/burrow/src/daemon/rpc/response.rs index 6d03581..d72fcd3 100644 --- a/burrow/src/daemon/rpc/response.rs +++ b/burrow/src/daemon/rpc/response.rs @@ -71,6 +71,8 @@ pub struct ServerConfig { #[serde(default)] pub routes: Vec, #[serde(default)] + pub excluded_routes: Vec, + #[serde(default)] pub dns_servers: Vec, #[serde(default)] pub search_domains: Vec, @@ -91,6 +93,7 @@ impl TryFrom<&Config> for ServerConfig { .iter() .flat_map(|peer| peer.allowed_ips.iter().cloned()) .collect(), + excluded_routes: Vec::new(), dns_servers: config.interface.dns.clone(), search_domains: Vec::new(), include_default_route: false, @@ -105,6 +108,7 @@ impl Default for ServerConfig { Self { address: vec!["10.13.13.2".to_string()], // Dummy remote address routes: Vec::new(), + excluded_routes: Vec::new(), dns_servers: Vec::new(), search_domains: Vec::new(), include_default_route: false, diff --git a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap index 68b4195..6bc3ce1 100644 --- a/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap +++ b/burrow/src/daemon/rpc/snapshots/burrow__daemon__rpc__response__response_serialization-4.snap @@ -2,4 +2,4 @@ source: burrow/src/daemon/rpc/response.rs expression: "serde_json::to_string(&DaemonResponse::new(Ok::(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" --- -{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} +{"result":{"Ok":{"type":"ServerConfig","address":["10.13.13.2"],"routes":[],"excluded_routes":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0} diff --git a/burrow/src/daemon/runtime.rs b/burrow/src/daemon/runtime.rs index 31821a2..ae5fa99 100644 --- a/burrow/src/daemon/runtime.rs +++ b/burrow/src/daemon/runtime.rs @@ -20,6 +20,8 @@ use crate::{ TailscaleLoginStartRequest, TailscaleLoginStatus, }, control::{discovery, TailnetConfig}, + proxy_runtime, + proxy_subscription::ProxySubscriptionPayload, wireguard::{Config, Interface as WireGuardInterface}, }; @@ -46,6 +48,10 @@ pub enum ResolvedTunnel { identity: RuntimeIdentity, config: Config, }, + ProxySubscription { + identity: RuntimeIdentity, + payload: ProxySubscriptionPayload, + }, } impl ResolvedTunnel { @@ -73,6 +79,12 @@ impl ResolvedTunnel { let config = Config::from_content_fmt(&payload, "ini")?; Ok(Self::WireGuard { identity, config }) } + NetworkType::ProxySubscription => { + let payload: ProxySubscriptionPayload = serde_json::from_slice(&network.payload) + .context("proxy subscription payload must be valid JSON")?; + crate::proxy_subscription::validate_payload(&payload)?; + Ok(Self::ProxySubscription { identity, payload }) + } } } @@ -80,7 +92,8 @@ impl ResolvedTunnel { match self { Self::Passthrough { identity } | Self::Tailnet { identity, .. } - | Self::WireGuard { identity, .. } => identity, + | Self::WireGuard { identity, .. } + | Self::ProxySubscription { identity, .. } => identity, } } @@ -89,6 +102,7 @@ impl ResolvedTunnel { Self::Passthrough { .. } => Ok(ServerConfig { address: Vec::new(), routes: Vec::new(), + excluded_routes: Vec::new(), dns_servers: Vec::new(), search_domains: Vec::new(), include_default_route: false, @@ -98,6 +112,7 @@ impl ResolvedTunnel { Self::Tailnet { .. } => Ok(ServerConfig { address: Vec::new(), routes: tailnet_routes(), + excluded_routes: Vec::new(), dns_servers: tailnet_dns_servers(), search_domains: Vec::new(), include_default_route: false, @@ -105,6 +120,7 @@ impl ResolvedTunnel { mtu: Some(1280), }), Self::WireGuard { config, .. } => ServerConfig::try_from(config), + Self::ProxySubscription { payload, .. } => proxy_subscription_server_config(payload), } } @@ -119,6 +135,7 @@ impl ResolvedTunnel { server_config: ServerConfig { address: Vec::new(), routes: Vec::new(), + excluded_routes: Vec::new(), dns_servers: Vec::new(), search_domains: Vec::new(), include_default_route: false, @@ -137,10 +154,9 @@ impl ResolvedTunnel { }; let status = wait_for_tailnet_ready(helper.as_ref()).await?; let server_config = tailnet_server_config(&status); - let packet_socket = helper - .packet_socket() - .map(PathBuf::from) - .ok_or_else(|| anyhow::anyhow!("tailnet helper did not report a packet socket"))?; + let packet_socket = helper.packet_socket().map(PathBuf::from).ok_or_else(|| { + anyhow::anyhow!("tailnet helper did not report a packet socket") + })?; let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?; #[cfg(target_vendor = "apple")] let tun_task = None; @@ -182,10 +198,61 @@ impl ResolvedTunnel { } } } + Self::ProxySubscription { identity, payload } => { + let runtime_config = proxy_runtime::proxy_runtime_config(&payload)?; + let server_config = proxy_subscription_server_config_for_selected( + &payload, + &runtime_config.selected_name, + )?; + let packet_bridge = + proxy_runtime::start_proxy_packet_bridge(runtime_config.outbound); + #[cfg(target_vendor = "apple")] + let tun_task = None; + #[cfg(not(target_vendor = "apple"))] + let tun_task = { + let tun = TunOptions::new().open()?; + tun_interface.write().await.replace(tun); + Some(tokio::spawn(proxy_runtime::run_proxy_tun_bridge( + tun_interface.clone(), + packet_bridge.outbound_sender(), + packet_bridge.subscribe(), + ))) + }; + Ok(ActiveTunnel::ProxySubscription { + identity, + server_config, + packet_bridge, + tun_task, + }) + } } } } +fn proxy_subscription_server_config(payload: &ProxySubscriptionPayload) -> Result { + let runtime_config = proxy_runtime::proxy_runtime_config(payload)?; + proxy_subscription_server_config_for_selected(payload, &runtime_config.selected_name) +} + +fn proxy_subscription_server_config_for_selected( + payload: &ProxySubscriptionPayload, + selected_name: &str, +) -> Result { + let dns_servers = proxy_runtime::proxy_dns_servers(); + let mut excluded_routes = proxy_runtime::proxy_excluded_routes(payload)?; + excluded_routes.extend(proxy_runtime::proxy_dns_excluded_routes(&dns_servers)); + Ok(ServerConfig { + address: proxy_runtime::proxy_server_addresses(), + routes: Vec::new(), + excluded_routes, + dns_servers, + search_domains: Vec::new(), + include_default_route: true, + name: Some(format!("{} ({})", payload.name, selected_name)), + mtu: Some(1500), + }) +} + pub enum ActiveTunnel { Passthrough { identity: RuntimeIdentity, @@ -205,6 +272,12 @@ pub enum ActiveTunnel { interface: Arc>, task: JoinHandle>, }, + ProxySubscription { + identity: RuntimeIdentity, + server_config: ServerConfig, + packet_bridge: proxy_runtime::ProxyPacketBridge, + tun_task: Option>>, + }, } impl ActiveTunnel { @@ -212,7 +285,8 @@ impl ActiveTunnel { match self { Self::Passthrough { identity, .. } | Self::Tailnet { identity, .. } - | Self::WireGuard { identity, .. } => identity, + | Self::WireGuard { identity, .. } + | Self::ProxySubscription { identity, .. } => identity, } } @@ -220,22 +294,54 @@ impl ActiveTunnel { match self { Self::Passthrough { server_config, .. } | Self::Tailnet { server_config, .. } - | Self::WireGuard { server_config, .. } => server_config, + | Self::WireGuard { server_config, .. } + | Self::ProxySubscription { server_config, .. } => server_config, } } - pub fn packet_stream( - &self, - ) -> Option<(mpsc::Sender>, broadcast::Receiver>)> { + pub fn packet_stream(&self) -> Option<(mpsc::Sender>, broadcast::Receiver>)> { match self { - Self::Tailnet { packet_bridge, .. } => Some(( - packet_bridge.outbound_sender(), - packet_bridge.subscribe(), - )), + Self::Tailnet { packet_bridge, .. } => { + Some((packet_bridge.outbound_sender(), packet_bridge.subscribe())) + } + Self::ProxySubscription { packet_bridge, .. } => { + Some((packet_bridge.outbound_sender(), packet_bridge.subscribe())) + } _ => None, } } + pub async fn apply_proxy_subscription_payload( + &mut self, + network_id: i32, + payload: &ProxySubscriptionPayload, + ) -> Result { + let Self::ProxySubscription { + identity, + server_config, + packet_bridge, + .. + } = self + else { + return Ok(false); + }; + + let RuntimeIdentity::Network { id, network_type, .. } = identity else { + return Ok(false); + }; + if *id != network_id || *network_type != NetworkType::ProxySubscription { + return Ok(false); + } + + let runtime_config = proxy_runtime::proxy_runtime_config(payload)?; + *server_config = + proxy_subscription_server_config_for_selected(payload, &runtime_config.selected_name)?; + packet_bridge + .set_proxy_outbound(runtime_config.outbound) + .await; + Ok(true) + } + pub async fn shutdown(self, tun_interface: &Arc>>) -> Result<()> { match self { Self::Passthrough { .. } => Ok(()), @@ -268,17 +374,28 @@ impl ActiveTunnel { } Ok(()) } - Self::WireGuard { - interface, - task, - .. - } => { + Self::WireGuard { interface, task, .. } => { interface.read().await.remove_tun().await; let task_result = task.await; tun_interface.write().await.take(); task_result??; Ok(()) } + Self::ProxySubscription { packet_bridge, tun_task, .. } => { + if let Some(tun_task) = tun_task { + tun_task.abort(); + match tun_task.await { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err), + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(err.into()), + } + } + packet_bridge.abort(); + packet_bridge.join().await?; + tun_interface.write().await.take(); + Ok(()) + } } } } @@ -396,6 +513,7 @@ fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig { .map(|ip| tailnet_cidr(ip)) .collect(), routes: tailnet_routes(), + excluded_routes: Vec::new(), dns_servers: tailnet_dns_servers(), search_domains, include_default_route: false, @@ -605,7 +723,10 @@ mod tests { fn tailnet_server_config_uses_host_prefixes() { let status = TailscaleLoginStatus { running: true, - tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()], + tailscale_ips: vec![ + "100.101.102.103".to_owned(), + "fd7a:115c:a1e0::123".to_owned(), + ], ..Default::default() }; let config = tailnet_server_config(&status); diff --git a/burrow/src/database.rs b/burrow/src/database.rs index fe9a3c7..4b2350a 100644 --- a/burrow/src/database.rs +++ b/burrow/src/database.rs @@ -1,6 +1,6 @@ use std::path::Path; -use anyhow::Result; +use anyhow::{anyhow, Result}; use rusqlite::{params, Connection}; use crate::{ @@ -8,6 +8,7 @@ use crate::{ daemon::rpc::grpc_defs::{ Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType, }, + proxy_subscription::ProxySubscriptionPayload, wireguard::config::{Config, Interface, Peer}, }; @@ -138,6 +139,18 @@ pub fn add_network(conn: &Connection, network: &RPCNetwork) -> Result<()> { Ok(()) } +pub fn upsert_network(conn: &Connection, network: &RPCNetwork) -> Result<()> { + validate_network_payload(network)?; + let updated = conn.execute( + "UPDATE network SET type = ?, payload = ? WHERE id = ?", + params![network.r#type().as_str_name(), &network.payload, network.id], + )?; + if updated == 0 { + add_network(conn, network)?; + } + Ok(()) +} + pub fn list_networks(conn: &Connection) -> Result> { let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx, id")?; let networks: Vec = stmt @@ -183,6 +196,70 @@ pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<() renumber_networks(conn, &ordered_ids) } +pub fn proxy_subscription_payload( + conn: &Connection, + id: i32, +) -> Result> { + let row = conn.query_row( + "SELECT type, payload FROM network WHERE id = ?", + params![id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec>(1)?)), + ); + let (network_type, payload) = match row { + Ok(row) => row, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(error) => return Err(error.into()), + }; + let network_type = NetworkType::from_str_name(network_type.as_str()) + .ok_or_else(|| anyhow!("stored network type {network_type} is not recognized"))?; + if network_type != NetworkType::ProxySubscription { + return Ok(None); + } + Ok(Some(serde_json::from_slice(&payload)?)) +} + +pub fn select_proxy_subscription_node( + conn: &Connection, + id: i32, + selected_ordinal: i32, +) -> Result { + if selected_ordinal < 0 { + return Err(anyhow!("selected proxy node ordinal must be non-negative")); + } + + let (network_type, payload): (String, Vec) = conn.query_row( + "SELECT type, payload FROM network WHERE id = ?", + params![id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + let network_type = NetworkType::from_str_name(network_type.as_str()) + .ok_or_else(|| anyhow!("stored network type {network_type} is not recognized"))?; + if network_type != NetworkType::ProxySubscription { + return Err(anyhow!("network {id} is not a proxy subscription")); + } + + let mut payload: ProxySubscriptionPayload = serde_json::from_slice(&payload)?; + let selected_ordinal = selected_ordinal as usize; + let node = payload + .nodes + .iter() + .find(|node| node.ordinal == selected_ordinal) + .ok_or_else(|| anyhow!("selected proxy node ordinal {selected_ordinal} was not found"))?; + if let Some(error) = crate::proxy_runtime::runtime_support_error(node) { + return Err(anyhow!(error)); + } + + let selected_name = node.name.clone(); + payload.selected_ordinal = Some(selected_ordinal); + payload.selected_name = Some(selected_name); + crate::proxy_subscription::validate_payload(&payload)?; + conn.execute( + "UPDATE network SET payload = ? WHERE id = ?", + params![serde_json::to_vec_pretty(&payload)?, id], + )?; + Ok(selected_ordinal as i32) +} + fn parse_lst(s: &str) -> Vec { if s.is_empty() { return vec![]; @@ -206,6 +283,10 @@ fn validate_network_payload(network: &RPCNetwork) -> Result<()> { NetworkType::Tailnet => { TailnetConfig::from_slice(&network.payload)?; } + NetworkType::ProxySubscription => { + let payload: ProxySubscriptionPayload = serde_json::from_slice(&network.payload)?; + crate::proxy_subscription::validate_payload(&payload)?; + } } Ok(()) } @@ -278,6 +359,79 @@ Endpoint = wg.burrow.rs:51820 .to_vec() } + fn sample_proxy_subscription_payload() -> Vec { + br#"{ + "name":"example proxy subscription", + "source":{"url":"https://example.com/sub"}, + "detected_format":"uri-list", + "selected_ordinal":0, + "nodes":[{ + "ordinal":0, + "name":"example trojan", + "protocol":"trojan", + "server":"example.com", + "port":443, + "warnings":[], + "config":{ + "type":"trojan", + "password":"secret", + "sni":"front.example", + "alpn":[], + "skip_cert_verify":false, + "network":"tcp", + "client_fingerprint":"chrome", + "fingerprint":null + } + }], + "warnings":[] +}"# + .to_vec() + } + + fn sample_proxy_subscription_payload_with_two_nodes() -> Vec { + br#"{ + "name":"example proxy subscription", + "source":{"url":"https://example.com/sub"}, + "detected_format":"uri-list", + "selected_ordinal":0, + "nodes":[{ + "ordinal":0, + "name":"example trojan", + "protocol":"trojan", + "server":"example.com", + "port":443, + "warnings":[], + "config":{ + "type":"trojan", + "password":"secret", + "sni":"front.example", + "alpn":[], + "skip_cert_verify":false, + "network":"tcp", + "client_fingerprint":"chrome", + "fingerprint":null + } + },{ + "ordinal":1, + "name":"example shadowsocks", + "protocol":"shadowsocks", + "server":"ss.example.com", + "port":8388, + "warnings":[], + "config":{ + "type":"shadowsocks", + "cipher":"aes-256-gcm", + "password":"secret", + "udp":true, + "udp_over_tcp":false, + "plugin":null + } + }], + "warnings":[] +}"# + .to_vec() + } + #[test] fn test_db() { let conn = Connection::open_in_memory().unwrap(); @@ -317,6 +471,16 @@ Endpoint = wg.burrow.rs:51820 &conn, &RPCNetwork { id: 3, + r#type: NetworkType::ProxySubscription.into(), + payload: sample_proxy_subscription_payload(), + }, + ) + .unwrap(); + + add_network( + &conn, + &RPCNetwork { + id: 4, r#type: NetworkType::WireGuard.into(), payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380), }, @@ -326,7 +490,7 @@ Endpoint = wg.burrow.rs:51820 assert!(add_network( &conn, &RPCNetwork { - id: 4, + id: 5, r#type: NetworkType::WireGuard.into(), payload: b"not-a-config".to_vec(), }, @@ -336,19 +500,29 @@ Endpoint = wg.burrow.rs:51820 assert!(add_network( &conn, &RPCNetwork { - id: 5, + id: 6, r#type: NetworkType::Tailnet.into(), payload: b"not-a-tailnet-config".to_vec(), }, ) .is_err()); + assert!(add_network( + &conn, + &RPCNetwork { + id: 7, + r#type: NetworkType::ProxySubscription.into(), + payload: b"not-a-trojan-config".to_vec(), + }, + ) + .is_err()); + let ids: Vec = list_networks(&conn) .unwrap() .into_iter() .map(|n| n.id) .collect(); - assert_eq!(ids, vec![1, 2, 3]); + assert_eq!(ids, vec![1, 2, 3, 4]); } #[test] @@ -389,6 +563,87 @@ Endpoint = wg.burrow.rs:51820 assert_eq!(ids, vec![3, 2]); } + #[test] + fn upsert_network_preserves_priority() { + let conn = Connection::open_in_memory().unwrap(); + initialize_tables(&conn).unwrap(); + + add_network( + &conn, + &RPCNetwork { + id: 1, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380), + }, + ) + .unwrap(); + add_network( + &conn, + &RPCNetwork { + id: 2, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address("10.42.0.3/32", 1381), + }, + ) + .unwrap(); + + upsert_network( + &conn, + &RPCNetwork { + id: 1, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address("10.99.0.2/32", 1400), + }, + ) + .unwrap(); + + let networks = list_networks(&conn).unwrap(); + let ids: Vec = networks.iter().map(|n| n.id).collect(); + assert_eq!(ids, vec![1, 2]); + + let updated = String::from_utf8(networks[0].payload.clone()).unwrap(); + assert!(updated.contains("10.99.0.2/32")); + } + + #[test] + fn select_proxy_subscription_node_updates_payload_without_reorder() { + let conn = Connection::open_in_memory().unwrap(); + initialize_tables(&conn).unwrap(); + + add_network( + &conn, + &RPCNetwork { + id: 1, + r#type: NetworkType::ProxySubscription.into(), + payload: sample_proxy_subscription_payload_with_two_nodes(), + }, + ) + .unwrap(); + add_network( + &conn, + &RPCNetwork { + id: 2, + r#type: NetworkType::WireGuard.into(), + payload: sample_wireguard_payload_with_address("10.42.0.3/32", 1381), + }, + ) + .unwrap(); + + assert_eq!(select_proxy_subscription_node(&conn, 1, 1).unwrap(), 1); + + let networks = list_networks(&conn).unwrap(); + let ids: Vec = networks.iter().map(|n| n.id).collect(); + assert_eq!(ids, vec![1, 2]); + + let payload: ProxySubscriptionPayload = + serde_json::from_slice(&networks[0].payload).unwrap(); + assert_eq!(payload.selected_ordinal, Some(1)); + assert_eq!( + payload.selected_name.as_deref(), + Some("example shadowsocks") + ); + } + #[test] fn get_connection_does_not_seed_a_default_interface() { let dir = tempdir().unwrap(); diff --git a/burrow/src/lib.rs b/burrow/src/lib.rs index 7867d18..f0b24d5 100644 --- a/burrow/src/lib.rs +++ b/burrow/src/lib.rs @@ -1,6 +1,9 @@ #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod control; +pub mod proxy_runtime; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +pub mod proxy_subscription; #[cfg(any(target_os = "linux", target_vendor = "apple"))] pub mod wireguard; diff --git a/burrow/src/main.rs b/burrow/src/main.rs index cfa2085..39a4af1 100644 --- a/burrow/src/main.rs +++ b/burrow/src/main.rs @@ -5,6 +5,9 @@ use clap::{Args, Parser, Subcommand}; mod control; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod daemon; +mod proxy_runtime; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +mod proxy_subscription; pub(crate) mod tracing; #[cfg(any(target_os = "linux", target_vendor = "apple"))] mod wireguard; @@ -80,6 +83,16 @@ enum Commands { TailnetPing(TailnetPingArgs), /// Send a UDP echo probe through the active Tailnet tunnel over daemon packet streaming TailnetUdpEcho(TailnetUdpEchoArgs), + /// Send an HTTP probe through the active proxy tunnel over daemon packet streaming + ProxyTcpProbe(ProxyTcpProbeArgs), + /// Send a UDP echo probe through the active proxy tunnel over daemon packet streaming + ProxyUdpEcho(ProxyUdpEchoArgs), + /// Send an HTTPS probe through the active proxy tunnel over daemon packet streaming + ProxyHttpsProbe(ProxyHttpsProbeArgs), + /// Select a proxy subscription node through the daemon + ProxySubscriptionSelect(ProxySubscriptionSelectArgs), + /// Preview proxy nodes from a subscription URL + ProxySubscriptionPreview(ProxySubscriptionPreviewArgs), #[cfg(target_os = "linux")] /// Run a command in an unshared Linux namespace using a Burrow backend Exec(ExecArgs), @@ -148,6 +161,59 @@ struct TailnetUdpEchoArgs { timeout_ms: u64, } +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct ProxyTcpProbeArgs { + #[arg(long, default_value = "1.1.1.1:80")] + remote: String, + #[arg(long, default_value = "one.one.one.one")] + host: String, + #[arg(long)] + dns_name: Option, + #[arg(long, default_value = "100.64.0.2:53")] + dns_server: String, + #[arg(long, default_value_t = 8000)] + timeout_ms: u64, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct ProxyUdpEchoArgs { + remote: String, + #[arg(long, default_value = "burrow-proxy-udp-smoke")] + message: String, + #[arg(long, default_value_t = 5000)] + timeout_ms: u64, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct ProxyHttpsProbeArgs { + #[arg(long, default_value = "1.1.1.1:443")] + remote: String, + #[arg(long)] + host: Option, + #[arg(long)] + dns_name: Option, + #[arg(long, default_value = "100.64.0.2:53")] + dns_server: String, + #[arg(long, default_value_t = 15000)] + timeout_ms: u64, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct ProxySubscriptionSelectArgs { + id: i32, + selected_ordinal: i32, +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +#[derive(Args)] +struct ProxySubscriptionPreviewArgs { + url: String, +} + #[cfg(target_os = "linux")] #[derive(Args)] struct TorExecArgs { @@ -389,6 +455,21 @@ async fn try_tailnet_ping(remote: &str, payload: &str, timeout_ms: u64) -> Resul #[cfg(any(target_os = "linux", target_vendor = "apple"))] async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> { + try_packet_udp_echo("Tailnet", remote, message, timeout_ms).await +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_proxy_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> Result<()> { + try_packet_udp_echo("Proxy", remote, message, timeout_ms).await +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_packet_udp_echo( + label: &str, + remote: &str, + message: &str, + timeout_ms: u64, +) -> Result<()> { use std::net::SocketAddr; use anyhow::{bail, Context}; @@ -405,6 +486,7 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R let remote_addr: SocketAddr = remote .parse() .with_context(|| format!("invalid remote socket address {remote}"))?; + let label = label.to_owned(); let mut client = BurrowClient::from_uds().await?; client.tunnel_client.tunnel_start(Empty {}).await?; @@ -424,9 +506,11 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R .enable_udp(true) .enable_tcp(true) .build() - .context("failed to build userspace UDP stack")?; - let runner = runner.context("userspace UDP stack runner unavailable")?; - let udp_socket = udp_socket.context("userspace UDP stack socket unavailable")?; + .with_context(|| format!("failed to build userspace {label} UDP stack"))?; + let runner = + runner.with_context(|| format!("userspace {label} UDP stack runner unavailable"))?; + let udp_socket = + udp_socket.with_context(|| format!("userspace {label} UDP stack socket unavailable"))?; let (mut stack_sink, mut stack_stream) = stack.split(); let (mut udp_reader, mut udp_writer) = udp_socket.split(); @@ -437,18 +521,21 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R .await? .into_inner(); + let ingress_label = label.clone(); let ingress_task = tokio::spawn(async move { loop { match tunnel_packets.message().await? { Some(packet) => { log::debug!( - "tailnet udp echo received {} bytes from daemon packet stream", + "{} UDP echo received {} bytes from daemon packet stream", + ingress_label, packet.payload.len() ); - stack_sink - .send(packet.payload) - .await - .context("failed to feed inbound tailnet packet into userspace stack")?; + stack_sink.send(packet.payload).await.with_context(|| { + format!( + "failed to feed inbound {ingress_label} packet into userspace stack" + ) + })?; } None => break, } @@ -456,17 +543,21 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R Result::<()>::Ok(()) }); + let egress_label = label.clone(); let egress_task = tokio::spawn(async move { while let Some(packet) = stack_stream.next().await { let payload = packet.context("failed to read outbound packet from userspace stack")?; log::debug!( - "tailnet udp echo sending {} bytes into daemon packet stream", + "{} UDP echo sending {} bytes into daemon packet stream", + egress_label, payload.len() ); outbound_tx .send(TunnelPacket { payload }) .await - .context("failed to forward outbound tailnet packet to daemon")?; + .with_context(|| { + format!("failed to forward outbound {egress_label} packet to daemon") + })?; } Result::<()>::Ok(()) }); @@ -476,13 +567,13 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R udp_writer .send((message.as_bytes().to_vec(), local_addr, remote_addr)) .await - .context("failed to send UDP echo probe into userspace stack")?; - log::debug!("tailnet udp echo probe queued from {local_addr} to {remote_addr}"); + .with_context(|| format!("failed to send {label} UDP echo probe into userspace stack"))?; + log::debug!("{label} UDP echo probe queued from {local_addr} to {remote_addr}"); let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) .await - .with_context(|| format!("timed out waiting for UDP echo from {remote_addr}"))? - .context("userspace UDP stack ended before returning a reply")?; + .with_context(|| format!("timed out waiting for {label} UDP echo from {remote_addr}"))? + .with_context(|| format!("userspace {label} UDP stack ended before returning a reply"))?; let (payload, reply_source, reply_destination) = response; let response_text = String::from_utf8_lossy(&payload); @@ -500,9 +591,691 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R bail!("UDP echo payload mismatch"); } - println!("Tailnet UDP Echo Source: {reply_source}"); - println!("Tailnet UDP Echo Destination: {reply_destination}"); - println!("Tailnet UDP Echo Payload: {response_text}"); + println!("{label} UDP Echo Source: {reply_source}"); + println!("{label} UDP Echo Destination: {reply_destination}"); + println!("{label} UDP Echo Payload: {response_text}"); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_proxy_subscription_preview(url: &str) -> Result<()> { + let body = proxy_subscription::fetch_subscription(url).await?; + let preview = proxy_subscription::parse_subscription_body(&body, Some(url)); + + println!("Proxy subscription nodes: {}", preview.nodes.len()); + println!("Rejected entries: {}", preview.rejected.len()); + println!("Detected format: {}", preview.detected_format.as_str()); + for node in preview.nodes.iter() { + println!( + "{}: {} {}:{} ({})", + node.ordinal, + node.name, + node.server, + node.port, + node.protocol.as_str() + ); + } + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn proxy_dns_probe( + dns_name: &str, + dns_server: &str, + timeout_ms: u64, +) -> Result { + use std::net::{IpAddr, SocketAddr}; + + use anyhow::{bail, Context}; + use futures::{SinkExt, StreamExt}; + use netstack_smoltcp::StackBuilder; + use rand::Rng; + use tokio::{ + sync::mpsc, + time::{timeout, Duration}, + }; + use tokio_stream::wrappers::ReceiverStream; + + use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; + + let dns_server_addr: SocketAddr = dns_server + .parse() + .with_context(|| format!("invalid DNS server socket address {dns_server}"))?; + + let mut query = proxy_runtime::build_dns_query(dns_name, 1)?; + let query_id = rand::thread_rng().gen::(); + query[0..2].copy_from_slice(&query_id.to_be_bytes()); + + let mut client = BurrowClient::from_uds().await?; + client.tunnel_client.tunnel_start(Empty {}).await?; + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let config = config_stream + .message() + .await? + .context("tunnel configuration stream ended before yielding a config")?; + let local_addr = select_tailnet_local_socket(&config.addresses, dns_server_addr.ip())?; + + let (stack, runner, udp_socket, _) = StackBuilder::default() + .enable_udp(true) + .enable_tcp(true) + .build() + .context("failed to build userspace DNS probe stack")?; + let runner = runner.context("userspace DNS probe stack runner unavailable")?; + let udp_socket = udp_socket.context("userspace DNS probe socket unavailable")?; + let (mut stack_sink, mut stack_stream) = stack.split(); + let (mut udp_reader, mut udp_writer) = udp_socket.split(); + + let (outbound_tx, outbound_rx) = mpsc::channel::(128); + let mut tunnel_packets = client + .tunnel_client + .tunnel_packets(ReceiverStream::new(outbound_rx)) + .await? + .into_inner(); + + let ingress_task = tokio::spawn(async move { + while let Some(packet) = tunnel_packets.message().await? { + stack_sink + .send(packet.payload) + .await + .context("failed to feed inbound DNS probe packet into userspace stack")?; + } + Result::<()>::Ok(()) + }); + let egress_task = tokio::spawn(async move { + while let Some(packet) = stack_stream.next().await { + let payload = packet.context("failed to read outbound DNS probe packet")?; + outbound_tx + .send(TunnelPacket { payload }) + .await + .context("failed to forward outbound DNS probe packet to daemon")?; + } + Result::<()>::Ok(()) + }); + let runner_task = tokio::spawn(async move { runner.await.map_err(anyhow::Error::from) }); + + udp_writer + .send((query, local_addr, dns_server_addr)) + .await + .context("failed to send DNS probe query")?; + + let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next()) + .await + .with_context(|| format!("timed out waiting for DNS response from {dns_server_addr}"))? + .context("userspace DNS probe stack ended before returning a reply")?; + let (payload, reply_source, _reply_destination) = response; + + ingress_task.abort(); + egress_task.abort(); + runner_task.abort(); + + if reply_source != dns_server_addr { + bail!("received DNS response from unexpected source {reply_source}"); + } + for ip in proxy_runtime::parse_dns_response(&payload, query_id)? { + if let IpAddr::V4(ip) = ip { + return Ok(ip); + } + } + bail!("DNS response for {dns_name} did not include an A record") +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_proxy_tcp_probe( + remote: &str, + host: &str, + dns_name: Option<&str>, + dns_server: &str, + timeout_ms: u64, +) -> Result<()> { + use std::net::{IpAddr, SocketAddr}; + + use anyhow::{bail, Context}; + use rand::Rng; + use tokio::{ + sync::mpsc, + time::{timeout, Duration}, + }; + use tokio_stream::wrappers::ReceiverStream; + + use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; + + let mut remote_addr: SocketAddr = remote + .parse() + .with_context(|| format!("invalid remote socket address {remote}"))?; + let mut http_host = host.to_owned(); + if let Some(dns_name) = dns_name { + let resolved_ip = proxy_dns_probe(dns_name, dns_server, timeout_ms).await?; + remote_addr = SocketAddr::new(IpAddr::V4(resolved_ip), remote_addr.port()); + if host == "one.one.one.one" { + http_host = dns_name.to_owned(); + } + println!("Proxy TCP Probe DNS Name: {dns_name}"); + println!("Proxy TCP Probe DNS Resolved: {resolved_ip}"); + } + let remote_ip = match remote_addr.ip() { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => bail!("proxy tcp probe currently supports IPv4 targets only"), + }; + + let mut client = BurrowClient::from_uds().await?; + client.tunnel_client.tunnel_start(Empty {}).await?; + + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let config = config_stream + .message() + .await? + .context("tunnel configuration stream ended before yielding a config")?; + let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?; + let local_ip = match local_addr.ip() { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => bail!("proxy tcp probe selected an IPv6 local address"), + }; + let local_port = local_addr.port(); + + let (outbound_tx, outbound_rx) = mpsc::channel::(128); + let mut tunnel_packets = client + .tunnel_client + .tunnel_packets(ReceiverStream::new(outbound_rx)) + .await? + .into_inner(); + + let initial_seq = rand::thread_rng().gen::(); + let syn = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + initial_seq, + 0, + TCP_SYN, + &[], + ); + outbound_tx + .send(TunnelPacket { payload: syn }) + .await + .context("failed to send proxy tcp SYN into daemon packet stream")?; + + let syn_ack = timeout(Duration::from_millis(timeout_ms), async { + loop { + let packet = tunnel_packets + .message() + .await + .context("failed to read packet from daemon packet stream")? + .context("daemon packet stream ended before SYN-ACK")?; + if let Some(segment) = parse_tcp_ipv4_segment( + &packet.payload, + local_ip, + remote_ip, + local_port, + remote_addr.port(), + )? { + if segment.flags & TCP_SYN != 0 && segment.flags & TCP_ACK != 0 { + break Ok::<_, anyhow::Error>(segment); + } + if segment.flags & TCP_RST != 0 { + bail!("proxy tcp probe received RST before SYN-ACK"); + } + } + } + }) + .await + .with_context(|| format!("timed out waiting for proxy tcp SYN-ACK from {remote_addr}"))??; + + let mut client_seq = initial_seq.wrapping_add(1); + let mut peer_ack = syn_ack.sequence.wrapping_add(1); + let ack = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + client_seq, + peer_ack, + TCP_ACK, + &[], + ); + outbound_tx + .send(TunnelPacket { payload: ack }) + .await + .context("failed to send proxy tcp handshake ACK")?; + + let request = format!( + "GET / HTTP/1.1\r\nHost: {http_host}\r\nConnection: close\r\nUser-Agent: burrow-proxy-tcp-probe\r\n\r\n" + ) + .into_bytes(); + let request_packet = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + client_seq, + peer_ack, + TCP_PSH | TCP_ACK, + &request, + ); + outbound_tx + .send(TunnelPacket { payload: request_packet }) + .await + .context("failed to send proxy tcp HTTP request")?; + client_seq = client_seq.wrapping_add(request.len() as u32); + + let response = timeout(Duration::from_millis(timeout_ms), async { + loop { + let packet = tunnel_packets + .message() + .await + .context("failed to read packet from daemon packet stream")? + .context("daemon packet stream ended before HTTP response")?; + let Some(segment) = parse_tcp_ipv4_segment( + &packet.payload, + local_ip, + remote_ip, + local_port, + remote_addr.port(), + )? + else { + continue; + }; + + if segment.flags & TCP_RST != 0 { + bail!("proxy tcp probe received RST before HTTP response"); + } + if !segment.payload.is_empty() { + peer_ack = segment.sequence.wrapping_add(segment.payload.len() as u32); + let ack = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + client_seq, + peer_ack, + TCP_ACK, + &[], + ); + outbound_tx + .send(TunnelPacket { payload: ack }) + .await + .context("failed to ACK proxy tcp response")?; + break Ok::<_, anyhow::Error>(segment.payload); + } + if segment.flags & TCP_FIN != 0 { + bail!("proxy tcp probe received FIN before HTTP response bytes"); + } + } + }) + .await + .with_context(|| { + format!("timed out waiting for HTTP response through proxy to {remote_addr}") + })??; + + let first_line = String::from_utf8_lossy(&response) + .lines() + .next() + .unwrap_or("") + .to_owned(); + println!("Proxy TCP Probe Local: {local_addr}"); + println!("Proxy TCP Probe Remote: {remote_addr}"); + println!("Proxy TCP Probe Response Bytes: {}", response.len()); + println!("Proxy TCP Probe First Line: {first_line}"); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn open_proxy_packet_tcp_stream( + remote_addr: std::net::SocketAddr, + timeout_ms: u64, +) -> Result<(tokio::io::DuplexStream, std::net::SocketAddr)> { + use std::net::IpAddr; + + use anyhow::{bail, Context}; + use rand::Rng; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::mpsc, + time::{timeout, Duration}, + }; + use tokio_stream::wrappers::ReceiverStream; + + use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket}; + + let remote_ip = match remote_addr.ip() { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => bail!("proxy packet tcp stream currently supports IPv4 targets only"), + }; + + let mut client = BurrowClient::from_uds().await?; + client.tunnel_client.tunnel_start(Empty {}).await?; + + let mut config_stream = client + .tunnel_client + .tunnel_configuration(Empty {}) + .await? + .into_inner(); + let config = config_stream + .message() + .await? + .context("tunnel configuration stream ended before yielding a config")?; + let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?; + let local_ip = match local_addr.ip() { + IpAddr::V4(ip) => ip, + IpAddr::V6(_) => bail!("proxy packet tcp stream selected an IPv6 local address"), + }; + let local_port = local_addr.port(); + + let (outbound_tx, outbound_rx) = mpsc::channel::(128); + let mut tunnel_packets = client + .tunnel_client + .tunnel_packets(ReceiverStream::new(outbound_rx)) + .await? + .into_inner(); + + let initial_seq = rand::thread_rng().gen::(); + let syn = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + initial_seq, + 0, + TCP_SYN, + &[], + ); + outbound_tx + .send(TunnelPacket { payload: syn }) + .await + .context("failed to send proxy packet TCP SYN into daemon packet stream")?; + + let syn_ack = timeout(Duration::from_millis(timeout_ms), async { + loop { + let packet = tunnel_packets + .message() + .await + .context("failed to read packet from daemon packet stream")? + .context("daemon packet stream ended before SYN-ACK")?; + if let Some(segment) = parse_tcp_ipv4_segment( + &packet.payload, + local_ip, + remote_ip, + local_port, + remote_addr.port(), + )? { + if segment.flags & TCP_SYN != 0 && segment.flags & TCP_ACK != 0 { + break Ok::<_, anyhow::Error>(segment); + } + if segment.flags & TCP_RST != 0 { + bail!("proxy packet TCP stream received RST before SYN-ACK"); + } + } + } + }) + .await + .with_context(|| format!("timed out waiting for proxy TCP SYN-ACK from {remote_addr}"))??; + + let mut client_seq = initial_seq.wrapping_add(1); + let mut peer_ack = syn_ack.sequence.wrapping_add(1); + let ack = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + client_seq, + peer_ack, + TCP_ACK, + &[], + ); + outbound_tx + .send(TunnelPacket { payload: ack }) + .await + .context("failed to send proxy packet TCP handshake ACK")?; + + let (client_stream, manager_stream) = tokio::io::duplex(128 * 1024); + let (mut app_reader, mut app_writer) = tokio::io::split(manager_stream); + tokio::spawn(async move { + let mut app_buf = vec![0u8; 8192]; + loop { + tokio::select! { + read = app_reader.read(&mut app_buf) => { + let read = match read { + Ok(read) => read, + Err(error) => { + ::tracing::debug!(%error, "proxy packet TCP stream app read failed"); + break; + } + }; + if read == 0 { + let fin = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + client_seq, + peer_ack, + TCP_FIN | TCP_ACK, + &[], + ); + let _ = outbound_tx.send(TunnelPacket { payload: fin }).await; + break; + } + + for chunk in app_buf[..read].chunks(1200) { + let packet = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + client_seq, + peer_ack, + TCP_PSH | TCP_ACK, + chunk, + ); + if let Err(error) = outbound_tx.send(TunnelPacket { payload: packet }).await { + ::tracing::debug!(%error, "proxy packet TCP stream outbound send failed"); + return; + } + client_seq = client_seq.wrapping_add(chunk.len() as u32); + } + } + packet = tunnel_packets.message() => { + let packet = match packet { + Ok(Some(packet)) => packet, + Ok(None) => break, + Err(error) => { + ::tracing::debug!(%error, "proxy packet TCP stream inbound read failed"); + break; + } + }; + let segment = match parse_tcp_ipv4_segment( + &packet.payload, + local_ip, + remote_ip, + local_port, + remote_addr.port(), + ) { + Ok(Some(segment)) => segment, + Ok(None) => continue, + Err(error) => { + ::tracing::debug!(%error, "proxy packet TCP stream segment parse failed"); + break; + } + }; + if segment.flags & TCP_RST != 0 { + ::tracing::debug!("proxy packet TCP stream received RST"); + break; + } + + let mut ack_increment = segment.payload.len() as u32; + if segment.flags & TCP_FIN != 0 { + ack_increment = ack_increment.wrapping_add(1); + } + if ack_increment > 0 { + peer_ack = segment.sequence.wrapping_add(ack_increment); + let ack = build_tcp_ipv4_packet( + local_ip, + remote_ip, + local_port, + remote_addr.port(), + client_seq, + peer_ack, + TCP_ACK, + &[], + ); + if let Err(error) = outbound_tx.send(TunnelPacket { payload: ack }).await { + ::tracing::debug!(%error, "proxy packet TCP stream ACK send failed"); + return; + } + } + + if !segment.payload.is_empty() { + if let Err(error) = app_writer.write_all(&segment.payload).await { + ::tracing::debug!(%error, "proxy packet TCP stream app write failed"); + break; + } + } + if segment.flags & TCP_FIN != 0 { + let _ = app_writer.shutdown().await; + break; + } + } + } + } + }); + + Ok((client_stream, local_addr)) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_proxy_https_probe( + remote: &str, + host: Option<&str>, + dns_name: Option<&str>, + dns_server: &str, + timeout_ms: u64, +) -> Result<()> { + use std::{ + net::{IpAddr, SocketAddr}, + sync::{Arc, Once}, + }; + + use anyhow::{bail, Context}; + use rustls::{pki_types::ServerName, ClientConfig, RootCertStore}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + time::{timeout, Duration}, + }; + use tokio_rustls::TlsConnector; + + static INSTALL_RUSTLS_PROVIDER: Once = Once::new(); + INSTALL_RUSTLS_PROVIDER.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); + + let mut remote_addr: SocketAddr = remote + .parse() + .with_context(|| format!("invalid remote socket address {remote}"))?; + let tls_host = host + .or(dns_name) + .context("proxy HTTPS probe requires --host or --dns-name for TLS SNI")? + .to_owned(); + if let Some(dns_name) = dns_name { + let resolved_ip = proxy_dns_probe(dns_name, dns_server, timeout_ms).await?; + remote_addr = SocketAddr::new(IpAddr::V4(resolved_ip), remote_addr.port()); + println!("Proxy HTTPS Probe DNS Name: {dns_name}"); + println!("Proxy HTTPS Probe DNS Resolved: {resolved_ip}"); + } + + if !remote_addr.is_ipv4() { + bail!("proxy HTTPS probe currently supports IPv4 targets only"); + } + + let mut root_store = RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let mut tls_config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + tls_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + let connector = TlsConnector::from(Arc::new(tls_config)); + let server_name = ServerName::try_from(tls_host.clone()) + .with_context(|| format!("invalid TLS server name {tls_host}"))?; + + let (stream, local_addr) = open_proxy_packet_tcp_stream(remote_addr, timeout_ms).await?; + let mut tls_stream = timeout( + Duration::from_millis(timeout_ms), + connector.connect(server_name, stream), + ) + .await + .with_context(|| format!("timed out waiting for TLS handshake with {tls_host}"))? + .with_context(|| format!("TLS handshake with {tls_host} failed"))?; + + let request = format!( + "GET / HTTP/1.1\r\nHost: {tls_host}\r\nConnection: close\r\nUser-Agent: burrow-proxy-https-probe\r\n\r\n" + ); + timeout( + Duration::from_millis(timeout_ms), + tls_stream.write_all(request.as_bytes()), + ) + .await + .with_context(|| format!("timed out sending HTTPS request to {tls_host}"))? + .with_context(|| format!("failed to send HTTPS request to {tls_host}"))?; + + let response = timeout(Duration::from_millis(timeout_ms), async { + let mut response = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let read = tls_stream + .read(&mut buf) + .await + .context("failed to read HTTPS response")?; + if read == 0 { + break; + } + response.extend_from_slice(&buf[..read]); + if response.iter().any(|byte| *byte == b'\n') { + break; + } + } + if response.is_empty() { + bail!("HTTPS response ended before returning bytes"); + } + Ok::<_, anyhow::Error>(response) + }) + .await + .with_context(|| format!("timed out waiting for HTTPS response from {tls_host}"))??; + + let first_line = String::from_utf8_lossy(&response) + .lines() + .next() + .unwrap_or("") + .to_owned(); + println!("Proxy HTTPS Probe Local: {local_addr}"); + println!("Proxy HTTPS Probe Remote: {remote_addr}"); + println!("Proxy HTTPS Probe Host: {tls_host}"); + println!("Proxy HTTPS Probe Response Bytes: {}", response.len()); + println!("Proxy HTTPS Probe First Line: {first_line}"); + Ok(()) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +async fn try_proxy_subscription_select(id: i32, selected_ordinal: i32) -> Result<()> { + use crate::daemon::rpc::grpc_defs::ProxySubscriptionSelectRequest; + + let mut client = BurrowClient::from_uds().await?; + let response = client + .proxy_subscription_client + .select_node(ProxySubscriptionSelectRequest { id, selected_ordinal }) + .await? + .into_inner(); + println!( + "Proxy Subscription Selected: id={} selected_ordinal={}", + response.id, response.selected_ordinal + ); Ok(()) } @@ -547,6 +1320,24 @@ struct IcmpEchoReply { payload: Vec, } +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +const TCP_FIN: u16 = 0x01; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +const TCP_SYN: u16 = 0x02; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +const TCP_RST: u16 = 0x04; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +const TCP_PSH: u16 = 0x08; +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +const TCP_ACK: u16 = 0x10; + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +struct TcpSegment { + sequence: u32, + flags: u16, + payload: Vec, +} + #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn build_icmp_echo_request( source: std::net::IpAddr, @@ -643,6 +1434,113 @@ fn parse_icmp_echo_reply( })) } +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn build_tcp_ipv4_packet( + source: std::net::Ipv4Addr, + destination: std::net::Ipv4Addr, + source_port: u16, + destination_port: u16, + sequence: u32, + ack: u32, + flags: u16, + payload: &[u8], +) -> Vec { + let tcp_len = 20 + payload.len(); + let total_len = 20 + tcp_len; + let mut packet = Vec::with_capacity(total_len); + packet.push(0x45); + packet.push(0); + packet.extend_from_slice(&(total_len as u16).to_be_bytes()); + packet.extend_from_slice(&0u16.to_be_bytes()); + packet.extend_from_slice(&0x4000u16.to_be_bytes()); + packet.push(64); + packet.push(6); + packet.extend_from_slice(&[0, 0]); + packet.extend_from_slice(&source.octets()); + packet.extend_from_slice(&destination.octets()); + let header_checksum = internet_checksum(&packet); + packet[10..12].copy_from_slice(&header_checksum.to_be_bytes()); + + let tcp_start = packet.len(); + packet.extend_from_slice(&source_port.to_be_bytes()); + packet.extend_from_slice(&destination_port.to_be_bytes()); + packet.extend_from_slice(&sequence.to_be_bytes()); + packet.extend_from_slice(&ack.to_be_bytes()); + packet.push(5 << 4); + packet.push((flags & 0xff) as u8); + packet.extend_from_slice(&64240u16.to_be_bytes()); + packet.extend_from_slice(&[0, 0]); + packet.extend_from_slice(&0u16.to_be_bytes()); + packet.extend_from_slice(payload); + + let checksum = tcp_ipv4_checksum(source, destination, &packet[tcp_start..]); + packet[tcp_start + 16..tcp_start + 18].copy_from_slice(&checksum.to_be_bytes()); + packet +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn parse_tcp_ipv4_segment( + packet: &[u8], + local_ip: std::net::Ipv4Addr, + remote_ip: std::net::Ipv4Addr, + local_port: u16, + remote_port: u16, +) -> Result> { + use anyhow::bail; + + if packet.len() < 40 { + return Ok(None); + } + let version = packet[0] >> 4; + if version != 4 { + return Ok(None); + } + let ihl = (packet[0] & 0x0f) as usize * 4; + if packet.len() < ihl + 20 || packet[9] != 6 { + return Ok(None); + } + + let source = std::net::Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]); + let destination = std::net::Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]); + if source != remote_ip || destination != local_ip { + return Ok(None); + } + + let tcp = &packet[ihl..]; + let source_port = u16::from_be_bytes([tcp[0], tcp[1]]); + let destination_port = u16::from_be_bytes([tcp[2], tcp[3]]); + if source_port != remote_port || destination_port != local_port { + return Ok(None); + } + let data_offset = ((tcp[12] >> 4) as usize) * 4; + if data_offset < 20 || tcp.len() < data_offset { + bail!("invalid TCP data offset in proxy tcp probe response"); + } + let sequence = u32::from_be_bytes([tcp[4], tcp[5], tcp[6], tcp[7]]); + let flags = (tcp[13] as u16) & 0x3f; + Ok(Some(TcpSegment { + sequence, + flags, + payload: tcp[data_offset..].to_vec(), + })) +} + +#[cfg(any(target_os = "linux", target_vendor = "apple"))] +fn tcp_ipv4_checksum( + source: std::net::Ipv4Addr, + destination: std::net::Ipv4Addr, + tcp_segment: &[u8], +) -> u16 { + let mut pseudo = Vec::with_capacity(12 + tcp_segment.len()); + pseudo.extend_from_slice(&source.octets()); + pseudo.extend_from_slice(&destination.octets()); + pseudo.push(0); + pseudo.push(6); + pseudo.extend_from_slice(&(tcp_segment.len() as u16).to_be_bytes()); + pseudo.extend_from_slice(tcp_segment); + internet_checksum(&pseudo) +} + #[cfg(any(target_os = "linux", target_vendor = "apple"))] fn internet_checksum(bytes: &[u8]) -> u16 { let mut sum = 0u32; @@ -775,6 +1673,35 @@ async fn main() -> Result<()> { Commands::TailnetUdpEcho(args) => { try_tailnet_udp_echo(&args.remote, &args.message, args.timeout_ms).await? } + Commands::ProxyTcpProbe(args) => { + try_proxy_tcp_probe( + &args.remote, + &args.host, + args.dns_name.as_deref(), + &args.dns_server, + args.timeout_ms, + ) + .await? + } + Commands::ProxyUdpEcho(args) => { + try_proxy_udp_echo(&args.remote, &args.message, args.timeout_ms).await? + } + Commands::ProxyHttpsProbe(args) => { + try_proxy_https_probe( + &args.remote, + args.host.as_deref(), + args.dns_name.as_deref(), + &args.dns_server, + args.timeout_ms, + ) + .await? + } + Commands::ProxySubscriptionSelect(args) => { + try_proxy_subscription_select(args.id, args.selected_ordinal).await? + } + Commands::ProxySubscriptionPreview(args) => { + try_proxy_subscription_preview(&args.url).await? + } #[cfg(target_os = "linux")] Commands::Exec(args) => { try_exec( diff --git a/burrow/src/proxy_runtime.rs b/burrow/src/proxy_runtime.rs new file mode 100644 index 0000000..7eacdda --- /dev/null +++ b/burrow/src/proxy_runtime.rs @@ -0,0 +1,8346 @@ +#[cfg(target_vendor = "apple")] +use std::{cmp::Reverse, ffi::CStr, process::Command}; +use std::{ + collections::{HashMap, VecDeque}, + fs, + future::Future, + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs}, + num::NonZeroU32, + os::fd::AsRawFd, + pin::Pin, + str::FromStr, + sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex as StdMutex, Once, + }, + task::{Context as TaskContext, Poll}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use aead::{ + generic_array::{ + typenum::{U12, U16, U24}, + GenericArray, + }, + AeadInPlace, KeyInit, +}; +use aegis::{aegis128l::Aegis128L, aegis256::Aegis256}; +use aes::{ + cipher::{generic_array::GenericArray as AesGenericArray, BlockDecrypt, BlockEncrypt}, + Aes128, Aes192, Aes256, +}; +use aes_gcm::AesGcm; +use anyhow::{anyhow, bail, Context, Result}; +use base64::{ + engine::general_purpose::{ + STANDARD as BASE64_STANDARD, URL_SAFE as BASE64_URL_SAFE, + URL_SAFE_NO_PAD as BASE64_URL_SAFE_NO_PAD, + }, + Engine as _, +}; +use bytes::Bytes; +use ccm::Ccm; +use chacha20::{ + cipher::{KeyIvInit as ChachaKeyIvInit, StreamCipher as ChachaStreamCipher}, + ChaCha20Legacy, XChaCha20, +}; +use chacha20poly1305::{ChaCha8Poly1305, XChaCha8Poly1305}; +use deoxys::{ + aead::{array::Array as DeoxysArray, AeadInOut as DeoxysAeadInOut, KeyInit as DeoxysKeyInit}, + DeoxysII256, +}; +use futures::{SinkExt, StreamExt}; +use http::{Method, Request, StatusCode}; +use lea::{ + cipher::{ + generic_array::GenericArray as LeaGenericArray, BlockEncrypt as LeaBlockEncrypt, + NewBlockCipher as LeaNewBlockCipher, + }, + Lea128, Lea192, Lea256, +}; +use md5::Md5; +#[cfg(target_vendor = "apple")] +use native_tls::{Identity as NativeTlsIdentity, TlsConnector as NativeTlsConnector}; +use netstack_smoltcp::{ + StackBuilder, TcpListener as StackTcpListener, TcpStream as StackTcpStream, + UdpSocket as StackUdpSocket, +}; +use poly1305::{universal_hash::UniversalHash, Poly1305}; +use rabbit::{ + cipher::{KeyIvInit as RabbitKeyIvInit, StreamCipher as RabbitStreamCipher}, + Rabbit, +}; +#[cfg(feature = "boring-browser-fingerprints")] +use rama_net::{ + address::Domain as RamaDomain, + tls::{ + client::{ + ClientHelloExtension as RamaClientHelloExtension, + ServerVerifyMode as RamaServerVerifyMode, + }, + SupportedGroup as RamaSupportedGroup, + }, +}; +#[cfg(feature = "boring-browser-fingerprints")] +use rama_tls_boring::{ + client::{ + ConnectorConfigClientAuth as BoringConnectorConfigClientAuth, + TlsConnectorDataBuilder as BoringTlsConnectorDataBuilder, + }, + core::{ + hash::MessageDigest as BoringMessageDigest, + pkey::PKey as BoringPKey, + x509::{X509Ref as BoringX509Ref, X509 as BoringX509}, + }, +}; +#[cfg(feature = "boring-browser-fingerprints")] +use rama_ua::{ + profile::{try_load_embedded_profiles, TlsProfile}, + PlatformKind, UserAgentKind, +}; +use rand::{rngs::OsRng, Rng, RngCore}; +use ring::{hmac, pbkdf2}; +use rust_tokio_kcp::{ + Aes128BlockCrypt as KcpAes128BlockCrypt, Aes192BlockCrypt as KcpAes192BlockCrypt, + Aes256BlockCrypt as KcpAes256BlockCrypt, AesGcmBlockCrypt as KcpAesGcmBlockCrypt, + BlockCrypt as KcpBlockCrypt, BlowfishBlockCrypt as KcpBlowfishBlockCrypt, + Cast5BlockCrypt as KcpCast5BlockCrypt, KcpConfig, KcpNoDelayConfig, KcpStream, + NoneBlockCrypt as KcpNoneBlockCrypt, Salsa20BlockCrypt as KcpSalsa20BlockCrypt, + SimpleXorBlockCrypt as KcpSimpleXorBlockCrypt, Sm4BlockCrypt as KcpSm4BlockCrypt, + TeaBlockCrypt as KcpTeaBlockCrypt, TripleDesBlockCrypt as KcpTripleDesBlockCrypt, + TwofishBlockCrypt as KcpTwofishBlockCrypt, XteaBlockCrypt as KcpXteaBlockCrypt, +}; +use rustls::{ + client::{ + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + EchConfig, EchMode, + }, + pki_types::{CertificateDer, EchConfigListBytes, PrivateKeyDer, ServerName, UnixTime}, + version, ClientConfig, DigitallySignedStruct, Error as RustlsError, RootCertStore, + SignatureScheme, +}; +use sha2::{Digest, Sha224, Sha256}; +use shadowsocks::{ + config::{ + ServerAddr as SsServerAddr, ServerConfig as SsServerConfig, ServerType as SsServerType, + }, + context::{Context as SsContext, SharedContext as SsSharedContext}, + crypto::CipherKind as SsCipherKind, + relay::{ + socks5::Address as SsAddress, + udprelay::{ + proxy_socket::UdpSocketType as SsUdpSocketType, DatagramReceive, DatagramSend, + DatagramSocket, ProxySocket as SsProxySocket, + }, + }, + ProxyClientStream as SsProxyClientStream, +}; +use subtle::ConstantTimeEq; +use tokio::{ + io::{split, AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}, + net::{TcpSocket, TcpStream, UdpSocket}, + sync::{broadcast, mpsc, oneshot, Mutex, RwLock}, + task::{JoinHandle, JoinSet}, + time::timeout, +}; +#[cfg(target_vendor = "apple")] +use tokio_native_tls::TlsConnector as TokioNativeTlsConnector; +use tokio_rustls::TlsConnector; +use tokio_snappy::SnappyIO; +use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; +use tun::tokio::TunInterface; + +use crate::proxy_subscription::{ + ProxyNode, ProxyNodeConfig, ProxySubscriptionPayload, ShadowsocksNode, ShadowsocksPlugin, + ShadowsocksSmux, TrojanNetwork, TrojanNode, +}; + +const PROXY_TUN_V4: &str = "100.64.0.2/24"; +const PROXY_TUN_V6: &str = "fd00:64::2/64"; +const PROXY_DNS_V4: &str = "100.64.0.2"; +const UDP_IDLE_TIMEOUT: Duration = Duration::from_secs(30); +const TROJAN_MAX_UDP_PAYLOAD: usize = 8192; +const MIHOMO_TROJAN_TCP_DEFAULT_ALPN: &[&str] = &["h2", "http/1.1"]; +const SS_INFO: &[u8] = b"ss-subkey"; +const UOT_VERSION: u8 = 2; +#[allow(dead_code)] +const TLS_RECORD_HEADER_LEN: usize = 5; +#[allow(dead_code)] +const TLS_APPLICATION_DATA_RECORD_TYPE: u8 = 0x17; +#[allow(dead_code)] +const TLS12_RECORD_VERSION: [u8; 2] = [0x03, 0x03]; +const UOT_LEGACY_VERSION: u8 = 1; +const UOT_MAGIC_ADDRESS: &str = "sp.v2.udp-over-tcp.arpa"; +const UOT_LEGACY_MAGIC_ADDRESS: &str = "sp.udp-over-tcp.arpa"; +const SING_MUX_ADDRESS: &str = "sp.mux.sing-box.arpa"; +const SING_MUX_PORT: u16 = 444; +const SING_MUX_PROTOCOL_SMUX: u8 = 0; +const SING_MUX_PROTOCOL_YAMUX: u8 = 1; +const SING_MUX_PROTOCOL_H2MUX: u8 = 2; +const SING_MUX_VERSION_0: u8 = 0; +const SING_MUX_VERSION_1: u8 = 1; +const SING_MUX_PADDING_FRAMES: usize = 16; +const SING_MUX_MIN_PADDING_LEN: usize = 256; +const SING_MUX_PADDING_LEN_RANGE: usize = 512; +const SING_MUX_STREAM_FLAG_UDP: u16 = 1; +const SING_MUX_STREAM_STATUS_SUCCESS: u8 = 0; +const SING_MUX_STREAM_STATUS_ERROR: u8 = 1; +const SING_MUX_BRUTAL_EXCHANGE_DOMAIN: &str = "_BrutalBwExchange"; +const AEAD2022_SESSION_SUBKEY_CONTEXT: &str = "shadowsocks 2022 session subkey"; +const AEAD2022_IDENTITY_SUBKEY_CONTEXT: &str = "shadowsocks 2022 identity subkey"; +const AEAD2022_HEADER_TYPE_CLIENT: u8 = 0; +const AEAD2022_HEADER_TYPE_SERVER: u8 = 1; +const AEAD2022_TIMESTAMP_MAX_DIFF: u64 = 30; +const PUBLIC_DNS_SERVERS: &[&str] = &["1.1.1.1", "8.8.8.8"]; +const SHADOW_TLS_MAX_RECORD_PAYLOAD: usize = 16 * 1024; +const SHADOW_TLS_V3_HMAC_SIZE: usize = 4; +const SHADOW_TLS_V3_TLS_HEADER_SIZE: usize = 5; +const SHADOW_TLS_V3_TLS_RANDOM_SIZE: usize = 32; +const SHADOW_TLS_V3_SESSION_ID_SIZE: usize = 32; +const SHADOW_TLS_V3_SESSION_ID_START: usize = 1 + 3 + 2 + SHADOW_TLS_V3_TLS_RANDOM_SIZE + 1; +const SHADOW_TLS_V3_SERVER_RANDOM_INDEX: usize = SHADOW_TLS_V3_TLS_HEADER_SIZE + 1 + 3 + 2; +const SHADOW_TLS_V3_SESSION_ID_LENGTH_INDEX: usize = + SHADOW_TLS_V3_SERVER_RANDOM_INDEX + SHADOW_TLS_V3_TLS_RANDOM_SIZE; +const SHADOW_TLS_V3_HMAC_HEADER_SIZE: usize = + SHADOW_TLS_V3_TLS_HEADER_SIZE + SHADOW_TLS_V3_HMAC_SIZE; +const KCPTUN_RATE_LIMIT_BURST_BYTES: u64 = 64 * 1500; +const KCPTUN_SMUX_DEFAULT_KEEP_ALIVE_TIMEOUT: Duration = Duration::from_secs(30); +const RESTLS_DEFAULT_SCRIPT: &str = "250?100<1,350~100<1,600~100,300~200,300~100"; +#[allow(dead_code)] +const RESTLS_APP_DATA_MAC_LEN: usize = 8; +#[allow(dead_code)] +const RESTLS_CMD_LEN: usize = 2; +#[allow(dead_code)] +const RESTLS_MASK_LEN: usize = RESTLS_CMD_LEN + 2; +#[allow(dead_code)] +const RESTLS_APP_DATA_AUTH_HEADER_LEN: usize = RESTLS_APP_DATA_MAC_LEN + RESTLS_MASK_LEN; +#[allow(dead_code)] +const RESTLS_APP_DATA_OFFSET: usize = RESTLS_APP_DATA_AUTH_HEADER_LEN; +#[allow(dead_code)] +const RESTLS_APP_DATA_LEN_OFFSET: usize = RESTLS_APP_DATA_MAC_LEN; +#[allow(dead_code)] +const RESTLS_MAX_PLAINTEXT: usize = 16_384; +#[allow(dead_code)] +const RESTLS_RANDOM_RESPONSE_MAGIC: &[u8] = b"restls-random-response"; +#[allow(dead_code)] +const RESTLS_HANDSHAKE_MAC_LEN: usize = 16; +#[allow(dead_code)] +const RESTLS_TLS12_CLIENT_AUTH_LAYOUT_3: [usize; 4] = [0, 11, 22, 32]; +#[allow(dead_code)] +const RESTLS_TLS12_CLIENT_AUTH_LAYOUT_4: [usize; 5] = [0, 8, 16, 24, 32]; +const DIRECT_DNS_TIMEOUT: Duration = Duration::from_secs(2); +const DNS_TYPE_HTTPS: u16 = 65; +const HTTPS_SVC_PARAM_ECH: u16 = 5; +const FAKE_DNS_BASE: u32 = u32::from_be_bytes([198, 18, 64, 1]); + +type Aes192Gcm = AesGcm; +type Aes128Ccm = Ccm; +type Aes192Ccm = Ccm; +type Aes256Ccm = Ccm; + +pub struct ProxyRuntimeConfig { + pub selected_name: String, + pub outbound: ProxyOutbound, +} + +pub struct ProxyPacketBridge { + outbound: mpsc::Sender>, + inbound: broadcast::Sender>, + proxy_outbound: Arc>, + task: JoinHandle>, +} + +impl ProxyPacketBridge { + pub fn outbound_sender(&self) -> mpsc::Sender> { + self.outbound.clone() + } + + pub fn subscribe(&self) -> broadcast::Receiver> { + self.inbound.subscribe() + } + + pub async fn set_proxy_outbound(&self, outbound: ProxyOutbound) { + *self.proxy_outbound.write().await = outbound; + } + + pub fn abort(&self) { + self.task.abort(); + } + + pub async fn join(self) -> Result<()> { + match self.task.await { + Ok(Ok(())) => Ok(()), + Ok(Err(err)) => Err(err), + Err(err) if err.is_cancelled() => Ok(()), + Err(err) => Err(err.into()), + } + } +} + +#[derive(Clone)] +pub enum ProxyOutbound { + Trojan(TrojanOutbound), + Shadowsocks(ShadowsocksOutbound), +} + +type DnsHostnameCache = Arc>; + +#[derive(Debug)] +struct DnsHostnameCacheState { + reverse: HashMap, + fake_by_hostname: HashMap, + next_fake_offset: u32, +} + +impl DnsHostnameCacheState { + fn new() -> Self { + Self { + reverse: HashMap::new(), + fake_by_hostname: HashMap::new(), + next_fake_offset: 0, + } + } + + fn lookup_hostname(&self, ip: IpAddr) -> Option { + self.reverse.get(&ip).cloned() + } + + fn insert_mapping(&mut self, ip: IpAddr, hostname: String) { + self.reverse.insert(ip, hostname); + } + + fn fake_ip_for_hostname(&mut self, hostname: &str) -> Ipv4Addr { + if let Some(ip) = self.fake_by_hostname.get(hostname) { + return *ip; + } + + let raw = FAKE_DNS_BASE.wrapping_add(self.next_fake_offset); + self.next_fake_offset = self.next_fake_offset.wrapping_add(1); + let ip = Ipv4Addr::from(raw); + self.fake_by_hostname.insert(hostname.to_owned(), ip); + self.reverse.insert(IpAddr::V4(ip), hostname.to_owned()); + ip + } +} + +#[derive(Debug, Clone)] +struct ProxyTcpTarget { + address: SocketAddr, + hostname: Option, +} + +impl ProxyOutbound { + async fn bridge_tcp(&self, inbound: StackTcpStream, target: ProxyTcpTarget) -> Result<()> { + match self { + Self::Trojan(outbound) => outbound.bridge_tcp(inbound, target).await, + Self::Shadowsocks(outbound) => outbound.bridge_tcp(inbound, target).await, + } + } + + async fn start_udp_session( + &self, + key: UdpFlowKey, + outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + ) -> Result<()> { + match self { + Self::Trojan(outbound) => outbound.run_udp_session(key, outbound_rx, reply_tx).await, + Self::Shadowsocks(outbound) => { + outbound.run_udp_session(key, outbound_rx, reply_tx).await + } + } + } +} + +#[derive(Clone)] +pub struct TrojanOutbound { + endpoint: ProxyServerEndpoint, + password: String, + sni: String, + alpn: Vec, + skip_cert_verify: bool, + udp_enabled: bool, + certificate_fingerprint: Option, +} + +#[derive(Clone)] +pub struct ShadowsocksOutbound { + endpoint: ProxyServerEndpoint, + protocol: ShadowsocksProtocol, + plugin: Option, + kcptun_pool: Option>, + sing_mux: Option, + sing_mux_sessions: Arc>>, + udp_enabled: bool, + udp_over_tcp: bool, + udp_over_tcp_version: u8, +} + +#[derive(Clone)] +enum ShadowsocksProtocol { + None, + Standard { + server_config: Arc, + context: SsSharedContext, + }, + CustomAead { + kind: CustomSsAeadKind, + master_key: Arc<[u8]>, + }, + CustomStream { + kind: CustomSsStreamKind, + key: Arc<[u8]>, + }, + Aead2022AesCcm { + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ShadowsocksPluginTransport { + SimpleObfs { mode: SimpleObfsMode, host: String }, + WebSocket(ShadowsocksWebSocketTransport), + ShadowTls(ShadowsocksShadowTlsTransport), + Kcptun(ShadowsocksKcptunTransport), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShadowsocksSingMuxTransport { + protocol: ShadowsocksSingMuxProtocol, + max_connections: usize, + min_streams: usize, + max_streams: usize, + padding: bool, + udp: bool, + brutal: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ShadowsocksSingMuxBrutalTransport { + send_bps: u64, + receive_bps: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShadowsocksSingMuxProtocol { + H2Mux, + Smux, + Yamux, +} + +impl ShadowsocksSingMuxProtocol { + fn name(self) -> &'static str { + match self { + Self::H2Mux => "h2mux", + Self::Smux => "smux", + Self::Yamux => "yamux", + } + } + + fn wire_id(self) -> u8 { + match self { + Self::H2Mux => SING_MUX_PROTOCOL_H2MUX, + Self::Smux => SING_MUX_PROTOCOL_SMUX, + Self::Yamux => SING_MUX_PROTOCOL_YAMUX, + } + } +} + +#[derive(Clone)] +enum ShadowsocksSingMuxSession { + H2Mux(ShadowsocksH2MuxSession), + Smux(Arc), + Yamux(ShadowsocksYamuxSession), +} + +#[derive(Clone)] +struct ShadowsocksH2MuxSession { + send_request: h2::client::SendRequest, + active_streams: Arc, + closed: Arc, +} + +#[derive(Clone)] +struct ShadowsocksYamuxSession { + open_tx: mpsc::Sender, + active_streams: Arc, + closed: Arc, +} + +struct YamuxCommand { + response: oneshot::Sender>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SimpleObfsMode { + Http, + Tls, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind, + host: String, + path: String, + headers: Vec<(String, String)>, + tls: bool, + ech: Option, + skip_cert_verify: bool, + certificate_fingerprint: Option, + client_identity: Option, + mux: ShadowsocksWebSocketMux, + v2ray_http_upgrade: bool, + v2ray_http_upgrade_fast_open: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShadowsocksWebSocketKind { + V2ray, + Gost, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShadowsocksWebSocketMux { + None, + V2ray, + Gost, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShadowsocksEchConfig { + config_list: Option>, + query_server_name: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShadowsocksShadowTlsTransport { + host: String, + password: String, + version: u8, + alpn: Vec, + skip_cert_verify: bool, + certificate_fingerprint: Option, + client_identity: Option, + client_fingerprint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TlsClientIdentity { + certificate: String, + private_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShadowsocksKcptunTransport { + key: String, + crypt: String, + mode: String, + conn: usize, + auto_expire: u64, + scavenge_ttl: u64, + mtu: usize, + rate_limit: u32, + sndwnd: u16, + rcvwnd: u16, + data_shard: usize, + parity_shard: usize, + dscp: u32, + no_comp: bool, + ack_nodelay: bool, + no_delay: i32, + interval: i32, + resend: i32, + no_congestion: bool, + sockbuf: usize, + smux_ver: u8, + smux_buf: usize, + frame_size: usize, + stream_buf: usize, + keep_alive: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ShadowsocksRestlsTransport { + host: String, + password: String, + version_hint: RestlsVersionHint, + script: Vec, + client_fingerprint: RestlsClientFingerprint, + session_tickets_disabled: bool, + secret: [u8; 32], +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RestlsVersionHint { + Tls12, + Tls13, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RestlsClientFingerprint { + Chrome, + Firefox, + Safari, + Ios, +} + +impl RestlsClientFingerprint { + #[cfg(feature = "boring-browser-fingerprints")] + fn browser_profile(self) -> MihomoClientFingerprint { + match self { + Self::Chrome => MihomoClientFingerprint::Chrome, + Self::Firefox => MihomoClientFingerprint::Firefox, + Self::Safari => MihomoClientFingerprint::Safari, + Self::Ios => MihomoClientFingerprint::Ios, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MihomoClientFingerprint { + Chrome, + Firefox, + Safari, + Ios, + Android, + Edge, + Browser360, + Qq, + Random, + Chrome120, + Firefox120, + Safari16, + ChromePsk, + ChromePskShuffle, + ChromePaddingPskShuffle, + ChromePq, + ChromePqPsk, + Randomized, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RestlsScriptLine { + target_len: RestlsTargetLength, + command: RestlsCommand, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RestlsTargetLength { + base: u16, + random_range: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RestlsCommand { + Noop, + Response(u16), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +enum RestlsRecordDirection { + ToServer, + ToClient, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RestlsApplicationFrame { + data: Vec, + command: RestlsCommand, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RestlsWriteResult { + record: Vec, + consumed: usize, + command: RestlsCommand, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct RestlsApplicationCodec { + secret: [u8; 32], + server_random: [u8; 32], + to_server_counter: u64, + to_client_counter: u64, + tls12_gcm: bool, + tls12_gcm_to_client_disable_counter: bool, + client_finished_auth: Option>, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +struct RestlsStreamState { + codec: RestlsApplicationCodec, + script: Vec, + send_buffer: Vec, + write_pending: bool, +} + +#[allow(dead_code)] +struct RestlsStream { + inner: S, + state: RestlsStreamState, + read_stage: RestlsRecordReadStage, + read_buf: Vec, + read_offset: usize, + write_buf: Vec, + write_offset: usize, +} + +#[allow(dead_code)] +enum RestlsRecordReadStage { + Header { + bytes: [u8; TLS_RECORD_HEADER_LEN], + filled: usize, + }, + Payload { + header: [u8; TLS_RECORD_HEADER_LEN], + bytes: Vec, + filled: usize, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RestlsStreamWriteResult { + accepted: usize, + records: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RestlsStreamReadResult { + data: Vec, + records: Vec>, + resumed_pending_write: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RestlsServerAuthRecord { + record: Vec, + tls12_gcm_server_disable_counter: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RestlsTls13ClientHelloAuthMaterial { + key_shares: Vec<(u16, Vec)>, + psk_labels: Vec>, +} + +struct KcptunSessionPool { + endpoint: ProxyServerEndpoint, + config: ShadowsocksKcptunTransport, + state: Mutex, +} + +struct KcptunSessionPoolState { + sessions: Vec>, + rr: usize, +} + +struct KcptunTimedSession { + session: Arc, + expiry_at: Option, +} + +impl Default for ShadowsocksKcptunTransport { + fn default() -> Self { + let mut config = Self::empty(); + config.fill_defaults(); + config + } +} + +impl ShadowsocksKcptunTransport { + fn empty() -> Self { + Self { + key: String::new(), + crypt: String::new(), + mode: String::new(), + conn: 0, + auto_expire: 0, + scavenge_ttl: 0, + mtu: 0, + rate_limit: 0, + sndwnd: 0, + rcvwnd: 0, + data_shard: 0, + parity_shard: 0, + dscp: 0, + no_comp: false, + ack_nodelay: false, + no_delay: 0, + interval: 0, + resend: 0, + no_congestion: false, + sockbuf: 0, + smux_ver: 0, + smux_buf: 0, + frame_size: 0, + stream_buf: 0, + keep_alive: 0, + } + } + + fn fill_defaults(&mut self) { + if self.key.is_empty() { + self.key = "it's a secrect".to_owned(); + } + if self.crypt.is_empty() { + self.crypt = "aes".to_owned(); + } + if self.mode.is_empty() { + self.mode = "fast".to_owned(); + } + if self.conn == 0 { + self.conn = 1; + } + if self.scavenge_ttl == 0 { + self.scavenge_ttl = 600; + } + if self.mtu == 0 { + self.mtu = 1350; + } + if self.sndwnd == 0 { + self.sndwnd = 128; + } + if self.rcvwnd == 0 { + self.rcvwnd = 512; + } + if self.data_shard == 0 { + self.data_shard = 10; + } + if self.parity_shard == 0 { + self.parity_shard = 3; + } + if self.interval == 0 { + self.interval = 50; + } + if self.sockbuf == 0 { + self.sockbuf = 4_194_304; + } + if self.smux_ver == 0 { + self.smux_ver = 1; + } + if self.smux_buf == 0 { + self.smux_buf = 4_194_304; + } + if self.frame_size == 0 { + self.frame_size = 8192; + } + if self.stream_buf == 0 { + self.stream_buf = 2_097_152; + } + if self.keep_alive == 0 { + self.keep_alive = 10; + } + match self.mode.trim().to_ascii_lowercase().as_str() { + "normal" => { + self.no_delay = 0; + self.interval = 40; + self.resend = 2; + self.no_congestion = true; + } + "fast" => { + self.no_delay = 0; + self.interval = 30; + self.resend = 2; + self.no_congestion = true; + } + "fast2" => { + self.no_delay = 1; + self.interval = 20; + self.resend = 2; + self.no_congestion = true; + } + "fast3" => { + self.no_delay = 1; + self.interval = 10; + self.resend = 2; + self.no_congestion = true; + } + _ => {} + } + if self.smux_ver > 2 { + self.smux_ver = 2; + } + } +} + +impl KcptunSessionPool { + fn new(endpoint: ProxyServerEndpoint, config: ShadowsocksKcptunTransport) -> Self { + let conn = config.conn.max(1); + Self { + endpoint, + config, + state: Mutex::new(KcptunSessionPoolState { + sessions: std::iter::repeat_with(|| None).take(conn).collect(), + rr: 0, + }), + } + } + + async fn open_stream(&self) -> Result<(Box, SocketAddr)> { + let address = *self + .endpoint + .addresses + .first() + .ok_or_else(|| anyhow!("Shadowsocks kcptun endpoint has no resolved address"))?; + for attempt in 0..2 { + let session = self.session_for_next_slot(address).await?; + match kcptun_open_smux_stream(session.clone()).await { + Ok(stream) => return Ok((stream, address)), + Err(err) if attempt == 0 => { + tracing::debug!( + error = %err, + server = %self.endpoint.host, + port = self.endpoint.port, + "discarding failed Shadowsocks kcptun smux session and retrying" + ); + self.invalidate_session(&session).await; + } + Err(err) => return Err(err), + } + } + unreachable!("kcptun retry loop has fixed attempts") + } + + async fn session_for_next_slot(&self, address: SocketAddr) -> Result> { + let mut state = self.state.lock().await; + if state.sessions.is_empty() { + state.sessions.push(None); + } + let index = state.rr % state.sessions.len(); + state.rr = state.rr.wrapping_add(1); + let now = Instant::now(); + let expired = state.sessions[index] + .as_ref() + .and_then(|session| session.expiry_at) + .is_some_and(|expiry| now >= expiry); + let needs_reconnect = state.sessions[index] + .as_ref() + .map(|session| session.session.is_closed() || expired) + .unwrap_or(true); + if needs_reconnect { + if let Some(previous) = state.sessions[index].take() { + if expired && !previous.session.is_closed() { + schedule_kcptun_session_close(previous.session, self.config.scavenge_ttl); + } + } + let session = kcptun_open_session(&self.endpoint, address, &self.config).await?; + let expiry_at = if self.config.auto_expire == 0 { + None + } else { + Some(Instant::now() + Duration::from_secs(self.config.auto_expire)) + }; + state.sessions[index] = Some(KcptunTimedSession { + session: session.clone(), + expiry_at, + }); + return Ok(session); + } + Ok(state.sessions[index] + .as_ref() + .expect("kcptun session exists when reconnect is not needed") + .session + .clone()) + } + + async fn invalidate_session(&self, session: &Arc) { + let mut state = self.state.lock().await; + let stale = state + .sessions + .iter_mut() + .find(|slot| { + slot.as_ref() + .map(|candidate| Arc::ptr_eq(&candidate.session, session)) + .unwrap_or(false) + }) + .and_then(Option::take); + drop(state); + if let Some(stale) = stale { + let _ = stale.session.close().await; + } + } +} + +#[derive(Clone)] +struct ProxyServerEndpoint { + host: String, + port: u16, + addresses: Arc<[SocketAddr]>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CustomSsAeadKind { + Aes192Gcm, + Aes192Ccm, + Chacha8IetfPoly1305, + XChacha8IetfPoly1305, + Rabbit128Poly1305, + Aegis128L, + Aegis256, + Aez384, + DeoxysII256128, + Ascon128, + Ascon128A, + Lea128Gcm, + Lea192Gcm, + Lea256Gcm, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CustomSsStreamKind { + Chacha20, + XChacha20, +} + +impl CustomSsStreamKind { + fn from_name(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "chacha20" => Some(Self::Chacha20), + "xchacha20" => Some(Self::XChacha20), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::Chacha20 => "chacha20", + Self::XChacha20 => "xchacha20", + } + } + + fn key_len(self) -> usize { + 32 + } + + fn salt_len(self) -> usize { + match self { + Self::Chacha20 => 8, + Self::XChacha20 => 24, + } + } +} + +impl CustomSsAeadKind { + fn from_name(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "aes-192-gcm" => Some(Self::Aes192Gcm), + "aes-192-ccm" => Some(Self::Aes192Ccm), + "chacha8-ietf-poly1305" => Some(Self::Chacha8IetfPoly1305), + "xchacha8-ietf-poly1305" => Some(Self::XChacha8IetfPoly1305), + "rabbit128-poly1305" => Some(Self::Rabbit128Poly1305), + "aegis-128l" => Some(Self::Aegis128L), + "aegis-256" => Some(Self::Aegis256), + "aez-384" => Some(Self::Aez384), + "deoxys-ii-256-128" => Some(Self::DeoxysII256128), + "ascon128" => Some(Self::Ascon128), + "ascon128a" => Some(Self::Ascon128A), + "lea-128-gcm" => Some(Self::Lea128Gcm), + "lea-192-gcm" => Some(Self::Lea192Gcm), + "lea-256-gcm" => Some(Self::Lea256Gcm), + _ => None, + } + } + + fn key_len(self) -> usize { + match self { + Self::Aes192Gcm | Self::Aes192Ccm | Self::Lea192Gcm => 24, + Self::Chacha8IetfPoly1305 | Self::XChacha8IetfPoly1305 => 32, + Self::Rabbit128Poly1305 | Self::Aegis128L | Self::Lea128Gcm => 16, + Self::Aegis256 | Self::DeoxysII256128 | Self::Lea256Gcm => 32, + Self::Aez384 => 48, + Self::Ascon128 | Self::Ascon128A => 16, + } + } + + fn salt_len(self) -> usize { + self.key_len() + } + + fn tag_len(self) -> usize { + 16 + } + + fn nonce_len(self) -> usize { + match self { + Self::Aes192Gcm + | Self::Aes192Ccm + | Self::Lea128Gcm + | Self::Lea192Gcm + | Self::Lea256Gcm => 12, + Self::Chacha8IetfPoly1305 => 12, + Self::XChacha8IetfPoly1305 => 24, + Self::Rabbit128Poly1305 => 8, + Self::Aegis128L => 16, + Self::Aegis256 => 32, + Self::Aez384 => 16, + Self::DeoxysII256128 => 15, + Self::Ascon128 | Self::Ascon128A => 16, + } + } + + fn name(self) -> &'static str { + match self { + Self::Aes192Gcm => "aes-192-gcm", + Self::Aes192Ccm => "aes-192-ccm", + Self::Chacha8IetfPoly1305 => "chacha8-ietf-poly1305", + Self::XChacha8IetfPoly1305 => "xchacha8-ietf-poly1305", + Self::Rabbit128Poly1305 => "rabbit128-poly1305", + Self::Aegis128L => "aegis-128l", + Self::Aegis256 => "aegis-256", + Self::Aez384 => "aez-384", + Self::DeoxysII256128 => "deoxys-ii-256-128", + Self::Ascon128 => "ascon128", + Self::Ascon128A => "ascon128a", + Self::Lea128Gcm => "lea-128-gcm", + Self::Lea192Gcm => "lea-192-gcm", + Self::Lea256Gcm => "lea-256-gcm", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Aead2022AesCcmKind { + Aes128, + Aes256, +} + +impl Aead2022AesCcmKind { + fn from_name(name: &str) -> Option { + match name.trim().to_ascii_lowercase().as_str() { + "2022-blake3-aes-128-ccm" => Some(Self::Aes128), + "2022-blake3-aes-256-ccm" => Some(Self::Aes256), + _ => None, + } + } + + fn name(self) -> &'static str { + match self { + Self::Aes128 => "2022-blake3-aes-128-ccm", + Self::Aes256 => "2022-blake3-aes-256-ccm", + } + } + + fn key_len(self) -> usize { + match self { + Self::Aes128 => 16, + Self::Aes256 => 32, + } + } + + fn tag_len(self) -> usize { + 16 + } + + fn nonce_len(self) -> usize { + 12 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CertificateFingerprint([u8; 32]); + +#[derive(Debug)] +struct UdpReply { + payload: Vec, + source: SocketAddr, + destination: SocketAddr, +} + +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +struct UdpFlowKey { + local: SocketAddr, + remote: SocketAddr, +} + +pub fn proxy_server_config_name(payload: &ProxySubscriptionPayload) -> String { + match selected_node(payload) { + Ok(node) => format!("{} ({})", payload.name, node.name), + Err(_) => payload.name.clone(), + } +} + +pub fn proxy_server_addresses() -> Vec { + vec![PROXY_TUN_V4.to_owned(), PROXY_TUN_V6.to_owned()] +} + +pub fn proxy_dns_servers() -> Vec { + let servers = vec![PROXY_DNS_V4.to_owned()]; + tracing::info!( + dns_servers = ?servers, + "configured proxy subscription DNS servers from daemon fake-IP resolver" + ); + servers +} + +pub fn proxy_dns_excluded_routes(dns_servers: &[String]) -> Vec { + let mut routes = Vec::new(); + for server in dns_servers { + let Ok(ip) = server.parse::() else { + continue; + }; + if ip == IpAddr::V4(Ipv4Addr::new(100, 64, 0, 2)) { + continue; + } + let route = host_route_for_ip(ip); + if !routes.contains(&route) { + routes.push(route); + } + } + tracing::info!( + excluded_routes = ?routes, + "configured proxy DNS excluded routes" + ); + routes +} + +pub fn proxy_excluded_routes(payload: &ProxySubscriptionPayload) -> Result> { + let node = selected_node(payload)?; + let routes = excluded_routes_for_server(&node.server, node.port)?; + tracing::info!( + server = %node.server, + port = node.port, + excluded_routes = ?routes, + "configured proxy server excluded routes" + ); + Ok(routes) +} + +pub fn proxy_runtime_config(payload: &ProxySubscriptionPayload) -> Result { + let node = selected_node(payload)?; + tracing::info!( + subscription = %payload.name, + node = %node.name, + protocol = %node.protocol.as_str(), + server = %node.server, + port = node.port, + "selected proxy subscription node" + ); + let outbound = match &node.config { + ProxyNodeConfig::Trojan(config) => { + ProxyOutbound::Trojan(TrojanOutbound::from_node(node, config)?) + } + ProxyNodeConfig::Shadowsocks(config) => { + ProxyOutbound::Shadowsocks(ShadowsocksOutbound::from_node(node, config)?) + } + }; + Ok(ProxyRuntimeConfig { + selected_name: node.name.clone(), + outbound, + }) +} + +pub fn selected_node(payload: &ProxySubscriptionPayload) -> Result<&ProxyNode> { + if let Some(name) = payload.selected_name.as_deref() { + let node = payload + .nodes + .iter() + .find(|node| node.name == name) + .ok_or_else(|| anyhow!("selected proxy subscription node {name} was not found"))?; + validate_runtime_supported_node(node)?; + return Ok(node); + } + + if let Some(ordinal) = payload.selected_ordinal { + let node = payload + .nodes + .iter() + .find(|node| node.ordinal == ordinal) + .ok_or_else(|| anyhow!("selected proxy subscription node {ordinal} was not found"))?; + validate_runtime_supported_node(node)?; + return Ok(node); + } + + payload + .nodes + .iter() + .find(|node| validate_runtime_supported_node(node).is_ok()) + .ok_or_else(|| { + anyhow!("proxy subscription contains no runtime-supported Trojan or Shadowsocks nodes") + }) +} + +fn validate_runtime_supported_node(node: &ProxyNode) -> Result<()> { + match &node.config { + ProxyNodeConfig::Trojan(config) => { + if !matches!(config.network, TrojanNetwork::Tcp) { + bail!( + "Trojan node {} uses {:?}; only tcp transport is supported by the packet runtime", + node.name, + config.network + ); + } + } + ProxyNodeConfig::Shadowsocks(config) => { + shadowsocks_plugin_transport( + config.plugin.as_ref(), + config.client_fingerprint.as_deref(), + )?; + if config.udp_over_tcp { + normalize_uot_version(config.udp_over_tcp_version)?; + } + validate_shadowsocks_cipher(&config.cipher)?; + let protocol = shadowsocks_protocol_for_node(node, config)?; + shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol)?; + } + } + Ok(()) +} + +pub fn runtime_support_error(node: &ProxyNode) -> Option { + validate_runtime_supported_node(node) + .err() + .map(|err| err.to_string()) +} + +pub fn start_proxy_packet_bridge(outbound: ProxyOutbound) -> ProxyPacketBridge { + let (outbound_tx, outbound_rx) = mpsc::channel(128); + let (inbound_tx, _) = broadcast::channel(128); + let proxy_outbound = Arc::new(RwLock::new(outbound)); + let task = tokio::spawn(run_proxy_packet_stack( + outbound_rx, + inbound_tx.clone(), + proxy_outbound.clone(), + )); + ProxyPacketBridge { + outbound: outbound_tx, + inbound: inbound_tx, + proxy_outbound, + task, + } +} + +pub async fn run_proxy_tun_bridge( + tun: Arc>>, + outbound_tx: mpsc::Sender>, + mut inbound_rx: broadcast::Receiver>, +) -> Result<()> { + let inbound_tun = tun.clone(); + let inbound = tokio::spawn(async move { + loop { + let packet = match inbound_rx.recv().await { + Ok(packet) => packet, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }; + let guard = inbound_tun.read().await; + let Some(tun) = guard.as_ref() else { + bail!("proxy subscription tun interface unavailable"); + }; + tun.send(&packet) + .await + .context("failed to write proxy packet to tun")?; + } + Result::<()>::Ok(()) + }); + + let outbound_tun = tun.clone(); + let outbound = tokio::spawn(async move { + let mut buf = vec![0u8; 65_535]; + loop { + let len = { + let guard = outbound_tun.read().await; + let Some(tun) = guard.as_ref() else { + bail!("proxy subscription tun interface unavailable"); + }; + tun.recv(&mut buf) + .await + .context("failed to read proxy packet from tun")? + }; + outbound_tx + .send(buf[..len].to_vec()) + .await + .context("failed to forward packet to proxy stack")?; + } + #[allow(unreachable_code)] + Result::<()>::Ok(()) + }); + + let (inbound_result, outbound_result) = tokio::try_join!(inbound, outbound)?; + inbound_result?; + outbound_result?; + Ok(()) +} + +async fn run_proxy_packet_stack( + mut packet_rx: mpsc::Receiver>, + packet_tx: broadcast::Sender>, + outbound: Arc>, +) -> Result<()> { + let (stack, runner, udp_socket, tcp_listener) = StackBuilder::default() + .stack_buffer_size(1024) + .udp_buffer_size(1024) + .tcp_buffer_size(1024) + .enable_udp(true) + .enable_tcp(true) + .enable_icmp(true) + .build() + .context("failed to build proxy userspace netstack")?; + let (mut stack_sink, mut stack_stream) = stack.split(); + + let mut tasks = JoinSet::new(); + if let Some(runner) = runner { + tasks.spawn(async move { runner.await.map_err(anyhow::Error::from) }); + } + + tasks.spawn(async move { + while let Some(packet) = packet_rx.recv().await { + stack_sink + .send(packet) + .await + .context("failed to send tunnel packet into proxy netstack")?; + } + Result::<()>::Ok(()) + }); + + tasks.spawn(async move { + while let Some(packet) = stack_stream.next().await { + let packet = packet.context("failed to receive packet from proxy netstack")?; + let _ = packet_tx.send(packet); + } + Result::<()>::Ok(()) + }); + + if let Some(tcp_listener) = tcp_listener { + let tcp_outbound = outbound.clone(); + let dns_cache = Arc::new(RwLock::new(DnsHostnameCacheState::new())); + let tcp_dns_cache = dns_cache.clone(); + tasks.spawn( + async move { tcp_dispatch_loop(tcp_listener, tcp_outbound, tcp_dns_cache).await }, + ); + + if let Some(udp_socket) = udp_socket { + tasks.spawn(async move { udp_dispatch_loop(udp_socket, outbound, dns_cache).await }); + } + } else if let Some(udp_socket) = udp_socket { + let dns_cache = Arc::new(RwLock::new(DnsHostnameCacheState::new())); + tasks.spawn(async move { udp_dispatch_loop(udp_socket, outbound, dns_cache).await }); + } + + while let Some(result) = tasks.join_next().await { + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => return Err(err), + Err(err) if err.is_cancelled() => {} + Err(err) => return Err(err.into()), + } + } + Ok(()) +} + +async fn tcp_dispatch_loop( + mut listener: StackTcpListener, + outbound: Arc>, + dns_cache: DnsHostnameCache, +) -> Result<()> { + let mut tasks = JoinSet::new(); + loop { + tokio::select! { + Some(result) = tasks.join_next(), if !tasks.is_empty() => { + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => tracing::warn!(?err, "proxy tcp bridge task failed"), + Err(err) if err.is_cancelled() => {} + Err(err) => tracing::warn!(?err, "proxy tcp bridge task panicked"), + } + } + next = listener.next() => match next { + Some((stream, local_addr, remote_addr)) => { + let hostname = dns_cache.read().await.lookup_hostname(remote_addr.ip()); + tracing::debug!( + %local_addr, + %remote_addr, + hostname = hostname.as_deref(), + "accepted proxy tcp stream" + ); + let outbound = outbound.read().await.clone(); + let target = ProxyTcpTarget { + address: remote_addr, + hostname, + }; + tasks.spawn(async move { + outbound.bridge_tcp(stream, target).await + }); + } + None => break, + } + } + } + + tasks.abort_all(); + Ok(()) +} + +async fn udp_dispatch_loop( + socket: StackUdpSocket, + outbound: Arc>, + dns_cache: DnsHostnameCache, +) -> Result<()> { + let (mut udp_reader, mut udp_writer) = socket.split(); + let (reply_tx, mut reply_rx) = mpsc::channel::(128); + let sessions = Arc::new(Mutex::new( + HashMap::>>::new(), + )); + let mut session_tasks = JoinSet::new(); + + loop { + tokio::select! { + Some(result) = session_tasks.join_next(), if !session_tasks.is_empty() => { + match result { + Ok(Ok(())) => {} + Ok(Err(err)) => tracing::warn!(?err, "proxy udp session failed"), + Err(err) if err.is_cancelled() => {} + Err(err) => tracing::warn!(?err, "proxy udp session panicked"), + } + } + maybe_reply = reply_rx.recv() => match maybe_reply { + Some(reply) => { + record_dns_mappings(&dns_cache, &reply.payload).await; + udp_writer + .send((reply.payload, reply.source, reply.destination)) + .await + .context("failed to write udp reply into proxy netstack")?; + } + None => break, + }, + maybe_datagram = udp_reader.next() => match maybe_datagram { + Some((payload, local_addr, remote_addr)) => { + if is_proxy_dns_request(remote_addr) { + if let Some(response) = handle_fake_dns_request(&dns_cache, &payload).await { + udp_writer + .send((response, remote_addr, local_addr)) + .await + .context("failed to write fake DNS reply into proxy netstack")?; + continue; + } + } + dispatch_udp( + outbound.clone(), + payload, + local_addr, + remote_addr, + reply_tx.clone(), + sessions.clone(), + &mut session_tasks, + ).await?; + } + None => break, + } + } + } + + session_tasks.abort_all(); + Ok(()) +} + +fn is_proxy_dns_request(remote_addr: SocketAddr) -> bool { + remote_addr.port() == 53 && remote_addr.ip() == IpAddr::V4(Ipv4Addr::new(100, 64, 0, 2)) +} + +async fn handle_fake_dns_request(cache: &DnsHostnameCache, payload: &[u8]) -> Option> { + match build_fake_dns_response(cache, payload).await { + Ok(response) => Some(response), + Err(err) => { + tracing::debug!(error = ?err, "failed to answer fake proxy DNS request"); + None + } + } +} + +async fn build_fake_dns_response(cache: &DnsHostnameCache, query: &[u8]) -> Result> { + let questions = parse_dns_query(query)?; + let mut answers = Vec::new(); + { + let mut guard = cache.write().await; + for question in &questions { + if question.class != 1 || question.name.is_empty() { + continue; + } + if question.record_type == 1 { + let ip = guard.fake_ip_for_hostname(&question.name); + tracing::debug!( + hostname = %question.name, + %ip, + "answered proxy fake DNS A query" + ); + answers.push((question.name.clone(), ip)); + } + } + } + Ok(encode_fake_dns_response(query, &questions, &answers)) +} + +#[derive(Debug)] +struct DnsQuestion { + name: String, + record_type: u16, + class: u16, +} + +fn parse_dns_query(query: &[u8]) -> Result> { + if query.len() < 12 { + bail!("truncated DNS query"); + } + let flags = u16::from_be_bytes([query[2], query[3]]); + if flags & 0x8000 != 0 { + bail!("DNS packet is not a query"); + } + let question_count = u16::from_be_bytes([query[4], query[5]]) as usize; + let mut offset = 12; + let mut questions = Vec::new(); + for _ in 0..question_count { + let (name, next_offset) = read_dns_name(query, offset)?; + offset = next_offset; + if query.len() < offset + 4 { + bail!("truncated DNS question"); + } + let record_type = u16::from_be_bytes([query[offset], query[offset + 1]]); + let class = u16::from_be_bytes([query[offset + 2], query[offset + 3]]); + offset += 4; + questions.push(DnsQuestion { name, record_type, class }); + } + Ok(questions) +} + +fn encode_fake_dns_response( + query: &[u8], + questions: &[DnsQuestion], + answers: &[(String, Ipv4Addr)], +) -> Vec { + let question_end = dns_question_section_end(query).unwrap_or(query.len()); + let mut response = Vec::with_capacity(question_end + answers.len() * 32); + response.extend_from_slice(&query[..2]); + response.extend_from_slice(&0x8180u16.to_be_bytes()); + response.extend_from_slice(&(questions.len() as u16).to_be_bytes()); + response.extend_from_slice(&(answers.len() as u16).to_be_bytes()); + response.extend_from_slice(&0u16.to_be_bytes()); + response.extend_from_slice(&0u16.to_be_bytes()); + response.extend_from_slice(&query[12..question_end]); + + for (hostname, ip) in answers { + encode_dns_name(&mut response, hostname); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&60u32.to_be_bytes()); + response.extend_from_slice(&4u16.to_be_bytes()); + response.extend_from_slice(&ip.octets()); + } + response +} + +fn dns_question_section_end(query: &[u8]) -> Result { + if query.len() < 12 { + bail!("truncated DNS query"); + } + let question_count = u16::from_be_bytes([query[4], query[5]]) as usize; + let mut offset = 12; + for _ in 0..question_count { + offset = skip_dns_name(query, offset)?; + if query.len() < offset + 4 { + bail!("truncated DNS question"); + } + offset += 4; + } + Ok(offset) +} + +fn encode_dns_name(out: &mut Vec, hostname: &str) { + for label in hostname.trim_end_matches('.').split('.') { + out.push(label.len().min(63) as u8); + out.extend_from_slice( + label + .as_bytes() + .get(..label.len().min(63)) + .unwrap_or_default(), + ); + } + out.push(0); +} + +async fn dispatch_udp( + outbound: Arc>, + payload: Vec, + local_addr: SocketAddr, + remote_addr: SocketAddr, + reply_tx: mpsc::Sender, + sessions: Arc>>>>, + session_tasks: &mut JoinSet>, +) -> Result<()> { + let key = UdpFlowKey { + local: local_addr, + remote: remote_addr, + }; + let existing = { sessions.lock().await.get(&key).cloned() }; + if let Some(sender) = existing { + if sender.send(payload.clone()).await.is_ok() { + return Ok(()); + } + sessions.lock().await.remove(&key); + } + + let (tx, rx) = mpsc::channel::>(32); + tx.send(payload) + .await + .context("failed to enqueue proxy udp payload")?; + sessions.lock().await.insert(key.clone(), tx); + + session_tasks.spawn(async move { + let outbound = outbound.read().await.clone(); + let result = outbound + .start_udp_session(key.clone(), rx, reply_tx) + .await + .with_context(|| format!("proxy udp session {} -> {} failed", key.local, key.remote)); + sessions.lock().await.remove(&key); + result + }); + Ok(()) +} + +impl TrojanOutbound { + fn from_node(node: &ProxyNode, config: &TrojanNode) -> Result { + if !matches!(config.network, TrojanNetwork::Tcp) { + bail!( + "Trojan node {} uses {:?}; only tcp transport is supported by the packet runtime", + node.name, + config.network + ); + } + let sni = config.sni.clone().unwrap_or_else(|| node.server.clone()); + let certificate_fingerprint = config + .fingerprint + .as_deref() + .map(parse_certificate_fingerprint) + .transpose()?; + let endpoint = ProxyServerEndpoint::resolve(&node.server, node.port) + .with_context(|| format!("failed to resolve Trojan server for node {}", node.name))?; + if let Some(client_fingerprint) = unsupported_client_fingerprint(config) { + tracing::warn!( + node = %node.name, + client_fingerprint, + "Trojan client-fingerprint is stored but uTLS ClientHello impersonation is not implemented; using platform TLS ClientHello" + ); + } + tracing::info!( + node = %node.name, + server = %endpoint.host, + port = endpoint.port, + sni = %sni, + alpn = ?trojan_alpn(config), + alpn_present = config.alpn_present, + skip_cert_verify = config.skip_cert_verify, + udp = config.udp, + certificate_fingerprint = config.fingerprint.is_some(), + resolved_addresses = ?endpoint.addresses, + "configured Trojan proxy outbound" + ); + Ok(Self { + endpoint, + password: config.password.clone(), + sni, + alpn: trojan_alpn(config), + skip_cert_verify: config.skip_cert_verify, + udp_enabled: config.udp, + certificate_fingerprint, + }) + } + + async fn bridge_tcp(&self, inbound: StackTcpStream, target: ProxyTcpTarget) -> Result<()> { + let remote_addr = target.address; + let mut outbound = self.connect_tls().await?; + let socks_target = socks_addr_for_target(&target)?; + write_trojan_header(&mut outbound, &self.password, 0x01, &socks_target).await?; + tracing::debug!( + %remote_addr, + hostname = target.hostname.as_deref(), + "wrote Trojan TCP request header" + ); + bridge_tcp_streams(inbound, outbound, remote_addr, target.hostname).await?; + Ok(()) + } + + async fn run_udp_session( + &self, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + ) -> Result<()> { + if !self.udp_enabled { + bail!("Trojan UDP is disabled for this node"); + } + let mut stream = self.connect_tls().await?; + write_trojan_header(&mut stream, &self.password, 0x03, &socks_addr(key.remote)?).await?; + let (mut reader, mut writer) = split(stream); + let remote_socks = socks_addr(key.remote)?; + + let mut write_task = tokio::spawn(async move { + while let Some(payload) = outbound_rx.recv().await { + write_trojan_udp_packet(&mut writer, &remote_socks, &payload).await?; + } + Result::<()>::Ok(()) + }); + + let mut read_task = tokio::spawn(async move { + let mut buf = vec![0u8; 65_535]; + loop { + let (source, payload) = read_trojan_udp_packet(&mut reader, &mut buf).await?; + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue Trojan udp reply")?; + } + #[allow(unreachable_code)] + Result::<()>::Ok(()) + }); + + tokio::select! { + result = &mut write_task => { + read_task.abort(); + result??; + } + result = &mut read_task => { + write_task.abort(); + result??; + } + } + Ok(()) + } + + async fn connect_tls(&self) -> Result { + let (tcp, address) = connect_proxy_tcp(&self.endpoint).await.with_context(|| { + format!( + "failed to connect Trojan server {}:{}", + self.endpoint.host, self.endpoint.port + ) + })?; + #[cfg(target_vendor = "apple")] + { + return self.connect_native_tls(tcp, address).await; + } + #[cfg(not(target_vendor = "apple"))] + { + self.connect_rustls(tcp, address).await + } + } + + #[cfg(not(target_vendor = "apple"))] + async fn connect_rustls(&self, tcp: TcpStream, address: SocketAddr) -> Result { + let config = trojan_tls_config( + &self.alpn, + self.skip_cert_verify, + self.certificate_fingerprint.as_ref(), + )?; + let connector = TlsConnector::from(Arc::new(config)); + let name = ServerName::try_from(self.sni.clone()) + .with_context(|| format!("invalid Trojan SNI {}", self.sni))?; + tracing::debug!( + server = %self.endpoint.host, + port = self.endpoint.port, + %address, + sni = %self.sni, + alpn = ?self.alpn, + skip_cert_verify = self.skip_cert_verify, + "starting Trojan TLS handshake" + ); + let tls = match connector.connect(name, tcp).await { + Ok(tls) => { + let negotiated_alpn = tls + .get_ref() + .1 + .alpn_protocol() + .map(|protocol| String::from_utf8_lossy(protocol).into_owned()); + tracing::debug!( + server = %self.endpoint.host, + port = self.endpoint.port, + %address, + sni = %self.sni, + alpn = ?negotiated_alpn, + "completed Trojan TLS handshake" + ); + tls + } + Err(err) => { + tracing::warn!( + server = %self.endpoint.host, + port = self.endpoint.port, + %address, + sni = %self.sni, + error = %err, + "failed Trojan TLS handshake" + ); + return Err(err).with_context(|| { + format!("failed to complete Trojan TLS handshake with {}", self.sni) + }); + } + }; + Ok(Box::new(tls)) + } + + #[cfg(target_vendor = "apple")] + async fn connect_native_tls( + &self, + tcp: TcpStream, + address: SocketAddr, + ) -> Result { + let mut builder = NativeTlsConnector::builder(); + if self.skip_cert_verify || self.certificate_fingerprint.is_some() { + builder.danger_accept_invalid_certs(true); + } + let alpn = self.alpn.iter().map(String::as_str).collect::>(); + if !alpn.is_empty() { + builder.request_alpns(&alpn); + } + let connector = TokioNativeTlsConnector::from( + builder + .build() + .context("failed to build native TLS connector for Trojan")?, + ); + tracing::debug!( + server = %self.endpoint.host, + port = self.endpoint.port, + %address, + sni = %self.sni, + alpn = ?self.alpn, + skip_cert_verify = self.skip_cert_verify, + certificate_fingerprint = self.certificate_fingerprint.is_some(), + "starting Trojan native TLS handshake" + ); + let tls = match connector.connect(&self.sni, tcp).await { + Ok(tls) => { + if let Some(fingerprint) = self.certificate_fingerprint.as_ref() { + verify_native_tls_peer_certificate(&tls, fingerprint, "Trojan")?; + } + let negotiated_alpn = tls + .get_ref() + .negotiated_alpn() + .ok() + .flatten() + .map(|protocol| String::from_utf8_lossy(&protocol).into_owned()); + tracing::debug!( + server = %self.endpoint.host, + port = self.endpoint.port, + %address, + sni = %self.sni, + alpn = ?negotiated_alpn, + "completed Trojan native TLS handshake" + ); + tls + } + Err(err) => { + tracing::warn!( + server = %self.endpoint.host, + port = self.endpoint.port, + %address, + sni = %self.sni, + error = %err, + "failed Trojan native TLS handshake" + ); + return Err(err).with_context(|| { + format!( + "failed to complete Trojan native TLS handshake with {}", + self.sni + ) + }); + } + }; + Ok(Box::new(tls)) + } +} + +async fn bridge_tcp_streams( + inbound: StackTcpStream, + outbound: TrojanStream, + remote_addr: SocketAddr, + hostname: Option, +) -> Result<()> { + let (inbound_reader, inbound_writer) = split(inbound); + let (outbound_reader, outbound_writer) = split(outbound); + let hostname_for_client = hostname.clone(); + let mut client_to_proxy = tokio::spawn(async move { + copy_tcp_direction( + "client_to_proxy", + inbound_reader, + outbound_writer, + remote_addr, + hostname_for_client, + ) + .await + }); + let mut proxy_to_client = tokio::spawn(async move { + copy_tcp_direction( + "proxy_to_client", + outbound_reader, + inbound_writer, + remote_addr, + hostname, + ) + .await + }); + + tokio::select! { + result = &mut client_to_proxy => { + proxy_to_client.abort(); + let bytes = result.context("client-to-proxy bridge task panicked")??; + tracing::debug!(%remote_addr, bytes, "Trojan tcp client-to-proxy bridge completed first"); + } + result = &mut proxy_to_client => { + client_to_proxy.abort(); + let bytes = result.context("proxy-to-client bridge task panicked")??; + tracing::debug!(%remote_addr, bytes, "Trojan tcp proxy-to-client bridge completed first"); + } + } + Ok(()) +} + +async fn copy_tcp_direction( + direction: &'static str, + mut reader: R, + mut writer: W, + remote_addr: SocketAddr, + hostname: Option, +) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut total = 0u64; + let mut logged_first_chunk = false; + let mut buf = vec![0u8; 16 * 1024]; + loop { + let len = reader + .read(&mut buf) + .await + .with_context(|| format!("failed to read {direction} proxy tcp bytes"))?; + if len == 0 { + tracing::debug!( + %remote_addr, + hostname = hostname.as_deref(), + direction, + total, + "proxy tcp direction reached EOF" + ); + writer + .shutdown() + .await + .with_context(|| format!("failed to shutdown {direction} proxy tcp writer"))?; + return Ok(total); + } + if !logged_first_chunk { + logged_first_chunk = true; + tracing::debug!( + %remote_addr, + hostname = hostname.as_deref(), + direction, + len, + "proxy tcp direction forwarding first bytes" + ); + } + writer + .write_all(&buf[..len]) + .await + .with_context(|| format!("failed to write {direction} proxy tcp bytes"))?; + total += len as u64; + } +} + +fn trojan_alpn(config: &TrojanNode) -> Vec { + if config.alpn_present { + config.alpn.clone() + } else { + MIHOMO_TROJAN_TCP_DEFAULT_ALPN + .iter() + .map(|value| (*value).to_owned()) + .collect() + } +} + +async fn bridge_streams(inbound: StackTcpStream, stream: S) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let (mut inbound_reader, mut inbound_writer) = split(inbound); + let (mut stream_reader, mut stream_writer) = split(stream); + let mut upload = tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = inbound_reader + .read(&mut buf) + .await + .context("failed to read tcp payload from proxy netstack")?; + if n == 0 { + break; + } + stream_writer.write_all(&buf[..n]).await?; + } + stream_writer + .shutdown() + .await + .context("failed to shutdown proxy tcp upload")?; + Result::<()>::Ok(()) + }); + + let mut download = tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = stream_reader + .read(&mut buf) + .await + .context("failed to read proxy tcp payload")?; + if n == 0 { + break; + } + inbound_writer + .write_all(&buf[..n]) + .await + .context("failed to write tcp payload to proxy netstack")?; + } + inbound_writer + .shutdown() + .await + .context("failed to shutdown proxy netstack tcp download")?; + Result::<()>::Ok(()) + }); + + tokio::select! { + result = &mut upload => { + download.abort(); + result??; + } + result = &mut download => { + upload.abort(); + result??; + } + } + Ok(()) +} + +impl ShadowsocksOutbound { + fn from_node(node: &ProxyNode, config: &ShadowsocksNode) -> Result { + let plugin = shadowsocks_plugin_transport( + config.plugin.as_ref(), + config.client_fingerprint.as_deref(), + ) + .with_context(|| { + format!( + "Shadowsocks node {} uses an unsupported plugin transport", + node.name + ) + })?; + let force_udp_over_tcp = matches!(plugin, Some(ShadowsocksPluginTransport::Kcptun(_))); + let udp_over_tcp = config.udp_over_tcp || force_udp_over_tcp; + let udp_over_tcp_version = if udp_over_tcp { + normalize_uot_version(config.udp_over_tcp_version)? + } else { + UOT_LEGACY_VERSION + }; + let endpoint = + ProxyServerEndpoint::resolve(&node.server, node.port).with_context(|| { + format!( + "failed to resolve Shadowsocks server for node {}", + node.name + ) + })?; + let protocol = shadowsocks_protocol_for_node(node, config)?; + let sing_mux = shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol) + .with_context(|| { + format!( + "Shadowsocks node {} uses an unsupported top-level sing-mux mode", + node.name + ) + })?; + let kcptun_pool = match plugin.as_ref() { + Some(ShadowsocksPluginTransport::Kcptun(config)) => Some(Arc::new( + KcptunSessionPool::new(endpoint.clone(), config.clone()), + )), + _ => None, + }; + tracing::info!( + node = %node.name, + server = %endpoint.host, + port = endpoint.port, + cipher = %config.cipher, + udp = config.udp, + udp_over_tcp, + udp_over_tcp_version, + plugin = ?plugin, + sing_mux = ?sing_mux, + resolved_addresses = ?endpoint.addresses, + "configured Shadowsocks proxy outbound" + ); + Ok(Self { + endpoint, + protocol, + plugin, + kcptun_pool, + udp_enabled: config.udp + || udp_over_tcp + || sing_mux + .as_ref() + .map(|sing_mux| sing_mux.udp) + .unwrap_or(false), + sing_mux, + sing_mux_sessions: Arc::new(Mutex::new(Vec::new())), + udp_over_tcp, + udp_over_tcp_version, + }) + } + + async fn connect_tcp(&self) -> Result<(Box, SocketAddr)> { + if let Some(pool) = self.kcptun_pool.as_ref() { + return pool.open_stream().await; + } + let (tcp, address) = connect_proxy_tcp(&self.endpoint).await.with_context(|| { + format!( + "failed to connect Shadowsocks server {}:{}", + self.endpoint.host, self.endpoint.port + ) + })?; + let stream: Box = match self.plugin.as_ref() { + Some(ShadowsocksPluginTransport::SimpleObfs { mode, host }) => Box::new( + SimpleObfsStream::new(tcp, *mode, host.clone(), self.endpoint.port), + ), + Some(ShadowsocksPluginTransport::WebSocket(config)) => { + let stream = + websocket_plugin_connect(Box::new(tcp), config, self.endpoint.port).await?; + match config.mux { + ShadowsocksWebSocketMux::None => stream, + ShadowsocksWebSocketMux::V2ray => Box::new(V2rayMuxStream::new(stream)), + ShadowsocksWebSocketMux::Gost => gost_smux_stream(stream).await?, + } + } + Some(ShadowsocksPluginTransport::ShadowTls(config)) => { + shadow_tls_connect(Box::new(tcp), config).await? + } + Some(ShadowsocksPluginTransport::Kcptun(_)) => { + bail!("internal Shadowsocks kcptun transport dispatch error") + } + None => Box::new(tcp), + }; + Ok((stream, address)) + } + + async fn connect_tcp_to_target( + &self, + target: &ProxyTcpTarget, + ) -> Result<(Box, SocketAddr)> { + let (mut tcp, address) = self.connect_tcp().await?; + match &self.protocol { + ShadowsocksProtocol::None => { + let socks_target = socks_addr_for_target(target)?; + tcp.write_all(&socks_target).await.with_context(|| { + format!( + "failed to write Shadowsocks none target address for {}", + target.address + ) + })?; + tcp.flush() + .await + .context("failed to flush Shadowsocks none target address")?; + Ok((tcp, address)) + } + ShadowsocksProtocol::Standard { server_config, context } => { + let stream = SsProxyClientStream::from_stream( + context.clone(), + tcp, + server_config, + shadowsocks_addr_for_target(target)?, + ); + Ok((Box::new(stream), address)) + } + ShadowsocksProtocol::CustomAead { kind, master_key } => { + let stream = + custom_aead_plain_tcp_stream(tcp, target, *kind, master_key.clone()).await?; + Ok((stream, address)) + } + ShadowsocksProtocol::CustomStream { kind, key } => { + let stream = + custom_stream_plain_tcp_stream(tcp, target, *kind, key.clone()).await?; + Ok((stream, address)) + } + ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { + let stream = aead2022_aes_ccm_plain_tcp_stream( + tcp, + target, + *kind, + user_key.clone(), + identity_keys.clone(), + ) + .await?; + Ok((stream, address)) + } + } + } + + async fn open_sing_mux_tcp_stream(&self, target: &ProxyTcpTarget) -> Result> { + let session = self.sing_mux_session().await?; + let mut stream = session.open_stream().await?; + write_sing_mux_tcp_request(&mut stream, target).await?; + Ok(Box::new(SingMuxTcpStream::new(stream))) + } + + async fn sing_mux_session(&self) -> Result { + let config = self + .sing_mux + .as_ref() + .context("Shadowsocks sing-mux is not configured")?; + let mut sessions = self.sing_mux_sessions.lock().await; + sessions.retain(|session| !session.is_closed()); + + if config.brutal.is_some() { + if let Some(session) = sessions.first() { + return Ok(session.clone()); + } + let session = self.connect_sing_mux_session().await?; + sessions.push(session.clone()); + return Ok(session); + } + + let mut best = None; + for (index, session) in sessions.iter().enumerate() { + let streams = session.num_streams().await; + match best { + None => best = Some((index, streams)), + Some((_, current)) if streams < current => best = Some((index, streams)), + _ => {} + } + } + + if let Some((index, streams)) = best { + if streams == 0 + || (config.max_connections > 0 + && (sessions.len() >= config.max_connections || streams < config.min_streams)) + || (config.max_connections == 0 + && config.max_streams > 0 + && streams < config.max_streams) + { + return Ok(sessions[index].clone()); + } + } + + let session = self.connect_sing_mux_session().await?; + sessions.push(session.clone()); + Ok(session) + } + + async fn connect_sing_mux_session(&self) -> Result { + let target = ProxyTcpTarget { + address: SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), SING_MUX_PORT), + hostname: Some(SING_MUX_ADDRESS.to_owned()), + }; + let sing_mux = self + .sing_mux + .as_ref() + .context("Shadowsocks sing-mux session requested without sing-mux config")?; + let (mut stream, address) = self.connect_tcp_to_target(&target).await?; + write_sing_mux_session_preface(&mut stream, sing_mux.protocol, sing_mux.padding).await?; + let stream = if sing_mux.padding { + sing_mux_padding_stream(stream) + } else { + stream + }; + + let session = match sing_mux.protocol { + ShadowsocksSingMuxProtocol::H2Mux => start_sing_mux_h2mux_session(stream).await?, + ShadowsocksSingMuxProtocol::Smux => { + let mut config = smux_rust::Config::default(); + config.keep_alive_disabled = true; + let session = + smux_rust::client(Box::new(ProxyIoAdapter::new(stream)), Some(config)) + .await + .map_err(|err| { + anyhow!("failed to start Shadowsocks sing-mux smux session: {err}") + })?; + ShadowsocksSingMuxSession::Smux(session) + } + ShadowsocksSingMuxProtocol::Yamux => start_sing_mux_yamux_session(stream).await?, + }; + if let Some(brutal) = sing_mux.brutal { + sing_mux_brutal_exchange(&session, brutal).await?; + } + tracing::debug!( + %address, + mux_host = SING_MUX_ADDRESS, + mux_port = SING_MUX_PORT, + protocol = sing_mux.protocol.name(), + brutal = sing_mux.brutal.is_some(), + "opened Shadowsocks sing-mux session" + ); + Ok(session) + } + + async fn bridge_tcp(&self, inbound: StackTcpStream, target: ProxyTcpTarget) -> Result<()> { + if self.sing_mux.is_some() { + let stream = self.open_sing_mux_tcp_stream(&target).await?; + return bridge_streams(inbound, stream).await; + } + let (tcp, _) = self.connect_tcp().await?; + match &self.protocol { + ShadowsocksProtocol::None => self.bridge_none_tcp(inbound, tcp, target).await, + ShadowsocksProtocol::Standard { server_config, context } => { + let stream = SsProxyClientStream::from_stream( + context.clone(), + tcp, + server_config, + shadowsocks_addr_for_target(&target)?, + ); + bridge_streams(inbound, stream).await + } + ShadowsocksProtocol::CustomAead { kind, master_key } => { + self.bridge_custom_aead_tcp(inbound, tcp, target, *kind, master_key.clone()) + .await + } + ShadowsocksProtocol::CustomStream { kind, key } => { + self.bridge_custom_stream_tcp(inbound, tcp, target, *kind, key.clone()) + .await + } + ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { + self.bridge_aead2022_aes_ccm_tcp( + inbound, + tcp, + target, + *kind, + user_key.clone(), + identity_keys.clone(), + ) + .await + } + } + } + + async fn bridge_none_tcp( + &self, + inbound: StackTcpStream, + mut tcp: S, + target: ProxyTcpTarget, + ) -> Result<()> + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let remote_addr = target.address; + let socks_target = socks_addr_for_target(&target)?; + tcp.write_all(&socks_target).await.with_context(|| { + format!("failed to write Shadowsocks none target address for {remote_addr}") + })?; + tcp.flush() + .await + .context("failed to flush Shadowsocks none target address")?; + bridge_streams(inbound, tcp).await + } + + async fn bridge_custom_aead_tcp( + &self, + inbound: StackTcpStream, + tcp: S, + target: ProxyTcpTarget, + kind: CustomSsAeadKind, + master_key: Arc<[u8]>, + ) -> Result<()> + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let remote_addr = target.address; + let (reader, writer) = split(tcp); + let mut ss_reader = CustomSsAeadReader::new(reader, kind, master_key.clone()); + let mut ss_writer = CustomSsAeadWriter::new(writer, kind, master_key) + .await + .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; + let socks_target = socks_addr_for_target(&target)?; + ss_writer + .write_chunk(&socks_target) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks target address for {remote_addr} with {}", + kind.name() + ) + })?; + let (mut inbound_reader, mut inbound_writer) = split(inbound); + let mut upload = tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = inbound_reader + .read(&mut buf) + .await + .context("failed to read tcp payload from proxy netstack")?; + if n == 0 { + break; + } + ss_writer.write_chunk(&buf[..n]).await?; + } + ss_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks tcp upload")?; + Result::<()>::Ok(()) + }); + + let mut download = tokio::spawn(async move { + let mut buf = Vec::new(); + loop { + buf.clear(); + match ss_reader.read_chunk(&mut buf).await { + Ok(0) => break, + Ok(_) => { + inbound_writer + .write_all(&buf) + .await + .context("failed to write tcp payload to proxy netstack")?; + } + Err(err) => return Err(err), + } + } + inbound_writer + .shutdown() + .await + .context("failed to shutdown proxy netstack tcp download")?; + Result::<()>::Ok(()) + }); + + tokio::select! { + result = &mut upload => { + download.abort(); + result??; + } + result = &mut download => { + upload.abort(); + result??; + } + } + Ok(()) + } + + async fn bridge_custom_stream_tcp( + &self, + inbound: StackTcpStream, + tcp: S, + target: ProxyTcpTarget, + kind: CustomSsStreamKind, + key: Arc<[u8]>, + ) -> Result<()> + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let remote_addr = target.address; + let (reader, writer) = split(tcp); + let mut ss_reader = CustomSsStreamReader::new(reader, kind, key.clone()); + let mut ss_writer = CustomSsStreamWriter::new(writer, kind, key) + .await + .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; + let socks_target = socks_addr_for_target(&target)?; + ss_writer + .write_all_encrypted(&socks_target) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks target address for {remote_addr} with {}", + kind.name() + ) + })?; + let (mut inbound_reader, mut inbound_writer) = split(inbound); + let mut upload = tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = inbound_reader + .read(&mut buf) + .await + .context("failed to read tcp payload from proxy netstack")?; + if n == 0 { + break; + } + ss_writer.write_all_encrypted(&buf[..n]).await?; + } + ss_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks tcp upload")?; + Result::<()>::Ok(()) + }); + + let mut download = tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = ss_reader.read_decrypted(&mut buf).await?; + if n == 0 { + break; + } + inbound_writer + .write_all(&buf[..n]) + .await + .context("failed to write tcp payload to proxy netstack")?; + } + inbound_writer + .shutdown() + .await + .context("failed to shutdown proxy netstack tcp download")?; + Result::<()>::Ok(()) + }); + + tokio::select! { + result = &mut upload => { + download.abort(); + result??; + } + result = &mut download => { + upload.abort(); + result??; + } + } + Ok(()) + } + + async fn bridge_aead2022_aes_ccm_tcp( + &self, + inbound: StackTcpStream, + tcp: S, + target: ProxyTcpTarget, + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, + ) -> Result<()> + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { + let remote_addr = target.address; + let (reader, writer) = split(tcp); + let mut ss_writer = + Aead2022AesCcmWriter::new(writer, kind, user_key.clone(), identity_keys) + .await + .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; + let request_salt = ss_writer + .write_request(&socks_addr_for_target(&target)?, 1) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks 2022 target address for {remote_addr} with {}", + kind.name() + ) + })?; + let mut ss_reader = Aead2022AesCcmReader::new(reader, kind, user_key, request_salt); + let (mut inbound_reader, mut inbound_writer) = split(inbound); + let mut upload = tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = inbound_reader + .read(&mut buf) + .await + .context("failed to read tcp payload from proxy netstack")?; + if n == 0 { + break; + } + ss_writer.write_chunk(&buf[..n]).await?; + } + ss_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks 2022 tcp upload")?; + Result::<()>::Ok(()) + }); + + let mut download = tokio::spawn(async move { + let mut buf = Vec::new(); + loop { + buf.clear(); + match ss_reader.read_chunk(&mut buf).await { + Ok(0) => break, + Ok(_) => { + inbound_writer + .write_all(&buf) + .await + .context("failed to write tcp payload to proxy netstack")?; + } + Err(err) => return Err(err), + } + } + inbound_writer + .shutdown() + .await + .context("failed to shutdown proxy netstack tcp download")?; + Result::<()>::Ok(()) + }); + + tokio::select! { + result = &mut upload => { + download.abort(); + result??; + } + result = &mut download => { + upload.abort(); + result??; + } + } + Ok(()) + } + + async fn run_udp_session( + &self, + key: UdpFlowKey, + outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + ) -> Result<()> { + if self + .sing_mux + .as_ref() + .map(|sing_mux| sing_mux.udp) + .unwrap_or(false) + { + return self + .run_sing_mux_udp_session(key, outbound_rx, reply_tx) + .await; + } + if self.udp_over_tcp { + return self + .run_udp_over_tcp_session(key, outbound_rx, reply_tx) + .await; + } + if !self.udp_enabled { + bail!("Shadowsocks UDP is disabled for this node"); + } + let (socket, server) = connect_proxy_udp(&self.endpoint).await.with_context(|| { + format!( + "failed to connect Shadowsocks udp socket to {}:{}", + self.endpoint.host, self.endpoint.port + ) + })?; + socket.connect(server).await.with_context(|| { + format!("failed to connect udp socket to Shadowsocks server {server}") + })?; + match &self.protocol { + ShadowsocksProtocol::None => { + self.run_none_udp_loop(socket, server, key, outbound_rx, reply_tx) + .await + } + ShadowsocksProtocol::Standard { server_config, context } => { + let socket = SsProxySocket::from_socket( + SsUdpSocketType::Client, + context.clone(), + server_config, + BurrowDatagramSocket(socket), + ); + self.run_standard_udp_loop(socket, server, key, outbound_rx, reply_tx) + .await + } + ShadowsocksProtocol::CustomAead { kind, master_key } => { + self.run_custom_aead_udp_loop( + socket, + server, + key, + outbound_rx, + reply_tx, + *kind, + master_key.clone(), + ) + .await + } + ShadowsocksProtocol::CustomStream { kind, key: stream_key } => { + self.run_custom_stream_udp_loop( + socket, + server, + key, + outbound_rx, + reply_tx, + *kind, + stream_key.clone(), + ) + .await + } + ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { + self.run_aead2022_aes_ccm_udp_loop( + socket, + server, + key, + outbound_rx, + reply_tx, + *kind, + user_key.clone(), + identity_keys.clone(), + ) + .await + } + } + } + + async fn run_sing_mux_udp_session( + &self, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + ) -> Result<()> { + let session = self.sing_mux_session().await?; + let mut stream = session.open_stream().await?; + let mut request_written = false; + let mut response_read = false; + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + if !request_written { + write_sing_mux_udp_request(&mut stream, key.remote, &payload).await?; + request_written = true; + } else { + write_sing_mux_udp_payload(&mut stream, &payload).await?; + } + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, read_sing_mux_udp_payload(&mut stream, &mut response_read)), if request_written => match recv { + Ok(Ok(payload)) => { + reply_tx + .send(UdpReply { + payload, + source: key.remote, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks sing-mux udp reply")?; + } + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks sing-mux udp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_udp_over_tcp_session( + &self, + key: UdpFlowKey, + outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + ) -> Result<()> { + let (tcp, server) = self.connect_tcp().await.with_context(|| { + format!( + "failed to connect Shadowsocks tcp socket to {}:{} for udp-over-tcp", + self.endpoint.host, self.endpoint.port + ) + })?; + match &self.protocol { + ShadowsocksProtocol::None => { + let mut stream = tcp; + let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; + stream.write_all(&uot_target).await.with_context(|| { + "failed to write Shadowsocks none udp-over-tcp target".to_owned() + })?; + stream + .flush() + .await + .context("failed to flush Shadowsocks none udp-over-tcp target")?; + self.run_standard_uot_loop( + stream, + server, + key, + outbound_rx, + reply_tx, + self.udp_over_tcp_version, + ) + .await + } + ShadowsocksProtocol::Standard { server_config, context } => { + let stream = SsProxyClientStream::from_stream( + context.clone(), + tcp, + server_config, + uot_magic_shadowsocks_addr(self.udp_over_tcp_version), + ); + self.run_standard_uot_loop( + stream, + server, + key, + outbound_rx, + reply_tx, + self.udp_over_tcp_version, + ) + .await + } + ShadowsocksProtocol::CustomAead { kind, master_key } => { + let (reader, writer) = split(tcp); + let mut ss_writer = CustomSsAeadWriter::new(writer, *kind, master_key.clone()) + .await + .with_context(|| { + format!("failed to initialize {} udp-over-tcp stream", kind.name()) + })?; + let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; + ss_writer.write_chunk(&uot_target).await.with_context(|| { + format!( + "failed to write Shadowsocks udp-over-tcp target for {}", + kind.name() + ) + })?; + let ss_reader = CustomSsAeadReader::new(reader, *kind, master_key.clone()); + self.run_custom_aead_uot_loop( + CustomSsAeadByteReader::new(ss_reader), + ss_writer, + server, + key, + outbound_rx, + reply_tx, + self.udp_over_tcp_version, + ) + .await + } + ShadowsocksProtocol::CustomStream { kind, key: stream_key } => { + let (reader, writer) = split(tcp); + let mut ss_writer = CustomSsStreamWriter::new(writer, *kind, stream_key.clone()) + .await + .with_context(|| { + format!("failed to initialize {} udp-over-tcp stream", kind.name()) + })?; + let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; + ss_writer + .write_all_encrypted(&uot_target) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks udp-over-tcp target for {}", + kind.name() + ) + })?; + let ss_reader = CustomSsStreamReader::new(reader, *kind, stream_key.clone()); + self.run_custom_stream_uot_loop( + CustomSsStreamByteReader::new(ss_reader), + ss_writer, + server, + key, + outbound_rx, + reply_tx, + self.udp_over_tcp_version, + ) + .await + } + ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys } => { + let (reader, writer) = split(tcp); + let mut ss_writer = Aead2022AesCcmWriter::new( + writer, + *kind, + user_key.clone(), + identity_keys.clone(), + ) + .await + .with_context(|| { + format!("failed to initialize {} udp-over-tcp stream", kind.name()) + })?; + let uot_target = socks_domain_addr(uot_magic_domain(self.udp_over_tcp_version), 0)?; + let request_salt = + ss_writer + .write_request(&uot_target, 1) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks 2022 udp-over-tcp target for {}", + kind.name() + ) + })?; + let ss_reader = + Aead2022AesCcmReader::new(reader, *kind, user_key.clone(), request_salt); + self.run_aead2022_aes_ccm_uot_loop( + Aead2022AesCcmByteReader::new(ss_reader), + ss_writer, + server, + key, + outbound_rx, + reply_tx, + self.udp_over_tcp_version, + ) + .await + } + } + } + + async fn run_none_udp_loop( + &self, + socket: UdpSocket, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + ) -> Result<()> { + let remote = socks_addr(key.remote)?; + let mut buf = vec![0u8; 65_535]; + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + let mut packet = Vec::with_capacity(remote.len() + payload.len()); + packet.extend_from_slice(&remote); + packet.extend_from_slice(&payload); + socket + .send(&packet) + .await + .with_context(|| format!("failed to send Shadowsocks none udp payload to {server}"))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { + Ok(Ok(packet_len)) => { + let (source, used) = parse_socks_addr(&buf[..packet_len]) + .context("failed to parse Shadowsocks none udp response source")?; + reply_tx + .send(UdpReply { + payload: buf[used..packet_len].to_vec(), + source, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks none udp reply")?; + } + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks none udp response"), + Err(_) => break, + }, + } + } + Ok(()) + } + + async fn run_standard_udp_loop( + &self, + socket: SsProxySocket, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + ) -> Result<()> { + let remote = SsAddress::SocketAddress(key.remote); + let mut buf = vec![0u8; 65_535]; + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + socket + .send(&remote, &payload) + .await + .with_context(|| format!("failed to send Shadowsocks udp payload to {server}"))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { + Ok(Ok((payload_len, source, _packet_len))) => { + let source = shadowsocks_addr_to_socket_addr(source)?; + reply_tx + .send(UdpReply { + payload: buf[..payload_len].to_vec(), + source, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks udp reply")?; + } + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_standard_uot_loop( + &self, + mut stream: S, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + version: u8, + ) -> Result<()> + where + S: AsyncRead + AsyncWrite + Unpin, + { + if version == UOT_VERSION { + write_uot_request(&mut stream, key.remote).await?; + } + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + write_uot_frame(&mut stream, key.remote, &payload) + .await + .with_context(|| format!("failed to send Shadowsocks udp-over-tcp payload to {server}"))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, read_uot_frame(&mut stream)) => match recv { + Ok(Ok((source, payload))) => { + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks udp-over-tcp reply")?; + } + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp-over-tcp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_custom_aead_udp_loop( + &self, + socket: UdpSocket, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + kind: CustomSsAeadKind, + master_key: Arc<[u8]>, + ) -> Result<()> { + let mut buf = vec![0u8; 65_535]; + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + let packet = encrypt_custom_ss_udp_packet(kind, &master_key, key.remote, &payload)?; + socket + .send(&packet) + .await + .with_context(|| format!("failed to send {} udp payload to {server}", kind.name()))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { + Ok(Ok(len)) => { + let (source, payload) = + decrypt_custom_ss_udp_packet(kind, &master_key, &buf[..len])?; + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks udp reply")?; + } + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_custom_aead_uot_loop( + &self, + mut reader: CustomSsAeadByteReader, + mut writer: CustomSsAeadWriter, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + version: u8, + ) -> Result<()> + where + W: AsyncWrite + Unpin, + R: AsyncRead + Unpin, + { + if version == UOT_VERSION { + writer.write_chunk(&uot_request(key.remote)?).await?; + } + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + let frame = uot_frame(key.remote, &payload)?; + writer.write_chunk(&frame) + .await + .with_context(|| format!("failed to send custom Shadowsocks udp-over-tcp payload to {server}"))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, reader.read_frame()) => match recv { + Ok(Ok(Some((source, payload)))) => { + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue custom Shadowsocks udp-over-tcp reply")?; + } + Ok(Ok(None)) => break, + Ok(Err(err)) => return Err(err).context("failed to receive custom Shadowsocks udp-over-tcp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_custom_stream_udp_loop( + &self, + socket: UdpSocket, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + kind: CustomSsStreamKind, + stream_key: Arc<[u8]>, + ) -> Result<()> { + let mut buf = vec![0u8; 65_535]; + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + let packet = encrypt_custom_ss_stream_udp_packet(kind, &stream_key, key.remote, &payload)?; + socket + .send(&packet) + .await + .with_context(|| format!("failed to send {} udp payload to {server}", kind.name()))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { + Ok(Ok(len)) => { + let (source, payload) = + decrypt_custom_ss_stream_udp_packet(kind, &stream_key, &buf[..len])?; + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks udp reply")?; + } + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks udp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_custom_stream_uot_loop( + &self, + mut reader: CustomSsStreamByteReader, + mut writer: CustomSsStreamWriter, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + version: u8, + ) -> Result<()> + where + W: AsyncWrite + Unpin, + R: AsyncRead + Unpin, + { + if version == UOT_VERSION { + writer + .write_all_encrypted(&uot_request(key.remote)?) + .await?; + } + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + let frame = uot_frame(key.remote, &payload)?; + writer.write_all_encrypted(&frame) + .await + .with_context(|| format!("failed to send custom Shadowsocks udp-over-tcp payload to {server}"))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, reader.read_frame()) => match recv { + Ok(Ok(Some((source, payload)))) => { + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue custom Shadowsocks udp-over-tcp reply")?; + } + Ok(Ok(None)) => break, + Ok(Err(err)) => return Err(err).context("failed to receive custom Shadowsocks udp-over-tcp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_aead2022_aes_ccm_udp_loop( + &self, + socket: UdpSocket, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, + ) -> Result<()> { + let mut session = Aead2022AesCcmUdpSession::new(kind, user_key, identity_keys)?; + let mut buf = vec![0u8; 65_535]; + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + let packet = session.encrypt_client_packet(key.remote, &payload)?; + socket + .send(&packet) + .await + .with_context(|| format!("failed to send {} udp payload to {server}", kind.name()))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, socket.recv(&mut buf)) => match recv { + Ok(Ok(len)) => { + let (source, payload) = session.decrypt_server_packet(&buf[..len])?; + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks 2022 udp reply")?; + } + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks 2022 udp response"), + Err(_) => break, + } + } + } + Ok(()) + } + + async fn run_aead2022_aes_ccm_uot_loop( + &self, + mut reader: Aead2022AesCcmByteReader, + mut writer: Aead2022AesCcmWriter, + server: SocketAddr, + key: UdpFlowKey, + mut outbound_rx: mpsc::Receiver>, + reply_tx: mpsc::Sender, + version: u8, + ) -> Result<()> + where + W: AsyncWrite + Unpin, + R: AsyncRead + Unpin, + { + if version == UOT_VERSION { + writer.write_chunk(&uot_request(key.remote)?).await?; + } + loop { + tokio::select! { + maybe_payload = outbound_rx.recv() => match maybe_payload { + Some(payload) => { + let frame = uot_frame(key.remote, &payload)?; + writer.write_chunk(&frame) + .await + .with_context(|| format!("failed to send Shadowsocks 2022 udp-over-tcp payload to {server}"))?; + } + None => break, + }, + recv = timeout(UDP_IDLE_TIMEOUT, reader.read_frame()) => match recv { + Ok(Ok(Some((source, payload)))) => { + reply_tx + .send(UdpReply { + payload, + source, + destination: key.local, + }) + .await + .context("failed to enqueue Shadowsocks 2022 udp-over-tcp reply")?; + } + Ok(Ok(None)) => break, + Ok(Err(err)) => return Err(err).context("failed to receive Shadowsocks 2022 udp-over-tcp response"), + Err(_) => break, + } + } + } + Ok(()) + } +} + +trait ProxyIo: AsyncRead + AsyncWrite + Unpin + Send {} + +impl ProxyIo for T where T: AsyncRead + AsyncWrite + Unpin + Send {} + +impl ShadowsocksSingMuxSession { + fn is_closed(&self) -> bool { + match self { + Self::H2Mux(session) => session.closed.load(Ordering::Relaxed), + Self::Smux(session) => session.is_closed(), + Self::Yamux(session) => session.closed.load(Ordering::Relaxed), + } + } + + async fn num_streams(&self) -> usize { + match self { + Self::H2Mux(session) => session.active_streams.load(Ordering::Relaxed), + Self::Smux(session) => session.num_streams().await, + Self::Yamux(session) => session.active_streams.load(Ordering::Relaxed), + } + } + + async fn open_stream(&self) -> Result> { + match self { + Self::H2Mux(session) => session.open_stream().await, + Self::Smux(session) => { + let stream = session.open_stream().await.map_err(|err| { + anyhow!("failed to open Shadowsocks sing-mux smux stream: {err}") + })?; + Ok(Box::new(stream)) + } + Self::Yamux(session) => { + let (response, stream_rx) = oneshot::channel(); + session + .open_tx + .send(YamuxCommand { response }) + .await + .context("Shadowsocks sing-mux yamux session is closed")?; + let stream = stream_rx + .await + .context("Shadowsocks sing-mux yamux session stopped before opening stream")? + .map_err(|err| { + anyhow!("failed to open Shadowsocks sing-mux yamux stream: {err}") + })?; + session.active_streams.fetch_add(1, Ordering::Relaxed); + Ok(Box::new(CountingProxyIo::new( + stream.compat(), + session.active_streams.clone(), + ))) + } + } + } +} + +impl ShadowsocksH2MuxSession { + async fn open_stream(&self) -> Result> { + let mut send_request = self + .send_request + .clone() + .ready() + .await + .context("Shadowsocks sing-mux h2mux session is not ready")?; + let request = Request::builder() + .method(Method::CONNECT) + .uri("https://localhost") + .body(()) + .context("failed to build Shadowsocks sing-mux h2mux CONNECT request")?; + let (response, send_stream) = send_request + .send_request(request, false) + .context("failed to open Shadowsocks sing-mux h2mux stream")?; + self.active_streams.fetch_add(1, Ordering::Relaxed); + Ok(Box::new(H2MuxStream::new( + Box::pin(response), + send_stream, + self.active_streams.clone(), + ))) + } +} + +async fn start_sing_mux_h2mux_session( + stream: Box, +) -> Result { + let (send_request, connection) = h2::client::handshake(ProxyIoAdapter::new(stream)) + .await + .context("failed to start Shadowsocks sing-mux h2mux session")?; + let active_streams = Arc::new(AtomicUsize::new(0)); + let closed = Arc::new(AtomicBool::new(false)); + let driver_closed = closed.clone(); + + tokio::spawn(async move { + if let Err(err) = connection.await { + tracing::debug!(?err, "Shadowsocks sing-mux h2mux session stopped"); + } + driver_closed.store(true, Ordering::Relaxed); + }); + + Ok(ShadowsocksSingMuxSession::H2Mux(ShadowsocksH2MuxSession { + send_request, + active_streams, + closed, + })) +} + +async fn start_sing_mux_yamux_session( + stream: Box, +) -> Result { + let connection = yamux::Connection::new( + ProxyIoAdapter::new(stream).compat(), + yamux::Config::default(), + yamux::Mode::Client, + ); + let (open_tx, open_rx) = mpsc::channel(32); + let active_streams = Arc::new(AtomicUsize::new(0)); + let closed = Arc::new(AtomicBool::new(false)); + let driver_closed = closed.clone(); + + tokio::spawn(async move { + YamuxDriver::new(connection, open_rx).await; + driver_closed.store(true, Ordering::Relaxed); + }); + + Ok(ShadowsocksSingMuxSession::Yamux(ShadowsocksYamuxSession { + open_tx, + active_streams, + closed, + })) +} + +struct YamuxDriver { + connection: yamux::Connection, + open_rx: mpsc::Receiver, + pending_open: VecDeque>>, +} + +impl YamuxDriver { + fn new(connection: yamux::Connection, open_rx: mpsc::Receiver) -> Self { + Self { + connection, + open_rx, + pending_open: VecDeque::new(), + } + } +} + +impl Future for YamuxDriver +where + T: futures::io::AsyncRead + futures::io::AsyncWrite + Unpin, +{ + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll { + loop { + let mut progressed = false; + + while let Poll::Ready(command) = self.open_rx.poll_recv(cx) { + let Some(command) = command else { + break; + }; + self.pending_open.push_back(command.response); + progressed = true; + } + + while let Some(response) = self.pending_open.pop_front() { + match self.connection.poll_new_outbound(cx) { + Poll::Ready(Ok(stream)) => { + let _ = response.send(Ok(stream)); + progressed = true; + } + Poll::Ready(Err(err)) => { + let _ = response.send(Err(err.to_string())); + progressed = true; + } + Poll::Pending => { + self.pending_open.push_front(response); + break; + } + } + } + + match self.connection.poll_next_inbound(cx) { + Poll::Ready(Some(Ok(_stream))) => { + tracing::debug!("discarding unexpected server-opened Shadowsocks yamux stream"); + progressed = true; + } + Poll::Ready(Some(Err(err))) => { + tracing::debug!(?err, "Shadowsocks sing-mux yamux session stopped"); + return Poll::Ready(()); + } + Poll::Ready(None) => return Poll::Ready(()), + Poll::Pending => {} + } + + if !progressed { + return Poll::Pending; + } + } + } +} + +type H2MuxResponseFuture = Pin< + Box, h2::Error>> + Send>, +>; + +struct H2MuxStream { + response: Option, + recv: Option, + send: h2::SendStream, + read_buf: Bytes, + active_streams: Arc, + send_closed: bool, +} + +impl H2MuxStream { + fn new( + response: H2MuxResponseFuture, + send: h2::SendStream, + active_streams: Arc, + ) -> Self { + Self { + response: Some(response), + recv: None, + send, + read_buf: Bytes::new(), + active_streams, + send_closed: false, + } + } +} + +impl Drop for H2MuxStream { + fn drop(&mut self) { + if !self.send_closed { + self.send.send_reset(h2::Reason::CANCEL); + } + self.active_streams.fetch_sub(1, Ordering::Relaxed); + } +} + +impl AsyncRead for H2MuxStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if !self.read_buf.is_empty() { + let n = buf.remaining().min(self.read_buf.len()); + buf.put_slice(&self.read_buf[..n]); + let _ = self.read_buf.split_to(n); + return Poll::Ready(Ok(())); + } + + if self.recv.is_none() { + let Some(response) = self.response.as_mut() else { + return Poll::Ready(Ok(())); + }; + let response = match response.as_mut().poll(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Ok(response)) => response, + Poll::Ready(Err(err)) => return Poll::Ready(Err(io::Error::other(err))), + }; + if response.status() != StatusCode::OK { + return Poll::Ready(Err(io::Error::other(format!( + "unexpected Shadowsocks sing-mux h2mux status {}", + response.status() + )))); + } + self.recv = Some(response.into_body()); + self.response = None; + } + + let recv = self.recv.as_mut().expect("h2mux response body initialized"); + match recv.poll_data(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Some(Ok(bytes))) => { + if bytes.is_empty() { + continue; + } + let len = bytes.len(); + if let Err(err) = recv.flow_control().release_capacity(len) { + return Poll::Ready(Err(io::Error::other(err))); + } + self.read_buf = bytes; + } + Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(io::Error::other(err))), + Poll::Ready(None) => return Poll::Ready(Ok(())), + } + } + } +} + +impl AsyncWrite for H2MuxStream { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + if self.send_closed { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Shadowsocks sing-mux h2mux stream is closed", + ))); + } + if buf.is_empty() { + return Poll::Ready(Ok(0)); + } + self.send + .send_data(Bytes::copy_from_slice(buf), false) + .map_err(io::Error::other)?; + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, _cx: &mut TaskContext<'_>) -> Poll> { + if !self.send_closed { + self.send + .send_data(Bytes::new(), true) + .map_err(io::Error::other)?; + self.send_closed = true; + } + Poll::Ready(Ok(())) + } +} + +struct CountingProxyIo { + inner: S, + active_streams: Arc, +} + +impl CountingProxyIo { + fn new(inner: S, active_streams: Arc) -> Self { + Self { inner, active_streams } + } +} + +impl Drop for CountingProxyIo { + fn drop(&mut self) { + self.active_streams.fetch_sub(1, Ordering::Relaxed); + } +} + +impl AsyncRead for CountingProxyIo +where + S: AsyncRead + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for CountingProxyIo +where + S: AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +struct SingMuxTcpStream { + inner: S, + response_read: bool, +} + +impl SingMuxTcpStream { + fn new(inner: S) -> Self { + Self { inner, response_read: false } + } +} + +impl AsyncRead for SingMuxTcpStream +where + S: AsyncRead + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if !self.response_read { + let mut status = [0u8; 1]; + let mut status_buf = ReadBuf::new(&mut status); + match Pin::new(&mut self.inner).poll_read(cx, &mut status_buf) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if status_buf.filled().is_empty() => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Shadowsocks sing-mux stream closed before response status", + ))); + } + Poll::Ready(Ok(())) => match status[0] { + SING_MUX_STREAM_STATUS_SUCCESS => self.response_read = true, + SING_MUX_STREAM_STATUS_ERROR => { + return Poll::Ready(Err(io::Error::other( + "Shadowsocks sing-mux remote error", + ))); + } + other => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unknown Shadowsocks sing-mux stream response status {other}"), + ))); + } + }, + } + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for SingMuxTcpStream +where + S: AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +struct ProxyIoAdapter { + inner: Box, +} + +impl ProxyIoAdapter { + fn new(inner: Box) -> Self { + Self { inner } + } +} + +impl AsyncRead for ProxyIoAdapter { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for ProxyIoAdapter { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +async fn custom_aead_plain_tcp_stream( + tcp: S, + target: &ProxyTcpTarget, + kind: CustomSsAeadKind, + master_key: Arc<[u8]>, +) -> Result> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let remote_addr = target.address; + let (reader, writer) = split(tcp); + let mut ss_reader = CustomSsAeadReader::new(reader, kind, master_key.clone()); + let mut ss_writer = CustomSsAeadWriter::new(writer, kind, master_key) + .await + .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; + let socks_target = socks_addr_for_target(target)?; + ss_writer + .write_chunk(&socks_target) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks target address for {remote_addr} with {}", + kind.name() + ) + })?; + + let (plain, bridge) = tokio::io::duplex(64 * 1024); + let (mut plain_reader, mut plain_writer) = split(bridge); + let _upload = tokio::spawn(async move { + let result = async { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = plain_reader + .read(&mut buf) + .await + .context("failed to read plaintext payload for Shadowsocks tcp upload")?; + if n == 0 { + break; + } + ss_writer.write_chunk(&buf[..n]).await?; + } + ss_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks tcp upload")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!( + ?err, + "Shadowsocks custom AEAD plaintext upload task stopped" + ); + } + }); + + let _download = tokio::spawn(async move { + let result = async { + let mut buf = Vec::new(); + loop { + buf.clear(); + match ss_reader.read_chunk(&mut buf).await { + Ok(0) => break, + Ok(_) => { + plain_writer + .write_all(&buf) + .await + .context("failed to write plaintext payload from Shadowsocks tcp")?; + } + Err(err) => return Err(err), + } + } + plain_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks tcp download")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!( + ?err, + "Shadowsocks custom AEAD plaintext download task stopped" + ); + } + }); + + Ok(Box::new(plain)) +} + +async fn custom_stream_plain_tcp_stream( + tcp: S, + target: &ProxyTcpTarget, + kind: CustomSsStreamKind, + key: Arc<[u8]>, +) -> Result> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let remote_addr = target.address; + let (reader, writer) = split(tcp); + let mut ss_reader = CustomSsStreamReader::new(reader, kind, key.clone()); + let mut ss_writer = CustomSsStreamWriter::new(writer, kind, key) + .await + .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; + let socks_target = socks_addr_for_target(target)?; + ss_writer + .write_all_encrypted(&socks_target) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks target address for {remote_addr} with {}", + kind.name() + ) + })?; + + let (plain, bridge) = tokio::io::duplex(64 * 1024); + let (mut plain_reader, mut plain_writer) = split(bridge); + let _upload = tokio::spawn(async move { + let result = async { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = plain_reader + .read(&mut buf) + .await + .context("failed to read plaintext payload for Shadowsocks tcp upload")?; + if n == 0 { + break; + } + ss_writer.write_all_encrypted(&buf[..n]).await?; + } + ss_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks tcp upload")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!( + ?err, + "Shadowsocks custom stream plaintext upload task stopped" + ); + } + }); + + let _download = tokio::spawn(async move { + let result = async { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = ss_reader.read_decrypted(&mut buf).await?; + if n == 0 { + break; + } + plain_writer + .write_all(&buf[..n]) + .await + .context("failed to write plaintext payload from Shadowsocks tcp")?; + } + plain_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks tcp download")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!( + ?err, + "Shadowsocks custom stream plaintext download task stopped" + ); + } + }); + + Ok(Box::new(plain)) +} + +async fn aead2022_aes_ccm_plain_tcp_stream( + tcp: S, + target: &ProxyTcpTarget, + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, +) -> Result> +where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + let remote_addr = target.address; + let (reader, writer) = split(tcp); + let mut ss_writer = Aead2022AesCcmWriter::new(writer, kind, user_key.clone(), identity_keys) + .await + .with_context(|| format!("failed to initialize {} tcp stream", kind.name()))?; + let request_salt = ss_writer + .write_request(&socks_addr_for_target(target)?, 1) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks 2022 target address for {remote_addr} with {}", + kind.name() + ) + })?; + let mut ss_reader = Aead2022AesCcmReader::new(reader, kind, user_key, request_salt); + + let (plain, bridge) = tokio::io::duplex(64 * 1024); + let (mut plain_reader, mut plain_writer) = split(bridge); + let _upload = tokio::spawn(async move { + let result = async { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = plain_reader + .read(&mut buf) + .await + .context("failed to read plaintext payload for Shadowsocks 2022 tcp upload")?; + if n == 0 { + break; + } + ss_writer.write_chunk(&buf[..n]).await?; + } + ss_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks 2022 tcp upload")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!(?err, "Shadowsocks 2022 plaintext upload task stopped"); + } + }); + + let _download = tokio::spawn(async move { + let result = async { + let mut buf = Vec::new(); + loop { + buf.clear(); + match ss_reader.read_chunk(&mut buf).await { + Ok(0) => break, + Ok(_) => { + plain_writer.write_all(&buf).await.context( + "failed to write plaintext payload from Shadowsocks 2022 tcp", + )?; + } + Err(err) => return Err(err), + } + } + plain_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks 2022 tcp download")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!(?err, "Shadowsocks 2022 plaintext download task stopped"); + } + }); + + Ok(Box::new(plain)) +} + +type TrojanStream = Box; + +struct SimpleObfsStream { + inner: S, + mode: SimpleObfsMode, + host: String, + port: u16, + first_write: bool, + first_read: bool, + write_buf: Vec, + write_offset: usize, + read_buf: Vec, + read_offset: usize, + tls_read_stage: Option, +} + +enum SimpleObfsTlsReadStage { + Discard { remaining: usize }, + Length { bytes: [u8; 2], filled: usize }, + Payload { bytes: Vec, filled: usize }, +} + +impl SimpleObfsStream { + fn new(inner: S, mode: SimpleObfsMode, host: String, port: u16) -> Self { + Self { + inner, + mode, + host, + port, + first_write: true, + first_read: true, + write_buf: Vec::new(), + write_offset: 0, + read_buf: Vec::new(), + read_offset: 0, + tls_read_stage: None, + } + } + + fn compact_write_buffer(&mut self) { + if self.write_offset == self.write_buf.len() { + self.write_buf.clear(); + self.write_offset = 0; + } + } + + fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + return false; + } + let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); + buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); + self.read_offset += len; + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + } + true + } +} + +impl AsyncRead for SimpleObfsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + match self.mode { + SimpleObfsMode::Http => self.poll_read_http(cx, buf), + SimpleObfsMode::Tls => self.poll_read_tls(cx, buf), + } + } +} + +impl SimpleObfsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read_http( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if !self.first_read { + return Pin::new(&mut self.inner).poll_read(cx, buf); + } + let mut response = vec![0u8; 16 * 1024]; + let mut response_buf = ReadBuf::new(&mut response); + match Pin::new(&mut self.inner).poll_read(cx, &mut response_buf) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => { + let filled = response_buf.filled().len(); + let Some(header_end) = response[..filled] + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + else { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "simple-obfs HTTP response header was incomplete", + ))); + }; + self.first_read = false; + self.read_buf + .extend_from_slice(&response[header_end..filled]); + self.copy_read_buffer(buf); + Poll::Ready(Ok(())) + } + } + } + + fn poll_read_tls( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + if self.tls_read_stage.is_none() { + let discard = if self.first_read { 105 } else { 3 }; + self.first_read = false; + self.tls_read_stage = Some(SimpleObfsTlsReadStage::Discard { remaining: discard }); + } + + match self.tls_read_stage.take().expect("stage initialized") { + SimpleObfsTlsReadStage::Discard { mut remaining } => { + while remaining > 0 { + let mut scratch = [0u8; 128]; + let read_len = remaining.min(scratch.len()); + let mut discard_buf = ReadBuf::new(&mut scratch[..read_len]); + match Pin::new(&mut self.inner).poll_read(cx, &mut discard_buf) { + Poll::Pending => { + self.tls_read_stage = + Some(SimpleObfsTlsReadStage::Discard { remaining }); + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if discard_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + remaining -= discard_buf.filled().len(); + } + } + } + self.tls_read_stage = + Some(SimpleObfsTlsReadStage::Length { bytes: [0u8; 2], filled: 0 }); + } + SimpleObfsTlsReadStage::Length { mut bytes, mut filled } => { + while filled < 2 { + let mut len_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut len_buf) { + Poll::Pending => { + self.tls_read_stage = + Some(SimpleObfsTlsReadStage::Length { bytes, filled }); + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if len_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + filled += len_buf.filled().len(); + } + } + } + let len = u16::from_be_bytes(bytes) as usize; + self.tls_read_stage = Some(SimpleObfsTlsReadStage::Payload { + bytes: vec![0u8; len], + filled: 0, + }); + } + SimpleObfsTlsReadStage::Payload { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { + Poll::Pending => { + self.tls_read_stage = + Some(SimpleObfsTlsReadStage::Payload { bytes, filled }); + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + filled += payload_buf.filled().len(); + } + } + } + self.tls_read_stage = None; + self.read_buf = bytes; + } + } + } + } +} + +impl AsyncWrite for SimpleObfsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + + if self.first_write { + self.first_write = false; + self.write_buf = match self.mode { + SimpleObfsMode::Http => simple_obfs_http_request(&self.host, self.port, buf), + SimpleObfsMode::Tls => simple_obfs_tls_client_hello(&self.host, buf)?, + }; + self.write_offset = 0; + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Ready(Ok(buf.len())), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + return Poll::Ready(Ok(buf.len())); + } + + match self.mode { + SimpleObfsMode::Http => Pin::new(&mut self.inner).poll_write(cx, buf), + SimpleObfsMode::Tls => { + self.write_buf = simple_obfs_tls_record(buf)?; + self.write_offset = 0; + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Ready(Ok(buf.len())), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Poll::Ready(Ok(buf.len())) + } + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +struct HmacReadStream { + inner: S, + hmac: hmac::Context, +} + +#[cfg(feature = "boring-browser-fingerprints")] +struct DetachableStream { + inner: Option, +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl DetachableStream { + fn new(inner: S) -> Self { + Self { inner: Some(inner) } + } + + fn take(&mut self) -> io::Result { + self.inner.take().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "detachable TLS stream already taken", + ) + }) + } + + fn inner_mut(&mut self) -> io::Result<&mut S> { + self.inner.as_mut().ok_or_else(|| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "detachable TLS stream already taken", + ) + }) + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl AsyncRead for DetachableStream +where + S: AsyncRead + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let inner = match self.inner_mut() { + Ok(inner) => inner, + Err(err) => return Poll::Ready(Err(err)), + }; + Pin::new(inner).poll_read(cx, buf) + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl AsyncWrite for DetachableStream +where + S: AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + let inner = match self.inner_mut() { + Ok(inner) => inner, + Err(err) => return Poll::Ready(Err(err)), + }; + Pin::new(inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let inner = match self.inner_mut() { + Ok(inner) => inner, + Err(err) => return Poll::Ready(Err(err)), + }; + Pin::new(inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let inner = match self.inner_mut() { + Ok(inner) => inner, + Err(err) => return Poll::Ready(Err(err)), + }; + Pin::new(inner).poll_shutdown(cx) + } +} + +impl HmacReadStream { + fn new(inner: S, password: &str) -> Self { + Self { + inner, + hmac: hmac::Context::with_key(&hmac::Key::new( + hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, + password.as_bytes(), + )), + } + } + + fn finish(self) -> (S, [u8; 8]) { + let digest = self.hmac.sign(); + let mut prefix = [0u8; 8]; + prefix.copy_from_slice(&digest.as_ref()[..8]); + (self.inner, prefix) + } +} + +impl AsyncRead for HmacReadStream +where + S: AsyncRead + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let before = buf.filled().len(); + match Pin::new(&mut self.inner).poll_read(cx, buf) { + Poll::Ready(Ok(())) => { + let filled = buf.filled(); + if filled.len() > before { + self.hmac.update(&filled[before..]); + } + Poll::Ready(Ok(())) + } + other => other, + } + } +} + +impl AsyncWrite for HmacReadStream +where + S: AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +struct ShadowTlsV2Stream { + inner: S, + first_write_hmac: Option<[u8; 8]>, + read_stage: ShadowTlsReadStage, + write_buf: Vec, + write_offset: usize, +} + +enum ShadowTlsReadStage { + Header { bytes: [u8; 5], filled: usize }, + Payload { remaining: usize }, +} + +impl ShadowTlsV2Stream { + fn new(inner: S, first_write_hmac: [u8; 8]) -> Self { + Self { + inner, + first_write_hmac: Some(first_write_hmac), + read_stage: ShadowTlsReadStage::Header { bytes: [0u8; 5], filled: 0 }, + write_buf: Vec::new(), + write_offset: 0, + } + } + + fn compact_write_buffer(&mut self) { + if self.write_offset == self.write_buf.len() { + self.write_buf.clear(); + self.write_offset = 0; + } + } +} + +impl AsyncRead for ShadowTlsV2Stream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + match self.read_stage { + ShadowTlsReadStage::Header { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut header_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { + Poll::Pending => { + self.read_stage = ShadowTlsReadStage::Header { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { + return Poll::Ready(Ok(())); + } + Poll::Ready(Ok(())) => { + filled += header_buf.filled().len(); + } + } + } + if bytes[0] != 23 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected ShadowTLS record type {}", bytes[0]), + ))); + } + let remaining = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; + self.read_stage = ShadowTlsReadStage::Payload { remaining }; + } + ShadowTlsReadStage::Payload { remaining } => { + if remaining == 0 { + self.read_stage = ShadowTlsReadStage::Header { bytes: [0u8; 5], filled: 0 }; + continue; + } + let before = buf.filled().len(); + let read_len = remaining.min(buf.remaining()); + if read_len == 0 { + return Poll::Ready(Ok(())); + } + let initialized = buf.initialize_unfilled_to(read_len); + let mut payload_buf = ReadBuf::new(initialized); + match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { + Poll::Pending => { + self.read_stage = ShadowTlsReadStage::Payload { remaining }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + let read = payload_buf.filled().len(); + buf.advance(read); + let remaining = remaining - read; + self.read_stage = if remaining == 0 { + ShadowTlsReadStage::Header { bytes: [0u8; 5], filled: 0 } + } else { + ShadowTlsReadStage::Payload { remaining } + }; + if buf.filled().len() > before { + return Poll::Ready(Ok(())); + } + } + } + } + } + } + } +} + +impl AsyncWrite for ShadowTlsV2Stream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + + let write_len = buf.len().min(SHADOW_TLS_MAX_RECORD_PAYLOAD); + self.write_buf = shadow_tls_v2_record(buf, write_len, self.first_write_hmac.take())?; + self.write_offset = 0; + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Ready(Ok(write_len)), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Poll::Ready(Ok(write_len)) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +struct ShadowTlsV3HandshakeStream { + inner: S, + password: String, + read_stage: ShadowTlsV3ReadStage, + read_buf: Vec, + read_offset: usize, + server_random: Option<[u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]>, + read_hmac: Option, + read_hmac_key: Option<[u8; 32]>, + is_tls13: bool, + authorized: bool, +} + +#[cfg(feature = "boring-browser-fingerprints")] +struct ShadowTlsV3SignedClientHelloStream { + inner: ShadowTlsV3HandshakeStream, + password: String, + client_hello_signed: bool, + client_hello_buf: Vec, + write_buf: Vec, + write_offset: usize, +} + +enum ShadowTlsV3ReadStage { + Header { + bytes: [u8; 5], + filled: usize, + }, + Payload { + header: [u8; 5], + bytes: Vec, + filled: usize, + }, +} + +impl ShadowTlsV3HandshakeStream { + fn new(inner: S, password: &str) -> Self { + Self { + inner, + password: password.to_owned(), + read_stage: ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, + read_buf: Vec::new(), + read_offset: 0, + server_random: None, + read_hmac: None, + read_hmac_key: None, + is_tls13: false, + authorized: false, + } + } + + fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + return false; + } + let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); + buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); + self.read_offset += len; + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + } + true + } + + fn process_frame(&mut self, mut frame: Vec) -> io::Result<()> { + match frame.first().copied() { + Some(0x16) => { + if self.read_hmac.is_none() + && frame.len() + >= SHADOW_TLS_V3_SERVER_RANDOM_INDEX + SHADOW_TLS_V3_TLS_RANDOM_SIZE + && frame[SHADOW_TLS_V3_TLS_HEADER_SIZE] == 0x02 + { + let mut server_random = [0u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]; + server_random.copy_from_slice( + &frame[SHADOW_TLS_V3_SERVER_RANDOM_INDEX + ..SHADOW_TLS_V3_SERVER_RANDOM_INDEX + SHADOW_TLS_V3_TLS_RANDOM_SIZE], + ); + let read_hmac = shadow_tls_v3_hmac(&self.password, &server_random, b""); + let read_hmac_key = shadow_tls_v3_kdf(&self.password, &server_random); + self.is_tls13 = shadow_tls_v3_server_hello_supports_tls13(&frame); + if !self.is_tls13 { + self.authorized = true; + } + self.server_random = Some(server_random); + self.read_hmac_key = Some(read_hmac_key); + self.read_hmac = Some(read_hmac); + } + self.read_buf = frame; + } + Some(0x17) => { + self.authorized = false; + if frame.len() > SHADOW_TLS_V3_HMAC_HEADER_SIZE { + if let (Some(read_hmac), Some(read_hmac_key)) = + (self.read_hmac.as_mut(), self.read_hmac_key.as_ref()) + { + read_hmac.update(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); + let expected = + shadow_tls_v3_hmac_prefix(read_hmac, SHADOW_TLS_V3_HMAC_SIZE); + if expected[..] + == frame[SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE] + { + shadow_tls_v3_xor( + &mut frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..], + read_hmac_key, + ); + let payload_len = frame.len() - SHADOW_TLS_V3_HMAC_HEADER_SIZE; + let mut rewritten = + Vec::with_capacity(SHADOW_TLS_V3_TLS_HEADER_SIZE + payload_len); + rewritten.extend_from_slice(&frame[..SHADOW_TLS_V3_TLS_HEADER_SIZE]); + rewritten[3..5].copy_from_slice(&(payload_len as u16).to_be_bytes()); + rewritten.extend_from_slice(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); + self.read_buf = rewritten; + self.authorized = true; + } else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ShadowTLS v3 handshake HMAC mismatch", + )); + } + } else { + self.read_buf = frame; + } + } else { + self.read_buf = frame; + } + } + _ => { + self.read_buf = frame; + } + } + self.read_offset = 0; + Ok(()) + } + + fn into_inner(self) -> (S, ShadowTlsV3HandshakeState) { + ( + self.inner, + ShadowTlsV3HandshakeState { + server_random: self.server_random, + read_hmac: self.read_hmac, + is_tls13: self.is_tls13, + authorized: self.authorized, + }, + ) + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl ShadowTlsV3SignedClientHelloStream { + fn new(inner: ShadowTlsV3HandshakeStream, password: &str) -> Self { + Self { + inner, + password: password.to_owned(), + client_hello_signed: false, + client_hello_buf: Vec::new(), + write_buf: Vec::new(), + write_offset: 0, + } + } + + fn into_inner(self) -> (ShadowTlsV3HandshakeStream, bool) { + (self.inner, self.client_hello_signed) + } + + fn queue_write(&mut self, buf: &[u8]) -> io::Result<()> { + if self.client_hello_signed { + self.write_buf.extend_from_slice(buf); + return Ok(()); + } + self.client_hello_buf.extend_from_slice(buf); + if let Some(signed) = + shadow_tls_v3_sign_client_hello_record(&self.password, &self.client_hello_buf)? + { + self.write_buf.extend_from_slice(&signed); + self.client_hello_buf.clear(); + self.client_hello_signed = true; + } + Ok(()) + } + + fn poll_write_pending(&mut self, cx: &mut TaskContext<'_>) -> Poll> + where + S: AsyncRead + AsyncWrite + Unpin, + { + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + } + } + } + self.write_buf.clear(); + self.write_offset = 0; + Poll::Ready(Ok(())) + } +} + +struct ShadowTlsV3HandshakeState { + server_random: Option<[u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]>, + read_hmac: Option, + is_tls13: bool, + authorized: bool, +} + +#[allow(dead_code)] +struct RestlsHandshakeStream { + inner: S, + secret: [u8; 32], + tls12_gcm: bool, + read_stage: RestlsHandshakeReadStage, + read_buf: Vec, + read_offset: usize, + write_buf: Vec, + write_offset: usize, + write_capture_buf: Vec, + server_random: Option<[u8; 32]>, + client_finished_auth: Option>, + server_auth_unmasked: bool, + tls12_gcm_server_disable_counter: bool, +} + +#[cfg(feature = "boring-browser-fingerprints")] +struct RestlsSignedClientHelloStream { + inner: RestlsHandshakeStream, + secret: [u8; 32], + client_hello_signed: bool, + client_hello_buf: Vec, + write_buf: Vec, + write_offset: usize, +} + +#[allow(dead_code)] +enum RestlsHandshakeReadStage { + Header { + bytes: [u8; TLS_RECORD_HEADER_LEN], + filled: usize, + }, + Payload { + header: [u8; TLS_RECORD_HEADER_LEN], + bytes: Vec, + filled: usize, + }, +} + +#[allow(dead_code)] +struct RestlsHandshakeState { + server_random: Option<[u8; 32]>, + client_finished_auth: Option>, + server_auth_unmasked: bool, + tls12_gcm_server_disable_counter: bool, +} + +#[allow(dead_code)] +impl RestlsHandshakeStream { + fn new(inner: S, secret: [u8; 32], tls12_gcm: bool) -> Self { + Self { + inner, + secret, + tls12_gcm, + read_stage: RestlsHandshakeReadStage::Header { + bytes: [0u8; TLS_RECORD_HEADER_LEN], + filled: 0, + }, + read_buf: Vec::new(), + read_offset: 0, + write_buf: Vec::new(), + write_offset: 0, + write_capture_buf: Vec::new(), + server_random: None, + client_finished_auth: None, + server_auth_unmasked: false, + tls12_gcm_server_disable_counter: false, + } + } + + fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + return false; + } + let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); + buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); + self.read_offset += len; + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + } + true + } + + fn compact_write_buffer(&mut self) { + if self.write_offset == self.write_buf.len() { + self.write_buf.clear(); + self.write_offset = 0; + } + } + + fn process_read_frame(&mut self, frame: Vec) -> io::Result<()> { + if let Some(server_random) = restls_server_hello_random(&frame) { + self.server_random = Some(server_random); + self.read_buf = frame; + } else if !self.server_auth_unmasked && frame.first().copied() == Some(0x17) { + let server_random = self.server_random.ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "Restls server-auth record arrived before ServerHello random", + ) + })?; + let unmasked = restls_unmask_server_auth_record( + &self.secret, + &server_random, + &frame, + self.tls12_gcm, + ) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + self.tls12_gcm_server_disable_counter = unmasked.tls12_gcm_server_disable_counter; + self.server_auth_unmasked = true; + self.read_buf = unmasked.record; + } else { + self.read_buf = frame; + } + self.read_offset = 0; + Ok(()) + } + + fn process_write_bytes(&mut self, buf: &[u8]) { + if self.client_finished_auth.is_some() { + return; + } + self.write_capture_buf.extend_from_slice(buf); + loop { + if self.write_capture_buf.len() < TLS_RECORD_HEADER_LEN { + return; + } + let payload_len = usize::from(u16::from_be_bytes([ + self.write_capture_buf[3], + self.write_capture_buf[4], + ])); + let record_len = TLS_RECORD_HEADER_LEN + payload_len; + if self.write_capture_buf.len() < record_len { + return; + } + let record = self.write_capture_buf[..record_len].to_vec(); + self.write_capture_buf.drain(..record_len); + if record.first().copied() == Some(TLS_APPLICATION_DATA_RECORD_TYPE) { + self.client_finished_auth = Some(record); + self.write_capture_buf.clear(); + return; + } + } + } + + fn into_inner(self) -> (S, RestlsHandshakeState) { + ( + self.inner, + RestlsHandshakeState { + server_random: self.server_random, + client_finished_auth: self.client_finished_auth, + server_auth_unmasked: self.server_auth_unmasked, + tls12_gcm_server_disable_counter: self.tls12_gcm_server_disable_counter, + }, + ) + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl RestlsSignedClientHelloStream { + fn new(inner: RestlsHandshakeStream, secret: [u8; 32]) -> Self { + Self { + inner, + secret, + client_hello_signed: false, + client_hello_buf: Vec::new(), + write_buf: Vec::new(), + write_offset: 0, + } + } + + fn into_inner(self) -> (RestlsHandshakeStream, bool) { + (self.inner, self.client_hello_signed) + } + + fn queue_write(&mut self, buf: &[u8]) -> io::Result<()> { + if self.client_hello_signed { + self.write_buf.extend_from_slice(buf); + return Ok(()); + } + self.client_hello_buf.extend_from_slice(buf); + if let Some(signed) = + restls_tls13_sign_client_hello_record(&self.secret, &self.client_hello_buf)? + { + self.write_buf.extend_from_slice(&signed); + self.client_hello_buf.clear(); + self.client_hello_signed = true; + } + Ok(()) + } + + fn poll_write_pending(&mut self, cx: &mut TaskContext<'_>) -> Poll> + where + S: AsyncRead + AsyncWrite + Unpin, + { + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + } + } + } + self.write_buf.clear(); + self.write_offset = 0; + Poll::Ready(Ok(())) + } +} + +impl AsyncRead for RestlsHandshakeStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + loop { + match std::mem::replace( + &mut self.read_stage, + RestlsHandshakeReadStage::Header { + bytes: [0u8; TLS_RECORD_HEADER_LEN], + filled: 0, + }, + ) { + RestlsHandshakeReadStage::Header { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut header_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { + Poll::Pending => { + self.read_stage = + RestlsHandshakeReadStage::Header { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { + self.read_stage = + RestlsHandshakeReadStage::Header { bytes, filled }; + return Poll::Ready(Ok(())); + } + Poll::Ready(Ok(())) => { + filled += header_buf.filled().len(); + } + } + } + let remaining = usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); + self.read_stage = RestlsHandshakeReadStage::Payload { + header: bytes, + bytes: vec![0u8; remaining], + filled: 0, + }; + } + RestlsHandshakeReadStage::Payload { header, mut bytes, mut filled } => { + while filled < bytes.len() { + let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { + Poll::Pending => { + self.read_stage = + RestlsHandshakeReadStage::Payload { header, bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + filled += payload_buf.filled().len(); + } + } + } + let mut frame = Vec::with_capacity(TLS_RECORD_HEADER_LEN + bytes.len()); + frame.extend_from_slice(&header); + frame.extend_from_slice(&bytes); + self.process_read_frame(frame)?; + self.read_stage = RestlsHandshakeReadStage::Header { + bytes: [0u8; TLS_RECORD_HEADER_LEN], + filled: 0, + }; + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + } + } + } + } +} + +impl AsyncWrite for RestlsHandshakeStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + self.process_write_bytes(buf); + match Pin::new(&mut self.inner).poll_write(cx, buf) { + Poll::Pending => Poll::Pending, + Poll::Ready(result) => Poll::Ready(result), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl AsyncRead for RestlsSignedClientHelloStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.poll_write_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => {} + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl AsyncWrite for RestlsSignedClientHelloStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + if let Err(err) = self.queue_write(buf) { + return Poll::Ready(Err(err)); + } + match self.poll_write_pending(cx) { + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Pending | Poll::Ready(Ok(())) => Poll::Ready(Ok(buf.len())), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + if !self.client_hello_signed && !self.client_hello_buf.is_empty() { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "incomplete Restls ClientHello before flush", + ))); + } + match self.poll_write_pending(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_flush(cx), + } + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +#[allow(dead_code)] +impl RestlsStream { + fn new(inner: S, state: RestlsStreamState) -> Self { + Self { + inner, + state, + read_stage: RestlsRecordReadStage::Header { + bytes: [0u8; TLS_RECORD_HEADER_LEN], + filled: 0, + }, + read_buf: Vec::new(), + read_offset: 0, + write_buf: Vec::new(), + write_offset: 0, + } + } + + fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + return false; + } + let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); + buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); + self.read_offset += len; + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + } + true + } + + fn queue_records(&mut self, records: Vec>) { + for record in records { + self.write_buf.extend_from_slice(&record); + } + } + + fn compact_write_buffer(&mut self) { + if self.write_offset == self.write_buf.len() { + self.write_buf.clear(); + self.write_offset = 0; + } + } + + fn process_read_frame(&mut self, frame: Vec) -> io::Result<()> { + let read = self + .state + .read_to_client_record(&frame) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + self.queue_records(read.records); + self.read_buf = read.data; + self.read_offset = 0; + Ok(()) + } +} + +impl AsyncRead for RestlsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + match std::mem::replace( + &mut self.read_stage, + RestlsRecordReadStage::Header { + bytes: [0u8; TLS_RECORD_HEADER_LEN], + filled: 0, + }, + ) { + RestlsRecordReadStage::Header { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut header_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { + Poll::Pending => { + self.read_stage = RestlsRecordReadStage::Header { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { + self.read_stage = RestlsRecordReadStage::Header { bytes, filled }; + return Poll::Ready(Ok(())); + } + Poll::Ready(Ok(())) => { + filled += header_buf.filled().len(); + } + } + } + let remaining = usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); + self.read_stage = RestlsRecordReadStage::Payload { + header: bytes, + bytes: vec![0u8; remaining], + filled: 0, + }; + } + RestlsRecordReadStage::Payload { header, mut bytes, mut filled } => { + while filled < bytes.len() { + let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { + Poll::Pending => { + self.read_stage = + RestlsRecordReadStage::Payload { header, bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + filled += payload_buf.filled().len(); + } + } + } + let mut frame = Vec::with_capacity(TLS_RECORD_HEADER_LEN + bytes.len()); + frame.extend_from_slice(&header); + frame.extend_from_slice(&bytes); + self.process_read_frame(frame)?; + self.read_stage = RestlsRecordReadStage::Header { + bytes: [0u8; TLS_RECORD_HEADER_LEN], + filled: 0, + }; + } + } + } + } +} + +impl AsyncWrite for RestlsStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + let write = self + .state + .write(buf) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + let accepted = write.accepted; + self.queue_records(write.records); + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Ready(Ok(accepted)), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Poll::Ready(Ok(accepted)) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +impl AsyncRead for ShadowTlsV3HandshakeStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + match std::mem::replace( + &mut self.read_stage, + ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, + ) { + ShadowTlsV3ReadStage::Header { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut header_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { + Poll::Pending => { + self.read_stage = ShadowTlsV3ReadStage::Header { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { + return Poll::Ready(Ok(())); + } + Poll::Ready(Ok(())) => { + filled += header_buf.filled().len(); + } + } + } + let len = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; + self.read_stage = ShadowTlsV3ReadStage::Payload { + header: bytes, + bytes: vec![0u8; len], + filled: 0, + }; + } + ShadowTlsV3ReadStage::Payload { header, mut bytes, mut filled } => { + while filled < bytes.len() { + let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { + Poll::Pending => { + self.read_stage = + ShadowTlsV3ReadStage::Payload { header, bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + filled += payload_buf.filled().len(); + } + } + } + let mut frame = Vec::with_capacity(header.len() + bytes.len()); + frame.extend_from_slice(&header); + frame.extend_from_slice(&bytes); + if let Err(err) = self.process_frame(frame) { + return Poll::Ready(Err(err)); + } + self.read_stage = ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }; + } + } + } + } +} + +impl AsyncWrite for ShadowTlsV3HandshakeStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl AsyncRead for ShadowTlsV3SignedClientHelloStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + match self.poll_write_pending(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => {} + } + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +impl AsyncWrite for ShadowTlsV3SignedClientHelloStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + if let Err(err) = self.queue_write(buf) { + return Poll::Ready(Err(err)); + } + match self.poll_write_pending(cx) { + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Pending | Poll::Ready(Ok(())) => Poll::Ready(Ok(buf.len())), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + if !self.client_hello_signed && !self.client_hello_buf.is_empty() { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "incomplete ShadowTLS v3 ClientHello before flush", + ))); + } + match self.poll_write_pending(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_flush(cx), + } + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +struct ShadowTlsV3Stream { + inner: S, + hmac_add: hmac::Context, + hmac_verify: hmac::Context, + hmac_ignore: Option, + read_buf: Vec, + read_offset: usize, + read_stage: ShadowTlsV3ReadStage, + write_buf: Vec, + write_offset: usize, +} + +impl ShadowTlsV3Stream { + fn new( + inner: S, + password: &str, + server_random: [u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE], + hmac_ignore: Option, + ) -> Self { + Self { + inner, + hmac_add: shadow_tls_v3_hmac(password, &server_random, b"C"), + hmac_verify: shadow_tls_v3_hmac(password, &server_random, b"S"), + hmac_ignore, + read_buf: Vec::new(), + read_offset: 0, + read_stage: ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, + write_buf: Vec::new(), + write_offset: 0, + } + } + + fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + return false; + } + let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); + buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); + self.read_offset += len; + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + } + true + } + + fn process_frame(&mut self, frame: Vec) -> io::Result { + match frame.first().copied() { + Some(0x15) => Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + "ShadowTLS v3 remote alert", + )), + Some(0x17) => { + if let Some(hmac_ignore) = self.hmac_ignore.as_ref() { + if shadow_tls_v3_application_data_hmac(&frame, hmac_ignore).is_some_and( + |expected| { + expected[..] + == frame + [SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE] + }, + ) { + return Ok(false); + } + } + self.hmac_ignore = None; + let Some(expected) = shadow_tls_v3_application_data_hmac(&frame, &self.hmac_verify) + else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ShadowTLS v3 application data frame was invalid", + )); + }; + if expected[..] + != frame[SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE] + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ShadowTLS v3 application data verification failed", + )); + } + self.hmac_verify + .update(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); + self.hmac_verify.update(&expected); + self.read_buf = frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..].to_vec(); + self.read_offset = 0; + Ok(true) + } + Some(other) => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("unexpected ShadowTLS v3 record type {other}"), + )), + None => Ok(false), + } + } + + fn compact_write_buffer(&mut self) { + if self.write_offset == self.write_buf.len() { + self.write_buf.clear(); + self.write_offset = 0; + } + } +} + +impl AsyncRead for ShadowTlsV3Stream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + loop { + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + match std::mem::replace( + &mut self.read_stage, + ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }, + ) { + ShadowTlsV3ReadStage::Header { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut header_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut header_buf) { + Poll::Pending => { + self.read_stage = ShadowTlsV3ReadStage::Header { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if header_buf.filled().is_empty() => { + return Poll::Ready(Ok(())); + } + Poll::Ready(Ok(())) => filled += header_buf.filled().len(), + } + } + let len = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; + self.read_stage = ShadowTlsV3ReadStage::Payload { + header: bytes, + bytes: vec![0u8; len], + filled: 0, + }; + } + ShadowTlsV3ReadStage::Payload { header, mut bytes, mut filled } => { + while filled < bytes.len() { + let mut payload_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut payload_buf) { + Poll::Pending => { + self.read_stage = + ShadowTlsV3ReadStage::Payload { header, bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if payload_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += payload_buf.filled().len(), + } + } + let mut frame = Vec::with_capacity(header.len() + bytes.len()); + frame.extend_from_slice(&header); + frame.extend_from_slice(&bytes); + match self.process_frame(frame) { + Ok(true) => { + self.read_stage = + ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }; + continue; + } + Ok(false) => { + self.read_stage = + ShadowTlsV3ReadStage::Header { bytes: [0u8; 5], filled: 0 }; + continue; + } + Err(err) => return Poll::Ready(Err(err)), + } + } + } + } + } +} + +impl AsyncWrite for ShadowTlsV3Stream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + let write_len = buf.len().min(SHADOW_TLS_MAX_RECORD_PAYLOAD); + self.write_buf = shadow_tls_v3_record(buf, write_len, &mut self.hmac_add)?; + self.write_offset = 0; + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Ready(Ok(write_len)), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Poll::Ready(Ok(write_len)) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + while self.write_offset < self.write_buf.len() { + let offset = self.write_offset; + let pending = self.write_buf[offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +struct RateLimitedStream { + inner: S, + bytes_per_second: u64, + burst_capacity: u64, + available: u64, + last_refill: Instant, + sleep: Option>>, +} + +impl RateLimitedStream { + fn new(inner: S, bytes_per_second: u32) -> Self { + let burst_capacity = KCPTUN_RATE_LIMIT_BURST_BYTES; + Self { + inner, + bytes_per_second: u64::from(bytes_per_second), + burst_capacity, + available: burst_capacity, + last_refill: Instant::now(), + sleep: None, + } + } + + fn refill(&mut self) { + if self.bytes_per_second == 0 { + self.available = self.burst_capacity; + self.last_refill = Instant::now(); + return; + } + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.last_refill); + let tokens = (elapsed + .as_nanos() + .saturating_mul(u128::from(self.bytes_per_second)) + / 1_000_000_000) as u64; + if tokens > 0 { + self.available = self + .available + .saturating_add(tokens) + .min(self.burst_capacity); + self.last_refill = now; + } + } + + fn wait_duration(&self) -> Duration { + if self.bytes_per_second == 0 { + return Duration::ZERO; + } + let nanos = + 1_000_000_000_u64.saturating_add(self.bytes_per_second - 1) / self.bytes_per_second; + Duration::from_nanos(nanos.max(1)) + } +} + +impl AsyncRead for RateLimitedStream +where + S: AsyncRead + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for RateLimitedStream +where + S: AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + if buf.is_empty() || self.bytes_per_second == 0 { + return Pin::new(&mut self.inner).poll_write(cx, buf); + } + + loop { + self.refill(); + if self.available > 0 { + break; + } + if self.sleep.is_none() { + self.sleep = Some(Box::pin(tokio::time::sleep(self.wait_duration()))); + } + let sleep = self.sleep.as_mut().expect("rate limit sleep exists"); + match Future::poll(sleep.as_mut(), cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(()) => { + self.sleep = None; + self.refill(); + if self.available == 0 { + self.available = 1; + } + } + } + } + + let write_len = buf.len().min(self.available as usize); + match Pin::new(&mut self.inner).poll_write(cx, &buf[..write_len]) { + Poll::Ready(Ok(written)) => { + self.available = self.available.saturating_sub(written as u64); + Poll::Ready(Ok(written)) + } + other => other, + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +struct WebSocketStream { + inner: S, + read_stage: WebSocketReadStage, + read_buf: Vec, + read_offset: usize, + write_buf: Vec, + write_offset: usize, +} + +enum WebSocketReadStage { + Header { + bytes: [u8; 2], + filled: usize, + }, + ExtendedLength { + header: [u8; 2], + bytes: [u8; 8], + needed: usize, + filled: usize, + }, + Mask { + opcode: u8, + len: usize, + bytes: [u8; 4], + filled: usize, + }, + Payload { + opcode: u8, + len: usize, + mask: Option<[u8; 4]>, + bytes: Vec, + filled: usize, + }, +} + +impl WebSocketStream { + fn new(inner: S) -> Self { + Self { + inner, + read_stage: WebSocketReadStage::Header { bytes: [0u8; 2], filled: 0 }, + read_buf: Vec::new(), + read_offset: 0, + write_buf: Vec::new(), + write_offset: 0, + } + } + + fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + return false; + } + let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); + buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); + self.read_offset += len; + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + } + true + } + + fn compact_write_buffer(&mut self) { + if self.write_offset == self.write_buf.len() { + self.write_buf.clear(); + self.write_offset = 0; + } + } +} + +impl AsyncRead for WebSocketStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + + loop { + let stage = std::mem::replace( + &mut self.read_stage, + WebSocketReadStage::Header { bytes: [0u8; 2], filled: 0 }, + ); + match stage { + WebSocketReadStage::Header { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut read_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = WebSocketReadStage::Header { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + if filled == 0 { + return Poll::Ready(Ok(())); + } + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + let len_marker = bytes[1] & 0x7f; + if len_marker < 126 { + let len = usize::from(len_marker); + self.read_stage = if bytes[1] & 0x80 == 0 { + WebSocketReadStage::Payload { + opcode: bytes[0] & 0x0f, + len, + mask: None, + bytes: vec![0u8; len], + filled: 0, + } + } else { + WebSocketReadStage::Mask { + opcode: bytes[0] & 0x0f, + len, + bytes: [0u8; 4], + filled: 0, + } + }; + } else { + let needed = if len_marker == 126 { 2 } else { 8 }; + self.read_stage = WebSocketReadStage::ExtendedLength { + header: bytes, + bytes: [0u8; 8], + needed, + filled: 0, + }; + } + } + WebSocketReadStage::ExtendedLength { + header, + mut bytes, + needed, + mut filled, + } => { + while filled < needed { + let mut read_buf = ReadBuf::new(&mut bytes[filled..needed]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = WebSocketReadStage::ExtendedLength { + header, + bytes, + needed, + filled, + }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + let len = if needed == 2 { + u16::from_be_bytes([bytes[0], bytes[1]]) as usize + } else { + let len = u64::from_be_bytes(bytes); + match usize::try_from(len) { + Ok(len) => len, + Err(_) => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "websocket frame is too large", + ))); + } + } + }; + if len > 16 * 1024 * 1024 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "websocket frame exceeds Burrow frame limit", + ))); + } + self.read_stage = if header[1] & 0x80 == 0 { + WebSocketReadStage::Payload { + opcode: header[0] & 0x0f, + len, + mask: None, + bytes: vec![0u8; len], + filled: 0, + } + } else { + WebSocketReadStage::Mask { + opcode: header[0] & 0x0f, + len, + bytes: [0u8; 4], + filled: 0, + } + }; + } + WebSocketReadStage::Mask { + opcode, + len, + mut bytes, + mut filled, + } => { + while filled < bytes.len() { + let mut read_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = + WebSocketReadStage::Mask { opcode, len, bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + self.read_stage = WebSocketReadStage::Payload { + opcode, + len, + mask: Some(bytes), + bytes: vec![0u8; len], + filled: 0, + }; + } + WebSocketReadStage::Payload { + opcode, + len, + mask, + mut bytes, + mut filled, + } => { + while filled < len { + let mut read_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = WebSocketReadStage::Payload { + opcode, + len, + mask, + bytes, + filled, + }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + if let Some(mask) = mask { + apply_websocket_mask(&mut bytes, mask); + } + self.read_stage = WebSocketReadStage::Header { bytes: [0u8; 2], filled: 0 }; + match opcode { + 0x0 | 0x1 | 0x2 => { + self.read_buf = bytes; + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + } + 0x8 => return Poll::Ready(Ok(())), + _ => {} + } + } + } + } + } +} + +impl AsyncWrite for WebSocketStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + + self.write_buf = websocket_binary_frame(buf)?; + self.write_offset = 0; + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Ready(Ok(buf.len())), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +struct HttpUpgradeFastOpenStream { + inner: Box, + response: Vec, + validated: bool, +} + +impl HttpUpgradeFastOpenStream { + fn new(inner: Box) -> Self { + Self { + inner, + response: Vec::with_capacity(1024), + validated: false, + } + } +} + +impl AsyncRead for HttpUpgradeFastOpenStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + while !self.validated { + if self.response.len() >= 16 * 1024 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "websocket plugin response headers exceeded Burrow limit", + ))); + } + + let mut byte = [0u8; 1]; + let mut read_buf = ReadBuf::new(&mut byte); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => { + self.response.push(read_buf.filled()[0]); + if self.response.ends_with(b"\r\n\r\n") { + let response = match std::str::from_utf8(&self.response) { + Ok(response) => response, + Err(_) => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "websocket plugin response headers were not valid UTF-8", + ))); + } + }; + if let Err(err) = validate_websocket_upgrade_response(response, true, None) + { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + err.to_string(), + ))); + } + self.validated = true; + self.response.clear(); + } + } + } + } + + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for HttpUpgradeFastOpenStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.inner).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + Pin::new(&mut self.inner).poll_shutdown(cx) + } +} + +struct WebSocketEarlyDataStream { + inner: WebSocketEarlyDataInner, + config: ShadowsocksWebSocketTransport, + path: String, + port: u16, + max_early_data: usize, + request: Vec, + request_offset: usize, + request_flushed: bool, + response: Vec, + first_write_consumed: Option, + websocket_key: Option, +} + +enum WebSocketEarlyDataInner { + Pending(Box), + Raw(Box), + Framed(WebSocketStream>), + Empty, +} + +impl WebSocketEarlyDataStream { + fn new( + inner: Box, + config: ShadowsocksWebSocketTransport, + port: u16, + path: String, + max_early_data: usize, + ) -> Self { + Self { + inner: WebSocketEarlyDataInner::Pending(inner), + config, + path, + port, + max_early_data, + request: Vec::new(), + request_offset: 0, + request_flushed: false, + response: Vec::with_capacity(1024), + first_write_consumed: None, + websocket_key: None, + } + } + + fn start_handshake(&mut self, payload: &[u8]) -> io::Result<()> { + if self.first_write_consumed.is_some() { + return Ok(()); + } + let consumed = payload.len().min(self.max_early_data); + let (request, websocket_key) = websocket_plugin_request( + &self.config, + self.port, + &self.path, + (consumed > 0).then_some(&payload[..consumed]), + ) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err.to_string()))?; + self.request = request; + self.websocket_key = websocket_key; + self.first_write_consumed = Some(consumed); + Ok(()) + } + + fn poll_handshake(&mut self, cx: &mut TaskContext<'_>) -> Poll> { + loop { + let WebSocketEarlyDataInner::Pending(stream) = &mut self.inner else { + return Poll::Ready(Ok(())); + }; + while self.request_offset < self.request.len() { + let pending = self.request[self.request_offset..].to_vec(); + match Pin::new(&mut **stream).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => { + return Poll::Ready(Err(io::ErrorKind::WriteZero.into())); + } + Poll::Ready(Ok(written)) => self.request_offset += written, + } + } + + if !self.request_flushed { + match Pin::new(&mut **stream).poll_flush(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => self.request_flushed = true, + } + } + + if self.config.v2ray_http_upgrade && self.config.v2ray_http_upgrade_fast_open { + let stream = + match std::mem::replace(&mut self.inner, WebSocketEarlyDataInner::Empty) { + WebSocketEarlyDataInner::Pending(stream) => stream, + _ => unreachable!("early-data fast-open stream must be pending"), + }; + self.inner = + WebSocketEarlyDataInner::Raw(Box::new(HttpUpgradeFastOpenStream::new(stream))); + self.request.clear(); + self.response.clear(); + return Poll::Ready(Ok(())); + } + + while !self.response.ends_with(b"\r\n\r\n") { + if self.response.len() >= 16 * 1024 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "websocket plugin response headers exceeded Burrow limit", + ))); + } + let mut byte = [0u8; 1]; + let mut read_buf = ReadBuf::new(&mut byte); + match Pin::new(&mut **stream).poll_read(cx, &mut read_buf) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => self.response.push(read_buf.filled()[0]), + } + } + + let response = match std::str::from_utf8(&self.response) { + Ok(response) => response, + Err(_) => { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "websocket plugin response headers were not valid UTF-8", + ))); + } + }; + validate_websocket_upgrade_response( + response, + self.config.v2ray_http_upgrade, + self.websocket_key.as_deref(), + ) + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?; + let stream = match std::mem::replace(&mut self.inner, WebSocketEarlyDataInner::Empty) { + WebSocketEarlyDataInner::Pending(stream) => stream, + _ => unreachable!("early-data stream must be pending before validation"), + }; + self.inner = if self.config.v2ray_http_upgrade { + WebSocketEarlyDataInner::Raw(stream) + } else { + WebSocketEarlyDataInner::Framed(WebSocketStream::new(stream)) + }; + self.request.clear(); + self.response.clear(); + return Poll::Ready(Ok(())); + } + } +} + +impl AsyncRead for WebSocketEarlyDataStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if matches!(self.inner, WebSocketEarlyDataInner::Pending(_)) { + if let Err(err) = self.start_handshake(&[]) { + return Poll::Ready(Err(err)); + } + match self.poll_handshake(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => {} + } + } + + match &mut self.inner { + WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_read(cx, buf), + WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_read(cx, buf), + WebSocketEarlyDataInner::Pending(_) => { + unreachable!("pending early-data stream handled") + } + WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), + } + } +} + +impl AsyncWrite for WebSocketEarlyDataStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + if matches!(self.inner, WebSocketEarlyDataInner::Pending(_)) { + if let Err(err) = self.start_handshake(buf) { + return Poll::Ready(Err(err)); + } + match self.poll_handshake(cx) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => { + return Poll::Ready(Ok(self.first_write_consumed.take().unwrap_or(0))); + } + } + } + + match &mut self.inner { + WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_write(cx, buf), + WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_write(cx, buf), + WebSocketEarlyDataInner::Pending(_) => { + unreachable!("pending early-data stream handled") + } + WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match &mut self.inner { + WebSocketEarlyDataInner::Pending(stream) => Pin::new(&mut **stream).poll_flush(cx), + WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_flush(cx), + WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_flush(cx), + WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), + } + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match &mut self.inner { + WebSocketEarlyDataInner::Pending(stream) => Pin::new(&mut **stream).poll_shutdown(cx), + WebSocketEarlyDataInner::Raw(stream) => Pin::new(&mut **stream).poll_shutdown(cx), + WebSocketEarlyDataInner::Framed(stream) => Pin::new(stream).poll_shutdown(cx), + WebSocketEarlyDataInner::Empty => Poll::Ready(Err(io::ErrorKind::NotConnected.into())), + } + } +} + +struct V2rayMuxStream { + inner: S, + read_stage: V2rayMuxReadStage, + read_buf: Vec, + read_offset: usize, + write_buf: Vec, + write_offset: usize, + wrote_open: bool, +} + +enum V2rayMuxReadStage { + MetadataLength { bytes: [u8; 2], filled: usize }, + Metadata { bytes: Vec, filled: usize }, + DataLength { bytes: [u8; 2], filled: usize }, + Data { bytes: Vec, filled: usize }, +} + +impl V2rayMuxStream { + fn new(inner: S) -> Self { + Self { + inner, + read_stage: V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }, + read_buf: Vec::new(), + read_offset: 0, + write_buf: Vec::new(), + write_offset: 0, + wrote_open: false, + } + } + + fn copy_read_buffer(&mut self, buf: &mut ReadBuf<'_>) -> bool { + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + return false; + } + let len = (self.read_buf.len() - self.read_offset).min(buf.remaining()); + buf.put_slice(&self.read_buf[self.read_offset..self.read_offset + len]); + self.read_offset += len; + if self.read_offset == self.read_buf.len() { + self.read_buf.clear(); + self.read_offset = 0; + } + true + } + + fn compact_write_buffer(&mut self) { + if self.write_offset == self.write_buf.len() { + self.write_buf.clear(); + self.write_offset = 0; + } + } +} + +impl AsyncRead for V2rayMuxStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + + loop { + let stage = std::mem::replace( + &mut self.read_stage, + V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }, + ); + match stage { + V2rayMuxReadStage::MetadataLength { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut read_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = + V2rayMuxReadStage::MetadataLength { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + if filled == 0 { + return Poll::Ready(Ok(())); + } + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + let len = u16::from_be_bytes(bytes) as usize; + if len > 512 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "v2ray-plugin mux metadata is too large", + ))); + } + self.read_stage = V2rayMuxReadStage::Metadata { + bytes: vec![0u8; len], + filled: 0, + }; + } + V2rayMuxReadStage::Metadata { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut read_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = V2rayMuxReadStage::Metadata { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + if bytes.len() < 4 { + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::InvalidData, + "v2ray-plugin mux metadata is incomplete", + ))); + } + let status = bytes[2]; + let option = bytes[3]; + if status == 0x04 || option != 0x01 { + self.read_stage = + V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }; + } else { + self.read_stage = + V2rayMuxReadStage::DataLength { bytes: [0u8; 2], filled: 0 }; + } + } + V2rayMuxReadStage::DataLength { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut read_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = V2rayMuxReadStage::DataLength { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + let len = u16::from_be_bytes(bytes) as usize; + self.read_stage = V2rayMuxReadStage::Data { + bytes: vec![0u8; len], + filled: 0, + }; + } + V2rayMuxReadStage::Data { mut bytes, mut filled } => { + while filled < bytes.len() { + let mut read_buf = ReadBuf::new(&mut bytes[filled..]); + match Pin::new(&mut self.inner).poll_read(cx, &mut read_buf) { + Poll::Pending => { + self.read_stage = V2rayMuxReadStage::Data { bytes, filled }; + return Poll::Pending; + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(())) if read_buf.filled().is_empty() => { + return Poll::Ready(Err(io::ErrorKind::UnexpectedEof.into())); + } + Poll::Ready(Ok(())) => filled += read_buf.filled().len(), + } + } + self.read_stage = + V2rayMuxReadStage::MetadataLength { bytes: [0u8; 2], filled: 0 }; + self.read_buf = bytes; + if self.copy_read_buffer(buf) { + return Poll::Ready(Ok(())); + } + } + } + } + } +} + +impl AsyncWrite for V2rayMuxStream +where + S: AsyncRead + AsyncWrite + Unpin, +{ + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut TaskContext<'_>, + buf: &[u8], + ) -> Poll> { + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + + let payload_len = buf.len().min(u16::MAX as usize); + let payload = &buf[..payload_len]; + self.write_buf = if self.wrote_open { + v2ray_mux_data_frame(payload)? + } else { + self.wrote_open = true; + let mut frame = v2ray_mux_open_frame(); + frame.extend_from_slice(&v2ray_mux_data_frame(payload)?); + frame + }; + self.write_offset = 0; + + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Ready(Ok(payload_len)), + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Poll::Ready(Ok(payload_len)) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + while self.write_offset < self.write_buf.len() { + let pending = self.write_buf[self.write_offset..].to_vec(); + match Pin::new(&mut self.inner).poll_write(cx, &pending) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Ready(Ok(0)) => return Poll::Ready(Err(io::ErrorKind::WriteZero.into())), + Poll::Ready(Ok(written)) => { + self.write_offset += written; + self.compact_write_buffer(); + } + } + } + Pin::new(&mut self.inner).poll_flush(cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + match self.as_mut().poll_flush(cx) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(err)) => Poll::Ready(Err(err)), + Poll::Ready(Ok(())) => Pin::new(&mut self.inner).poll_shutdown(cx), + } + } +} + +#[cfg(target_vendor = "apple")] +fn verify_native_tls_peer_certificate( + tls: &tokio_native_tls::TlsStream, + fingerprint: &CertificateFingerprint, + protocol: &str, +) -> Result<()> { + let cert = tls + .get_ref() + .peer_certificate() + .with_context(|| format!("failed to read {protocol} native TLS peer certificate"))? + .ok_or_else(|| anyhow!("{protocol} native TLS peer did not provide a certificate"))?; + let digest = Sha256::digest( + cert.to_der() + .with_context(|| format!("failed to encode {protocol} native TLS peer certificate"))?, + ); + if digest.as_slice() != fingerprint.0 { + bail!("{protocol} certificate fingerprint mismatch"); + } + Ok(()) +} + +#[cfg(not(target_vendor = "apple"))] +fn trojan_tls_config( + alpn: &[String], + skip_cert_verify: bool, + certificate_fingerprint: Option<&CertificateFingerprint>, +) -> Result { + rustls_tls_config(alpn, skip_cert_verify, certificate_fingerprint, None, None) +} + +fn rustls_tls_config( + alpn: &[String], + skip_cert_verify: bool, + certificate_fingerprint: Option<&CertificateFingerprint>, + client_identity: Option<&TlsClientIdentity>, + ech_config_list: Option<&[u8]>, +) -> Result { + install_rustls_crypto_provider(); + let mut root_store = RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let builder = if let Some(ech_config_list) = ech_config_list { + let ech_config = rustls_ech_config(ech_config_list)?; + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::aws_lc_rs::default_provider())) + .with_ech(EchMode::Enable(ech_config)) + .context("failed to enable ECH for Shadowsocks websocket plugin")? + } else { + ClientConfig::builder() + }; + let builder = builder.with_root_certificates(root_store); + let mut config = if let Some(identity) = client_identity { + let (cert_chain, private_key) = load_rustls_client_identity(identity)?; + builder + .with_client_auth_cert(cert_chain, private_key) + .context("failed to configure TLS client certificate")? + } else { + builder.with_no_client_auth() + }; + config.alpn_protocols = alpn.iter().map(|value| value.as_bytes().to_vec()).collect(); + if let Some(fingerprint) = certificate_fingerprint { + config + .dangerous() + .set_certificate_verifier(Arc::new(PinnedCertificateVerifier { + fingerprint: fingerprint.clone(), + })); + } else if skip_cert_verify { + config + .dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerifier)); + } + Ok(config) +} + +fn rustls_ech_config(config_list: &[u8]) -> Result { + EchConfig::new( + EchConfigListBytes::from(config_list), + rustls::crypto::aws_lc_rs::hpke::ALL_SUPPORTED_SUITES, + ) + .context("failed to parse Shadowsocks websocket ECH config list") +} + +fn install_rustls_crypto_provider() { + static INSTALL: Once = Once::new(); + INSTALL.call_once( + || match rustls::crypto::aws_lc_rs::default_provider().install_default() { + Ok(()) => tracing::debug!("installed aws-lc-rs rustls crypto provider"), + Err(_) => tracing::debug!("rustls crypto provider was already installed"), + }, + ); +} + +fn parse_certificate_fingerprint(value: &str) -> Result { + let normalized = value + .chars() + .filter(|ch| *ch != ':' && *ch != '-' && !ch.is_whitespace()) + .collect::(); + if matches!( + normalized.to_ascii_lowercase().as_str(), + "chrome" | "firefox" | "safari" | "ios" | "android" | "edge" | "360" | "qq" + ) { + bail!( + "`fingerprint` is for TLS certificate pinning; use `client-fingerprint` for browser fingerprints" + ); + } + if normalized.len() != 64 { + bail!("TLS certificate fingerprint must be a SHA-256 hex digest"); + } + let mut bytes = [0u8; 32]; + for (idx, chunk) in normalized.as_bytes().chunks_exact(2).enumerate() { + let hi = hex_nibble(chunk[0]).ok_or_else(|| anyhow!("invalid certificate fingerprint"))?; + let lo = hex_nibble(chunk[1]).ok_or_else(|| anyhow!("invalid certificate fingerprint"))?; + bytes[idx] = (hi << 4) | lo; + } + Ok(CertificateFingerprint(bytes)) +} + +fn tls_material_bytes(value: &str, label: &str) -> Result> { + let trimmed = value.trim(); + if trimmed.contains("-----BEGIN ") { + return Ok(trimmed.as_bytes().to_vec()); + } + fs::read(trimmed).with_context(|| format!("failed to read TLS {label} from {trimmed}")) +} + +fn load_rustls_client_identity( + identity: &TlsClientIdentity, +) -> Result<(Vec>, PrivateKeyDer<'static>)> { + let certificate = tls_material_bytes(&identity.certificate, "client certificate")?; + let private_key = tls_material_bytes(&identity.private_key, "client private key")?; + + let mut certificate_reader = io::Cursor::new(certificate); + let certificate_chain = rustls_pemfile::certs(&mut certificate_reader) + .collect::, _>>() + .context("failed to parse TLS client certificate PEM")?; + if certificate_chain.is_empty() { + bail!("TLS client certificate PEM did not contain any certificates"); + } + + let mut private_key_reader = io::Cursor::new(private_key); + let private_key = rustls_pemfile::private_key(&mut private_key_reader) + .context("failed to parse TLS client private key PEM")? + .ok_or_else(|| anyhow!("TLS client private key PEM did not contain a private key"))?; + + Ok((certificate_chain, private_key)) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn load_boring_client_identity( + identity: &TlsClientIdentity, +) -> Result { + let certificate = tls_material_bytes(&identity.certificate, "client certificate")?; + let private_key = tls_material_bytes(&identity.private_key, "client private key")?; + + let mut certificate_reader = io::Cursor::new(certificate); + let certificate_chain = rustls_pemfile::certs(&mut certificate_reader) + .collect::, _>>() + .context("failed to parse TLS client certificate PEM")? + .into_iter() + .map(|certificate| { + BoringX509::from_der(certificate.as_ref()) + .context("failed to parse TLS client certificate as X.509") + }) + .collect::>>()?; + if certificate_chain.is_empty() { + bail!("TLS client certificate PEM did not contain any certificates"); + } + + let mut private_key_reader = io::Cursor::new(private_key); + let private_key = rustls_pemfile::private_key(&mut private_key_reader) + .context("failed to parse TLS client private key PEM")? + .ok_or_else(|| anyhow!("TLS client private key PEM did not contain a private key"))?; + let private_key = BoringPKey::private_key_from_der(private_key.secret_der()) + .context("failed to parse TLS client private key for BoringSSL")?; + + Ok(BoringConnectorConfigClientAuth { + cert_chain: certificate_chain, + private_key, + }) +} + +#[cfg(target_vendor = "apple")] +fn load_native_tls_identity(identity: &TlsClientIdentity) -> Result { + let certificate = tls_material_bytes(&identity.certificate, "client certificate")?; + let private_key = tls_material_bytes(&identity.private_key, "client private key")?; + NativeTlsIdentity::from_pkcs8(&certificate, &private_key) + .context("failed to configure TLS client certificate") +} + +fn unsupported_client_fingerprint(config: &TrojanNode) -> Option<&str> { + unsupported_client_fingerprint_value(config.client_fingerprint.as_deref()) +} + +fn unsupported_client_fingerprint_value(value: Option<&str>) -> Option<&str> { + let value = value?.trim(); + if value.is_empty() || value.eq_ignore_ascii_case("none") { + return None; + } + MihomoClientFingerprint::from_mihomo_name(value) + .is_some() + .then_some(value) +} + +impl MihomoClientFingerprint { + fn from_mihomo_name(value: &str) -> Option { + Some(match value.trim().to_ascii_lowercase().as_str() { + "chrome" => Self::Chrome, + "firefox" => Self::Firefox, + "safari" => Self::Safari, + "ios" => Self::Ios, + "android" => Self::Android, + "edge" => Self::Edge, + "360" => Self::Browser360, + "qq" => Self::Qq, + "random" => Self::Random, + "chrome120" => Self::Chrome120, + "firefox120" => Self::Firefox120, + "safari16" => Self::Safari16, + "chrome_psk" => Self::ChromePsk, + "chrome_psk_shuffle" => Self::ChromePskShuffle, + "chrome_padding_psk_shuffle" => Self::ChromePaddingPskShuffle, + "chrome_pq" => Self::ChromePq, + "chrome_pq_psk" => Self::ChromePqPsk, + "randomized" => Self::Randomized, + _ => return None, + }) + } + + fn mihomo_name(self) -> &'static str { + match self { + Self::Chrome => "chrome", + Self::Firefox => "firefox", + Self::Safari => "safari", + Self::Ios => "ios", + Self::Android => "android", + Self::Edge => "edge", + Self::Browser360 => "360", + Self::Qq => "qq", + Self::Random => "random", + Self::Chrome120 => "chrome120", + Self::Firefox120 => "firefox120", + Self::Safari16 => "safari16", + Self::ChromePsk => "chrome_psk", + Self::ChromePskShuffle => "chrome_psk_shuffle", + Self::ChromePaddingPskShuffle => "chrome_padding_psk_shuffle", + Self::ChromePq => "chrome_pq", + Self::ChromePqPsk => "chrome_pq_psk", + Self::Randomized => "randomized", + } + } + + fn shadow_tls_v2_boring_supported(self) -> bool { + #[cfg(not(feature = "boring-browser-fingerprints"))] + { + let _ = self; + false + } + #[cfg(feature = "boring-browser-fingerprints")] + matches!( + self, + Self::Chrome + | Self::Firefox + | Self::Safari + | Self::Ios + | Self::Android + | Self::Edge + | Self::Random + | Self::Safari16 + ) + } + +} + +fn hex_nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[derive(Debug)] +struct PinnedCertificateVerifier { + fingerprint: CertificateFingerprint, +} + +impl ServerCertVerifier for PinnedCertificateVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + let mut certificates = std::iter::once(end_entity).chain(intermediates.iter()); + if certificates + .any(|certificate| certificate_fingerprint_matches(certificate, &self.fingerprint)) + { + Ok(ServerCertVerified::assertion()) + } else { + Err(RustlsError::General( + "certificate chain does not match pinned fingerprint".to_owned(), + )) + } + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + supported_verify_schemes() + } +} + +fn certificate_fingerprint_matches( + certificate: &CertificateDer<'_>, + fingerprint: &CertificateFingerprint, +) -> bool { + let digest = Sha256::digest(certificate.as_ref()); + digest[..] == fingerprint.0[..] +} + +#[derive(Debug)] +struct NoCertificateVerifier; + +impl ServerCertVerifier for NoCertificateVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + supported_verify_schemes() + } +} + +fn supported_verify_schemes() -> Vec { + vec![ + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::ED25519, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + ] +} + +async fn write_trojan_header( + writer: &mut W, + password: &str, + command: u8, + socks_address: &[u8], +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let password_hash = trojan_password_hash(password); + let mut header = Vec::with_capacity(password_hash.len() + 2 + 1 + socks_address.len() + 2); + header.extend_from_slice(password_hash.as_bytes()); + header.extend_from_slice(b"\r\n"); + header.push(command); + header.extend_from_slice(socks_address); + header.extend_from_slice(b"\r\n"); + writer.write_all(&header).await?; + writer.flush().await?; + Ok(()) +} + +fn trojan_password_hash(password: &str) -> String { + let digest = Sha224::digest(password.as_bytes()); + hex_lower(&digest) +} + +async fn write_trojan_udp_packet( + writer: &mut W, + socks_address: &[u8], + payload: &[u8], +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + if payload.len() <= TROJAN_MAX_UDP_PAYLOAD { + write_trojan_udp_frame(writer, socks_address, payload).await?; + } else { + for chunk in payload.chunks(TROJAN_MAX_UDP_PAYLOAD) { + write_trojan_udp_frame(writer, socks_address, chunk).await?; + } + } + writer.flush().await?; + Ok(()) +} + +async fn write_trojan_udp_frame( + writer: &mut W, + socks_address: &[u8], + payload: &[u8], +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer.write_all(socks_address).await?; + writer + .write_all(&(payload.len() as u16).to_be_bytes()) + .await?; + writer.write_all(b"\r\n").await?; + writer.write_all(payload).await?; + Ok(()) +} + +async fn read_trojan_udp_packet(reader: &mut R, buf: &mut [u8]) -> Result<(SocketAddr, Vec)> +where + R: AsyncRead + Unpin, +{ + let source = read_socks_addr_async(reader).await?; + let mut len = [0u8; 2]; + reader.read_exact(&mut len).await?; + let payload_len = u16::from_be_bytes(len) as usize; + let mut crlf = [0u8; 2]; + reader.read_exact(&mut crlf).await?; + if crlf != *b"\r\n" { + bail!("invalid Trojan UDP packet delimiter"); + } + if payload_len > buf.len() { + bail!("Trojan UDP packet too large"); + } + reader.read_exact(&mut buf[..payload_len]).await?; + Ok((source, buf[..payload_len].to_vec())) +} + +include!("proxy_runtime/shadowsocks_crypto.rs"); + +include!("proxy_runtime/shadowsocks_plugins.rs"); + +fn validate_shadowsocks_cipher(name: &str) -> Result<()> { + if shadowsocks_cipher_kind(name).is_ok() + || CustomSsAeadKind::from_name(name).is_some() + || CustomSsStreamKind::from_name(name).is_some() + || Aead2022AesCcmKind::from_name(name).is_some() + { + return Ok(()); + } + bail!("unsupported Shadowsocks cipher {name}") +} + +fn normalize_uot_version(version: u8) -> Result { + match version { + 0 | UOT_LEGACY_VERSION => Ok(UOT_LEGACY_VERSION), + UOT_VERSION => Ok(UOT_VERSION), + other => bail!("unsupported Shadowsocks udp-over-tcp version {other}"), + } +} + +fn shadowsocks_sing_mux_transport( + smux: Option<&ShadowsocksSmux>, + _config: &ShadowsocksNode, + protocol: &ShadowsocksProtocol, +) -> Result> { + let Some(smux) = smux.filter(|smux| smux.enabled) else { + return Ok(None); + }; + let protocol_name = smux.protocol.as_deref().unwrap_or("h2mux"); + let sing_mux_protocol = if protocol_name.eq_ignore_ascii_case("h2mux") { + ShadowsocksSingMuxProtocol::H2Mux + } else if protocol_name.eq_ignore_ascii_case("smux") { + ShadowsocksSingMuxProtocol::Smux + } else if protocol_name.eq_ignore_ascii_case("yamux") { + ShadowsocksSingMuxProtocol::Yamux + } else { + bail!("top-level Shadowsocks sing-mux protocol {protocol_name} is not supported yet"); + }; + let brutal = smux + .brutal + .as_ref() + .filter(|brutal| brutal.enabled) + .map(|brutal| ShadowsocksSingMuxBrutalTransport { + send_bps: mihomo_string_to_bps(brutal.up.as_deref().unwrap_or("")), + receive_bps: mihomo_string_to_bps(brutal.down.as_deref().unwrap_or("")), + }); + match protocol { + ShadowsocksProtocol::None + | ShadowsocksProtocol::Standard { .. } + | ShadowsocksProtocol::CustomAead { .. } + | ShadowsocksProtocol::CustomStream { .. } + | ShadowsocksProtocol::Aead2022AesCcm { .. } => {} + } + Ok(Some(ShadowsocksSingMuxTransport { + protocol: sing_mux_protocol, + max_connections: smux.max_connections.unwrap_or(0) as usize, + min_streams: smux.min_streams.unwrap_or(0) as usize, + max_streams: smux.max_streams.unwrap_or(0) as usize, + padding: smux.padding, + udp: !smux.only_tcp, + brutal, + })) +} + +fn mihomo_string_to_bps(value: &str) -> u64 { + let value = value.trim(); + if value.is_empty() { + return 0; + } + if let Ok(mbps) = value.parse::() { + return mbps.saturating_mul(1_000_000 / 8); + } + + let digit_len = value + .as_bytes() + .iter() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + if digit_len == 0 { + return 0; + } + let Ok(amount) = value[..digit_len].parse::() else { + return 0; + }; + let unit = value[digit_len..].trim(); + let Some(suffix) = unit.strip_suffix("ps") else { + return 0; + }; + let (scale, bits) = match suffix { + "b" => (1, true), + "B" => (1, false), + "Kb" => (1_000, true), + "KB" => (1_000, false), + "Mb" => (1_000_000, true), + "MB" => (1_000_000, false), + "Gb" => (1_000_000_000, true), + "GB" => (1_000_000_000, false), + "Tb" => (1_000_000_000_000, true), + "TB" => (1_000_000_000_000, false), + _ => return 0, + }; + let bytes = amount.saturating_mul(scale); + if bits { + bytes / 8 + } else { + bytes + } +} + +fn shadowsocks_protocol_for_node( + node: &ProxyNode, + config: &ShadowsocksNode, +) -> Result { + if config.cipher.trim().eq_ignore_ascii_case("none") { + return Ok(ShadowsocksProtocol::None); + } + + if let Some(kind) = Aead2022AesCcmKind::from_name(&config.cipher) { + let (user_key, identity_keys) = parse_aead2022_keys(kind, &config.password)?; + return Ok(ShadowsocksProtocol::Aead2022AesCcm { kind, user_key, identity_keys }); + } + + if let Ok(cipher) = shadowsocks_cipher_kind(&config.cipher) { + validate_standard_aead2022_mihomo_password(cipher, &config.password)?; + let server_config = SsServerConfig::new( + SsServerAddr::DomainName(node.server.clone(), node.port), + config.password.clone(), + cipher, + ) + .with_context(|| { + format!( + "failed to configure Shadowsocks cipher {} for node {}", + config.cipher, node.name + ) + })?; + return Ok(ShadowsocksProtocol::Standard { + server_config: Arc::new(server_config), + context: SsContext::new_shared(SsServerType::Local), + }); + } + + if let Some(kind) = CustomSsAeadKind::from_name(&config.cipher) { + let master_key = evp_bytes_to_key(config.password.as_bytes(), kind.key_len()); + return Ok(ShadowsocksProtocol::CustomAead { + kind, + master_key: Arc::from(master_key), + }); + } + + if let Some(kind) = CustomSsStreamKind::from_name(&config.cipher) { + let key = evp_bytes_to_key(config.password.as_bytes(), kind.key_len()); + return Ok(ShadowsocksProtocol::CustomStream { kind, key: Arc::from(key) }); + } + + bail!("unsupported Shadowsocks cipher {}", config.cipher) +} + +fn shadowsocks_cipher_kind(name: &str) -> Result { + let canonical = canonical_shadowsocks_cipher_name(name); + SsCipherKind::from_str(&canonical).map_err(|_| anyhow!("unsupported Shadowsocks cipher {name}")) +} + +fn validate_standard_aead2022_mihomo_password(cipher: SsCipherKind, password: &str) -> Result<()> { + if !cipher.is_aead_2022() { + return Ok(()); + } + + if password.is_empty() { + bail!("Shadowsocks 2022 password is missing"); + } + + let supports_eih = matches!( + cipher, + SsCipherKind::AEAD2022_BLAKE3_AES_128_GCM | SsCipherKind::AEAD2022_BLAKE3_AES_256_GCM + ); + let segments = password.split(':').collect::>(); + if segments.len() > 1 && !supports_eih { + bail!("Shadowsocks 2022 EIH support only available in AES ciphers"); + } + + for segment in segments { + let decoded = BASE64_STANDARD + .decode(segment) + .context("failed to decode Shadowsocks 2022 base64 key")?; + if decoded.len() != cipher.key_len() { + bail!( + "Shadowsocks 2022 key length mismatch: required {}, got {}", + cipher.key_len(), + decoded.len() + ); + } + } + + Ok(()) +} + +fn canonical_shadowsocks_cipher_name(name: &str) -> String { + match name.trim().to_ascii_lowercase().as_str() { + "aead_aes_128_gcm" => "aes-128-gcm".to_owned(), + "aead_aes_256_gcm" => "aes-256-gcm".to_owned(), + "chacha20-poly1305" | "aead_chacha20_poly1305" => "chacha20-ietf-poly1305".to_owned(), + other => other.to_owned(), + } +} + +fn shadowsocks_addr_for_target(target: &ProxyTcpTarget) -> Result { + if let Some(hostname) = target.hostname.as_deref() { + let hostname = hostname.trim_end_matches('.'); + if !hostname.is_empty() && hostname.len() <= u8::MAX as usize { + return Ok(SsAddress::DomainNameAddress( + hostname.to_owned(), + target.address.port(), + )); + } + } + Ok(SsAddress::SocketAddress(target.address)) +} + +fn shadowsocks_addr_to_socket_addr(addr: SsAddress) -> Result { + match addr { + SsAddress::SocketAddress(addr) => Ok(addr), + SsAddress::DomainNameAddress(domain, port) => resolve_server(&domain, port), + } +} + +fn socks_addr(addr: SocketAddr) -> Result> { + let mut encoded = Vec::with_capacity(1 + 16 + 2); + match addr.ip() { + IpAddr::V4(ip) => { + encoded.push(1); + encoded.extend_from_slice(&ip.octets()); + } + IpAddr::V6(ip) => { + encoded.push(4); + encoded.extend_from_slice(&ip.octets()); + } + } + encoded.extend_from_slice(&addr.port().to_be_bytes()); + Ok(encoded) +} + +fn socks_domain_addr(domain: &str, port: u16) -> Result> { + let domain = domain.trim_end_matches('.'); + if domain.is_empty() || domain.len() > u8::MAX as usize { + bail!("invalid SOCKS domain length"); + } + let mut encoded = Vec::with_capacity(1 + 1 + domain.len() + 2); + encoded.push(3); + encoded.push(domain.len() as u8); + encoded.extend_from_slice(domain.as_bytes()); + encoded.extend_from_slice(&port.to_be_bytes()); + Ok(encoded) +} + +fn socks_addr_for_target(target: &ProxyTcpTarget) -> Result> { + if let Some(hostname) = target.hostname.as_deref() { + let hostname = hostname.trim_end_matches('.'); + if !hostname.is_empty() && hostname.len() <= u8::MAX as usize { + return socks_domain_addr(hostname, target.address.port()); + } + } + socks_addr(target.address) +} + +async fn sing_mux_brutal_exchange( + session: &ShadowsocksSingMuxSession, + brutal: ShadowsocksSingMuxBrutalTransport, +) -> Result<()> { + let mut stream = session.open_stream().await?; + write_sing_mux_brutal_request(&mut stream, brutal.receive_bps).await?; + let server_receive_bps = read_sing_mux_brutal_response(&mut stream).await?; + let send_bps = if server_receive_bps < brutal.send_bps { + server_receive_bps + } else { + brutal.send_bps + }; + tracing::debug!( + send_bps, + receive_bps = brutal.receive_bps, + server_receive_bps, + "completed Shadowsocks sing-mux brutal bandwidth exchange" + ); + Ok(()) +} + +async fn write_sing_mux_brutal_request(writer: &mut W, receive_bps: u64) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer + .write_all(&0u16.to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux brutal tcp flags")?; + writer + .write_all(&socks_domain_addr(SING_MUX_BRUTAL_EXCHANGE_DOMAIN, 0)?) + .await + .context("failed to write Shadowsocks sing-mux brutal exchange destination")?; + writer + .write_all(&receive_bps.to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux brutal receive bps")?; + writer + .flush() + .await + .context("failed to flush Shadowsocks sing-mux brutal request")?; + Ok(()) +} + +async fn read_sing_mux_brutal_response(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + read_sing_mux_stream_response(reader).await?; + let ok = reader + .read_u8() + .await + .context("failed to read Shadowsocks sing-mux brutal response status")?; + match ok { + 0 => { + let len = read_sing_mux_uvarint(reader).await?; + if len > 4096 { + bail!("Shadowsocks sing-mux brutal remote error message is too long"); + } + let mut message = vec![0u8; len as usize]; + reader + .read_exact(&mut message) + .await + .context("failed to read Shadowsocks sing-mux brutal remote error")?; + let message = String::from_utf8_lossy(&message); + bail!("Shadowsocks sing-mux brutal remote error: {message}"); + } + 1 => reader + .read_u64() + .await + .context("failed to read Shadowsocks sing-mux brutal server receive bps"), + other => bail!("unknown Shadowsocks sing-mux brutal response status {other}"), + } +} + +async fn write_sing_mux_tcp_request(writer: &mut W, target: &ProxyTcpTarget) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer + .write_all(&0u16.to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux tcp flags")?; + writer + .write_all(&socks_addr_for_target(target)?) + .await + .context("failed to write Shadowsocks sing-mux tcp destination")?; + writer + .flush() + .await + .context("failed to flush Shadowsocks sing-mux tcp request")?; + Ok(()) +} + +async fn write_sing_mux_udp_request( + writer: &mut W, + destination: SocketAddr, + payload: &[u8], +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer + .write_all(&SING_MUX_STREAM_FLAG_UDP.to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux udp flags")?; + writer + .write_all(&socks_addr(destination)?) + .await + .context("failed to write Shadowsocks sing-mux udp destination")?; + if !payload.is_empty() { + write_sing_mux_udp_payload(writer, payload).await?; + } else { + writer + .flush() + .await + .context("failed to flush Shadowsocks sing-mux udp request")?; + } + Ok(()) +} + +async fn write_sing_mux_udp_payload(writer: &mut W, payload: &[u8]) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + if payload.len() > u16::MAX as usize { + bail!("Shadowsocks sing-mux udp payload too large"); + } + writer + .write_all(&(payload.len() as u16).to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux udp payload length")?; + writer + .write_all(payload) + .await + .context("failed to write Shadowsocks sing-mux udp payload")?; + writer + .flush() + .await + .context("failed to flush Shadowsocks sing-mux udp payload")?; + Ok(()) +} + +async fn read_sing_mux_udp_payload(reader: &mut R, response_read: &mut bool) -> Result> +where + R: AsyncRead + Unpin, +{ + if !*response_read { + read_sing_mux_stream_response(reader).await?; + *response_read = true; + } + let payload_len = reader + .read_u16() + .await + .context("failed to read Shadowsocks sing-mux udp payload length")? + as usize; + let mut payload = vec![0u8; payload_len]; + reader + .read_exact(&mut payload) + .await + .context("failed to read Shadowsocks sing-mux udp payload")?; + Ok(payload) +} + +fn sing_mux_padding_len() -> usize { + SING_MUX_MIN_PADDING_LEN + (OsRng.next_u32() as usize % SING_MUX_PADDING_LEN_RANGE) +} + +async fn write_sing_mux_session_preface( + writer: &mut W, + protocol: ShadowsocksSingMuxProtocol, + padding: bool, +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + if !padding { + writer + .write_all(&[SING_MUX_VERSION_0, protocol.wire_id()]) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks sing-mux {} preface", + protocol.name() + ) + })?; + writer.flush().await.with_context(|| { + format!( + "failed to flush Shadowsocks sing-mux {} preface", + protocol.name() + ) + })?; + return Ok(()); + } + + let padding_len = sing_mux_padding_len(); + writer + .write_all(&[SING_MUX_VERSION_1, protocol.wire_id(), 1]) + .await + .with_context(|| { + format!( + "failed to write Shadowsocks sing-mux padded {} preface", + protocol.name() + ) + })?; + writer + .write_all(&(padding_len as u16).to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux preface padding length")?; + writer + .write_all(&vec![0u8; padding_len]) + .await + .context("failed to write Shadowsocks sing-mux preface padding")?; + writer + .flush() + .await + .context("failed to flush Shadowsocks sing-mux padded preface")?; + Ok(()) +} + +fn sing_mux_padding_stream(stream: Box) -> Box { + let (plain, bridge) = tokio::io::duplex(64 * 1024); + let (mut plain_reader, mut plain_writer) = split(bridge); + let (mut stream_reader, mut stream_writer) = split(stream); + + let _upload = tokio::spawn(async move { + let result = async { + let mut buf = vec![0u8; 16 * 1024]; + let mut frames = 0usize; + loop { + let n = plain_reader + .read(&mut buf) + .await + .context("failed to read Shadowsocks sing-mux padded upload payload")?; + if n == 0 { + break; + } + if frames < SING_MUX_PADDING_FRAMES { + let padding_len = sing_mux_padding_len(); + stream_writer + .write_all(&(n as u16).to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux padded upload length")?; + stream_writer + .write_all(&(padding_len as u16).to_be_bytes()) + .await + .context("failed to write Shadowsocks sing-mux upload padding length")?; + stream_writer + .write_all(&buf[..n]) + .await + .context("failed to write Shadowsocks sing-mux padded upload payload")?; + stream_writer + .write_all(&vec![0u8; padding_len]) + .await + .context("failed to write Shadowsocks sing-mux upload padding")?; + frames += 1; + } else { + stream_writer + .write_all(&buf[..n]) + .await + .context("failed to write Shadowsocks sing-mux upload payload")?; + } + } + stream_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks sing-mux padded upload")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!(?err, "Shadowsocks sing-mux padded upload task stopped"); + } + }); + + let _download = tokio::spawn(async move { + let result = async { + let mut header = [0u8; 4]; + let mut padding = vec![0u8; SING_MUX_MIN_PADDING_LEN + SING_MUX_PADDING_LEN_RANGE - 1]; + let mut frames = 0usize; + while frames < SING_MUX_PADDING_FRAMES { + stream_reader + .read_exact(&mut header) + .await + .context("failed to read Shadowsocks sing-mux padded download header")?; + let payload_len = u16::from_be_bytes([header[0], header[1]]) as usize; + let padding_len = u16::from_be_bytes([header[2], header[3]]) as usize; + let mut payload = vec![0u8; payload_len]; + stream_reader + .read_exact(&mut payload) + .await + .context("failed to read Shadowsocks sing-mux padded download payload")?; + let mut remaining_padding = padding_len; + while remaining_padding > 0 { + let read_len = remaining_padding.min(padding.len()); + stream_reader + .read_exact(&mut padding[..read_len]) + .await + .context("failed to read Shadowsocks sing-mux download padding")?; + remaining_padding -= read_len; + } + plain_writer + .write_all(&payload) + .await + .context("failed to write Shadowsocks sing-mux padded download payload")?; + frames += 1; + } + + let mut buf = vec![0u8; 16 * 1024]; + loop { + let n = stream_reader + .read(&mut buf) + .await + .context("failed to read Shadowsocks sing-mux download payload")?; + if n == 0 { + break; + } + plain_writer + .write_all(&buf[..n]) + .await + .context("failed to write Shadowsocks sing-mux download payload")?; + } + plain_writer + .shutdown() + .await + .context("failed to shutdown Shadowsocks sing-mux padded download")?; + Result::<()>::Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!(?err, "Shadowsocks sing-mux padded download task stopped"); + } + }); + + Box::new(plain) +} + +async fn read_sing_mux_stream_response(reader: &mut R) -> Result<()> +where + R: AsyncRead + Unpin, +{ + let mut status = [0u8; 1]; + reader + .read_exact(&mut status) + .await + .context("failed to read Shadowsocks sing-mux stream response")?; + match status[0] { + SING_MUX_STREAM_STATUS_SUCCESS => Ok(()), + SING_MUX_STREAM_STATUS_ERROR => { + let len = read_sing_mux_uvarint(reader).await?; + if len > 4096 { + bail!("Shadowsocks sing-mux remote error message is too long"); + } + let mut message = vec![0u8; len as usize]; + reader + .read_exact(&mut message) + .await + .context("failed to read Shadowsocks sing-mux remote error message")?; + bail!( + "Shadowsocks sing-mux remote error: {}", + String::from_utf8_lossy(&message) + ) + } + other => bail!("unknown Shadowsocks sing-mux stream response status {other}"), + } +} + +async fn read_sing_mux_uvarint(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + let mut value = 0u64; + for shift in (0..64).step_by(7) { + let mut byte = [0u8; 1]; + reader + .read_exact(&mut byte) + .await + .context("failed to read Shadowsocks sing-mux uvarint")?; + value |= u64::from(byte[0] & 0x7f) << shift; + if byte[0] < 0x80 { + return Ok(value); + } + } + bail!("Shadowsocks sing-mux uvarint is too large") +} + +fn uot_magic_domain(version: u8) -> &'static str { + match version { + UOT_VERSION => UOT_MAGIC_ADDRESS, + _ => UOT_LEGACY_MAGIC_ADDRESS, + } +} + +fn uot_magic_shadowsocks_addr(version: u8) -> SsAddress { + SsAddress::DomainNameAddress(uot_magic_domain(version).to_owned(), 0) +} + +fn uot_request(destination: SocketAddr) -> Result> { + let mut request = Vec::with_capacity(1 + 1 + 16 + 2); + request.push(0); + request.extend_from_slice(&socks_addr(destination)?); + Ok(request) +} + +async fn write_uot_request(writer: &mut W, destination: SocketAddr) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer.write_all(&uot_request(destination)?).await?; + writer.flush().await?; + Ok(()) +} + +fn uot_frame(destination: SocketAddr, payload: &[u8]) -> Result> { + if payload.len() > u16::MAX as usize { + bail!("udp-over-tcp payload too large"); + } + let mut frame = uot_addr(destination); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + frame.extend_from_slice(payload); + Ok(frame) +} + +async fn write_uot_frame(writer: &mut W, destination: SocketAddr, payload: &[u8]) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + writer.write_all(&uot_frame(destination, payload)?).await?; + writer.flush().await?; + Ok(()) +} + +fn uot_addr(addr: SocketAddr) -> Vec { + let mut encoded = Vec::with_capacity(1 + 16 + 2); + match addr.ip() { + IpAddr::V4(ip) => { + encoded.push(0x00); + encoded.extend_from_slice(&ip.octets()); + } + IpAddr::V6(ip) => { + encoded.push(0x01); + encoded.extend_from_slice(&ip.octets()); + } + } + encoded.extend_from_slice(&addr.port().to_be_bytes()); + encoded +} + +async fn read_uot_frame(reader: &mut R) -> Result<(SocketAddr, Vec)> +where + R: AsyncRead + Unpin, +{ + let source = read_uot_addr_async(reader).await?; + let payload_len = reader.read_u16().await? as usize; + let mut payload = vec![0u8; payload_len]; + reader.read_exact(&mut payload).await?; + Ok((source, payload)) +} + +async fn read_uot_addr_async(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + let atyp = reader.read_u8().await?; + match atyp { + 0x00 => { + let mut ip = [0u8; 4]; + reader.read_exact(&mut ip).await?; + let port = reader.read_u16().await?; + Ok(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port)) + } + 0x01 => { + let mut ip = [0u8; 16]; + reader.read_exact(&mut ip).await?; + let port = reader.read_u16().await?; + Ok(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port)) + } + 0x02 => { + let len = reader.read_u8().await? as usize; + let mut domain = vec![0u8; len]; + reader.read_exact(&mut domain).await?; + let port = reader.read_u16().await?; + let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; + resolve_server(&domain, port) + } + other => bail!("unsupported udp-over-tcp address type {other}"), + } +} + +async fn read_socks_addr_async(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + let atyp = reader.read_u8().await?; + match atyp { + 1 => { + let mut ip = [0u8; 4]; + reader.read_exact(&mut ip).await?; + let port = reader.read_u16().await?; + Ok(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port)) + } + 3 => { + let len = reader.read_u8().await? as usize; + let mut domain = vec![0u8; len]; + reader.read_exact(&mut domain).await?; + let port = reader.read_u16().await?; + let domain = String::from_utf8(domain).context("invalid SOCKS domain")?; + resolve_server(&domain, port) + } + 4 => { + let mut ip = [0u8; 16]; + reader.read_exact(&mut ip).await?; + let port = reader.read_u16().await?; + Ok(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port)) + } + other => bail!("unsupported SOCKS address type {other}"), + } +} + +fn parse_socks_addr(buf: &[u8]) -> Result<(SocketAddr, usize)> { + if buf.is_empty() { + bail!("missing SOCKS address"); + } + match buf[0] { + 1 => { + if buf.len() < 7 { + bail!("truncated IPv4 SOCKS address"); + } + let ip = Ipv4Addr::new(buf[1], buf[2], buf[3], buf[4]); + let port = u16::from_be_bytes([buf[5], buf[6]]); + Ok((SocketAddr::new(IpAddr::V4(ip), port), 7)) + } + 3 => { + if buf.len() < 2 { + bail!("truncated domain SOCKS address"); + } + let len = buf[1] as usize; + if buf.len() < 2 + len + 2 { + bail!("truncated domain SOCKS address"); + } + let domain = std::str::from_utf8(&buf[2..2 + len]).context("invalid SOCKS domain")?; + let port = u16::from_be_bytes([buf[2 + len], buf[3 + len]]); + Ok((resolve_server(domain, port)?, 2 + len + 2)) + } + 4 => { + if buf.len() < 19 { + bail!("truncated IPv6 SOCKS address"); + } + let mut ip = [0u8; 16]; + ip.copy_from_slice(&buf[1..17]); + let port = u16::from_be_bytes([buf[17], buf[18]]); + Ok((SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port), 19)) + } + other => bail!("unsupported SOCKS address type {other}"), + } +} + +fn resolve_server(host: &str, port: u16) -> Result { + (host, port) + .to_socket_addrs() + .with_context(|| format!("failed to resolve {host}:{port}"))? + .next() + .ok_or_else(|| anyhow!("no addresses resolved for {host}:{port}")) +} + +include!("proxy_runtime/networking.rs"); + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +#[cfg(test)] +include!("proxy_runtime/tests.rs"); diff --git a/burrow/src/proxy_runtime/networking.rs b/burrow/src/proxy_runtime/networking.rs new file mode 100644 index 0000000..f186543 --- /dev/null +++ b/burrow/src/proxy_runtime/networking.rs @@ -0,0 +1,961 @@ +impl ProxyServerEndpoint { + fn resolve(host: &str, port: u16) -> Result { + let addresses = resolve_server_addresses(host, port)?; + let endpoint = Self { + host: host.to_owned(), + port, + addresses: Arc::from(addresses), + }; + if endpoint + .addresses + .iter() + .any(|address| is_fake_or_benchmark_ip(address.ip())) + { + tracing::warn!( + server = %endpoint.host, + port = endpoint.port, + resolved_addresses = ?endpoint.addresses, + "proxy server resolved to a fake-IP or benchmarking range; outbound dial may recurse or fail" + ); + } else { + tracing::debug!( + server = %endpoint.host, + port = endpoint.port, + resolved_addresses = ?endpoint.addresses, + "resolved proxy server addresses" + ); + } + Ok(endpoint) + } +} + +async fn connect_proxy_tcp(endpoint: &ProxyServerEndpoint) -> Result<(TcpStream, SocketAddr)> { + let mut last_error = None; + for address in endpoint.addresses.iter().copied() { + tracing::debug!( + server = %endpoint.host, + port = endpoint.port, + %address, + "dialing proxy tcp server" + ); + let socket = match address { + SocketAddr::V4(_) => TcpSocket::new_v4(), + SocketAddr::V6(_) => TcpSocket::new_v6(), + } + .with_context(|| format!("failed to create tcp socket for {address}"))?; + + if let Err(err) = bind_proxy_outbound_socket(socket.as_raw_fd(), address.ip()) { + tracing::warn!( + server = %endpoint.host, + port = endpoint.port, + %address, + error = ?err, + "failed to bind proxy tcp socket to physical interface" + ); + last_error = Some(err); + continue; + } + + match socket.connect(address).await { + Ok(stream) => { + tracing::debug!( + server = %endpoint.host, + port = endpoint.port, + %address, + "connected proxy tcp server" + ); + return Ok((stream, address)); + } + Err(err) => { + tracing::warn!( + server = %endpoint.host, + port = endpoint.port, + %address, + error = %err, + "failed to connect proxy tcp server" + ); + last_error = Some(anyhow!(err).context(format!("failed to connect {address}"))); + } + } + } + + Err(last_error.unwrap_or_else(|| { + anyhow!( + "no cached addresses available for {}:{}", + endpoint.host, + endpoint.port + ) + })) +} + +async fn connect_proxy_udp(endpoint: &ProxyServerEndpoint) -> Result<(UdpSocket, SocketAddr)> { + let mut last_error = None; + for address in endpoint.addresses.iter().copied() { + tracing::debug!( + server = %endpoint.host, + port = endpoint.port, + %address, + "dialing proxy udp server" + ); + let bind_addr = match address { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let socket = match std::net::UdpSocket::bind(bind_addr) { + Ok(socket) => socket, + Err(err) => { + tracing::warn!( + server = %endpoint.host, + port = endpoint.port, + %address, + error = %err, + "failed to bind proxy udp socket" + ); + last_error = + Some(anyhow!(err).context(format!("failed to bind udp socket for {address}"))); + continue; + } + }; + + if let Err(err) = bind_proxy_outbound_socket(socket.as_raw_fd(), address.ip()) { + tracing::warn!( + server = %endpoint.host, + port = endpoint.port, + %address, + error = ?err, + "failed to bind proxy udp socket to physical interface" + ); + last_error = Some(err); + continue; + } + if let Err(err) = socket.set_nonblocking(true) { + tracing::warn!( + server = %endpoint.host, + port = endpoint.port, + %address, + error = %err, + "failed to make proxy udp socket nonblocking" + ); + last_error = Some(anyhow!(err).context("failed to make proxy udp socket nonblocking")); + continue; + } + + match UdpSocket::from_std(socket) { + Ok(socket) => { + tracing::debug!( + server = %endpoint.host, + port = endpoint.port, + %address, + "connected proxy udp server" + ); + return Ok((socket, address)); + } + Err(err) => { + tracing::warn!( + server = %endpoint.host, + port = endpoint.port, + %address, + error = %err, + "failed to wrap proxy udp socket" + ); + last_error = Some(anyhow!(err).context("failed to wrap proxy udp socket")); + } + } + } + + Err(last_error.unwrap_or_else(|| { + anyhow!( + "no cached addresses available for {}:{}", + endpoint.host, + endpoint.port + ) + })) +} + +fn resolve_server_addresses(host: &str, port: u16) -> Result> { + let addresses: Vec<_> = (host, port) + .to_socket_addrs() + .with_context(|| format!("failed to resolve {host}:{port}"))? + .collect(); + if addresses.is_empty() { + bail!("no addresses resolved for {host}:{port}"); + } + if addresses + .iter() + .all(|address| is_fake_or_benchmark_ip(address.ip())) + { + tracing::warn!( + server = %host, + port, + resolved_addresses = ?addresses, + "system DNS returned only fake-IP proxy server addresses; trying direct DNS" + ); + let direct_addresses = resolve_server_addresses_direct(host, port)?; + tracing::info!( + server = %host, + port, + resolved_addresses = ?direct_addresses, + "direct DNS resolved proxy server addresses" + ); + return Ok(direct_addresses); + } + Ok(addresses) +} + +fn resolve_server_addresses_direct(host: &str, port: u16) -> Result> { + if let Ok(ip) = host.parse::() { + return Ok(vec![SocketAddr::new(ip, port)]); + } + + let mut dns_servers = Vec::new(); + for server in platform_proxy_dns_servers() + .into_iter() + .chain(PUBLIC_DNS_SERVERS.iter().map(|server| (*server).to_owned())) + { + if dns_servers.iter().any(|existing| existing == &server) { + continue; + } + let Ok(ip) = server.parse::() else { + continue; + }; + if is_usable_proxy_dns_ip(ip) { + dns_servers.push(server); + } + } + + let mut addresses = Vec::new(); + let mut last_error = None; + for dns_server in dns_servers { + let Ok(ip) = dns_server.parse::() else { + continue; + }; + let dns_addr = SocketAddr::new(ip, 53); + match direct_dns_lookup(host, dns_addr) { + Ok(resolved) => { + for ip in resolved { + if is_fake_or_benchmark_ip(ip) { + continue; + } + let address = SocketAddr::new(ip, port); + if !addresses.contains(&address) { + addresses.push(address); + } + } + if !addresses.is_empty() { + break; + } + } + Err(err) => { + tracing::debug!( + server = %host, + %dns_addr, + error = ?err, + "direct DNS lookup failed" + ); + last_error = Some(err); + } + } + } + + if addresses.is_empty() { + return Err(last_error.unwrap_or_else(|| { + anyhow!("direct DNS did not resolve any usable addresses for {host}:{port}") + })); + } + Ok(addresses) +} + +fn direct_dns_lookup(host: &str, dns_server: SocketAddr) -> Result> { + let mut addresses = Vec::new(); + let mut last_error = None; + for query_type in [1u16, 28u16] { + let mut query = build_dns_query(host, query_type)?; + let id = (OsRng.next_u32() & 0xffff) as u16; + query[0..2].copy_from_slice(&id.to_be_bytes()); + + let bind_addr = match dns_server { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let socket = std::net::UdpSocket::bind(bind_addr) + .with_context(|| format!("failed to bind DNS socket for {dns_server}"))?; + bind_proxy_outbound_socket(socket.as_raw_fd(), dns_server.ip()).with_context(|| { + format!("failed to bind DNS socket to physical interface for {dns_server}") + })?; + socket + .set_read_timeout(Some(DIRECT_DNS_TIMEOUT)) + .context("failed to set DNS read timeout")?; + socket + .set_write_timeout(Some(DIRECT_DNS_TIMEOUT)) + .context("failed to set DNS write timeout")?; + if let Err(err) = socket.send_to(&query, dns_server) { + last_error = + Some(anyhow!(err).context(format!("failed to send DNS query to {dns_server}"))); + continue; + } + + let mut response = [0u8; 1500]; + match socket.recv_from(&mut response) { + Ok((len, _)) => match parse_dns_response(&response[..len], id) + .with_context(|| format!("failed to parse DNS response from {dns_server}")) + { + Ok(mut resolved) => addresses.append(&mut resolved), + Err(err) => last_error = Some(err), + }, + Err(err) => { + last_error = Some( + anyhow!(err) + .context(format!("failed to receive DNS response from {dns_server}")), + ); + } + } + } + if addresses.is_empty() { + return Err(last_error.unwrap_or_else(|| anyhow!("DNS lookup returned no answers"))); + } + Ok(addresses) +} + +fn direct_dns_https_ech_config_list(host: &str, dns_server: SocketAddr) -> Result>> { + let mut query = build_dns_query(host, DNS_TYPE_HTTPS)?; + let id = (OsRng.next_u32() & 0xffff) as u16; + query[0..2].copy_from_slice(&id.to_be_bytes()); + + let bind_addr = match dns_server { + SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + }; + let socket = std::net::UdpSocket::bind(bind_addr) + .with_context(|| format!("failed to bind DNS socket for {dns_server}"))?; + bind_proxy_outbound_socket(socket.as_raw_fd(), dns_server.ip()).with_context(|| { + format!("failed to bind DNS socket to physical interface for {dns_server}") + })?; + socket + .set_read_timeout(Some(DIRECT_DNS_TIMEOUT)) + .context("failed to set DNS read timeout")?; + socket + .set_write_timeout(Some(DIRECT_DNS_TIMEOUT)) + .context("failed to set DNS write timeout")?; + socket + .send_to(&query, dns_server) + .with_context(|| format!("failed to send DNS HTTPS query to {dns_server}"))?; + + let mut response = [0u8; 1500]; + let (len, _) = socket + .recv_from(&mut response) + .with_context(|| format!("failed to receive DNS HTTPS response from {dns_server}"))?; + parse_dns_https_ech_config_list(&response[..len], id) + .with_context(|| format!("failed to parse DNS HTTPS response from {dns_server}")) +} + +pub(crate) fn build_dns_query(host: &str, query_type: u16) -> Result> { + let mut query = Vec::with_capacity(512); + query.extend_from_slice(&0u16.to_be_bytes()); + query.extend_from_slice(&0x0100u16.to_be_bytes()); + query.extend_from_slice(&1u16.to_be_bytes()); + query.extend_from_slice(&0u16.to_be_bytes()); + query.extend_from_slice(&0u16.to_be_bytes()); + query.extend_from_slice(&0u16.to_be_bytes()); + + for label in host.trim_end_matches('.').split('.') { + if label.is_empty() || label.len() > 63 { + bail!("invalid DNS label in {host}"); + } + query.push(label.len() as u8); + query.extend_from_slice(label.as_bytes()); + } + query.push(0); + query.extend_from_slice(&query_type.to_be_bytes()); + query.extend_from_slice(&1u16.to_be_bytes()); + Ok(query) +} + +pub(crate) fn parse_dns_response(response: &[u8], expected_id: u16) -> Result> { + if response.len() < 12 { + bail!("truncated DNS response"); + } + let id = u16::from_be_bytes([response[0], response[1]]); + if id != expected_id { + bail!("DNS response id mismatch"); + } + let flags = u16::from_be_bytes([response[2], response[3]]); + if flags & 0x8000 == 0 { + bail!("DNS response is not marked as a response"); + } + let rcode = flags & 0x000f; + if rcode != 0 { + bail!("DNS response error code {rcode}"); + } + + let question_count = u16::from_be_bytes([response[4], response[5]]) as usize; + let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize; + let mut offset = 12; + for _ in 0..question_count { + offset = skip_dns_name(response, offset)?; + if response.len() < offset + 4 { + bail!("truncated DNS question"); + } + offset += 4; + } + + let mut addresses = Vec::new(); + for _ in 0..answer_count { + offset = skip_dns_name(response, offset)?; + if response.len() < offset + 10 { + bail!("truncated DNS answer"); + } + let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]); + let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]); + let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize; + offset += 10; + if response.len() < offset + data_len { + bail!("truncated DNS answer data"); + } + if record_class == 1 && record_type == 1 && data_len == 4 { + addresses.push(IpAddr::V4(Ipv4Addr::new( + response[offset], + response[offset + 1], + response[offset + 2], + response[offset + 3], + ))); + } else if record_class == 1 && record_type == 28 && data_len == 16 { + let mut octets = [0u8; 16]; + octets.copy_from_slice(&response[offset..offset + 16]); + addresses.push(IpAddr::V6(Ipv6Addr::from(octets))); + } + offset += data_len; + } + Ok(addresses) +} + +fn parse_dns_https_ech_config_list(response: &[u8], expected_id: u16) -> Result>> { + if response.len() < 12 { + bail!("truncated DNS response"); + } + let id = u16::from_be_bytes([response[0], response[1]]); + if id != expected_id { + bail!("DNS response id mismatch"); + } + let flags = u16::from_be_bytes([response[2], response[3]]); + if flags & 0x8000 == 0 { + bail!("DNS response is not marked as a response"); + } + let rcode = flags & 0x000f; + if rcode != 0 { + bail!("DNS response error code {rcode}"); + } + + let question_count = u16::from_be_bytes([response[4], response[5]]) as usize; + let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize; + let mut offset = 12; + for _ in 0..question_count { + offset = skip_dns_name(response, offset)?; + if response.len() < offset + 4 { + bail!("truncated DNS question"); + } + offset += 4; + } + + for _ in 0..answer_count { + offset = skip_dns_name(response, offset)?; + if response.len() < offset + 10 { + bail!("truncated DNS answer"); + } + let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]); + let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]); + let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize; + offset += 10; + if response.len() < offset + data_len { + bail!("truncated DNS answer data"); + } + if record_class == 1 && record_type == DNS_TYPE_HTTPS { + if let Some(config_list) = parse_https_rr_ech_config_list(response, offset, data_len)? { + return Ok(Some(config_list)); + } + } + offset += data_len; + } + Ok(None) +} + +fn parse_https_rr_ech_config_list( + response: &[u8], + offset: usize, + data_len: usize, +) -> Result>> { + let end = offset + .checked_add(data_len) + .ok_or_else(|| anyhow!("DNS HTTPS record offset overflow"))?; + if response.len() < end || data_len < 3 { + bail!("truncated DNS HTTPS record"); + } + let (_target, mut cursor) = read_dns_name(response, offset + 2)?; + if cursor > end { + bail!("DNS HTTPS record target exceeded RDATA"); + } + + while cursor < end { + if response.len() < cursor + 4 || cursor + 4 > end { + bail!("truncated DNS HTTPS service parameter"); + } + let key = u16::from_be_bytes([response[cursor], response[cursor + 1]]); + let value_len = u16::from_be_bytes([response[cursor + 2], response[cursor + 3]]) as usize; + cursor += 4; + let value_end = cursor + .checked_add(value_len) + .ok_or_else(|| anyhow!("DNS HTTPS parameter offset overflow"))?; + if value_end > end { + bail!("truncated DNS HTTPS service parameter value"); + } + if key == HTTPS_SVC_PARAM_ECH { + return Ok(Some(response[cursor..value_end].to_vec())); + } + cursor = value_end; + } + Ok(None) +} + +async fn record_dns_mappings(cache: &DnsHostnameCache, response: &[u8]) { + let mappings = match dns_response_host_mappings(response) { + Ok(mappings) => mappings, + Err(_) => return, + }; + if mappings.is_empty() { + return; + } + + let mut guard = cache.write().await; + for (ip, hostname) in mappings { + tracing::debug!(%ip, %hostname, "cached proxy DNS hostname mapping"); + guard.insert_mapping(ip, hostname); + } +} + +fn dns_response_host_mappings(response: &[u8]) -> Result> { + if response.len() < 12 { + bail!("truncated DNS response"); + } + let flags = u16::from_be_bytes([response[2], response[3]]); + if flags & 0x8000 == 0 { + bail!("DNS packet is not a response"); + } + if flags & 0x000f != 0 { + bail!("DNS response returned an error"); + } + + let question_count = u16::from_be_bytes([response[4], response[5]]) as usize; + let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize; + let mut offset = 12; + let mut question_names = Vec::new(); + for _ in 0..question_count { + let (name, next_offset) = read_dns_name(response, offset)?; + offset = next_offset; + if response.len() < offset + 4 { + bail!("truncated DNS question"); + } + if !name.is_empty() { + question_names.push(name); + } + offset += 4; + } + + let mut mappings = Vec::new(); + for _ in 0..answer_count { + let (answer_name, next_offset) = read_dns_name(response, offset)?; + offset = next_offset; + if response.len() < offset + 10 { + bail!("truncated DNS answer"); + } + let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]); + let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]); + let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize; + offset += 10; + if response.len() < offset + data_len { + bail!("truncated DNS answer data"); + } + + let hostname = question_names + .first() + .cloned() + .filter(|name| !name.is_empty()) + .unwrap_or(answer_name); + if record_class == 1 && record_type == 1 && data_len == 4 { + mappings.push(( + IpAddr::V4(Ipv4Addr::new( + response[offset], + response[offset + 1], + response[offset + 2], + response[offset + 3], + )), + hostname, + )); + } else if record_class == 1 && record_type == 28 && data_len == 16 { + let mut octets = [0u8; 16]; + octets.copy_from_slice(&response[offset..offset + 16]); + mappings.push((IpAddr::V6(Ipv6Addr::from(octets)), hostname)); + } + offset += data_len; + } + Ok(mappings) +} + +fn read_dns_name(response: &[u8], offset: usize) -> Result<(String, usize)> { + let mut labels = Vec::new(); + let mut cursor = offset; + let mut next_offset = None; + let mut jumps = 0usize; + + loop { + if cursor >= response.len() { + bail!("truncated DNS name"); + } + let len = response[cursor]; + if len & 0xc0 == 0xc0 { + if response.len() < cursor + 2 { + bail!("truncated compressed DNS name"); + } + if next_offset.is_none() { + next_offset = Some(cursor + 2); + } + let pointer = (((len & 0x3f) as usize) << 8) | response[cursor + 1] as usize; + cursor = pointer; + jumps += 1; + if jumps > 16 { + bail!("too many compressed DNS name jumps"); + } + continue; + } + if len & 0xc0 != 0 { + bail!("unsupported DNS label encoding"); + } + cursor += 1; + if len == 0 { + return Ok((labels.join("."), next_offset.unwrap_or(cursor))); + } + let end = cursor + .checked_add(len as usize) + .ok_or_else(|| anyhow!("DNS name offset overflow"))?; + if end > response.len() { + bail!("truncated DNS label"); + } + let label = + std::str::from_utf8(&response[cursor..end]).context("DNS label is not valid UTF-8")?; + labels.push(label.to_owned()); + cursor = end; + } +} + +fn skip_dns_name(response: &[u8], mut offset: usize) -> Result { + loop { + if offset >= response.len() { + bail!("truncated DNS name"); + } + let len = response[offset]; + if len & 0xc0 == 0xc0 { + if response.len() < offset + 2 { + bail!("truncated compressed DNS name"); + } + return Ok(offset + 2); + } + if len & 0xc0 != 0 { + bail!("unsupported DNS label encoding"); + } + offset += 1; + if len == 0 { + return Ok(offset); + } + offset = offset + .checked_add(len as usize) + .ok_or_else(|| anyhow!("DNS name offset overflow"))?; + } +} + +fn is_fake_or_benchmark_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + let octets = ip.octets(); + octets[0] == 198 && matches!(octets[1], 18 | 19) + } + IpAddr::V6(_) => false, + } +} + +#[cfg(target_vendor = "apple")] +fn platform_proxy_dns_servers() -> Vec { + match std::fs::read_to_string("/etc/resolv.conf") { + Ok(contents) => { + let servers = parse_resolv_conf_dns_servers(&contents); + if !servers.is_empty() { + return servers; + } + } + Err(err) => { + tracing::warn!(error = %err, "failed to read /etc/resolv.conf"); + } + } + + match Command::new("scutil").arg("--dns").output() { + Ok(output) if output.status.success() => { + parse_apple_physical_dns_servers(&String::from_utf8_lossy(&output.stdout)) + } + Ok(output) => { + tracing::warn!( + status = ?output.status.code(), + stderr = %String::from_utf8_lossy(&output.stderr), + "failed to read macOS DNS configuration" + ); + Vec::new() + } + Err(err) => { + tracing::warn!(error = %err, "failed to run scutil --dns"); + Vec::new() + } + } +} + +#[cfg(not(target_vendor = "apple"))] +fn platform_proxy_dns_servers() -> Vec { + Vec::new() +} + +fn parse_apple_physical_dns_servers(output: &str) -> Vec { + let mut servers = Vec::new(); + let mut resolver_servers = Vec::::new(); + let mut resolver_interface = None::; + + let mut flush_resolver = + |resolver_servers: &mut Vec, resolver_interface: &mut Option| { + let is_tunnel_resolver = resolver_interface + .as_deref() + .is_some_and(should_skip_dns_resolver_interface); + if !is_tunnel_resolver { + for server in resolver_servers.drain(..) { + if !servers.contains(&server) { + servers.push(server); + } + } + } else { + resolver_servers.clear(); + } + *resolver_interface = None; + }; + + for line in output.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("resolver #") { + flush_resolver(&mut resolver_servers, &mut resolver_interface); + continue; + } + if trimmed.starts_with("nameserver[") { + if let Some((_, value)) = trimmed.split_once(':') { + let server = value.trim(); + if let Ok(ip) = server.parse::() { + if is_usable_proxy_dns_ip(ip) { + resolver_servers.push(server.to_owned()); + } + } + } + continue; + } + if trimmed.starts_with("if_index") { + if let (Some(start), Some(end)) = (trimmed.find('('), trimmed.find(')')) { + resolver_interface = Some(trimmed[start + 1..end].to_owned()); + } + } + } + flush_resolver(&mut resolver_servers, &mut resolver_interface); + + servers +} + +fn parse_resolv_conf_dns_servers(contents: &str) -> Vec { + let mut servers = Vec::new(); + for line in contents.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + let mut fields = line.split_whitespace(); + if fields.next() != Some("nameserver") { + continue; + } + let Some(server) = fields.next() else { + continue; + }; + let Ok(ip) = server.parse::() else { + continue; + }; + if is_usable_proxy_dns_ip(ip) && !servers.iter().any(|existing| existing == server) { + servers.push(server.to_owned()); + } + } + servers +} + +fn should_skip_dns_resolver_interface(interface: &str) -> bool { + interface.starts_with("utun") + || interface.starts_with("lo") + || interface.starts_with("awdl") + || interface.starts_with("llw") +} + +fn is_usable_proxy_dns_ip(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => { + !ip.is_unspecified() + && !ip.is_loopback() + && !ip.is_multicast() + && !is_fake_or_benchmark_ip(IpAddr::V4(ip)) + } + IpAddr::V6(ip) => !ip.is_unspecified() && !ip.is_loopback() && !ip.is_multicast(), + } +} + +#[cfg(target_vendor = "apple")] +fn bind_proxy_outbound_socket(fd: std::os::fd::RawFd, ip: IpAddr) -> Result<()> { + if !should_bind_proxy_outbound_socket(ip) { + return Ok(()); + } + let interface_index = default_physical_interface_index().ok_or_else(|| { + anyhow!("no non-tunnel default interface found for proxy outbound socket") + })?; + let interface_index = interface_index as libc::c_uint; + let (level, option) = match ip { + IpAddr::V4(_) => (libc::IPPROTO_IP, libc::IP_BOUND_IF), + IpAddr::V6(_) => (libc::IPPROTO_IPV6, libc::IPV6_BOUND_IF), + }; + let result = unsafe { + libc::setsockopt( + fd, + level, + option, + (&interface_index as *const libc::c_uint).cast::(), + std::mem::size_of_val(&interface_index) as libc::socklen_t, + ) + }; + if result != 0 { + return Err(std::io::Error::last_os_error()).with_context(|| { + format!("failed to bind proxy outbound socket to interface index {interface_index}") + }); + } + Ok(()) +} + +#[cfg(not(target_vendor = "apple"))] +fn bind_proxy_outbound_socket(_fd: std::os::fd::RawFd, _ip: IpAddr) -> Result<()> { + Ok(()) +} + +fn should_bind_proxy_outbound_socket(ip: IpAddr) -> bool { + !ip.is_loopback() +} + +#[cfg(target_vendor = "apple")] +fn default_physical_interface_index() -> Option { + #[derive(Clone)] + struct Candidate { + name: String, + index: u32, + score: i32, + } + + let mut addrs = std::ptr::null_mut(); + if unsafe { libc::getifaddrs(&mut addrs) } != 0 || addrs.is_null() { + return None; + } + + struct IfAddrs(*mut libc::ifaddrs); + impl Drop for IfAddrs { + fn drop(&mut self) { + unsafe { libc::freeifaddrs(self.0) }; + } + } + let _guard = IfAddrs(addrs); + + let mut candidates = Vec::::new(); + let mut current = addrs; + while !current.is_null() { + let ifa = unsafe { &*current }; + current = ifa.ifa_next; + if ifa.ifa_addr.is_null() || ifa.ifa_name.is_null() { + continue; + } + let flags = ifa.ifa_flags as libc::c_uint; + if flags & libc::IFF_UP as libc::c_uint == 0 + || flags & libc::IFF_RUNNING as libc::c_uint == 0 + || flags & libc::IFF_LOOPBACK as libc::c_uint != 0 + || flags & libc::IFF_POINTOPOINT as libc::c_uint != 0 + { + continue; + } + + let family = unsafe { (*ifa.ifa_addr).sa_family as libc::c_int }; + if family != libc::AF_INET && family != libc::AF_INET6 { + continue; + } + + let name = unsafe { CStr::from_ptr(ifa.ifa_name) } + .to_string_lossy() + .into_owned(); + if should_skip_outbound_interface(&name) { + continue; + } + let index = unsafe { libc::if_nametoindex(ifa.ifa_name) }; + if index == 0 { + continue; + } + + let mut score = if name.starts_with("en") { 100 } else { 10 }; + if family == libc::AF_INET { + score += 20; + } + if name == "en0" { + score += 10; + } + + candidates.push(Candidate { name, index, score }); + } + + candidates.sort_by_key(|candidate| (Reverse(candidate.score), candidate.name.clone())); + candidates.first().map(|candidate| { + tracing::debug!( + interface = %candidate.name, + index = candidate.index, + score = candidate.score, + "selected proxy outbound physical interface" + ); + candidate.index + }) +} + +#[cfg(target_vendor = "apple")] +fn should_skip_outbound_interface(name: &str) -> bool { + name.starts_with("lo") + || name.starts_with("utun") + || name.starts_with("awdl") + || name.starts_with("llw") + || name.starts_with("bridge") + || name.starts_with("gif") + || name.starts_with("stf") + || name.starts_with("p2p") +} + +fn excluded_routes_for_server(host: &str, port: u16) -> Result> { + let mut routes = Vec::new(); + for address in resolve_server_addresses(host, port) + .with_context(|| format!("failed to resolve proxy server {host}:{port}"))? + { + let route = host_route_for_ip(address.ip()); + if !routes.contains(&route) { + routes.push(route); + } + } + if routes.is_empty() { + bail!("no addresses resolved for proxy server {host}:{port}"); + } + Ok(routes) +} + +fn host_route_for_ip(ip: IpAddr) -> String { + match ip { + IpAddr::V4(ip) => format!("{ip}/32"), + IpAddr::V6(ip) => format!("{ip}/128"), + } +} diff --git a/burrow/src/proxy_runtime/shadowsocks_crypto.rs b/burrow/src/proxy_runtime/shadowsocks_crypto.rs new file mode 100644 index 0000000..773a37d --- /dev/null +++ b/burrow/src/proxy_runtime/shadowsocks_crypto.rs @@ -0,0 +1,2004 @@ +struct BurrowDatagramSocket(UdpSocket); + +impl DatagramSocket for BurrowDatagramSocket { + fn local_addr(&self) -> std::io::Result { + self.0.local_addr() + } +} + +impl DatagramReceive for BurrowDatagramSocket { + fn poll_recv( + &self, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.0.poll_recv(cx, buf) + } + + fn poll_recv_from( + &self, + cx: &mut TaskContext<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + self.0.poll_recv_from(cx, buf) + } + + fn poll_recv_ready(&self, cx: &mut TaskContext<'_>) -> Poll> { + self.0.poll_recv_ready(cx) + } +} + +impl DatagramSend for BurrowDatagramSocket { + fn poll_send(&self, cx: &mut TaskContext<'_>, buf: &[u8]) -> Poll> { + self.0.poll_send(cx, buf) + } + + fn poll_send_to( + &self, + cx: &mut TaskContext<'_>, + buf: &[u8], + target: SocketAddr, + ) -> Poll> { + self.0.poll_send_to(cx, buf, target) + } + + fn poll_send_ready(&self, cx: &mut TaskContext<'_>) -> Poll> { + self.0.poll_send_ready(cx) + } +} + +struct CustomSsStreamWriter { + writer: W, + cipher: CustomSsStreamCipher, +} + +impl CustomSsStreamWriter +where + W: AsyncWrite + Unpin, +{ + async fn new(mut writer: W, kind: CustomSsStreamKind, key: Arc<[u8]>) -> Result { + let mut salt = vec![0u8; kind.salt_len()]; + OsRng.fill_bytes(&mut salt); + let cipher = CustomSsStreamCipher::new(kind, &key, &salt)?; + writer + .write_all(&salt) + .await + .context("failed to write Shadowsocks request salt")?; + Ok(Self { writer, cipher }) + } + + async fn write_all_encrypted(&mut self, payload: &[u8]) -> Result<()> { + if payload.is_empty() { + return Ok(()); + } + let mut encrypted = payload.to_vec(); + self.cipher.apply_keystream(&mut encrypted); + self.writer + .write_all(&encrypted) + .await + .context("failed to write Shadowsocks encrypted stream payload")?; + self.writer.flush().await?; + Ok(()) + } + + async fn shutdown(&mut self) -> Result<()> { + self.writer.shutdown().await?; + Ok(()) + } +} + +struct CustomSsStreamReader { + reader: R, + kind: CustomSsStreamKind, + key: Arc<[u8]>, + cipher: Option, +} + +impl CustomSsStreamReader +where + R: AsyncRead + Unpin, +{ + fn new(reader: R, kind: CustomSsStreamKind, key: Arc<[u8]>) -> Self { + Self { + reader, + kind, + key, + cipher: None, + } + } + + async fn read_decrypted(&mut self, output: &mut [u8]) -> Result { + if self.cipher.is_none() { + let mut salt = vec![0u8; self.kind.salt_len()]; + if let Err(err) = self.reader.read_exact(&mut salt).await { + if err.kind() == std::io::ErrorKind::UnexpectedEof { + return Ok(0); + } + return Err(err).context("failed to read Shadowsocks response salt"); + } + self.cipher = Some(CustomSsStreamCipher::new(self.kind, &self.key, &salt)?); + } + let n = self + .reader + .read(output) + .await + .context("failed to read Shadowsocks encrypted stream payload")?; + if n == 0 { + return Ok(0); + } + self.cipher + .as_mut() + .expect("cipher initialized") + .apply_keystream(&mut output[..n]); + Ok(n) + } +} + +struct CustomSsStreamByteReader { + reader: CustomSsStreamReader, + buffer: Vec, + offset: usize, +} + +impl CustomSsStreamByteReader +where + R: AsyncRead + Unpin, +{ + fn new(reader: CustomSsStreamReader) -> Self { + Self { + reader, + buffer: Vec::new(), + offset: 0, + } + } + + async fn read_frame(&mut self) -> Result)>> { + let source = match self.read_uot_addr().await? { + Some(source) => source, + None => return Ok(None), + }; + let Some(payload_len) = self.read_u16().await? else { + bail!("truncated udp-over-tcp payload length"); + }; + let payload = self.read_exact_vec(payload_len as usize).await?; + Ok(Some((source, payload))) + } + + async fn read_uot_addr(&mut self) -> Result> { + let Some(atyp) = self.read_u8().await? else { + return Ok(None); + }; + match atyp { + 0x00 => { + let ip = self.read_exact_array::<4>().await?; + let port = self.read_u16_required().await?; + Ok(Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port))) + } + 0x01 => { + let ip = self.read_exact_array::<16>().await?; + let port = self.read_u16_required().await?; + Ok(Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port))) + } + 0x02 => { + let len = self.read_u8_required().await? as usize; + let domain = self.read_exact_vec(len).await?; + let port = self.read_u16_required().await?; + let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; + Ok(Some(resolve_server(&domain, port)?)) + } + other => bail!("unsupported udp-over-tcp address type {other}"), + } + } + + async fn read_u8_required(&mut self) -> Result { + self.read_u8() + .await? + .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) + } + + async fn read_u16_required(&mut self) -> Result { + self.read_u16() + .await? + .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) + } + + async fn read_u8(&mut self) -> Result> { + if !self.ensure_available(1).await? { + return Ok(None); + } + let value = self.buffer[self.offset]; + self.offset += 1; + self.compact_buffer(); + Ok(Some(value)) + } + + async fn read_u16(&mut self) -> Result> { + if !self.ensure_available(2).await? { + return Ok(None); + } + let value = u16::from_be_bytes([self.buffer[self.offset], self.buffer[self.offset + 1]]); + self.offset += 2; + self.compact_buffer(); + Ok(Some(value)) + } + + async fn read_exact_array(&mut self) -> Result<[u8; N]> { + let bytes = self.read_exact_vec(N).await?; + Ok(bytes + .try_into() + .expect("read_exact_vec returned requested length")) + } + + async fn read_exact_vec(&mut self, len: usize) -> Result> { + if !self.ensure_available(len).await? { + bail!("truncated udp-over-tcp frame"); + } + let value = self.buffer[self.offset..self.offset + len].to_vec(); + self.offset += len; + self.compact_buffer(); + Ok(value) + } + + async fn ensure_available(&mut self, len: usize) -> Result { + while self.buffer.len().saturating_sub(self.offset) < len { + if self.offset > 0 { + self.buffer.drain(..self.offset); + self.offset = 0; + } + let mut chunk = vec![0u8; 16 * 1024]; + let read = self.reader.read_decrypted(&mut chunk).await?; + if read == 0 { + return Ok(false); + } + self.buffer.extend_from_slice(&chunk[..read]); + } + Ok(true) + } + + fn compact_buffer(&mut self) { + if self.offset == self.buffer.len() { + self.buffer.clear(); + self.offset = 0; + } else if self.offset > 4096 && self.offset * 2 > self.buffer.len() { + self.buffer.drain(..self.offset); + self.offset = 0; + } + } +} + +enum CustomSsStreamCipher { + Chacha20(ChaCha20Legacy), + XChacha20(XChaCha20), +} + +impl CustomSsStreamCipher { + fn new(kind: CustomSsStreamKind, key: &[u8], salt: &[u8]) -> Result { + match kind { + CustomSsStreamKind::Chacha20 => Ok(Self::Chacha20( + ChaCha20Legacy::new_from_slices(key, salt) + .map_err(|err| anyhow!("invalid chacha20 stream cipher material: {err}"))?, + )), + CustomSsStreamKind::XChacha20 => Ok(Self::XChacha20( + XChaCha20::new_from_slices(key, salt) + .map_err(|err| anyhow!("invalid xchacha20 stream cipher material: {err}"))?, + )), + } + } + + fn apply_keystream(&mut self, buf: &mut [u8]) { + match self { + Self::Chacha20(cipher) => cipher.apply_keystream(buf), + Self::XChacha20(cipher) => cipher.apply_keystream(buf), + } + } +} + +struct CustomSsAeadWriter { + writer: W, + cipher: CustomSsAeadCipher, + nonce: Vec, +} + +impl CustomSsAeadWriter +where + W: AsyncWrite + Unpin, +{ + async fn new(mut writer: W, kind: CustomSsAeadKind, master_key: Arc<[u8]>) -> Result { + let mut salt = vec![0u8; kind.salt_len()]; + OsRng.fill_bytes(&mut salt); + let cipher = custom_ss_subkey(kind, &master_key, &salt)?; + writer + .write_all(&salt) + .await + .context("failed to write Shadowsocks request salt")?; + Ok(Self { + writer, + cipher, + nonce: vec![0u8; kind.nonce_len()], + }) + } + + async fn write_chunk(&mut self, payload: &[u8]) -> Result<()> { + if payload.is_empty() { + return Ok(()); + } + let mut offset = 0; + while offset < payload.len() { + let end = (offset + 0x3fff).min(payload.len()); + let chunk = &payload[offset..end]; + + let mut encrypted_len = (chunk.len() as u16).to_be_bytes().to_vec(); + self.cipher + .seal_in_place(&mut self.nonce, &mut encrypted_len)?; + self.writer.write_all(&encrypted_len).await?; + + let mut encrypted_payload = chunk.to_vec(); + self.cipher + .seal_in_place(&mut self.nonce, &mut encrypted_payload)?; + self.writer.write_all(&encrypted_payload).await?; + offset = end; + } + self.writer.flush().await?; + Ok(()) + } + + async fn shutdown(&mut self) -> Result<()> { + self.writer.shutdown().await?; + Ok(()) + } +} + +struct CustomSsAeadReader { + reader: R, + kind: CustomSsAeadKind, + master_key: Arc<[u8]>, + cipher: Option, + nonce: Vec, +} + +impl CustomSsAeadReader +where + R: AsyncRead + Unpin, +{ + fn new(reader: R, kind: CustomSsAeadKind, master_key: Arc<[u8]>) -> Self { + Self { + reader, + kind, + master_key, + cipher: None, + nonce: vec![0u8; kind.nonce_len()], + } + } + + async fn read_chunk(&mut self, output: &mut Vec) -> Result { + if self.cipher.is_none() { + let mut salt = vec![0u8; self.kind.salt_len()]; + self.reader + .read_exact(&mut salt) + .await + .context("failed to read Shadowsocks response salt")?; + self.cipher = Some(custom_ss_subkey(self.kind, &self.master_key, &salt)?); + } + let cipher = self.cipher.as_ref().expect("cipher initialized"); + let tag_len = self.kind.tag_len(); + let mut encrypted_len = vec![0u8; 2 + tag_len]; + if let Err(err) = self.reader.read_exact(&mut encrypted_len).await { + if err.kind() == std::io::ErrorKind::UnexpectedEof { + return Ok(0); + } + return Err(err).context("failed to read Shadowsocks encrypted chunk length"); + } + let plaintext_len = cipher.open_in_place(&mut self.nonce, &mut encrypted_len)?; + let payload_len = u16::from_be_bytes([plaintext_len[0], plaintext_len[1]]) as usize; + if payload_len == 0 { + return Ok(0); + } + let mut encrypted_payload = vec![0u8; payload_len + tag_len]; + self.reader + .read_exact(&mut encrypted_payload) + .await + .context("failed to read Shadowsocks encrypted chunk")?; + let plaintext = cipher.open_in_place(&mut self.nonce, &mut encrypted_payload)?; + output.extend_from_slice(plaintext); + Ok(plaintext.len()) + } +} + +struct CustomSsAeadByteReader { + reader: CustomSsAeadReader, + buffer: Vec, + offset: usize, +} + +impl CustomSsAeadByteReader +where + R: AsyncRead + Unpin, +{ + fn new(reader: CustomSsAeadReader) -> Self { + Self { + reader, + buffer: Vec::new(), + offset: 0, + } + } + + async fn read_frame(&mut self) -> Result)>> { + let source = match self.read_uot_addr().await? { + Some(source) => source, + None => return Ok(None), + }; + let Some(payload_len) = self.read_u16().await? else { + bail!("truncated udp-over-tcp payload length"); + }; + let payload = self.read_exact_vec(payload_len as usize).await?; + Ok(Some((source, payload))) + } + + async fn read_uot_addr(&mut self) -> Result> { + let Some(atyp) = self.read_u8().await? else { + return Ok(None); + }; + match atyp { + 0x00 => { + let ip = self.read_exact_array::<4>().await?; + let port = self.read_u16_required().await?; + Ok(Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port))) + } + 0x01 => { + let ip = self.read_exact_array::<16>().await?; + let port = self.read_u16_required().await?; + Ok(Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port))) + } + 0x02 => { + let len = self.read_u8_required().await? as usize; + let domain = self.read_exact_vec(len).await?; + let port = self.read_u16_required().await?; + let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; + Ok(Some(resolve_server(&domain, port)?)) + } + other => bail!("unsupported udp-over-tcp address type {other}"), + } + } + + async fn read_u8_required(&mut self) -> Result { + self.read_u8() + .await? + .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) + } + + async fn read_u16_required(&mut self) -> Result { + self.read_u16() + .await? + .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) + } + + async fn read_u8(&mut self) -> Result> { + if !self.ensure_available(1).await? { + return Ok(None); + } + let value = self.buffer[self.offset]; + self.offset += 1; + self.compact_buffer(); + Ok(Some(value)) + } + + async fn read_u16(&mut self) -> Result> { + if !self.ensure_available(2).await? { + return Ok(None); + } + let value = u16::from_be_bytes([self.buffer[self.offset], self.buffer[self.offset + 1]]); + self.offset += 2; + self.compact_buffer(); + Ok(Some(value)) + } + + async fn read_exact_array(&mut self) -> Result<[u8; N]> { + let bytes = self.read_exact_vec(N).await?; + Ok(bytes + .try_into() + .expect("read_exact_vec returned requested length")) + } + + async fn read_exact_vec(&mut self, len: usize) -> Result> { + if !self.ensure_available(len).await? { + bail!("truncated udp-over-tcp frame"); + } + let value = self.buffer[self.offset..self.offset + len].to_vec(); + self.offset += len; + self.compact_buffer(); + Ok(value) + } + + async fn ensure_available(&mut self, len: usize) -> Result { + while self.buffer.len().saturating_sub(self.offset) < len { + if self.offset > 0 { + self.buffer.drain(..self.offset); + self.offset = 0; + } + let mut chunk = Vec::new(); + let read = self.reader.read_chunk(&mut chunk).await?; + if read == 0 { + return Ok(false); + } + self.buffer.extend_from_slice(&chunk); + } + Ok(true) + } + + fn compact_buffer(&mut self) { + if self.offset == self.buffer.len() { + self.buffer.clear(); + self.offset = 0; + } else if self.offset > 4096 && self.offset * 2 > self.buffer.len() { + self.buffer.drain(..self.offset); + self.offset = 0; + } + } +} + +struct Aead2022AesCcmWriter { + writer: W, + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, + cipher: Option, +} + +impl Aead2022AesCcmWriter +where + W: AsyncWrite + Unpin, +{ + async fn new( + writer: W, + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, + ) -> Result { + Ok(Self { + writer, + kind, + user_key, + identity_keys, + cipher: None, + }) + } + + async fn write_request( + &mut self, + socks_target: &[u8], + padding_len: usize, + ) -> Result> { + if padding_len > u16::MAX as usize { + bail!("Shadowsocks 2022 TCP request padding is too large"); + } + let mut salt = vec![0u8; self.kind.key_len()]; + OsRng.fill_bytes(&mut salt); + + let mut header = salt.clone(); + header.extend_from_slice(&aead2022_eih_blocks( + self.kind, + &self.identity_keys, + &self.user_key, + &salt, + )?); + + let mut cipher = Aead2022TcpCipher::new(self.kind, &self.user_key, &salt)?; + let variable_len = socks_target + .len() + .checked_add(2) + .and_then(|len| len.checked_add(padding_len)) + .ok_or_else(|| anyhow!("Shadowsocks 2022 TCP request header is too large"))?; + if variable_len > u16::MAX as usize { + bail!("Shadowsocks 2022 TCP request header is too large"); + } + + let mut fixed = Vec::with_capacity(1 + 8 + 2); + fixed.push(AEAD2022_HEADER_TYPE_CLIENT); + fixed.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); + fixed.extend_from_slice(&(variable_len as u16).to_be_bytes()); + header.extend_from_slice(&cipher.seal_packet(&fixed)?); + + let mut variable = Vec::with_capacity(variable_len); + variable.extend_from_slice(socks_target); + variable.extend_from_slice(&(padding_len as u16).to_be_bytes()); + variable.resize(variable.len() + padding_len, 0); + header.extend_from_slice(&cipher.seal_packet(&variable)?); + + self.writer + .write_all(&header) + .await + .context("failed to write Shadowsocks 2022 request header")?; + self.writer + .flush() + .await + .context("failed to flush Shadowsocks 2022 request header")?; + self.cipher = Some(cipher); + Ok(Arc::from(salt)) + } + + async fn write_chunk(&mut self, payload: &[u8]) -> Result<()> { + if payload.is_empty() { + return Ok(()); + } + let cipher = self + .cipher + .as_mut() + .ok_or_else(|| anyhow!("Shadowsocks 2022 TCP request was not initialized"))?; + let mut offset = 0; + while offset < payload.len() { + let end = (offset + u16::MAX as usize).min(payload.len()); + let chunk = &payload[offset..end]; + + let encrypted_len = cipher.seal_packet(&(chunk.len() as u16).to_be_bytes())?; + self.writer.write_all(&encrypted_len).await?; + + let encrypted_payload = cipher.seal_packet(chunk)?; + self.writer.write_all(&encrypted_payload).await?; + offset = end; + } + self.writer.flush().await?; + Ok(()) + } + + async fn shutdown(&mut self) -> Result<()> { + self.writer.shutdown().await?; + Ok(()) + } +} + +struct Aead2022AesCcmReader { + reader: R, + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + request_salt: Arc<[u8]>, + cipher: Option, + response_read: bool, +} + +impl Aead2022AesCcmReader +where + R: AsyncRead + Unpin, +{ + fn new( + reader: R, + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + request_salt: Arc<[u8]>, + ) -> Self { + Self { + reader, + kind, + user_key, + request_salt, + cipher: None, + response_read: false, + } + } + + async fn read_response(&mut self) -> Result<()> { + if self.response_read { + return Ok(()); + } + let mut salt = vec![0u8; self.kind.key_len()]; + self.reader + .read_exact(&mut salt) + .await + .context("failed to read Shadowsocks 2022 response salt")?; + let mut cipher = Aead2022TcpCipher::new(self.kind, &self.user_key, &salt)?; + + let fixed_len = 1 + 8 + self.kind.key_len() + 2; + let mut encrypted_fixed = vec![0u8; fixed_len + self.kind.tag_len()]; + self.reader + .read_exact(&mut encrypted_fixed) + .await + .context("failed to read Shadowsocks 2022 response fixed header")?; + let fixed = cipher.open_packet(&mut encrypted_fixed)?; + if fixed[0] != AEAD2022_HEADER_TYPE_SERVER { + bail!( + "unexpected Shadowsocks 2022 response header type {}", + fixed[0] + ); + } + aead2022_validate_timestamp(u64::from_be_bytes( + fixed[1..9].try_into().expect("fixed timestamp"), + ))?; + let response_request_salt = &fixed[9..9 + self.kind.key_len()]; + if !bool::from(response_request_salt.ct_eq(self.request_salt.as_ref())) { + bail!("Shadowsocks 2022 response request salt mismatch"); + } + let padding_len_offset = 9 + self.kind.key_len(); + let padding_len = + u16::from_be_bytes([fixed[padding_len_offset], fixed[padding_len_offset + 1]]) as usize; + if padding_len > 0 { + let mut encrypted_padding = vec![0u8; padding_len + self.kind.tag_len()]; + self.reader + .read_exact(&mut encrypted_padding) + .await + .context("failed to read Shadowsocks 2022 response padding")?; + let padding = cipher.open_packet(&mut encrypted_padding)?; + if padding.len() != padding_len { + bail!("Shadowsocks 2022 response padding length mismatch"); + } + } + + self.cipher = Some(cipher); + self.response_read = true; + Ok(()) + } + + async fn read_chunk(&mut self, output: &mut Vec) -> Result { + self.read_response().await?; + let cipher = self + .cipher + .as_mut() + .ok_or_else(|| anyhow!("Shadowsocks 2022 TCP response was not initialized"))?; + let mut encrypted_len = vec![0u8; 2 + self.kind.tag_len()]; + if let Err(err) = self.reader.read_exact(&mut encrypted_len).await { + if err.kind() == std::io::ErrorKind::UnexpectedEof { + return Ok(0); + } + return Err(err).context("failed to read Shadowsocks 2022 encrypted chunk length"); + } + let plaintext_len = cipher.open_packet(&mut encrypted_len)?; + let payload_len = u16::from_be_bytes([plaintext_len[0], plaintext_len[1]]) as usize; + if payload_len == 0 { + return Ok(0); + } + let mut encrypted_payload = vec![0u8; payload_len + self.kind.tag_len()]; + self.reader + .read_exact(&mut encrypted_payload) + .await + .context("failed to read Shadowsocks 2022 encrypted chunk")?; + let plaintext = cipher.open_packet(&mut encrypted_payload)?; + output.extend_from_slice(plaintext); + Ok(plaintext.len()) + } +} + +struct Aead2022AesCcmByteReader { + reader: Aead2022AesCcmReader, + buffer: Vec, + offset: usize, +} + +impl Aead2022AesCcmByteReader +where + R: AsyncRead + Unpin, +{ + fn new(reader: Aead2022AesCcmReader) -> Self { + Self { + reader, + buffer: Vec::new(), + offset: 0, + } + } + + async fn read_frame(&mut self) -> Result)>> { + let source = match self.read_uot_addr().await? { + Some(source) => source, + None => return Ok(None), + }; + let Some(payload_len) = self.read_u16().await? else { + bail!("truncated udp-over-tcp payload length"); + }; + let payload = self.read_exact_vec(payload_len as usize).await?; + Ok(Some((source, payload))) + } + + async fn read_uot_addr(&mut self) -> Result> { + let Some(atyp) = self.read_u8().await? else { + return Ok(None); + }; + match atyp { + 0x00 => { + let ip = self.read_exact_array::<4>().await?; + let port = self.read_u16_required().await?; + Ok(Some(SocketAddr::new(IpAddr::V4(Ipv4Addr::from(ip)), port))) + } + 0x01 => { + let ip = self.read_exact_array::<16>().await?; + let port = self.read_u16_required().await?; + Ok(Some(SocketAddr::new(IpAddr::V6(Ipv6Addr::from(ip)), port))) + } + 0x02 => { + let len = self.read_u8_required().await? as usize; + let domain = self.read_exact_vec(len).await?; + let port = self.read_u16_required().await?; + let domain = String::from_utf8(domain).context("invalid udp-over-tcp domain")?; + Ok(Some(resolve_server(&domain, port)?)) + } + other => bail!("unsupported udp-over-tcp address type {other}"), + } + } + + async fn read_u8_required(&mut self) -> Result { + self.read_u8() + .await? + .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) + } + + async fn read_u16_required(&mut self) -> Result { + self.read_u16() + .await? + .ok_or_else(|| anyhow!("truncated udp-over-tcp frame")) + } + + async fn read_u8(&mut self) -> Result> { + if !self.ensure_available(1).await? { + return Ok(None); + } + let value = self.buffer[self.offset]; + self.offset += 1; + self.compact_buffer(); + Ok(Some(value)) + } + + async fn read_u16(&mut self) -> Result> { + if !self.ensure_available(2).await? { + return Ok(None); + } + let value = u16::from_be_bytes([self.buffer[self.offset], self.buffer[self.offset + 1]]); + self.offset += 2; + self.compact_buffer(); + Ok(Some(value)) + } + + async fn read_exact_array(&mut self) -> Result<[u8; N]> { + let bytes = self.read_exact_vec(N).await?; + Ok(bytes + .try_into() + .expect("read_exact_vec returned requested length")) + } + + async fn read_exact_vec(&mut self, len: usize) -> Result> { + if !self.ensure_available(len).await? { + bail!("truncated udp-over-tcp frame"); + } + let value = self.buffer[self.offset..self.offset + len].to_vec(); + self.offset += len; + self.compact_buffer(); + Ok(value) + } + + async fn ensure_available(&mut self, len: usize) -> Result { + while self.buffer.len().saturating_sub(self.offset) < len { + if self.offset > 0 { + self.buffer.drain(..self.offset); + self.offset = 0; + } + let mut chunk = Vec::new(); + let read = self.reader.read_chunk(&mut chunk).await?; + if read == 0 { + return Ok(false); + } + self.buffer.extend_from_slice(&chunk); + } + Ok(true) + } + + fn compact_buffer(&mut self) { + if self.offset == self.buffer.len() { + self.buffer.clear(); + self.offset = 0; + } else if self.offset > 4096 && self.offset * 2 > self.buffer.len() { + self.buffer.drain(..self.offset); + self.offset = 0; + } + } +} + +enum Aead2022AesCcmCipher { + Aes128(Aes128Ccm), + Aes256(Aes256Ccm), +} + +impl Aead2022AesCcmCipher { + fn new(kind: Aead2022AesCcmKind, key: &[u8]) -> Result { + match kind { + Aead2022AesCcmKind::Aes128 => Ok(Self::Aes128( + Aes128Ccm::new_from_slice(key) + .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-128-CCM key"))?, + )), + Aead2022AesCcmKind::Aes256 => Ok(Self::Aes256( + Aes256Ccm::new_from_slice(key) + .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-256-CCM key"))?, + )), + } + } + + fn seal_in_place(&self, nonce: &[u8], buf: &mut Vec) -> Result<()> { + let tag = match self { + Self::Aes128(cipher) => cipher + .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) + .map_err(|_| anyhow!("Shadowsocks 2022 AES-128-CCM seal failed"))? + .as_slice() + .to_vec(), + Self::Aes256(cipher) => cipher + .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) + .map_err(|_| anyhow!("Shadowsocks 2022 AES-256-CCM seal failed"))? + .as_slice() + .to_vec(), + }; + buf.extend_from_slice(&tag); + Ok(()) + } + + fn open_in_place<'a>(&self, nonce: &[u8], buf: &'a mut [u8]) -> Result<&'a [u8]> { + if buf.len() < 16 { + bail!("Shadowsocks 2022 AEAD packet missing tag"); + } + let (ciphertext, tag) = buf.split_at_mut(buf.len() - 16); + match self { + Self::Aes128(cipher) => cipher + .decrypt_in_place_detached( + GenericArray::from_slice(nonce), + &[], + ciphertext, + GenericArray::from_slice(tag), + ) + .map_err(|_| anyhow!("Shadowsocks 2022 AES-128-CCM open failed"))?, + Self::Aes256(cipher) => cipher + .decrypt_in_place_detached( + GenericArray::from_slice(nonce), + &[], + ciphertext, + GenericArray::from_slice(tag), + ) + .map_err(|_| anyhow!("Shadowsocks 2022 AES-256-CCM open failed"))?, + }; + Ok(ciphertext) + } +} + +struct Aead2022TcpCipher { + cipher: Aead2022AesCcmCipher, + nonce: Vec, +} + +impl Aead2022TcpCipher { + fn new(kind: Aead2022AesCcmKind, user_key: &[u8], salt: &[u8]) -> Result { + Ok(Self { + cipher: Aead2022AesCcmCipher::new(kind, &aead2022_session_key(kind, user_key, salt)?)?, + nonce: vec![0u8; kind.nonce_len()], + }) + } + + fn seal_packet(&mut self, payload: &[u8]) -> Result> { + let mut packet = payload.to_vec(); + self.cipher.seal_in_place(&self.nonce, &mut packet)?; + increment_nonce(&mut self.nonce); + Ok(packet) + } + + fn open_packet<'a>(&mut self, packet: &'a mut [u8]) -> Result<&'a [u8]> { + let plaintext = self.cipher.open_in_place(&self.nonce, packet)?; + increment_nonce(&mut self.nonce); + Ok(plaintext) + } +} + +struct Aead2022AesCcmUdpSession { + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, + client_session_id: u64, + packet_id: u64, + client_cipher: Aead2022AesCcmCipher, +} + +impl Aead2022AesCcmUdpSession { + fn new( + kind: Aead2022AesCcmKind, + user_key: Arc<[u8]>, + identity_keys: Arc<[Arc<[u8]>]>, + ) -> Result { + let client_session_id = OsRng.next_u64(); + let client_session_id_bytes = client_session_id.to_be_bytes(); + let client_cipher = Aead2022AesCcmCipher::new( + kind, + &aead2022_session_key(kind, &user_key, &client_session_id_bytes)?, + )?; + Ok(Self { + kind, + user_key, + identity_keys, + client_session_id, + packet_id: 0, + client_cipher, + }) + } + + fn encrypt_client_packet( + &mut self, + destination: SocketAddr, + payload: &[u8], + ) -> Result> { + let packet_id = self.packet_id; + self.packet_id = self.packet_id.wrapping_add(1); + + let padding_len = aead2022_udp_padding_len(destination, payload); + let mut packet = Vec::with_capacity( + 16 + self.identity_keys.len() * 16 + 1 + 8 + 2 + padding_len + 19 + payload.len() + 16, + ); + packet.extend_from_slice(&self.client_session_id.to_be_bytes()); + packet.extend_from_slice(&packet_id.to_be_bytes()); + packet.extend_from_slice(&aead2022_udp_eih_blocks( + self.kind, + &self.identity_keys, + &self.user_key, + &packet[..16], + )?); + let data_index = packet.len(); + packet.push(AEAD2022_HEADER_TYPE_CLIENT); + packet.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); + packet.extend_from_slice(&(padding_len as u16).to_be_bytes()); + packet.resize(packet.len() + padding_len, 0); + packet.extend_from_slice(&socks_addr(destination)?); + packet.extend_from_slice(payload); + + let nonce: [u8; 12] = packet[4..16].try_into().expect("fixed packet nonce"); + let mut encrypted_body = packet[data_index..].to_vec(); + self.client_cipher + .seal_in_place(&nonce, &mut encrypted_body)?; + packet.truncate(data_index); + packet.extend_from_slice(&encrypted_body); + aead2022_aes_encrypt_block( + self.kind, + aead2022_udp_header_key(&self.identity_keys, &self.user_key), + &mut packet[..16], + )?; + Ok(packet) + } + + fn decrypt_server_packet(&mut self, packet: &[u8]) -> Result<(SocketAddr, Vec)> { + if packet.len() < 16 + 1 + 8 + 8 + 2 + self.kind.tag_len() { + bail!("Shadowsocks 2022 UDP packet is too short"); + } + let mut buf = packet.to_vec(); + aead2022_aes_decrypt_block(self.kind, &self.user_key, &mut buf[..16])?; + let session_id = u64::from_be_bytes(buf[..8].try_into().expect("fixed session id")); + let session_id_bytes = session_id.to_be_bytes(); + let cipher = Aead2022AesCcmCipher::new( + self.kind, + &aead2022_session_key(self.kind, &self.user_key, &session_id_bytes)?, + )?; + let nonce: [u8; 12] = buf[4..16].try_into().expect("fixed packet nonce"); + let plaintext = cipher.open_in_place(&nonce, &mut buf[16..])?; + if plaintext.len() < 1 + 8 + 8 + 2 { + bail!("Shadowsocks 2022 UDP response is truncated"); + } + if plaintext[0] != AEAD2022_HEADER_TYPE_SERVER { + bail!( + "unexpected Shadowsocks 2022 UDP response header type {}", + plaintext[0] + ); + } + aead2022_validate_timestamp(u64::from_be_bytes( + plaintext[1..9].try_into().expect("fixed timestamp"), + ))?; + let client_session_id = u64::from_be_bytes( + plaintext[9..17] + .try_into() + .expect("fixed client session id"), + ); + if client_session_id != self.client_session_id { + bail!("Shadowsocks 2022 UDP response client session id mismatch"); + } + let padding_len = u16::from_be_bytes([plaintext[17], plaintext[18]]) as usize; + let address_offset = 19usize + .checked_add(padding_len) + .ok_or_else(|| anyhow!("Shadowsocks 2022 UDP response padding is too large"))?; + if plaintext.len() < address_offset { + bail!("Shadowsocks 2022 UDP response padding is truncated"); + } + let (source, used) = parse_socks_addr(&plaintext[address_offset..])?; + Ok((source, plaintext[address_offset + used..].to_vec())) + } +} + +enum CustomSsAeadCipher { + Aes192Gcm(Aes192Gcm), + Aes192Ccm(Aes192Ccm), + Chacha8IetfPoly1305(ChaCha8Poly1305), + XChacha8IetfPoly1305(XChaCha8Poly1305), + Rabbit128Poly1305([u8; 16]), + Aegis128L([u8; 16]), + Aegis256([u8; 32]), + Aez384([u8; 48]), + DeoxysII256128(DeoxysII256), + Ascon128([u8; 16]), + Ascon128A([u8; 16]), + Lea128Gcm(Lea128), + Lea192Gcm(Lea192), + Lea256Gcm(Lea256), +} + +impl CustomSsAeadCipher { + fn new(kind: CustomSsAeadKind, key: &[u8]) -> Result { + match kind { + CustomSsAeadKind::Aes192Gcm => Ok(Self::Aes192Gcm( + Aes192Gcm::new_from_slice(key).map_err(|_| anyhow!("invalid AES-192-GCM key"))?, + )), + CustomSsAeadKind::Aes192Ccm => Ok(Self::Aes192Ccm( + Aes192Ccm::new_from_slice(key).map_err(|_| anyhow!("invalid AES-192-CCM key"))?, + )), + CustomSsAeadKind::Chacha8IetfPoly1305 => Ok(Self::Chacha8IetfPoly1305( + ChaCha8Poly1305::new_from_slice(key) + .map_err(|_| anyhow!("invalid ChaCha8-Poly1305 key"))?, + )), + CustomSsAeadKind::XChacha8IetfPoly1305 => Ok(Self::XChacha8IetfPoly1305( + XChaCha8Poly1305::new_from_slice(key) + .map_err(|_| anyhow!("invalid XChaCha8-Poly1305 key"))?, + )), + CustomSsAeadKind::Rabbit128Poly1305 => Ok(Self::Rabbit128Poly1305( + key.try_into() + .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 key"))?, + )), + CustomSsAeadKind::Aegis128L => Ok(Self::Aegis128L( + key.try_into() + .map_err(|_| anyhow!("invalid AEGIS-128L key"))?, + )), + CustomSsAeadKind::Aegis256 => Ok(Self::Aegis256( + key.try_into() + .map_err(|_| anyhow!("invalid AEGIS-256 key"))?, + )), + CustomSsAeadKind::Aez384 => Ok(Self::Aez384( + key.try_into().map_err(|_| anyhow!("invalid AEZ-384 key"))?, + )), + CustomSsAeadKind::DeoxysII256128 => Ok(Self::DeoxysII256128( + DeoxysII256::new_from_slice(key) + .map_err(|_| anyhow!("invalid Deoxys-II-256-128 key"))?, + )), + CustomSsAeadKind::Ascon128 => Ok(Self::Ascon128( + key.try_into() + .map_err(|_| anyhow!("invalid Ascon128 key"))?, + )), + CustomSsAeadKind::Ascon128A => Ok(Self::Ascon128A( + key.try_into() + .map_err(|_| anyhow!("invalid Ascon128a key"))?, + )), + CustomSsAeadKind::Lea128Gcm => Ok(Self::Lea128Gcm(Lea128::new( + LeaGenericArray::from_slice(key), + ))), + CustomSsAeadKind::Lea192Gcm => Ok(Self::Lea192Gcm(Lea192::new( + LeaGenericArray::from_slice(key), + ))), + CustomSsAeadKind::Lea256Gcm => Ok(Self::Lea256Gcm(Lea256::new( + LeaGenericArray::from_slice(key), + ))), + } + } + + fn seal_in_place(&self, nonce: &mut [u8], buf: &mut Vec) -> Result<()> { + let tag = match self { + Self::Aes192Gcm(cipher) => cipher + .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) + .map_err(|_| anyhow!("AES-192-GCM Shadowsocks seal failed"))? + .as_slice() + .to_vec(), + Self::Aes192Ccm(cipher) => cipher + .encrypt_in_place_detached(GenericArray::from_slice(nonce), &[], buf) + .map_err(|_| anyhow!("AES-192-CCM Shadowsocks seal failed"))? + .as_slice() + .to_vec(), + Self::Chacha8IetfPoly1305(cipher) => cipher + .encrypt_in_place_detached(GenericArray::::from_slice(nonce), &[], buf) + .map_err(|_| anyhow!("ChaCha8-Poly1305 Shadowsocks seal failed"))? + .as_slice() + .to_vec(), + Self::XChacha8IetfPoly1305(cipher) => cipher + .encrypt_in_place_detached(GenericArray::::from_slice(nonce), &[], buf) + .map_err(|_| anyhow!("XChaCha8-Poly1305 Shadowsocks seal failed"))? + .as_slice() + .to_vec(), + Self::Rabbit128Poly1305(key) => { + let nonce: [u8; 8] = nonce + .try_into() + .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 nonce"))?; + rabbit_poly1305_seal_in_place(key, &nonce, buf)? + } + Self::Aegis128L(key) => { + let nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid AEGIS-128L nonce"))?; + Aegis128L::<16>::new(key, &nonce) + .encrypt_in_place(buf, &[]) + .to_vec() + } + Self::Aegis256(key) => { + let nonce: [u8; 32] = nonce + .try_into() + .map_err(|_| anyhow!("invalid AEGIS-256 nonce"))?; + Aegis256::<16>::new(key, &nonce) + .encrypt_in_place(buf, &[]) + .to_vec() + } + Self::Aez384(key) => { + let aead_nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid AEZ-384 nonce"))?; + let cipher = zears::Aez::new(key); + cipher.encrypt_vec(&aead_nonce, &[], 16, buf); + increment_nonce(nonce); + return Ok(()); + } + Self::DeoxysII256128(cipher) => cipher + .encrypt_inout_detached( + &DeoxysArray::try_from(&nonce[..]) + .map_err(|_| anyhow!("invalid Deoxys-II-256-128 nonce"))?, + &[], + buf.as_mut_slice().into(), + ) + .map_err(|_| anyhow!("Deoxys-II-256-128 Shadowsocks seal failed"))? + .as_slice() + .to_vec(), + Self::Ascon128(key) => { + let nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid Ascon128 nonce"))?; + ascon_seal_in_place(key, &nonce, AsconMode::Ascon128, buf) + } + Self::Ascon128A(key) => { + let nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid Ascon128a nonce"))?; + ascon_seal_in_place(key, &nonce, AsconMode::Ascon128A, buf) + } + Self::Lea128Gcm(_) | Self::Lea192Gcm(_) | Self::Lea256Gcm(_) => { + let nonce: [u8; 12] = nonce + .try_into() + .map_err(|_| anyhow!("invalid LEA-GCM nonce"))?; + lea_gcm_seal_in_place(self, &nonce, buf) + } + }; + buf.extend_from_slice(&tag); + increment_nonce(nonce); + Ok(()) + } + + fn open_in_place<'a>(&self, nonce: &mut [u8], buf: &'a mut [u8]) -> Result<&'a [u8]> { + if buf.len() < 16 { + bail!("Shadowsocks AEAD packet missing tag"); + } + let (ciphertext, tag) = buf.split_at_mut(buf.len() - 16); + match self { + Self::Aes192Gcm(cipher) => cipher + .decrypt_in_place_detached( + GenericArray::from_slice(nonce), + &[], + ciphertext, + GenericArray::from_slice(tag), + ) + .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, + Self::Aes192Ccm(cipher) => cipher + .decrypt_in_place_detached( + GenericArray::from_slice(nonce), + &[], + ciphertext, + GenericArray::from_slice(tag), + ) + .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, + Self::Chacha8IetfPoly1305(cipher) => cipher + .decrypt_in_place_detached( + GenericArray::::from_slice(nonce), + &[], + ciphertext, + GenericArray::from_slice(tag), + ) + .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, + Self::XChacha8IetfPoly1305(cipher) => cipher + .decrypt_in_place_detached( + GenericArray::::from_slice(nonce), + &[], + ciphertext, + GenericArray::from_slice(tag), + ) + .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))?, + Self::Rabbit128Poly1305(key) => { + let nonce: [u8; 8] = nonce + .try_into() + .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 nonce"))?; + rabbit_poly1305_open_in_place(key, &nonce, ciphertext, tag)? + } + Self::Aegis128L(key) => { + let nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid AEGIS-128L nonce"))?; + let tag: [u8; 16] = tag + .try_into() + .map_err(|_| anyhow!("invalid AEGIS-128L tag"))?; + Aegis128L::<16>::new(key, &nonce) + .decrypt_in_place(ciphertext, &tag, &[]) + .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))? + } + Self::Aegis256(key) => { + let nonce: [u8; 32] = nonce + .try_into() + .map_err(|_| anyhow!("invalid AEGIS-256 nonce"))?; + let tag: [u8; 16] = tag + .try_into() + .map_err(|_| anyhow!("invalid AEGIS-256 tag"))?; + Aegis256::<16>::new(key, &nonce) + .decrypt_in_place(ciphertext, &tag, &[]) + .map_err(|_| anyhow!("Shadowsocks AEAD open failed"))? + } + Self::Aez384(key) => { + let aead_nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid AEZ-384 nonce"))?; + let cipher = zears::Aez::new(key); + let mut encrypted = Vec::with_capacity(ciphertext.len() + tag.len()); + encrypted.extend_from_slice(ciphertext); + encrypted.extend_from_slice(tag); + let plaintext = cipher + .decrypt(&aead_nonce, &[], 16, &encrypted) + .ok_or_else(|| anyhow!("Shadowsocks AEZ-384 open failed"))?; + ciphertext[..plaintext.len()].copy_from_slice(&plaintext); + increment_nonce(nonce); + return Ok(&ciphertext[..plaintext.len()]); + } + Self::DeoxysII256128(cipher) => cipher + .decrypt_inout_detached( + &DeoxysArray::try_from(&nonce[..]) + .map_err(|_| anyhow!("invalid Deoxys-II-256-128 nonce"))?, + &[], + ciphertext.into(), + &DeoxysArray::try_from(&tag[..]) + .map_err(|_| anyhow!("invalid Deoxys-II-256-128 tag"))?, + ) + .map_err(|_| anyhow!("Shadowsocks Deoxys-II-256-128 open failed"))?, + Self::Ascon128(key) => { + let nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid Ascon128 nonce"))?; + ascon_open_in_place(key, &nonce, AsconMode::Ascon128, ciphertext, tag)? + } + Self::Ascon128A(key) => { + let nonce: [u8; 16] = nonce + .try_into() + .map_err(|_| anyhow!("invalid Ascon128a nonce"))?; + ascon_open_in_place(key, &nonce, AsconMode::Ascon128A, ciphertext, tag)? + } + Self::Lea128Gcm(_) | Self::Lea192Gcm(_) | Self::Lea256Gcm(_) => { + let nonce: [u8; 12] = nonce + .try_into() + .map_err(|_| anyhow!("invalid LEA-GCM nonce"))?; + lea_gcm_open_in_place(self, &nonce, ciphertext, tag)? + } + }; + increment_nonce(nonce); + Ok(ciphertext) + } +} + +fn lea_gcm_seal_in_place( + cipher: &CustomSsAeadCipher, + nonce: &[u8; 12], + plaintext_in_ciphertext_out: &mut [u8], +) -> Vec { + lea_gcm_apply_ctr(cipher, nonce, plaintext_in_ciphertext_out); + lea_gcm_tag(cipher, nonce, plaintext_in_ciphertext_out) +} + +fn lea_gcm_open_in_place( + cipher: &CustomSsAeadCipher, + nonce: &[u8; 12], + ciphertext: &mut [u8], + tag: &[u8], +) -> Result<()> { + let expected = lea_gcm_tag(cipher, nonce, ciphertext); + if !bool::from(expected.as_slice().ct_eq(tag)) { + bail!("Shadowsocks LEA-GCM tag verification failed"); + } + lea_gcm_apply_ctr(cipher, nonce, ciphertext); + Ok(()) +} + +fn lea_gcm_apply_ctr(cipher: &CustomSsAeadCipher, nonce: &[u8; 12], buf: &mut [u8]) { + let mut counter = [0u8; 16]; + counter[..12].copy_from_slice(nonce); + counter[15] = 1; + lea_gcm_increment_counter(&mut counter); + + for chunk in buf.chunks_mut(16) { + let mut key_stream = counter; + lea_encrypt_block(cipher, &mut key_stream); + for (byte, key_stream_byte) in chunk.iter_mut().zip(key_stream) { + *byte ^= key_stream_byte; + } + lea_gcm_increment_counter(&mut counter); + } +} + +fn lea_gcm_tag(cipher: &CustomSsAeadCipher, nonce: &[u8; 12], ciphertext: &[u8]) -> Vec { + let mut h = [0u8; 16]; + lea_encrypt_block(cipher, &mut h); + + let mut tag_mask = [0u8; 16]; + tag_mask[..12].copy_from_slice(nonce); + tag_mask[15] = 1; + lea_encrypt_block(cipher, &mut tag_mask); + + let auth = ghash(&h, &[], ciphertext); + tag_mask + .iter() + .zip(auth) + .map(|(left, right)| left ^ right) + .collect() +} + +fn lea_encrypt_block(cipher: &CustomSsAeadCipher, block: &mut [u8; 16]) { + let block = LeaGenericArray::from_mut_slice(block); + match cipher { + CustomSsAeadCipher::Lea128Gcm(cipher) => cipher.encrypt_block(block), + CustomSsAeadCipher::Lea192Gcm(cipher) => cipher.encrypt_block(block), + CustomSsAeadCipher::Lea256Gcm(cipher) => cipher.encrypt_block(block), + _ => unreachable!("LEA-GCM block encryption called with non-LEA cipher"), + } +} + +fn lea_gcm_increment_counter(counter: &mut [u8; 16]) { + let value = + u32::from_be_bytes(counter[12..16].try_into().expect("fixed GCM counter")).wrapping_add(1); + counter[12..16].copy_from_slice(&value.to_be_bytes()); +} + +fn ghash(h: &[u8; 16], associated_data: &[u8], ciphertext: &[u8]) -> [u8; 16] { + let h = u128::from_be_bytes(*h); + let mut y = 0u128; + ghash_update(&mut y, h, associated_data); + ghash_update(&mut y, h, ciphertext); + let length_block = + ((associated_data.len() as u128) * 8) << 64 | ((ciphertext.len() as u128) * 8); + y = ghash_mul(y ^ length_block, h); + y.to_be_bytes() +} + +fn ghash_update(y: &mut u128, h: u128, mut input: &[u8]) { + while input.len() >= 16 { + *y = ghash_mul( + *y ^ u128::from_be_bytes(input[..16].try_into().expect("full GHASH block")), + h, + ); + input = &input[16..]; + } + if !input.is_empty() { + let mut block = [0u8; 16]; + block[..input.len()].copy_from_slice(input); + *y = ghash_mul(*y ^ u128::from_be_bytes(block), h); + } +} + +fn ghash_mul(mut x: u128, mut y: u128) -> u128 { + let reduction = 0xe1000000000000000000000000000000u128; + let mut z = 0u128; + for _ in 0..128 { + if x & (1u128 << 127) != 0 { + z ^= y; + } + let carry = y & 1 != 0; + y >>= 1; + if carry { + y ^= reduction; + } + x <<= 1; + } + z +} + +#[derive(Debug, Clone, Copy)] +enum AsconMode { + Ascon128, + Ascon128A, +} + +impl AsconMode { + fn block_size(self) -> usize { + match self { + Self::Ascon128 => 8, + Self::Ascon128A => 16, + } + } + + fn perm_b(self) -> usize { + match self { + Self::Ascon128 => 6, + Self::Ascon128A => 8, + } + } +} + +fn ascon_seal_in_place( + key: &[u8; 16], + nonce: &[u8; 16], + mode: AsconMode, + plaintext_in_ciphertext_out: &mut [u8], +) -> Vec { + let mut state = ascon_initialize(key, nonce, mode); + ascon_assoc_data(&mut state, mode, &[]); + ascon_proc_text(&mut state, mode, plaintext_in_ciphertext_out, true); + ascon_finalize(&mut state, mode, key) +} + +fn ascon_open_in_place( + key: &[u8; 16], + nonce: &[u8; 16], + mode: AsconMode, + ciphertext: &mut [u8], + tag: &[u8], +) -> Result<()> { + let mut state = ascon_initialize(key, nonce, mode); + ascon_assoc_data(&mut state, mode, &[]); + ascon_proc_text(&mut state, mode, ciphertext, false); + let expected = ascon_finalize(&mut state, mode, key); + if !bool::from(expected.as_slice().ct_eq(tag)) { + bail!("Shadowsocks Ascon tag verification failed"); + } + Ok(()) +} + +fn ascon_initialize(key: &[u8; 16], nonce: &[u8; 16], mode: AsconMode) -> [u64; 5] { + let k1 = u64::from_be_bytes(key[..8].try_into().expect("fixed key half")); + let k2 = u64::from_be_bytes(key[8..].try_into().expect("fixed key half")); + let block_bits = (mode.block_size() as u64) * 8; + let perm_b = mode.perm_b() as u64; + let n1 = u64::from_be_bytes(nonce[..8].try_into().expect("fixed nonce half")); + let n2 = u64::from_be_bytes(nonce[8..].try_into().expect("fixed nonce half")); + let mut state = [ + (128u64 << 56) | (block_bits << 48) | (12u64 << 40) | (perm_b << 32), + k1, + k2, + n1, + n2, + ]; + ascon_perm(12, &mut state); + state[3] ^= k1; + state[4] ^= k2; + state +} + +fn ascon_assoc_data(state: &mut [u64; 5], mode: AsconMode, mut associated_data: &[u8]) { + let block_size = mode.block_size(); + let perm_b = mode.perm_b(); + if !associated_data.is_empty() { + while associated_data.len() >= block_size { + for index in 0..(block_size / 8) { + let start = index * 8; + state[index] ^= u64::from_be_bytes( + associated_data[start..start + 8] + .try_into() + .expect("full Ascon AD block"), + ); + } + ascon_perm(perm_b, state); + associated_data = &associated_data[block_size..]; + } + for (index, byte) in associated_data.iter().enumerate() { + state[index / 8] ^= (*byte as u64) << (56 - 8 * (index % 8)); + } + state[associated_data.len() / 8] ^= 0x80u64 << (56 - 8 * (associated_data.len() % 8)); + ascon_perm(perm_b, state); + } + state[4] ^= 0x01; +} + +fn ascon_proc_text(state: &mut [u64; 5], mode: AsconMode, buf: &mut [u8], encrypt: bool) { + let block_size = mode.block_size(); + let perm_b = mode.perm_b(); + let mut offset = 0; + while buf.len() - offset >= block_size { + for index in 0..(block_size / 8) { + let start = offset + index * 8; + let input = u64::from_be_bytes(buf[start..start + 8].try_into().expect("full block")); + let output = state[index] ^ input; + buf[start..start + 8].copy_from_slice(&output.to_be_bytes()); + state[index] = if encrypt { output } else { input }; + } + ascon_perm(perm_b, state); + offset += block_size; + } + + let remaining = buf.len() - offset; + for index in 0..remaining { + let state_index = index / 8; + let shift = 56 - 8 * (index % 8); + let state_byte = ((state[state_index] >> shift) & 0xff) as u8; + let input = buf[offset + index]; + let output = state_byte ^ input; + buf[offset + index] = output; + let state_value = if encrypt { output } else { input }; + state[state_index] = + (state[state_index] & !(0xffu64 << shift)) | ((state_value as u64) << shift); + } + state[remaining / 8] ^= 0x80u64 << (56 - 8 * (remaining % 8)); +} + +fn ascon_finalize(state: &mut [u64; 5], mode: AsconMode, key: &[u8; 16]) -> Vec { + let k1 = u64::from_be_bytes(key[..8].try_into().expect("fixed key half")); + let k2 = u64::from_be_bytes(key[8..].try_into().expect("fixed key half")); + let block_words = mode.block_size() / 8; + state[block_words] ^= k1; + state[block_words + 1] ^= k2; + ascon_perm(12, state); + + let mut tag = Vec::with_capacity(16); + tag.extend_from_slice(&(state[3] ^ k1).to_be_bytes()); + tag.extend_from_slice(&(state[4] ^ k2).to_be_bytes()); + tag +} + +fn ascon_perm(rounds: usize, state: &mut [u64; 5]) { + let [mut x0, mut x1, mut x2, mut x3, mut x4] = *state; + for round in (12 - rounds)..12 { + x2 ^= (((0xf - round) << 4) | round) as u64; + + x0 ^= x4; + x4 ^= x3; + x2 ^= x1; + let t0 = x0 & !x4; + let mut t1 = x2 & !x1; + x0 ^= t1; + t1 = x4 & !x3; + x2 ^= t1; + t1 = x1 & !x0; + x4 ^= t1; + t1 = x3 & !x2; + x1 ^= t1; + x3 ^= t0; + x1 ^= x0; + x3 ^= x2; + x0 ^= x4; + x2 = !x2; + + x0 ^= x0.rotate_right(19) ^ x0.rotate_right(28); + x1 ^= x1.rotate_right(61) ^ x1.rotate_right(39); + x2 ^= x2.rotate_right(1) ^ x2.rotate_right(6); + x3 ^= x3.rotate_right(10) ^ x3.rotate_right(17); + x4 ^= x4.rotate_right(7) ^ x4.rotate_right(41); + } + *state = [x0, x1, x2, x3, x4]; +} + +fn rabbit_poly1305_seal_in_place( + key: &[u8; 16], + nonce: &[u8; 8], + plaintext_in_ciphertext_out: &mut [u8], +) -> Result> { + let mut poly_key = [0u8; 32]; + rabbit_apply_keystream(key, nonce, &mut poly_key)?; + rabbit_apply_keystream(key, nonce, plaintext_in_ciphertext_out)?; + Ok(rabbit_poly1305_tag(&poly_key, &[], plaintext_in_ciphertext_out).to_vec()) +} + +fn rabbit_poly1305_open_in_place( + key: &[u8; 16], + nonce: &[u8; 8], + ciphertext: &mut [u8], + tag: &[u8], +) -> Result<()> { + let mut poly_key = [0u8; 32]; + rabbit_apply_keystream(key, nonce, &mut poly_key)?; + let expected = rabbit_poly1305_tag(&poly_key, &[], ciphertext); + if !bool::from(expected.as_slice().ct_eq(tag)) { + bail!("Shadowsocks Rabbit128-Poly1305 tag verification failed"); + } + rabbit_apply_keystream(key, nonce, ciphertext)?; + Ok(()) +} + +fn rabbit_apply_keystream(key: &[u8; 16], nonce: &[u8; 8], buf: &mut [u8]) -> Result<()> { + let mut cipher = Rabbit::new_from_slices(key, nonce) + .map_err(|_| anyhow!("invalid Rabbit128-Poly1305 key or nonce"))?; + cipher.apply_keystream(buf); + Ok(()) +} + +fn rabbit_poly1305_tag(poly_key: &[u8; 32], associated_data: &[u8], ciphertext: &[u8]) -> [u8; 16] { + let mut mac = Poly1305::new(GenericArray::from_slice(poly_key)); + mac.update_padded(associated_data); + mac.update_padded(ciphertext); + let mut lengths = [0u8; 16]; + lengths[..8].copy_from_slice(&(associated_data.len() as u64).to_le_bytes()); + lengths[8..].copy_from_slice(&(ciphertext.len() as u64).to_le_bytes()); + mac.update_padded(&lengths); + mac.finalize().into() +} + +fn encrypt_custom_ss_udp_packet( + kind: CustomSsAeadKind, + master_key: &[u8], + target: SocketAddr, + payload: &[u8], +) -> Result> { + let mut salt = vec![0u8; kind.salt_len()]; + OsRng.fill_bytes(&mut salt); + let cipher = custom_ss_subkey(kind, master_key, &salt)?; + let mut nonce = vec![0u8; kind.nonce_len()]; + let mut plaintext = socks_addr(target)?; + plaintext.extend_from_slice(payload); + cipher.seal_in_place(&mut nonce, &mut plaintext)?; + let mut packet = salt; + packet.extend_from_slice(&plaintext); + Ok(packet) +} + +fn decrypt_custom_ss_udp_packet( + kind: CustomSsAeadKind, + master_key: &[u8], + packet: &[u8], +) -> Result<(SocketAddr, Vec)> { + let salt_len = kind.salt_len(); + if packet.len() <= salt_len { + bail!("Shadowsocks UDP packet missing salt"); + } + let (salt, encrypted) = packet.split_at(salt_len); + let cipher = custom_ss_subkey(kind, master_key, salt)?; + let mut nonce = vec![0u8; kind.nonce_len()]; + let mut buf = encrypted.to_vec(); + let plaintext = cipher.open_in_place(&mut nonce, &mut buf)?; + let (source, used) = parse_socks_addr(plaintext)?; + Ok((source, plaintext[used..].to_vec())) +} + +fn encrypt_custom_ss_stream_udp_packet( + kind: CustomSsStreamKind, + key: &[u8], + target: SocketAddr, + payload: &[u8], +) -> Result> { + let mut salt = vec![0u8; kind.salt_len()]; + OsRng.fill_bytes(&mut salt); + let mut cipher = CustomSsStreamCipher::new(kind, key, &salt)?; + let mut plaintext = socks_addr(target)?; + plaintext.extend_from_slice(payload); + cipher.apply_keystream(&mut plaintext); + let mut packet = salt; + packet.extend_from_slice(&plaintext); + Ok(packet) +} + +fn decrypt_custom_ss_stream_udp_packet( + kind: CustomSsStreamKind, + key: &[u8], + packet: &[u8], +) -> Result<(SocketAddr, Vec)> { + let salt_len = kind.salt_len(); + if packet.len() <= salt_len { + bail!("Shadowsocks UDP packet missing salt"); + } + let (salt, encrypted) = packet.split_at(salt_len); + let mut cipher = CustomSsStreamCipher::new(kind, key, salt)?; + let mut plaintext = encrypted.to_vec(); + cipher.apply_keystream(&mut plaintext); + let (source, used) = parse_socks_addr(&plaintext)?; + Ok((source, plaintext[used..].to_vec())) +} + +fn parse_aead2022_keys( + kind: Aead2022AesCcmKind, + password: &str, +) -> Result<(Arc<[u8]>, Arc<[Arc<[u8]>]>)> { + if password.is_empty() { + bail!("Shadowsocks 2022 password is missing"); + } + let mut keys = Vec::new(); + for segment in password.split(':') { + let decoded = BASE64_STANDARD + .decode(segment) + .context("failed to decode Shadowsocks 2022 base64 key")?; + if decoded.len() != kind.key_len() { + bail!( + "Shadowsocks 2022 key length mismatch: required {}, got {}", + kind.key_len(), + decoded.len() + ); + } + keys.push(Arc::<[u8]>::from(decoded)); + } + let user_key = keys + .pop() + .ok_or_else(|| anyhow!("Shadowsocks 2022 password is missing"))?; + Ok((user_key, Arc::from(keys))) +} + +fn aead2022_session_key(kind: Aead2022AesCcmKind, user_key: &[u8], salt: &[u8]) -> Result> { + if user_key.len() != kind.key_len() || salt.is_empty() { + bail!("invalid Shadowsocks 2022 session key material"); + } + let mut material = Vec::with_capacity(user_key.len() + salt.len()); + material.extend_from_slice(user_key); + material.extend_from_slice(salt); + let derived = blake3::derive_key(AEAD2022_SESSION_SUBKEY_CONTEXT, &material); + Ok(derived[..kind.key_len()].to_vec()) +} + +fn aead2022_identity_subkey( + kind: Aead2022AesCcmKind, + identity_key: &[u8], + salt: &[u8], +) -> Result> { + if identity_key.len() != kind.key_len() || salt.len() != kind.key_len() { + bail!("invalid Shadowsocks 2022 identity key material"); + } + let mut material = Vec::with_capacity(identity_key.len() + salt.len()); + material.extend_from_slice(identity_key); + material.extend_from_slice(salt); + let derived = blake3::derive_key(AEAD2022_IDENTITY_SUBKEY_CONTEXT, &material); + Ok(derived[..kind.key_len()].to_vec()) +} + +fn aead2022_eih_blocks( + kind: Aead2022AesCcmKind, + identity_keys: &[Arc<[u8]>], + user_key: &[u8], + salt: &[u8], +) -> Result> { + let mut blocks = Vec::with_capacity(identity_keys.len() * 16); + for (index, identity_key) in identity_keys.iter().enumerate() { + let next_key = identity_keys + .get(index + 1) + .map(AsRef::as_ref) + .unwrap_or(user_key); + let mut block = aead2022_psk_hash_block(next_key); + let subkey = aead2022_identity_subkey(kind, identity_key, salt)?; + aead2022_aes_encrypt_block(kind, &subkey, &mut block)?; + blocks.extend_from_slice(&block); + } + Ok(blocks) +} + +fn aead2022_udp_eih_blocks( + kind: Aead2022AesCcmKind, + identity_keys: &[Arc<[u8]>], + user_key: &[u8], + packet_header: &[u8], +) -> Result> { + let mut blocks = Vec::with_capacity(identity_keys.len() * 16); + for (index, identity_key) in identity_keys.iter().enumerate() { + let next_key = identity_keys + .get(index + 1) + .map(AsRef::as_ref) + .unwrap_or(user_key); + let mut block = aead2022_psk_hash_block(next_key); + for (left, right) in block.iter_mut().zip(packet_header.iter().copied()) { + *left ^= right; + } + aead2022_aes_encrypt_block(kind, identity_key, &mut block)?; + blocks.extend_from_slice(&block); + } + Ok(blocks) +} + +fn aead2022_psk_hash_block(key: &[u8]) -> [u8; 16] { + let hash = blake3::hash(key); + hash.as_bytes()[..16] + .try_into() + .expect("fixed BLAKE3 block") +} + +fn aead2022_udp_header_key<'a>(identity_keys: &'a [Arc<[u8]>], user_key: &'a [u8]) -> &'a [u8] { + identity_keys.first().map(AsRef::as_ref).unwrap_or(user_key) +} + +fn aead2022_aes_encrypt_block( + kind: Aead2022AesCcmKind, + key: &[u8], + block: &mut [u8], +) -> Result<()> { + if block.len() != 16 { + bail!("Shadowsocks 2022 AES block must be 16 bytes"); + } + match kind { + Aead2022AesCcmKind::Aes128 => { + let cipher = Aes128::new_from_slice(key) + .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-128 block key"))?; + cipher.encrypt_block(AesGenericArray::from_mut_slice(block)); + } + Aead2022AesCcmKind::Aes256 => { + let cipher = Aes256::new_from_slice(key) + .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-256 block key"))?; + cipher.encrypt_block(AesGenericArray::from_mut_slice(block)); + } + } + Ok(()) +} + +fn aead2022_aes_decrypt_block( + kind: Aead2022AesCcmKind, + key: &[u8], + block: &mut [u8], +) -> Result<()> { + if block.len() != 16 { + bail!("Shadowsocks 2022 AES block must be 16 bytes"); + } + match kind { + Aead2022AesCcmKind::Aes128 => { + let cipher = Aes128::new_from_slice(key) + .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-128 block key"))?; + cipher.decrypt_block(AesGenericArray::from_mut_slice(block)); + } + Aead2022AesCcmKind::Aes256 => { + let cipher = Aes256::new_from_slice(key) + .map_err(|_| anyhow!("invalid Shadowsocks 2022 AES-256 block key"))?; + cipher.decrypt_block(AesGenericArray::from_mut_slice(block)); + } + } + Ok(()) +} + +fn aead2022_now_timestamp() -> Result { + Ok(SystemTime::now() + .duration_since(UNIX_EPOCH) + .context("system clock is before unix epoch")? + .as_secs()) +} + +fn aead2022_validate_timestamp(timestamp: u64) -> Result<()> { + let now = aead2022_now_timestamp()?; + if now.abs_diff(timestamp) > AEAD2022_TIMESTAMP_MAX_DIFF { + bail!("Shadowsocks 2022 timestamp is outside the allowed skew"); + } + Ok(()) +} + +fn aead2022_udp_padding_len(destination: SocketAddr, payload: &[u8]) -> usize { + if destination.port() == 53 && payload.len() < 900 { + (OsRng.next_u32() as usize % (900 - payload.len())) + 1 + } else { + 0 + } +} + +fn custom_ss_subkey( + kind: CustomSsAeadKind, + master_key: &[u8], + salt: &[u8], +) -> Result { + let prk = hmac::sign( + &hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, salt), + master_key, + ); + let prk = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, prk.as_ref()); + let mut okm = Vec::with_capacity(kind.key_len()); + let mut previous = Vec::new(); + let mut counter = 1u8; + while okm.len() < kind.key_len() { + let mut input = Vec::with_capacity(previous.len() + SS_INFO.len() + 1); + input.extend_from_slice(&previous); + input.extend_from_slice(SS_INFO); + input.push(counter); + previous = hmac::sign(&prk, &input).as_ref().to_vec(); + okm.extend_from_slice(&previous); + counter = counter + .checked_add(1) + .ok_or_else(|| anyhow!("Shadowsocks HKDF output too long"))?; + } + okm.truncate(kind.key_len()); + CustomSsAeadCipher::new(kind, &okm) +} + +fn evp_bytes_to_key(password: &[u8], key_len: usize) -> Vec { + let mut out = Vec::with_capacity(key_len); + let mut previous = Vec::new(); + while out.len() < key_len { + let mut hasher = Md5::new(); + if !previous.is_empty() { + hasher.update(&previous); + } + hasher.update(password); + previous = hasher.finalize().to_vec(); + out.extend_from_slice(&previous); + } + out.truncate(key_len); + out +} + +fn increment_nonce(nonce: &mut [u8]) { + for byte in nonce { + let (next, overflow) = byte.overflowing_add(1); + *byte = next; + if !overflow { + break; + } + } +} diff --git a/burrow/src/proxy_runtime/shadowsocks_plugins.rs b/burrow/src/proxy_runtime/shadowsocks_plugins.rs new file mode 100644 index 0000000..7f765d9 --- /dev/null +++ b/burrow/src/proxy_runtime/shadowsocks_plugins.rs @@ -0,0 +1,2735 @@ +async fn websocket_plugin_connect( + mut stream: Box, + config: &ShadowsocksWebSocketTransport, + port: u16, +) -> Result> { + if config.tls { + stream = websocket_plugin_tls_connect( + stream, + config.websocket_host_header(), + config.skip_cert_verify, + config.certificate_fingerprint.as_ref(), + config.client_identity.as_ref(), + config.ech.as_ref(), + ) + .await?; + } + let (path, max_early_data) = websocket_path_and_early_data(&config.path); + tracing::debug!( + kind = ?config.kind, + host = %config.host, + port, + path = %path, + tls = config.tls, + mux = ?config.mux, + v2ray_http_upgrade = config.v2ray_http_upgrade, + v2ray_http_upgrade_fast_open = config.v2ray_http_upgrade_fast_open, + "starting Shadowsocks websocket plugin handshake" + ); + if let Some(max_early_data) = max_early_data { + return Ok(Box::new(WebSocketEarlyDataStream::new( + stream, + config.clone(), + port, + path, + max_early_data, + ))); + } + let (request, websocket_key) = websocket_plugin_request(config, port, &path, None)?; + stream.write_all(&request).await?; + stream.flush().await?; + if config.v2ray_http_upgrade && config.v2ray_http_upgrade_fast_open { + return Ok(Box::new(HttpUpgradeFastOpenStream::new(stream))); + } + let response = read_http_upgrade_response(&mut stream).await?; + validate_websocket_upgrade_response( + &response, + config.v2ray_http_upgrade, + websocket_key.as_deref(), + )?; + tracing::debug!( + kind = ?config.kind, + host = %config.host, + port, + path = %path, + "completed Shadowsocks websocket plugin handshake" + ); + + if config.v2ray_http_upgrade { + Ok(stream) + } else { + Ok(Box::new(WebSocketStream::new(stream))) + } +} + +async fn gost_smux_stream(stream: Box) -> Result> { + let mut smux_config = tokio_smux::SmuxConfig::default(); + smux_config.keep_alive_disable = true; + let mut session = tokio_smux::Session::client(stream, smux_config) + .map_err(|err| anyhow!("failed to start gost-plugin smux session: {err}"))?; + let mut smux_stream = session + .open_stream() + .await + .map_err(|err| anyhow!("failed to open gost-plugin smux stream: {err}"))?; + let (client, bridge) = tokio::io::duplex(64 * 1024); + let (mut bridge_read, mut bridge_write) = split(bridge); + let (upload_tx, mut upload_rx) = mpsc::channel::>(32); + + let upload = tokio::spawn(async move { + let mut buf = vec![0u8; 16 * 1024]; + loop { + let read = bridge_read.read(&mut buf).await?; + if read == 0 { + break; + } + if upload_tx.send(buf[..read].to_vec()).await.is_err() { + break; + } + } + Result::<()>::Ok(()) + }); + + tokio::spawn(async move { + let _session = session; + let result: Result<()> = async { + loop { + tokio::select! { + outbound = upload_rx.recv() => { + let Some(outbound) = outbound else { + break; + }; + smux_stream + .send_message(outbound) + .await + .map_err(|err| anyhow!("failed to write gost-plugin smux stream: {err}"))?; + } + inbound = smux_stream.recv_message() => { + let Some(inbound) = inbound + .map_err(|err| anyhow!("failed to read gost-plugin smux stream: {err}"))? + else { + break; + }; + bridge_write.write_all(&inbound).await?; + bridge_write.flush().await?; + } + } + } + Ok(()) + } + .await; + if let Err(err) = result { + tracing::debug!(error = %err, "gost-plugin smux bridge stopped"); + } + upload.abort(); + }); + + Ok(Box::new(client)) +} + +async fn kcptun_open_session( + endpoint: &ProxyServerEndpoint, + address: SocketAddr, + config: &ShadowsocksKcptunTransport, +) -> Result> { + let kcp_config = KcpConfig { + mtu: config.mtu, + nodelay: KcpNoDelayConfig { + nodelay: config.no_delay != 0, + interval: config.interval, + resend: config.resend, + nc: config.no_congestion, + }, + wnd_size: (config.sndwnd, config.rcvwnd), + flush_write: false, + flush_acks_input: config.ack_nodelay, + stream: true, + fec_data_shards: config.data_shard, + fec_parity_shards: config.parity_shard, + crypt: kcptun_block_crypt(&config.key, &config.crypt)?, + ..Default::default() + }; + let udp = match address.ip() { + IpAddr::V4(_) => UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?, + IpAddr::V6(_) => UdpSocket::bind((Ipv6Addr::UNSPECIFIED, 0)).await?, + }; + bind_proxy_outbound_socket(udp.as_raw_fd(), address.ip())?; + configure_kcptun_udp_socket(udp.as_raw_fd(), address.ip(), config); + let kcp = KcpStream::connect_with_socket(&kcp_config, udp, address) + .await + .with_context(|| { + format!( + "failed to connect Shadowsocks kcptun transport {}:{}", + endpoint.host, endpoint.port + ) + })?; + let keep_alive = if config.keep_alive == 0 { + 10 + } else { + config.keep_alive + }; + let keep_alive_interval = Duration::from_secs(keep_alive); + let smux_config = smux_rust::Config { + version: config.smux_ver, + keep_alive_disabled: false, + keep_alive_interval, + keep_alive_timeout: kcptun_smux_keep_alive_timeout(keep_alive_interval), + max_frame_size: config.frame_size, + max_receive_buffer: config.smux_buf, + max_stream_buffer: config.stream_buf, + }; + let session = match (config.no_comp, config.rate_limit) { + (true, 0) => kcptun_open_smux_session(kcp, smux_config).await?, + (true, rate_limit) => { + kcptun_open_smux_session(RateLimitedStream::new(kcp, rate_limit), smux_config).await? + } + (false, 0) => kcptun_open_smux_session(SnappyIO::new(kcp), smux_config).await?, + (false, rate_limit) => { + kcptun_open_smux_session( + SnappyIO::new(RateLimitedStream::new(kcp, rate_limit)), + smux_config, + ) + .await? + } + }; + tracing::debug!( + server = %endpoint.host, + port = endpoint.port, + crypt = %config.crypt, + mode = %config.mode, + rate_limit = config.rate_limit, + no_comp = config.no_comp, + smux_ver = config.smux_ver, + "opened Shadowsocks kcptun plugin session" + ); + Ok(session) +} + +async fn kcptun_open_smux_session( + transport: T, + smux_config: smux_rust::Config, +) -> Result> +where + T: AsyncRead + AsyncWrite + Unpin + Send + 'static, +{ + smux_config + .verify() + .map_err(|err| anyhow!("invalid Shadowsocks kcptun smux config: {err}"))?; + let session = smux_rust::client(Box::new(transport), Some(smux_config)) + .await + .map_err(|err| anyhow!("failed to start Shadowsocks kcptun smux session: {err}"))?; + Ok(session) +} + +async fn kcptun_open_smux_stream(session: Arc) -> Result> { + let stream = session + .open_stream() + .await + .map_err(|err| anyhow!("failed to open Shadowsocks kcptun smux stream: {err}"))?; + Ok(Box::new(stream)) +} + +fn kcptun_smux_keep_alive_timeout(keep_alive_interval: Duration) -> Duration { + if keep_alive_interval >= KCPTUN_SMUX_DEFAULT_KEEP_ALIVE_TIMEOUT { + keep_alive_interval.saturating_mul(3) + } else { + KCPTUN_SMUX_DEFAULT_KEEP_ALIVE_TIMEOUT + } +} + +fn schedule_kcptun_session_close(session: Arc, scavenge_ttl: u64) { + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(scavenge_ttl)).await; + if !session.is_closed() { + let _ = session.close().await; + } + }); +} + +fn configure_kcptun_udp_socket( + fd: std::os::fd::RawFd, + ip: IpAddr, + config: &ShadowsocksKcptunTransport, +) { + if config.dscp > 0 { + if let Err(err) = set_kcptun_dscp(fd, ip, config.dscp) { + tracing::debug!(dscp = config.dscp, error = %err, "failed to set Shadowsocks kcptun DSCP"); + } + } + if config.sockbuf > 0 { + let sockbuf = kcptun_socket_int_value(config.sockbuf as u64); + if let Err(err) = set_socket_int_option(fd, libc::SOL_SOCKET, libc::SO_RCVBUF, sockbuf) { + tracing::debug!(sockbuf = config.sockbuf, error = %err, "failed to set Shadowsocks kcptun receive buffer"); + } + if let Err(err) = set_socket_int_option(fd, libc::SOL_SOCKET, libc::SO_SNDBUF, sockbuf) { + tracing::debug!(sockbuf = config.sockbuf, error = %err, "failed to set Shadowsocks kcptun send buffer"); + } + } +} + +fn set_kcptun_dscp(fd: std::os::fd::RawFd, ip: IpAddr, dscp: u32) -> io::Result<()> { + match ip { + IpAddr::V4(_) => set_socket_int_option( + fd, + libc::IPPROTO_IP, + libc::IP_TOS, + kcptun_ipv4_dscp_tos(dscp), + ), + IpAddr::V6(_) => set_socket_int_option( + fd, + libc::IPPROTO_IPV6, + libc::IPV6_TCLASS, + kcptun_socket_int_value(dscp as u64), + ), + } +} + +fn kcptun_ipv4_dscp_tos(dscp: u32) -> libc::c_int { + dscp.saturating_mul(4).min(u8::MAX as u32) as libc::c_int +} + +fn kcptun_socket_int_value(value: u64) -> libc::c_int { + value.min(libc::c_int::MAX as u64) as libc::c_int +} + +fn set_socket_int_option( + fd: std::os::fd::RawFd, + level: libc::c_int, + option: libc::c_int, + value: libc::c_int, +) -> io::Result<()> { + let result = unsafe { + libc::setsockopt( + fd, + level, + option, + (&value as *const libc::c_int).cast::(), + std::mem::size_of_val(&value) as libc::socklen_t, + ) + }; + if result != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn kcptun_block_crypt(key: &str, crypt: &str) -> Result>> { + let mut derived = [0u8; 32]; + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA1, + NonZeroU32::new(4096).expect("non-zero PBKDF2 iterations"), + b"kcp-go", + key.as_bytes(), + &mut derived, + ); + let crypt = crypt.trim().to_ascii_lowercase(); + let block: Arc = match crypt.as_str() { + "null" => return Ok(None), + "none" => KcpNoneBlockCrypt::new(&derived) + .map_err(|err| anyhow!("invalid kcptun none crypt key: {err}"))?, + "tea" => Arc::new( + KcpTeaBlockCrypt::new(&derived[..16]) + .map_err(|err| anyhow!("invalid kcptun tea crypt key: {err}"))?, + ), + "xor" | "simple_xor" => Arc::new( + KcpSimpleXorBlockCrypt::new(&derived) + .map_err(|err| anyhow!("invalid kcptun xor crypt key: {err}"))?, + ), + "aes" | "aes-256" => Arc::new( + KcpAes256BlockCrypt::new(&derived) + .map_err(|err| anyhow!("invalid kcptun aes crypt key: {err}"))?, + ), + "aes-128" => Arc::new( + KcpAes128BlockCrypt::new(&derived[..16]) + .map_err(|err| anyhow!("invalid kcptun aes-128 crypt key: {err}"))?, + ), + "aes-192" => Arc::new( + KcpAes192BlockCrypt::new(&derived[..24]) + .map_err(|err| anyhow!("invalid kcptun aes-192 crypt key: {err}"))?, + ), + "blowfish" => Arc::new( + KcpBlowfishBlockCrypt::new(&derived) + .map_err(|err| anyhow!("invalid kcptun blowfish crypt key: {err}"))?, + ), + "cast5" => Arc::new( + KcpCast5BlockCrypt::new(&derived[..16]) + .map_err(|err| anyhow!("invalid kcptun cast5 crypt key: {err}"))?, + ), + "3des" | "triple_des" => Arc::new( + KcpTripleDesBlockCrypt::new(&derived[..24]) + .map_err(|err| anyhow!("invalid kcptun 3des crypt key: {err}"))?, + ), + "twofish" => Arc::new( + KcpTwofishBlockCrypt::new(&derived) + .map_err(|err| anyhow!("invalid kcptun twofish crypt key: {err}"))?, + ), + "xtea" => Arc::new( + KcpXteaBlockCrypt::new(&derived[..16]) + .map_err(|err| anyhow!("invalid kcptun xtea crypt key: {err}"))?, + ), + "salsa20" => Arc::new( + KcpSalsa20BlockCrypt::new(&derived) + .map_err(|err| anyhow!("invalid kcptun salsa20 crypt key: {err}"))?, + ), + "sm4" => Arc::new( + KcpSm4BlockCrypt::new(&derived[..16]) + .map_err(|err| anyhow!("invalid kcptun sm4 crypt key: {err}"))?, + ), + "aes-128-gcm" => Arc::new( + KcpAesGcmBlockCrypt::new(&derived[..16]) + .map_err(|err| anyhow!("invalid kcptun aes-128-gcm crypt key: {err}"))?, + ), + _ => Arc::new( + KcpAes256BlockCrypt::new(&derived) + .map_err(|err| anyhow!("invalid kcptun fallback aes crypt key: {err}"))?, + ), + }; + Ok(Some(block)) +} + +async fn websocket_plugin_tls_connect( + stream: Box, + server_name: &str, + skip_cert_verify: bool, + certificate_fingerprint: Option<&CertificateFingerprint>, + client_identity: Option<&TlsClientIdentity>, + ech: Option<&ShadowsocksEchConfig>, +) -> Result> { + #[cfg(target_vendor = "apple")] + { + if ech.is_none() { + return websocket_plugin_native_tls_connect( + stream, + server_name, + skip_cert_verify, + certificate_fingerprint, + client_identity, + ) + .await; + } + } + websocket_plugin_rustls_tls_connect( + stream, + server_name, + skip_cert_verify, + certificate_fingerprint, + client_identity, + ech, + ) + .await +} + +async fn websocket_plugin_rustls_tls_connect( + stream: Box, + server_name: &str, + skip_cert_verify: bool, + certificate_fingerprint: Option<&CertificateFingerprint>, + client_identity: Option<&TlsClientIdentity>, + ech: Option<&ShadowsocksEchConfig>, +) -> Result> { + let ech_config_list = resolve_shadowsocks_ech_config_list(server_name, ech)?; + let config = rustls_tls_config( + &["http/1.1".to_owned()], + skip_cert_verify, + certificate_fingerprint, + client_identity, + ech_config_list.as_deref(), + )?; + let connector = TlsConnector::from(Arc::new(config)); + let name = ServerName::try_from(server_name.to_owned()) + .with_context(|| format!("invalid websocket plugin SNI {server_name}"))?; + Ok(Box::new(connector.connect(name, stream).await?)) +} + +fn resolve_shadowsocks_ech_config_list( + server_name: &str, + ech: Option<&ShadowsocksEchConfig>, +) -> Result>> { + let Some(ech) = ech else { + return Ok(None); + }; + if let Some(config_list) = ech.config_list.as_ref() { + return Ok(Some(config_list.clone())); + } + + let query_name = ech.query_server_name.as_deref().unwrap_or(server_name); + let mut last_error = None; + for dns_server in platform_proxy_dns_servers() { + let Ok(ip) = dns_server.parse::() else { + continue; + }; + let dns_addr = SocketAddr::new(ip, 53); + match direct_dns_https_ech_config_list(query_name, dns_addr) { + Ok(Some(config_list)) => return Ok(Some(config_list)), + Ok(None) => { + last_error = Some(anyhow!( + "DNS HTTPS response for {query_name} did not include an ECH config" + )); + } + Err(err) => last_error = Some(err), + } + } + Err(last_error.unwrap_or_else(|| { + anyhow!("failed to resolve ECH config for Shadowsocks websocket host {query_name}") + })) +} + +#[cfg(target_vendor = "apple")] +async fn websocket_plugin_native_tls_connect( + stream: Box, + server_name: &str, + skip_cert_verify: bool, + certificate_fingerprint: Option<&CertificateFingerprint>, + client_identity: Option<&TlsClientIdentity>, +) -> Result> { + let mut builder = NativeTlsConnector::builder(); + if skip_cert_verify || certificate_fingerprint.is_some() { + builder.danger_accept_invalid_certs(true); + } + if let Some(identity) = client_identity { + builder.identity(load_native_tls_identity(identity)?); + } + builder.request_alpns(&["http/1.1"]); + let connector = TokioNativeTlsConnector::from( + builder + .build() + .context("failed to build native TLS connector for Shadowsocks websocket plugin")?, + ); + let tls = connector.connect(server_name, stream).await?; + if let Some(fingerprint) = certificate_fingerprint { + verify_native_tls_peer_certificate(&tls, fingerprint, "Shadowsocks websocket plugin")?; + } + Ok(Box::new(tls)) +} + +async fn shadow_tls_connect( + stream: Box, + config: &ShadowsocksShadowTlsTransport, +) -> Result> { + match config.version { + 1 => { + let tls_config = shadow_tls_client_config(config)?; + let connector = TlsConnector::from(Arc::new(tls_config)); + let name = ServerName::try_from(config.host.clone()) + .with_context(|| format!("invalid ShadowTLS host {}", config.host))?; + let tls = connector + .connect(name, stream) + .await + .with_context(|| format!("ShadowTLS v1 handshake failed for {}", config.host))?; + let (stream, _) = tls.into_inner(); + Ok(stream) + } + 2 => { + if config.client_fingerprint.is_some() { + #[cfg(feature = "boring-browser-fingerprints")] + return shadow_tls_v2_boring_connect(stream, config).await; + #[cfg(not(feature = "boring-browser-fingerprints"))] + bail!( + "unsupported ShadowTLS v2 client-fingerprint: browser TLS profiles require the boring-browser-fingerprints feature" + ); + } + let tls_config = shadow_tls_client_config(config)?; + let connector = TlsConnector::from(Arc::new(tls_config)); + let name = ServerName::try_from(config.host.clone()) + .with_context(|| format!("invalid ShadowTLS host {}", config.host))?; + let hashed = HmacReadStream::new(stream, &config.password); + let tls = connector + .connect(name, hashed) + .await + .with_context(|| format!("ShadowTLS v2 handshake failed for {}", config.host))?; + let (hashed, _) = tls.into_inner(); + let (stream, prefix) = hashed.finish(); + Ok(Box::new(ShadowTlsV2Stream::new(stream, prefix))) + } + version => bail!("unsupported ShadowTLS version {version}"), + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +async fn shadow_tls_v2_boring_connect( + stream: Box, + config: &ShadowsocksShadowTlsTransport, +) -> Result> { + let fingerprint = config + .client_fingerprint + .ok_or_else(|| anyhow!("missing ShadowTLS v2 client fingerprint"))?; + let profile = shadow_tls_v2_boring_profile(fingerprint)?; + let domain = RamaDomain::try_from(config.host.clone()) + .with_context(|| format!("invalid ShadowTLS host {}", config.host))?; + let mut builder = BoringTlsConnectorDataBuilder::try_from(profile.client_config.as_ref()) + .with_context(|| { + format!( + "failed to build browser TLS profile for client-fingerprint {}", + fingerprint.mihomo_name() + ) + })?; + builder = builder + .with_server_name(domain) + .with_alpn_protos(Bytes::from(alpn_wire_protocols(&config.alpn)?)); + if config.skip_cert_verify || config.certificate_fingerprint.is_some() { + builder = builder.with_server_verify_mode(RamaServerVerifyMode::Disable); + } + if let Some(identity) = &config.client_identity { + builder = builder.with_client_auth(load_boring_client_identity(identity)?); + } + let data = builder.build().with_context(|| { + format!( + "failed to configure ShadowTLS v2 browser TLS profile for {}", + config.host + ) + })?; + let hashed = DetachableStream::new(HmacReadStream::new(stream, &config.password)); + let mut tls = rama_tls_boring::core::tokio::connect(data.config, config.host.as_str(), hashed) + .await + .map_err(|err| { + anyhow!( + "ShadowTLS v2 browser-profile handshake failed for {} using client-fingerprint {}: {err}", + config.host, + fingerprint.mihomo_name() + ) + })?; + if let Some(certificate_fingerprint) = config.certificate_fingerprint.as_ref() { + verify_boring_tls_peer_certificate( + &tls, + certificate_fingerprint, + "ShadowTLS v2 browser-profile", + )?; + } + let hashed = tls + .get_mut() + .take() + .context("failed to recover ShadowTLS v2 browser-profile transport")?; + let (stream, prefix) = hashed.finish(); + Ok(Box::new(ShadowTlsV2Stream::new(stream, prefix))) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn shadow_tls_v2_boring_profile(fingerprint: MihomoClientFingerprint) -> Result { + let profile = mihomo_browser_tls_profile(fingerprint)?; + Ok(strip_shadow_tls_v2_unsupported_groups(profile)) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn mihomo_browser_tls_profile(fingerprint: MihomoClientFingerprint) -> Result { + Ok(mihomo_browser_user_agent_profile(fingerprint)?.tls.clone()) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn mihomo_browser_user_agent_profile( + fingerprint: MihomoClientFingerprint, +) -> Result<&'static rama_ua::profile::UserAgentProfile> { + let selected = match fingerprint { + MihomoClientFingerprint::Random => mihomo_initial_random_fingerprint(), + other => other, + }; + let (kind, platform, edge, required_ua_marker) = match selected { + MihomoClientFingerprint::Chrome => ( + UserAgentKind::Chromium, + Some(PlatformKind::MacOS), + false, + None, + ), + MihomoClientFingerprint::Firefox => ( + UserAgentKind::Firefox, + Some(PlatformKind::MacOS), + false, + None, + ), + MihomoClientFingerprint::Safari => ( + UserAgentKind::Safari, + Some(PlatformKind::MacOS), + false, + None, + ), + MihomoClientFingerprint::Safari16 => ( + UserAgentKind::Safari, + Some(PlatformKind::MacOS), + false, + Some("Version/16."), + ), + MihomoClientFingerprint::Ios => { + (UserAgentKind::Safari, Some(PlatformKind::IOS), false, None) + } + MihomoClientFingerprint::Android => ( + UserAgentKind::Chromium, + Some(PlatformKind::Android), + false, + None, + ), + MihomoClientFingerprint::Edge => ( + UserAgentKind::Chromium, + Some(PlatformKind::Windows), + true, + None, + ), + other => bail!( + "unsupported client-fingerprint {}: no matching browser TLS profile is available yet", + other.mihomo_name() + ), + }; + embedded_user_agent_profiles()? + .iter() + .filter(|profile| { + profile.ua_kind == kind + && profile.platform == platform + && required_ua_marker + .map(|marker| { + profile + .ua_str() + .map(|ua| ua.contains(marker)) + .unwrap_or(false) + }) + .unwrap_or(true) + && profile + .ua_str() + .map(|ua| ua.contains(" Edg/") == edge) + .unwrap_or(!edge) + }) + .max_by_key(|profile| profile.ua_version.unwrap_or(0)) + .ok_or_else(|| { + anyhow!( + "no embedded browser TLS profile matched client-fingerprint {}", + fingerprint.mihomo_name() + ) + }) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn strip_shadow_tls_v2_unsupported_groups(mut profile: TlsProfile) -> TlsProfile { + let mut config = profile.client_config.as_ref().clone(); + strip_x25519_mlkem768_from_client_config(&mut config); + profile.client_config = Arc::new(config); + profile +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn strip_x25519_mlkem768_from_client_config(config: &mut rama_net::tls::client::ClientConfig) { + if let Some(extensions) = config.extensions.as_mut() { + for extension in extensions { + if let RamaClientHelloExtension::SupportedGroups(groups) = extension { + groups.retain(|group| *group != RamaSupportedGroup::X25519MLKEM768); + } + } + } +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn mihomo_initial_random_fingerprint() -> MihomoClientFingerprint { + use std::sync::OnceLock; + + static FINGERPRINT: OnceLock = OnceLock::new(); + *FINGERPRINT.get_or_init(|| match rand::thread_rng().gen_range(0..12) { + 0..=5 => MihomoClientFingerprint::Chrome, + 6..=8 => MihomoClientFingerprint::Safari, + 9..=10 => MihomoClientFingerprint::Ios, + _ => MihomoClientFingerprint::Firefox, + }) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn embedded_user_agent_profiles() -> Result<&'static [rama_ua::profile::UserAgentProfile]> { + use std::sync::OnceLock; + + static PROFILES: OnceLock, String>> = + OnceLock::new(); + PROFILES + .get_or_init(|| { + try_load_embedded_profiles() + .map(|profiles| profiles.collect()) + .map_err(|err| err.to_string()) + }) + .as_deref() + .map_err(|err| anyhow!("failed to load embedded browser TLS profiles: {err}")) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn alpn_wire_protocols(protocols: &[String]) -> Result> { + let mut out = Vec::new(); + for protocol in protocols { + let protocol = protocol.as_bytes(); + let len = u8::try_from(protocol.len()).context("ALPN protocol identifier is too long")?; + out.push(len); + out.extend_from_slice(protocol); + } + Ok(out) +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn verify_boring_tls_peer_certificate( + tls: &rama_tls_boring::client::BoringTlsStream, + fingerprint: &CertificateFingerprint, + protocol: &str, +) -> Result<()> { + let leaf = tls + .ssl() + .peer_certificate() + .ok_or_else(|| anyhow!("{protocol} peer did not present a certificate"))?; + if boring_certificate_fingerprint_matches(&leaf, fingerprint)? { + return Ok(()); + } + if let Some(chain) = tls.ssl().peer_cert_chain() { + for certificate in chain { + if boring_certificate_fingerprint_matches(certificate, fingerprint)? { + return Ok(()); + } + } + } + bail!("{protocol} certificate fingerprint mismatch"); +} + +#[cfg(feature = "boring-browser-fingerprints")] +fn boring_certificate_fingerprint_matches( + certificate: &BoringX509Ref, + fingerprint: &CertificateFingerprint, +) -> Result { + let digest = certificate + .digest(BoringMessageDigest::sha256()) + .context("failed to compute TLS certificate fingerprint")?; + Ok(&*digest == fingerprint.0) +} + +#[cfg(feature = "boring-browser-fingerprints")] +async fn restls_boring_connect( + stream: Box, + config: &ShadowsocksRestlsTransport, +) -> Result> { + let fingerprint = config.client_fingerprint.browser_profile(); + let profile = mihomo_browser_tls_profile(fingerprint)?; + let domain = RamaDomain::try_from(config.host.clone()) + .with_context(|| format!("invalid Restls host {}", config.host))?; + let builder = BoringTlsConnectorDataBuilder::try_from(profile.client_config.as_ref()) + .with_context(|| { + format!( + "failed to build Restls browser TLS profile for client-fingerprint {}", + fingerprint.mihomo_name() + ) + })? + .with_server_name(domain); + let data = builder.build().with_context(|| { + format!( + "failed to configure Restls browser TLS profile for {}", + config.host + ) + })?; + let handshake_stream = RestlsHandshakeStream::new(stream, config.secret, false); + let signed_stream = DetachableStream::new(RestlsSignedClientHelloStream::new( + handshake_stream, + config.secret, + )); + let mut tls = + rama_tls_boring::core::tokio::connect(data.config, config.host.as_str(), signed_stream) + .await + .map_err(|err| { + anyhow!( + "Restls browser-profile handshake failed for {} using client-fingerprint {}: {err}", + config.host, + fingerprint.mihomo_name() + ) + })?; + let signed_stream = tls + .get_mut() + .take() + .context("failed to recover Restls browser-profile transport")?; + let (handshake_stream, client_hello_signed) = signed_stream.into_inner(); + if !client_hello_signed { + bail!("Restls browser-profile handshake did not sign the ClientHello session ID"); + } + let (stream, handshake) = handshake_stream.into_inner(); + if !handshake.server_auth_unmasked { + bail!("Restls server authentication record was not observed"); + } + let server_random = handshake + .server_random + .ok_or_else(|| anyhow!("Restls handshake did not expose server random"))?; + let client_finished = handshake + .client_finished_auth + .ok_or_else(|| anyhow!("Restls handshake did not capture ClientFinished record"))?; + let mut codec = RestlsApplicationCodec::new(config.secret, server_random, false); + codec.set_client_finished_auth(client_finished); + codec.set_tls12_gcm_to_client_disable_counter(handshake.tls12_gcm_server_disable_counter); + let state = RestlsStreamState::new(codec, config.script.clone()); + Ok(Box::new(RestlsStream::new(stream, state))) +} + +fn shadow_tls_client_config(config: &ShadowsocksShadowTlsTransport) -> Result { + install_rustls_crypto_provider(); + let mut root_store = RootCertStore::empty(); + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let versions = if config.version == 1 { + &[&version::TLS12][..] + } else { + rustls::DEFAULT_VERSIONS + }; + let builder = + ClientConfig::builder_with_protocol_versions(versions).with_root_certificates(root_store); + let mut tls_config = if let Some(identity) = config.client_identity.as_ref() { + let (cert_chain, private_key) = load_rustls_client_identity(identity)?; + builder + .with_client_auth_cert(cert_chain, private_key) + .context("failed to configure ShadowTLS client certificate")? + } else { + builder.with_no_client_auth() + }; + tls_config.alpn_protocols = config + .alpn + .iter() + .map(|value| value.as_bytes().to_vec()) + .collect(); + if let Some(fingerprint) = config.certificate_fingerprint.as_ref() { + tls_config + .dangerous() + .set_certificate_verifier(Arc::new(PinnedCertificateVerifier { + fingerprint: fingerprint.clone(), + })); + } else if config.skip_cert_verify { + tls_config + .dangerous() + .set_certificate_verifier(Arc::new(NoCertificateVerifier)); + } + Ok(tls_config) +} + +async fn read_http_upgrade_response(stream: &mut Box) -> Result { + let mut response = Vec::with_capacity(1024); + let mut byte = [0u8; 1]; + while response.len() < 16 * 1024 { + stream.read_exact(&mut byte).await?; + response.push(byte[0]); + if response.ends_with(b"\r\n\r\n") { + return String::from_utf8(response) + .context("websocket plugin response headers were not valid UTF-8"); + } + } + bail!("websocket plugin response headers exceeded Burrow limit") +} + +fn validate_websocket_upgrade_response( + response: &str, + v2ray_http_upgrade: bool, + _websocket_key: Option<&str>, +) -> Result<()> { + let mut lines = response.split("\r\n"); + let status = lines.next().unwrap_or_default(); + let switching = status.contains(" 101 "); + let mut connection_upgrade = false; + let mut upgrade_websocket = false; + for line in lines { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim(); + if key.eq_ignore_ascii_case("connection") + && value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + { + connection_upgrade = true; + } + if key.eq_ignore_ascii_case("upgrade") && value.eq_ignore_ascii_case("websocket") { + upgrade_websocket = true; + } + } + if !switching || !connection_upgrade || !upgrade_websocket { + bail!("unexpected websocket plugin response status {status}"); + } + if !v2ray_http_upgrade { + // Mihomo validates Sec-WebSocket-Accept only in debug builds. Burrow keeps + // the same operational behavior and requires the protocol switch headers. + } + Ok(()) +} + +fn websocket_binary_frame(payload: &[u8]) -> io::Result> { + let mut frame = Vec::with_capacity(payload.len() + 14); + frame.push(0x82); + let len = payload.len(); + if len < 126 { + frame.push(0x80 | len as u8); + } else if u16::try_from(len).is_ok() { + frame.push(0x80 | 126); + frame.extend_from_slice(&(len as u16).to_be_bytes()); + } else { + frame.push(0x80 | 127); + frame.extend_from_slice(&(len as u64).to_be_bytes()); + } + let mut mask = [0u8; 4]; + OsRng.fill_bytes(&mut mask); + frame.extend_from_slice(&mask); + let mut masked = payload.to_vec(); + apply_websocket_mask(&mut masked, mask); + frame.extend_from_slice(&masked); + Ok(frame) +} + +fn apply_websocket_mask(payload: &mut [u8], mask: [u8; 4]) { + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask[index % 4]; + } +} + +fn websocket_path(path: &str) -> String { + let path = path.trim(); + if path.is_empty() { + return "/".to_owned(); + } + if path.starts_with('/') { + path.to_owned() + } else { + format!("/{path}") + } +} + +fn websocket_path_and_early_data(path: &str) -> (String, Option) { + let path = websocket_path(path); + let Some((base, query)) = path.split_once('?') else { + return (path, None); + }; + let mut max_early_data = None; + let mut kept = Vec::new(); + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key == "ed" { + if max_early_data.is_none() { + max_early_data = value.parse::().ok().filter(|value| *value > 0); + } + continue; + } + kept.push(pair); + } + let path = if kept.is_empty() { + base.to_owned() + } else { + format!("{base}?{}", kept.join("&")) + }; + (path, max_early_data) +} + +fn websocket_plugin_request( + config: &ShadowsocksWebSocketTransport, + _port: u16, + path: &str, + early_data: Option<&[u8]>, +) -> Result<(Vec, Option)> { + let host = config.websocket_host_header(); + let mut request = format!( + "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n" + ); + for (key, value) in config.headers.iter() { + if key.eq_ignore_ascii_case("host") + || key.eq_ignore_ascii_case("connection") + || key.eq_ignore_ascii_case("upgrade") + || (early_data.is_some() && key.eq_ignore_ascii_case("sec-websocket-protocol")) + || (!config.v2ray_http_upgrade + && (key.eq_ignore_ascii_case("sec-websocket-key") + || key.eq_ignore_ascii_case("sec-websocket-version"))) + { + continue; + } + request.push_str(key); + request.push_str(": "); + request.push_str(value); + request.push_str("\r\n"); + } + + let websocket_key = if config.v2ray_http_upgrade { + None + } else { + let mut key = [0u8; 16]; + OsRng.fill_bytes(&mut key); + let key = BASE64_STANDARD.encode(key); + request.push_str("Sec-WebSocket-Version: 13\r\n"); + request.push_str("Sec-WebSocket-Key: "); + request.push_str(&key); + request.push_str("\r\n"); + Some(key) + }; + if let Some(early_data) = early_data { + request.push_str("Sec-WebSocket-Protocol: "); + request.push_str(&BASE64_URL_SAFE_NO_PAD.encode(early_data)); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + Ok((request.into_bytes(), websocket_key)) +} + +impl ShadowsocksWebSocketTransport { + fn websocket_host_header(&self) -> &str { + self.headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case("host")) + .map(|(_, value)| value.as_str()) + .unwrap_or(&self.host) + } +} + +fn v2ray_mux_open_frame() -> Vec { + let mut frame = Vec::new(); + frame.extend_from_slice(&[0u8, 0u8]); + frame.extend_from_slice(&[0u8, 0u8]); + frame.push(0x01); + frame.push(0x00); + frame.push(0x01); + frame.extend_from_slice(&0u16.to_be_bytes()); + frame.push(0x02); + frame.extend_from_slice(b"127.0.0.1"); + let len = u16::try_from(frame.len() - 2).expect("v2ray mux open frame fits u16"); + frame[..2].copy_from_slice(&len.to_be_bytes()); + frame +} + +fn v2ray_mux_data_frame(payload: &[u8]) -> io::Result> { + if payload.len() > u16::MAX as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "v2ray-plugin mux payload is too large", + )); + } + let mut frame = Vec::with_capacity(payload.len() + 8); + frame.extend_from_slice(&4u16.to_be_bytes()); + frame.extend_from_slice(&[0u8, 0u8]); + frame.push(0x02); + frame.push(0x01); + frame.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + frame.extend_from_slice(payload); + Ok(frame) +} + +fn shadowsocks_plugin_transport( + plugin: Option<&ShadowsocksPlugin>, + client_fingerprint: Option<&str>, +) -> Result> { + let Some(plugin) = plugin else { + return Ok(None); + }; + let name = plugin.name.trim(); + if name == "obfs" { + let mode = plugin + .opts + .get("mode") + .or_else(|| plugin.opts.get("obfs")) + .map(|value| value.trim().to_ascii_lowercase()) + .ok_or_else(|| anyhow!("Shadowsocks obfs plugin requires mode"))?; + let mode = match mode.as_str() { + "http" => SimpleObfsMode::Http, + "tls" => SimpleObfsMode::Tls, + other => bail!("unsupported Shadowsocks obfs mode {other}"), + }; + let host = plugin + .opts + .get("host") + .or_else(|| plugin.opts.get("obfs-host")) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("bing.com") + .to_owned(); + return Ok(Some(ShadowsocksPluginTransport::SimpleObfs { mode, host })); + } + if name == "v2ray-plugin" || name == "gost-plugin" { + let mode = plugin + .opts + .get("mode") + .or_else(|| plugin.opts.get("obfs")) + .map(|value| value.trim().to_ascii_lowercase()) + .ok_or_else(|| anyhow!("Shadowsocks {name} plugin requires websocket mode"))?; + if mode != "websocket" { + bail!("unsupported Shadowsocks {name} mode {mode}"); + } + let mux = plugin_bool_opt(&plugin.opts, "mux").unwrap_or(true); + let mux = match (name, mux) { + ("v2ray-plugin", true) => ShadowsocksWebSocketMux::V2ray, + ("gost-plugin", true) => ShadowsocksWebSocketMux::Gost, + (_, false) => ShadowsocksWebSocketMux::None, + _ => ShadowsocksWebSocketMux::None, + }; + let host = plugin + .opts + .get("host") + .or_else(|| plugin.opts.get("obfs-host")) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("bing.com") + .to_owned(); + let path = plugin + .opts + .get("path") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("/") + .to_owned(); + let transport = ShadowsocksWebSocketTransport { + kind: if name == "v2ray-plugin" { + ShadowsocksWebSocketKind::V2ray + } else { + ShadowsocksWebSocketKind::Gost + }, + host, + path, + headers: plugin_headers(&plugin.opts), + tls: plugin_bool_opt(&plugin.opts, "tls").unwrap_or(false), + ech: plugin_ech_config(&plugin.opts)?, + skip_cert_verify: plugin_bool_opt(&plugin.opts, "skip-cert-verify").unwrap_or(false), + certificate_fingerprint: plugin + .opts + .get("fingerprint") + .map(|value| parse_certificate_fingerprint(value)) + .transpose()?, + client_identity: plugin_tls_client_identity(&plugin.opts)?, + mux, + v2ray_http_upgrade: plugin_bool_opt(&plugin.opts, "v2ray-http-upgrade") + .unwrap_or(false), + v2ray_http_upgrade_fast_open: name == "v2ray-plugin" + && plugin_bool_opt(&plugin.opts, "v2ray-http-upgrade-fast-open").unwrap_or(false), + }; + return Ok(Some(ShadowsocksPluginTransport::WebSocket(transport))); + } + if name == "shadow-tls" { + let host = plugin + .opts + .get("host") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("Shadowsocks shadow-tls plugin requires host"))? + .to_owned(); + let version = plugin_u8_opt(&plugin.opts, "version").unwrap_or(2); + match version { + 1 | 2 => {} + other => bail!("unsupported Shadowsocks shadow-tls version {other}"), + } + let parsed_client_fingerprint = + client_fingerprint.and_then(MihomoClientFingerprint::from_mihomo_name); + if let Some(client_fingerprint) = parsed_client_fingerprint { + if version == 2 && !client_fingerprint.shadow_tls_v2_boring_supported() { + bail!( + "unsupported Shadowsocks {name} client-fingerprint {}: no matching browser TLS profile is available yet", + client_fingerprint.mihomo_name() + ); + } + } + let password = plugin + .opts + .get("password") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or(""); + if version != 1 && password.is_empty() { + bail!("Shadowsocks shadow-tls plugin requires password for version {version}"); + } + let password = password.to_owned(); + let alpn = plugin_list_opt(&plugin.opts, "alpn") + .unwrap_or_else(|| vec!["h2".to_owned(), "http/1.1".to_owned()]); + return Ok(Some(ShadowsocksPluginTransport::ShadowTls( + ShadowsocksShadowTlsTransport { + host, + password, + version, + alpn, + skip_cert_verify: plugin_bool_opt(&plugin.opts, "skip-cert-verify") + .unwrap_or(false), + certificate_fingerprint: plugin + .opts + .get("fingerprint") + .map(|value| parse_certificate_fingerprint(value)) + .transpose()?, + client_identity: plugin_tls_client_identity(&plugin.opts)?, + client_fingerprint: if version == 2 || version == 3 { + parsed_client_fingerprint + } else { + None + }, + }, + ))); + } + if name == "kcptun" { + let mut transport = ShadowsocksKcptunTransport::empty(); + if let Some(value) = plugin.opts.get("key") { + transport.key = value.trim().to_owned(); + } + if let Some(value) = plugin.opts.get("crypt") { + transport.crypt = value.trim().to_owned(); + } + if let Some(value) = plugin.opts.get("mode") { + transport.mode = value.trim().to_owned(); + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "conn") { + transport.conn = value; + } + if let Some(value) = plugin_u64_opt(&plugin.opts, "autoexpire") { + transport.auto_expire = value; + } + if let Some(value) = plugin_u64_opt(&plugin.opts, "scavengettl") { + transport.scavenge_ttl = value; + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "mtu") { + transport.mtu = value; + } + if let Some(value) = plugin_u32_opt(&plugin.opts, "ratelimit") { + transport.rate_limit = value; + } + if let Some(value) = plugin_u16_opt(&plugin.opts, "sndwnd") { + transport.sndwnd = value; + } + if let Some(value) = plugin_u16_opt(&plugin.opts, "rcvwnd") { + transport.rcvwnd = value; + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "datashard") { + transport.data_shard = value; + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "parityshard") { + transport.parity_shard = value; + } + if let Some(value) = plugin_u32_opt(&plugin.opts, "dscp") { + transport.dscp = value; + } + if let Some(value) = plugin_bool_opt(&plugin.opts, "nocomp") { + transport.no_comp = value; + } + if let Some(value) = plugin_bool_opt(&plugin.opts, "acknodelay") { + transport.ack_nodelay = value; + } + if let Some(value) = plugin_i32_opt(&plugin.opts, "nodelay") { + transport.no_delay = value; + } + if let Some(value) = plugin_i32_opt(&plugin.opts, "interval") { + transport.interval = value; + } + if let Some(value) = plugin_i32_opt(&plugin.opts, "resend") { + transport.resend = value; + } + if let Some(value) = plugin_bool_opt(&plugin.opts, "nc") { + transport.no_congestion = value; + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "sockbuf") { + transport.sockbuf = value; + } + if let Some(value) = plugin_u8_opt(&plugin.opts, "smuxver") { + transport.smux_ver = value; + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "smuxbuf") { + transport.smux_buf = value; + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "framesize") { + transport.frame_size = value; + } + if let Some(value) = plugin_usize_opt(&plugin.opts, "streambuf") { + transport.stream_buf = value; + } + if let Some(value) = plugin_u64_opt(&plugin.opts, "keepalive") { + transport.keep_alive = value; + } + transport.fill_defaults(); + return Ok(Some(ShadowsocksPluginTransport::Kcptun(transport))); + } + Ok(None) +} + +fn restls_transport( + plugin: &ShadowsocksPlugin, + client_fingerprint: Option<&str>, +) -> Result { + let host = plugin + .opts + .get("host") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow!("Shadowsocks restls plugin requires host"))? + .to_owned(); + let password = plugin + .opts + .get("password") + .map(|value| value.trim()) + .unwrap_or("") + .to_owned(); + let version_hint = plugin + .opts + .get("version-hint") + .map(|value| restls_version_hint(value)) + .transpose()? + .ok_or_else(|| anyhow!("Shadowsocks restls plugin requires version-hint tls12 or tls13"))?; + let script = plugin + .opts + .get("restls-script") + .map(|value| value.as_str()) + .unwrap_or(RESTLS_DEFAULT_SCRIPT); + let script = parse_restls_script(script)?; + let client_fingerprint = restls_client_fingerprint(client_fingerprint); + let secret = blake3::derive_key("restls-traffic-key", password.as_bytes()); + let session_tickets_disabled = version_hint == RestlsVersionHint::Tls13; + Ok(ShadowsocksRestlsTransport { + host, + password, + version_hint, + script, + client_fingerprint, + session_tickets_disabled, + secret, + }) +} + +fn restls_version_hint(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "tls12" => Ok(RestlsVersionHint::Tls12), + "tls13" => Ok(RestlsVersionHint::Tls13), + _ => bail!("invalid Shadowsocks restls version-hint: expected tls12 or tls13"), + } +} + +fn restls_client_fingerprint(value: Option<&str>) -> RestlsClientFingerprint { + match value.map(str::trim).unwrap_or("") { + "firefox" => RestlsClientFingerprint::Firefox, + "safari" => RestlsClientFingerprint::Safari, + "ios" => RestlsClientFingerprint::Ios, + _ => RestlsClientFingerprint::Chrome, + } +} + +fn parse_restls_script(script: &str) -> Result> { + let mut lines = Vec::new(); + for raw in script.replace(' ', "").split(',') { + if raw.is_empty() { + continue; + } + let mut input = raw; + let base = restls_take_integer(&mut input) + .with_context(|| format!("invalid Shadowsocks restls script line {raw}"))?; + let mut target_len = RestlsTargetLength { base, random_range: 0 }; + if input.starts_with('~') || input.starts_with('?') { + let randomize_once = input.starts_with('?'); + input = &input[1..]; + let random_range = restls_take_integer(&mut input) + .with_context(|| format!("invalid Shadowsocks restls script line {raw}"))?; + if u32::from(base) + u32::from(random_range) > 32768 { + bail!("Shadowsocks restls script target length exceeds 32768"); + } + if randomize_once { + let offset = if random_range == 0 { + 0 + } else { + rand::thread_rng().gen_range(0..usize::from(random_range)) as u16 + }; + target_len.base = base + offset; + } else { + target_len.random_range = random_range; + } + } + let command = if input.is_empty() { + RestlsCommand::Noop + } else if let Some(rest) = input.strip_prefix('<') { + input = rest; + RestlsCommand::Response( + restls_take_integer(&mut input) + .with_context(|| format!("invalid Shadowsocks restls script line {raw}"))?, + ) + } else { + bail!("invalid Shadowsocks restls script line {raw}"); + }; + if !input.is_empty() { + bail!("invalid Shadowsocks restls script line {raw}"); + } + lines.push(RestlsScriptLine { target_len, command }); + } + Ok(lines) +} + +fn restls_take_integer(input: &mut &str) -> Result { + let digit_len = input + .bytes() + .take_while(|byte| byte.is_ascii_digit()) + .count(); + if digit_len == 0 { + bail!("missing integer"); + } + let (digits, rest) = input.split_at(digit_len); + let value = digits.parse::().context("invalid integer")?; + if value > 32768 { + bail!("integer exceeds 32768"); + } + *input = rest; + Ok(value) +} + +#[allow(dead_code)] +impl RestlsCommand { + fn to_bytes(self) -> [u8; RESTLS_CMD_LEN] { + match self { + RestlsCommand::Noop => [0, 0], + RestlsCommand::Response(value) => [1, value as u8], + } + } + + fn from_bytes(bytes: [u8; RESTLS_CMD_LEN]) -> Result { + match bytes[0] { + 0 => Ok(RestlsCommand::Noop), + 1 => Ok(RestlsCommand::Response(u16::from(bytes[1]))), + _ => bail!("unsupported Shadowsocks restls command"), + } + } +} + +#[allow(dead_code)] +impl RestlsTargetLength { + fn len(&self) -> usize { + let offset = if self.random_range == 0 { + 0 + } else { + rand::thread_rng().gen_range(0..usize::from(self.random_range)) + }; + usize::from(self.base) + offset + } +} + +#[allow(dead_code)] +fn restls_tls13_session_id( + secret: &[u8; 32], + key_shares: &[(u16, Vec)], + psk_labels: &[Vec], + mut session_id: [u8; 32], +) -> [u8; 32] { + let mut hmac = blake3::Hasher::new_keyed(secret); + for (group, data) in key_shares { + hmac.update(&group.to_be_bytes()); + hmac.update(data); + } + for label in psk_labels { + hmac.update(label); + } + let digest = hmac.finalize(); + session_id[..RESTLS_HANDSHAKE_MAC_LEN] + .copy_from_slice(&digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]); + session_id +} + +#[allow(dead_code)] +fn restls_tls13_session_id_from_client_hello( + secret: &[u8; 32], + client_hello: &[u8], + seed_session_id: [u8; 32], +) -> Result<[u8; 32]> { + let material = restls_tls13_client_hello_auth_material(client_hello)?; + Ok(restls_tls13_session_id( + secret, + &material.key_shares, + &material.psk_labels, + seed_session_id, + )) +} + +fn restls_tls13_session_id_for_client_hello( + secret: &[u8; 32], + client_hello: &[u8], + error: &StdMutex>, +) -> [u8; 32] { + let mut session_id_seed = [0u8; 32]; + OsRng.fill_bytes(&mut session_id_seed[RESTLS_HANDSHAKE_MAC_LEN..]); + match restls_tls13_session_id_from_client_hello(secret, client_hello, session_id_seed) { + Ok(session_id) => session_id, + Err(err) => { + let message = + format!("failed to derive Restls TLS 1.3 session id from ClientHello: {err:#}"); + tracing::warn!(error = %err, "failed to derive Restls TLS 1.3 session id from ClientHello"); + if let Ok(mut slot) = error.lock() { + *slot = Some(message); + } + session_id_seed + } + } +} + +fn restls_tls12_session_id_for_client_hello( + secret: &[u8; 32], + _client_hello: &[u8], + materials: &[Vec], + error: &StdMutex>, +) -> [u8; 32] { + match restls_tls12_session_id(secret, materials) { + Ok(session_id) => session_id, + Err(err) => { + let message = + format!("failed to derive Restls TLS 1.2 session id from key materials: {err:#}"); + tracing::warn!(error = %err, "failed to derive Restls TLS 1.2 session id"); + if let Ok(mut slot) = error.lock() { + *slot = Some(message); + } + let mut fallback = [0u8; 32]; + OsRng.fill_bytes(&mut fallback); + fallback + } + } +} + +fn take_restls_session_id_error(error: &StdMutex>) -> Option { + error.lock().ok().and_then(|mut slot| slot.take()) +} + +#[allow(dead_code)] +fn restls_tls13_sign_client_hello_record( + secret: &[u8; 32], + bytes: &[u8], +) -> io::Result>> { + if bytes.len() < TLS_RECORD_HEADER_LEN { + return Ok(None); + } + if bytes[0] != 0x16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Restls first TLS record is not a handshake record", + )); + } + let payload_len = usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); + let record_len = TLS_RECORD_HEADER_LEN + payload_len; + if bytes.len() < record_len { + return Ok(None); + } + let payload = &bytes[TLS_RECORD_HEADER_LEN..record_len]; + if payload.first().copied() != Some(0x01) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Restls first handshake message is not ClientHello", + )); + } + let session_id_length_index = SHADOW_TLS_V3_SESSION_ID_START - 1; + if payload.get(session_id_length_index).copied() != Some(32) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Restls browser ClientHello did not reserve a 32-byte session ID", + )); + } + if payload.len() < SHADOW_TLS_V3_SESSION_ID_START + 32 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Restls browser ClientHello session ID is truncated", + )); + } + let mut seed_session_id = [0u8; 32]; + seed_session_id.copy_from_slice( + &payload[SHADOW_TLS_V3_SESSION_ID_START..SHADOW_TLS_V3_SESSION_ID_START + 32], + ); + let session_id = restls_tls13_session_id_from_client_hello(secret, payload, seed_session_id) + .map_err(|err| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("failed to derive Restls TLS 1.3 session ID from ClientHello: {err:#}"), + ) + })?; + let mut signed = bytes.to_vec(); + let session_id_start = TLS_RECORD_HEADER_LEN + SHADOW_TLS_V3_SESSION_ID_START; + signed[session_id_start..session_id_start + 32].copy_from_slice(&session_id); + Ok(Some(signed)) +} + +#[allow(dead_code)] +fn restls_tls13_client_hello_auth_material( + client_hello: &[u8], +) -> Result { + let mut input = client_hello; + let handshake_type = restls_read_u8(&mut input) + .context("Shadowsocks restls ClientHello is missing handshake type")?; + if handshake_type != 0x01 { + bail!("Shadowsocks restls auth material requires a ClientHello handshake"); + } + let handshake_len = usize::try_from(restls_read_u24(&mut input)?).expect("u24 fits into usize"); + let handshake = restls_read_exact(&mut input, handshake_len) + .context("Shadowsocks restls ClientHello body is truncated")?; + if !input.is_empty() { + bail!("Shadowsocks restls ClientHello has trailing bytes"); + } + + let mut body = handshake; + restls_read_exact(&mut body, 2).context("Shadowsocks restls ClientHello misses version")?; + restls_read_exact(&mut body, 32).context("Shadowsocks restls ClientHello misses random")?; + let session_id_len = usize::from( + restls_read_u8(&mut body).context("Shadowsocks restls ClientHello misses session ID")?, + ); + restls_read_exact(&mut body, session_id_len) + .context("Shadowsocks restls ClientHello session ID is truncated")?; + let cipher_suites_len = usize::from( + restls_read_u16(&mut body) + .context("Shadowsocks restls ClientHello misses cipher suites")?, + ); + if cipher_suites_len % 2 != 0 { + bail!("Shadowsocks restls ClientHello cipher suites length is invalid"); + } + restls_read_exact(&mut body, cipher_suites_len) + .context("Shadowsocks restls ClientHello cipher suites are truncated")?; + let compression_len = usize::from( + restls_read_u8(&mut body).context("Shadowsocks restls ClientHello misses compression")?, + ); + restls_read_exact(&mut body, compression_len) + .context("Shadowsocks restls ClientHello compression list is truncated")?; + let extensions_len = usize::from( + restls_read_u16(&mut body).context("Shadowsocks restls ClientHello misses extensions")?, + ); + let mut extensions = restls_read_exact(&mut body, extensions_len) + .context("Shadowsocks restls ClientHello extensions are truncated")?; + if !body.is_empty() { + bail!("Shadowsocks restls ClientHello body has trailing bytes"); + } + + let mut key_shares = Vec::new(); + let mut psk_labels = Vec::new(); + while !extensions.is_empty() { + let extension_type = restls_read_u16(&mut extensions) + .context("Shadowsocks restls ClientHello extension type is truncated")?; + let extension_len = usize::from(restls_read_u16(&mut extensions).with_context(|| { + format!("Shadowsocks restls ClientHello extension {extension_type} misses length") + })?); + let mut extension = + restls_read_exact(&mut extensions, extension_len).with_context(|| { + format!("Shadowsocks restls ClientHello extension {extension_type} is truncated") + })?; + match extension_type { + 0x0033 => key_shares = restls_tls13_key_share_material(&mut extension)?, + 0x0029 => psk_labels = restls_tls13_psk_labels(&mut extension)?, + _ => {} + } + if !extension.is_empty() { + bail!("Shadowsocks restls ClientHello extension {extension_type} has trailing bytes"); + } + } + + if key_shares.is_empty() { + bail!("Shadowsocks restls TLS 1.3 ClientHello has no key share material"); + } + Ok(RestlsTls13ClientHelloAuthMaterial { key_shares, psk_labels }) +} + +#[allow(dead_code)] +fn restls_tls13_key_share_material(extension: &mut &[u8]) -> Result)>> { + let list_len = usize::from( + restls_read_u16(extension) + .context("Shadowsocks restls key_share extension misses length")?, + ); + let mut shares = restls_read_exact(extension, list_len) + .context("Shadowsocks restls key_share extension is truncated")?; + let mut material = Vec::new(); + while !shares.is_empty() { + let group = restls_read_u16(&mut shares) + .context("Shadowsocks restls key_share entry misses group")?; + let key_len = usize::from( + restls_read_u16(&mut shares) + .context("Shadowsocks restls key_share entry misses key length")?, + ); + let key = restls_read_exact(&mut shares, key_len) + .context("Shadowsocks restls key_share entry is truncated")?; + material.push((group, key.to_vec())); + } + Ok(material) +} + +#[allow(dead_code)] +fn restls_tls13_psk_labels(extension: &mut &[u8]) -> Result>> { + let identities_len = usize::from( + restls_read_u16(extension) + .context("Shadowsocks restls pre_shared_key extension misses identities length")?, + ); + let mut identities = restls_read_exact(extension, identities_len) + .context("Shadowsocks restls pre_shared_key identities are truncated")?; + let mut labels = Vec::new(); + while !identities.is_empty() { + let label_len = usize::from( + restls_read_u16(&mut identities) + .context("Shadowsocks restls pre_shared_key identity misses label length")?, + ); + let label = restls_read_exact(&mut identities, label_len) + .context("Shadowsocks restls pre_shared_key identity label is truncated")?; + labels.push(label.to_vec()); + restls_read_exact(&mut identities, 4) + .context("Shadowsocks restls pre_shared_key identity misses ticket age")?; + } + let binders_len = usize::from( + restls_read_u16(extension) + .context("Shadowsocks restls pre_shared_key extension misses binders length")?, + ); + restls_read_exact(extension, binders_len) + .context("Shadowsocks restls pre_shared_key binders are truncated")?; + Ok(labels) +} + +#[allow(dead_code)] +fn restls_read_u8(input: &mut &[u8]) -> Result { + let (byte, rest) = input + .split_first() + .ok_or_else(|| anyhow!("unexpected end of Restls TLS data"))?; + *input = rest; + Ok(*byte) +} + +#[allow(dead_code)] +fn restls_read_u16(input: &mut &[u8]) -> Result { + let bytes = restls_read_exact(input, 2)?; + Ok(u16::from_be_bytes( + bytes.try_into().expect("fixed u16 byte length"), + )) +} + +#[allow(dead_code)] +fn restls_read_u24(input: &mut &[u8]) -> Result { + let bytes = restls_read_exact(input, 3)?; + Ok((u32::from(bytes[0]) << 16) | (u32::from(bytes[1]) << 8) | u32::from(bytes[2])) +} + +#[allow(dead_code)] +fn restls_read_exact<'a>(input: &mut &'a [u8], len: usize) -> Result<&'a [u8]> { + if input.len() < len { + bail!("unexpected end of Restls TLS data"); + } + let (head, tail) = input.split_at(len); + *input = tail; + Ok(head) +} + +#[allow(dead_code)] +fn restls_tls12_session_id(secret: &[u8; 32], materials: &[Vec]) -> Result<[u8; 32]> { + let layout: &[usize] = match materials.len() { + 3 => &RESTLS_TLS12_CLIENT_AUTH_LAYOUT_3, + 4 => &RESTLS_TLS12_CLIENT_AUTH_LAYOUT_4, + len => bail!("Shadowsocks restls TLS 1.2 auth requires 3 or 4 key materials, got {len}"), + }; + let mut session_id = [0u8; 32]; + for (index, material) in materials.iter().enumerate() { + let mut hmac = blake3::Hasher::new_keyed(secret); + hmac.update(material); + let digest = hmac.finalize(); + let start = layout[index]; + let end = layout[index + 1]; + session_id[start..end].copy_from_slice(&digest.as_bytes()[..end - start]); + } + Ok(session_id) +} + +#[allow(dead_code)] +fn restls_unmask_server_auth_record( + secret: &[u8; 32], + server_random: &[u8; 32], + record: &[u8], + tls12_gcm: bool, +) -> Result { + if record.len() < TLS_RECORD_HEADER_LEN { + bail!("Shadowsocks restls server auth record is too short"); + } + let mut hmac = blake3::Hasher::new_keyed(secret); + hmac.update(server_random); + let digest = hmac.finalize(); + let mask = &digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]; + let mut unmasked = record.to_vec(); + let mut tls12_gcm_server_disable_counter = false; + let offset = if tls12_gcm + && record.len() >= TLS_RECORD_HEADER_LEN + 8 + && record[TLS_RECORD_HEADER_LEN..TLS_RECORD_HEADER_LEN + 8] == [0u8; 8] + { + TLS_RECORD_HEADER_LEN + 8 + } else { + tls12_gcm_server_disable_counter = tls12_gcm; + TLS_RECORD_HEADER_LEN + }; + xor_with_restls_mask(&mut unmasked[offset..], mask); + Ok(RestlsServerAuthRecord { + record: unmasked, + tls12_gcm_server_disable_counter, + }) +} + +#[allow(dead_code)] +fn restls_server_hello_random(record: &[u8]) -> Option<[u8; 32]> { + if record.len() < TLS_RECORD_HEADER_LEN + 1 + 3 + 2 + 32 { + return None; + } + if record[0] != 0x16 || record[TLS_RECORD_HEADER_LEN] != 0x02 { + return None; + } + let payload_len = usize::from(u16::from_be_bytes([record[3], record[4]])); + if payload_len + TLS_RECORD_HEADER_LEN != record.len() { + return None; + } + let handshake_len = (usize::from(record[TLS_RECORD_HEADER_LEN + 1]) << 16) + | (usize::from(record[TLS_RECORD_HEADER_LEN + 2]) << 8) + | usize::from(record[TLS_RECORD_HEADER_LEN + 3]); + if handshake_len + 4 > payload_len || handshake_len < 34 { + return None; + } + let random_start = TLS_RECORD_HEADER_LEN + 1 + 3 + 2; + let mut server_random = [0u8; 32]; + server_random.copy_from_slice(&record[random_start..random_start + 32]); + Some(server_random) +} + +#[allow(dead_code)] +fn restls_tls_records(mut bytes: &[u8]) -> Vec<&[u8]> { + let mut records = Vec::new(); + while bytes.len() >= TLS_RECORD_HEADER_LEN { + let record_len = + TLS_RECORD_HEADER_LEN + usize::from(u16::from_be_bytes([bytes[3], bytes[4]])); + if bytes.len() < record_len { + break; + } + let (record, rest) = bytes.split_at(record_len); + records.push(record); + bytes = rest; + } + records +} + +#[allow(dead_code)] +impl RestlsApplicationCodec { + fn new(secret: [u8; 32], server_random: [u8; 32], tls12_gcm: bool) -> Self { + Self { + secret, + server_random, + to_server_counter: 0, + to_client_counter: 0, + tls12_gcm, + tls12_gcm_to_client_disable_counter: false, + client_finished_auth: None, + } + } + + fn set_client_finished_auth(&mut self, record: Vec) { + self.client_finished_auth = Some(record); + } + + fn set_tls12_gcm_to_client_disable_counter(&mut self, disabled: bool) { + self.tls12_gcm_to_client_disable_counter = disabled; + } + + fn auth_header_hash(&self, direction: RestlsRecordDirection) -> blake3::Hasher { + let mut hasher = blake3::Hasher::new_keyed(&self.secret); + hasher.update(&self.server_random); + let mut counter = [0u8; 8]; + match direction { + RestlsRecordDirection::ToServer => { + hasher.update(b"client-to-server"); + counter.copy_from_slice(&self.to_server_counter.to_be_bytes()); + } + RestlsRecordDirection::ToClient => { + hasher.update(b"server-to-client"); + counter.copy_from_slice(&self.to_client_counter.to_be_bytes()); + } + } + hasher.update(&counter); + hasher + } + + fn build_to_server_record( + &mut self, + data: &[u8], + script: &[RestlsScriptLine], + ) -> Result { + self.build_to_server_record_inner(data, script, false) + } + + fn build_to_server_fake_response_record( + &mut self, + script: &[RestlsScriptLine], + ) -> Result { + self.build_to_server_record_inner(&[], script, true) + } + + fn build_to_server_record_inner( + &mut self, + data: &[u8], + script: &[RestlsScriptLine], + allow_empty: bool, + ) -> Result { + if data.is_empty() && !allow_empty { + bail!("Shadowsocks restls application record requires non-empty data"); + } + let mut data_len = data.len(); + let mut padding_len = 0usize; + let mut command = RestlsCommand::Noop; + if let Some(line) = script.get(self.to_server_counter as usize) { + data_len = line.target_len.len(); + command = line.command; + } + if data_len == 0 { + padding_len = 19 + rand::thread_rng().gen_range(0..100); + } + if data.len() < data_len { + padding_len = data_len - data.len(); + data_len = data.len(); + } + + let auth_header_len = RESTLS_APP_DATA_AUTH_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 }; + let mut payload_len = data_len + padding_len + auth_header_len; + if payload_len > RESTLS_MAX_PLAINTEXT { + payload_len = RESTLS_MAX_PLAINTEXT; + } + data_len = payload_len + .checked_sub(auth_header_len + padding_len) + .ok_or_else(|| anyhow!("Shadowsocks restls target length is too large"))?; + let payload_len_u16 = u16::try_from(payload_len) + .context("Shadowsocks restls application record is too large")?; + + let header_len = TLS_RECORD_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 }; + let mut record = Vec::with_capacity(TLS_RECORD_HEADER_LEN + payload_len); + record.push(TLS_APPLICATION_DATA_RECORD_TYPE); + record.extend_from_slice(&TLS12_RECORD_VERSION); + record.extend_from_slice(&payload_len_u16.to_be_bytes()); + if self.tls12_gcm { + record.extend_from_slice(&(self.to_server_counter + 1).to_be_bytes()); + } + record.resize(header_len + RESTLS_APP_DATA_AUTH_HEADER_LEN, 0); + record.extend_from_slice(&data[..data_len]); + let padding_start = record.len(); + record.resize(padding_start + padding_len, 0); + OsRng.fill_bytes(&mut record[padding_start..]); + self.write_auth_header( + &mut record, + data_len, + command, + header_len, + RestlsRecordDirection::ToServer, + )?; + self.to_server_counter += 1; + Ok(RestlsWriteResult { + record, + consumed: data_len, + command, + }) + } + + fn decode_to_client_record(&mut self, record: &[u8]) -> Result { + let frame = self.decode_record(record, RestlsRecordDirection::ToClient)?; + self.to_client_counter += 1; + Ok(frame) + } + + fn decode_to_server_record(&mut self, record: &[u8]) -> Result { + let frame = self.decode_record(record, RestlsRecordDirection::ToServer)?; + self.to_server_counter += 1; + Ok(frame) + } + + #[cfg(test)] + fn build_to_client_test_record( + &mut self, + data: &[u8], + command: RestlsCommand, + ) -> Result> { + let payload_len = + RESTLS_APP_DATA_AUTH_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 } + data.len(); + let payload_len_u16 = u16::try_from(payload_len) + .context("Shadowsocks restls application record is too large")?; + let header_len = TLS_RECORD_HEADER_LEN + if self.tls12_gcm { 8 } else { 0 }; + let mut record = Vec::with_capacity(TLS_RECORD_HEADER_LEN + payload_len); + record.push(TLS_APPLICATION_DATA_RECORD_TYPE); + record.extend_from_slice(&TLS12_RECORD_VERSION); + record.extend_from_slice(&payload_len_u16.to_be_bytes()); + if self.tls12_gcm { + record.extend_from_slice(&(self.to_client_counter + 1).to_be_bytes()); + } + record.resize(header_len + RESTLS_APP_DATA_AUTH_HEADER_LEN, 0); + record.extend_from_slice(data); + self.write_auth_header( + &mut record, + data.len(), + command, + header_len, + RestlsRecordDirection::ToClient, + )?; + self.to_client_counter += 1; + Ok(record) + } + + fn write_auth_header( + &mut self, + record: &mut [u8], + data_len: usize, + command: RestlsCommand, + header_len: usize, + direction: RestlsRecordDirection, + ) -> Result<()> { + let (header, body) = record.split_at_mut(header_len); + let sample_len = 32.min(body[RESTLS_APP_DATA_OFFSET..].len()); + let mut hmac_mask = self.auth_header_hash(direction); + hmac_mask.update(&body[RESTLS_APP_DATA_OFFSET..RESTLS_APP_DATA_OFFSET + sample_len]); + let mask = hmac_mask.finalize(); + let mask = &mask.as_bytes()[..RESTLS_MASK_LEN]; + + body[RESTLS_APP_DATA_LEN_OFFSET..RESTLS_APP_DATA_LEN_OFFSET + 2] + .copy_from_slice(&(data_len as u16).to_be_bytes()); + body[RESTLS_APP_DATA_LEN_OFFSET + 2..RESTLS_APP_DATA_OFFSET] + .copy_from_slice(&command.to_bytes()); + xor_with_restls_mask( + &mut body[RESTLS_APP_DATA_LEN_OFFSET..RESTLS_APP_DATA_OFFSET], + mask, + ); + + let mut hmac_auth = self.auth_header_hash(direction); + if direction == RestlsRecordDirection::ToServer { + if let Some(client_finished) = self.client_finished_auth.take() { + hmac_auth.update(&client_finished); + } + } + hmac_auth.update(header); + hmac_auth.update(&body[RESTLS_APP_DATA_LEN_OFFSET..]); + let auth_mac = hmac_auth.finalize(); + body[..RESTLS_APP_DATA_MAC_LEN] + .copy_from_slice(&auth_mac.as_bytes()[..RESTLS_APP_DATA_MAC_LEN]); + Ok(()) + } + + fn decode_record( + &mut self, + record: &[u8], + direction: RestlsRecordDirection, + ) -> Result { + if record.len() < TLS_RECORD_HEADER_LEN + RESTLS_APP_DATA_AUTH_HEADER_LEN { + bail!("Shadowsocks restls application record is too short"); + } + if record[0] != TLS_APPLICATION_DATA_RECORD_TYPE { + bail!("Shadowsocks restls record is not application data"); + } + if record[1..3] != TLS12_RECORD_VERSION { + bail!("Shadowsocks restls record has invalid TLS record version"); + } + let payload_len = usize::from(u16::from_be_bytes([record[3], record[4]])); + if payload_len + TLS_RECORD_HEADER_LEN != record.len() { + bail!("Shadowsocks restls application record length mismatch"); + } + + let mut header_len = TLS_RECORD_HEADER_LEN; + let tls12_gcm_counter_present = self.tls12_gcm + && !(direction == RestlsRecordDirection::ToClient + && self.tls12_gcm_to_client_disable_counter); + if tls12_gcm_counter_present { + if record.len() < TLS_RECORD_HEADER_LEN + 8 + RESTLS_APP_DATA_AUTH_HEADER_LEN { + bail!("Shadowsocks restls GCM application record is too short"); + } + let expected_counter = match direction { + RestlsRecordDirection::ToServer => self.to_server_counter + 1, + RestlsRecordDirection::ToClient => self.to_client_counter + 1, + }; + let nonce = u64::from_be_bytes( + record[TLS_RECORD_HEADER_LEN..TLS_RECORD_HEADER_LEN + 8] + .try_into() + .expect("nonce slice has fixed length"), + ); + if nonce != expected_counter { + bail!("Shadowsocks restls GCM application record counter mismatch"); + } + header_len += 8; + } + + let header = &record[..header_len]; + let mut body = record[header_len..].to_vec(); + let mut hmac_auth = self.auth_header_hash(direction); + if direction == RestlsRecordDirection::ToServer { + if let Some(client_finished) = self.client_finished_auth.as_ref() { + hmac_auth.update(client_finished); + } + } + hmac_auth.update(header); + hmac_auth.update(&body[RESTLS_APP_DATA_LEN_OFFSET..]); + let auth_mac = hmac_auth.finalize(); + if auth_mac.as_bytes()[..RESTLS_APP_DATA_MAC_LEN] + .ct_eq(&body[..RESTLS_APP_DATA_MAC_LEN]) + .unwrap_u8() + != 1 + { + bail!("Shadowsocks restls application record authentication failed"); + } + if direction == RestlsRecordDirection::ToServer { + self.client_finished_auth = None; + } + + let sample_len = 32.min(body[RESTLS_APP_DATA_OFFSET..].len()); + let mut hmac_mask = self.auth_header_hash(direction); + hmac_mask.update(&body[RESTLS_APP_DATA_OFFSET..RESTLS_APP_DATA_OFFSET + sample_len]); + let mask = hmac_mask.finalize(); + xor_with_restls_mask( + &mut body[RESTLS_APP_DATA_LEN_OFFSET..RESTLS_APP_DATA_OFFSET], + &mask.as_bytes()[..RESTLS_MASK_LEN], + ); + let data_len = usize::from(u16::from_be_bytes([ + body[RESTLS_APP_DATA_LEN_OFFSET], + body[RESTLS_APP_DATA_LEN_OFFSET + 1], + ])); + if body.len() < RESTLS_APP_DATA_OFFSET + data_len { + bail!("Shadowsocks restls application record data length exceeds payload"); + } + let command = RestlsCommand::from_bytes([ + body[RESTLS_APP_DATA_LEN_OFFSET + 2], + body[RESTLS_APP_DATA_LEN_OFFSET + 3], + ])?; + Ok(RestlsApplicationFrame { + data: body[RESTLS_APP_DATA_OFFSET..RESTLS_APP_DATA_OFFSET + data_len].to_vec(), + command, + }) + } +} + +#[allow(dead_code)] +impl RestlsCommand { + fn need_interrupt(self) -> bool { + matches!(self, RestlsCommand::Response(_)) + } +} + +#[allow(dead_code)] +impl RestlsStreamState { + fn new(codec: RestlsApplicationCodec, script: Vec) -> Self { + Self { + codec, + script, + send_buffer: Vec::new(), + write_pending: false, + } + } + + fn write(&mut self, data_new: &[u8]) -> Result { + let fake_response = data_new == RESTLS_RANDOM_RESPONSE_MAGIC; + let accepted = if fake_response { 0 } else { data_new.len() }; + let data_new = if fake_response { &[][..] } else { data_new }; + if fake_response { + self.write_pending = false; + } + + if self.write_pending { + self.send_buffer.extend_from_slice(data_new); + return Ok(RestlsStreamWriteResult { accepted, records: Vec::new() }); + } + + let mut data = if self.send_buffer.is_empty() { + data_new.to_vec() + } else { + self.send_buffer.extend_from_slice(data_new); + std::mem::take(&mut self.send_buffer) + }; + let mut records = Vec::new(); + let mut fake_response_pending = fake_response; + + while !data.is_empty() || fake_response_pending { + let record = if fake_response_pending { + self.codec + .build_to_server_fake_response_record(&self.script)? + } else { + self.codec.build_to_server_record(&data, &self.script)? + }; + let consumed = record.consumed; + let command = record.command; + records.push(record.record); + if command.need_interrupt() && !fake_response_pending { + self.write_pending = true; + } + if consumed > data.len() { + bail!("Shadowsocks restls consumed more data than available"); + } + data.drain(..consumed); + if command.need_interrupt() && !fake_response_pending { + break; + } + fake_response_pending = false; + } + + self.send_buffer = data; + Ok(RestlsStreamWriteResult { accepted, records }) + } + + fn receive_command( + &mut self, + command: RestlsCommand, + resumed_pending_write: bool, + ) -> Result>> { + let mut records = Vec::new(); + let RestlsCommand::Response(count) = command else { + return Ok(records); + }; + let count = restls_response_repeat_count(count, resumed_pending_write); + for _ in 0..count { + records.extend(self.write(RESTLS_RANDOM_RESPONSE_MAGIC)?.records); + } + Ok(records) + } + + fn resume_pending_write(&mut self) -> Result { + self.write_pending = false; + self.write(&[]) + } + + fn read_to_client_record(&mut self, record: &[u8]) -> Result { + let frame = self.codec.decode_to_client_record(record)?; + let mut records = Vec::new(); + let mut resumed_pending_write = false; + if self.write_pending { + let resumed = self.resume_pending_write()?; + resumed_pending_write = !resumed.records.is_empty(); + records.extend(resumed.records); + } + records.extend(self.receive_command(frame.command, resumed_pending_write)?); + Ok(RestlsStreamReadResult { + data: frame.data, + records, + resumed_pending_write, + }) + } +} + +fn restls_response_repeat_count(count: u16, resumed_pending_write: bool) -> usize { + let mut count = (count as u8) as i8; + if resumed_pending_write { + count = count.wrapping_sub(1); + } + if count <= 0 { + 0 + } else { + count as usize + } +} + +#[allow(dead_code)] +fn xor_with_restls_mask(data: &mut [u8], mask: &[u8]) { + for (byte, mask) in data.iter_mut().zip(mask.iter()) { + *byte ^= *mask; + } +} + +fn plugin_bool_opt(opts: &HashMap, key: &str) -> Option { + opts.get(key).map(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" | "on" + ) + }) +} + +fn plugin_u8_opt(opts: &HashMap, key: &str) -> Option { + opts.get(key) + .and_then(|value| value.trim().parse::().ok()) +} + +fn plugin_u16_opt(opts: &HashMap, key: &str) -> Option { + opts.get(key) + .and_then(|value| value.trim().parse::().ok()) +} + +fn plugin_u32_opt(opts: &HashMap, key: &str) -> Option { + opts.get(key) + .and_then(|value| value.trim().parse::().ok()) +} + +fn plugin_u64_opt(opts: &HashMap, key: &str) -> Option { + opts.get(key) + .and_then(|value| value.trim().parse::().ok()) +} + +fn plugin_usize_opt(opts: &HashMap, key: &str) -> Option { + opts.get(key) + .and_then(|value| value.trim().parse::().ok()) +} + +fn plugin_i32_opt(opts: &HashMap, key: &str) -> Option { + opts.get(key) + .and_then(|value| value.trim().parse::().ok()) +} + +fn plugin_list_opt(opts: &HashMap, key: &str) -> Option> { + opts.get(key).map(|value| { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() + }) +} + +fn plugin_tls_client_identity(opts: &HashMap) -> Result> { + let certificate = opts + .get("certificate") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + let private_key = opts + .get("private-key") + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + match (certificate, private_key) { + (None, None) => Ok(None), + (Some(certificate), Some(private_key)) => Ok(Some(TlsClientIdentity { + certificate: certificate.to_owned(), + private_key: private_key.to_owned(), + })), + _ => bail!("Shadowsocks TLS plugin certificate and private-key must be provided together"), + } +} + +fn plugin_ech_config(opts: &HashMap) -> Result> { + let enabled = plugin_bool_opt(opts, "ech-opts.enable") + .or_else(|| plugin_bool_opt(opts, "ech.enable")) + .unwrap_or(false); + if !enabled { + return Ok(None); + } + + let config_list = opts + .get("ech-opts.config") + .or_else(|| opts.get("ech.config")) + .map(|value| { + BASE64_STANDARD + .decode(value.trim()) + .context("failed to decode Shadowsocks websocket ECH config") + }) + .transpose()?; + let query_server_name = opts + .get("ech-opts.query-server-name") + .or_else(|| opts.get("ech.query-server-name")) + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + + Ok(Some(ShadowsocksEchConfig { + config_list, + query_server_name, + })) +} + +fn plugin_headers(opts: &HashMap) -> Vec<(String, String)> { + opts.iter() + .filter_map(|(key, value)| { + key.strip_prefix("headers.") + .or_else(|| key.strip_prefix("header.")) + .map(|header| (header.to_owned(), value.to_owned())) + }) + .collect() +} + +fn simple_obfs_http_request(host: &str, port: u16, payload: &[u8]) -> Vec { + let mut websocket_key = [0u8; 16]; + OsRng.fill_bytes(&mut websocket_key); + let user_agent_minor = OsRng.next_u32() % 54; + let user_agent_patch = OsRng.next_u32() % 2; + let request_host = if port == 80 { + host.to_owned() + } else { + format!("{host}:{port}") + }; + let mut request = Vec::new(); + request.extend_from_slice(format!("GET http://{host}/ HTTP/1.1\r\n").as_bytes()); + request.extend_from_slice(format!("Host: {request_host}\r\n").as_bytes()); + request.extend_from_slice( + format!("User-Agent: curl/7.{user_agent_minor}.{user_agent_patch}\r\n").as_bytes(), + ); + request.extend_from_slice(b"Upgrade: websocket\r\n"); + request.extend_from_slice(b"Connection: Upgrade\r\n"); + request.extend_from_slice( + format!( + "Sec-WebSocket-Key: {}\r\n", + BASE64_URL_SAFE.encode(websocket_key) + ) + .as_bytes(), + ); + request.extend_from_slice(format!("Content-Length: {}\r\n\r\n", payload.len()).as_bytes()); + request.extend_from_slice(payload); + request +} + +fn simple_obfs_tls_record(payload: &[u8]) -> io::Result> { + if payload.len() > u16::MAX as usize { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS payload is too large", + )); + } + let mut record = Vec::with_capacity(5 + payload.len()); + record.extend_from_slice(&[0x17, 0x03, 0x03]); + record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + record.extend_from_slice(payload); + Ok(record) +} + +fn shadow_tls_v2_record( + payload: &[u8], + len: usize, + first_write_hmac: Option<[u8; 8]>, +) -> io::Result> { + if len > SHADOW_TLS_MAX_RECORD_PAYLOAD || len > payload.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ShadowTLS v2 payload is too large", + )); + } + let prefix_len = first_write_hmac.as_ref().map(|_| 8).unwrap_or(0); + let record_len = prefix_len + len; + let mut record = Vec::with_capacity(5 + record_len); + record.extend_from_slice(&[0x17, 0x03, 0x03]); + record.extend_from_slice(&(record_len as u16).to_be_bytes()); + if let Some(prefix) = first_write_hmac { + record.extend_from_slice(&prefix); + } + record.extend_from_slice(&payload[..len]); + Ok(record) +} + +fn shadow_tls_v3_record( + payload: &[u8], + len: usize, + hmac_add: &mut hmac::Context, +) -> io::Result> { + if len > SHADOW_TLS_MAX_RECORD_PAYLOAD || len > payload.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "ShadowTLS v3 payload is too large", + )); + } + let payload = &payload[..len]; + let mut record = Vec::with_capacity(SHADOW_TLS_V3_HMAC_HEADER_SIZE + payload.len()); + record.extend_from_slice(&[0x17, 0x03, 0x03]); + record.extend_from_slice(&((SHADOW_TLS_V3_HMAC_SIZE + payload.len()) as u16).to_be_bytes()); + hmac_add.update(payload); + let hmac_prefix = shadow_tls_v3_hmac_prefix(hmac_add, SHADOW_TLS_V3_HMAC_SIZE); + hmac_add.update(&hmac_prefix); + record.extend_from_slice(&hmac_prefix); + record.extend_from_slice(payload); + Ok(record) +} + +fn shadow_tls_v3_generate_session_id(password: &str, client_hello: &[u8]) -> [u8; 32] { + let mut session_id = [0u8; SHADOW_TLS_V3_SESSION_ID_SIZE]; + OsRng.fill_bytes(&mut session_id[..SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE]); + if client_hello.len() >= SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE { + let mut context = shadow_tls_v3_hmac(password, b"", b""); + context.update(&client_hello[..SHADOW_TLS_V3_SESSION_ID_START]); + context.update(&session_id); + context.update( + &client_hello[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..], + ); + let prefix = shadow_tls_v3_hmac_prefix(&context, SHADOW_TLS_V3_HMAC_SIZE); + session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..] + .copy_from_slice(&prefix); + } + session_id +} + +#[allow(dead_code)] +fn shadow_tls_v3_sign_client_hello_record( + password: &str, + bytes: &[u8], +) -> io::Result>> { + if bytes.len() < SHADOW_TLS_V3_TLS_HEADER_SIZE { + return Ok(None); + } + if bytes[0] != 0x16 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ShadowTLS v3 first TLS record is not a handshake record", + )); + } + let payload_len = u16::from_be_bytes([bytes[3], bytes[4]]) as usize; + let record_len = SHADOW_TLS_V3_TLS_HEADER_SIZE + payload_len; + if bytes.len() < record_len { + return Ok(None); + } + let payload = &bytes[SHADOW_TLS_V3_TLS_HEADER_SIZE..record_len]; + if payload.first().copied() != Some(0x01) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ShadowTLS v3 first handshake message is not ClientHello", + )); + } + let session_id_length_index = SHADOW_TLS_V3_SESSION_ID_START - 1; + if payload.get(session_id_length_index).copied() != Some(SHADOW_TLS_V3_SESSION_ID_SIZE as u8) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ShadowTLS v3 browser ClientHello did not reserve a 32-byte session ID", + )); + } + if payload.len() < SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "ShadowTLS v3 browser ClientHello session ID is truncated", + )); + } + let session_id = shadow_tls_v3_generate_session_id(password, payload); + let mut signed = bytes.to_vec(); + let session_id_start = SHADOW_TLS_V3_TLS_HEADER_SIZE + SHADOW_TLS_V3_SESSION_ID_START; + signed[session_id_start..session_id_start + SHADOW_TLS_V3_SESSION_ID_SIZE] + .copy_from_slice(&session_id); + Ok(Some(signed)) +} + +fn shadow_tls_v3_hmac(password: &str, first: &[u8], second: &[u8]) -> hmac::Context { + let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, password.as_bytes()); + let mut context = hmac::Context::with_key(&key); + context.update(first); + context.update(second); + context +} + +fn shadow_tls_v3_hmac_prefix(context: &hmac::Context, len: usize) -> Vec { + let digest = context.clone().sign(); + digest.as_ref()[..len].to_vec() +} + +fn shadow_tls_v3_application_data_hmac(frame: &[u8], context: &hmac::Context) -> Option> { + if frame.len() < SHADOW_TLS_V3_HMAC_HEADER_SIZE + || frame[0] != 0x17 + || frame[1] != 0x03 + || frame[2] != 0x03 + { + return None; + } + let mut candidate = context.clone(); + candidate.update(&frame[SHADOW_TLS_V3_HMAC_HEADER_SIZE..]); + Some(shadow_tls_v3_hmac_prefix( + &candidate, + SHADOW_TLS_V3_HMAC_SIZE, + )) +} + +fn shadow_tls_v3_kdf( + password: &str, + server_random: &[u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE], +) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + hasher.update(server_random); + hasher.finalize().into() +} + +fn shadow_tls_v3_xor(data: &mut [u8], key: &[u8; 32]) { + for (index, value) in data.iter_mut().enumerate() { + *value ^= key[index % key.len()]; + } +} + +fn shadow_tls_v3_server_hello_supports_tls13(frame: &[u8]) -> bool { + if frame.len() < SHADOW_TLS_V3_SESSION_ID_LENGTH_INDEX { + return false; + } + let mut index = SHADOW_TLS_V3_SESSION_ID_LENGTH_INDEX; + let Some(session_id_len) = frame.get(index).copied().map(usize::from) else { + return false; + }; + index += 1 + session_id_len; + if frame.len() < index + 3 { + return false; + } + index += 3; + if frame.len() < index + 2 { + return false; + } + let extensions_len = u16::from_be_bytes([frame[index], frame[index + 1]]) as usize; + index += 2; + let end = index.saturating_add(extensions_len).min(frame.len()); + while index + 4 <= end { + let extension_type = u16::from_be_bytes([frame[index], frame[index + 1]]); + let extension_len = u16::from_be_bytes([frame[index + 2], frame[index + 3]]) as usize; + index += 4; + if index + extension_len > end { + return false; + } + if extension_type == 43 { + return extension_len == 2 + && u16::from_be_bytes([frame[index], frame[index + 1]]) == 0x0304; + } + index += extension_len; + } + false +} + +fn simple_obfs_tls_client_hello(server: &str, payload: &[u8]) -> io::Result> { + let payload_len = u16::try_from(payload.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello payload is too large", + ) + })?; + let server_len = u16::try_from(server.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello server name is too large", + ) + })?; + let record_len = 212u16 + .checked_add(payload_len) + .and_then(|len| len.checked_add(server_len)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello is too large", + ) + })?; + let handshake_len = 208u16 + .checked_add(payload_len) + .and_then(|len| len.checked_add(server_len)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello is too large", + ) + })?; + let extension_len = 79u16 + .checked_add(payload_len) + .and_then(|len| len.checked_add(server_len)) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello is too large", + ) + })?; + let sni_ext_len = server_len.checked_add(5).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello server name is too large", + ) + })?; + let sni_list_len = server_len.checked_add(3).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello server name is too large", + ) + })?; + if server_len == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "simple-obfs TLS client hello server name is empty", + )); + } + let mut random = [0u8; 28]; + let mut session_id = [0u8; 32]; + OsRng.fill_bytes(&mut random); + OsRng.fill_bytes(&mut session_id); + + let mut hello = Vec::with_capacity(217 + server.len() + payload.len()); + hello.push(22); + hello.extend_from_slice(&[0x03, 0x01]); + hello.extend_from_slice(&record_len.to_be_bytes()); + hello.push(1); + hello.push(0); + hello.extend_from_slice(&handshake_len.to_be_bytes()); + hello.extend_from_slice(&[0x03, 0x03]); + hello.extend_from_slice(&(aead2022_now_timestamp().unwrap_or(0) as u32).to_be_bytes()); + hello.extend_from_slice(&random); + hello.push(32); + hello.extend_from_slice(&session_id); + hello.extend_from_slice(&[0x00, 0x38]); + hello.extend_from_slice(&[ + 0xc0, 0x2c, 0xc0, 0x30, 0x00, 0x9f, 0xcc, 0xa9, 0xcc, 0xa8, 0xcc, 0xaa, 0xc0, 0x2b, 0xc0, + 0x2f, 0x00, 0x9e, 0xc0, 0x24, 0xc0, 0x28, 0x00, 0x6b, 0xc0, 0x23, 0xc0, 0x27, 0x00, 0x67, + 0xc0, 0x0a, 0xc0, 0x14, 0x00, 0x39, 0xc0, 0x09, 0xc0, 0x13, 0x00, 0x33, 0x00, 0x9d, 0x00, + 0x9c, 0x00, 0x3d, 0x00, 0x3c, 0x00, 0x35, 0x00, 0x2f, 0x00, 0xff, + ]); + hello.extend_from_slice(&[0x01, 0x00]); + hello.extend_from_slice(&extension_len.to_be_bytes()); + hello.extend_from_slice(&[0x00, 0x23]); + hello.extend_from_slice(&payload_len.to_be_bytes()); + hello.extend_from_slice(payload); + hello.extend_from_slice(&[0x00, 0x00]); + hello.extend_from_slice(&sni_ext_len.to_be_bytes()); + hello.extend_from_slice(&sni_list_len.to_be_bytes()); + hello.push(0); + hello.extend_from_slice(&server_len.to_be_bytes()); + hello.extend_from_slice(server.as_bytes()); + hello.extend_from_slice(&[0x00, 0x0b, 0x00, 0x04, 0x03, 0x01, 0x00, 0x02]); + hello.extend_from_slice(&[ + 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x19, 0x00, 0x18, + ]); + hello.extend_from_slice(&[ + 0x00, 0x0d, 0x00, 0x20, 0x00, 0x1e, 0x06, 0x01, 0x06, 0x02, 0x06, 0x03, 0x05, 0x01, 0x05, + 0x02, 0x05, 0x03, 0x04, 0x01, 0x04, 0x02, 0x04, 0x03, 0x03, 0x01, 0x03, 0x02, 0x03, 0x03, + 0x02, 0x01, 0x02, 0x02, 0x02, 0x03, + ]); + hello.extend_from_slice(&[0x00, 0x16, 0x00, 0x00]); + hello.extend_from_slice(&[0x00, 0x17, 0x00, 0x00]); + Ok(hello) +} diff --git a/burrow/src/proxy_runtime/tests.rs b/burrow/src/proxy_runtime/tests.rs new file mode 100644 index 0000000..9eb046b --- /dev/null +++ b/burrow/src/proxy_runtime/tests.rs @@ -0,0 +1,3677 @@ +#[cfg(test)] +mod tests { + use super::*; + + async fn read_test_tls_record(stream: &mut S) -> Vec + where + S: AsyncRead + Unpin, + { + let mut header = [0u8; TLS_RECORD_HEADER_LEN]; + stream.read_exact(&mut header).await.unwrap(); + let payload_len = usize::from(u16::from_be_bytes([header[3], header[4]])); + let mut record = header.to_vec(); + record.resize(TLS_RECORD_HEADER_LEN + payload_len, 0); + stream + .read_exact(&mut record[TLS_RECORD_HEADER_LEN..]) + .await + .unwrap(); + record + } + + #[test] + fn trojan_hash_is_sha224_hex() { + assert_eq!( + trojan_password_hash("password"), + "d63dc919e201d7bc4c825630d2cf25fdc93d4b2f0d46706d29038d01" + ); + } + + #[test] + fn socks_addr_round_trips_ipv4_and_ipv6() { + for addr in [ + "1.2.3.4:53".parse::().unwrap(), + "[2001:db8::1]:443".parse::().unwrap(), + ] { + let encoded = socks_addr(addr).unwrap(); + let (decoded, used) = parse_socks_addr(&encoded).unwrap(); + assert_eq!(decoded, addr); + assert_eq!(used, encoded.len()); + } + } + + #[test] + fn shadowsocks_runtime_accepts_expanded_cipher_families() { + for cipher in [ + "none", + "plain", + "table", + "rc4-md5", + "rc4", + "aes-128-ctr", + "aes-192-ctr", + "aes-256-ctr", + "aes-128-cfb", + "aes-128-cfb1", + "aes-128-cfb8", + "aes-128-cfb128", + "aes-192-cfb", + "aes-192-cfb1", + "aes-192-cfb8", + "aes-192-cfb128", + "aes-256-cfb", + "aes-256-cfb1", + "aes-256-cfb8", + "aes-256-cfb128", + "aes-128-ofb", + "aes-192-ofb", + "aes-256-ofb", + "camellia-128-ctr", + "camellia-192-ctr", + "camellia-256-ctr", + "camellia-128-cfb", + "camellia-128-cfb1", + "camellia-128-cfb8", + "camellia-128-cfb128", + "camellia-192-cfb", + "camellia-192-cfb1", + "camellia-192-cfb8", + "camellia-192-cfb128", + "camellia-256-cfb", + "camellia-256-cfb1", + "camellia-256-cfb8", + "camellia-256-cfb128", + "camellia-128-ofb", + "camellia-192-ofb", + "camellia-256-ofb", + "chacha20", + "chacha20-ietf", + "xchacha20", + "aes-128-gcm", + "aes-192-gcm", + "aes-256-gcm", + "aes-128-ccm", + "aes-192-ccm", + "aes-256-ccm", + "aes-128-gcm-siv", + "aes-256-gcm-siv", + "chacha8-ietf-poly1305", + "chacha20-ietf-poly1305", + "rabbit128-poly1305", + "xchacha8-ietf-poly1305", + "xchacha20-ietf-poly1305", + "sm4-gcm", + "sm4-ccm", + "aegis-128l", + "aegis-256", + "aez-384", + "deoxys-ii-256-128", + "ascon128", + "ascon128a", + "lea-128-gcm", + "lea-192-gcm", + "lea-256-gcm", + "2022-blake3-aes-128-gcm", + "2022-blake3-aes-256-gcm", + "2022-blake3-aes-128-ccm", + "2022-blake3-aes-256-ccm", + "2022-blake3-chacha20-poly1305", + "2022-blake3-chacha8-poly1305", + ] { + validate_shadowsocks_cipher(cipher) + .unwrap_or_else(|err| panic!("expected {cipher} to be supported: {err:#}")); + } + assert!(validate_shadowsocks_cipher("not-a-real-cipher").is_err()); + } + + #[test] + fn shadowsocks_runtime_has_no_known_mihomo_cipher_gaps() { + let mihomo_only_ciphers: [&str; 0] = []; + assert!(mihomo_only_ciphers + .iter() + .all(|cipher| validate_shadowsocks_cipher(cipher).is_ok())); + } + + #[test] + fn shadowsocks_none_cipher_builds_runtime_outbound() { + let node = ProxyNode { + ordinal: 0, + name: "ss-none".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "none".to_owned(), + password: "unused".to_owned(), + udp: true, + udp_over_tcp: true, + udp_over_tcp_version: UOT_LEGACY_VERSION, + client_fingerprint: None, + plugin: None, + smux: None, + }), + }; + + assert!(runtime_support_error(&node).is_none()); + let ProxyNodeConfig::Shadowsocks(config) = &node.config else { + panic!("expected Shadowsocks config"); + }; + let outbound = ShadowsocksOutbound::from_node(&node, config).unwrap(); + assert!(matches!(&outbound.protocol, ShadowsocksProtocol::None)); + assert!(outbound.udp_enabled); + assert!(outbound.udp_over_tcp); + } + + #[test] + fn shadowsocks_runtime_keeps_legacy_cipher_aliases() { + for cipher in [ + "aead_aes_128_gcm", + "aead_aes_256_gcm", + "chacha20-poly1305", + "aead_chacha20_poly1305", + ] { + shadowsocks_cipher_kind(cipher) + .unwrap_or_else(|err| panic!("expected {cipher} alias to be supported: {err:#}")); + } + } + + #[test] + fn shadowsocks_runtime_accepts_udp_over_tcp_nodes() { + let payload = ProxySubscriptionPayload { + name: "sub".to_owned(), + source: crate::proxy_subscription::ProxySubscriptionSource { + url: "https://sub.example/path".to_owned(), + }, + detected_format: crate::proxy_subscription::ProxySubscriptionFormat::ClashYaml, + selected_ordinal: Some(0), + selected_name: None, + nodes: vec![ProxyNode { + ordinal: 0, + name: "ss-uot".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "pass".to_owned(), + udp: true, + udp_over_tcp: true, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: None, + }), + }], + warnings: Vec::new(), + }; + + assert_eq!(selected_node(&payload).unwrap().name, "ss-uot"); + assert!(normalize_uot_version(0).is_ok()); + assert!(normalize_uot_version(UOT_LEGACY_VERSION).is_ok()); + assert!(normalize_uot_version(UOT_VERSION).is_ok()); + assert!(normalize_uot_version(3).is_err()); + } + + #[test] + fn runtime_support_accepts_tcp_only_shadowsocks_smux() { + let mut node = ProxyNode { + ordinal: 0, + name: "ss-smux".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "pass".to_owned(), + udp: true, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: Some(crate::proxy_subscription::ShadowsocksSmux { + enabled: true, + protocol: Some("smux".to_owned()), + max_connections: Some(4), + min_streams: Some(2), + max_streams: None, + padding: false, + statistic: false, + only_tcp: true, + brutal: None, + }), + }), + }; + + assert!(runtime_support_error(&node).is_none()); + let ProxyNodeConfig::Shadowsocks(config) = &node.config else { + panic!("expected Shadowsocks node"); + }; + let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); + let transport = + shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); + assert_eq!( + transport, + Some(ShadowsocksSingMuxTransport { + protocol: ShadowsocksSingMuxProtocol::Smux, + max_connections: 4, + min_streams: 2, + max_streams: 0, + padding: false, + udp: false, + brutal: None, + }) + ); + + if let ProxyNodeConfig::Shadowsocks(config) = &mut node.config { + config.udp = true; + config.smux.as_mut().unwrap().only_tcp = false; + } + assert!(runtime_support_error(&node).is_none()); + let ProxyNodeConfig::Shadowsocks(config) = &node.config else { + panic!("expected Shadowsocks node"); + }; + let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); + let transport = + shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); + assert_eq!( + transport, + Some(ShadowsocksSingMuxTransport { + protocol: ShadowsocksSingMuxProtocol::Smux, + max_connections: 4, + min_streams: 2, + max_streams: 0, + padding: false, + udp: true, + brutal: None, + }) + ); + } + + #[test] + fn runtime_support_accepts_default_shadowsocks_h2mux() { + let node = ProxyNode { + ordinal: 0, + name: "ss-h2mux".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "pass".to_owned(), + udp: true, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: Some(crate::proxy_subscription::ShadowsocksSmux { + enabled: true, + protocol: None, + max_connections: Some(4), + min_streams: Some(2), + max_streams: None, + padding: false, + statistic: false, + only_tcp: true, + brutal: None, + }), + }), + }; + + assert!(runtime_support_error(&node).is_none()); + let ProxyNodeConfig::Shadowsocks(config) = &node.config else { + panic!("expected Shadowsocks node"); + }; + let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); + let transport = + shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); + assert_eq!( + transport, + Some(ShadowsocksSingMuxTransport { + protocol: ShadowsocksSingMuxProtocol::H2Mux, + max_connections: 4, + min_streams: 2, + max_streams: 0, + padding: false, + udp: false, + brutal: None, + }) + ); + } + + #[test] + fn runtime_support_accepts_padded_shadowsocks_smux_udp_when_base_udp_is_false() { + let node = ProxyNode { + ordinal: 0, + name: "ss-smux-padding".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "pass".to_owned(), + udp: false, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: Some(crate::proxy_subscription::ShadowsocksSmux { + enabled: true, + protocol: Some("smux".to_owned()), + max_connections: Some(4), + min_streams: Some(2), + max_streams: None, + padding: true, + statistic: false, + only_tcp: false, + brutal: None, + }), + }), + }; + + assert!(runtime_support_error(&node).is_none()); + let ProxyNodeConfig::Shadowsocks(config) = &node.config else { + panic!("expected Shadowsocks node"); + }; + let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); + let transport = + shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); + assert_eq!( + transport, + Some(ShadowsocksSingMuxTransport { + protocol: ShadowsocksSingMuxProtocol::Smux, + max_connections: 4, + min_streams: 2, + max_streams: 0, + padding: true, + udp: true, + brutal: None, + }) + ); + + let outbound = ShadowsocksOutbound::from_node(&node, config).unwrap(); + assert!( + outbound.udp_enabled, + "Mihomo sing-mux supports UDP unless only-tcp is set, even when the underlying Shadowsocks udp flag is false" + ); + } + + #[test] + fn runtime_support_accepts_brutal_shadowsocks_smux() { + let node = ProxyNode { + ordinal: 0, + name: "ss-smux-brutal".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "pass".to_owned(), + udp: true, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: Some(crate::proxy_subscription::ShadowsocksSmux { + enabled: true, + protocol: Some("smux".to_owned()), + max_connections: Some(4), + min_streams: Some(2), + max_streams: None, + padding: false, + statistic: false, + only_tcp: false, + brutal: Some(crate::proxy_subscription::ShadowsocksSmuxBrutal { + enabled: true, + up: Some("10 Mbps".to_owned()), + down: Some("20 Mbps".to_owned()), + }), + }), + }), + }; + + assert!(runtime_support_error(&node).is_none()); + let ProxyNodeConfig::Shadowsocks(config) = &node.config else { + panic!("expected Shadowsocks node"); + }; + let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); + let transport = + shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); + assert_eq!( + transport, + Some(ShadowsocksSingMuxTransport { + protocol: ShadowsocksSingMuxProtocol::Smux, + max_connections: 4, + min_streams: 2, + max_streams: 0, + padding: false, + udp: true, + brutal: Some(ShadowsocksSingMuxBrutalTransport { + send_bps: 1_250_000, + receive_bps: 2_500_000, + }), + }) + ); + } + + #[test] + fn runtime_support_accepts_tcp_only_shadowsocks_yamux() { + let node = ProxyNode { + ordinal: 0, + name: "ss-yamux".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "pass".to_owned(), + udp: true, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: Some(crate::proxy_subscription::ShadowsocksSmux { + enabled: true, + protocol: Some("yamux".to_owned()), + max_connections: Some(4), + min_streams: Some(2), + max_streams: None, + padding: false, + statistic: false, + only_tcp: true, + brutal: None, + }), + }), + }; + + assert!(runtime_support_error(&node).is_none()); + let ProxyNodeConfig::Shadowsocks(config) = &node.config else { + panic!("expected Shadowsocks node"); + }; + let protocol = shadowsocks_protocol_for_node(&node, config).unwrap(); + let transport = + shadowsocks_sing_mux_transport(config.smux.as_ref(), config, &protocol).unwrap(); + assert_eq!( + transport, + Some(ShadowsocksSingMuxTransport { + protocol: ShadowsocksSingMuxProtocol::Yamux, + max_connections: 4, + min_streams: 2, + max_streams: 0, + padding: false, + udp: false, + brutal: None, + }) + ); + } + + #[test] + fn runtime_support_accepts_custom_cipher_shadowsocks_smux() { + let node = ProxyNode { + ordinal: 0, + name: "ss-custom-smux".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-192-gcm".to_owned(), + password: "pass".to_owned(), + udp: false, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: Some(crate::proxy_subscription::ShadowsocksSmux { + enabled: true, + protocol: Some("smux".to_owned()), + max_connections: Some(2), + min_streams: None, + max_streams: Some(16), + padding: false, + statistic: false, + only_tcp: false, + brutal: None, + }), + }), + }; + + assert!(runtime_support_error(&node).is_none()); + } + + #[test] + fn runtime_support_accepts_aead2022_ccm_shadowsocks_smux() { + let kind = Aead2022AesCcmKind::Aes128; + let password = BASE64_STANDARD.encode(vec![3u8; kind.key_len()]); + let node = ProxyNode { + ordinal: 0, + name: "ss-2022-ccm-smux".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: kind.name().to_owned(), + password, + udp: false, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: Some(crate::proxy_subscription::ShadowsocksSmux { + enabled: true, + protocol: Some("smux".to_owned()), + max_connections: Some(2), + min_streams: None, + max_streams: Some(16), + padding: false, + statistic: false, + only_tcp: false, + brutal: None, + }), + }), + }; + + assert!(runtime_support_error(&node).is_none()); + } + + #[tokio::test] + async fn sing_mux_tcp_request_uses_mihomo_stream_header_shape() { + let (mut client, mut server) = tokio::io::duplex(64); + let target = ProxyTcpTarget { + address: "198.18.64.1:443".parse().unwrap(), + hostname: Some("example.com.".to_owned()), + }; + + let writer = + tokio::spawn(async move { write_sing_mux_tcp_request(&mut client, &target).await }); + let mut request = vec![0u8; 2 + 1 + 1 + "example.com".len() + 2]; + server.read_exact(&mut request).await.unwrap(); + writer.await.unwrap().unwrap(); + + let mut expected = vec![0, 0, 3, "example.com".len() as u8]; + expected.extend_from_slice(b"example.com"); + expected.extend_from_slice(&443u16.to_be_bytes()); + assert_eq!(request, expected); + } + + #[tokio::test] + async fn sing_mux_udp_request_uses_mihomo_packet_header_shape() { + let (mut client, mut server) = tokio::io::duplex(64); + let target: SocketAddr = "198.18.64.1:53".parse().unwrap(); + let writer = + tokio::spawn( + async move { write_sing_mux_udp_request(&mut client, target, b"query").await }, + ); + let mut request = vec![0u8; 2 + 1 + 4 + 2 + 2 + "query".len()]; + server.read_exact(&mut request).await.unwrap(); + writer.await.unwrap().unwrap(); + + let mut expected = vec![0, SING_MUX_STREAM_FLAG_UDP as u8, 1, 198, 18, 64, 1]; + expected.extend_from_slice(&53u16.to_be_bytes()); + expected.extend_from_slice(&5u16.to_be_bytes()); + expected.extend_from_slice(b"query"); + assert_eq!(request, expected); + } + + #[tokio::test] + async fn sing_mux_udp_payload_reads_status_then_length_prefixed_packet() { + let (mut client, mut server) = tokio::io::duplex(64); + let writer = tokio::spawn(async move { + server + .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS]) + .await + .unwrap(); + server.write_all(&4u16.to_be_bytes()).await.unwrap(); + server.write_all(b"pong").await.unwrap(); + }); + let mut response_read = false; + let payload = read_sing_mux_udp_payload(&mut client, &mut response_read) + .await + .unwrap(); + writer.await.unwrap(); + assert!(response_read); + assert_eq!(&payload, b"pong"); + } + + #[test] + fn mihomo_bps_parser_matches_decimal_rate_strings() { + assert_eq!(mihomo_string_to_bps(""), 0); + assert_eq!(mihomo_string_to_bps("10"), 1_250_000); + assert_eq!(mihomo_string_to_bps("10 Mbps"), 1_250_000); + assert_eq!(mihomo_string_to_bps("10Mbps"), 1_250_000); + assert_eq!(mihomo_string_to_bps("10 MBps"), 10_000_000); + assert_eq!(mihomo_string_to_bps("1 Kbps"), 125); + assert_eq!(mihomo_string_to_bps("not-a-rate"), 0); + } + + #[tokio::test] + async fn sing_mux_brutal_request_uses_mihomo_exchange_shape() { + let (mut client, mut server) = tokio::io::duplex(128); + let writer = + tokio::spawn( + async move { write_sing_mux_brutal_request(&mut client, 2_500_000).await }, + ); + let mut request = vec![0u8; 2 + 1 + 1 + SING_MUX_BRUTAL_EXCHANGE_DOMAIN.len() + 2 + 8]; + server.read_exact(&mut request).await.unwrap(); + writer.await.unwrap().unwrap(); + + let mut expected = vec![0, 0, 3, SING_MUX_BRUTAL_EXCHANGE_DOMAIN.len() as u8]; + expected.extend_from_slice(SING_MUX_BRUTAL_EXCHANGE_DOMAIN.as_bytes()); + expected.extend_from_slice(&0u16.to_be_bytes()); + expected.extend_from_slice(&2_500_000u64.to_be_bytes()); + assert_eq!(request, expected); + } + + #[tokio::test] + async fn sing_mux_brutal_response_reads_stream_status_then_server_rate() { + let (mut client, mut server) = tokio::io::duplex(64); + let writer = tokio::spawn(async move { + server + .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS, 1]) + .await + .unwrap(); + server.write_all(&3_000_000u64.to_be_bytes()).await.unwrap(); + }); + let receive_bps = read_sing_mux_brutal_response(&mut client).await.unwrap(); + writer.await.unwrap(); + assert_eq!(receive_bps, 3_000_000); + + let (mut client, mut server) = tokio::io::duplex(64); + let writer = tokio::spawn(async move { + server + .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS, 0, 4]) + .await + .unwrap(); + server.write_all(b"oops").await.unwrap(); + }); + let err = read_sing_mux_brutal_response(&mut client) + .await + .unwrap_err(); + writer.await.unwrap(); + assert!(err.to_string().contains("oops"), "{err}"); + } + + #[tokio::test] + async fn sing_mux_session_preface_uses_mihomo_padding_shape() { + let (mut client, mut server) = tokio::io::duplex(1024); + let writer = tokio::spawn(async move { + write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::Smux, false) + .await + }); + let mut plain = [0u8; 2]; + server.read_exact(&mut plain).await.unwrap(); + writer.await.unwrap().unwrap(); + assert_eq!(plain, [SING_MUX_VERSION_0, SING_MUX_PROTOCOL_SMUX]); + + let (mut client, mut server) = tokio::io::duplex(1024); + let writer = tokio::spawn(async move { + write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::Yamux, false) + .await + }); + let mut plain = [0u8; 2]; + server.read_exact(&mut plain).await.unwrap(); + writer.await.unwrap().unwrap(); + assert_eq!(plain, [SING_MUX_VERSION_0, SING_MUX_PROTOCOL_YAMUX]); + + let (mut client, mut server) = tokio::io::duplex(1024); + let writer = tokio::spawn(async move { + write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::H2Mux, false) + .await + }); + let mut plain = [0u8; 2]; + server.read_exact(&mut plain).await.unwrap(); + writer.await.unwrap().unwrap(); + assert_eq!(plain, [SING_MUX_VERSION_0, SING_MUX_PROTOCOL_H2MUX]); + + let (mut client, mut server) = tokio::io::duplex(2048); + let writer = tokio::spawn(async move { + write_sing_mux_session_preface(&mut client, ShadowsocksSingMuxProtocol::Smux, true) + .await + }); + let mut header = [0u8; 5]; + server.read_exact(&mut header).await.unwrap(); + assert_eq!( + &header[..3], + &[SING_MUX_VERSION_1, SING_MUX_PROTOCOL_SMUX, 1] + ); + let padding_len = u16::from_be_bytes([header[3], header[4]]) as usize; + assert!(padding_len >= SING_MUX_MIN_PADDING_LEN); + assert!(padding_len < SING_MUX_MIN_PADDING_LEN + SING_MUX_PADDING_LEN_RANGE); + let mut padding = vec![0u8; padding_len]; + server.read_exact(&mut padding).await.unwrap(); + writer.await.unwrap().unwrap(); + assert!(padding.iter().all(|byte| *byte == 0)); + } + + #[tokio::test] + async fn sing_mux_yamux_session_opens_mihomo_style_streams() { + let (client, server) = tokio::io::duplex(4096); + let session = start_sing_mux_yamux_session(Box::new(client)) + .await + .unwrap(); + let mut server_connection = yamux::Connection::new( + server.compat(), + yamux::Config::default(), + yamux::Mode::Server, + ); + let (stream_tx, mut stream_rx) = mpsc::channel(1); + let server_driver = tokio::spawn(async move { + let mut stream_tx = Some(stream_tx); + std::future::poll_fn(move |cx| loop { + match server_connection.poll_next_inbound(cx) { + Poll::Ready(Some(Ok(stream))) => { + if let Some(sender) = stream_tx.take() { + let _ = sender.try_send(stream.compat()); + } + } + Poll::Ready(Some(Err(err))) => return Poll::Ready(Err(anyhow!(err))), + Poll::Ready(None) => return Poll::Ready(Ok(())), + Poll::Pending => return Poll::Pending, + } + }) + .await + }); + + let mut client_stream = session.open_stream().await.unwrap(); + client_stream.write_all(b"ping").await.unwrap(); + let mut server_stream = stream_rx.recv().await.unwrap(); + let mut request = [0u8; 4]; + server_stream.read_exact(&mut request).await.unwrap(); + assert_eq!(&request, b"ping"); + + drop(client_stream); + assert_eq!(session.num_streams().await, 0); + server_driver.abort(); + } + + #[tokio::test] + async fn sing_mux_h2mux_session_opens_mihomo_style_connect_streams() { + let (client, server) = tokio::io::duplex(4096); + let session = start_sing_mux_h2mux_session(Box::new(client)) + .await + .unwrap(); + let (done_tx, mut done_rx) = oneshot::channel(); + let server_driver = tokio::spawn(async move { + let mut server = h2::server::handshake(server).await?; + let Some(request) = server.accept().await else { + bail!("h2mux server closed before receiving CONNECT stream"); + }; + let (request, mut respond) = request?; + assert_eq!(request.method(), Method::CONNECT); + assert_eq!(request.uri().to_string(), "localhost"); + + let mut body = request.into_body(); + let response = http::Response::builder() + .status(StatusCode::OK) + .body(()) + .unwrap(); + let mut send = respond.send_response(response, false)?; + let data = body.data().await.unwrap()?; + body.flow_control().release_capacity(data.len())?; + assert_eq!(&data[..], b"ping"); + send.reserve_capacity(4); + std::future::poll_fn(|cx| send.poll_capacity(cx)) + .await + .context("h2mux test response stream closed before capacity")??; + send.send_data(Bytes::from_static(b"pong"), true)?; + loop { + tokio::select! { + _ = &mut done_rx => break, + accepted = server.accept() => { + if accepted.is_none() { + break; + } + } + } + } + Ok::<_, anyhow::Error>(()) + }); + + let mut client_stream = session.open_stream().await.unwrap(); + client_stream.write_all(b"ping").await.unwrap(); + let mut response = [0u8; 4]; + client_stream.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"pong"); + let _ = done_tx.send(()); + + drop(client_stream); + assert_eq!(session.num_streams().await, 0); + server_driver.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn sing_mux_padding_stream_frames_first_reads_and_writes() { + let (client, mut server) = tokio::io::duplex(4096); + let mut stream = sing_mux_padding_stream(Box::new(client)); + + stream.write_all(b"ping").await.unwrap(); + let mut header = [0u8; 4]; + server.read_exact(&mut header).await.unwrap(); + let payload_len = u16::from_be_bytes([header[0], header[1]]) as usize; + let padding_len = u16::from_be_bytes([header[2], header[3]]) as usize; + assert_eq!(payload_len, 4); + assert!(padding_len >= SING_MUX_MIN_PADDING_LEN); + assert!(padding_len < SING_MUX_MIN_PADDING_LEN + SING_MUX_PADDING_LEN_RANGE); + let mut payload = vec![0u8; payload_len]; + server.read_exact(&mut payload).await.unwrap(); + assert_eq!(&payload, b"ping"); + let mut padding = vec![0u8; padding_len]; + server.read_exact(&mut padding).await.unwrap(); + + let response_padding = SING_MUX_MIN_PADDING_LEN; + server.write_all(&4u16.to_be_bytes()).await.unwrap(); + server + .write_all(&(response_padding as u16).to_be_bytes()) + .await + .unwrap(); + server.write_all(b"pong").await.unwrap(); + server + .write_all(&vec![0u8; response_padding]) + .await + .unwrap(); + + let mut response = [0u8; 4]; + stream.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"pong"); + } + + #[tokio::test] + async fn sing_mux_stream_response_accepts_success_and_reports_remote_errors() { + let (mut client, mut server) = tokio::io::duplex(64); + let success = tokio::spawn(async move { + server + .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS]) + .await + .unwrap(); + }); + read_sing_mux_stream_response(&mut client).await.unwrap(); + success.await.unwrap(); + + let (mut client, mut server) = tokio::io::duplex(64); + let failure = tokio::spawn(async move { + server + .write_all(&[SING_MUX_STREAM_STATUS_ERROR, 4, b'o', b'o', b'p', b's']) + .await + .unwrap(); + }); + let err = read_sing_mux_stream_response(&mut client) + .await + .unwrap_err(); + failure.await.unwrap(); + assert!(err.to_string().contains("oops"), "{err}"); + } + + #[tokio::test] + async fn sing_mux_tcp_stream_reads_lazy_success_status_before_payload() { + let (client, mut server) = tokio::io::duplex(64); + let mut stream = SingMuxTcpStream::new(client); + let writer = tokio::spawn(async move { + server + .write_all(&[SING_MUX_STREAM_STATUS_SUCCESS, b'o', b'k']) + .await + .unwrap(); + }); + + let mut payload = [0u8; 2]; + stream.read_exact(&mut payload).await.unwrap(); + writer.await.unwrap(); + assert_eq!(&payload, b"ok"); + + let (client, mut server) = tokio::io::duplex(64); + let mut stream = SingMuxTcpStream::new(client); + let writer = tokio::spawn(async move { + server + .write_all(&[SING_MUX_STREAM_STATUS_ERROR]) + .await + .unwrap(); + }); + let err = stream.read_exact(&mut payload).await.unwrap_err(); + writer.await.unwrap(); + assert!(err.to_string().contains("remote error"), "{err}"); + } + + #[tokio::test] + async fn custom_aead_plain_tcp_stream_exposes_plaintext_for_sing_mux() { + let kind = CustomSsAeadKind::Aes192Gcm; + let key: Arc<[u8]> = Arc::from(vec![7u8; kind.key_len()]); + let (client_tcp, server_tcp) = tokio::io::duplex(4096); + let target = ProxyTcpTarget { + address: "198.18.64.1:443".parse().unwrap(), + hostname: Some("example.com.".to_owned()), + }; + let mut plain = custom_aead_plain_tcp_stream(client_tcp, &target, kind, key.clone()) + .await + .unwrap(); + let (server_reader, server_writer) = split(server_tcp); + let mut ss_reader = CustomSsAeadReader::new(server_reader, kind, key.clone()); + let mut ss_writer = CustomSsAeadWriter::new(server_writer, kind, key) + .await + .unwrap(); + + let mut received = Vec::new(); + ss_reader.read_chunk(&mut received).await.unwrap(); + assert_eq!(received, socks_domain_addr("example.com", 443).unwrap()); + + plain.write_all(b"ping").await.unwrap(); + received.clear(); + ss_reader.read_chunk(&mut received).await.unwrap(); + assert_eq!(received, b"ping"); + + ss_writer.write_chunk(b"pong").await.unwrap(); + let mut response = [0u8; 4]; + plain.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"pong"); + } + + #[tokio::test] + async fn custom_stream_plain_tcp_stream_exposes_plaintext_for_sing_mux() { + let kind = CustomSsStreamKind::XChacha20; + let key: Arc<[u8]> = Arc::from(vec![9u8; kind.key_len()]); + let (client_tcp, server_tcp) = tokio::io::duplex(4096); + let target = ProxyTcpTarget { + address: "198.18.64.1:443".parse().unwrap(), + hostname: Some("example.com.".to_owned()), + }; + let mut plain = custom_stream_plain_tcp_stream(client_tcp, &target, kind, key.clone()) + .await + .unwrap(); + let (server_reader, server_writer) = split(server_tcp); + let mut ss_reader = CustomSsStreamReader::new(server_reader, kind, key.clone()); + let mut ss_writer = CustomSsStreamWriter::new(server_writer, kind, key) + .await + .unwrap(); + + let mut received = vec![0u8; 64]; + let n = ss_reader.read_decrypted(&mut received).await.unwrap(); + assert_eq!( + &received[..n], + socks_domain_addr("example.com", 443).unwrap().as_slice() + ); + + plain.write_all(b"ping").await.unwrap(); + let n = ss_reader.read_decrypted(&mut received).await.unwrap(); + assert_eq!(&received[..n], b"ping"); + + ss_writer.write_all_encrypted(b"pong").await.unwrap(); + let mut response = [0u8; 4]; + plain.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"pong"); + } + + #[tokio::test] + async fn aead2022_aes_ccm_plain_tcp_stream_exposes_plaintext_for_sing_mux() { + let kind = Aead2022AesCcmKind::Aes128; + let user_key: Arc<[u8]> = Arc::from(vec![11u8; kind.key_len()]); + let identity_keys: Arc<[Arc<[u8]>]> = Arc::from(Vec::>::new()); + let (client_tcp, mut server_tcp) = tokio::io::duplex(4096); + let target = ProxyTcpTarget { + address: "198.18.64.1:443".parse().unwrap(), + hostname: Some("example.com.".to_owned()), + }; + let mut plain = aead2022_aes_ccm_plain_tcp_stream( + client_tcp, + &target, + kind, + user_key.clone(), + identity_keys, + ) + .await + .unwrap(); + + let (request_salt, mut request_cipher, variable) = + read_aead2022_tcp_request_for_test(&mut server_tcp, kind, &user_key) + .await + .unwrap(); + let expected_target = socks_domain_addr("example.com", 443).unwrap(); + assert!(variable.starts_with(&expected_target)); + let padding_len_offset = expected_target.len(); + let padding_len = u16::from_be_bytes([ + variable[padding_len_offset], + variable[padding_len_offset + 1], + ]) as usize; + assert_eq!(padding_len, 1); + + plain.write_all(b"ping").await.unwrap(); + let payload = read_aead2022_tcp_chunk_for_test(&mut server_tcp, kind, &mut request_cipher) + .await + .unwrap(); + assert_eq!(payload, b"ping"); + + let mut response_cipher = write_aead2022_tcp_response_for_test( + &mut server_tcp, + kind, + &user_key, + request_salt.as_ref(), + ) + .await + .unwrap(); + write_aead2022_tcp_chunk_for_test(&mut server_tcp, &mut response_cipher, b"pong") + .await + .unwrap(); + + let mut response = [0u8; 4]; + plain.read_exact(&mut response).await.unwrap(); + assert_eq!(&response, b"pong"); + } + + async fn read_aead2022_tcp_request_for_test( + reader: &mut R, + kind: Aead2022AesCcmKind, + user_key: &[u8], + ) -> Result<(Arc<[u8]>, Aead2022TcpCipher, Vec)> + where + R: AsyncRead + Unpin, + { + let mut salt = vec![0u8; kind.key_len()]; + reader.read_exact(&mut salt).await?; + let mut cipher = Aead2022TcpCipher::new(kind, user_key, &salt)?; + let mut encrypted_fixed = vec![0u8; 1 + 8 + 2 + kind.tag_len()]; + reader.read_exact(&mut encrypted_fixed).await?; + let fixed = cipher.open_packet(&mut encrypted_fixed)?; + assert_eq!(fixed[0], AEAD2022_HEADER_TYPE_CLIENT); + aead2022_validate_timestamp(u64::from_be_bytes( + fixed[1..9].try_into().expect("fixed timestamp"), + ))?; + let variable_len = u16::from_be_bytes([fixed[9], fixed[10]]) as usize; + let mut encrypted_variable = vec![0u8; variable_len + kind.tag_len()]; + reader.read_exact(&mut encrypted_variable).await?; + let variable = cipher.open_packet(&mut encrypted_variable)?.to_vec(); + Ok((Arc::from(salt), cipher, variable)) + } + + async fn read_aead2022_tcp_chunk_for_test( + reader: &mut R, + kind: Aead2022AesCcmKind, + cipher: &mut Aead2022TcpCipher, + ) -> Result> + where + R: AsyncRead + Unpin, + { + let mut encrypted_len = vec![0u8; 2 + kind.tag_len()]; + reader.read_exact(&mut encrypted_len).await?; + let len = cipher.open_packet(&mut encrypted_len)?; + let payload_len = u16::from_be_bytes([len[0], len[1]]) as usize; + let mut encrypted_payload = vec![0u8; payload_len + kind.tag_len()]; + reader.read_exact(&mut encrypted_payload).await?; + Ok(cipher.open_packet(&mut encrypted_payload)?.to_vec()) + } + + async fn write_aead2022_tcp_response_for_test( + writer: &mut W, + kind: Aead2022AesCcmKind, + user_key: &[u8], + request_salt: &[u8], + ) -> Result + where + W: AsyncWrite + Unpin, + { + let response_salt = vec![13u8; kind.key_len()]; + let mut cipher = Aead2022TcpCipher::new(kind, user_key, &response_salt)?; + let mut fixed = Vec::with_capacity(1 + 8 + kind.key_len() + 2); + fixed.push(AEAD2022_HEADER_TYPE_SERVER); + fixed.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); + fixed.extend_from_slice(request_salt); + fixed.extend_from_slice(&0u16.to_be_bytes()); + writer.write_all(&response_salt).await?; + writer.write_all(&cipher.seal_packet(&fixed)?).await?; + writer.flush().await?; + Ok(cipher) + } + + async fn write_aead2022_tcp_chunk_for_test( + writer: &mut W, + cipher: &mut Aead2022TcpCipher, + payload: &[u8], + ) -> Result<()> + where + W: AsyncWrite + Unpin, + { + writer + .write_all(&cipher.seal_packet(&(payload.len() as u16).to_be_bytes())?) + .await?; + writer.write_all(&cipher.seal_packet(payload)?).await?; + writer.flush().await?; + Ok(()) + } + + #[test] + fn shadowsocks_udp_over_tcp_enables_udp_session_path() { + fn shadowsocks_node( + name: &str, + udp: bool, + udp_over_tcp: bool, + plugin: Option, + ) -> ProxyNode { + ProxyNode { + ordinal: 0, + name: name.to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "pass".to_owned(), + udp, + udp_over_tcp, + udp_over_tcp_version: UOT_LEGACY_VERSION, + client_fingerprint: None, + plugin, + smux: None, + }), + } + } + + let uot_node = shadowsocks_node("ss-uot-without-native-udp", false, true, None); + let ProxyNodeConfig::Shadowsocks(config) = &uot_node.config else { + panic!("expected Shadowsocks config"); + }; + let outbound = ShadowsocksOutbound::from_node(&uot_node, config).unwrap(); + assert!(outbound.udp_enabled); + assert!(outbound.udp_over_tcp); + + let native_udp_node = shadowsocks_node("ss-native-udp-disabled", false, false, None); + let ProxyNodeConfig::Shadowsocks(config) = &native_udp_node.config else { + panic!("expected Shadowsocks config"); + }; + let outbound = ShadowsocksOutbound::from_node(&native_udp_node, config).unwrap(); + assert!(!outbound.udp_enabled); + assert!(!outbound.udp_over_tcp); + + let kcptun_node = shadowsocks_node( + "ss-kcptun-forced-uot", + false, + false, + Some(ShadowsocksPlugin { + name: "kcptun".to_owned(), + opts: HashMap::new(), + }), + ); + let ProxyNodeConfig::Shadowsocks(config) = &kcptun_node.config else { + panic!("expected Shadowsocks config"); + }; + let outbound = ShadowsocksOutbound::from_node(&kcptun_node, config).unwrap(); + assert!(outbound.udp_enabled); + assert!(outbound.udp_over_tcp); + } + + #[test] + fn runtime_support_rejects_shadowsocks_tls_client_fingerprint() { + fn tls_plugin_node(plugin: ShadowsocksPlugin) -> ProxyNode { + ProxyNode { + ordinal: 0, + name: "ss-tls-fingerprint".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "proxy.example".to_owned(), + port: 443, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher: "aes-128-gcm".to_owned(), + password: "ss-secret".to_owned(), + udp: true, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: Some("firefox".to_owned()), + plugin: Some(plugin), + smux: None, + }), + } + } + + let shadow_tls = ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("password".to_owned(), "secret".to_owned()), + ("version".to_owned(), "2".to_owned()), + ]), + }; + + #[cfg(not(feature = "boring-browser-fingerprints"))] + let rejected_nodes = [tls_plugin_node(shadow_tls.clone())]; + #[cfg(feature = "boring-browser-fingerprints")] + let rejected_nodes: [ProxyNode; 0] = []; + + for node in rejected_nodes { + let err = runtime_support_error(&node) + .expect("uTLS client-fingerprint is not runtime-supported yet"); + assert!(err.contains("client-fingerprint firefox"), "{err}"); + assert!(err.contains("browser TLS profile"), "{err}"); + } + + #[cfg(feature = "boring-browser-fingerprints")] + assert!(runtime_support_error(&tls_plugin_node(shadow_tls)).is_none()); + + let shadow_tls_v1 = tls_plugin_node(ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("version".to_owned(), "1".to_owned()), + ]), + }); + assert!(runtime_support_error(&shadow_tls_v1).is_none()); + + let mut none_node = tls_plugin_node(ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("password".to_owned(), "secret".to_owned()), + ("version".to_owned(), "2".to_owned()), + ]), + }); + if let ProxyNodeConfig::Shadowsocks(shadowsocks) = &mut none_node.config { + shadowsocks.client_fingerprint = Some("none".to_owned()); + } + assert!(runtime_support_error(&none_node).is_none()); + + let mut unknown_node = none_node; + if let ProxyNodeConfig::Shadowsocks(shadowsocks) = &mut unknown_node.config { + shadowsocks.client_fingerprint = Some("unknown-browser".to_owned()); + } + assert!(runtime_support_error(&unknown_node).is_none()); + } + + #[cfg(feature = "boring-browser-fingerprints")] + const TEST_CLIENT_CERTIFICATE_PEM: &str = r#"-----BEGIN CERTIFICATE----- +MIIDDTCCAfWgAwIBAgIUcQjiH+9SQYcKZJBHGs3/ymabzqYwDQYJKoZIhvcNAQEL +BQAwFjEUMBIGA1UEAwwLYnVycm93LXRlc3QwHhcNMjYwNjAxMTUwMjA5WhcNMjYw +NjAyMTUwMjA5WjAWMRQwEgYDVQQDDAtidXJyb3ctdGVzdDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAOahzwpBhDKatLPvsnSjIQGdb9jq34dGt5Yu2znj +PAL4aTI9mFsid5L7RlHJTV1MSRsYddrLTCchDZl46hEArRQCQRf/kGiHIqJWcYDt +7gJaQHZXNTasQ9CWEoC0X+Jqv89lf9q9caMYye9ZXICeRbDxhCrQSEfY1rxXtN3i +iqJFcxmaUdzMXjgfIyMlFCHdcYtGiITZQEVizI1EelFr0RyxDuE+9IEvokpgbI2y +PnMirWVoeOKyaWFbJyPR3qSKuDF1jYrtsluW7IkTHF0B8jvbg6IJNSV7r2JaiQhD +RrgPxvXXHvfPjoZbCqAMN6JarRnWkw58uuNCr+3OxZygUkECAwEAAaNTMFEwHQYD +VR0OBBYEFI2Pv9COWSRHYwScAy17EP8MUr6uMB8GA1UdIwQYMBaAFI2Pv9COWSRH +YwScAy17EP8MUr6uMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB +AHgOvphtyX9CO/ibtA8KKcH9puWeV7CJLoqHW9XU5ZvdawI01IEdn8oIn5IAzurj +X7DAqaBUvzc58EB9cmnGnGhZZJMHBE2PVlxea1EcB5geWV+y6lIM7peqP+XGsAJo +32pUr2W+Sl3IMf6FkXy4VneZCkGlOgnxrEB9ULm1foLGQtTkXTT3dqT098Gqs3ip +HC37IQ6Bqg3/TseRUpzAJ8RVV8qvyJlYvrPvYqYRMl8xHRS2Gj5q2FvxuKlCWP/t +3oXNpvfjecQFMpghL3BcMoQUq2kMLwOz5sAUFxjP6pQf6K1kB98u1NgkzF8VXgUU +0dEYKIH7JFYVNeMzscI+9cM= +-----END CERTIFICATE-----"#; + + #[cfg(feature = "boring-browser-fingerprints")] + const TEST_CLIENT_PRIVATE_KEY_PEM: &str = r#"-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDmoc8KQYQymrSz +77J0oyEBnW/Y6t+HRreWLts54zwC+GkyPZhbIneS+0ZRyU1dTEkbGHXay0wnIQ2Z +eOoRAK0UAkEX/5BohyKiVnGA7e4CWkB2VzU2rEPQlhKAtF/iar/PZX/avXGjGMnv +WVyAnkWw8YQq0EhH2Na8V7Td4oqiRXMZmlHczF44HyMjJRQh3XGLRoiE2UBFYsyN +RHpRa9EcsQ7hPvSBL6JKYGyNsj5zIq1laHjismlhWycj0d6kirgxdY2K7bJbluyJ +ExxdAfI724OiCTUle69iWokIQ0a4D8b11x73z46GWwqgDDeiWq0Z1pMOfLrjQq/t +zsWcoFJBAgMBAAECggEADce+8wTCzTKWN9F2JGb3hWeunlORAz2z9tAaPuViGWar +WRpPZ1PyDhZfzL+OP6lbuDfoGX3Kho9V3ITaLiWQBzaH732l12u0+OXZBqmbbjON ++iQwJJylY/9biqonDS8bV9003FEzytl5KLPPsEsErKkO9cSX1Qcn9Cf6FsSUS6ll +SIq5iKPYtFIKOV7U8Bu1MqiMx9eKIpCcHVKNlP4uConpsq/TPc0pouSIkT8okdt0 +JxAAvNo+YvqkqbK7+t5rCvaG8csT+Uw6osa0GcupKmXWbVbIeMWjdHJNqBfinmRF +U62sGDzkVDdj1HroBNf9Ns9sovET29UgX5z6oj9WuwKBgQD1OTsDgrfkoWaXQuJR +8Vm5SILE+vKWlAXAyJrN6Dp+jbUNaV98kYWziTg4ngftjBPjUf0PYyZUmYo55+Os +LALCEjc7axgFSAjIvlH+WlfLChAGOAcOyLA1QzSn1G6f31OlMUFu9GRmGsZf5Ufc +wMn/MKQbAxTrOtmYyUmxztYnRwKBgQDwxGx2odDVD2NnE03427wQfnKETkS31DQb +7mp7uZDVg21Dr+5V0tmuxRywQFtSVDNo8sxB4REozbhCibDJcvWD0hAd/8FsxAuU +AkeXjfwaneSrw8b0JoEK/9aEkm0rin+XN/CmmCCPWNjiIguG/klL2YfXkSv71fpw +if4PgcgONwKBgQCJQVFAs8fOFnDftTYL+3Tm+ikHrBZgJdXag+3x1kv3TcXLDfG+ +PY2CYgmv1vRFB6SSFe/4ztxDefUeWCbc1X1ttthnT5gQTLNt+OjX3yVIpgc2E+IP +alEGXul4DrUkktG0oo8nVW9knxPt1N2WN+pYBZe07tKknznwBKpU9Zp0PQKBgC1w +1Qu61KAxrFAa659pUWBHjTN9VijfywnugHhjeHtjt66LuM7H4b/Dgfud2d5698z5 +7iUM5mEuGnWsaQpMQRwk/Fe9GnN9uLWxjHOFH6yiWjM02wrfbYF28bTJsgMCu7v9 +mdTHZ3XGjgB37ncG7Sx8nM/JnWSFaSPuV13z358XAoGAIuigV+9koumwqNd2ueIQ +uCWixSkOVwRHPiWG4iyWku9RpspR+Sw23gUbQ45J7u326Aw0P3oHDme2bxgsvME6 +ld+z0BdOLxyLEw55UtQrJkFNCbGsNFHRs774PuZdfExfzcd9d+56vYcVb49PSBb6 +OW4mfN8jmXq6wTtOv85mBGE= +-----END PRIVATE KEY-----"#; + + #[cfg(feature = "boring-browser-fingerprints")] + #[test] + fn boring_client_identity_loads_inline_pem() { + let auth = load_boring_client_identity(&TlsClientIdentity { + certificate: TEST_CLIENT_CERTIFICATE_PEM.to_owned(), + private_key: TEST_CLIENT_PRIVATE_KEY_PEM.to_owned(), + }) + .expect("inline PEM client identity should load for BoringSSL"); + + assert_eq!(auth.cert_chain.len(), 1); + } + + #[cfg(feature = "boring-browser-fingerprints")] + #[test] + fn shadow_tls_random_fingerprint_is_mihomo_style_initial_choice() { + let first = mihomo_initial_random_fingerprint(); + assert!(matches!( + first, + MihomoClientFingerprint::Chrome + | MihomoClientFingerprint::Safari + | MihomoClientFingerprint::Ios + | MihomoClientFingerprint::Firefox + )); + for _ in 0..16 { + assert_eq!(mihomo_initial_random_fingerprint(), first); + } + } + + #[cfg(feature = "boring-browser-fingerprints")] + #[test] + fn shadow_tls_v2_browser_profile_removes_x25519_mlkem768() { + let mut config = rama_net::tls::client::ClientConfig { + extensions: Some(vec![RamaClientHelloExtension::SupportedGroups(vec![ + RamaSupportedGroup::X25519MLKEM768, + RamaSupportedGroup::X25519, + ])]), + ..Default::default() + }; + + strip_x25519_mlkem768_from_client_config(&mut config); + + let extensions = config.extensions.expect("extensions should remain present"); + let RamaClientHelloExtension::SupportedGroups(groups) = &extensions[0] else { + panic!("expected supported groups extension"); + }; + assert_eq!(groups, &[RamaSupportedGroup::X25519]); + } + + #[cfg(feature = "boring-browser-fingerprints")] + #[test] + fn shadow_tls_browser_profiles_cover_mihomo_safari16_alias() { + let profile = mihomo_browser_user_agent_profile(MihomoClientFingerprint::Safari16) + .expect("safari16 should map to an embedded browser TLS profile"); + + assert_eq!(profile.ua_kind, UserAgentKind::Safari); + assert_eq!(profile.platform, Some(PlatformKind::MacOS)); + assert!(profile + .ua_str() + .is_some_and(|ua| ua.contains("Version/16."))); + assert!(MihomoClientFingerprint::Safari16.shadow_tls_v2_boring_supported()); + } + + #[test] + fn shadowsocks_runtime_accepts_simple_obfs_plugins() { + let plugin = ShadowsocksPlugin { + name: "obfs".to_owned(), + opts: HashMap::from([ + ("mode".to_owned(), "http".to_owned()), + ("host".to_owned(), "front.example".to_owned()), + ]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&plugin), None).unwrap(), + Some(ShadowsocksPluginTransport::SimpleObfs { + mode: SimpleObfsMode::Http, + host: "front.example".to_owned() + }) + ); + + let uri_normalized_plugin = ShadowsocksPlugin { + name: "obfs".to_owned(), + opts: HashMap::from([ + ("obfs".to_owned(), "tls".to_owned()), + ("obfs-host".to_owned(), "cdn.example".to_owned()), + ]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&uri_normalized_plugin), None).unwrap(), + Some(ShadowsocksPluginTransport::SimpleObfs { + mode: SimpleObfsMode::Tls, + host: "cdn.example".to_owned() + }) + ); + + let yaml_simple_obfs_alias = ShadowsocksPlugin { + name: "obfs-local".to_owned(), + opts: HashMap::from([ + ("obfs".to_owned(), "tls".to_owned()), + ("obfs-host".to_owned(), "cdn.example".to_owned()), + ]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&yaml_simple_obfs_alias), None).unwrap(), + None + ); + + let v2ray = ShadowsocksPlugin { + name: "v2ray-plugin".to_owned(), + opts: HashMap::from([ + ("mode".to_owned(), "websocket".to_owned()), + ("path".to_owned(), "/ws".to_owned()), + ]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&v2ray), None).unwrap(), + Some(ShadowsocksPluginTransport::WebSocket( + ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::V2ray, + host: "bing.com".to_owned(), + path: "/ws".to_owned(), + headers: Vec::new(), + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::V2ray, + v2ray_http_upgrade: false, + v2ray_http_upgrade_fast_open: false, + } + )) + ); + + let v2ray_uri_aliases = ShadowsocksPlugin { + name: "v2ray-plugin".to_owned(), + opts: HashMap::from([ + ("obfs".to_owned(), "websocket".to_owned()), + ("obfs-host".to_owned(), "cdn.example".to_owned()), + ("path".to_owned(), "/ws".to_owned()), + ("tls".to_owned(), "true".to_owned()), + ]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&v2ray_uri_aliases), None).unwrap(), + Some(ShadowsocksPluginTransport::WebSocket( + ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::V2ray, + host: "cdn.example".to_owned(), + path: "/ws".to_owned(), + headers: Vec::new(), + tls: true, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::V2ray, + v2ray_http_upgrade: false, + v2ray_http_upgrade_fast_open: false, + } + )) + ); + + let v2ray_fast_open = ShadowsocksPlugin { + name: "v2ray-plugin".to_owned(), + opts: HashMap::from([ + ("mode".to_owned(), "websocket".to_owned()), + ("v2ray-http-upgrade".to_owned(), "true".to_owned()), + ("v2ray-http-upgrade-fast-open".to_owned(), "true".to_owned()), + ]), + }; + let Some(ShadowsocksPluginTransport::WebSocket(v2ray_fast_open)) = + shadowsocks_plugin_transport(Some(&v2ray_fast_open), None).unwrap() + else { + panic!("expected websocket plugin transport"); + }; + assert!(v2ray_fast_open.v2ray_http_upgrade); + assert!(v2ray_fast_open.v2ray_http_upgrade_fast_open); + + let v2ray_mtls = ShadowsocksPlugin { + name: "v2ray-plugin".to_owned(), + opts: HashMap::from([ + ("mode".to_owned(), "websocket".to_owned()), + ("tls".to_owned(), "true".to_owned()), + ("certificate".to_owned(), "/tmp/client.crt".to_owned()), + ("private-key".to_owned(), "/tmp/client.key".to_owned()), + ]), + }; + let Some(ShadowsocksPluginTransport::WebSocket(v2ray_mtls)) = + shadowsocks_plugin_transport(Some(&v2ray_mtls), None).unwrap() + else { + panic!("expected websocket plugin transport"); + }; + assert_eq!( + v2ray_mtls.client_identity, + Some(TlsClientIdentity { + certificate: "/tmp/client.crt".to_owned(), + private_key: "/tmp/client.key".to_owned(), + }) + ); + + let v2ray_ech = ShadowsocksPlugin { + name: "v2ray-plugin".to_owned(), + opts: HashMap::from([ + ("mode".to_owned(), "websocket".to_owned()), + ("tls".to_owned(), "true".to_owned()), + ("ech-opts.enable".to_owned(), "true".to_owned()), + ("ech-opts.config".to_owned(), "AQIDBA==".to_owned()), + ( + "ech-opts.query-server-name".to_owned(), + "public.example".to_owned(), + ), + ]), + }; + let Some(ShadowsocksPluginTransport::WebSocket(v2ray_ech)) = + shadowsocks_plugin_transport(Some(&v2ray_ech), None).unwrap() + else { + panic!("expected websocket plugin transport"); + }; + assert_eq!( + v2ray_ech.ech, + Some(ShadowsocksEchConfig { + config_list: Some(vec![1, 2, 3, 4]), + query_server_name: Some("public.example".to_owned()), + }) + ); + + let gost_without_mux = ShadowsocksPlugin { + name: "gost-plugin".to_owned(), + opts: HashMap::from([ + ("mode".to_owned(), "websocket".to_owned()), + ("mux".to_owned(), "false".to_owned()), + ]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&gost_without_mux), None).unwrap(), + Some(ShadowsocksPluginTransport::WebSocket( + ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::Gost, + host: "bing.com".to_owned(), + path: "/".to_owned(), + headers: Vec::new(), + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::None, + v2ray_http_upgrade: false, + v2ray_http_upgrade_fast_open: false, + } + )) + ); + + let gost_default_mux = ShadowsocksPlugin { + name: "gost-plugin".to_owned(), + opts: HashMap::from([("mode".to_owned(), "websocket".to_owned())]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&gost_default_mux), None).unwrap(), + Some(ShadowsocksPluginTransport::WebSocket( + ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::Gost, + host: "bing.com".to_owned(), + path: "/".to_owned(), + headers: Vec::new(), + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::Gost, + v2ray_http_upgrade: false, + v2ray_http_upgrade_fast_open: false, + } + )) + ); + + let missing_obfs_mode = ShadowsocksPlugin { + name: "obfs".to_owned(), + opts: HashMap::new(), + }; + assert!(shadowsocks_plugin_transport(Some(&missing_obfs_mode), None).is_err()); + + let uppercase_obfs_plugin = ShadowsocksPlugin { + name: "Obfs".to_owned(), + opts: HashMap::from([("mode".to_owned(), "http".to_owned())]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&uppercase_obfs_plugin), None).unwrap(), + None + ); + + let missing_websocket_mode = ShadowsocksPlugin { + name: "v2ray-plugin".to_owned(), + opts: HashMap::new(), + }; + assert!(shadowsocks_plugin_transport(Some(&missing_websocket_mode), None).is_err()); + + let shadow_tls_alias = ShadowsocksPlugin { + name: "shadowtls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("password".to_owned(), "secret".to_owned()), + ]), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&shadow_tls_alias), None).unwrap(), + None + ); + + let unknown_plugin = ShadowsocksPlugin { + name: "unknown-plugin".to_owned(), + opts: HashMap::new(), + }; + assert_eq!( + shadowsocks_plugin_transport(Some(&unknown_plugin), None).unwrap(), + None + ); + + let shadow_tls = ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("password".to_owned(), "secret".to_owned()), + ("version".to_owned(), "1".to_owned()), + ("alpn".to_owned(), "http/1.1".to_owned()), + ("skip-cert-verify".to_owned(), "true".to_owned()), + ( + "fingerprint".to_owned(), + "00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff:00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff" + .to_owned(), + ), + ]), + }; + let shadow_tls_fingerprint = parse_certificate_fingerprint( + "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + ) + .unwrap(); + assert_eq!( + shadowsocks_plugin_transport(Some(&shadow_tls), None).unwrap(), + Some(ShadowsocksPluginTransport::ShadowTls( + ShadowsocksShadowTlsTransport { + host: "cover.example".to_owned(), + password: "secret".to_owned(), + version: 1, + alpn: vec!["http/1.1".to_owned()], + skip_cert_verify: true, + certificate_fingerprint: Some(shadow_tls_fingerprint), + client_identity: None, + client_fingerprint: None, + } + )) + ); + + let shadow_tls_v1_without_password_plugin = ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("version".to_owned(), "1".to_owned()), + ]), + }; + let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_v1_without_password)) = + shadowsocks_plugin_transport(Some(&shadow_tls_v1_without_password_plugin), None) + .unwrap() + else { + panic!("expected shadow-tls plugin transport"); + }; + assert_eq!(shadow_tls_v1_without_password.version, 1); + assert_eq!(shadow_tls_v1_without_password.password, ""); + assert_eq!( + shadow_tls_v1_without_password.alpn, + vec!["h2".to_owned(), "http/1.1".to_owned()] + ); + + let shadow_tls_explicit_empty_alpn = ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("version".to_owned(), "1".to_owned()), + ("alpn".to_owned(), "".to_owned()), + ]), + }; + let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_empty_alpn)) = + shadowsocks_plugin_transport(Some(&shadow_tls_explicit_empty_alpn), None).unwrap() + else { + panic!("expected shadow-tls plugin transport"); + }; + assert!(shadow_tls_empty_alpn.alpn.is_empty()); + + let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_v1_with_fingerprint)) = + shadowsocks_plugin_transport( + Some(&shadow_tls_v1_without_password_plugin), + Some("firefox"), + ) + .unwrap() + else { + panic!("expected shadow-tls plugin transport"); + }; + assert_eq!(shadow_tls_v1_with_fingerprint.version, 1); + assert_eq!(shadow_tls_v1_with_fingerprint.client_fingerprint, None); + + let shadow_tls_v2_with_fingerprint_plugin = ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("password".to_owned(), "secret".to_owned()), + ("version".to_owned(), "2".to_owned()), + ]), + }; + #[cfg(feature = "boring-browser-fingerprints")] + { + let Some(ShadowsocksPluginTransport::ShadowTls(shadow_tls_v2_with_fingerprint)) = + shadowsocks_plugin_transport( + Some(&shadow_tls_v2_with_fingerprint_plugin), + Some("firefox"), + ) + .unwrap() + else { + panic!("expected shadow-tls plugin transport"); + }; + assert_eq!(shadow_tls_v2_with_fingerprint.version, 2); + assert_eq!( + shadow_tls_v2_with_fingerprint.client_fingerprint, + Some(MihomoClientFingerprint::Firefox) + ); + } + #[cfg(not(feature = "boring-browser-fingerprints"))] + assert!(shadowsocks_plugin_transport( + Some(&shadow_tls_v2_with_fingerprint_plugin), + Some("firefox"), + ) + .is_err()); + + let shadow_tls_v2_without_password = ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("version".to_owned(), "2".to_owned()), + ]), + }; + assert!(shadowsocks_plugin_transport(Some(&shadow_tls_v2_without_password), None).is_err()); + + let incomplete_mtls = ShadowsocksPlugin { + name: "gost-plugin".to_owned(), + opts: HashMap::from([ + ("mode".to_owned(), "websocket".to_owned()), + ("certificate".to_owned(), "/tmp/client.crt".to_owned()), + ]), + }; + assert!(shadowsocks_plugin_transport(Some(&incomplete_mtls), None).is_err()); + + let shadow_tls_v3_plugin = ShadowsocksPlugin { + name: "shadow-tls".to_owned(), + opts: HashMap::from([ + ("host".to_owned(), "cover.example".to_owned()), + ("password".to_owned(), "secret".to_owned()), + ("version".to_owned(), "3".to_owned()), + ]), + }; + assert!(shadowsocks_plugin_transport(Some(&shadow_tls_v3_plugin), None).is_err()); + + let kcptun = ShadowsocksPlugin { + name: "kcptun".to_owned(), + opts: HashMap::from([ + ("key".to_owned(), "secret".to_owned()), + ("crypt".to_owned(), "tea".to_owned()), + ("mode".to_owned(), "fast3".to_owned()), + ("conn".to_owned(), "2".to_owned()), + ("nocomp".to_owned(), "true".to_owned()), + ("ratelimit".to_owned(), "2048".to_owned()), + ("dscp".to_owned(), "46".to_owned()), + ("sockbuf".to_owned(), "8388608".to_owned()), + ("smuxver".to_owned(), "3".to_owned()), + ("framesize".to_owned(), "4096".to_owned()), + ]), + }; + let Some(ShadowsocksPluginTransport::Kcptun(kcptun)) = + shadowsocks_plugin_transport(Some(&kcptun), None).unwrap() + else { + panic!("expected kcptun plugin transport"); + }; + assert_eq!(kcptun.key, "secret"); + assert_eq!(kcptun.crypt, "tea"); + assert_eq!(kcptun.mode, "fast3"); + assert_eq!(kcptun.conn, 2); + assert_eq!(kcptun.no_delay, 1); + assert_eq!(kcptun.interval, 10); + assert_eq!(kcptun.resend, 2); + assert!(kcptun.no_congestion); + assert!(kcptun.no_comp); + assert_eq!(kcptun.rate_limit, 2048); + assert_eq!(kcptun.dscp, 46); + assert_eq!(kcptun.sockbuf, 8_388_608); + assert_eq!(kcptun.smux_ver, 2); + assert_eq!(kcptun.frame_size, 4096); + assert_eq!(kcptun_ipv4_dscp_tos(kcptun.dscp), 184); + + } + + #[test] + fn restls_application_records_authenticate_and_mask_headers() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x42; 32]; + let script = vec![RestlsScriptLine { + target_len: RestlsTargetLength { base: 8, random_range: 0 }, + command: RestlsCommand::Response(1), + }]; + let mut writer = RestlsApplicationCodec::new(secret, server_random, false); + let result = writer.build_to_server_record(b"hello", &script).unwrap(); + + assert_eq!(result.consumed, 5); + assert_eq!(result.command, RestlsCommand::Response(1)); + assert_eq!(result.record[0], TLS_APPLICATION_DATA_RECORD_TYPE); + assert_eq!(&result.record[1..3], &TLS12_RECORD_VERSION); + assert_eq!( + usize::from(u16::from_be_bytes([result.record[3], result.record[4]])), + RESTLS_APP_DATA_AUTH_HEADER_LEN + 8 + ); + assert_ne!( + &result.record[TLS_RECORD_HEADER_LEN + RESTLS_APP_DATA_LEN_OFFSET + ..TLS_RECORD_HEADER_LEN + RESTLS_APP_DATA_OFFSET], + &[0, 5, 1, 1], + "Restls masks the length/command bytes with the BLAKE3 auth header hash" + ); + + let mut reader = RestlsApplicationCodec::new(secret, server_random, false); + let frame = reader.decode_to_server_record(&result.record).unwrap(); + assert_eq!(frame.data, b"hello"); + assert_eq!(frame.command, RestlsCommand::Response(1)); + + let mut tampered = result.record.clone(); + let last = tampered.last_mut().unwrap(); + *last ^= 0x01; + let mut reader = RestlsApplicationCodec::new(secret, server_random, false); + assert!(reader.decode_to_server_record(&tampered).is_err()); + } + + #[test] + fn restls_first_application_record_binds_client_finished() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x11; 32]; + let client_finished = b"encrypted-client-finished".to_vec(); + let mut writer = RestlsApplicationCodec::new(secret, server_random, false); + writer.set_client_finished_auth(client_finished.clone()); + let first = writer.build_to_server_record(b"first", &[]).unwrap(); + let second = writer.build_to_server_record(b"second", &[]).unwrap(); + + let mut missing_finished = RestlsApplicationCodec::new(secret, server_random, false); + assert!(missing_finished + .decode_to_server_record(&first.record) + .is_err()); + + let mut reader = RestlsApplicationCodec::new(secret, server_random, false); + reader.set_client_finished_auth(client_finished); + let first_frame = reader.decode_to_server_record(&first.record).unwrap(); + assert_eq!(first_frame.data, b"first"); + assert_eq!(first_frame.command, RestlsCommand::Noop); + + let second_frame = reader.decode_to_server_record(&second.record).unwrap(); + assert_eq!(second_frame.data, b"second"); + assert_eq!(second_frame.command, RestlsCommand::Noop); + } + + #[test] + fn restls_tls12_gcm_application_records_carry_explicit_counters() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x24; 32]; + let mut writer = RestlsApplicationCodec::new(secret, server_random, true); + let result = writer.build_to_server_record(b"abc", &[]).unwrap(); + assert_eq!( + u64::from_be_bytes( + result.record[TLS_RECORD_HEADER_LEN..TLS_RECORD_HEADER_LEN + 8] + .try_into() + .unwrap() + ), + 1 + ); + + let mut reader = RestlsApplicationCodec::new(secret, server_random, true); + let frame = reader.decode_to_server_record(&result.record).unwrap(); + assert_eq!(frame.data, b"abc"); + assert_eq!(frame.command, RestlsCommand::Noop); + + let mut replay_reader = RestlsApplicationCodec::new(secret, server_random, true); + replay_reader.to_server_counter = 1; + assert!(replay_reader + .decode_to_server_record(&result.record) + .is_err()); + } + + #[test] + fn restls_tls12_gcm_to_client_can_disable_explicit_counter() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x25; 32]; + let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); + let record = server_writer + .build_to_client_test_record(b"abc", RestlsCommand::Noop) + .unwrap(); + + let mut strict_reader = RestlsApplicationCodec::new(secret, server_random, true); + assert!(strict_reader.decode_to_client_record(&record).is_err()); + + let mut reader = RestlsApplicationCodec::new(secret, server_random, true); + reader.set_tls12_gcm_to_client_disable_counter(true); + let frame = reader.decode_to_client_record(&record).unwrap(); + assert_eq!(frame.data, b"abc"); + assert_eq!(frame.command, RestlsCommand::Noop); + } + + #[test] + fn restls_response_count_matches_mihomo_signed_byte_shape() { + assert_eq!(restls_response_repeat_count(2, false), 2); + assert_eq!(restls_response_repeat_count(2, true), 1); + assert_eq!(restls_response_repeat_count(0, true), 0); + assert_eq!(restls_response_repeat_count(200, false), 0); + assert_eq!(restls_response_repeat_count(128, true), 127); + assert_eq!(restls_response_repeat_count(300, false), 44); + } + + #[test] + fn restls_stream_state_buffers_and_resumes_scripted_interrupts() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x51; 32]; + let script = vec![ + RestlsScriptLine { + target_len: RestlsTargetLength { base: 3, random_range: 0 }, + command: RestlsCommand::Response(2), + }, + RestlsScriptLine { + target_len: RestlsTargetLength { base: 6, random_range: 0 }, + command: RestlsCommand::Noop, + }, + ]; + let mut writer = RestlsStreamState::new( + RestlsApplicationCodec::new(secret, server_random, false), + script, + ); + let mut reader = RestlsApplicationCodec::new(secret, server_random, false); + + let first = writer.write(b"abcdef").unwrap(); + assert_eq!(first.accepted, 6); + assert_eq!(first.records.len(), 1); + assert!(writer.write_pending); + assert_eq!(writer.send_buffer, b"def"); + let frame = reader.decode_to_server_record(&first.records[0]).unwrap(); + assert_eq!(frame.data, b"abc"); + assert_eq!(frame.command, RestlsCommand::Response(2)); + + let buffered = writer.write(b"ghi").unwrap(); + assert_eq!(buffered.accepted, 3); + assert!(buffered.records.is_empty()); + assert_eq!(writer.send_buffer, b"defghi"); + + let resumed = writer.resume_pending_write().unwrap(); + assert_eq!(resumed.records.len(), 1); + assert!(!writer.write_pending); + assert!(writer.send_buffer.is_empty()); + let frame = reader.decode_to_server_record(&resumed.records[0]).unwrap(); + assert_eq!(frame.data, b"defghi"); + assert_eq!(frame.command, RestlsCommand::Noop); + + let response_records = writer + .receive_command(RestlsCommand::Response(2), true) + .unwrap(); + assert_eq!(response_records.len(), 1); + let frame = reader + .decode_to_server_record(&response_records[0]) + .unwrap(); + assert!(frame.data.is_empty()); + assert_eq!(frame.command, RestlsCommand::Noop); + } + + #[test] + fn restls_stream_state_decodes_server_records_and_unblocks_upload() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x52; 32]; + let script = vec![ + RestlsScriptLine { + target_len: RestlsTargetLength { base: 2, random_range: 0 }, + command: RestlsCommand::Response(2), + }, + RestlsScriptLine { + target_len: RestlsTargetLength { base: 2, random_range: 0 }, + command: RestlsCommand::Noop, + }, + RestlsScriptLine { + target_len: RestlsTargetLength { base: 0, random_range: 0 }, + command: RestlsCommand::Noop, + }, + ]; + let mut state = RestlsStreamState::new( + RestlsApplicationCodec::new(secret, server_random, false), + script, + ); + let mut peer_reader = RestlsApplicationCodec::new(secret, server_random, false); + let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); + + let upload = state.write(b"abcd").unwrap(); + assert_eq!(upload.records.len(), 1); + assert!(state.write_pending); + assert_eq!(state.send_buffer, b"cd"); + assert_eq!( + peer_reader + .decode_to_server_record(&upload.records[0]) + .unwrap() + .data, + b"ab" + ); + + let server_record = server_writer + .build_to_client_test_record(b"server-data", RestlsCommand::Response(2)) + .unwrap(); + let read = state.read_to_client_record(&server_record).unwrap(); + assert_eq!(read.data, b"server-data"); + assert!(read.resumed_pending_write); + assert_eq!(read.records.len(), 2); + assert!(!state.write_pending); + assert!(state.send_buffer.is_empty()); + + let resumed = peer_reader + .decode_to_server_record(&read.records[0]) + .unwrap(); + assert_eq!(resumed.data, b"cd"); + assert_eq!(resumed.command, RestlsCommand::Noop); + let response = peer_reader + .decode_to_server_record(&read.records[1]) + .unwrap(); + assert!(response.data.is_empty()); + assert_eq!(response.command, RestlsCommand::Noop); + } + + #[tokio::test] + async fn restls_stream_wraps_application_read_and_write() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x61; 32]; + let state = RestlsStreamState::new( + RestlsApplicationCodec::new(secret, server_random, false), + Vec::new(), + ); + let (client, mut server) = tokio::io::duplex(4096); + let mut stream = RestlsStream::new(client, state); + + stream.write_all(b"upload").await.unwrap(); + stream.flush().await.unwrap(); + let record = read_test_tls_record(&mut server).await; + let mut peer_reader = RestlsApplicationCodec::new(secret, server_random, false); + let frame = peer_reader.decode_to_server_record(&record).unwrap(); + assert_eq!(frame.data, b"upload"); + assert_eq!(frame.command, RestlsCommand::Noop); + + let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); + let record = server_writer + .build_to_client_test_record(b"download", RestlsCommand::Noop) + .unwrap(); + server.write_all(&record).await.unwrap(); + + let mut body = [0u8; 8]; + stream.read_exact(&mut body).await.unwrap(); + assert_eq!(&body, b"download"); + } + + #[tokio::test] + async fn restls_stream_resumes_pending_write_from_server_response() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x62; 32]; + let script = vec![ + RestlsScriptLine { + target_len: RestlsTargetLength { base: 2, random_range: 0 }, + command: RestlsCommand::Response(2), + }, + RestlsScriptLine { + target_len: RestlsTargetLength { base: 2, random_range: 0 }, + command: RestlsCommand::Noop, + }, + RestlsScriptLine { + target_len: RestlsTargetLength { base: 0, random_range: 0 }, + command: RestlsCommand::Noop, + }, + ]; + let state = RestlsStreamState::new( + RestlsApplicationCodec::new(secret, server_random, false), + script, + ); + let (client, mut server) = tokio::io::duplex(4096); + let mut stream = RestlsStream::new(client, state); + let mut peer_reader = RestlsApplicationCodec::new(secret, server_random, false); + + stream.write_all(b"abcd").await.unwrap(); + stream.flush().await.unwrap(); + let first = read_test_tls_record(&mut server).await; + let frame = peer_reader.decode_to_server_record(&first).unwrap(); + assert_eq!(frame.data, b"ab"); + assert_eq!(frame.command, RestlsCommand::Response(2)); + + let mut server_writer = RestlsApplicationCodec::new(secret, server_random, false); + let response = server_writer + .build_to_client_test_record(b"ok", RestlsCommand::Response(2)) + .unwrap(); + server.write_all(&response).await.unwrap(); + + let mut body = [0u8; 2]; + stream.read_exact(&mut body).await.unwrap(); + assert_eq!(&body, b"ok"); + + let resumed = read_test_tls_record(&mut server).await; + let frame = peer_reader.decode_to_server_record(&resumed).unwrap(); + assert_eq!(frame.data, b"cd"); + assert_eq!(frame.command, RestlsCommand::Noop); + + let fake_response = read_test_tls_record(&mut server).await; + let frame = peer_reader.decode_to_server_record(&fake_response).unwrap(); + assert!(frame.data.is_empty()); + assert_eq!(frame.command, RestlsCommand::Noop); + } + + #[test] + fn restls_client_hello_auth_material_matches_mihomo_shape() { + fn extension(extension_type: u16, payload: Vec) -> Vec { + let mut encoded = Vec::with_capacity(4 + payload.len()); + encoded.extend_from_slice(&extension_type.to_be_bytes()); + encoded.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + encoded.extend_from_slice(&payload); + encoded + } + + fn test_client_hello(key_shares: &[(u16, Vec)], psk_labels: &[Vec]) -> Vec { + let mut key_share_entries = Vec::new(); + for (group, key) in key_shares { + key_share_entries.extend_from_slice(&group.to_be_bytes()); + key_share_entries.extend_from_slice(&(key.len() as u16).to_be_bytes()); + key_share_entries.extend_from_slice(key); + } + let mut key_share_payload = Vec::new(); + key_share_payload.extend_from_slice(&(key_share_entries.len() as u16).to_be_bytes()); + key_share_payload.extend_from_slice(&key_share_entries); + + let mut identities = Vec::new(); + for label in psk_labels { + identities.extend_from_slice(&(label.len() as u16).to_be_bytes()); + identities.extend_from_slice(label); + identities.extend_from_slice(&0u32.to_be_bytes()); + } + let mut psk_payload = Vec::new(); + psk_payload.extend_from_slice(&(identities.len() as u16).to_be_bytes()); + psk_payload.extend_from_slice(&identities); + psk_payload.extend_from_slice(&2u16.to_be_bytes()); + psk_payload.extend_from_slice(&[0xaa, 0xbb]); + + let mut extensions = Vec::new(); + extensions.extend_from_slice(&extension(0x0033, key_share_payload)); + extensions.extend_from_slice(&extension(0x0029, psk_payload)); + + let mut body = Vec::new(); + body.extend_from_slice(&[0x03, 0x03]); + body.extend_from_slice(&[0x44; 32]); + body.push(32); + body.extend_from_slice(&[0; 32]); + body.extend_from_slice(&2u16.to_be_bytes()); + body.extend_from_slice(&0x1301u16.to_be_bytes()); + body.push(1); + body.push(0); + body.extend_from_slice(&(extensions.len() as u16).to_be_bytes()); + body.extend_from_slice(&extensions); + + let mut hello = Vec::new(); + hello.push(0x01); + hello.extend_from_slice(&[ + ((body.len() >> 16) & 0xff) as u8, + ((body.len() >> 8) & 0xff) as u8, + (body.len() & 0xff) as u8, + ]); + hello.extend_from_slice(&body); + hello + } + + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let key_shares = vec![(0x001d, vec![1, 2, 3, 4]), (0x0017, vec![5, 6, 7, 8])]; + let psk_labels = vec![b"ticket-a".to_vec(), b"ticket-b".to_vec()]; + let seed_session_id = [0x7a; 32]; + let session_id = + restls_tls13_session_id(&secret, &key_shares, &psk_labels, seed_session_id); + + let mut expected_hmac = blake3::Hasher::new_keyed(&secret); + expected_hmac.update(&0x001du16.to_be_bytes()); + expected_hmac.update(&[1, 2, 3, 4]); + expected_hmac.update(&0x0017u16.to_be_bytes()); + expected_hmac.update(&[5, 6, 7, 8]); + expected_hmac.update(b"ticket-a"); + expected_hmac.update(b"ticket-b"); + let expected = expected_hmac.finalize(); + assert_eq!( + &session_id[..RESTLS_HANDSHAKE_MAC_LEN], + &expected.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN] + ); + assert_eq!(&session_id[RESTLS_HANDSHAKE_MAC_LEN..], &[0x7a; 16]); + + let client_hello = test_client_hello(&key_shares, &psk_labels); + let material = restls_tls13_client_hello_auth_material(&client_hello).unwrap(); + assert_eq!(material.key_shares, key_shares); + assert_eq!(material.psk_labels, psk_labels); + assert_eq!( + restls_tls13_session_id_from_client_hello(&secret, &client_hello, seed_session_id) + .unwrap(), + session_id + ); + let mut truncated = client_hello; + truncated.pop(); + assert!(restls_tls13_client_hello_auth_material(&truncated).is_err()); + + let error = StdMutex::new(None); + let fallback_session_id = restls_tls13_session_id_for_client_hello(&secret, &[], &error); + assert_eq!( + &fallback_session_id[..RESTLS_HANDSHAKE_MAC_LEN], + &[0; RESTLS_HANDSHAKE_MAC_LEN] + ); + let err = take_restls_session_id_error(&error).expect("session id error was recorded"); + assert!(err.contains("failed to derive Restls TLS 1.3 session id from ClientHello")); + assert!(err.contains("missing handshake type")); + + let materials = vec![ + vec![0x11; 32], + vec![0x22; 65], + vec![0x33; 97], + b"session-ticket".to_vec(), + ]; + let session_id = restls_tls12_session_id(&secret, &materials).unwrap(); + for (index, window) in RESTLS_TLS12_CLIENT_AUTH_LAYOUT_4.windows(2).enumerate() { + let mut hmac = blake3::Hasher::new_keyed(&secret); + hmac.update(&materials[index]); + let digest = hmac.finalize(); + assert_eq!( + &session_id[window[0]..window[1]], + &digest.as_bytes()[..window[1] - window[0]] + ); + } + assert!(restls_tls12_session_id(&secret, &materials[..2]).is_err()); + } + + #[test] + fn restls_tls13_client_hello_record_signer_preserves_random_tail() { + fn extension(extension_type: u16, payload: Vec) -> Vec { + let mut encoded = Vec::with_capacity(4 + payload.len()); + encoded.extend_from_slice(&extension_type.to_be_bytes()); + encoded.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + encoded.extend_from_slice(&payload); + encoded + } + + let mut key_share_entries = Vec::new(); + key_share_entries.extend_from_slice(&0x001du16.to_be_bytes()); + key_share_entries.extend_from_slice(&4u16.to_be_bytes()); + key_share_entries.extend_from_slice(&[1, 2, 3, 4]); + let mut key_share_payload = Vec::new(); + key_share_payload.extend_from_slice(&(key_share_entries.len() as u16).to_be_bytes()); + key_share_payload.extend_from_slice(&key_share_entries); + + let mut extensions = Vec::new(); + extensions.extend_from_slice(&extension(0x0033, key_share_payload)); + + let mut body = Vec::new(); + body.extend_from_slice(&[0x03, 0x03]); + body.extend_from_slice(&[0x44; 32]); + body.push(32); + body.extend_from_slice(&[0x7a; 32]); + body.extend_from_slice(&2u16.to_be_bytes()); + body.extend_from_slice(&0x1301u16.to_be_bytes()); + body.push(1); + body.push(0); + body.extend_from_slice(&(extensions.len() as u16).to_be_bytes()); + body.extend_from_slice(&extensions); + + let mut payload = vec![0x01]; + payload.extend_from_slice(&[ + ((body.len() >> 16) & 0xff) as u8, + ((body.len() >> 8) & 0xff) as u8, + (body.len() & 0xff) as u8, + ]); + payload.extend_from_slice(&body); + + let mut record = vec![0x16, 0x03, 0x03]; + record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + record.extend_from_slice(&payload); + record.extend_from_slice(b"next-record"); + + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + assert!(restls_tls13_sign_client_hello_record(&secret, &record[..4]) + .unwrap() + .is_none()); + let signed = restls_tls13_sign_client_hello_record(&secret, &record) + .unwrap() + .expect("complete ClientHello record is signed"); + assert_eq!( + &signed[signed.len() - b"next-record".len()..], + b"next-record" + ); + + let session_id_start = TLS_RECORD_HEADER_LEN + SHADOW_TLS_V3_SESSION_ID_START; + let session_id = &signed[session_id_start..session_id_start + 32]; + let mut expected_hmac = blake3::Hasher::new_keyed(&secret); + expected_hmac.update(&0x001du16.to_be_bytes()); + expected_hmac.update(&[1, 2, 3, 4]); + let expected = expected_hmac.finalize(); + assert_eq!( + &session_id[..RESTLS_HANDSHAKE_MAC_LEN], + &expected.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN] + ); + assert_eq!(&session_id[RESTLS_HANDSHAKE_MAC_LEN..], &[0x7a; 16]); + } + + #[test] + fn restls_server_auth_record_unmasking_matches_mihomo_offsets() { + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x99; 32]; + let mut hmac = blake3::Hasher::new_keyed(&secret); + hmac.update(&server_random); + let digest = hmac.finalize(); + let mask = &digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]; + + let mut plain = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 3, 3, 0, 18]; + plain.extend_from_slice(b"server-auth-record"); + let mut masked = plain.clone(); + xor_with_restls_mask(&mut masked[TLS_RECORD_HEADER_LEN..], mask); + let unmasked = + restls_unmask_server_auth_record(&secret, &server_random, &masked, false).unwrap(); + assert_eq!(unmasked.record, plain); + assert!(!unmasked.tls12_gcm_server_disable_counter); + + let mut plain_gcm = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 3, 3, 0, 23]; + plain_gcm.extend_from_slice(&[0; 8]); + plain_gcm.extend_from_slice(b"gcm-server-auth"); + let mut masked_gcm = plain_gcm.clone(); + xor_with_restls_mask(&mut masked_gcm[TLS_RECORD_HEADER_LEN + 8..], mask); + let unmasked = + restls_unmask_server_auth_record(&secret, &server_random, &masked_gcm, true).unwrap(); + assert_eq!(unmasked.record, plain_gcm); + assert!(!unmasked.tls12_gcm_server_disable_counter); + + let mut plain_gcm_no_counter = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 3, 3, 0, 23]; + plain_gcm_no_counter.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 1]); + plain_gcm_no_counter.extend_from_slice(b"gcm-server-auth"); + let mut masked_gcm_no_counter = plain_gcm_no_counter.clone(); + xor_with_restls_mask(&mut masked_gcm_no_counter[TLS_RECORD_HEADER_LEN..], mask); + let unmasked = + restls_unmask_server_auth_record(&secret, &server_random, &masked_gcm_no_counter, true) + .unwrap(); + assert_eq!(unmasked.record, plain_gcm_no_counter); + assert!(unmasked.tls12_gcm_server_disable_counter); + } + + #[test] + fn restls_handshake_stream_captures_auth_state() { + fn server_hello_record(server_random: [u8; 32]) -> Vec { + let mut body = Vec::new(); + body.extend_from_slice(&[0x03, 0x03]); + body.extend_from_slice(&server_random); + body.push(0); + + let mut payload = Vec::new(); + payload.push(0x02); + payload.extend_from_slice(&[ + ((body.len() >> 16) & 0xff) as u8, + ((body.len() >> 8) & 0xff) as u8, + (body.len() & 0xff) as u8, + ]); + payload.extend_from_slice(&body); + + let mut record = vec![0x16, 0x03, 0x03]; + record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + record.extend_from_slice(&payload); + record + } + + let secret = blake3::derive_key("restls-traffic-key", b"secret"); + let server_random = [0x71; 32]; + let mut stream = RestlsHandshakeStream::new((), secret, false); + let server_hello = server_hello_record(server_random); + stream.process_read_frame(server_hello.clone()).unwrap(); + assert_eq!(stream.server_random, Some(server_random)); + assert_eq!(stream.read_buf, server_hello); + + let mut hmac = blake3::Hasher::new_keyed(&secret); + hmac.update(&server_random); + let digest = hmac.finalize(); + let mask = &digest.as_bytes()[..RESTLS_HANDSHAKE_MAC_LEN]; + let mut plain_auth = vec![TLS_APPLICATION_DATA_RECORD_TYPE, 0x03, 0x03, 0, 11]; + plain_auth.extend_from_slice(b"server-auth"); + let mut masked_auth = plain_auth.clone(); + xor_with_restls_mask(&mut masked_auth[TLS_RECORD_HEADER_LEN..], mask); + stream.process_read_frame(masked_auth).unwrap(); + assert_eq!(stream.read_buf, plain_auth); + assert!(stream.server_auth_unmasked); + + let ccs = [0x14, 0x03, 0x03, 0, 1, 1]; + let client_finished = [TLS_APPLICATION_DATA_RECORD_TYPE, 0x03, 0x03, 0, 3, 1, 2, 3]; + let mut write = Vec::new(); + write.extend_from_slice(&ccs); + stream.process_write_bytes(&write); + stream.process_write_bytes(&client_finished[..2]); + assert_eq!(stream.client_finished_auth, None); + stream.process_write_bytes(&client_finished[2..]); + + let (_, state) = stream.into_inner(); + assert_eq!(state.server_random, Some(server_random)); + assert_eq!(state.client_finished_auth, Some(client_finished.to_vec())); + assert!(state.server_auth_unmasked); + assert!(!state.tls12_gcm_server_disable_counter); + } + + #[test] + fn kcptun_rate_limiter_uses_mihomo_burst_shape() { + let (client, _server) = tokio::io::duplex(1); + let stream = RateLimitedStream::new(client, 2048); + assert_eq!(stream.bytes_per_second, 2048); + assert_eq!(stream.burst_capacity, KCPTUN_RATE_LIMIT_BURST_BYTES); + assert_eq!(stream.available, KCPTUN_RATE_LIMIT_BURST_BYTES); + } + + #[test] + fn kcptun_smux_keep_alive_timeout_matches_mihomo_default_shape() { + assert_eq!( + kcptun_smux_keep_alive_timeout(Duration::from_secs(5)), + Duration::from_secs(30) + ); + assert_eq!( + kcptun_smux_keep_alive_timeout(Duration::from_secs(10)), + Duration::from_secs(30) + ); + assert_eq!( + kcptun_smux_keep_alive_timeout(Duration::from_secs(30)), + Duration::from_secs(90) + ); + } + + #[test] + fn kcptun_block_crypt_accepts_mihomo_names() { + assert!(kcptun_block_crypt("secret", "null").unwrap().is_none()); + for crypt in [ + "aes", + "aes-128", + "aes-192", + "aes-256", + "tea", + "xor", + "none", + "blowfish", + "cast5", + "3des", + "twofish", + "xtea", + "salsa20", + "sm4", + "aes-128-gcm", + "unknown-falls-back-to-aes", + ] { + assert!( + kcptun_block_crypt("secret", crypt).unwrap().is_some(), + "crypt {crypt}" + ); + } + } + + #[tokio::test] + async fn uot_frame_round_trips_ipv4_and_ipv6() { + for addr in [ + "1.2.3.4:53".parse::().unwrap(), + "[2001:db8::1]:443".parse::().unwrap(), + ] { + let (mut writer, mut reader) = tokio::io::duplex(128); + let write = + tokio::spawn(async move { write_uot_frame(&mut writer, addr, b"hello").await }); + + let (decoded_addr, payload) = read_uot_frame(&mut reader).await.unwrap(); + write.await.unwrap().unwrap(); + assert_eq!(decoded_addr, addr); + assert_eq!(payload, b"hello"); + } + } + + #[tokio::test] + async fn simple_obfs_http_wraps_first_write_and_strips_response() { + let (client, mut server) = tokio::io::duplex(4096); + let mut stream = SimpleObfsStream::new( + client, + SimpleObfsMode::Http, + "front.example".to_owned(), + 8080, + ); + + let write = tokio::spawn(async move { + stream.write_all(b"hello").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + Result::<[u8; 5]>::Ok(body) + }); + + let mut request = vec![0u8; 2048]; + let len = server.read(&mut request).await.unwrap(); + let request = &request[..len]; + assert!(request.starts_with(b"GET http://front.example/ HTTP/1.1\r\n")); + assert!(request + .windows(b"Host: front.example:8080\r\n".len()) + .any(|window| window == b"Host: front.example:8080\r\n")); + assert!(request.ends_with(b"\r\n\r\nhello")); + + server + .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\n\r\nworld") + .await + .unwrap(); + assert_eq!(write.await.unwrap().unwrap(), *b"world"); + } + + #[tokio::test] + async fn simple_obfs_tls_wraps_writes_and_strips_records() { + let (client, mut server) = tokio::io::duplex(4096); + let mut stream = + SimpleObfsStream::new(client, SimpleObfsMode::Tls, "front.example".to_owned(), 443); + + let write = tokio::spawn(async move { + stream.write_all(b"hello").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + stream.write_all(b"again").await?; + stream.flush().await?; + Result::<[u8; 5]>::Ok(body) + }); + + let mut client_hello = vec![0u8; 4096]; + let len = server.read(&mut client_hello).await.unwrap(); + let client_hello = &client_hello[..len]; + assert_eq!(client_hello[0], 22); + assert!(client_hello + .windows("front.example".len()) + .any(|window| window == b"front.example")); + assert!(client_hello.windows(5).any(|window| window == b"hello")); + + let mut response = vec![0u8; 105]; + response.extend_from_slice(&5u16.to_be_bytes()); + response.extend_from_slice(b"world"); + server.write_all(&response).await.unwrap(); + + let mut record = [0u8; 10]; + server.read_exact(&mut record).await.unwrap(); + assert_eq!(&record[..3], &[0x17, 0x03, 0x03]); + assert_eq!(u16::from_be_bytes([record[3], record[4]]), 5); + assert_eq!(&record[5..], b"again"); + assert_eq!(write.await.unwrap().unwrap(), *b"world"); + } + + #[tokio::test] + async fn shadow_tls_v2_wraps_records_and_strips_headers() { + let (client, mut server) = tokio::io::duplex(4096); + let mut stream = ShadowTlsV2Stream::new(client, [1, 2, 3, 4, 5, 6, 7, 8]); + + let write = tokio::spawn(async move { + stream.write_all(b"hello").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + stream.write_all(b"again").await?; + stream.flush().await?; + Result::<[u8; 5]>::Ok(body) + }); + + let mut first_record = [0u8; 18]; + server.read_exact(&mut first_record).await.unwrap(); + assert_eq!(&first_record[..3], &[0x17, 0x03, 0x03]); + assert_eq!(u16::from_be_bytes([first_record[3], first_record[4]]), 13); + assert_eq!(&first_record[5..13], &[1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(&first_record[13..], b"hello"); + + server + .write_all(&[0x17, 0x03, 0x03, 0x00, 0x05]) + .await + .unwrap(); + server.write_all(b"world").await.unwrap(); + + let mut second_record = [0u8; 10]; + server.read_exact(&mut second_record).await.unwrap(); + assert_eq!(&second_record[..3], &[0x17, 0x03, 0x03]); + assert_eq!(u16::from_be_bytes([second_record[3], second_record[4]]), 5); + assert_eq!(&second_record[5..], b"again"); + assert_eq!(write.await.unwrap().unwrap(), *b"world"); + } + + #[test] + fn shadow_tls_v3_session_id_carries_password_hmac() { + let mut client_hello = + vec![0u8; SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE + 8]; + client_hello[0] = 0x01; + client_hello[SHADOW_TLS_V3_SESSION_ID_START - 1] = SHADOW_TLS_V3_SESSION_ID_SIZE as u8; + let session_id = shadow_tls_v3_generate_session_id("secret", &client_hello); + + let mut context = shadow_tls_v3_hmac("secret", b"", b""); + let mut signed_session_id = session_id; + signed_session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..].fill(0); + context.update(&client_hello[..SHADOW_TLS_V3_SESSION_ID_START]); + context.update(&signed_session_id); + context.update( + &client_hello[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..], + ); + let expected = shadow_tls_v3_hmac_prefix(&context, SHADOW_TLS_V3_HMAC_SIZE); + assert_eq!( + &session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..], + expected.as_slice() + ); + } + + #[test] + fn shadow_tls_v3_client_hello_record_signer_rewrites_session_id() { + let mut payload = + vec![0u8; SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE + 8]; + payload[0] = 0x01; + payload[SHADOW_TLS_V3_SESSION_ID_START - 1] = SHADOW_TLS_V3_SESSION_ID_SIZE as u8; + payload[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..].fill(0x7a); + + let mut record = vec![0x16, 0x03, 0x03]; + record.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + record.extend_from_slice(&payload); + + assert!( + shadow_tls_v3_sign_client_hello_record("secret", &record[..4]) + .unwrap() + .is_none() + ); + let signed = shadow_tls_v3_sign_client_hello_record("secret", &record) + .unwrap() + .expect("complete ClientHello record is signed"); + assert_eq!(signed.len(), record.len()); + let session_id_start = SHADOW_TLS_V3_TLS_HEADER_SIZE + SHADOW_TLS_V3_SESSION_ID_START; + let session_id = + &signed[session_id_start..session_id_start + SHADOW_TLS_V3_SESSION_ID_SIZE]; + assert_ne!(session_id, &[0u8; SHADOW_TLS_V3_SESSION_ID_SIZE]); + + let mut expected_context = shadow_tls_v3_hmac("secret", b"", b""); + let mut signed_session_id = [0u8; SHADOW_TLS_V3_SESSION_ID_SIZE]; + signed_session_id.copy_from_slice(session_id); + signed_session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..].fill(0); + expected_context.update(&payload[..SHADOW_TLS_V3_SESSION_ID_START]); + expected_context.update(&signed_session_id); + expected_context + .update(&payload[SHADOW_TLS_V3_SESSION_ID_START + SHADOW_TLS_V3_SESSION_ID_SIZE..]); + let expected = shadow_tls_v3_hmac_prefix(&expected_context, SHADOW_TLS_V3_HMAC_SIZE); + assert_eq!( + &session_id[SHADOW_TLS_V3_SESSION_ID_SIZE - SHADOW_TLS_V3_HMAC_SIZE..], + expected.as_slice() + ); + } + + #[tokio::test] + async fn shadow_tls_v3_wraps_and_verifies_application_data() { + let (client, mut server) = tokio::io::duplex(4096); + let server_random = [9u8; SHADOW_TLS_V3_TLS_RANDOM_SIZE]; + let mut stream = ShadowTlsV3Stream::new(client, "secret", server_random, None); + + let task = tokio::spawn(async move { + stream.write_all(b"hello").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + Result::<[u8; 5]>::Ok(body) + }); + + let mut first_record = [0u8; SHADOW_TLS_V3_HMAC_HEADER_SIZE + 5]; + server.read_exact(&mut first_record).await.unwrap(); + assert_eq!(&first_record[..3], &[0x17, 0x03, 0x03]); + assert_eq!( + u16::from_be_bytes([first_record[3], first_record[4]]), + (SHADOW_TLS_V3_HMAC_SIZE + 5) as u16 + ); + let mut client_hmac = shadow_tls_v3_hmac("secret", &server_random, b"C"); + client_hmac.update(b"hello"); + assert_eq!( + &first_record[SHADOW_TLS_V3_TLS_HEADER_SIZE..SHADOW_TLS_V3_HMAC_HEADER_SIZE], + shadow_tls_v3_hmac_prefix(&client_hmac, SHADOW_TLS_V3_HMAC_SIZE).as_slice() + ); + assert_eq!(&first_record[SHADOW_TLS_V3_HMAC_HEADER_SIZE..], b"hello"); + + let mut server_hmac = shadow_tls_v3_hmac("secret", &server_random, b"S"); + let response = shadow_tls_v3_record(b"world", 5, &mut server_hmac).unwrap(); + server.write_all(&response).await.unwrap(); + + assert_eq!(task.await.unwrap().unwrap(), *b"world"); + } + + #[tokio::test] + async fn websocket_plugin_wraps_frames_after_upgrade() { + let (client, mut server) = tokio::io::duplex(4096); + let config = ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::V2ray, + host: "front.example".to_owned(), + path: "ws?ed=0".to_owned(), + headers: vec![("User-Agent".to_owned(), "BurrowTest".to_owned())], + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::None, + v2ray_http_upgrade: false, + v2ray_http_upgrade_fast_open: false, + }; + + let client_task = tokio::spawn(async move { + let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; + stream.write_all(b"hello").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + Result::<[u8; 5]>::Ok(body) + }); + + let request = read_http_headers_for_test(&mut server).await; + assert!(request.starts_with("GET /ws HTTP/1.1\r\n")); + assert!(request.contains("Host: front.example\r\n")); + assert!(request.contains("Upgrade: websocket\r\n")); + assert!(request.contains("Sec-WebSocket-Key: ")); + assert!(request.contains("User-Agent: BurrowTest\r\n")); + server + .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + .await + .unwrap(); + + assert_eq!( + read_client_websocket_frame_for_test(&mut server).await, + b"hello" + ); + server + .write_all(&server_websocket_frame_for_test(b"world")) + .await + .unwrap(); + assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); + } + + #[tokio::test] + async fn websocket_early_data_moves_first_payload_to_protocol_header() { + let (client, mut server) = tokio::io::duplex(4096); + let config = ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::V2ray, + host: "front.example".to_owned(), + path: "/ws?ed=8&x=1".to_owned(), + headers: vec![("Sec-WebSocket-Protocol".to_owned(), "stale".to_owned())], + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::None, + v2ray_http_upgrade: false, + v2ray_http_upgrade_fast_open: false, + }; + + let client_task = tokio::spawn(async move { + let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; + stream.write_all(b"hello-world").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + Result::<[u8; 5]>::Ok(body) + }); + + let request = read_http_headers_for_test(&mut server).await; + assert!(request.starts_with("GET /ws?x=1 HTTP/1.1\r\n")); + assert!(request.contains("Sec-WebSocket-Protocol: aGVsbG8td28\r\n")); + assert!(!request.contains("Sec-WebSocket-Protocol: stale\r\n")); + server + .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + .await + .unwrap(); + assert_eq!( + read_client_websocket_frame_for_test(&mut server).await, + b"rld" + ); + server + .write_all(&server_websocket_frame_for_test(b"world")) + .await + .unwrap(); + assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); + } + + #[tokio::test] + async fn websocket_early_data_without_payload_omits_protocol_header() { + let (client, mut server) = tokio::io::duplex(4096); + let config = ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::V2ray, + host: "front.example".to_owned(), + path: "/ws?ed=8".to_owned(), + headers: Vec::new(), + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::None, + v2ray_http_upgrade: false, + v2ray_http_upgrade_fast_open: false, + }; + + let client_task = tokio::spawn(async move { + let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + Result::<[u8; 5]>::Ok(body) + }); + + let request = read_http_headers_for_test(&mut server).await; + assert!(request.starts_with("GET /ws HTTP/1.1\r\n")); + assert!(!request.contains("Sec-WebSocket-Protocol:")); + server + .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + .await + .unwrap(); + server + .write_all(&server_websocket_frame_for_test(b"world")) + .await + .unwrap(); + assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); + } + + #[tokio::test] + async fn websocket_http_upgrade_leaves_stream_raw() { + let (client, mut server) = tokio::io::duplex(4096); + let config = ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::V2ray, + host: "front.example".to_owned(), + path: "/upgrade".to_owned(), + headers: Vec::new(), + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::None, + v2ray_http_upgrade: true, + v2ray_http_upgrade_fast_open: false, + }; + + let client_task = tokio::spawn(async move { + let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; + stream.write_all(b"raw").await?; + stream.flush().await?; + let mut body = [0u8; 4]; + stream.read_exact(&mut body).await?; + Result::<[u8; 4]>::Ok(body) + }); + + let request = read_http_headers_for_test(&mut server).await; + assert!(request.starts_with("GET /upgrade HTTP/1.1\r\n")); + assert!(!request.contains("Sec-WebSocket-Key")); + server + .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n") + .await + .unwrap(); + let mut raw = [0u8; 3]; + server.read_exact(&mut raw).await.unwrap(); + assert_eq!(&raw, b"raw"); + server.write_all(b"pong").await.unwrap(); + assert_eq!(client_task.await.unwrap().unwrap(), *b"pong"); + } + + #[tokio::test] + async fn websocket_http_upgrade_fast_open_writes_before_response() { + let (client, mut server) = tokio::io::duplex(4096); + let config = ShadowsocksWebSocketTransport { + kind: ShadowsocksWebSocketKind::V2ray, + host: "front.example".to_owned(), + path: "/upgrade".to_owned(), + headers: Vec::new(), + tls: false, + ech: None, + skip_cert_verify: false, + certificate_fingerprint: None, + client_identity: None, + mux: ShadowsocksWebSocketMux::None, + v2ray_http_upgrade: true, + v2ray_http_upgrade_fast_open: true, + }; + + let client_task = tokio::spawn(async move { + let mut stream = websocket_plugin_connect(Box::new(client), &config, 443).await?; + stream.write_all(b"raw").await?; + stream.flush().await?; + let mut body = [0u8; 4]; + stream.read_exact(&mut body).await?; + Result::<[u8; 4]>::Ok(body) + }); + + let request = read_http_headers_for_test(&mut server).await; + assert!(request.starts_with("GET /upgrade HTTP/1.1\r\n")); + let mut raw = [0u8; 3]; + server.read_exact(&mut raw).await.unwrap(); + assert_eq!(&raw, b"raw"); + server + .write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\npong") + .await + .unwrap(); + assert_eq!(client_task.await.unwrap().unwrap(), *b"pong"); + } + + #[tokio::test] + async fn v2ray_mux_wraps_open_and_data_frames() { + let (client, mut server) = tokio::io::duplex(4096); + let mut stream = V2rayMuxStream::new(client); + + let client_task = tokio::spawn(async move { + stream.write_all(b"hello").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + Result::<[u8; 5]>::Ok(body) + }); + + let mut open_len = [0u8; 2]; + server.read_exact(&mut open_len).await.unwrap(); + let open_len = u16::from_be_bytes(open_len) as usize; + let mut open = vec![0u8; open_len]; + server.read_exact(&mut open).await.unwrap(); + assert_eq!(&open[..4], &[0, 0, 0x01, 0x00]); + assert!(open + .windows("127.0.0.1".len()) + .any(|window| window == b"127.0.0.1")); + + let mut data_prefix = [0u8; 8]; + server.read_exact(&mut data_prefix).await.unwrap(); + assert_eq!(&data_prefix[..6], &[0, 4, 0, 0, 0x02, 0x01]); + assert_eq!(u16::from_be_bytes([data_prefix[6], data_prefix[7]]), 5); + let mut payload = [0u8; 5]; + server.read_exact(&mut payload).await.unwrap(); + assert_eq!(&payload, b"hello"); + + server + .write_all(&[0, 4, 0, 0, 0x02, 0x01, 0, 5]) + .await + .unwrap(); + server.write_all(b"world").await.unwrap(); + assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); + } + + #[tokio::test] + async fn gost_smux_stream_opens_client_stream() { + let (client, server) = tokio::io::duplex(4096); + let client_task = tokio::spawn(async move { + let mut stream = gost_smux_stream(Box::new(client)).await?; + stream.write_all(b"hello").await?; + stream.flush().await?; + let mut body = [0u8; 5]; + stream.read_exact(&mut body).await?; + Result::<[u8; 5]>::Ok(body) + }); + + let mut config = tokio_smux::SmuxConfig::default(); + config.keep_alive_disable = true; + let mut session = tokio_smux::Session::server(server, config).unwrap(); + let mut stream = session.accept_stream().await.unwrap(); + assert_eq!(stream.recv_message().await.unwrap().unwrap(), b"hello"); + stream.send_message(b"world".to_vec()).await.unwrap(); + assert_eq!(client_task.await.unwrap().unwrap(), *b"world"); + } + + async fn read_http_headers_for_test(stream: &mut S) -> String + where + S: AsyncRead + Unpin, + { + let mut bytes = Vec::new(); + let mut byte = [0u8; 1]; + loop { + stream.read_exact(&mut byte).await.unwrap(); + bytes.push(byte[0]); + if bytes.ends_with(b"\r\n\r\n") { + return String::from_utf8(bytes).unwrap(); + } + } + } + + async fn read_client_websocket_frame_for_test(stream: &mut S) -> Vec + where + S: AsyncRead + Unpin, + { + let mut header = [0u8; 2]; + stream.read_exact(&mut header).await.unwrap(); + assert_eq!(header[0] & 0x0f, 0x02); + assert_ne!(header[1] & 0x80, 0); + let len = usize::from(header[1] & 0x7f); + let mut mask = [0u8; 4]; + stream.read_exact(&mut mask).await.unwrap(); + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload).await.unwrap(); + apply_websocket_mask(&mut payload, mask); + payload + } + + fn server_websocket_frame_for_test(payload: &[u8]) -> Vec { + let mut frame = Vec::new(); + frame.push(0x82); + frame.push(payload.len() as u8); + frame.extend_from_slice(payload); + frame + } + + #[tokio::test] + async fn custom_aead_byte_reader_reassembles_uot_frame_chunks() { + let kind = CustomSsAeadKind::Aes192Gcm; + let key = Arc::<[u8]>::from(evp_bytes_to_key(b"secret", kind.key_len())); + let target = "8.8.8.8:53".parse::().unwrap(); + let frame = uot_frame(target, b"hello").unwrap(); + let split_at = 3; + let (client, server) = tokio::io::duplex(256); + let mut writer = CustomSsAeadWriter::new(client, kind, key.clone()) + .await + .unwrap(); + let mut reader = + CustomSsAeadByteReader::new(CustomSsAeadReader::new(server, kind, key.clone())); + + let write = tokio::spawn(async move { + writer.write_chunk(&frame[..split_at]).await?; + writer.write_chunk(&frame[split_at..]).await?; + Result::<()>::Ok(()) + }); + + let (decoded_addr, payload) = reader.read_frame().await.unwrap().unwrap(); + write.await.unwrap().unwrap(); + assert_eq!(decoded_addr, target); + assert_eq!(payload, b"hello"); + } + + #[test] + fn aead2022_aes_ccm_keys_follow_mihomo_base64_rules() { + let kind = Aead2022AesCcmKind::Aes128; + let identity = vec![1u8; kind.key_len()]; + let user = vec![2u8; kind.key_len()]; + let password = format!( + "{}:{}", + BASE64_STANDARD.encode(&identity), + BASE64_STANDARD.encode(&user) + ); + let (parsed_user, parsed_identities) = parse_aead2022_keys(kind, &password).unwrap(); + assert_eq!(parsed_user.as_ref(), user.as_slice()); + assert_eq!(parsed_identities.len(), 1); + assert_eq!(parsed_identities[0].as_ref(), identity.as_slice()); + + assert!(parse_aead2022_keys(kind, &BASE64_STANDARD.encode([3u8; 64])).is_err()); + assert!(parse_aead2022_keys(kind, &BASE64_STANDARD.encode([1u8; 8])).is_err()); + } + + #[test] + fn standard_aead2022_keys_follow_mihomo_base64_rules() { + let identity = vec![1u8; 16]; + let user = vec![2u8; 16]; + let password = format!( + "{}:{}", + BASE64_STANDARD.encode(&identity), + BASE64_STANDARD.encode(&user) + ); + let config = ShadowsocksNode { + cipher: "2022-blake3-aes-128-gcm".to_owned(), + password, + udp: true, + udp_over_tcp: false, + udp_over_tcp_version: UOT_VERSION, + client_fingerprint: None, + plugin: None, + smux: None, + }; + let node = ProxyNode { + ordinal: 0, + name: "ss-2022-gcm".to_owned(), + protocol: crate::proxy_subscription::ProxyProtocol::Shadowsocks, + server: "127.0.0.1".to_owned(), + port: 8388, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(config.clone()), + }; + + let protocol = shadowsocks_protocol_for_node(&node, &config).unwrap(); + let ShadowsocksProtocol::Standard { server_config, .. } = protocol else { + panic!("expected delegated standard Shadowsocks protocol"); + }; + assert_eq!(server_config.key(), user.as_slice()); + assert_eq!(server_config.identity_keys().len(), 1); + assert_eq!( + server_config.identity_keys()[0].as_ref(), + identity.as_slice() + ); + + let unpadded_user = BASE64_STANDARD + .encode(&user) + .trim_end_matches('=') + .to_owned(); + assert!( + validate_standard_aead2022_mihomo_password( + SsCipherKind::AEAD2022_BLAKE3_AES_128_GCM, + &unpadded_user + ) + .is_err(), + "Mihomo's base64.StdEncoding requires key padding for 16-byte keys" + ); + assert!( + validate_standard_aead2022_mihomo_password( + SsCipherKind::AEAD2022_BLAKE3_CHACHA20_POLY1305, + &format!( + "{}:{}", + BASE64_STANDARD.encode([3u8; 32]), + BASE64_STANDARD.encode([4u8; 32]) + ) + ) + .is_err(), + "Mihomo rejects EIH identity chains on Shadowsocks 2022 chacha methods" + ); + validate_standard_aead2022_mihomo_password( + SsCipherKind::AEAD2022_BLAKE3_CHACHA20_POLY1305, + &BASE64_STANDARD.encode([5u8; 32]), + ) + .unwrap(); + } + + #[test] + fn aead2022_aes_ccm_tcp_cipher_round_trips_chunks() { + for kind in [Aead2022AesCcmKind::Aes128, Aead2022AesCcmKind::Aes256] { + let key = vec![7u8; kind.key_len()]; + let salt = vec![9u8; kind.key_len()]; + let mut writer = Aead2022TcpCipher::new(kind, &key, &salt).unwrap(); + let mut reader = Aead2022TcpCipher::new(kind, &key, &salt).unwrap(); + + let mut encrypted_len = writer.seal_packet(&5u16.to_be_bytes()).unwrap(); + let len = reader.open_packet(&mut encrypted_len).unwrap(); + assert_eq!(u16::from_be_bytes([len[0], len[1]]), 5); + + let mut encrypted_payload = writer.seal_packet(b"hello").unwrap(); + let payload = reader.open_packet(&mut encrypted_payload).unwrap(); + assert_eq!(payload, b"hello"); + } + } + + #[test] + fn aead2022_aes_ccm_udp_packets_round_trip_client_and_server_shapes() { + for kind in [Aead2022AesCcmKind::Aes128, Aead2022AesCcmKind::Aes256] { + let user_key = Arc::<[u8]>::from(vec![5u8; kind.key_len()]); + let mut session = + Aead2022AesCcmUdpSession::new(kind, user_key.clone(), Arc::from(Vec::new())) + .unwrap(); + let target = "8.8.8.8:443".parse::().unwrap(); + let packet = session.encrypt_client_packet(target, b"hello").unwrap(); + let (decoded_target, decoded_payload) = + decrypt_aead2022_client_packet_for_test(kind, &user_key, &packet).unwrap(); + assert_eq!(decoded_target, target); + assert_eq!(decoded_payload, b"hello"); + + let source = "1.1.1.1:53".parse::().unwrap(); + let response = encrypt_aead2022_server_packet_for_test( + kind, + &user_key, + 99, + 1, + session.client_session_id, + source, + b"world", + ) + .unwrap(); + let (decoded_source, decoded_payload) = + session.decrypt_server_packet(&response).unwrap(); + assert_eq!(decoded_source, source); + assert_eq!(decoded_payload, b"world"); + } + } + + #[test] + fn custom_shadowsocks_aead_udp_packet_round_trips_mihomo_extra_methods() { + for cipher in [ + "aes-192-gcm", + "aes-192-ccm", + "chacha8-ietf-poly1305", + "xchacha8-ietf-poly1305", + "rabbit128-poly1305", + "aegis-128l", + "aegis-256", + "aez-384", + "deoxys-ii-256-128", + "ascon128", + "ascon128a", + "lea-128-gcm", + "lea-192-gcm", + "lea-256-gcm", + ] { + let kind = CustomSsAeadKind::from_name(cipher).unwrap(); + let key = evp_bytes_to_key(b"secret", kind.key_len()); + let target = "8.8.8.8:53".parse().unwrap(); + let packet = encrypt_custom_ss_udp_packet(kind, &key, target, b"hello").unwrap(); + let (decoded_target, payload) = + decrypt_custom_ss_udp_packet(kind, &key, &packet).unwrap(); + assert_eq!(decoded_target, target); + assert_eq!(payload, b"hello"); + } + } + + #[test] + fn custom_shadowsocks_stream_udp_packet_round_trips_mihomo_methods() { + for cipher in ["chacha20", "xchacha20"] { + let kind = CustomSsStreamKind::from_name(cipher).unwrap(); + let key = evp_bytes_to_key(b"secret", kind.key_len()); + let target = "8.8.8.8:53".parse().unwrap(); + let packet = encrypt_custom_ss_stream_udp_packet(kind, &key, target, b"hello").unwrap(); + let (decoded_target, payload) = + decrypt_custom_ss_stream_udp_packet(kind, &key, &packet).unwrap(); + assert_eq!(decoded_target, target); + assert_eq!(payload, b"hello"); + } + } + + #[tokio::test] + async fn custom_shadowsocks_stream_tcp_cipher_keeps_state_across_writes() { + for cipher in ["chacha20", "xchacha20"] { + let kind = CustomSsStreamKind::from_name(cipher).unwrap(); + let key = Arc::<[u8]>::from(evp_bytes_to_key(b"secret", kind.key_len())); + let (client, mut server) = tokio::io::duplex(1024); + let mut writer = CustomSsStreamWriter::new(client, kind, key.clone()) + .await + .unwrap(); + writer.write_all_encrypted(b"hello").await.unwrap(); + writer.write_all_encrypted(b"world").await.unwrap(); + writer.shutdown().await.unwrap(); + + let mut raw = Vec::new(); + server.read_to_end(&mut raw).await.unwrap(); + let (salt, encrypted) = raw.split_at(kind.salt_len()); + let mut decrypted = encrypted.to_vec(); + let mut cipher = CustomSsStreamCipher::new(kind, &key, salt).unwrap(); + cipher.apply_keystream(&mut decrypted); + assert_eq!(decrypted, b"helloworld"); + + let (mut server, client) = tokio::io::duplex(1024); + let salt = vec![7u8; kind.salt_len()]; + let mut encrypted = b"response".to_vec(); + let mut cipher = CustomSsStreamCipher::new(kind, &key, &salt).unwrap(); + cipher.apply_keystream(&mut encrypted[..3]); + cipher.apply_keystream(&mut encrypted[3..]); + server.write_all(&salt).await.unwrap(); + server.write_all(&encrypted).await.unwrap(); + drop(server); + + let mut reader = CustomSsStreamReader::new(client, kind, key); + let mut response = Vec::new(); + let mut output = [0u8; 4]; + loop { + let n = reader.read_decrypted(&mut output).await.unwrap(); + if n == 0 { + break; + } + response.extend_from_slice(&output[..n]); + } + assert_eq!(response, b"response"); + } + } + + #[test] + fn custom_ascon128_matches_v12_reference_vector() { + let key: [u8; 16] = core::array::from_fn(|index| index as u8); + let nonce: [u8; 16] = core::array::from_fn(|index| index as u8); + let mut plaintext = Vec::new(); + let tag = ascon_seal_in_place(&key, &nonce, AsconMode::Ascon128, &mut plaintext); + assert_eq!(hex_lower(&tag), "e355159f292911f794cb1432a0103a8a"); + + let mut plaintext = Vec::new(); + let tag = ascon_seal_in_place(&key, &nonce, AsconMode::Ascon128A, &mut plaintext); + assert_eq!(hex_lower(&tag), "7a834e6f09210957067b10fd831f0078"); + } + + #[test] + fn custom_lea_gcm_matches_mihomo_vectors() { + for (cipher, expected) in [ + ("lea-128-gcm", "b644a1f5ad436a94f4eac45f12010eed1396e49e9b"), + ("lea-192-gcm", "9ed6792982070248de7ddcb355da658774ab781ea0"), + ("lea-256-gcm", "223060f9a91775199a3f9331cd8d410e5935b0be50"), + ] { + let kind = CustomSsAeadKind::from_name(cipher).unwrap(); + let key: Vec = (0..kind.key_len()).map(|index| index as u8).collect(); + let cipher = CustomSsAeadCipher::new(kind, &key).unwrap(); + let mut nonce: Vec = (0..kind.nonce_len()).map(|index| index as u8).collect(); + let mut message = b"hello".to_vec(); + cipher.seal_in_place(&mut nonce, &mut message).unwrap(); + assert_eq!(hex_lower(&message), expected); + } + } + + fn decrypt_aead2022_client_packet_for_test( + kind: Aead2022AesCcmKind, + user_key: &[u8], + packet: &[u8], + ) -> Result<(SocketAddr, Vec)> { + let mut buf = packet.to_vec(); + aead2022_aes_decrypt_block(kind, user_key, &mut buf[..16])?; + let session_id_bytes: [u8; 8] = buf[..8].try_into().expect("fixed session id"); + let cipher = Aead2022AesCcmCipher::new( + kind, + &aead2022_session_key(kind, user_key, &session_id_bytes)?, + )?; + let nonce: [u8; 12] = buf[4..16].try_into().expect("fixed nonce"); + let plaintext = cipher.open_in_place(&nonce, &mut buf[16..])?; + if plaintext[0] != AEAD2022_HEADER_TYPE_CLIENT { + bail!("unexpected test client packet type"); + } + let padding_len = u16::from_be_bytes([plaintext[9], plaintext[10]]) as usize; + let address_offset = 11 + padding_len; + let (target, used) = parse_socks_addr(&plaintext[address_offset..])?; + Ok((target, plaintext[address_offset + used..].to_vec())) + } + + fn encrypt_aead2022_server_packet_for_test( + kind: Aead2022AesCcmKind, + user_key: &[u8], + session_id: u64, + packet_id: u64, + client_session_id: u64, + source: SocketAddr, + payload: &[u8], + ) -> Result> { + let session_id_bytes = session_id.to_be_bytes(); + let cipher = Aead2022AesCcmCipher::new( + kind, + &aead2022_session_key(kind, user_key, &session_id_bytes)?, + )?; + let mut packet = Vec::new(); + packet.extend_from_slice(&session_id.to_be_bytes()); + packet.extend_from_slice(&packet_id.to_be_bytes()); + let data_index = packet.len(); + packet.push(AEAD2022_HEADER_TYPE_SERVER); + packet.extend_from_slice(&aead2022_now_timestamp()?.to_be_bytes()); + packet.extend_from_slice(&client_session_id.to_be_bytes()); + packet.extend_from_slice(&0u16.to_be_bytes()); + packet.extend_from_slice(&socks_addr(source)?); + packet.extend_from_slice(payload); + let nonce: [u8; 12] = packet[4..16].try_into().expect("fixed nonce"); + let mut encrypted_body = packet[data_index..].to_vec(); + cipher.seal_in_place(&nonce, &mut encrypted_body)?; + packet.truncate(data_index); + packet.extend_from_slice(&encrypted_body); + aead2022_aes_encrypt_block(kind, user_key, &mut packet[..16])?; + Ok(packet) + } + + #[test] + fn shadowsocks_address_preserves_fake_dns_hostname() { + let target = ProxyTcpTarget { + address: "198.18.64.1:443".parse().unwrap(), + hostname: Some("example.com.".to_owned()), + }; + + assert_eq!( + shadowsocks_addr_for_target(&target).unwrap(), + SsAddress::DomainNameAddress("example.com".to_owned(), 443) + ); + } + + #[test] + fn trojan_runtime_uses_mihomo_default_alpn_when_absent() { + let mut node = TrojanNode { + password: "secret".to_owned(), + sni: None, + alpn: Vec::new(), + alpn_present: false, + skip_cert_verify: false, + network: TrojanNetwork::Tcp, + udp: true, + client_fingerprint: None, + fingerprint: None, + }; + assert_eq!(trojan_alpn(&node), vec!["h2", "http/1.1"]); + + node.alpn_present = true; + assert!(trojan_alpn(&node).is_empty()); + + node.alpn = vec!["h3".to_owned()]; + assert_eq!(trojan_alpn(&node), vec!["h3"]); + } + + #[test] + fn parses_certificate_fingerprint_hex() { + let fp = parse_certificate_fingerprint( + "00:112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + ) + .unwrap(); + assert_eq!(fp.0[0], 0x00); + assert_eq!(fp.0[1], 0x11); + assert_eq!(fp.0[31], 0xff); + assert!(parse_certificate_fingerprint("chrome").is_err()); + assert!(parse_certificate_fingerprint("abcd").is_err()); + } + + #[tokio::test] + async fn trojan_udp_writer_fragments_oversized_payloads() { + let target = socks_addr("1.2.3.4:53".parse().unwrap()).unwrap(); + let payload = vec![7u8; TROJAN_MAX_UDP_PAYLOAD + 3]; + let (mut writer, mut reader) = tokio::io::duplex(20_000); + + let write = + tokio::spawn( + async move { write_trojan_udp_packet(&mut writer, &target, &payload).await }, + ); + + let mut encoded = Vec::new(); + reader.read_to_end(&mut encoded).await.unwrap(); + write.await.unwrap().unwrap(); + + let first_payload_offset = 7 + 2 + 2; + assert_eq!( + u16::from_be_bytes([encoded[7], encoded[8]]) as usize, + TROJAN_MAX_UDP_PAYLOAD + ); + assert_eq!(&encoded[9..11], b"\r\n"); + assert_eq!(encoded[first_payload_offset], 7); + let second = 7 + 2 + 2 + TROJAN_MAX_UDP_PAYLOAD; + assert_eq!( + u16::from_be_bytes([encoded[second + 7], encoded[second + 8]]) as usize, + 3 + ); + assert_eq!(&encoded[second + 9..second + 11], b"\r\n"); + assert_eq!(encoded.len(), second + 11 + 3); + } + + #[test] + fn fake_ip_detection_flags_benchmark_range() { + assert!(is_fake_or_benchmark_ip("198.18.0.1".parse().unwrap())); + assert!(is_fake_or_benchmark_ip("198.19.255.254".parse().unwrap())); + assert!(!is_fake_or_benchmark_ip("198.20.0.1".parse().unwrap())); + assert!(!is_fake_or_benchmark_ip("2001:db8::1".parse().unwrap())); + } + + #[test] + fn proxy_outbound_socket_binding_skips_loopback() { + assert!(!should_bind_proxy_outbound_socket( + "127.0.0.1".parse().unwrap() + )); + assert!(!should_bind_proxy_outbound_socket("::1".parse().unwrap())); + assert!(should_bind_proxy_outbound_socket( + "192.0.2.10".parse().unwrap() + )); + } + + #[test] + fn dns_query_encodes_host_labels() { + let query = build_dns_query("example.com", 1).unwrap(); + assert_eq!(&query[12..25], b"\x07example\x03com\0"); + assert_eq!(u16::from_be_bytes([query[25], query[26]]), 1); + assert_eq!(u16::from_be_bytes([query[27], query[28]]), 1); + } + + #[test] + fn dns_response_parser_reads_compressed_a_and_aaaa_answers() { + let mut response = Vec::new(); + response.extend_from_slice(&0x1234u16.to_be_bytes()); + response.extend_from_slice(&0x8180u16.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&2u16.to_be_bytes()); + response.extend_from_slice(&0u16.to_be_bytes()); + response.extend_from_slice(&0u16.to_be_bytes()); + response.extend_from_slice(b"\x07example\x03com\0"); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&[0xc0, 0x0c]); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&60u32.to_be_bytes()); + response.extend_from_slice(&4u16.to_be_bytes()); + response.extend_from_slice(&[93, 184, 216, 34]); + response.extend_from_slice(&[0xc0, 0x0c]); + response.extend_from_slice(&28u16.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&60u32.to_be_bytes()); + response.extend_from_slice(&16u16.to_be_bytes()); + response.extend_from_slice(&[ + 0x26, 0x06, 0x28, 0x00, 0x02, 0x20, 0x00, 0x01, 0x02, 0x48, 0x18, 0x93, 0x25, 0xc8, + 0x19, 0x46, + ]); + + let addresses = parse_dns_response(&response, 0x1234).unwrap(); + assert_eq!( + addresses, + vec![ + "93.184.216.34".parse::().unwrap(), + "2606:2800:220:1:248:1893:25c8:1946" + .parse::() + .unwrap(), + ] + ); + } + + #[test] + fn dns_https_parser_extracts_ech_service_parameter() { + let mut response = build_dns_query("example.com", DNS_TYPE_HTTPS).unwrap(); + response[0..2].copy_from_slice(&0x1234u16.to_be_bytes()); + response[2..4].copy_from_slice(&0x8180u16.to_be_bytes()); + response[6..8].copy_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&[0xc0, 0x0c]); + response.extend_from_slice(&DNS_TYPE_HTTPS.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.extend_from_slice(&60u32.to_be_bytes()); + response.extend_from_slice(&10u16.to_be_bytes()); + response.extend_from_slice(&1u16.to_be_bytes()); + response.push(0); + response.extend_from_slice(&HTTPS_SVC_PARAM_ECH.to_be_bytes()); + response.extend_from_slice(&3u16.to_be_bytes()); + response.extend_from_slice(&[0xaa, 0xbb, 0xcc]); + + assert_eq!( + parse_dns_https_ech_config_list(&response, 0x1234).unwrap(), + Some(vec![0xaa, 0xbb, 0xcc]) + ); + } + + #[tokio::test] + async fn fake_dns_response_returns_stable_hostname_mapping() { + let query = build_dns_query("example.com", 1).unwrap(); + let cache = Arc::new(RwLock::new(DnsHostnameCacheState::new())); + let response = build_fake_dns_response(&cache, &query).await.unwrap(); + let addresses = parse_dns_response(&response, 0).unwrap(); + assert_eq!(addresses, vec!["198.18.64.1".parse::().unwrap()]); + assert_eq!( + cache + .read() + .await + .lookup_hostname("198.18.64.1".parse().unwrap()), + Some("example.com".to_owned()) + ); + } + + #[test] + fn runtime_selection_skips_unsupported_transports_when_unselected() { + let preview = crate::proxy_subscription::parse_subscription_body( + r#" +proxies: + - name: trojan-grpc + type: trojan + server: example.com + port: 443 + password: secret + network: grpc + - name: ss-aead + type: ss + server: example.net + port: 8388 + cipher: aes-128-gcm + password: secret +"#, + Some("https://example.com/sub"), + ); + let payload = crate::proxy_subscription::build_payload( + preview, + "https://example.com/sub", + Some("Proxy Subscription".to_owned()), + None, + ); + assert_eq!(selected_node(&payload).unwrap().name, "ss-aead"); + } + + #[test] + fn runtime_selection_prefers_selected_name_over_stale_ordinal() { + let preview = crate::proxy_subscription::parse_subscription_body( + r#" +proxies: + - name: first + type: ss + server: first.example.net + port: 8388 + cipher: aes-128-gcm + password: secret + - name: selected + type: ss + server: selected.example.net + port: 8388 + cipher: aes-128-gcm + password: secret +"#, + Some("https://example.com/sub"), + ); + let mut payload = crate::proxy_subscription::build_payload( + preview, + "https://example.com/sub", + Some("Proxy Subscription".to_owned()), + Some(0), + ); + payload.selected_name = Some("selected".to_owned()); + + assert_eq!(selected_node(&payload).unwrap().name, "selected"); + } + + #[test] + fn proxy_server_bypass_routes_use_host_prefixes() { + let routes = excluded_routes_for_server("127.0.0.1", 443).unwrap(); + assert_eq!(routes, vec!["127.0.0.1/32"]); + } + + #[test] + fn parses_physical_dns_from_scutil_output() { + let servers = parse_apple_physical_dns_servers( + r#" +DNS configuration + +resolver #1 + nameserver[0] : 2606:4700:4700::1111 + nameserver[1] : 1.1.1.1 + if_index : 35 (utun8) + +DNS configuration (for scoped queries) + +resolver #1 + search domain[0] : lan + nameserver[0] : 192.168.2.1 + if_index : 17 (en0) + +resolver #2 + nameserver[0] : 198.18.0.1 + if_index : 35 (utun8) +"#, + ); + assert_eq!(servers, vec!["192.168.2.1"]); + } + + #[test] + fn parses_resolv_conf_dns_servers() { + let servers = parse_resolv_conf_dns_servers( + r#" +# macOS generated resolver file +nameserver 192.168.2.1 +nameserver 198.18.0.1 +nameserver 192.168.2.1 +nameserver ::1 +"#, + ); + assert_eq!(servers, vec!["192.168.2.1"]); + } + + #[test] + fn proxy_dns_excluded_routes_use_host_prefixes() { + let routes = proxy_dns_excluded_routes(&[ + "100.64.0.2".to_owned(), + "192.168.2.1".to_owned(), + "2001:db8::53".to_owned(), + "not-an-ip".to_owned(), + ]); + assert_eq!(routes, vec!["192.168.2.1/32", "2001:db8::53/128"]); + } + + #[test] + fn proxy_dns_servers_use_daemon_resolver() { + assert_eq!(proxy_dns_servers(), vec!["100.64.0.2"]); + } +} diff --git a/burrow/src/proxy_subscription.rs b/burrow/src/proxy_subscription.rs new file mode 100644 index 0000000..428f74d --- /dev/null +++ b/burrow/src/proxy_subscription.rs @@ -0,0 +1,1455 @@ +use std::{collections::HashMap, time::Duration}; + +use anyhow::{anyhow, bail, Context, Result}; +use base64::{ + engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD}, + Engine as _, +}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use serde_yaml::Value; + +const MAX_SUBSCRIPTION_BYTES: usize = 1024 * 1024; +const SUBSCRIPTION_USER_AGENT: &str = "mihomo/1.18.3"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProxySubscriptionPayload { + pub name: String, + pub source: ProxySubscriptionSource, + pub detected_format: ProxySubscriptionFormat, + pub selected_ordinal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_name: Option, + pub nodes: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProxySubscriptionSource { + pub url: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum ProxySubscriptionFormat { + ClashYaml, + UriList, +} + +impl ProxySubscriptionFormat { + pub fn as_str(self) -> &'static str { + match self { + Self::ClashYaml => "clash-yaml", + Self::UriList => "uri-list", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProxyNode { + pub ordinal: usize, + pub name: String, + pub protocol: ProxyProtocol, + pub server: String, + pub port: u16, + pub warnings: Vec, + pub config: ProxyNodeConfig, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum ProxyProtocol { + Trojan, + Shadowsocks, +} + +impl ProxyProtocol { + pub fn as_str(self) -> &'static str { + match self { + Self::Trojan => "trojan", + Self::Shadowsocks => "ss", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ProxyNodeConfig { + Trojan(TrojanNode), + Shadowsocks(ShadowsocksNode), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct TrojanNode { + pub password: String, + pub sni: Option, + pub alpn: Vec, + #[serde(default)] + pub alpn_present: bool, + pub skip_cert_verify: bool, + pub network: TrojanNetwork, + #[serde(default = "default_true")] + pub udp: bool, + pub client_fingerprint: Option, + pub fingerprint: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum TrojanNetwork { + Tcp, + Ws { + path: Option, + host: Option, + }, + Grpc { + service_name: Option, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShadowsocksNode { + pub cipher: String, + pub password: String, + pub udp: bool, + pub udp_over_tcp: bool, + #[serde(default = "default_uot_legacy_version")] + pub udp_over_tcp_version: u8, + pub client_fingerprint: Option, + pub plugin: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub smux: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShadowsocksPlugin { + pub name: String, + pub opts: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShadowsocksSmux { + #[serde(default)] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_connections: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub min_streams: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_streams: Option, + #[serde(default)] + pub padding: bool, + #[serde(default)] + pub statistic: bool, + #[serde(default)] + pub only_tcp: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub brutal: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ShadowsocksSmuxBrutal { + #[serde(default)] + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub up: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub down: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxySubscriptionPreview { + pub suggested_name: String, + pub detected_format: ProxySubscriptionFormat, + pub nodes: Vec, + pub rejected: Vec, + pub warnings: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProxyRejectedEntry { + pub ordinal: usize, + pub scheme: Option, + pub reason: String, +} + +#[derive(Debug, Deserialize)] +struct ProxySchema { + proxies: Option>>, +} + +pub async fn fetch_subscription(url: &str) -> Result { + let redacted_url = redacted_subscription_url(url); + let client = reqwest::Client::builder() + .user_agent(SUBSCRIPTION_USER_AGENT) + .redirect(reqwest::redirect::Policy::limited(5)) + .timeout(Duration::from_secs(20)) + .build()?; + let response = client + .get(url) + .send() + .await + .with_context(|| format!("failed to fetch {redacted_url}"))?; + let status = response.status(); + let body = response + .bytes() + .await + .with_context(|| format!("failed to read subscription body from {redacted_url}"))?; + if !status.is_success() { + bail!( + "subscription request failed for {}: HTTP {}{}", + redacted_url, + status.as_u16(), + subscription_error_body(&body) + ); + } + if body.len() > MAX_SUBSCRIPTION_BYTES { + bail!("subscription body exceeds {} bytes", MAX_SUBSCRIPTION_BYTES); + } + Ok(String::from_utf8_lossy(&body).into_owned()) +} + +fn redacted_subscription_url(raw: &str) -> String { + let Ok(mut url) = Url::parse(raw) else { + return "".to_owned(); + }; + + if !url.username().is_empty() { + let _ = url.set_username("REDACTED"); + } + if url.password().is_some() { + let _ = url.set_password(Some("REDACTED")); + } + if url.query().is_some() { + url.set_query(Some("REDACTED")); + } + if url.fragment().is_some() { + url.set_fragment(Some("REDACTED")); + } + url.to_string() +} + +fn subscription_error_body(body: &[u8]) -> String { + let excerpt = String::from_utf8_lossy(&body[..body.len().min(160)]) + .chars() + .map(|ch| if ch.is_control() { ' ' } else { ch }) + .collect::() + .trim() + .to_owned(); + if excerpt.is_empty() { + String::new() + } else { + format!(": {excerpt}") + } +} + +pub fn build_payload( + preview: ProxySubscriptionPreview, + source_url: &str, + name: Option, + selected_ordinal: Option, +) -> ProxySubscriptionPayload { + let selected_name = selected_ordinal.and_then(|ordinal| { + preview + .nodes + .iter() + .find(|node| node.ordinal == ordinal) + .map(|node| node.name.clone()) + }); + ProxySubscriptionPayload { + name: name + .and_then(|value| non_empty(value.trim()).map(ToOwned::to_owned)) + .unwrap_or_else(|| preview.suggested_name.clone()), + source: ProxySubscriptionSource { url: source_url.to_owned() }, + detected_format: preview.detected_format, + selected_ordinal, + selected_name, + nodes: preview.nodes, + warnings: preview.warnings, + } +} + +pub fn validate_payload(payload: &ProxySubscriptionPayload) -> Result<()> { + if payload.name.trim().is_empty() { + bail!("proxy subscription name must not be empty"); + } + if payload.nodes.is_empty() { + bail!("proxy subscription must include at least one compatible node"); + } + if let Some(selected) = payload.selected_ordinal { + if !payload.nodes.iter().any(|node| node.ordinal == selected) { + bail!("selected proxy node ordinal {selected} is not present in subscription"); + } + } + if let Some(selected) = &payload.selected_name { + if !payload.nodes.iter().any(|node| node.name == *selected) { + bail!("selected proxy node {selected} is not present in subscription"); + } + } + for node in &payload.nodes { + validate_node(node)?; + } + Ok(()) +} + +pub fn parse_subscription_body(body: &str, source_url: Option<&str>) -> ProxySubscriptionPreview { + match parse_yaml_subscription(body, source_url) { + Ok(preview) => preview, + Err(yaml_error) => { + let mut preview = parse_uri_subscription(body, source_url); + if preview.nodes.is_empty() && preview.rejected.is_empty() { + preview.warnings.push(format!( + "subscription is not Mihomo YAML and did not contain proxy URIs: {yaml_error}" + )); + } + preview + } + } +} + +pub fn summarize_payload(payload: &ProxySubscriptionPayload) -> String { + let selected = payload + .selected_name + .as_deref() + .and_then(|name| payload.nodes.iter().find(|node| node.name == name)) + .or_else(|| { + payload + .selected_ordinal + .and_then(|ordinal| payload.nodes.iter().find(|node| node.ordinal == ordinal)) + }) + .or_else(|| payload.nodes.first()); + match selected { + Some(node) => format!( + "{} - {} {}:{} ({} nodes)", + payload.name, + node.protocol.as_str(), + node.server, + node.port, + payload.nodes.len() + ), + None => format!("{} - no compatible nodes", payload.name), + } +} + +fn parse_yaml_subscription( + body: &str, + source_url: Option<&str>, +) -> Result { + let value: Value = serde_yaml::from_str(body).context("subscription is not proxy YAML")?; + let mapping = value + .as_mapping() + .ok_or_else(|| anyhow!("YAML subscription must be a mapping"))?; + if !mapping.contains_key(Value::String("proxies".to_owned())) { + bail!("YAML subscription must include a `proxies` field"); + } + + let schema: ProxySchema = + serde_yaml::from_str(body).context("subscription is not proxy YAML")?; + let proxies = schema + .proxies + .ok_or_else(|| anyhow!("YAML subscription must include a `proxies` field"))?; + let mut nodes = Vec::new(); + let mut rejected = Vec::new(); + let mut warnings = Vec::new(); + + for (ordinal, mapping) in proxies.into_iter().enumerate() { + let scheme = yaml_string(&mapping, "type").map(str::to_owned); + match parse_yaml_proxy(ordinal, mapping) { + Ok(node) => nodes.push(node), + Err(error) => rejected.push(ProxyRejectedEntry { + ordinal, + scheme, + reason: error.to_string(), + }), + } + } + + if nodes.is_empty() { + warnings.push("no compatible Trojan or Shadowsocks nodes found in YAML".to_owned()); + } + + Ok(ProxySubscriptionPreview { + suggested_name: suggested_name(source_url), + detected_format: ProxySubscriptionFormat::ClashYaml, + nodes, + rejected, + warnings, + }) +} + +fn parse_uri_subscription(body: &str, source_url: Option<&str>) -> ProxySubscriptionPreview { + let decoded = decode_subscription_body(body).unwrap_or_else(|| body.to_owned()); + let mut nodes = Vec::new(); + let mut rejected = Vec::new(); + + for (ordinal, raw) in decoded + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .enumerate() + { + let scheme = raw + .split_once("://") + .map(|(scheme, _)| scheme.to_ascii_lowercase()); + let parsed = match scheme.as_deref() { + Some("trojan") => parse_trojan_uri(raw, ordinal), + Some("ss") => parse_shadowsocks_uri(raw, ordinal), + Some(other) => Err(anyhow!("unsupported proxy URI scheme {other}")), + None => Err(anyhow!("missing proxy URI scheme")), + }; + match parsed { + Ok(node) => nodes.push(node), + Err(error) => rejected.push(ProxyRejectedEntry { + ordinal, + scheme, + reason: error.to_string(), + }), + } + } + + ProxySubscriptionPreview { + suggested_name: suggested_name(source_url), + detected_format: ProxySubscriptionFormat::UriList, + nodes, + rejected, + warnings: Vec::new(), + } +} + +fn parse_yaml_proxy(ordinal: usize, mapping: HashMap) -> Result { + let proxy_type = required_yaml_string(&mapping, "type")?.to_ascii_lowercase(); + match proxy_type.as_str() { + "trojan" => parse_yaml_trojan(ordinal, &mapping), + "ss" | "shadowsocks" => parse_yaml_shadowsocks(ordinal, &mapping), + _ => bail!("unsupported proxy type {proxy_type}"), + } +} + +fn parse_yaml_trojan(ordinal: usize, mapping: &HashMap) -> Result { + let name = required_yaml_string(mapping, "name")?.to_owned(); + let server = required_yaml_string(mapping, "server")?.to_owned(); + let port = required_yaml_u16(mapping, "port")?; + let password = required_yaml_string(mapping, "password")?.to_owned(); + let alpn = yaml_string(mapping, "alpn") + .map(split_csv) + .or_else(|| yaml_string_vec(mapping, "alpn")) + .unwrap_or_default(); + let alpn_present = mapping.contains_key("alpn"); + let skip_cert_verify = yaml_bool(mapping, "skip-cert-verify").unwrap_or(false); + let network = match yaml_string(mapping, "network") + .unwrap_or("tcp") + .to_ascii_lowercase() + .as_str() + { + "tcp" | "" => TrojanNetwork::Tcp, + "ws" => { + let ws_opts = yaml_mapping(mapping, "ws-opts"); + let host = ws_opts + .as_ref() + .and_then(|opts| yaml_mapping(opts, "headers")) + .and_then(|headers| { + yaml_string(&headers, "Host") + .or_else(|| yaml_string(&headers, "host")) + .map(ToOwned::to_owned) + }); + TrojanNetwork::Ws { + path: ws_opts + .as_ref() + .and_then(|opts| yaml_string(opts, "path").map(ToOwned::to_owned)), + host, + } + } + "grpc" => { + let grpc_opts = yaml_mapping(mapping, "grpc-opts"); + TrojanNetwork::Grpc { + service_name: grpc_opts + .as_ref() + .and_then(|opts| yaml_string(opts, "grpc-service-name")) + .map(ToOwned::to_owned), + } + } + other => bail!("unsupported Trojan network {other}"), + }; + Ok(ProxyNode { + ordinal, + name, + protocol: ProxyProtocol::Trojan, + server, + port, + warnings: Vec::new(), + config: ProxyNodeConfig::Trojan(TrojanNode { + password, + sni: yaml_string(mapping, "sni").map(ToOwned::to_owned), + alpn, + alpn_present, + skip_cert_verify, + network, + udp: yaml_bool(mapping, "udp").unwrap_or(false), + client_fingerprint: yaml_string(mapping, "client-fingerprint").map(ToOwned::to_owned), + fingerprint: yaml_string(mapping, "fingerprint").map(ToOwned::to_owned), + }), + }) +} + +fn parse_yaml_shadowsocks(ordinal: usize, mapping: &HashMap) -> Result { + let name = required_yaml_scalar_string(mapping, "name")?; + let server = required_yaml_scalar_string(mapping, "server")?; + let port = required_yaml_u16(mapping, "port")?; + let cipher = required_yaml_scalar_string(mapping, "cipher")?; + let password = required_yaml_scalar_string(mapping, "password")?; + Ok(ProxyNode { + ordinal, + name, + protocol: ProxyProtocol::Shadowsocks, + server, + port, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher, + password, + udp: yaml_bool(mapping, "udp").unwrap_or(false), + udp_over_tcp: yaml_bool(mapping, "udp-over-tcp").unwrap_or(false), + udp_over_tcp_version: yaml_u8(mapping, "udp-over-tcp-version") + .unwrap_or(UOT_LEGACY_VERSION), + client_fingerprint: yaml_scalar_string(mapping, "client-fingerprint"), + plugin: yaml_plugin(mapping), + smux: yaml_smux(mapping), + }), + }) +} + +fn parse_trojan_uri(uri: &str, ordinal: usize) -> Result { + let url = Url::parse(uri).context("invalid Trojan URI")?; + let server = url + .host_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("missing Trojan server host"))?; + let port = url + .port() + .ok_or_else(|| anyhow!("missing Trojan server port"))?; + let password = percent_decode(url.username())?; + if password.is_empty() { + bail!("missing Trojan password"); + } + + let query = url.query_pairs().collect::>(); + let skip_cert_verify = query + .get("allowInsecure") + .or_else(|| query.get("skip-cert-verify")) + .or_else(|| query.get("skipCertVerify")) + .map(|value| is_truthy(value)) + .unwrap_or(false); + let network = query + .get("type") + .or_else(|| query.get("network")) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_else(|| "tcp".to_owned()); + let trojan_network = match network.as_str() { + "" | "tcp" => TrojanNetwork::Tcp, + "ws" => TrojanNetwork::Ws { + path: query + .get("path") + .and_then(|value| non_empty(value).map(ToOwned::to_owned)), + host: query + .get("host") + .or_else(|| query.get("Host")) + .and_then(|value| non_empty(value).map(ToOwned::to_owned)), + }, + "grpc" => TrojanNetwork::Grpc { + service_name: query + .get("serviceName") + .or_else(|| query.get("grpc-service-name")) + .and_then(|value| non_empty(value).map(ToOwned::to_owned)), + }, + other => bail!("unsupported Trojan network {other}"), + }; + let fingerprint = query + .get("fp") + .and_then(|value| non_empty(value).map(ToOwned::to_owned)) + .or_else(|| Some("chrome".to_owned())); + let alpn = query + .get("alpn") + .and_then(|value| non_empty(value).map(split_csv)) + .unwrap_or_default(); + let alpn_present = !alpn.is_empty(); + + Ok(ProxyNode { + ordinal, + name: uri_fragment_name(&url, "Proxy Node", ordinal)?, + protocol: ProxyProtocol::Trojan, + server, + port, + warnings: Vec::new(), + config: ProxyNodeConfig::Trojan(TrojanNode { + password, + sni: query + .get("sni") + .or_else(|| query.get("peer")) + .or_else(|| query.get("host")) + .and_then(|value| non_empty(value).map(ToOwned::to_owned)), + alpn, + alpn_present, + skip_cert_verify, + network: trojan_network, + udp: true, + client_fingerprint: fingerprint, + fingerprint: query + .get("pcs") + .and_then(|value| non_empty(value).map(ToOwned::to_owned)), + }), + }) +} + +fn parse_shadowsocks_uri(uri: &str, ordinal: usize) -> Result { + let mut url = Url::parse(uri).context("invalid Shadowsocks URI")?; + if url.port().is_none() { + let decoded_host = decode_base64_segment(url.host_str().unwrap_or_default()) + .context("invalid legacy Shadowsocks host encoding")?; + url = Url::parse(&format!("ss://{decoded_host}")) + .context("invalid decoded legacy Shadowsocks URI")?; + } + let server = url + .host_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("missing Shadowsocks server host"))?; + let port = url + .port() + .ok_or_else(|| anyhow!("missing Shadowsocks server port"))?; + let user = url.username(); + let (cipher, password) = match url.password() { + Some(password) => (user.to_owned(), password.to_owned()), + None => decode_base64_segment(user) + .ok() + .and_then(|decoded| { + decoded + .split_once(':') + .map(|(a, b)| (a.to_owned(), b.to_owned())) + }) + .ok_or_else(|| anyhow!("missing Shadowsocks cipher/password"))?, + }; + let query = url.query_pairs().collect::>(); + let udp_over_tcp = query + .get("udp-over-tcp") + .map(|value| value == "true") + .unwrap_or(false) + || query.get("uot").map(|value| value == "1").unwrap_or(false); + let udp_over_tcp_version = query + .get("udp-over-tcp-version") + .and_then(|value| value.parse::().ok()) + .or_else(|| { + query + .get("uot-version") + .and_then(|value| value.parse::().ok()) + }) + .unwrap_or(UOT_LEGACY_VERSION); + + Ok(ProxyNode { + ordinal, + name: uri_fragment_name(&url, "Proxy Node", ordinal)?, + protocol: ProxyProtocol::Shadowsocks, + server, + port, + warnings: Vec::new(), + config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode { + cipher, + password, + udp: true, + udp_over_tcp, + udp_over_tcp_version, + client_fingerprint: query + .get("client-fingerprint") + .or_else(|| query.get("fp")) + .and_then(|value| non_empty(value).map(ToOwned::to_owned)), + plugin: query.get("plugin").and_then(|value| parse_ss_plugin(value)), + smux: None, + }), + }) +} + +fn validate_node(node: &ProxyNode) -> Result<()> { + if node.name.trim().is_empty() { + bail!("proxy node name must not be empty"); + } + if node.server.trim().is_empty() { + bail!("proxy node server must not be empty"); + } + match &node.config { + ProxyNodeConfig::Trojan(config) if config.password.is_empty() => { + bail!("Trojan node password must not be empty") + } + ProxyNodeConfig::Shadowsocks(config) + if config.cipher.is_empty() || config.password.is_empty() => + { + bail!("Shadowsocks node cipher and password must not be empty") + } + _ => Ok(()), + } +} + +fn yaml_string<'a>(mapping: &'a HashMap, key: &str) -> Option<&'a str> { + mapping.get(key).and_then(Value::as_str) +} + +fn yaml_scalar_string(mapping: &HashMap, key: &str) -> Option { + yaml_value_to_scalar_string(mapping.get(key)?) +} + +fn yaml_value_to_scalar_string(value: &Value) -> Option { + value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| value.as_bool().map(|value| value.to_string())) + .or_else(|| value.as_i64().map(|value| value.to_string())) + .or_else(|| value.as_u64().map(|value| value.to_string())) + .or_else(|| value.as_f64().map(|value| value.to_string())) +} + +fn yaml_string_vec(mapping: &HashMap, key: &str) -> Option> { + let values = mapping.get(key)?.as_sequence()?; + Some( + values + .iter() + .filter_map(Value::as_str) + .map(ToOwned::to_owned) + .collect(), + ) +} + +fn yaml_mapping<'a>( + mapping: &'a HashMap, + key: &str, +) -> Option> { + mapping.get(key)?.as_mapping().and_then(|map| { + map.iter() + .map(|(key, value)| Some((key.as_str()?.to_owned(), value.clone()))) + .collect::>>() + }) +} + +fn required_yaml_string<'a>(mapping: &'a HashMap, key: &str) -> Result<&'a str> { + yaml_string(mapping, key).ok_or_else(|| anyhow!("missing `{key}`")) +} + +fn required_yaml_scalar_string(mapping: &HashMap, key: &str) -> Result { + yaml_scalar_string(mapping, key).ok_or_else(|| anyhow!("missing `{key}`")) +} + +fn required_yaml_u16(mapping: &HashMap, key: &str) -> Result { + if let Some(value) = mapping.get(key).and_then(Value::as_u64) { + return u16::try_from(value).with_context(|| format!("invalid `{key}`")); + } + let value = required_yaml_string(mapping, key)?; + value + .parse::() + .with_context(|| format!("invalid `{key}`")) +} + +fn yaml_bool(mapping: &HashMap, key: &str) -> Option { + mapping + .get(key) + .and_then(Value::as_bool) + .or_else(|| { + mapping + .get(key) + .and_then(Value::as_i64) + .map(|value| value != 0) + }) + .or_else(|| { + mapping + .get(key) + .and_then(Value::as_u64) + .map(|value| value != 0) + }) + .or_else(|| yaml_string(mapping, key).map(is_truthy)) +} + +fn yaml_u8(mapping: &HashMap, key: &str) -> Option { + mapping + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u8::try_from(value).ok()) + .or_else(|| yaml_string(mapping, key).and_then(|value| value.parse::().ok())) +} + +fn yaml_u32(mapping: &HashMap, key: &str) -> Option { + mapping + .get(key) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .or_else(|| yaml_string(mapping, key).and_then(|value| value.parse::().ok())) +} + +fn yaml_smux(mapping: &HashMap) -> Option { + let smux = yaml_mapping(mapping, "smux")?; + let brutal = yaml_mapping(&smux, "brutal-opts").map(|brutal| ShadowsocksSmuxBrutal { + enabled: yaml_bool(&brutal, "enabled").unwrap_or(false), + up: yaml_string(&brutal, "up").map(ToOwned::to_owned), + down: yaml_string(&brutal, "down").map(ToOwned::to_owned), + }); + + Some(ShadowsocksSmux { + enabled: yaml_bool(&smux, "enabled").unwrap_or(false), + protocol: yaml_string(&smux, "protocol").map(ToOwned::to_owned), + max_connections: yaml_u32(&smux, "max-connections"), + min_streams: yaml_u32(&smux, "min-streams"), + max_streams: yaml_u32(&smux, "max-streams"), + padding: yaml_bool(&smux, "padding").unwrap_or(false), + statistic: yaml_bool(&smux, "statistic").unwrap_or(false), + only_tcp: yaml_bool(&smux, "only-tcp").unwrap_or(false), + brutal, + }) +} + +fn yaml_plugin(mapping: &HashMap) -> Option { + let name = yaml_scalar_string(mapping, "plugin")?; + let mut opts: HashMap = yaml_mapping(mapping, "plugin-opts") + .map(|opts| { + opts.iter() + .filter_map(|(key, value)| { + yaml_plugin_value(value).map(|value| (key.clone(), value)) + }) + .collect() + }) + .unwrap_or_default(); + if let Some(headers) = + yaml_mapping(mapping, "plugin-opts").and_then(|opts| yaml_mapping(&opts, "headers")) + { + for (key, value) in headers { + if let Some(value) = yaml_plugin_value(&value) { + opts.insert(format!("headers.{key}"), value.to_owned()); + } + } + } + if let Some(ech_opts) = + yaml_mapping(mapping, "plugin-opts").and_then(|opts| yaml_mapping(&opts, "ech-opts")) + { + for (key, value) in ech_opts { + if let Some(value) = yaml_plugin_value(&value) { + opts.insert(format!("ech-opts.{key}"), value.to_owned()); + } + } + } + Some(ShadowsocksPlugin { name, opts }) +} + +fn yaml_plugin_value(value: &Value) -> Option { + yaml_value_to_scalar_string(value).or_else(|| { + value.as_sequence().map(|values| { + values + .iter() + .filter_map(yaml_value_to_scalar_string) + .collect::>() + .join(",") + }) + }) +} + +fn parse_ss_plugin(plugin: &str) -> Option { + if !plugin.contains(';') { + return None; + } + let (name, rest) = plugin.split_once(';').unwrap_or((plugin, "")); + let mut opts = HashMap::new(); + for part in rest.split(';').filter(|part| !part.is_empty()) { + if let Some((key, value)) = part.split_once('=') { + let key = decode_ss_plugin_component(key); + if let Some(key) = non_empty(&key) { + opts.insert(key.to_owned(), decode_ss_plugin_component(value)); + } + } else if let Some(key) = non_empty(part) { + let key = decode_ss_plugin_component(key); + if let Some(key) = non_empty(&key) { + opts.insert(key.to_owned(), "true".to_owned()); + } + } + } + let name = non_empty(name)?; + let normalized_name = normalize_ss_uri_plugin_name(name)?; + normalize_ss_uri_plugin_opts(plugin, normalized_name, &mut opts); + Some(ShadowsocksPlugin { + name: normalized_name.to_owned(), + opts, + }) +} + +fn normalize_ss_uri_plugin_name(name: &str) -> Option<&str> { + let normalized = name.trim().to_ascii_lowercase(); + if normalized.contains("obfs") { + Some("obfs") + } else if normalized.contains("v2ray-plugin") { + Some("v2ray-plugin") + } else { + None + } +} + +fn normalize_ss_uri_plugin_opts( + raw_plugin: &str, + normalized_name: &str, + opts: &mut HashMap, +) { + if normalized_name == "v2ray-plugin" { + if !opts.contains_key("mode") { + if let Some(mode) = opts.get("obfs").cloned() { + opts.insert("mode".to_owned(), mode); + } + } + if !opts.contains_key("host") { + if let Some(host) = opts.get("obfs-host").cloned() { + opts.insert("host".to_owned(), host); + } + } + if raw_plugin.contains("tls") { + opts.insert("tls".to_owned(), "true".to_owned()); + } + } +} + +fn decode_ss_plugin_component(value: &str) -> String { + let query_value = value.replace('+', " "); + percent_decode(&query_value).unwrap_or_else(|_| value.to_owned()) +} + +fn decode_subscription_body(body: &str) -> Option { + let compact: String = body.chars().filter(|ch| !ch.is_whitespace()).collect(); + decode_base64_segment(&compact).ok() +} + +fn decode_base64_segment(input: &str) -> Result { + if let Ok(decoded) = STANDARD_NO_PAD.decode(input.as_bytes()) { + return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); + } + if let Ok(decoded) = STANDARD.decode(input.as_bytes()) { + return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); + } + if let Ok(decoded) = URL_SAFE_NO_PAD.decode(input.as_bytes()) { + return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); + } + if let Ok(decoded) = URL_SAFE.decode(input.as_bytes()) { + return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8"); + } + bail!("invalid base64 text") +} + +fn uri_fragment_name(url: &Url, fallback: &str, ordinal: usize) -> Result { + Ok(url + .fragment() + .map(percent_decode) + .transpose()? + .and_then(|fragment| non_empty(&fragment).map(ToOwned::to_owned)) + .or_else(|| non_empty(url.host_str().unwrap_or_default()).map(ToOwned::to_owned)) + .unwrap_or_else(|| format!("{fallback} {}", ordinal + 1))) +} + +fn suggested_name(source_url: Option<&str>) -> String { + source_url + .and_then(|url| Url::parse(url).ok()) + .and_then(|url| url.host_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| "Proxy Subscription".to_owned()) +} + +fn split_csv(value: &str) -> Vec { + value + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn is_truthy(value: impl AsRef) -> bool { + matches!( + value.as_ref().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "y" + ) +} + +fn default_true() -> bool { + true +} + +const UOT_LEGACY_VERSION: u8 = 1; + +fn default_uot_legacy_version() -> u8 { + UOT_LEGACY_VERSION +} + +fn non_empty(value: &str) -> Option<&str> { + let value = value.trim(); + (!value.is_empty()).then_some(value) +} + +fn percent_decode(input: &str) -> Result { + let mut out = Vec::with_capacity(input.len()); + let bytes = input.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if i + 2 >= bytes.len() { + bail!("invalid percent encoding"); + } + let hi = hex_value(bytes[i + 1]).ok_or_else(|| anyhow!("invalid percent encoding"))?; + let lo = hex_value(bytes[i + 2]).ok_or_else(|| anyhow!("invalid percent encoding"))?; + out.push((hi << 4) | lo); + i += 3; + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8(out).context("percent-decoded text is not valid UTF-8") +} + +fn hex_value(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_base64_uri_list_with_trojan_and_shadowsocks() { + let body = STANDARD.encode( + "trojan://secret@example.com:443?security=tls&sni=front.example&type=grpc&serviceName=edge&allowInsecure=1&fp=random#US%20Trojan\nss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?uot=1&uot-version=2#SS%20Node\n", + ); + let preview = parse_subscription_body(&body, Some("https://sub.example/path?token=secret")); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); + assert_eq!(preview.nodes.len(), 2); + assert_eq!(preview.rejected.len(), 0); + assert_eq!(preview.nodes[0].name, "US Trojan"); + assert!(preview.nodes[0].warnings.is_empty()); + let ProxyNodeConfig::Trojan(trojan) = &preview.nodes[0].config else { + panic!("expected Trojan node"); + }; + assert!(!trojan.alpn_present); + assert_eq!(trojan_alpn_for_test(trojan), Vec::::new()); + assert!(trojan.udp); + assert_eq!(preview.nodes[1].protocol, ProxyProtocol::Shadowsocks); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[1].config else { + panic!("expected Shadowsocks node"); + }; + assert!(shadowsocks.udp); + assert!(shadowsocks.udp_over_tcp); + assert_eq!(shadowsocks.udp_over_tcp_version, 2); + } + + #[test] + fn parses_shadowsocks_v2ray_plugin_uri_aliases() { + let body = "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=v2ray-plugin%3Bobfs%3Dwebsocket%3Bobfs-host%3Dcdn.example%3Bpath%3D%252Fws%3Btls#SS%20V2Ray"; + let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); + assert_eq!(preview.nodes.len(), 1); + assert_eq!(preview.rejected.len(), 0); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { + panic!("expected Shadowsocks node"); + }; + let plugin = shadowsocks.plugin.as_ref().unwrap(); + assert_eq!(plugin.name, "v2ray-plugin"); + assert_eq!(plugin.opts.get("mode"), Some(&"websocket".to_owned())); + assert_eq!(plugin.opts.get("host"), Some(&"cdn.example".to_owned())); + assert_eq!(plugin.opts.get("obfs"), Some(&"websocket".to_owned())); + assert_eq!( + plugin.opts.get("obfs-host"), + Some(&"cdn.example".to_owned()) + ); + assert_eq!(plugin.opts.get("path"), Some(&"/ws".to_owned())); + assert_eq!(plugin.opts.get("tls"), Some(&"true".to_owned())); + } + + #[test] + fn parses_base64_uri_list_even_when_yaml_accepts_scalar_text() { + let body = STANDARD + .encode("ss://Y2hhY2hhMjAtaWV0Zi1wb2x5MTMwNTpwYXNz@example.net:1080#SS%20Node\n"); + let preview = parse_subscription_body(&body, Some("https://sub.example/path?token=secret")); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); + assert_eq!(preview.rejected.len(), 0); + assert_eq!(preview.nodes.len(), 1); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { + panic!("expected Shadowsocks node"); + }; + assert_eq!(shadowsocks.cipher, "chacha20-ietf-poly1305"); + assert_eq!(shadowsocks.password, "pass"); + } + + #[test] + fn parses_shadowsocks_simple_obfs_uri_like_mihomo_converter() { + let body = "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=obfs-local%3Bobfs%3Dhttp%3Bobfs-host%3Dcdn.example#SS%20Obfs"; + let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); + assert_eq!(preview.nodes.len(), 1); + assert_eq!(preview.rejected.len(), 0); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { + panic!("expected Shadowsocks node"); + }; + let plugin = shadowsocks.plugin.as_ref().unwrap(); + assert_eq!(plugin.name, "obfs"); + assert_eq!(plugin.opts.get("obfs"), Some(&"http".to_owned())); + assert_eq!( + plugin.opts.get("obfs-host"), + Some(&"cdn.example".to_owned()) + ); + } + + #[test] + fn ignores_bare_shadowsocks_uri_plugin_like_mihomo_converter() { + let body = + "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=v2ray-plugin#SS%20Bare%20Plugin"; + let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); + assert_eq!(preview.nodes.len(), 1); + assert_eq!(preview.rejected.len(), 0); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { + panic!("expected Shadowsocks node"); + }; + assert!(shadowsocks.plugin.is_none()); + } + + #[test] + fn ignores_unknown_shadowsocks_uri_plugin_like_mihomo_converter() { + let body = "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?plugin=gost-plugin%3Bmode%3Dwebsocket#SS%20Gost%20URI"; + let preview = parse_subscription_body(body, Some("https://sub.example/path?token=secret")); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList); + assert_eq!(preview.nodes.len(), 1); + assert_eq!(preview.rejected.len(), 0); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { + panic!("expected Shadowsocks node"); + }; + assert!(shadowsocks.plugin.is_none()); + } + + #[test] + fn parses_mihomo_yaml_proxies() { + let preview = parse_subscription_body( + r#" +proxies: + - name: trojan-ws + type: trojan + server: example.com + port: 443 + password: secret + sni: edge.example.com + network: ws + ws-opts: + path: /ws + headers: + Host: edge.example.com + - name: ss + type: ss + server: example.net + port: 8388 + cipher: aes-128-gcm + password: pass + plugin: v2ray-plugin + plugin-opts: + mode: websocket + path: /ws + v2ray-http-upgrade: true + v2ray-http-upgrade-fast-open: true + headers: + Host: edge.example.net + ech-opts: + enable: true + config: AQIDBA== + query-server-name: public.example.net + udp-over-tcp: true + udp-over-tcp-version: 2 + smux: + enabled: true + protocol: smux + max-connections: 4 + min-streams: 2 + padding: true + statistic: true + only-tcp: false + brutal-opts: + enabled: true + up: 10 Mbps + down: 20 Mbps + - name: ss-shadowtls + type: ss + server: shadow.example.net + port: 443 + cipher: aes-128-gcm + password: pass + client-fingerprint: chrome + plugin: shadow-tls + plugin-opts: + host: cover.example.com + password: shadow-secret + version: 1 + fingerprint: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + alpn: + - h2 + - http/1.1 + - name: ss-kcptun + type: ss + server: kcp.example.net + port: 29900 + cipher: aes-128-gcm + password: pass + plugin: kcptun + plugin-opts: + key: secret + crypt: aes + mode: fast2 + conn: 2 + nocomp: true + smuxver: 2 +"#, + None, + ); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::ClashYaml); + assert_eq!(preview.nodes.len(), 4); + assert_eq!(preview.rejected.len(), 0); + let ProxyNodeConfig::Trojan(trojan) = &preview.nodes[0].config else { + panic!("expected Trojan node"); + }; + assert!(!trojan.alpn_present); + assert!(!trojan.udp); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[1].config else { + panic!("expected Shadowsocks node"); + }; + assert!(!shadowsocks.udp); + assert!(shadowsocks.udp_over_tcp); + assert_eq!(shadowsocks.udp_over_tcp_version, 2); + assert_eq!( + shadowsocks + .plugin + .as_ref() + .unwrap() + .opts + .get("headers.Host"), + Some(&"edge.example.net".to_owned()) + ); + assert_eq!( + shadowsocks + .plugin + .as_ref() + .unwrap() + .opts + .get("v2ray-http-upgrade-fast-open"), + Some(&"true".to_owned()) + ); + assert_eq!( + shadowsocks + .plugin + .as_ref() + .unwrap() + .opts + .get("ech-opts.enable"), + Some(&"true".to_owned()) + ); + assert_eq!( + shadowsocks + .plugin + .as_ref() + .unwrap() + .opts + .get("ech-opts.config"), + Some(&"AQIDBA==".to_owned()) + ); + assert_eq!( + shadowsocks + .plugin + .as_ref() + .unwrap() + .opts + .get("ech-opts.query-server-name"), + Some(&"public.example.net".to_owned()) + ); + let smux = shadowsocks + .smux + .as_ref() + .expect("expected Shadowsocks smux"); + assert!(smux.enabled); + assert_eq!(smux.protocol.as_deref(), Some("smux")); + assert_eq!(smux.max_connections, Some(4)); + assert_eq!(smux.min_streams, Some(2)); + assert!(smux.padding); + assert!(smux.statistic); + assert!(!smux.only_tcp); + let brutal = smux.brutal.as_ref().expect("expected brutal opts"); + assert!(brutal.enabled); + assert_eq!(brutal.up.as_deref(), Some("10 Mbps")); + assert_eq!(brutal.down.as_deref(), Some("20 Mbps")); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[2].config else { + panic!("expected Shadowsocks node"); + }; + let plugin = shadowsocks.plugin.as_ref().unwrap(); + assert_eq!(plugin.name, "shadow-tls"); + assert_eq!(shadowsocks.client_fingerprint.as_deref(), Some("chrome")); + assert_eq!(plugin.opts.get("version"), Some(&"1".to_owned())); + assert_eq!( + plugin.opts.get("fingerprint"), + Some(&"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff".to_owned()) + ); + assert_eq!(plugin.opts.get("alpn"), Some(&"h2,http/1.1".to_owned())); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[3].config else { + panic!("expected Shadowsocks node"); + }; + let plugin = shadowsocks.plugin.as_ref().unwrap(); + assert_eq!(plugin.name, "kcptun"); + assert_eq!(plugin.opts.get("conn"), Some(&"2".to_owned())); + assert_eq!(plugin.opts.get("nocomp"), Some(&"true".to_owned())); + assert_eq!(plugin.opts.get("smuxver"), Some(&"2".to_owned())); + } + + #[test] + fn shadowsocks_yaml_udp_defaults_match_mihomo() { + let preview = parse_subscription_body( + r#" +proxies: + - name: ss-default + type: ss + server: example.net + port: 8388 + cipher: aes-128-gcm + password: pass + - name: ss-udp + type: ss + server: example.org + port: 8388 + cipher: aes-128-gcm + password: pass + udp: true +"#, + None, + ); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::ClashYaml); + assert_eq!(preview.rejected.len(), 0); + let ProxyNodeConfig::Shadowsocks(default_node) = &preview.nodes[0].config else { + panic!("expected Shadowsocks node"); + }; + assert!(!default_node.udp); + let ProxyNodeConfig::Shadowsocks(udp_node) = &preview.nodes[1].config else { + panic!("expected Shadowsocks node"); + }; + assert!(udp_node.udp); + } + + #[test] + fn shadowsocks_yaml_accepts_weakly_typed_scalars_like_mihomo() { + let preview = parse_subscription_body( + r#" +proxies: + - name: 12345 + type: ss + server: 127.0.0.1 + port: "8388" + cipher: aes-128-gcm + password: 123456 + udp: 1 + udp-over-tcp: 0 + client-fingerprint: false + plugin: v2ray-plugin + plugin-opts: + mode: websocket + path: /ws + mux: true + alpn: + - h2 + - 123 + smux: + enabled: 1 + padding: 0 +"#, + None, + ); + assert_eq!(preview.detected_format, ProxySubscriptionFormat::ClashYaml); + assert_eq!(preview.rejected.len(), 0); + assert_eq!(preview.nodes.len(), 1); + assert_eq!(preview.nodes[0].name, "12345"); + let ProxyNodeConfig::Shadowsocks(shadowsocks) = &preview.nodes[0].config else { + panic!("expected Shadowsocks node"); + }; + assert_eq!(shadowsocks.password, "123456"); + assert!(shadowsocks.udp); + assert!(!shadowsocks.udp_over_tcp); + assert_eq!(shadowsocks.client_fingerprint.as_deref(), Some("false")); + let smux = shadowsocks.smux.as_ref().expect("expected smux"); + assert!(smux.enabled); + assert!(!smux.padding); + let plugin = shadowsocks.plugin.as_ref().expect("expected plugin"); + assert_eq!(plugin.name, "v2ray-plugin"); + assert_eq!(plugin.opts.get("mux"), Some(&"true".to_owned())); + assert_eq!(plugin.opts.get("alpn"), Some(&"h2,123".to_owned())); + } + + #[test] + fn tracks_explicit_trojan_alpn_presence() { + let preview = parse_subscription_body( + r#" +proxies: + - name: trojan-empty-alpn + type: trojan + server: example.com + port: 443 + password: secret + alpn: [] + - name: trojan-custom-alpn + type: trojan + server: example.net + port: 443 + password: secret + alpn: + - h3 +"#, + None, + ); + let ProxyNodeConfig::Trojan(empty_alpn) = &preview.nodes[0].config else { + panic!("expected Trojan node"); + }; + assert!(empty_alpn.alpn_present); + assert!(empty_alpn.alpn.is_empty()); + + let ProxyNodeConfig::Trojan(custom_alpn) = &preview.nodes[1].config else { + panic!("expected Trojan node"); + }; + assert!(custom_alpn.alpn_present); + assert_eq!(custom_alpn.alpn, vec!["h3"]); + } + + #[test] + fn build_payload_records_selected_node_name() { + let preview = parse_subscription_body( + "ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388#SS%20Node\n", + Some("https://sub.example/path"), + ); + let payload = build_payload( + preview, + "https://sub.example/path", + Some("sub".to_owned()), + Some(0), + ); + + assert_eq!(payload.selected_ordinal, Some(0)); + assert_eq!(payload.selected_name.as_deref(), Some("SS Node")); + } + + #[test] + fn redacts_subscription_url_secrets_for_errors() { + let redacted = redacted_subscription_url( + "https://user:password@sub.example/path?token=secret&user=alice#fragment", + ); + + assert_eq!( + redacted, + "https://REDACTED:REDACTED@sub.example/path?REDACTED#REDACTED" + ); + assert!(!redacted.contains("password")); + assert!(!redacted.contains("token=secret")); + assert!(!redacted.contains("fragment")); + } + + fn trojan_alpn_for_test(node: &TrojanNode) -> Vec { + node.alpn.clone() + } +} diff --git a/burrow/src/tracing.rs b/burrow/src/tracing.rs index 8a245ef..fb88100 100644 --- a/burrow/src/tracing.rs +++ b/burrow/src/tracing.rs @@ -1,7 +1,13 @@ -use std::sync::Once; +use std::{ + fs::{File, OpenOptions}, + io::{self, Write}, + path::PathBuf, + sync::{Arc, Mutex, Once}, +}; use tracing::{error, info}; use tracing_subscriber::{ + fmt::writer::MakeWriter, layer::{Layer, SubscriberExt}, EnvFilter, Registry, }; @@ -20,14 +26,30 @@ pub fn initialize() { .with_writer(std::io::stderr) .with_line_number(true) .compact() - .with_filter(EnvFilter::from_default_env()) + .with_filter(env_filter()) + }; + + let make_file = || { + tracing_log_file().map(|writer| { + tracing_subscriber::fmt::layer() + .with_ansi(false) + .with_level(true) + .with_line_number(true) + .compact() + .with_writer(writer) + .with_filter(env_filter()) + }) }; #[cfg(target_os = "windows")] let subscriber = { let system_log = Some(tracing_subscriber::fmt::layer()); - let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); - Registry::default().with(stderr).with(system_log) + let stderr = + (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default() + .with(stderr) + .with(system_log) + .with(make_file()) }; #[cfg(target_os = "linux")] @@ -41,8 +63,12 @@ pub fn initialize() { None } }; - let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); - Registry::default().with(stderr).with(system_log) + let stderr = + (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default() + .with(stderr) + .with(system_log) + .with(make_file()) }; #[cfg(target_os = "macos")] @@ -54,15 +80,20 @@ pub fn initialize() { std::env::var("BURROW_ENABLE_OSLOG").as_deref(), Ok("1" | "true" | "TRUE" | "yes" | "YES") ); - let system_log = enable_oslog.then(|| { - tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing") - }); - let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); - Registry::default().with(stderr).with(system_log) + let system_log = enable_oslog + .then(|| tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing")); + let stderr = + (console::user_attended_stderr() || system_log.is_none()).then(make_stderr); + Registry::default() + .with(stderr) + .with(system_log) + .with(make_file()) }; #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] - let subscriber = Registry::default().with(Some(make_stderr())); + let subscriber = Registry::default() + .with(Some(make_stderr())) + .with(make_file()); #[cfg(feature = "tokio-console")] let subscriber = subscriber.with( @@ -80,3 +111,50 @@ pub fn initialize() { info!("Initialized logging") }); } + +fn env_filter() -> EnvFilter { + EnvFilter::try_from_env("BURROW_LOG") + .or_else(|_| EnvFilter::try_from_default_env()) + .unwrap_or_else(|_| EnvFilter::new("info")) +} + +fn tracing_log_file() -> Option { + let path = std::env::var_os("BURROW_LOG_FILE").map(PathBuf::from)?; + let file = match OpenOptions::new().create(true).append(true).open(&path) { + Ok(file) => file, + Err(err) => { + error!(path = %path.display(), error = %err, "failed to open Burrow log file"); + return None; + } + }; + Some(SharedLogWriter { + file: Arc::new(Mutex::new(file)), + }) +} + +#[derive(Clone)] +struct SharedLogWriter { + file: Arc>, +} + +struct SharedLogGuard { + file: Arc>, +} + +impl<'a> MakeWriter<'a> for SharedLogWriter { + type Writer = SharedLogGuard; + + fn make_writer(&'a self) -> Self::Writer { + SharedLogGuard { file: self.file.clone() } + } +} + +impl Write for SharedLogGuard { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.file.lock().unwrap().write(buf) + } + + fn flush(&mut self) -> io::Result<()> { + self.file.lock().unwrap().flush() + } +} diff --git a/deploy/railway-sing-box/Dockerfile b/deploy/railway-sing-box/Dockerfile new file mode 100644 index 0000000..2b2c4cf --- /dev/null +++ b/deploy/railway-sing-box/Dockerfile @@ -0,0 +1,6 @@ +FROM ghcr.io/sagernet/sing-box:v1.13.12 + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/deploy/railway-sing-box/README.md b/deploy/railway-sing-box/README.md new file mode 100644 index 0000000..64d4c87 --- /dev/null +++ b/deploy/railway-sing-box/README.md @@ -0,0 +1,68 @@ +# Railway sing-box Shadowsocks Test Node + +This is a minimal sing-box Shadowsocks server for testing Burrow against a real +internet-hosted node on Railway. + +Railway public networking exposes raw TCP through TCP Proxy. That is enough for +plain Shadowsocks TCP testing. It is not a full UDP validation environment, so +use Burrow's local self-tests or a VPS for native UDP testing. + +## Deploy + +1. Create a new Railway service from this repo. +2. Set the service root directory to: + + ```text + deploy/railway-sing-box + ``` + +3. Add Railway variables: + + ```text + SS_PASSWORD= + SS_METHOD=chacha20-ietf-poly1305 + SS_LISTEN_HOST=0.0.0.0 + SS_LISTEN_PORT=8388 + ``` + + `SS_METHOD`, `SS_LISTEN_HOST`, and `SS_LISTEN_PORT` are optional; those are + the defaults. + +4. Deploy the service. +5. In the service settings, create a TCP Proxy with internal port `8388`. +6. Railway will show an external proxy host and port, for example: + + ```text + shuttle.proxy.rlwy.net:15140 + ``` + +## Burrow Test URI + +Build the Shadowsocks URI with: + +```sh +uv run python - <<'PY' +import base64 +import urllib.parse + +method = "chacha20-ietf-poly1305" +password = "" +host = "" +port = "" +name = "railway-sing-box-ss" + +userinfo = base64.urlsafe_b64encode(f"{method}:{password}".encode()).decode().rstrip("=") +print(f"ss://{userinfo}@{host}:{port}#{urllib.parse.quote(name)}") +PY +``` + +Use the generated `ss://` URI in a Burrow proxy subscription or local test +payload. + +## Notes + +- Railway's TCP Proxy external port is assigned by Railway. Use that external + port in the client URI, not `8388`. +- This is intentionally a plain Shadowsocks node. Once plain TCP works, add + extra protocol features in separate test deployments so failures stay easy to + isolate. diff --git a/deploy/railway-sing-box/entrypoint.sh b/deploy/railway-sing-box/entrypoint.sh new file mode 100644 index 0000000..1ba2800 --- /dev/null +++ b/deploy/railway-sing-box/entrypoint.sh @@ -0,0 +1,47 @@ +#!/bin/sh +set -eu + +: "${SS_PASSWORD:?Set SS_PASSWORD to a strong test password in Railway variables}" + +SS_METHOD="${SS_METHOD:-chacha20-ietf-poly1305}" +SS_LISTEN_HOST="${SS_LISTEN_HOST:-0.0.0.0}" +SS_LISTEN_PORT="${SS_LISTEN_PORT:-8388}" +LOG_LEVEL="${LOG_LEVEL:-info}" + +json_escape() { + printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +SS_METHOD_JSON="$(json_escape "$SS_METHOD")" +SS_PASSWORD_JSON="$(json_escape "$SS_PASSWORD")" +LOG_LEVEL_JSON="$(json_escape "$LOG_LEVEL")" +SS_LISTEN_HOST_JSON="$(json_escape "$SS_LISTEN_HOST")" + +mkdir -p /etc/sing-box +cat >/etc/sing-box/config.json <