Compare commits
4 commits
main
...
trojan-sha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7cf38c382 | ||
|
|
6c6bfe5434 | ||
|
|
4d1f589280 | ||
|
|
d1638726ca |
56 changed files with 34061 additions and 599 deletions
|
|
@ -15,5 +15,7 @@
|
|||
<array>
|
||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||
</array>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23091" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
|
||||
<dependencies>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23091"/>
|
||||
<deployment identifier="macosx"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
|
||||
</dependencies>
|
||||
<objects>
|
||||
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
|
||||
|
|
|
|||
|
|
@ -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 = "<group>"; };
|
||||
D00AA8962A4669BC005C8102 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = "<group>"; };
|
||||
D020F63D29E4A1FF002790F6 /* Identity.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Identity.xcconfig; sourceTree = "<group>"; };
|
||||
D020F64029E4A1FF002790F6 /* Compiler.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Compiler.xcconfig; sourceTree = "<group>"; };
|
||||
D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
|
|
@ -188,18 +186,14 @@
|
|||
D0D4E58E2C8D9D0A007F820A /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = "<group>"; };
|
||||
D0D4E58F2C8D9D0A007F820A /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = "<group>"; };
|
||||
D0D4E5902C8D9D0A007F820A /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = "<group>"; };
|
||||
D0FA10032D10200100112233 /* burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = "<group>"; };
|
||||
D0FA10042D10200100112233 /* burrow.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.grpc.swift; sourceTree = "<group>"; };
|
||||
D0FA10032D10200100112233 /* Generated/burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = "<group>"; };
|
||||
D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.grpc.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
D11000052F70000100112233 /* UITests.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = UITests.xcconfig; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
D11000072F70000100112233 /* AppUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D11000042F70000100112233 /* BurrowUITests.swift */,
|
||||
);
|
||||
path = AppUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
|
|
@ -401,27 +395,17 @@
|
|||
path = Constants;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D11000072F70000100112233 /* AppUITests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D11000042F70000100112233 /* BurrowUITests.swift */,
|
||||
);
|
||||
path = AppUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
DEVELOPMENT_TEAM = P6PV2R9443
|
||||
APP_BUNDLE_IDENTIFIER = com.hackclub.burrow
|
||||
DEVELOPMENT_TEAM = 2KPBQHRJ2D
|
||||
APP_BUNDLE_IDENTIFIER = com.tianruichen.burrow
|
||||
|
|
|
|||
|
|
@ -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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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<D: SwiftProtobuf.Decoder>(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<V: SwiftProtobuf.Visitor>(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
|
||||
|
|
|
|||
|
|
@ -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<D: SwiftProtobuf.Decoder>(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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -28,13 +28,13 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
|
|||
get throws { try _client.get() }
|
||||
}
|
||||
private let _client: Result<TunnelClient, Swift.Error> = 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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<Bool> {
|
||||
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,6 +1853,8 @@ 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)
|
||||
|
|
@ -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<Int32> {
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -98,23 +98,33 @@ public final class NetworkExtensionTunnel: Tunnel {
|
|||
}
|
||||
}
|
||||
|
||||
guard managers.isEmpty else { return }
|
||||
|
||||
let manager = NETunnelProviderManager()
|
||||
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
|
||||
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<String> = [
|
||||
"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<URL, Error>) 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."
|
||||
|
|
|
|||
1632
Cargo.lock
generated
1632
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
434
Scripts/burrow-doctor
Executable file
434
Scripts/burrow-doctor
Executable file
|
|
@ -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 <<EOF
|
||||
# Burrow Doctor
|
||||
|
||||
Generated: $(date)
|
||||
Repo: $repo_root
|
||||
Log window: $last
|
||||
App group: $group_id
|
||||
DB path: $db_path
|
||||
Runtime log: $runtime_log
|
||||
|
||||
Purpose: snapshot CURRENT networking state while Burrow is active but not
|
||||
routing requests, so this report can be inspected later after switching to a
|
||||
working VPN.
|
||||
EOF
|
||||
|
||||
section "Quick Findings"
|
||||
if printf '%s\n' "$nc_list" | grep -Eiq '\(Connected\).+Burrow'; then
|
||||
echo "- Burrow is currently connected according to scutil."
|
||||
else
|
||||
echo "- Burrow is NOT currently connected according to scutil. If this was not captured during the broken active-Burrow window, rerun it while Burrow is active."
|
||||
fi
|
||||
|
||||
other_connected="$(printf '%s\n' "$nc_list" | grep -Ei '\(Connected\)' | grep -Eiv 'Burrow|MT65xx' || true)"
|
||||
if [[ -n "$other_connected" ]]; then
|
||||
echo "- Another VPN-like service is connected and may supersede or interfere with Burrow:"
|
||||
printf '%s\n' "$other_connected" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if printf '%s\n' "$proxy_state" | grep -Eq 'HTTPProxy : 127\.0\.0\.1|SOCKSProxy : 127\.0\.0\.1|HTTPSProxy : 127\.0\.0\.1'; then
|
||||
echo "- System proxy settings point to localhost. See Local Proxy Listeners for the owning process."
|
||||
fi
|
||||
|
||||
if printf '%s\n%s\n' "$dns_state" "$inet_routes" | grep -Eq '198\.18\.|198\.19\.'; then
|
||||
echo "- Current DNS/routes include 198.18.0.0/15 fake-IP style addresses."
|
||||
fi
|
||||
|
||||
if printf '%s\n' "$inet_routes" | grep -Eq '^default[[:space:]].*utun'; then
|
||||
echo "- IPv4 default route currently points at a utun interface."
|
||||
fi
|
||||
|
||||
if [[ -n "$proxy_listeners" ]]; then
|
||||
echo "- Common localhost proxy ports are listening:"
|
||||
printf '%s\n' "$proxy_listeners" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [[ -f "$runtime_log" ]] && tail -400 "$runtime_log" | grep -q 'panicked'; then
|
||||
echo "- Burrow runtime log contains recent task panics. See Burrow Runtime Log File."
|
||||
fi
|
||||
|
||||
if [[ -f "$runtime_log" ]] && tail -400 "$runtime_log" | grep -q 'CryptoProvider'; then
|
||||
echo "- Burrow runtime log mentions rustls CryptoProvider initialization failures."
|
||||
fi
|
||||
|
||||
if printf '%s\n' "$dns_state" | grep -A8 '^resolver #1' | grep -Eq 'if_index[[:space:]]*:.*utun' \
|
||||
&& [[ -f "$runtime_log" ]] \
|
||||
&& tail -400 "$runtime_log" | grep -q 'proxy udp session failed'; then
|
||||
echo "- Primary DNS is on the tunnel while Burrow proxy UDP sessions are failing; name resolution may be blocked before HTTP/TCP requests can start."
|
||||
fi
|
||||
|
||||
if ! printf '%s\n' "$dns_state" | awk '
|
||||
/^DNS configuration$/ { global = 1; next }
|
||||
/^DNS configuration \(for scoped queries\)/ { global = 0; next }
|
||||
global && /nameserver\[[0-9]+\]/ { found = 1 }
|
||||
END { exit found ? 0 : 1 }
|
||||
'; then
|
||||
echo "- No global DNS nameserver is configured. If only scoped physical DNS remains, ordinary hostname lookups may fail under the full-tunnel route."
|
||||
fi
|
||||
|
||||
section "Selected Burrow Network"
|
||||
if [[ -f "$db_path" ]]; then
|
||||
run_python - "$db_path" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
|
||||
db_path = sys.argv[1]
|
||||
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
rows = list(conn.execute("select id,type,idx,payload,interface_id from network order by idx"))
|
||||
if not rows:
|
||||
print("No rows in network table.")
|
||||
raise SystemExit
|
||||
|
||||
summaries = []
|
||||
for row in rows:
|
||||
payload_blob = row["payload"]
|
||||
payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob
|
||||
try:
|
||||
payload = json.loads(payload_text) if payload_text else {}
|
||||
except Exception as exc:
|
||||
summaries.append({
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
"idx": row["idx"],
|
||||
"payload_parse_error": str(exc),
|
||||
})
|
||||
continue
|
||||
|
||||
summary = {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
"idx": row["idx"],
|
||||
"interface_id": row["interface_id"],
|
||||
"name": payload.get("name"),
|
||||
"selected_ordinal": payload.get("selected_ordinal"),
|
||||
"selected_name": payload.get("selected_name"),
|
||||
"source": payload.get("source"),
|
||||
"node_count": len(payload.get("nodes") or []),
|
||||
}
|
||||
selected_node = None
|
||||
for node in payload.get("nodes") or []:
|
||||
if node.get("name") == payload.get("selected_name") or node.get("ordinal") == payload.get("selected_ordinal"):
|
||||
selected_node = node
|
||||
break
|
||||
summary["selected_node"] = selected_node
|
||||
summaries.append(summary)
|
||||
|
||||
print(json.dumps(summaries, ensure_ascii=False, indent=2))
|
||||
PY
|
||||
else
|
||||
echo "DB not found: $db_path"
|
||||
fi
|
||||
|
||||
run "VPN Configuration List" scutil --nc list
|
||||
run "System Proxy State" scutil --proxy
|
||||
run_zsh "Local Proxy Listeners" "lsof -nP -iTCP:6152 -iTCP:6153 -iTCP:7890 -iTCP:7891 -iTCP:7897 -iTCP:1080 -iTCP:1087 -sTCP:LISTEN || true"
|
||||
run "DNS State" scutil --dns
|
||||
run "IPv4 Routes" netstat -rn -f inet
|
||||
run "IPv6 Routes" netstat -rn -f inet6
|
||||
run "Interfaces" ifconfig
|
||||
run_zsh "Burrow And VPN Processes" "ps aux | egrep -i 'Burrow|BurrowNetworkExtension|nesessionmanager|neagent|Surge|Tailscale|Clash|mihomo|sing-box' | egrep -v 'egrep|burrow-doctor'"
|
||||
run_zsh "Burrow Socket Owners" "lsof -U | egrep 'burrow.*sock|Burrow|BurrowNetworkExtension|NetworkExtension' || true"
|
||||
run "App Group Directory" ls -la "$group_dir"
|
||||
|
||||
section "Selected Proxy Server Connectivity"
|
||||
if [[ -f "$db_path" ]]; then
|
||||
run_python - "$db_path" <<'PY'
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
db_path = sys.argv[1]
|
||||
|
||||
|
||||
def choose_node(payload: dict) -> 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' "$?"; }
|
||||
576
Scripts/burrow-proxy-ladder
Executable file
576
Scripts/burrow-proxy-ladder
Executable file
|
|
@ -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())
|
||||
1417
Scripts/burrow-proxy-selftest
Executable file
1417
Scripts/burrow-proxy-selftest
Executable file
File diff suppressed because it is too large
Load diff
504
Scripts/burrow-shadowsocks-singmux-interop
Executable file
504
Scripts/burrow-shadowsocks-singmux-interop
Executable file
|
|
@ -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" <<EOF
|
||||
module burrow-singmux-interop
|
||||
|
||||
go 1.23
|
||||
|
||||
require (
|
||||
github.com/metacubex/sing v0.5.7
|
||||
github.com/metacubex/sing-mux v0.3.9
|
||||
)
|
||||
|
||||
replace github.com/metacubex/sing => $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" <<EOF
|
||||
{
|
||||
"name": "burrow sing-mux interop",
|
||||
"source": { "url": "selftest://sing-mux" },
|
||||
"detected_format": "uri-list",
|
||||
"selected_ordinal": 1,
|
||||
"selected_name": "local shadowsocks sing-mux $protocol padding $padding",
|
||||
"nodes": [
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "local shadowsocks sing-mux $protocol padding $padding",
|
||||
"protocol": "shadowsocks",
|
||||
"server": "127.0.0.1",
|
||||
"port": $server_port,
|
||||
"warnings": [],
|
||||
"config": {
|
||||
"type": "shadowsocks",
|
||||
"cipher": "none",
|
||||
"password": "unused",
|
||||
"udp": $node_udp,
|
||||
"udp_over_tcp": false,
|
||||
"udp_over_tcp_version": 1,
|
||||
"client_fingerprint": null,
|
||||
"plugin": null,
|
||||
"smux": {
|
||||
"enabled": true,
|
||||
"protocol": "$protocol",
|
||||
"max_connections": 1,
|
||||
"min_streams": 0,
|
||||
"max_streams": 16,
|
||||
"padding": $padding,
|
||||
"statistic": false,
|
||||
"only_tcp": false,
|
||||
"brutal": null
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": []
|
||||
}
|
||||
EOF
|
||||
|
||||
(
|
||||
cd "$tmpdir"
|
||||
BURROW_SOCKET_PATH="$socket" "$burrow_bin" daemon >"$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
|
||||
4559
burrow-gtk/Cargo.lock
generated
4559
burrow-gtk/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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<String>,
|
||||
tailnet: Option<String>,
|
||||
},
|
||||
PreviewProxySubscription {
|
||||
url: String,
|
||||
},
|
||||
AddProxySubscription {
|
||||
url: String,
|
||||
name: String,
|
||||
selected_ordinal: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>()
|
||||
.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<i32>,
|
||||
) {
|
||||
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<HomeScreen>,
|
||||
) {
|
||||
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::<i32>));
|
||||
let node_ordinals = Rc::new(RefCell::new(Vec::<i32>::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)
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<ProxyNodePreview>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyNodePreview {
|
||||
pub ordinal: i32,
|
||||
pub name: String,
|
||||
pub protocol: String,
|
||||
pub server: String,
|
||||
pub port: i32,
|
||||
pub warnings: Vec<String>,
|
||||
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<ProxySubscriptionPreview> {
|
||||
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<i32>,
|
||||
) -> Result<i32> {
|
||||
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<TailnetDiscovery> {
|
||||
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::<ProxySubscriptionPayload>(&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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -92,8 +92,13 @@ pub async fn probe_tailnet_authority(authority: &str) -> Result<TailnetAuthority
|
|||
.build()
|
||||
.context("failed to build tailnet authority probe client")?;
|
||||
|
||||
if let Some(status) =
|
||||
probe_url(&client, base_url.join("/health")?, &authority, "Tailnet server reachable").await?
|
||||
if let Some(status) = probe_url(
|
||||
&client,
|
||||
base_url.join("/health")?,
|
||||
&authority,
|
||||
"Tailnet server reachable",
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(status);
|
||||
}
|
||||
|
|
@ -188,11 +193,17 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<T
|
|||
.context("invalid tailnet discovery document")
|
||||
.map(Some),
|
||||
StatusCode::NOT_FOUND => 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<Option<String>> {
|
||||
async fn discover_webfinger(
|
||||
client: &Client,
|
||||
email: &str,
|
||||
base_url: &Url,
|
||||
) -> Result<Option<String>> {
|
||||
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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<PathBuf>, db_path_buf: Option<PathBuf>) {
|
||||
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<PathBuf>, 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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TunnelConfigurationResponse, RspStatus> {
|
||||
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<bool, RspStatus> {
|
||||
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<ProxySubscriptionImportRequest>,
|
||||
) -> Result<Response<ProxySubscriptionPreviewResponse>, 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<ProxySubscriptionApplyRequest>,
|
||||
) -> Result<Response<ProxySubscriptionApplyResponse>, 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<ProxySubscriptionSelectRequest>,
|
||||
) -> Result<Response<ProxySubscriptionSelectResponse>, 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<usize> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = match bind_daemon_socket(sock_path)? {
|
||||
Some(uds) => uds,
|
||||
None => {
|
||||
if let Some(n) = notify_ready {
|
||||
n.notify_one();
|
||||
}
|
||||
let uds = UnixListener::bind(sock_path)?;
|
||||
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<Option<UnixListener>> {
|
||||
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::{
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
pub networks_client: NetworksClient<T>,
|
||||
pub proxy_subscription_client: ProxySubscriptionsClient<T>,
|
||||
pub tailnet_client: TailnetControlClient<T>,
|
||||
pub tunnel_client: TunnelClient<T>,
|
||||
}
|
||||
|
|
@ -35,10 +36,12 @@ impl BurrowClient<tonic::transport::Channel> {
|
|||
}))
|
||||
.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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -71,6 +71,8 @@ pub struct ServerConfig {
|
|||
#[serde(default)]
|
||||
pub routes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub excluded_routes: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub dns_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub search_domains: Vec<String>,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
source: burrow/src/daemon/rpc/response.rs
|
||||
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(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}
|
||||
|
|
|
|||
|
|
@ -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,9 +198,60 @@ 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<ServerConfig> {
|
||||
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<ServerConfig> {
|
||||
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 {
|
||||
|
|
@ -205,6 +272,12 @@ pub enum ActiveTunnel {
|
|||
interface: Arc<RwLock<WireGuardInterface>>,
|
||||
task: JoinHandle<Result<()>>,
|
||||
},
|
||||
ProxySubscription {
|
||||
identity: RuntimeIdentity,
|
||||
server_config: ServerConfig,
|
||||
packet_bridge: proxy_runtime::ProxyPacketBridge,
|
||||
tun_task: Option<JoinHandle<Result<()>>>,
|
||||
},
|
||||
}
|
||||
|
||||
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<Vec<u8>>, broadcast::Receiver<Vec<u8>>)> {
|
||||
pub fn packet_stream(&self) -> Option<(mpsc::Sender<Vec<u8>>, broadcast::Receiver<Vec<u8>>)> {
|
||||
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<bool> {
|
||||
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<RwLock<Option<TunInterface>>>) -> 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);
|
||||
|
|
|
|||
|
|
@ -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<Vec<RPCNetwork>> {
|
||||
let mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx, id")?;
|
||||
let networks: Vec<RPCNetwork> = 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<Option<ProxySubscriptionPayload>> {
|
||||
let row = conn.query_row(
|
||||
"SELECT type, payload FROM network WHERE id = ?",
|
||||
params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(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<i32> {
|
||||
if selected_ordinal < 0 {
|
||||
return Err(anyhow!("selected proxy node ordinal must be non-negative"));
|
||||
}
|
||||
|
||||
let (network_type, payload): (String, Vec<u8>) = 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<String> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<i32> = 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<i32> = 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<i32> = 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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
8346
burrow/src/proxy_runtime.rs
Normal file
8346
burrow/src/proxy_runtime.rs
Normal file
File diff suppressed because it is too large
Load diff
961
burrow/src/proxy_runtime/networking.rs
Normal file
961
burrow/src/proxy_runtime/networking.rs
Normal file
|
|
@ -0,0 +1,961 @@
|
|||
impl ProxyServerEndpoint {
|
||||
fn resolve(host: &str, port: u16) -> Result<Self> {
|
||||
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<Vec<SocketAddr>> {
|
||||
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<Vec<SocketAddr>> {
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
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::<IpAddr>() 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::<IpAddr>() 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<Vec<IpAddr>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Vec<u8>> {
|
||||
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<Vec<IpAddr>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Option<Vec<u8>>> {
|
||||
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<Vec<(IpAddr, String)>> {
|
||||
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<usize> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn parse_apple_physical_dns_servers(output: &str) -> Vec<String> {
|
||||
let mut servers = Vec::new();
|
||||
let mut resolver_servers = Vec::<String>::new();
|
||||
let mut resolver_interface = None::<String>;
|
||||
|
||||
let mut flush_resolver =
|
||||
|resolver_servers: &mut Vec<String>, resolver_interface: &mut Option<String>| {
|
||||
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::<IpAddr>() {
|
||||
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<String> {
|
||||
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::<IpAddr>() 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::<libc::c_void>(),
|
||||
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<u32> {
|
||||
#[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::<Candidate>::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<Vec<String>> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
2004
burrow/src/proxy_runtime/shadowsocks_crypto.rs
Normal file
2004
burrow/src/proxy_runtime/shadowsocks_crypto.rs
Normal file
File diff suppressed because it is too large
Load diff
2735
burrow/src/proxy_runtime/shadowsocks_plugins.rs
Normal file
2735
burrow/src/proxy_runtime/shadowsocks_plugins.rs
Normal file
File diff suppressed because it is too large
Load diff
3677
burrow/src/proxy_runtime/tests.rs
Normal file
3677
burrow/src/proxy_runtime/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
1455
burrow/src/proxy_subscription.rs
Normal file
1455
burrow/src/proxy_subscription.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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<SharedLogWriter> {
|
||||
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<Mutex<File>>,
|
||||
}
|
||||
|
||||
struct SharedLogGuard {
|
||||
file: Arc<Mutex<File>>,
|
||||
}
|
||||
|
||||
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<usize> {
|
||||
self.file.lock().unwrap().write(buf)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.file.lock().unwrap().flush()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
6
deploy/railway-sing-box/Dockerfile
Normal file
6
deploy/railway-sing-box/Dockerfile
Normal file
|
|
@ -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"]
|
||||
68
deploy/railway-sing-box/README.md
Normal file
68
deploy/railway-sing-box/README.md
Normal file
|
|
@ -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=<strong random 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 = "<SS_PASSWORD>"
|
||||
host = "<RAILWAY_TCP_PROXY_DOMAIN>"
|
||||
port = "<RAILWAY_TCP_PROXY_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.
|
||||
47
deploy/railway-sing-box/entrypoint.sh
Normal file
47
deploy/railway-sing-box/entrypoint.sh
Normal file
|
|
@ -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 <<EOF
|
||||
{
|
||||
"log": {
|
||||
"level": "$LOG_LEVEL_JSON",
|
||||
"timestamp": true
|
||||
},
|
||||
"inbounds": [
|
||||
{
|
||||
"type": "shadowsocks",
|
||||
"tag": "ss-test",
|
||||
"listen": "$SS_LISTEN_HOST_JSON",
|
||||
"listen_port": $SS_LISTEN_PORT,
|
||||
"method": "$SS_METHOD_JSON",
|
||||
"password": "$SS_PASSWORD_JSON"
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{
|
||||
"type": "direct",
|
||||
"tag": "direct"
|
||||
}
|
||||
]
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "starting sing-box Shadowsocks test server on $SS_LISTEN_HOST:$SS_LISTEN_PORT"
|
||||
exec sing-box run -c /etc/sing-box/config.json
|
||||
10
deploy/railway-sing-box/railway.json
Normal file
10
deploy/railway-sing-box/railway.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"$schema": "https://railway.com/railway.schema.json",
|
||||
"build": {
|
||||
"builder": "DOCKERFILE"
|
||||
},
|
||||
"deploy": {
|
||||
"restartPolicyType": "ON_FAILURE",
|
||||
"restartPolicyMaxRetries": 10
|
||||
}
|
||||
}
|
||||
601
docs/trojan-mihomo-discrepancies.md
Normal file
601
docs/trojan-mihomo-discrepancies.md
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
# Burrow vs Mihomo Trojan Implementation Discrepancies
|
||||
|
||||
Date: 2026-05-31
|
||||
|
||||
Scope: code-path comparison between the local Burrow checkout at
|
||||
`/Users/jettchen/dev/burrow` and the local Mihomo checkout at
|
||||
`/Users/jettchen/dev/vpn-ref/mihomo`. This is not based on current live system
|
||||
network state.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The most important differences for the stored Hong Kong Trojan node are:
|
||||
|
||||
1. Mihomo uses `client-fingerprint: chrome` for Trojan URIs when no `fp` query is
|
||||
present and switches to uTLS when that fingerprint is configured. Burrow
|
||||
stores `client_fingerprint` but the Trojan runtime does not use it.
|
||||
2. Mihomo distinguishes absent ALPN from explicitly empty ALPN. For Trojan TCP,
|
||||
absent ALPN defaults to `["h2", "http/1.1"]`; explicitly empty ALPN remains
|
||||
empty. Burrow now carries an `alpn_present` marker for new imports and treats
|
||||
legacy missing metadata as absent.
|
||||
3. Mihomo has a dedicated proxy-server DNS path (`proxy-server-nameserver` /
|
||||
`ProxyServerHostResolver`). Burrow resolves proxy server names with the
|
||||
process/system resolver through `ToSocketAddrs`.
|
||||
4. Mihomo preserves and reconstructs destination hostname metadata in fake-IP
|
||||
and mapping modes. Burrow's packet runtime receives `SocketAddr` values from
|
||||
its userspace stack and sends IP-form SOCKS addresses to Trojan, not domain
|
||||
names.
|
||||
5. Mihomo supports Trojan `tcp`, `ws`, and `grpc` runtimes, plus additional TLS
|
||||
features such as ECH, REALITY, pinned certificate fingerprint, and optional
|
||||
Trojan-over-Shadowsocks wrapping. Burrow parses some of that metadata but the
|
||||
packet runtime only supports Trojan TCP.
|
||||
6. Mihomo's dial path has timeout, retry, dual-stack fallback, optional parallel
|
||||
dialing, interface/routing-mark controls, DNS cache/fallback, and UDP
|
||||
fragmentation behavior. Burrow now matches Trojan UDP fragmentation, but its
|
||||
dial path remains a much narrower serial dialer plus a fixed packet bridge.
|
||||
|
||||
Implementation note:
|
||||
|
||||
- `BEP-0011` and the current runtime patch address ALPN absence/defaulting,
|
||||
Trojan UDP import/runtime gating, Trojan UDP fragmentation, and certificate
|
||||
fingerprint pinning.
|
||||
- Burrow still does not implement Mihomo's uTLS/browser ClientHello
|
||||
impersonation, ws/grpc Trojan transports, REALITY/ECH, proxy-server DNS plane,
|
||||
fake-IP metadata, or policy/rule pipeline.
|
||||
|
||||
## Stored Node Context
|
||||
|
||||
Current Burrow database state inspected from the app group DB:
|
||||
|
||||
```text
|
||||
network type: ProxySubscription
|
||||
subscription: bitz
|
||||
selected ordinal: 2
|
||||
selected name: Hong Kong Guangdong BGP 2
|
||||
protocol: trojan
|
||||
server: ce1081d2-df04-48d7-80bf-46b8c50b4f33.bnsepserv.com
|
||||
port: 32445
|
||||
network: tcp
|
||||
sni: pull-flv-q1-admin.douyincdn.com
|
||||
skip_cert_verify: true
|
||||
alpn: []
|
||||
client_fingerprint: chrome
|
||||
fingerprint: null
|
||||
```
|
||||
|
||||
Evidence:
|
||||
|
||||
- Burrow stores Trojan node fields in `TrojanNode`: `password`, `sni`, `alpn`,
|
||||
`alpn_present`, `skip_cert_verify`, `network`, `udp`,
|
||||
`client_fingerprint`, and `fingerprint`
|
||||
(`burrow/src/proxy_subscription.rs:82`).
|
||||
- The runtime `TrojanOutbound` carries endpoint, password, SNI, ALPN, skip-cert,
|
||||
UDP enablement, and parsed certificate fingerprint. It still does not apply
|
||||
`client_fingerprint`.
|
||||
|
||||
## 1. Subscription and Config Parsing
|
||||
|
||||
### Supported subscription shapes
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Parses individual proxies through a generic adapter parser
|
||||
(`adapter/parser.go:11`).
|
||||
- For `type: trojan`, decodes the full `TrojanOption` with the common structure
|
||||
decoder and constructs `NewTrojan` (`adapter/parser.go:79`).
|
||||
- Supports proxy providers with `file`, `http`, and `inline` vehicles
|
||||
(`adapter/provider/parser.go:77`).
|
||||
- Provider schemas include filter, exclude-filter, exclude-type, dialer-proxy,
|
||||
size-limit, headers, override, payload, health-check, and refresh interval
|
||||
(`adapter/provider/parser.go:28`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Parses either a top-level YAML `proxies` field or a plaintext/base64 URI list
|
||||
(`burrow/src/proxy_subscription.rs:235`, `burrow/src/proxy_subscription.rs:274`,
|
||||
`burrow/src/proxy_subscription.rs:312`).
|
||||
- Does not parse Mihomo `proxy-providers`, `proxy-groups`, provider refresh
|
||||
options, health-checks, overrides, or group selectors.
|
||||
- For URI-list content, only `trojan://` and `ss://` are accepted; other schemes
|
||||
become rejected entries (`burrow/src/proxy_subscription.rs:323`).
|
||||
|
||||
Impact:
|
||||
|
||||
- Burrow can import the selected node, but it does not preserve Mihomo's full
|
||||
provider/group behavior. Selection is Burrow-owned, not Mihomo group semantics.
|
||||
|
||||
### URI conversion
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Trojan URI conversion maps fragment to name, host/port/user to server/port/
|
||||
password, `allowInsecure` to `skip-cert-verify`, optional `sni`, optional
|
||||
`alpn`, optional `type`, ws/grpc options, `pcs` to certificate fingerprint,
|
||||
and `fp` to `client-fingerprint` (`common/convert/converter.go:149`).
|
||||
- The URI converter also sets `udp: true` for Trojan URI imports
|
||||
(`common/convert/converter.go:162`).
|
||||
- If `fp` is absent, Mihomo sets `client-fingerprint` to `chrome`
|
||||
(`common/convert/converter.go:198`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Maps similar basics and also defaults missing `fp` to `chrome`
|
||||
(`burrow/src/proxy_subscription.rs:491`).
|
||||
- Stores `pcs` as `fingerprint` (`burrow/src/proxy_subscription.rs:517`).
|
||||
- Stores ALPN with an `alpn_present` marker, so new imports can distinguish
|
||||
missing ALPN from explicit empty ALPN (`burrow/src/proxy_subscription.rs:83`).
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo can distinguish "ALPN field absent" from "ALPN field present but empty"
|
||||
at the decoded option/runtime layer because the option slice can be nil or
|
||||
non-nil. Burrow now records that distinction for new imports.
|
||||
- Mihomo URI conversion only sets `alpn` when the URI query value is non-empty;
|
||||
this is still different from Burrow because Burrow imports absent URI ALPN as
|
||||
a concrete empty vector that the runtime later treats as intentional.
|
||||
- Mihomo carries Trojan's `udp` option through the base outbound model. Burrow
|
||||
now stores the Trojan `udp` field and gates Trojan UDP sessions on it.
|
||||
|
||||
## 2. Runtime Transport Support
|
||||
|
||||
Mihomo:
|
||||
|
||||
- `TrojanOption.Network` supports default TCP, `ws`, and `grpc`
|
||||
(`adapter/outbound/trojan.go:51`).
|
||||
- Runtime branches:
|
||||
- `ws`: wraps the TCP connection in websocket over TLS
|
||||
(`adapter/outbound/trojan.go:67`).
|
||||
- `grpc`: uses gun transport and a gRPC client pool
|
||||
(`adapter/outbound/trojan.go:175`, `adapter/outbound/trojan.go:298`).
|
||||
- default TCP: TLS, then Trojan header (`adapter/outbound/trojan.go:119`).
|
||||
- Trojan options also include `ECHOpts`, `RealityOpts`, `SSOpts`, and
|
||||
`ClientFingerprint` (`adapter/outbound/trojan.go:52`).
|
||||
- If `ss-opts.enabled` is set, Mihomo wraps Trojan streams in Shadowsocks using
|
||||
the configured method/password, defaulting the method to `AES-128-GCM`
|
||||
(`adapter/outbound/trojan.go:284`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Parser represents `Tcp`, `Ws`, and `Grpc` (`burrow/src/proxy_subscription.rs:93`).
|
||||
- Runtime rejects any Trojan network except `Tcp`
|
||||
(`burrow/src/proxy_runtime.rs:553`).
|
||||
- Runtime preview can mark unsupported transports via `runtime_support_error`
|
||||
(`burrow/src/proxy_runtime.rs:279`).
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Burrow parses ws/grpc metadata but cannot run ws/grpc Trojan nodes.
|
||||
- Mihomo can run ws and grpc Trojan nodes.
|
||||
- Burrow has no Trojan-over-Shadowsocks wrapper equivalent for Mihomo
|
||||
`ss-opts`.
|
||||
- Burrow has no Trojan REALITY or ECH runtime support.
|
||||
|
||||
## 3. ALPN Behavior
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Trojan TCP default ALPN is `["h2", "http/1.1"]`
|
||||
(`transport/trojan/trojan.go:23`).
|
||||
- For default TCP, Mihomo starts with that default, then overrides only if
|
||||
`option.ALPN != nil` (`adapter/outbound/trojan.go:122`).
|
||||
- For websocket, default ALPN is `["http/1.1"]` and is overridden only if
|
||||
`option.ALPN != nil` (`adapter/outbound/trojan.go:95`).
|
||||
- For grpc, TLS `NextProtos` is fixed to `["h2"]`
|
||||
(`adapter/outbound/trojan.go:307`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Runtime ALPN uses Mihomo's TCP default when `alpn_present` is false, and uses
|
||||
`TrojanNode.alpn` exactly when `alpn_present` is true
|
||||
(`burrow/src/proxy_runtime.rs:695`).
|
||||
- TLS config writes those strings directly into rustls `alpn_protocols`
|
||||
(`burrow/src/proxy_runtime.rs:820`).
|
||||
- Parser stores missing ALPN as `alpn_present: false`; legacy stored nodes that
|
||||
lack `alpn_present` deserialize the same way.
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- For a Trojan URI that omits `alpn`, Mihomo sends `h2,http/1.1`; Burrow now
|
||||
applies the same default through the presence marker.
|
||||
- Explicit empty ALPN is possible through Mihomo's decoded YAML/options path,
|
||||
but not through its URI converter because empty URI `alpn` is not emitted.
|
||||
|
||||
Likely relevance:
|
||||
|
||||
- The selected node has `alpn: []` in Burrow. If the subscription omitted ALPN,
|
||||
Mihomo would use the default ALPN list, while Burrow now cannot know that and
|
||||
sends none.
|
||||
|
||||
## 4. Client Fingerprint and uTLS
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Trojan options include `ClientFingerprint` (`adapter/outbound/trojan.go:57`).
|
||||
- URI conversion defaults missing `fp` to `chrome`
|
||||
(`common/convert/converter.go:198`).
|
||||
- TLS connection code checks `GetFingerprint`; when recognized, it converts the
|
||||
TLS config to a uTLS config and creates a uTLS client
|
||||
(`transport/vmess/tls.go:46`).
|
||||
- uTLS preserves `NextProtos`, `ServerName`, cert settings, and version bounds
|
||||
from the config (`component/tls/utls.go:175`).
|
||||
- `GetFingerprint` falls back to a global fingerprint if the node omits one,
|
||||
returns no uTLS for empty/`none`, supports `random`, and recognizes browser
|
||||
profiles such as `chrome`, `firefox`, `safari`, `ios`, `android`, `edge`,
|
||||
`360`, and `qq` (`component/tls/utls.go:42`, `component/tls/utls.go:65`,
|
||||
`component/tls/utls.go:81`).
|
||||
- For websocket, Mihomo forces the uTLS ALPN extension to `http/1.1`
|
||||
(`component/tls/utls.go:258`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Parser stores `client_fingerprint` (`burrow/src/proxy_subscription.rs:89`).
|
||||
- Runtime logs it when present, but does not use it to build a uTLS ClientHello.
|
||||
- Runtime uses OpenSSL on macOS and rustls/tokio-rustls elsewhere, not Mihomo's
|
||||
uTLS implementation.
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo sends a Chrome-like TLS ClientHello for this selected node
|
||||
(`client_fingerprint: chrome`).
|
||||
- Burrow sends the platform TLS stack's ClientHello.
|
||||
|
||||
Likely relevance:
|
||||
|
||||
- Many Trojan subscriptions use `fp=chrome` or Mihomo's default `chrome` because
|
||||
the server or fronting path expects that TLS fingerprint. Burrow ignoring it is
|
||||
a high-probability compatibility gap.
|
||||
|
||||
## 5. Certificate Verification and Pinning
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Trojan options include `SkipCertVerify`, `Fingerprint`, `Certificate`, and
|
||||
`PrivateKey` (`adapter/outbound/trojan.go:46`).
|
||||
- TLS setup passes those into `ca.GetTLSConfig`
|
||||
(`adapter/outbound/trojan.go:101`, `transport/vmess/tls.go:27`).
|
||||
- URI conversion maps `pcs` to `fingerprint`
|
||||
(`common/convert/converter.go:204`).
|
||||
- Mihomo treats `fingerprint` as SHA-256 certificate pinning. Browser names such
|
||||
as `chrome` are rejected there with an instruction to use
|
||||
`client-fingerprint` instead (`component/ca/fingerprint.go:13`).
|
||||
- A pinned fingerprint may match any certificate in the chain; if it matches a
|
||||
non-leaf certificate, Mihomo verifies the leaf against that pinned certificate
|
||||
as a trusted root (`component/ca/fingerprint.go:29`).
|
||||
- `GetTLSConfig` installs `VerifyConnection` and sets `InsecureSkipVerify` when
|
||||
pinning is enabled, so Go's default verifier is bypassed in favor of the pin
|
||||
verifier (`component/ca/config.go:100`).
|
||||
- Mihomo supports TLS client certificates through `certificate`/`private-key`,
|
||||
including inline material, safe file paths, file watching, or generated random
|
||||
key material when both are empty (`component/ca/config.go:114`,
|
||||
`component/ca/keypair.go:25`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Parser stores `fingerprint` (`burrow/src/proxy_subscription.rs:90`).
|
||||
- Runtime parses `fingerprint` as a SHA-256 certificate pin and checks the
|
||||
presented certificate chain against it (`burrow/src/proxy_runtime.rs:865`).
|
||||
- Runtime root store is webpki roots; `skip_cert_verify` installs a verifier
|
||||
that accepts the certificate/signature checks (`burrow/src/proxy_runtime.rs:820`,
|
||||
`burrow/src/proxy_runtime.rs:835`).
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo supports pinned certificate fingerprint and custom certificate material.
|
||||
- Burrow now supports SHA-256 certificate fingerprint pinning, normal root
|
||||
validation, or all-cert skip verification.
|
||||
- Burrow has no client certificate fields for Trojan.
|
||||
|
||||
## 6. SNI Behavior
|
||||
|
||||
Mihomo:
|
||||
|
||||
- `NewTrojan` defaults empty SNI to the server host
|
||||
(`adapter/outbound/trojan.go:251`).
|
||||
- Trojan URI conversion sets SNI only when the `sni` query is present
|
||||
(`common/convert/converter.go:168`).
|
||||
- TLS config uses `option.SNI` as host/server name
|
||||
(`adapter/outbound/trojan.go:126`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Parser accepts URI `sni`, `peer`, or `host` as SNI
|
||||
(`burrow/src/proxy_subscription.rs:505`).
|
||||
- Runtime defaults SNI to node server if absent
|
||||
(`burrow/src/proxy_runtime.rs:561`).
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Burrow accepts more SNI aliases in URI parsing than Mihomo's converter path.
|
||||
- Runtime defaulting is effectively aligned for TCP.
|
||||
|
||||
## 7. Trojan Header and Password Hash
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Password is converted with `trojan.Key` (`adapter/outbound/trojan.go:269`).
|
||||
- Trojan header writes hex password, CRLF, command byte, SOCKS address, CRLF
|
||||
(`transport/trojan/trojan.go:39`).
|
||||
- TCP command is `1`, UDP command is `3`
|
||||
(`transport/trojan/trojan.go:31`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Password hash is SHA-224 hex (`burrow/src/proxy_runtime.rs:903`).
|
||||
- Header writes hash, CRLF, command byte, SOCKS address, CRLF as a single
|
||||
coalesced buffer, matching Mihomo's one-write `trojan.WriteHeader` behavior.
|
||||
- TCP uses command `0x01`; UDP uses `0x03`
|
||||
(`burrow/src/proxy_runtime.rs:583`, `burrow/src/proxy_runtime.rs:604`).
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- No remaining framing discrepancy for basic TCP/UDP header shape. A live
|
||||
provider check showed that splitting the Trojan TCP header across multiple TLS
|
||||
writes could produce EOF before any target response bytes; Burrow now
|
||||
coalesces the TCP header before writing it.
|
||||
|
||||
## 8. Destination Address and Hostname Metadata
|
||||
|
||||
Mihomo:
|
||||
|
||||
- `serializesSocksAddr` writes a domain-form SOCKS address when
|
||||
`metadata.AddrType()` is domain (`adapter/outbound/util.go:15`).
|
||||
- Fake-IP and mapping preprocessing can translate a destination fake IP back to
|
||||
hostname, clear `DstIP`, and mark `DNSFakeIP`
|
||||
(`tunnel/tunnel.go:287`).
|
||||
- If fake-IP metadata is missing, Mihomo may sniff TCP to recover a domain
|
||||
(`tunnel/tunnel.go:511`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Netstack TCP accept yields `remote_addr: SocketAddr`; Burrow now checks its
|
||||
daemon-owned fake-IP DNS cache and writes a domain-form SOCKS address when the
|
||||
accepted destination IP maps back to a hostname. It falls back to IP-form when
|
||||
no hostname is known.
|
||||
- UDP sessions key on local/remote `SocketAddr`. DNS queries to Burrow's
|
||||
daemon-owned resolver are answered locally before they become proxy UDP flows.
|
||||
Other UDP flows still use socket-address metadata.
|
||||
- Burrow can parse a domain-form SOCKS address in Trojan UDP responses, but it
|
||||
resolves that domain immediately through the process/system resolver and
|
||||
returns a `SocketAddr` (`burrow/src/proxy_runtime.rs:1187`).
|
||||
- Burrow has a basic fake-IP map for A queries answered by the daemon resolver,
|
||||
but no TCP sniffer or rules engine.
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo can preserve domain targets through fake-IP/mapping paths.
|
||||
- Burrow now preserves DNS-backed domain targets for TCP, but only for domains
|
||||
resolved through its daemon-owned A-query fake DNS path.
|
||||
|
||||
Compatibility impact:
|
||||
|
||||
- For ordinary HTTPS this may still work because the application supplies TLS
|
||||
SNI to the target site. It breaks or weakens Mihomo-style rule selection,
|
||||
fake-IP DNS, and any proxy behavior that needs the destination hostname at the
|
||||
outbound layer.
|
||||
|
||||
## 9. Proxy Server DNS Resolution
|
||||
|
||||
Mihomo:
|
||||
|
||||
- The generic dialer resolves proxy server hostnames through
|
||||
`resolver.ProxyServerHostResolver` by default (`component/dialer/dialer.go:358`).
|
||||
- `ProxyServerHostResolver` is wired from `proxy-server-nameserver` when present,
|
||||
otherwise from the normal resolver (`hub/executor/executor.go:290`).
|
||||
- Resolver lookup checks hosts first, IP literals second, then configured
|
||||
resolver or system resolver fallback (`component/resolver/resolver.go:154`).
|
||||
- DNS exchange uses cache, singleflight, retry monitor, main/fallback
|
||||
nameservers, and policy routing (`dns/resolver.go:150`,
|
||||
`dns/resolver.go:220`, `dns/resolver.go:298`).
|
||||
- Resolver caches are TTL based, can serve stale records with background refresh,
|
||||
and use a default max size of 4096 (`dns/resolver.go:150`,
|
||||
`dns/resolver.go:452`, `dns/util.go:48`).
|
||||
- The system resolver refreshes `/etc/resolv.conf` periodically and can fall
|
||||
back to public DNS servers if no system DNS clients are available
|
||||
(`dns/system_common.go:15`, `dns/system.go:68`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Proxy endpoint resolution starts with `(host, port).to_socket_addrs()` via the
|
||||
process/system resolver.
|
||||
- The current workspace caches resolved proxy server addresses in
|
||||
`ProxyServerEndpoint` when the outbound is configured
|
||||
(`burrow/src/proxy_runtime.rs:1263`).
|
||||
- If system DNS returns only `198.18.0.0/15` fake-IP addresses, Burrow falls
|
||||
back to direct DNS using physical/public DNS servers and excludes the resolved
|
||||
real proxy-server host routes from the tunnel.
|
||||
- There is no user-configurable proxy-server resolver equivalent.
|
||||
- There is no DNS TTL refresh or resolver cache for proxy server hosts beyond
|
||||
the current cached address list.
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo has a first-class proxy-server DNS plane; Burrow does not.
|
||||
- Mihomo has richer policy and cache controls for proxy-server DNS. Burrow now
|
||||
has a narrower fake-IP fallback that avoids recursive proxy-server dials for
|
||||
observed `198.18.0.0/15` answers.
|
||||
|
||||
## 10. Dialing Strategy, Interface Binding, and Socket Options
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Dialer parses all resolved IPs, normalizes IPv4-in-IPv6, and supports IPv4/
|
||||
IPv6 preference (`component/dialer/dialer.go:358`).
|
||||
- It can dial serially, in dual-stack fallback mode, or in parallel depending on
|
||||
`tcpConcurrent` (`component/dialer/dialer.go:58`,
|
||||
`component/dialer/dialer.go:207`).
|
||||
- Dialer applies keepalive and MPTCP settings (`component/dialer/dialer.go:139`).
|
||||
- Dialer supports explicit `interface-name`, default interface, interface
|
||||
finder, routing mark, TFO, MPTCP, and custom net dialers
|
||||
(`component/dialer/dialer.go:143`, `adapter/outbound/base.go:145`).
|
||||
- The dialer option model includes `interfaceName`, `fallbackBind`,
|
||||
`routingMark`, preferred IP family, TFO, MPTCP, resolver, and custom net
|
||||
dialer (`component/dialer/options.go:33`).
|
||||
- Darwin interface binding uses `IP_BOUND_IF` / `IPV6_BOUND_IF`
|
||||
(`component/dialer/bind_darwin.go:14`).
|
||||
- Linux interface binding uses bind-to-device, and Linux routing marks use
|
||||
`SO_MARK` (`component/dialer/bind_linux.go:12`,
|
||||
`component/dialer/mark_linux.go:20`).
|
||||
- Mihomo wraps outbound connection creation in a 5 second context and a retry
|
||||
loop with up to 10 attempts, stopping early for non-retryable DNS/reject
|
||||
errors (`tunnel/tunnel.go:560`, `tunnel/tunnel.go:702`).
|
||||
- The dialer has a 300 ms dual-stack fallback window and optional parallel dial
|
||||
mode (`component/dialer/dialer.go:25`, `component/dialer/dialer.go:58`,
|
||||
`component/dialer/dialer.go:207`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Dials cached addresses sequentially (`burrow/src/proxy_runtime.rs:1294`).
|
||||
- No explicit TCP connect timeout wrapper is applied in this path.
|
||||
- No TFO, MPTCP, routing mark, custom dialer, or configurable interface name.
|
||||
- On Apple, Burrow chooses a default physical interface by scanning interfaces,
|
||||
preferring `en*` and skipping `utun`, loopback, bridge, awdl, etc.
|
||||
(`burrow/src/proxy_runtime.rs:1501`).
|
||||
- Burrow skips physical-interface binding for loopback proxy-server addresses so
|
||||
controlled local proxy runtime tests can use `127.0.0.1`.
|
||||
- On non-Apple, `bind_proxy_outbound_socket` is a no-op
|
||||
(`burrow/src/proxy_runtime.rs:1495`).
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo has a mature, configurable dialer. Burrow has a narrow Apple-focused
|
||||
physical-interface bind path and a sequential dial loop.
|
||||
- Burrow has no high-level per-flow retry loop comparable to Mihomo's tunnel
|
||||
retry.
|
||||
|
||||
## 11. UDP Behavior
|
||||
|
||||
Mihomo:
|
||||
|
||||
- Trojan supports UDP through `ListenPacketContext`
|
||||
(`adapter/outbound/trojan.go:202`).
|
||||
- UDP exposure is gated by the node's `udp` option through `Base.SupportUDP`
|
||||
(`adapter/outbound/trojan.go:50`, `adapter/outbound/base.go:105`).
|
||||
- Before UDP, it resolves destination metadata if needed
|
||||
(`adapter/outbound/trojan.go:203`, `adapter/outbound/base.go:178`).
|
||||
- `SupportUOT` returns true (`adapter/outbound/trojan.go:225`).
|
||||
- UDP packet handling maintains mappings from origin metadata to target address
|
||||
(`tunnel/connection.go:70`).
|
||||
- Trojan UDP packet writes fragment payloads larger than 8192 bytes into
|
||||
multiple Trojan UDP frames (`transport/trojan/trojan.go:54`,
|
||||
`transport/trojan/trojan.go:66`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- UDP is enabled in the userspace netstack (`burrow/src/proxy_runtime.rs:379`).
|
||||
- Burrow's import/runtime model has no Trojan `udp` enable flag, so the runtime
|
||||
does not mirror Mihomo's per-node UDP gating.
|
||||
- UDP sessions are keyed by local/remote socket address and use a channel per
|
||||
flow (`burrow/src/proxy_runtime.rs:516`).
|
||||
- Trojan UDP opens a TLS connection per UDP flow, writes a Trojan UDP header,
|
||||
and relays Trojan UDP packets over that stream
|
||||
(`burrow/src/proxy_runtime.rs:598`).
|
||||
- UDP flow idle timeout is 30 seconds (`burrow/src/proxy_runtime.rs:45`).
|
||||
- No UOT support is exposed.
|
||||
- Burrow now fragments single Trojan UDP payloads above 8192 bytes into multiple
|
||||
Trojan UDP frames (`burrow/src/proxy_runtime.rs:1050`).
|
||||
- Burrow now carries a Trojan `udp` enable/disable flag.
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo has metadata-aware UDP resolution/mapping and UOT support. Burrow has
|
||||
direct per-flow Trojan UDP over TLS without hostname metadata.
|
||||
- Mihomo and Burrow both fragment oversized Trojan UDP payloads after
|
||||
`BEP-0011`.
|
||||
|
||||
## 12. Packet Tunnel and DNS Model
|
||||
|
||||
Mihomo:
|
||||
|
||||
- TUN mode has metadata preprocessing, fake-IP reverse mapping, sniffing, rule
|
||||
resolution, and then proxy dial (`tunnel/tunnel.go:287`,
|
||||
`tunnel/tunnel.go:540`).
|
||||
- DNS service and resolver enhancer support fake-IP/mapping modes
|
||||
(`dns/service.go:27`, `dns/enhancer.go:23`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Apple Network Extension forwards packets to a daemon packet stream and writes
|
||||
daemon responses back to `packetFlow` (`Apple/NetworkExtension/PacketTunnelProvider.swift:91`).
|
||||
- Burrow's proxy tunnel settings use virtual addresses and point DNS at the
|
||||
daemon-owned resolver address `100.64.0.2`.
|
||||
- The daemon answers A queries with stable fake IPs and records fake-IP to
|
||||
hostname mappings for later TCP proxy requests. There is still no rules engine
|
||||
or TCP sniffer in the packet runtime.
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo is a policy-aware packet tunnel with DNS hijack/fake-IP metadata.
|
||||
- Burrow is a simpler packet-to-selected-outbound bridge with daemon-owned fake
|
||||
DNS for A queries. It does not yet implement Mihomo's policy routing,
|
||||
sniffing, resolver modes, or per-rule metadata pipeline.
|
||||
|
||||
## 13. Logging and Observability
|
||||
|
||||
Mihomo:
|
||||
|
||||
- DNS cache hits, DNS resolution, metadata preprocessing failures, process lookup
|
||||
failures, and UDP resolution failures are logged in the relevant paths
|
||||
(`dns/resolver.go:170`, `tunnel/tunnel.go:339`, `tunnel/tunnel.go:506`,
|
||||
`tunnel/connection.go:88`).
|
||||
|
||||
Burrow:
|
||||
|
||||
- Runtime now logs selected node, configured Trojan outbound metadata, resolved
|
||||
proxy addresses, fake-IP/benchmark-range warning, and socket bind/connect
|
||||
failures (`burrow/src/proxy_runtime.rs:215`,
|
||||
`burrow/src/proxy_runtime.rs:552`, `burrow/src/proxy_runtime.rs:1263`,
|
||||
`burrow/src/proxy_runtime.rs:1294`).
|
||||
- `Scripts/burrow-proxy-selftest` provides a controlled daemon packet-runtime
|
||||
test against a local Trojan server, and `proxy-tcp-probe --dns-name` exercises
|
||||
daemon DNS plus raw TCP packet streaming without changing system proxy state.
|
||||
- On macOS, Rust tracing goes to stderr by default; OSLog is opt-in via
|
||||
`BURROW_ENABLE_OSLOG=1` (`burrow/src/tracing.rs:50`).
|
||||
|
||||
Discrepancy:
|
||||
|
||||
- Mihomo logs more of the full metadata/rule/DNS pipeline.
|
||||
- Burrow now logs the outbound path and has standalone packet-runtime probes,
|
||||
but still lacks Mihomo's full metadata/rule diagnostics.
|
||||
|
||||
## 14. Likely Compatibility Gaps for the Selected Node
|
||||
|
||||
Highest probability:
|
||||
|
||||
1. `client_fingerprint: chrome` is stored but not implemented by Burrow runtime.
|
||||
Mihomo would use uTLS Chrome, so this remains a compatibility gap for
|
||||
providers that strictly require browser ClientHello impersonation.
|
||||
2. The selected provider node succeeds through Mihomo when Mihomo is configured
|
||||
from Burrow's stored DB payload, pinned to real proxy-server IPs, and bound to
|
||||
`en0`. Burrow's daemon packet probe now also succeeds for `google.com:80`
|
||||
after macOS OpenSSL TLS and coalesced Trojan header writes.
|
||||
3. Older stored nodes may have `alpn: []` from pre-`BEP-0011` imports. Burrow now
|
||||
treats missing `alpn_present` as absent ALPN and applies Mihomo's TCP default.
|
||||
4. Burrow's proxy-server resolver is narrower than Mihomo's configurable
|
||||
proxy-server DNS plane.
|
||||
|
||||
Medium probability:
|
||||
|
||||
5. Burrow only preserves domain metadata for TCP flows that went through its
|
||||
daemon-owned A-query fake DNS path.
|
||||
6. Burrow has no Mihomo-style sniffing or rule metadata. This is
|
||||
less likely to break simple all-traffic proxying, but it is a major semantic
|
||||
difference.
|
||||
|
||||
Lower probability for this exact selected node:
|
||||
|
||||
7. ws/grpc transport is unsupported by Burrow runtime. The selected node is TCP,
|
||||
so this is not the immediate failure for that node.
|
||||
8. Certificate pinning is now implemented by Burrow. The selected node has no
|
||||
stored pinned fingerprint, so this does not explain that exact node.
|
||||
|
||||
## Suggested Burrow Follow-Ups
|
||||
|
||||
1. Implement `client_fingerprint` for Trojan, at least `chrome`, or reject nodes
|
||||
requiring runtime fingerprints until uTLS-style support exists.
|
||||
2. Expand the proxy-server resolver path toward Mihomo's configurable
|
||||
`proxy-server-nameserver` behavior and TTL/cache controls.
|
||||
3. Add sniffing or broader DNS metadata handling for flows that do not use the
|
||||
daemon-owned A-query fake DNS path.
|
||||
4. Either implement Trojan ws/grpc or make unsupported transport status more
|
||||
prominent when importing/selecting nodes.
|
||||
5. Add Trojan client certificate/private-key support if subscriptions need it.
|
||||
|
|
@ -31,6 +31,11 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to
|
|||
- login/session lifecycle brokering
|
||||
- runtime start/stop/reconcile
|
||||
- translating helper or bridge processes into stable daemon RPCs
|
||||
- Apple uses two daemon sockets with one shared app-group database:
|
||||
- the app-facing control socket, `burrow.sock`, is for UI management RPCs such as network list, import, preview, refresh, node selection, account/login control, and other non-packet workflows
|
||||
- the packet-tunnel socket, `burrow-packet-tunnel.sock`, is opened by `PacketTunnelProvider` and owns active tunnel runtime RPCs such as `TunnelStart`, `TunnelConfiguration`, `TunnelPackets`, and `TunnelStop`
|
||||
- The Network Extension must host or connect to the packet-tunnel daemon socket while a VPN session is active. The app must not hot-swap packet runtime state underneath an already-running Apple `NEPacketTunnelProvider`.
|
||||
- UI-side network mutations may persist desired state through the control daemon. If those mutations affect active packet-tunnel settings or runtime, the Apple client must restart or otherwise reassert the Network Extension so settings and packet runtime are installed from the same selected network state.
|
||||
- `burrow/src/control/` owns transport-neutral control-plane semantics such as discovery, authority normalization, and request/response shaping.
|
||||
- Apple UI owns presentation only:
|
||||
- forms
|
||||
|
|
@ -43,7 +48,7 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to
|
|||
|
||||
- Keeping control-plane I/O out of Swift UI reduces accidental secret, token, and callback sprawl across app code.
|
||||
- The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized.
|
||||
- Apple daemon lifecycle ownership must be explicit: either the app ensures the daemon is running before RPC or the extension owns it and the UI surfaces daemon-unavailable state clearly.
|
||||
- Apple daemon lifecycle ownership must be explicit: the app owns the control daemon used for presentation workflows, and the Network Extension owns the packet daemon used for active tunnel runtime. Both use the app-group database as the durable desired-state store.
|
||||
- Non-Apple presentation clients should follow the same daemon-first lifecycle pattern: connect to a managed daemon when present, or start a user-scoped embedded daemon before issuing RPCs, without adding platform-local control-plane paths.
|
||||
|
||||
## Contributor Playbook
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
# `BEP-0009` - Trojan Proxy and Subscription Import
|
||||
|
||||
```text
|
||||
Status: Superseded
|
||||
Proposal: BEP-0009
|
||||
Authors: gpt-5.5
|
||||
Coordinator: gpt-5.5
|
||||
Reviewers: Pending
|
||||
Constitution Sections: II, IV, V
|
||||
Implementation PRs: Pending
|
||||
Decision Date: 2026-05-27
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
Superseded by `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md`.
|
||||
|
||||
Burrow should add daemon-owned Trojan proxy support as a proxy egress transport, plus a subscription importer that can parse base64 URI subscriptions containing `trojan://` nodes. This proposal covers Trojan profile ingestion, validation, storage, runtime boundaries, and user-facing import behavior without claiming broader Clash or Mihomo protocol parity.
|
||||
|
||||
Trojan support should be implemented as an explicit Burrow protocol family with tested URI parsing and daemon-managed runtime behavior. Apple clients may present imported profiles and statuses, but they must continue to talk only to the daemon over gRPC.
|
||||
|
||||
## Motivation
|
||||
|
||||
- Real-world proxy subscriptions commonly expose Trojan nodes as base64-encoded lists of `trojan://` URIs rather than static Clash YAML.
|
||||
- Users should be able to import a subscription URL and let Burrow identify compatible nodes without leaking tokens into logs, source files, or UI copy.
|
||||
- Trojan is different from WireGuard, Tailnet, and future MASQUE `CONNECT-IP`; it needs an explicit profile model, TLS validation policy, and proxy egress runtime.
|
||||
- Supporting one proxy protocol first gives Burrow a concrete path for future proxy families without adding an unbounded "Clash compatible" claim.
|
||||
|
||||
## Detailed Design
|
||||
|
||||
- Add a new daemon-owned network/profile type for Trojan proxy egress.
|
||||
- The stable code/protocol identifier should use `trojan`.
|
||||
- User-facing copy should spell the project name as `Burrow` and should name the protocol as `Trojan`.
|
||||
- The profile type should be distinct from `WireGuard` and `Tailnet`.
|
||||
- Define a `TrojanProfile` payload with at least:
|
||||
- display name
|
||||
- server host
|
||||
- server port
|
||||
- password or credential reference
|
||||
- TLS server name indication
|
||||
- optional peer/host metadata when present in imported subscriptions
|
||||
- transport, initially restricted to `tcp`
|
||||
- certificate verification policy
|
||||
- source metadata such as imported subscription name and refresh timestamp
|
||||
- Add a subscription import flow owned by the daemon.
|
||||
- Fetch subscription URLs in daemon code, not Swift UI.
|
||||
- Redact query tokens and credentials from logs and errors.
|
||||
- Accept base64 URI subscriptions whose decoded body is a newline-separated URI list.
|
||||
- Parse `trojan://` URIs and reject unknown schemes unless future proposals add them.
|
||||
- Preserve node labels from URI fragments as display names after normalization.
|
||||
- Treat `allowInsecure=1`, `skip-cert-verify=true`, or equivalent flags as an explicit insecure TLS policy.
|
||||
- Deduplicate imported nodes by stable profile fields rather than display label alone.
|
||||
- Add daemon gRPC methods before any Apple UI work.
|
||||
- A minimal shape is `SubscriptionImportPreview`, `SubscriptionImportApply`, and `SubscriptionRefresh`.
|
||||
- Preview should return compatible profile counts, rejected entry counts, redacted warnings, and normalized display labels.
|
||||
- Apply should write only selected compatible profiles.
|
||||
- Refresh should preserve local enable/order choices where possible.
|
||||
- Add runtime support in stages.
|
||||
- Stage 1: parser, preview, storage, and tests only.
|
||||
- Stage 2: daemon-managed Trojan outbound connector for TCP proxy egress.
|
||||
- Evaluate `trojan-rust/trojan-rust` as the first implementation dependency for this stage.
|
||||
- Prefer the narrowest usable crate, likely `trojan-proto` for protocol parsing/serialization or `trojan-client` for a SOCKS5-backed client runtime.
|
||||
- Avoid depending on the top-level `trojan` crate with default features unless Burrow needs the bundled CLI, certificate, SQL, or agent features.
|
||||
- Wrap any external runtime API behind a Burrow-owned adapter so profile storage, logging, kill-switch behavior, and daemon lifecycle remain local.
|
||||
- Stage 3: route/DNS integration with Burrow's tunnel and proxy selection model.
|
||||
- Stage 4: UI controls for import, refresh, certificate policy warnings, and per-profile status.
|
||||
- Keep the runtime boundary explicit.
|
||||
- Apple UI may display forms, previews, warning states, and daemon statuses.
|
||||
- Apple UI must not fetch subscription URLs directly.
|
||||
- Apple UI must not open Trojan sockets directly.
|
||||
- Any helper process must be brokered and supervised by the daemon.
|
||||
|
||||
## Security and Operational Considerations
|
||||
|
||||
- Subscription URLs often contain bearer tokens. Burrow must treat full subscription URLs as secrets.
|
||||
- Imported Trojan URIs contain passwords. Burrow must avoid writing decoded raw subscriptions to logs, crash reports, test snapshots, or telemetry.
|
||||
- Certificate verification defaults should be secure. Insecure imported nodes may be stored only with an explicit warning field and UI-visible risk state.
|
||||
- Passwords should move into the same secret-storage path Burrow uses for other long-lived credentials instead of remaining in plain payload blobs.
|
||||
- The importer must set fetch limits for response size, redirect depth, and timeout.
|
||||
- The parser must be independent and fuzzable enough to handle malformed URI lists without panics.
|
||||
- Runtime support should include a kill-switch path so disabling a profile tears down active sockets and route state.
|
||||
- Burrow must not present Trojan as a VPN with the same security or routing semantics as WireGuard. It is a proxy egress transport unless later proposals define full-device routing behavior.
|
||||
|
||||
## Contributor Playbook
|
||||
|
||||
1. Add parser-only fixtures for representative `trojan://` URIs, including TLS SNI, host, peer, TCP transport, fragments, duplicate labels, malformed ports, unsupported schemes, and insecure certificate flags.
|
||||
2. Add a redacted subscription fixture that mirrors a real base64 URI-list response without preserving live tokens, passwords, or hostnames that identify a paid account.
|
||||
3. Extend `proto/burrow.proto` with daemon-owned subscription preview/apply/refresh RPCs before adding Apple UI.
|
||||
4. Add daemon storage for `trojan` profiles and migrate old payload storage only if the profile model requires it.
|
||||
5. Verify generated Swift and Rust bindings compile after proto changes.
|
||||
6. Implement runtime behind a feature flag or internal capability gate until end-to-end proxy egress tests exist.
|
||||
7. Run:
|
||||
|
||||
```bash
|
||||
cargo test --workspace --all-features
|
||||
python3 Scripts/check-bep-metadata.py
|
||||
```
|
||||
|
||||
8. For Apple work, verify no Swift UI or support code fetches subscription URLs or talks to Trojan endpoints directly.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Import full Clash or Mihomo YAML first. Rejected for this proposal because it would imply a broad compatibility contract across many protocols before Burrow has a proxy abstraction.
|
||||
- Shell out to Mihomo as a helper. Rejected as the default path because it would create a large external runtime dependency and make Burrow's protocol guarantees harder to test. A future proposal may revisit helper-based compatibility as an explicit adapter.
|
||||
- Vendor or reimplement the Trojan wire protocol immediately. Rejected as the default path while `trojan-rust/trojan-rust` appears to provide GPL-compatible Rust crates for protocol parsing and client behavior. Burrow should still keep an adapter boundary so it can replace the dependency if maintenance, API, or security review fails.
|
||||
- Treat Trojan nodes as WireGuard-like networks. Rejected because Trojan is proxy egress over TLS, not a packet VPN with WireGuard peer semantics.
|
||||
- Let Apple UI fetch and parse subscriptions for convenience. Rejected because it violates the daemon IPC boundary and spreads subscription tokens into UI code.
|
||||
|
||||
## Impact on Other Work
|
||||
|
||||
- Depends on the daemon boundary in BEP-0005.
|
||||
- Should align with the transport-neutral route and policy work described in BEP-0003, but should not block parser-only subscription import.
|
||||
- Creates a path for future Shadowsocks or VLESS proposals by forcing shared subscription parsing and redaction rules.
|
||||
- May require a follow-up secret-storage proposal if current network payload storage cannot safely hold proxy credentials.
|
||||
|
||||
## Decision
|
||||
|
||||
Superseded by `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md` on 2026-05-27.
|
||||
|
||||
## References
|
||||
|
||||
- Trojan protocol: https://trojan-gfw.github.io/trojan/protocol.html
|
||||
- Trojan config: https://trojan-gfw.github.io/trojan/config.html
|
||||
- Trojan reference implementation: https://github.com/trojan-gfw/trojan
|
||||
- Rust Trojan implementation candidate: https://github.com/trojan-rust/trojan-rust
|
||||
- Rust `trojan-client` crate: https://crates.io/crates/trojan-client
|
||||
- Rust `trojan-proto` crate: https://crates.io/crates/trojan-proto
|
||||
- Mihomo Trojan proxy configuration: https://wiki.metacubex.one/en/config/proxies/trojan/
|
||||
- BEP-0003: `evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md`
|
||||
- BEP-0005: `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md`
|
||||
- Burrow protocol schema: `proto/burrow.proto`
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
# `BEP-0010` - Proxy Subscriptions as Packet Tunnel Networks
|
||||
|
||||
```text
|
||||
Status: Draft
|
||||
Proposal: BEP-0010
|
||||
Authors: Codex
|
||||
Coordinator: Codex
|
||||
Reviewers: Pending
|
||||
Constitution Sections: II, IV, V
|
||||
Implementation PRs: Pending
|
||||
Decision Date: Pending
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
Burrow should support Trojan and Shadowsocks subscription imports as packet-tunnel networks, not as local SOCKS or system proxy mode. The user-facing add action should be named `Import Proxy Subscription` on every UI surface. Imported proxy subscriptions should participate in Burrow's existing tunnel model: platform tunnel capture feeds daemon-owned routing and proxy execution, and a selected proxy outbound carries TCP and UDP flows.
|
||||
|
||||
This proposal supersedes `BEP-0009`. The earlier SOCKS-backed Trojan runtime was useful as a smoke-test path, but Burrow's product semantics are VPN-shaped. Keeping a local SOCKS mode would add another behavior surface, confuse how proxy networks relate to WireGuard and Tailnet, and delay the packet-tunnel work Burrow actually needs.
|
||||
|
||||
## Motivation
|
||||
|
||||
- Users expect the main Burrow tunnel switch to affect full-device traffic, as it does for packet-shaped WireGuard and Tailnet integrations.
|
||||
- Trojan and Shadowsocks subscriptions are commonly distributed as Clash/Mihomo YAML, proxy-provider content, plain URI lists, or base64 URI lists. Burrow's current Trojan parser only covers a narrow `trojan://` URI-list subset.
|
||||
- Mihomo's TUN mode is the closest reference behavior: it captures packets, runs a userspace IP stack, hijacks DNS, resolves rules and proxy selections, then dials protocol-specific outbounds.
|
||||
- Burrow should avoid a second user-facing "proxy mode" based on `HTTP_PROXY`, SOCKS settings, or a loopback listener because that is not equivalent to VPN behavior and creates platform-specific state outside Burrow's tunnel contract.
|
||||
- Subscription URLs and proxy node credentials are secret-bearing. Fetching, parsing, storing, refreshing, and redacting them belong in the daemon boundary described by `BEP-0005`.
|
||||
|
||||
## Detailed Design
|
||||
|
||||
### User Model
|
||||
|
||||
- Add a UI action named `Import Proxy Subscription`.
|
||||
- Imported subscriptions are stored as `Network` records because they represent routeable connectivity sources.
|
||||
- Proxy nodes inside a subscription are selectable endpoints within that network.
|
||||
- Node selection should mirror Mihomo selector behavior in `adapter/outboundgroup/selector.go`: persist an explicit selected node when the user chooses one, validate that it exists, and fall back to the first runtime-supported node only when no explicit supported selection is available.
|
||||
- Proxy subscriptions do not create `Account` records by default. Accounts remain for identity and sign-in state such as Tailnet login or future provider identity flows.
|
||||
- Apple SwiftUI, Linux GTK, and future UI surfaces should expose the same import, preview, warning, selection, refresh, and node status behavior over the daemon API.
|
||||
- UI clients may show subscription name, refresh state, selected node, node health, warnings, and import preview results. They must not fetch subscription URLs or open Trojan/Shadowsocks sockets directly.
|
||||
- Import preview UIs must expose the runtime-supported Trojan and Shadowsocks nodes as a selectable list and pass the selected ordinal to the daemon `ApplyImport` request. Parsed-but-unsupported nodes should remain visible as warnings or counts, not selectable runtime choices.
|
||||
|
||||
### Removed SOCKS Mode
|
||||
|
||||
- Remove the daemon runtime path that starts a stored Trojan profile as a local SOCKS5 proxy.
|
||||
- Remove CLI and smoke entrypoints whose primary purpose is operating Burrow as a SOCKS proxy.
|
||||
- Keep parser tests and sanitized live-comparison tooling only when they validate subscription import or packet-tunnel behavior.
|
||||
- Do not expose system `HTTP_PROXY`, system SOCKS, PAC, or "set system proxy" behavior as part of this proposal.
|
||||
- If a temporary developer-only loopback proxy is needed for debugging, it must be clearly separate from product behavior and must not be the stored network runtime.
|
||||
|
||||
### Subscription Import Compatibility
|
||||
|
||||
Implement the importer in the daemon and mirror Mihomo's behavior as closely as Burrow's narrower protocol scope allows. Path references in this section are relative to the Mihomo repository root. When behavior is ambiguous, contributors should inspect the referenced Mihomo code path and either match it or document a deliberate deviation in this BEP before implementation.
|
||||
|
||||
- Full config parsing should recognize subscription-relevant top-level fields analogous to `proxies`, `proxy-providers`, and `proxy-groups` in `config/config.go`.
|
||||
- Provider parsing should support `file`, `http`, and `inline` style sources analogous to `adapter/provider/parser.go`.
|
||||
- Provider content parsing should first try YAML with a `proxies` field. If YAML cannot be parsed, fall back to V2Ray-style URI conversion, matching the shape in `adapter/provider/provider.go`.
|
||||
- Plain and base64 URI-list inputs should follow Mihomo's behavior in `common/convert/converter.go` and `common/convert/base64.go`: attempt raw/std base64 decoding, otherwise treat the body as plaintext.
|
||||
- Trojan URI conversion should closely follow Mihomo's `trojan` case in `common/convert/converter.go` and the accepted outbound fields in `adapter/outbound/trojan.go`. Burrow should preserve Mihomo-compatible names and semantics internally where practical, then adapt them into Burrow's storage model. It should map at least:
|
||||
- URI fragment to display name
|
||||
- host, port, and username to server, port, and password
|
||||
- `allowInsecure` to certificate verification policy
|
||||
- `sni`, `alpn`, `type=ws`, `type=grpc`, client fingerprint, and pinned certificate fingerprint where supported
|
||||
- Mihomo's default client fingerprint behavior where applicable
|
||||
- Mihomo's ws and grpc option shapes before translating them into Burrow-owned structs
|
||||
- Shadowsocks URI conversion should map at least:
|
||||
- legacy base64 host form
|
||||
- base64 `cipher:password` userinfo
|
||||
- server, port, cipher, password, and node name
|
||||
- `udp-over-tcp` or `uot`
|
||||
- common `obfs` and `v2ray-plugin` plugin options when support is implemented
|
||||
- Unsupported protocols, unsupported plugin modes, malformed nodes, duplicate node names, and insecure TLS flags must be returned in import preview warnings without logging credentials.
|
||||
|
||||
Burrow should not claim full Clash or full Mihomo compatibility in this proposal. The compatibility target is subscription import for Trojan and Shadowsocks nodes plus the minimum provider and group metadata needed to select and refresh outbounds. Within that target, subscription parsing and Trojan normalization should be intentionally Mihomo-like so a subscription that imports cleanly in Mihomo has the same node set, labels, security warnings, and supported Trojan transport metadata in Burrow unless the BEP records a specific exception.
|
||||
|
||||
### Data Model
|
||||
|
||||
Add a proxy-subscription network payload with:
|
||||
|
||||
- subscription display name
|
||||
- redacted subscription URL or a credential reference to the full URL
|
||||
- provider format detected during import
|
||||
- refresh policy and last refresh result
|
||||
- ordered proxy nodes
|
||||
- selected node or selection strategy
|
||||
- certificate and transport warnings
|
||||
- per-node protocol type, server metadata, transport metadata, and secret references
|
||||
|
||||
Proxy node secrets must move out of plain network payload blobs when Burrow's secret-storage path exists. Until then, payload storage must be treated as sensitive and logs/tests must use redacted fixtures.
|
||||
|
||||
### Daemon APIs
|
||||
|
||||
Add daemon gRPC APIs before platform UI work:
|
||||
|
||||
- preview import from subscription URL
|
||||
- apply selected preview result as a network
|
||||
- refresh an existing subscription network
|
||||
- list nodes for a proxy subscription network
|
||||
- set selected node or selection strategy
|
||||
|
||||
Preview responses should include compatible count, rejected count, detected format, sanitized warnings, suggested network name, and redacted node labels. Apply and refresh must preserve local node order and selected-node state where possible.
|
||||
|
||||
### Packet Tunnel Runtime
|
||||
|
||||
Burrow should route proxy subscriptions through the existing tunnel architecture:
|
||||
|
||||
1. A platform tunnel provider starts the tunnel and applies daemon-provided network settings.
|
||||
2. Platform packet capture forwards packets through the daemon packet interface. Apple uses `PacketTunnelProvider` and the existing `TunnelPackets` gRPC stream against the packet-tunnel daemon socket owned by the Network Extension; Linux should use the daemon-owned TUN path already aligned with the GTK client.
|
||||
3. The daemon owns a proxy packet engine for active proxy-subscription networks.
|
||||
4. The packet engine reconstructs TCP and UDP flows from IP packets.
|
||||
5. DNS packets are handled by a daemon-owned DNS path so hostname metadata can be preserved for rule selection and proxy dialing.
|
||||
6. Each flow is matched to the selected proxy node or selection strategy.
|
||||
7. The daemon opens a Trojan or Shadowsocks outbound connection and relays flow bytes.
|
||||
8. Return packets are serialized back to Apple through `TunnelPackets`.
|
||||
|
||||
Mihomo's equivalent kernel path is:
|
||||
|
||||
- TUN recreation and listener lifecycle in `listener/listener.go`
|
||||
- TUN option construction and stack startup in `listener/sing_tun/server.go`
|
||||
- DNS hijack in `listener/sing_tun/dns.go`
|
||||
- metadata conversion in `listener/sing/sing.go`
|
||||
- TCP and UDP handling in `tunnel/tunnel.go`
|
||||
- outbound protocol construction in `adapter/parser.go`, `adapter/outbound/trojan.go`, and `adapter/outbound/shadowsocks.go`
|
||||
|
||||
Burrow should use these as architecture references, not as code to vendor blindly. The Burrow implementation must fit the daemon IPC boundary, Rust runtime, Apple NetworkExtension path, Linux daemon/TUN path, GTK client, and existing network selection model.
|
||||
|
||||
### Implemented Runtime Shape
|
||||
|
||||
The first packet-tunnel implementation follows the Mihomo adapter shape but keeps Burrow's narrower protocol surface:
|
||||
|
||||
- `burrow/src/proxy_runtime.rs` owns the daemon packet engine.
|
||||
- Apple uses the existing `TunnelPackets` stream, matching the Tailnet packet-stream boundary.
|
||||
- Linux uses the daemon TUN bridge path.
|
||||
- Tunnel configuration excludes resolved selected proxy server host routes from the captured default route so proxy-server dials cannot be trapped by the full tunnel.
|
||||
- On Apple, the proxy packet runtime binds Trojan and Shadowsocks outbound sockets to a non-tunnel physical interface before connect and also installs resolved Network Extension excluded routes for the selected proxy server. Loopback proxy-server addresses are not physical-interface bound so controlled local tests and developer-only local endpoints can still connect. The excluded route is intentionally redundant: it prevents proxy-server dials and server DNS artifacts from recursively entering the full tunnel when socket binding is unavailable, delayed, or ignored by a platform path.
|
||||
- On Apple, node selection and refresh are applied to both the app-facing control daemon and, while connected, the packet-tunnel daemon over `burrow-packet-tunnel.sock`. The packet daemon swaps the active proxy outbound inside the existing packet bridge, so new TCP and UDP flows use the selected node without restarting the Network Extension. Existing flows may continue on the outbound they opened with until they close.
|
||||
- TCP and UDP packets are reconstructed with the existing smoltcp userspace stack.
|
||||
- Trojan TCP nodes are dialed over TLS and use Trojan TCP and UDP request framing directly.
|
||||
- Shadowsocks nodes are dialed directly for TCP and UDP through Burrow's Shadowsocks runtime adapter. `BEP-0012` refines the Mihomo-compatible Shadowsocks runtime path, including expanded cipher families, UDP gating, and staged plugin/UDP-over-TCP support.
|
||||
- Unsupported runtime transports such as Trojan `ws`/`grpc`, Shadowsocks plugins, and Shadowsocks UDP-over-TCP are still parsed and preserved from subscriptions, but they are rejected by runtime selection until those adapter transports are implemented. If no node is explicitly selected, Burrow chooses the first runtime-supported node instead of starting an unsupported transport.
|
||||
- Apple SwiftUI and Linux GTK import flows preview nodes, restrict the picker to runtime-supported nodes, and pass the chosen node ordinal to the daemon when applying an import.
|
||||
- Proxy-subscription tunnel settings point DNS at the daemon-owned tunnel address. The packet runtime answers ordinary A queries with fake IPs and records the fake-IP to hostname mapping so later TCP flows can send hostname-form proxy requests. This avoids making website DNS depend on outbound UDP support while preserving hostname metadata for Trojan and Shadowsocks dialing.
|
||||
|
||||
### Routing, DNS, and Apple Runtime Ownership
|
||||
|
||||
For full-tunnel proxy networks, daemon tunnel configuration should include:
|
||||
|
||||
- a virtual IPv4 address from a documentation or carrier-grade NAT range suitable for the packet engine
|
||||
- a virtual IPv6 address once the packet engine is enabled for IPv6 flows
|
||||
- MTU selected for userspace packet handling
|
||||
- default IPv4 route when full-tunnel is enabled
|
||||
- default IPv6 route when the platform tunnel surface supports it
|
||||
- a daemon-owned DNS server address reachable inside the tunnel
|
||||
|
||||
DNS handling should be explicit. Proxy-subscription tunnel configuration should use the daemon-owned DNS address inside the tunnel, not ambient physical resolvers and not a public upstream that requires outbound UDP before TCP can start. The daemon DNS path may answer with fake IPs and must keep those mappings scoped to the active proxy runtime so hostname-preserving proxy dialing does not leak between networks.
|
||||
|
||||
On Apple, the app-facing daemon and packet-tunnel daemon are intentionally separate in-process daemon instances over different app-group Unix sockets. The app-facing daemon handles subscription preview, import, refresh, node selection, network list, and account/login control through `burrow.sock`. The Network Extension hosts the packet-tunnel daemon over `burrow-packet-tunnel.sock` while the VPN session is active; `TunnelStart`, `TunnelConfiguration`, `TunnelPackets`, and `TunnelStop` for the active session use that socket.
|
||||
|
||||
This split keeps macOS packet runtime ownership aligned with `NEPacketTunnelProvider`: routes, DNS settings, packet stream lifetime, and proxy runtime are created from the same selected network state when the extension starts. UI mutations persist desired state in the shared app-group database. If a mutation changes active packet-tunnel behavior, the Apple client should update the packet daemon's desired state and only restart or reassert the Network Extension when the platform tunnel settings themselves change.
|
||||
|
||||
Proxy-subscription routing must avoid recursively routing the proxy outbound through the full tunnel. Burrow uses daemon-side outbound socket binding, analogous to Mihomo's default-interface outbound behavior, and installs resolved proxy-server excluded routes as a second guardrail. Selector changes remain daemon-local when they do not require platform route changes; if the selected node's resolved proxy-server routes change, the Network Extension should be restarted or have its settings reapplied so the excluded routes match the active outbound.
|
||||
|
||||
Selector changes should be daemon-local whenever socket binding is available. The Network Extension packet stream and route/DNS settings should stay up; the packet daemon updates its proxy outbound handle, and future flows read the new selection from that shared handle. This mirrors Mihomo's selector model more closely than restarting TUN for every node change.
|
||||
|
||||
Daemon-owned DNS/fake-IP metadata is part of the proxy packet runtime. The proxy-server route exclusion protects the selected outbound itself; ordinary website DNS is answered by the daemon and converted into hostname metadata for future flows. Mihomo-style rule selection can build on the same mapping without depending on ambient system DNS after the tunnel is active.
|
||||
|
||||
### Rollout Stages
|
||||
|
||||
1. Proposal and model cleanup.
|
||||
- Supersede `BEP-0009`.
|
||||
- Remove SOCKS runtime from the target design.
|
||||
2. Parser and preview.
|
||||
- Implement daemon-owned import preview for Mihomo-compatible YAML and URI-list inputs.
|
||||
- Add fixtures for Trojan and Shadowsocks.
|
||||
3. Storage and refresh.
|
||||
- Store proxy subscriptions as networks.
|
||||
- Preserve selected node and local ordering across refreshes.
|
||||
4. Packet engine foundation.
|
||||
- Add daemon packet-stream support for proxy networks.
|
||||
- Reconstruct TCP flows first with controlled DNS behavior.
|
||||
5. Protocol outbounds.
|
||||
- Add Trojan outbound.
|
||||
- Add Shadowsocks outbound.
|
||||
- Add UDP once NAT, timeout, and protocol support are explicit.
|
||||
6. Platform UI.
|
||||
- Add `Import Proxy Subscription` to Apple SwiftUI and Linux GTK add flows.
|
||||
- Add preview, warning, selection, refresh, and node status views to Apple SwiftUI, Linux GTK, and any future first-party UI surface.
|
||||
7. Compatibility and operations.
|
||||
- Add Mihomo comparison fixtures and sanitized live-check scripts.
|
||||
- Document remaining incompatibilities.
|
||||
|
||||
## Security and Operational Considerations
|
||||
|
||||
- Subscription URLs frequently contain bearer tokens. Full URLs must not appear in logs, screenshots, fixtures, crash reports, or generated docs.
|
||||
- Proxy node credentials must be redacted in all errors and diagnostics.
|
||||
- Insecure TLS settings imported from subscriptions must be visible in preview and stored as explicit risk metadata.
|
||||
- Refresh must have timeout, redirect, response-size, and content-type limits.
|
||||
- Parser failures must be non-fatal per node; malformed input should never panic the daemon.
|
||||
- Packet-tunnel activation must have a kill-switch path: when the tunnel stops or a proxy network is disabled, packet handling, DNS state, NAT state, and outbound sockets must be torn down.
|
||||
- If DNS mapping is fake-IP based, stale mappings must expire and must not leak between networks in ways that route traffic through the wrong node.
|
||||
- Full-tunnel mode must avoid recursive routing of the proxy server connection through the tunnel. On Apple, preferred bypass behavior is outbound socket binding to a non-tunnel interface. Route exclusions are fallback behavior for platforms or environments where socket binding is not available.
|
||||
- Removing SOCKS mode simplifies the threat model by leaving one supported runtime behavior for proxy networks.
|
||||
|
||||
## Contributor Playbook
|
||||
|
||||
1. Keep all UI subscription operations behind daemon gRPC.
|
||||
2. Update or remove current Trojan SOCKS runtime code instead of layering the packet-tunnel runtime beside it.
|
||||
3. Add parser fixtures for:
|
||||
- Clash/Mihomo YAML with `proxies`
|
||||
- proxy-provider content
|
||||
- base64 URI list
|
||||
- plaintext URI list
|
||||
- Trojan TCP, ws, grpc metadata
|
||||
- Shadowsocks legacy and SIP002-style links
|
||||
- unsupported protocols and malformed nodes
|
||||
- Mihomo parity cases copied from the behavior of `common/convert/converter.go`, especially Trojan `allowInsecure`, `sni`, `alpn`, `type=ws`, `type=grpc`, `fp`, and `pcs`
|
||||
4. Add packet-engine tests with synthetic TCP and DNS packets before full platform UI integration.
|
||||
5. Add a migration or cleanup path for any existing stored `NetworkType::Trojan` payloads created by the SOCKS-era implementation.
|
||||
6. Validate metadata and affected builds:
|
||||
|
||||
```bash
|
||||
uv run python Scripts/check-bep-metadata.py
|
||||
cargo test -p burrow proxy_subscription
|
||||
cargo check -p burrow
|
||||
```
|
||||
|
||||
7. For platform UI work, regenerate bindings after proto changes and verify Apple SwiftUI and Linux GTK still talk only to daemon gRPC for subscription operations.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Keep local SOCKS mode. Rejected because it is not Burrow's VPN behavior, adds another support surface, and makes imported proxy subscriptions feel unlike WireGuard and Tailnet.
|
||||
- Use system `HTTP_PROXY` or SOCKS settings. Rejected because only proxy-aware apps participate, DNS behavior is inconsistent, and cleanup/rollback becomes OS-setting specific.
|
||||
- Shell out to Mihomo. Rejected as the default implementation because Burrow would inherit a large external runtime and a separate control plane. Mihomo remains the behavioral reference for import and TUN semantics.
|
||||
- Claim full Mihomo compatibility immediately. Rejected because Burrow should first support Trojan and Shadowsocks imports plus packet-tunnel routing correctly.
|
||||
- Treat every proxy node as a separate account. Rejected because nodes are endpoints under one imported network source, not identities.
|
||||
|
||||
## Impact on Other Work
|
||||
|
||||
- Supersedes `BEP-0009`.
|
||||
- Depends on the daemon IPC boundary in `BEP-0005`.
|
||||
- Aligns with the route and policy direction in `BEP-0003`.
|
||||
- May require a follow-up secret-storage BEP if existing network payload storage remains insufficient for long-lived proxy credentials.
|
||||
- Creates shared import and packet-engine foundations for future proxy protocols, but this proposal only covers Trojan and Shadowsocks.
|
||||
|
||||
## Decision
|
||||
|
||||
Pending.
|
||||
|
||||
## References
|
||||
|
||||
- Burrow daemon IPC boundary: `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md`
|
||||
- Burrow route and negotiation roadmap: `evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md`
|
||||
- Superseded Trojan SOCKS proposal: `evolution/proposals/BEP-0009-trojan-proxy-and-subscription-import.md`
|
||||
- Burrow tunnel schema: `proto/burrow.proto`
|
||||
- Burrow Apple tunnel provider: `Apple/NetworkExtension/PacketTunnelProvider.swift`
|
||||
- Burrow Apple UI: `Apple/UI/`
|
||||
- Burrow GTK UI: `burrow-gtk/src/`
|
||||
- Burrow current Trojan parser/runtime: `burrow/src/trojan.rs`
|
||||
- Burrow daemon runtime: `burrow/src/daemon/runtime.rs`
|
||||
- Mihomo config model: `config/config.go`
|
||||
- Mihomo proxy provider parsing: `adapter/provider/parser.go`
|
||||
- Mihomo provider content parsing: `adapter/provider/provider.go`
|
||||
- Mihomo URI conversion: `common/convert/converter.go`
|
||||
- Mihomo base64 fallback: `common/convert/base64.go`
|
||||
- Mihomo TUN listener lifecycle: `listener/listener.go`
|
||||
- Mihomo TUN stack setup: `listener/sing_tun/server.go`
|
||||
- Mihomo DNS hijack: `listener/sing_tun/dns.go`
|
||||
- Mihomo listener metadata bridge: `listener/sing/sing.go`
|
||||
- Mihomo TCP/UDP tunnel handling: `tunnel/tunnel.go`
|
||||
- Mihomo outbound parser: `adapter/parser.go`
|
||||
- Mihomo selector group: `adapter/outboundgroup/selector.go`
|
||||
- Mihomo Trojan outbound: `adapter/outbound/trojan.go`
|
||||
- Mihomo Shadowsocks outbound: `adapter/outbound/shadowsocks.go`
|
||||
110
evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md
Normal file
110
evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# `BEP-0011` - Mihomo-Compatible Trojan Runtime
|
||||
|
||||
```text
|
||||
Status: Draft
|
||||
Proposal: BEP-0011
|
||||
Authors: Codex
|
||||
Coordinator: Jett Chen
|
||||
Reviewers: Pending
|
||||
Constitution Sections: 2, 4, 5
|
||||
Implementation PRs: Pending
|
||||
Decision Date: Pending
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
Burrow's proxy subscription runtime should match Mihomo's Trojan wire behavior
|
||||
for imported nodes wherever Burrow already claims runtime support. This proposal
|
||||
covers TCP Trojan nodes in the packet tunnel runtime: ALPN defaults, DNS-backed
|
||||
hostname preservation, UDP gating, UDP packet fragmentation, certificate
|
||||
pinning, and diagnostics for Mihomo-only TLS fingerprint behavior.
|
||||
|
||||
The goal is compatibility without overstating support. Burrow should apply
|
||||
Mihomo semantics that can be implemented correctly today, and it should make
|
||||
unsupported Mihomo features visible instead of silently pretending to support
|
||||
them.
|
||||
|
||||
## Motivation
|
||||
|
||||
- Imported Trojan subscriptions commonly target Mihomo-compatible clients.
|
||||
- A stored node can carry `client-fingerprint`, ALPN, UDP, and certificate
|
||||
pinning metadata that materially changes whether the proxy server accepts the
|
||||
connection.
|
||||
- Burrow previously flattened absent ALPN and explicit empty ALPN into the same
|
||||
value, ignored Trojan UDP enablement, rejected oversized Trojan UDP payloads
|
||||
instead of fragmenting them, and stored certificate pins without enforcing
|
||||
them.
|
||||
|
||||
## Detailed Design
|
||||
|
||||
- Store an `alpn_present` marker alongside the existing Trojan `alpn` vector.
|
||||
Missing ALPN uses Mihomo's TCP Trojan default `h2,http/1.1`; explicit ALPN,
|
||||
including an explicit empty YAML list, is honored as written.
|
||||
- Store Trojan `udp`. URI imports default to enabled to match Mihomo's URI
|
||||
converter. YAML imports default to disabled unless `udp` is present.
|
||||
- Point proxy-network DNS at the daemon-owned tunnel address. The packet runtime
|
||||
answers A queries with fake IPs, records fake-IP to hostname mappings, and
|
||||
uses hostname-form SOCKS addresses in Trojan TCP requests when a later TCP
|
||||
flow targets one of those fake IPs.
|
||||
- Enforce Trojan UDP gating at runtime and fragment UDP payloads above 8192
|
||||
bytes into multiple Trojan UDP frames.
|
||||
- Treat `fingerprint` as SHA-256 certificate pinning. Browser fingerprint names
|
||||
are rejected in `fingerprint` with guidance to use `client-fingerprint`.
|
||||
- Keep using rustls for non-Apple TLS and OpenSSL for the macOS Trojan TLS path.
|
||||
`client-fingerprint` is logged when present because Mihomo's uTLS ClientHello
|
||||
impersonation is not implemented in Burrow yet.
|
||||
- Coalesce the Trojan request header into one TLS write, matching Mihomo's
|
||||
`trojan.WriteHeader` behavior.
|
||||
- Continue rejecting Trojan `ws` and `grpc` at runtime until those transports
|
||||
are implemented with their complete TLS and framing behavior.
|
||||
|
||||
## Security and Operational Considerations
|
||||
|
||||
- Certificate pinning changes TLS verification semantics. When a pin is
|
||||
configured, Burrow verifies the presented chain against the pin instead of the
|
||||
WebPKI root store, matching Mihomo's pinning model.
|
||||
- The runtime must not log proxy passwords or subscription tokens.
|
||||
- uTLS/browser fingerprinting should not be faked with partial ClientHello
|
||||
tweaks. Until Burrow has a complete implementation, the runtime should say
|
||||
that it is using platform TLS rather than a Mihomo uTLS impersonation.
|
||||
- Rollback is a code rollback plus re-importing any subscriptions whose stored
|
||||
JSON schema has changed during testing.
|
||||
|
||||
## Contributor Playbook
|
||||
|
||||
1. Compare Burrow behavior against `/Users/jettchen/dev/vpn-ref/mihomo` before
|
||||
changing protocol details.
|
||||
2. Run `cargo fmt -p burrow`.
|
||||
3. Run `cargo test -p burrow proxy_subscription::tests::`.
|
||||
4. Run `cargo test -p burrow proxy_runtime::tests::`.
|
||||
5. Run `cargo check -p burrow`.
|
||||
6. For live validation, use a Trojan node with omitted ALPN and
|
||||
`client-fingerprint: chrome`; confirm the logs show Mihomo default ALPN, the
|
||||
physical-interface bind, the macOS OpenSSL handshake, and the platform TLS
|
||||
fingerprint warning.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Implement full uTLS immediately. Rejected for this change because rustls does
|
||||
not expose enough ClientHello control, and adding a second TLS stack needs
|
||||
separate review for Apple builds and static library consumers.
|
||||
- Treat all empty ALPN vectors as explicit empty ALPN. Rejected because older
|
||||
Burrow imports already stored omitted URI ALPN as `[]`, which would preserve
|
||||
the compatibility bug.
|
||||
- Ignore `fingerprint` until uTLS exists. Rejected because certificate pinning
|
||||
is independent of browser ClientHello impersonation.
|
||||
|
||||
## Impact on Other Work
|
||||
|
||||
- BEP-0010 remains the packet tunnel direction for proxy subscriptions.
|
||||
- A future BEP or revision should cover uTLS/browser fingerprint support,
|
||||
websocket Trojan, gRPC Trojan, and daemon-owned proxy-server DNS resolution.
|
||||
|
||||
## Decision
|
||||
|
||||
Pending review.
|
||||
|
||||
## References
|
||||
|
||||
- `docs/trojan-mihomo-discrepancies.md`
|
||||
- `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md`
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
# `BEP-0012` - Mihomo-Compatible Shadowsocks Runtime
|
||||
|
||||
```text
|
||||
Status: Draft
|
||||
Proposal: BEP-0012
|
||||
Authors: Codex
|
||||
Coordinator: Jett Chen
|
||||
Reviewers: Pending
|
||||
Constitution Sections: 2, 4, 5
|
||||
Implementation PRs: Pending
|
||||
Decision Date: Pending
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
Burrow's proxy subscription runtime should move from a narrow hand-written
|
||||
Shadowsocks AEAD implementation toward Mihomo-compatible Shadowsocks behavior
|
||||
for imported nodes. Mihomo remains the reference for accepted ciphers, URI
|
||||
conversion, plugins, UDP-over-TCP, and packet tunnel outbound semantics, while
|
||||
Burrow keeps the daemon-owned packet runtime and Apple gRPC boundary described
|
||||
by `BEP-0005` and `BEP-0010`.
|
||||
|
||||
This proposal covers the compatibility path for Shadowsocks outbounds. It starts
|
||||
by delegating protocol crypto and stream/datagram framing to a maintained Rust
|
||||
Shadowsocks implementation, adds Burrow-owned adapters for Mihomo cipher gaps
|
||||
where Rust crates are available, and implements Mihomo-compatible UDP-over-TCP.
|
||||
Plugin transports remain staged behind explicit lifecycle and security controls.
|
||||
|
||||
## Motivation
|
||||
|
||||
- Subscriptions that work in Mihomo commonly use more than `aes-128-gcm`,
|
||||
`aes-256-gcm`, or `chacha20-ietf-poly1305`.
|
||||
- Mihomo supports legacy stream ciphers, AEAD extra ciphers, AEAD 2022 methods,
|
||||
SIP003-style plugins, ShadowTLS/restls/kcptun transports, and UDP-over-TCP.
|
||||
- Burrow's previous hand-written Shadowsocks runtime made unsupported ciphers
|
||||
appear like permanent product choices rather than implementation gaps.
|
||||
- Protocol crypto and framing are security-sensitive. Burrow should use a
|
||||
maintained Rust crate where it can do so without shelling out to Mihomo or
|
||||
weakening the daemon packet-runtime boundary.
|
||||
|
||||
## Detailed Design
|
||||
|
||||
- Use the Rust `shadowsocks` crate as Burrow's default Shadowsocks protocol
|
||||
adapter for supported cipher families, TCP stream framing, and UDP datagram
|
||||
framing.
|
||||
- Add Burrow-owned AEAD adapters backed by Rust crypto crates for Mihomo methods
|
||||
missing from the default adapter when their wire behavior is straightforward.
|
||||
The first such methods are `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-*-gcm`, `2022-blake3-aes-128-ccm`, and
|
||||
`2022-blake3-aes-256-ccm`.
|
||||
- Add Burrow-owned legacy stream adapters for Mihomo's `chacha20` and
|
||||
`xchacha20` methods. They use the same Shadowsocks v1 IV-plus-stream shape as
|
||||
Mihomo's `sing-shadowsocks2` implementation, backed by Rust `chacha20`
|
||||
primitives.
|
||||
- Keep Burrow-owned socket creation, route exclusion, and Apple physical
|
||||
interface binding behavior. The Shadowsocks adapter wraps already-connected
|
||||
Burrow sockets where needed instead of taking over tunnel ownership.
|
||||
- Runtime-supported ciphers include the compiled Rust crate feature set:
|
||||
legacy stream methods, AEAD methods, AEAD extra methods, and AEAD 2022
|
||||
methods, plus an explicit Mihomo-compatible `none` transport, custom
|
||||
ChaCha20/XChaCha20 stream, AES-192 GCM/CCM, ChaCha8-Poly1305,
|
||||
Rabbit128-Poly1305, AEGIS, AEZ-384, Deoxys-II-256-128, Ascon128, Ascon128a,
|
||||
LEA-GCM, and AEAD 2022 AES-CCM adapters. `none` stays Burrow-owned because
|
||||
the upstream Rust crate accepts the method name during validation but cannot
|
||||
build a client runtime for it.
|
||||
AEAD 2022 password handling follows Mihomo's base64 key parsing rule:
|
||||
every decoded password segment must be exactly the method key length, with
|
||||
no hashing or truncation fallback for oversized material. Burrow pre-validates
|
||||
the delegated GCM/ChaCha 2022 methods so the upstream Rust adapter cannot
|
||||
accept unpadded key material that Mihomo's `base64.StdEncoding` would reject.
|
||||
Burrow keeps legacy aliases such as `aead_aes_128_gcm` and
|
||||
`chacha20-poly1305` for previously imported payloads.
|
||||
Burrow also preserves Mihomo's top-level Shadowsocks `smux` option during
|
||||
import. The packet runtime supports TCP and UDP `protocol: h2mux`, `protocol:
|
||||
smux`, and `protocol: yamux` paths for delegated standard ciphers, `none`, Burrow's custom ciphers,
|
||||
and AEAD 2022 CCM by dialing Mihomo's `sp.mux.sing-box.arpa:444` control
|
||||
destination through Shadowsocks, writing the sing-mux protocol preface, and
|
||||
opening mux streams with Mihomo's per-stream SOCKS destination request. Burrow
|
||||
also supports Mihomo's top-level sing-mux padding shape: the version 1 session
|
||||
preface carries a padding flag and 256-767 bytes of skipped padding, then the
|
||||
first 16 mux reads and writes are length-prefixed padded frames before the
|
||||
stream falls back to raw mux bytes. UDP-over-sing-mux uses Mihomo's packet
|
||||
stream shape: UDP stream flags, one SOCKS destination, one status byte, and
|
||||
length-prefixed datagrams. Brutal mode performs Mihomo's `_BrutalBwExchange`
|
||||
stream handshake and negotiates the same send-rate cap; OS-level TCP Brutal
|
||||
congestion control remains a runtime best-effort because it is
|
||||
Linux-kernel-module dependent in Mihomo and unavailable through every Burrow
|
||||
plugin wrapper.
|
||||
The remaining Mihomo compatibility gaps for Shadowsocks are advanced plugin
|
||||
transports and live interop coverage for additional external permutations,
|
||||
not base Shadowsocks cipher, UDP-over-TCP framing, or top-level sing-mux
|
||||
negotiation.
|
||||
- Honor the Shadowsocks `udp` flag at runtime. Clash/Mihomo YAML imports default
|
||||
omitted `udp` to false like Mihomo's `ShadowSocksOption` zero value, while
|
||||
`ss://` URI conversion keeps Mihomo's converter behavior of setting `udp` to
|
||||
true. UDP-disabled nodes must reject UDP sessions instead of silently
|
||||
attempting native UDP relay.
|
||||
- Honor `udp-over-tcp` for Shadowsocks nodes by opening a Shadowsocks TCP stream
|
||||
to Mihomo's UOT magic destination. Burrow supports both Mihomo's legacy
|
||||
version 1 destination, `sp.udp-over-tcp.arpa`, and version 2 destination,
|
||||
`sp.v2.udp-over-tcp.arpa`; absent or zero versions normalize to legacy v1 to
|
||||
match Mihomo config defaults. Runtime UDP sessions are allowed when
|
||||
`udp-over-tcp` is active even if native UDP is disabled, because packets are
|
||||
carried over the selected TCP plugin path instead of a native Shadowsocks UDP
|
||||
relay.
|
||||
- Support Mihomo's `obfs` plugin for HTTP and TLS simple-obfs by wrapping the
|
||||
already-connected TCP socket before the Shadowsocks stream handshake. Native
|
||||
UDP remains direct Shadowsocks UDP, while `udp-over-tcp` uses the same wrapped
|
||||
TCP path. The plugin mode must be explicit, matching Mihomo's `obfs` option
|
||||
decoder: simple-obfs accepts only `http` and `tls`. Burrow normalizes SIP002
|
||||
URI plugin names that contain `obfs` into Mihomo's YAML/runtime plugin name
|
||||
`obfs` during import only when the plugin string has semicolon-delimited
|
||||
SIP002 options; bare `ss://` plugin names and non-Mihomo-converted URI plugin
|
||||
names are ignored like Mihomo's converter. Daemon runtime and Apple usability
|
||||
checks intentionally require Mihomo's exact, case-sensitive plugin names. YAML
|
||||
`plugin: obfs-local`, `plugin: Obfs`, `plugin: shadowtls`, and other
|
||||
unrecognized plugin names therefore fall through to plain Shadowsocks like
|
||||
Mihomo's outbound constructor instead of being treated as supported plugin
|
||||
transports.
|
||||
- Support Mihomo's `v2ray-plugin` websocket mode, including the lightweight
|
||||
v2ray-plugin mux preface enabled by Mihomo's default `mux: true`, optional
|
||||
TLS, custom path, host, and `v2ray-http-upgrade` raw-upgrade mode. Burrow also supports Mihomo's
|
||||
`v2ray-http-upgrade-fast-open` behavior by returning the upgraded raw stream
|
||||
after sending the HTTP request and validating the 101 response on first read.
|
||||
Imported `ss://` v2ray-plugin strings preserve Mihomo's SIP002 aliases such
|
||||
as `obfs=websocket`, `obfs-host`, and bare `tls` flags, while also
|
||||
materializing Mihomo's converted `mode`, `host`, and boolean `tls` option
|
||||
shape.
|
||||
For websocket paths with Mihomo's `ed=` query option, Burrow removes `ed`
|
||||
from the request path and moves the first payload bytes into the
|
||||
`Sec-WebSocket-Protocol` header with unpadded URL-safe base64 encoding.
|
||||
When TLS is enabled, Burrow accepts Mihomo's `certificate` and `private-key`
|
||||
plugin options as a PEM client
|
||||
certificate/key pair for mTLS, and enforces Mihomo's `fingerprint`
|
||||
certificate pin option on both Rustls and Apple native TLS paths. Burrow also
|
||||
supports Mihomo's `ech-opts` for websocket TLS by passing inline ECH config
|
||||
lists to Rustls, or by resolving HTTPS records through the physical DNS
|
||||
resolver when `ech-opts.enable` is set without an inline config.
|
||||
- Support Mihomo's `gost-plugin` websocket mode, including the Go-smux client
|
||||
stream enabled by Mihomo's default `mux: true` and `mux=false` raw websocket
|
||||
mode. `v2ray-plugin` and `gost-plugin` require explicit `mode: websocket` or
|
||||
the imported `obfs=websocket` alias, because Mihomo rejects those plugin nodes
|
||||
when the mode field is omitted. TLS-enabled gost websocket nodes use the same
|
||||
Mihomo-compatible client certificate/key and certificate pin handling,
|
||||
including websocket ECH support.
|
||||
- Support Mihomo's `shadow-tls` plugin for versions 1, 2, and 3 by performing the
|
||||
cover TLS handshake in-process with Rustls, then exposing the post-handshake
|
||||
stream to the Shadowsocks adapter. Version 2 wraps application data in
|
||||
ShadowTLS application-data records and prepends the first client payload with
|
||||
the handshake HMAC, matching Mihomo's `sing-shadowtls` client behavior.
|
||||
Version 3 uses a ShadowTLS-capable Rustls fork for the ClientHello session ID
|
||||
HMAC, then verifies and emits the four-byte HMAC application-data frames used
|
||||
by Mihomo's `sing-shadowtls` v3 client. Version 1 follows Mihomo's option
|
||||
shape and does not require a plugin password.
|
||||
Burrow also parses and enforces Mihomo's ShadowTLS `fingerprint` certificate
|
||||
pin option and supports Mihomo's `certificate` and `private-key` mTLS client
|
||||
certificate options. `client-fingerprint` is preserved from subscriptions.
|
||||
ShadowTLS v1 ignores `client-fingerprint` like Mihomo because it always uses a
|
||||
normal TLS 1.2 handshake. For ShadowTLS v2/v3, unknown fingerprint names fall
|
||||
back to the normal TLS ClientHello, while names that Mihomo maps to uTLS
|
||||
profiles are rejected by the default runtime and Apple usability filter until
|
||||
Burrow has uTLS-style ClientHello impersonation. Burrow carries an optional
|
||||
`boring-browser-fingerprints` feature that wires ShadowTLS v2 active browser
|
||||
fingerprints through Rama's BoringSSL-backed embedded browser TLS profiles.
|
||||
ShadowTLS ALPN defaults to `h2,http/1.1` only when the option is absent;
|
||||
explicitly empty `alpn` remains empty like Mihomo's decoded option struct.
|
||||
The same feature also provides the ShadowTLS v3 browser-profile path by
|
||||
signing the first ClientHello record's 32-byte session ID before it reaches
|
||||
the wire, matching Mihomo's uTLS `SessionIDGenerator` shape. The BoringSSL
|
||||
browser-profile paths also load Mihomo-compatible `certificate` and
|
||||
`private-key` client-auth material, so ShadowTLS v2/v3 browser fingerprints
|
||||
and mTLS can be combined the same way Mihomo passes those options into
|
||||
`sing-shadowtls`. For ShadowTLS `client-fingerprint: random`, Burrow follows
|
||||
Mihomo's initial random selection shape by choosing one process-wide browser
|
||||
profile with the same chrome/safari/ios/firefox weights instead of re-rolling
|
||||
each connection. The ShadowTLS v2 browser-profile path also removes the
|
||||
`X25519MLKEM768` supported group before building the BoringSSL connector,
|
||||
matching Mihomo's v2-only workaround for servers that fail with that hybrid
|
||||
key-share. These paths are intentionally not default yet because they add
|
||||
a CMake/BoringSSL toolchain requirement, still need CI coverage, and still need
|
||||
live interop coverage. The optional browser-profile path covers Mihomo's
|
||||
common `chrome`, `firefox`, `safari`, `ios`, `android`, `edge`, `random`, and
|
||||
`safari16` names where Rama has matching embedded profiles; older fixed
|
||||
aliases such as `chrome120` and `firefox120` remain gated until Burrow has
|
||||
exact matching ClientHello profiles instead of approximate fallbacks.
|
||||
- Support Mihomo's `kcptun` plugin in-process with Rust KCP, snappy, and smux
|
||||
crates. Burrow parses Mihomo's kcptun option names and defaults, forces
|
||||
UDP-over-TCP for kcptun nodes like Mihomo, and opens Shadowsocks TCP streams
|
||||
over KCP plus optional snappy plus smux. Burrow maintains a Mihomo-style
|
||||
round-robin smux session pool for `conn` and rotates expired sessions using
|
||||
`autoexpire` plus delayed `scavengettl` cleanup. Burrow mirrors Mihomo's smux
|
||||
keepalive timeout shape: the default timeout remains 30 seconds unless the
|
||||
keepalive interval is at least that value, in which case timeout becomes three
|
||||
times the interval. Burrow also applies Mihomo-style best-effort `dscp` and
|
||||
`sockbuf` settings to the underlying UDP socket. For `ratelimit`, Burrow
|
||||
honors Mihomo's bytes-per-second value with
|
||||
the same `64 * 1500` byte burst shape at the KCP stream boundary; this is the
|
||||
closest in-process equivalent available through the selected Rust KCP crate,
|
||||
which does not expose kcp-go's exact queued UDP packet transmit hook.
|
||||
- Plugin support landed in stages and must stay covered:
|
||||
- `obfs`, matching Mihomo URI conversion and option shapes for
|
||||
`mode`/`obfs`, `host`/`obfs-host`, and the default `bing.com` host.
|
||||
- `v2ray-plugin` websocket and its lightweight mux.
|
||||
- `gost-plugin` websocket and smux.
|
||||
- TLS client certificate/key support for websocket plugins and `shadow-tls`
|
||||
v1/v2.
|
||||
- `kcptun` with Rust KCP, snappy, and smux transport code.
|
||||
- ShadowTLS v3 with a Rustls fork that exposes the session ID generator
|
||||
required by the protocol.
|
||||
- Websocket `ech-opts` on Rustls-backed TLS paths.
|
||||
- `v2ray-http-upgrade-fast-open` for v2ray-plugin raw upgrade mode.
|
||||
- Websocket early data via the `ed=` path query option.
|
||||
- `restls` after its TLS ClientHello, authentication, and traffic-shaping
|
||||
behavior are mapped to audited Rust code. Burrow now parses Mihomo's
|
||||
`restls` option shape, including `host`, `password`, `version-hint`,
|
||||
`restls-script`, top-level `client-fingerprint` preservation, Mihomo's
|
||||
case-sensitive Restls client ID lookup with Chrome fallback, Mihomo's TLS
|
||||
1.3 session ticket disablement rule, and the BLAKE3-derived traffic secret.
|
||||
The default runtime still rejects non-`none` uTLS fingerprints, while the
|
||||
optional `boring-browser-fingerprints` feature can run TLS 1.3 Restls over a
|
||||
BoringSSL-backed browser profile and signs the first ClientHello record's
|
||||
session ID using Mihomo's Restls TLS 1.3 auth material. It also
|
||||
ports and tests Mihomo's BLAKE3-authenticated ClientHello session-ID
|
||||
material for TLS 1.2 and TLS 1.3, extraction of TLS 1.3 key-share and PSK
|
||||
identity labels from the serialized ClientHello shape exposed by Burrow's
|
||||
Rustls session-ID hook, the server-auth record unmasking offsets, a
|
||||
handshake stream shim that captures ServerHello random, unmasks the first
|
||||
server-auth record, and captures the encrypted ClientFinished record for
|
||||
later application-data authentication, the application-data record header,
|
||||
masked length/command bytes, one-shot ClientFinished binding on the first
|
||||
client application record,
|
||||
script-driven padding target, TLS 1.2 GCM explicit counter shape, Mihomo's
|
||||
signed one-byte response-interrupt count behavior, and the post-auth stream
|
||||
state machine for buffering blocked writes, resuming them after server data,
|
||||
and emitting response-interrupt records. The Restls
|
||||
post-handshake state machine is also covered by an async stream wrapper that
|
||||
turns user writes into authenticated TLS application-data records, decodes
|
||||
server records back into plaintext reads, and flushes resumed uploads plus
|
||||
response-interrupt records before surfacing server plaintext. Burrow now
|
||||
wires TLS 1.3 Restls nodes into the Shadowsocks outbound path through the
|
||||
Rustls session-ID hook, the server-auth handshake shim, and the async Restls
|
||||
stream wrapper. Burrow vendors the ShadowTLS Rustls fork and its Tokio
|
||||
adapter so TLS 1.2 Restls can pre-generate the ECDHE keys used in Mihomo's
|
||||
session-ID HMAC material and then reuse the matching private key when
|
||||
emitting ClientKeyExchange. TLS 1.2 Restls nodes are now accepted by runtime
|
||||
support checks, surfaced as selectable Apple subscription nodes, and use the
|
||||
TLS 1.2 GCM Restls application-record codec.
|
||||
Restls still needs end-to-end interop coverage before it can be claimed as
|
||||
full Mihomo parity.
|
||||
- `Scripts/burrow-proxy-selftest` exercises daemon packet streaming against a
|
||||
controlled local Trojan/Shadowsocks harness and asserts observed TCP, native
|
||||
UDP, legacy UDP-over-TCP, version 2 UDP-over-TCP, simple-obfs HTTP/TLS,
|
||||
v2ray HTTP-upgrade/websocket/mux/early-data, and gost raw websocket shapes.
|
||||
- `Scripts/burrow-shadowsocks-singmux-interop` drives Burrow's daemon against a
|
||||
temporary Go peer built from Mihomo's `github.com/metacubex/sing-mux` module
|
||||
and asserts top-level Shadowsocks `smux`, `yamux`, and `h2mux` TCP and UDP
|
||||
behavior through the daemon with both unpadded and padded sing-mux sessions,
|
||||
including the `sp.mux.sing-box.arpa:444` control destination, per-stream TCP
|
||||
destination metadata, UDP packet destination metadata, and UDP packet echo
|
||||
delivery. The interop matrix intentionally leaves the underlying Shadowsocks
|
||||
`udp` flag false so Burrow matches Mihomo's behavior: top-level sing-mux
|
||||
carries UDP unless `only-tcp` is set.
|
||||
Remaining compatibility evidence still needs live interop against full
|
||||
Mihomo/sing-box deployments for plugin combinations that the local harness
|
||||
does not cover, especially Restls post-handshake behavior and
|
||||
kernel-dependent TCP Brutal socket behavior.
|
||||
- Keep UDP-over-TCP packet framing compatible with Mihomo's
|
||||
`udp-over-tcp-version` handling, including the version 2 request prefix and
|
||||
per-packet address framing.
|
||||
- UI runtime-support checks must not hard-code stale cipher lists that disagree
|
||||
with the daemon. When local decoding is unavoidable, the local list must be
|
||||
updated with the daemon-supported cipher families or replaced with daemon
|
||||
preview/list-node data.
|
||||
|
||||
## Security and Operational Considerations
|
||||
|
||||
- Shadowsocks passwords and subscription URLs remain secrets. Error messages and
|
||||
logs must not include decoded credentials or bearer tokens.
|
||||
- Plugin support must stay within Burrow's daemon-owned socket lifecycle unless
|
||||
a later BEP explicitly introduces subprocess management. Shelling out to
|
||||
SIP003 plugins would add lifecycle and supply-chain risk and must include
|
||||
teardown and selected-network scoping if introduced.
|
||||
- `none` and legacy stream methods exist for compatibility, not as a security
|
||||
recommendation. UI warnings may be added later for weak ciphers without
|
||||
rejecting Mihomo-compatible imports.
|
||||
- AEAD 2022 passwords may be encoded keys. Validation must report malformed keys
|
||||
without logging the raw password.
|
||||
- Rollback is a code rollback plus re-importing affected subscriptions if stored
|
||||
payload shape changes.
|
||||
|
||||
## Contributor Playbook
|
||||
|
||||
1. Compare behavior against `/Users/jettchen/dev/vpn-ref/mihomo` on the `Alpha`
|
||||
branch before changing Shadowsocks protocol semantics.
|
||||
2. Keep Apple UI subscription operations behind daemon gRPC.
|
||||
3. Prefer maintained Rust crates for Shadowsocks crypto/framing rather than
|
||||
hand-rolled protocol code.
|
||||
4. Preserve Burrow socket binding and proxy-server route exclusion behavior.
|
||||
5. Add tests for each parity expansion:
|
||||
- cipher acceptance and legacy aliases
|
||||
- `udp` gating
|
||||
- plugin parsing and runtime gating
|
||||
- UDP-over-TCP version handling and packet framing
|
||||
- AEAD 2022 TCP/UDP packet framing for custom adapters
|
||||
- end-to-end local Shadowsocks TCP, native UDP, UDP-over-TCP, and plugin
|
||||
selftests; the proxy selftest must switch the daemon from a Trojan node to
|
||||
Shadowsocks nodes, prove both selected outbounds can carry a TCP probe,
|
||||
prove the selected Shadowsocks outbounds can carry native UDP, legacy
|
||||
UDP-over-TCP, and version 2 UDP-over-TCP echo probes, and prove the
|
||||
simple-obfs HTTP/TLS plugins, v2ray-plugin websocket/default-mux/
|
||||
HTTP-upgrade/early-data modes, and gost-plugin raw websocket mode wrap
|
||||
selected Shadowsocks TCP probes
|
||||
- top-level sing-mux Shadowsocks TCP and UDP interop against Mihomo's Go
|
||||
`sing-mux` module
|
||||
6. Run:
|
||||
|
||||
```bash
|
||||
uv run python Scripts/check-bep-metadata.py
|
||||
cargo fmt -p burrow
|
||||
cargo test -p burrow proxy_subscription::tests::
|
||||
cargo test -p burrow proxy_runtime::tests::
|
||||
cargo check -p burrow
|
||||
Scripts/burrow-proxy-selftest
|
||||
Scripts/burrow-shadowsocks-singmux-interop
|
||||
```
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- Keep the hand-written AEAD-only implementation. Rejected because it locks
|
||||
Burrow into a narrow subset and makes Mihomo parity much slower.
|
||||
- Shell out to Mihomo. Rejected for the same reasons recorded in `BEP-0010`:
|
||||
Burrow would inherit a separate runtime, control plane, and lifecycle surface.
|
||||
- Implement plugins before cipher parity. Rejected because cipher parity is a
|
||||
lower-risk foundation and reduces custom crypto code first.
|
||||
|
||||
## Impact on Other Work
|
||||
|
||||
- Refines the Shadowsocks runtime portion of `BEP-0010`.
|
||||
- Complements `BEP-0011`, which covers Trojan-specific Mihomo compatibility.
|
||||
- Future plugin work may require a dedicated subprocess lifecycle BEP if the
|
||||
runtime cannot stay within the existing daemon teardown model.
|
||||
|
||||
## Decision
|
||||
|
||||
Pending review.
|
||||
|
||||
## References
|
||||
|
||||
- `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md`
|
||||
- `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md`
|
||||
- `evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md`
|
||||
- `/Users/jettchen/dev/vpn-ref/mihomo/adapter/outbound/shadowsocks.go`
|
||||
- `/Users/jettchen/dev/vpn-ref/mihomo/common/convert/converter.go`
|
||||
- Rust `shadowsocks` crate: https://crates.io/crates/shadowsocks
|
||||
|
|
@ -26,6 +26,12 @@ service TailnetControl {
|
|||
rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty);
|
||||
}
|
||||
|
||||
service ProxySubscriptions {
|
||||
rpc PreviewImport (ProxySubscriptionImportRequest) returns (ProxySubscriptionPreviewResponse);
|
||||
rpc ApplyImport (ProxySubscriptionApplyRequest) returns (ProxySubscriptionApplyResponse);
|
||||
rpc SelectNode (ProxySubscriptionSelectRequest) returns (ProxySubscriptionSelectResponse);
|
||||
}
|
||||
|
||||
message NetworkReorderRequest {
|
||||
int32 id = 1;
|
||||
int32 index = 2;
|
||||
|
|
@ -55,6 +61,62 @@ message Network {
|
|||
enum NetworkType {
|
||||
WireGuard = 0;
|
||||
Tailnet = 1;
|
||||
reserved 2;
|
||||
reserved "Trojan";
|
||||
ProxySubscription = 3;
|
||||
}
|
||||
|
||||
message ProxySubscriptionImportRequest {
|
||||
string url = 1;
|
||||
}
|
||||
|
||||
message ProxySubscriptionRejectedEntry {
|
||||
int32 ordinal = 1;
|
||||
string reason = 2;
|
||||
string scheme = 3;
|
||||
}
|
||||
|
||||
message ProxySubscriptionNodePreview {
|
||||
int32 ordinal = 1;
|
||||
string name = 2;
|
||||
string protocol = 3;
|
||||
string server = 4;
|
||||
int32 port = 5;
|
||||
repeated string warnings = 6;
|
||||
bool runtime_supported = 7;
|
||||
}
|
||||
|
||||
message ProxySubscriptionPreviewResponse {
|
||||
string suggested_name = 1;
|
||||
string detected_format = 2;
|
||||
int32 compatible_count = 3;
|
||||
int32 rejected_count = 4;
|
||||
repeated ProxySubscriptionNodePreview node = 5;
|
||||
repeated ProxySubscriptionRejectedEntry rejected = 6;
|
||||
repeated string warnings = 7;
|
||||
}
|
||||
|
||||
message ProxySubscriptionApplyRequest {
|
||||
int32 id = 1;
|
||||
string url = 2;
|
||||
string name = 3;
|
||||
int32 selected_ordinal = 4;
|
||||
}
|
||||
|
||||
message ProxySubscriptionApplyResponse {
|
||||
int32 id = 1;
|
||||
int32 imported_count = 2;
|
||||
int32 selected_ordinal = 3;
|
||||
}
|
||||
|
||||
message ProxySubscriptionSelectRequest {
|
||||
int32 id = 1;
|
||||
int32 selected_ordinal = 2;
|
||||
}
|
||||
|
||||
message ProxySubscriptionSelectResponse {
|
||||
int32 id = 1;
|
||||
int32 selected_ordinal = 2;
|
||||
}
|
||||
|
||||
message NetworkListResponse {
|
||||
|
|
@ -133,6 +195,7 @@ message TunnelConfigurationResponse {
|
|||
repeated string dns_servers = 4;
|
||||
repeated string search_domains = 5;
|
||||
bool include_default_route = 6;
|
||||
repeated string excluded_routes = 7;
|
||||
}
|
||||
|
||||
message TunnelPacket {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue