Add proxy subscription runtime support

Add daemon RPCs, Apple and GTK import flows, packet proxy runtime support, diagnostics, and BEPs for proxy subscription handling.

Redact subscription URL secrets from fetch errors before they reach logs or UI surfaces.
This commit is contained in:
JettChenT 2026-06-01 15:23:17 +08:00
parent 97c569fb35
commit d1638726ca
46 changed files with 15079 additions and 456 deletions

View file

@ -15,5 +15,7 @@
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>
</array>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View file

@ -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

View file

@ -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

View file

@ -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">

View file

@ -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 */

View file

@ -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)

View file

@ -1,2 +1,2 @@
DEVELOPMENT_TEAM = P6PV2R9443
APP_BUNDLE_IDENTIFIER = com.hackclub.burrow
DEVELOPMENT_TEAM = 2KPBQHRJ2D
APP_BUNDLE_IDENTIFIER = com.tianruichen.burrow

View file

@ -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

View file

@ -25,6 +25,8 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
public typealias RawValue = Int
case wireGuard // = 0
case tailnet // = 1
case trojan // = 2
case proxySubscription // = 3
case UNRECOGNIZED(Int)
public init() {
@ -35,6 +37,8 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
switch rawValue {
case 0: self = .wireGuard
case 1: self = .tailnet
case 2: self = .trojan
case 3: self = .proxySubscription
default: self = .UNRECOGNIZED(rawValue)
}
}
@ -43,6 +47,8 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
switch self {
case .wireGuard: return 0
case .tailnet: return 1
case .trojan: return 2
case .proxySubscription: return 3
case .UNRECOGNIZED(let i): return i
}
}
@ -51,6 +57,8 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
public static let allCases: [Burrow_NetworkType] = [
.wireGuard,
.tailnet,
.trojan,
.proxySubscription,
]
}
@ -217,6 +225,8 @@ public struct Burrow_TunnelConfigurationResponse: Sendable {
public var routes: [String] = []
public var excludedRoutes: [String] = []
public var dnsServers: [String] = []
public var searchDomains: [String] = []
@ -236,6 +246,8 @@ extension Burrow_NetworkType: SwiftProtobuf._ProtoNameProviding {
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
0: .same(proto: "WireGuard"),
1: .same(proto: "Tailnet"),
2: .same(proto: "Trojan"),
3: .same(proto: "ProxySubscription"),
]
}
@ -544,6 +556,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 +571,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 +587,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 +606,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}

View file

@ -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

View file

@ -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 {

View file

@ -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.

View file

@ -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"

View file

@ -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

View file

@ -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 {

View file

@ -53,6 +53,218 @@ 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 udpOverTCP: Bool?
var runtimeSupported: Bool {
switch type {
case "trojan":
return network?.isTrojanTCP ?? true
case "shadowsocks":
return plugin == nil
&& udpOverTCP != true
&& Self.supportedShadowsocksCiphers.contains(cipher?.lowercased() ?? "")
default:
return false
}
}
enum CodingKeys: String, CodingKey {
case type
case network
case cipher
case plugin
case udpOverTCP = "udp_over_tcp"
}
private static let supportedShadowsocksCiphers: Set<String> = [
"aes-128-gcm",
"aead_aes_128_gcm",
"aes-256-gcm",
"aead_aes_256_gcm",
"chacha20-ietf-poly1305",
"chacha20-poly1305",
"aead_chacha20_poly1305",
]
}
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
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 +351,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 +443,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 +465,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 +551,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 +562,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 +585,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 +629,14 @@ final class NetworkViewModel: Sendable {
WireGuardCard(network: network).card
case .tailnet:
TailnetCard(network: network).card
case .trojan:
unsupportedCard(
id: network.id,
title: "Legacy Trojan Proxy",
detail: "Unsupported SOCKS-era profile. Import a proxy subscription instead."
)
case .proxySubscription:
proxySubscriptionCard(network: network)
case .UNRECOGNIZED(let rawValue):
unsupportedCard(
id: network.id,
@ -269,6 +652,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 +731,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 +815,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 +824,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 +833,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 +842,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 +851,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 +859,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."

108
Cargo.lock generated
View file

@ -437,6 +437,28 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "aws-lc-rs"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "axum"
version = "0.6.20"
@ -733,6 +755,8 @@ dependencies = [
"libc",
"libsystemd",
"log",
"md-5",
"native-tls",
"netstack-smoltcp",
"nix 0.27.1",
"once_cell",
@ -745,12 +769,17 @@ dependencies = [
"ring",
"rusqlite",
"rust-ini",
"rustls",
"schemars 0.8.22",
"serde",
"serde_json",
"serde_yaml",
"sha2",
"subtle",
"tempfile",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tokio-stream",
"tokio-util",
"toml 0.8.23",
@ -764,6 +793,7 @@ dependencies = [
"tracing-oslog",
"tracing-subscriber",
"tun",
"webpki-roots 0.26.11",
"x25519-dalek",
]
@ -835,9 +865,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.38"
version = "1.2.61"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9"
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
dependencies = [
"find-msvc-tools",
"jobserver",
@ -991,6 +1021,15 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "cmake"
version = "0.1.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
dependencies = [
"cc",
]
[[package]]
name = "coarsetime"
version = "0.1.37"
@ -1669,6 +1708,12 @@ version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dyn-clone"
version = "1.0.20"
@ -1951,9 +1996,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
version = "0.1.2"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "fixedbitset"
@ -2034,6 +2079,12 @@ dependencies = [
"walkdir",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "fslock"
version = "0.2.1"
@ -2566,7 +2617,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.3",
]
[[package]]
@ -3170,6 +3221,16 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "md-5"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
dependencies = [
"cfg-if",
"digest",
]
[[package]]
name = "memchr"
version = "2.7.5"
@ -4484,7 +4545,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.3",
]
[[package]]
@ -4645,6 +4706,8 @@ version = "0.23.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
"rustls-pki-types",
@ -4678,6 +4741,7 @@ version = "0.103.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@ -4984,6 +5048,19 @@ dependencies = [
"syn 2.0.106",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap 2.11.4",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sha-1"
version = "0.10.1"
@ -6954,9 +7031,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.18.0"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "uncased"
@ -7007,6 +7084,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "untrusted"
version = "0.9.0"
@ -7265,6 +7348,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.3",
]
[[package]]
name = "webpki-roots"
version = "1.0.3"

434
Scripts/burrow-doctor Executable file
View 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
View 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())

272
Scripts/burrow-proxy-selftest Executable file
View file

@ -0,0 +1,272 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/burrow-proxy-selftest
Run a controlled Burrow proxy packet-runtime test without changing system proxy
settings or using the user's Burrow database. The script starts a local Trojan
TLS server, starts a Burrow daemon against a temporary database/socket, imports a
temporary ProxySubscription pointed at that server, then runs the packet-level
proxy-tcp-probe through daemon DNS and TCP packet streaming.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
burrow_bin="${BURROW_BIN:-$repo_root/target/debug/burrow}"
if [[ ! -x "$burrow_bin" ]]; then
(cd "$repo_root" && cargo build -p burrow)
fi
if ! command -v openssl >/dev/null 2>&1; then
echo "openssl is required for the local TLS server certificate" >&2
exit 2
fi
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-proxy-selftest.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 self-test artifacts: $tmpdir" >&2
else
rm -rf "$tmpdir"
fi
}
trap 'status=$?; cleanup' EXIT
cert="$tmpdir/cert.pem"
key="$tmpdir/key.pem"
socket="$tmpdir/burrow.sock"
port_file="$tmpdir/server.port"
server_result="$tmpdir/server-result.json"
payload="$tmpdir/proxy-payload.json"
daemon_log="$tmpdir/daemon.log"
server_log="$tmpdir/server.log"
openssl req \
-x509 \
-newkey rsa:2048 \
-nodes \
-subj /CN=localhost \
-keyout "$key" \
-out "$cert" \
-days 1 >/dev/null 2>&1
python3 - "$port_file" "$server_result" "$cert" "$key" >"$server_log" 2>&1 <<'PY' &
from __future__ import annotations
import hashlib
import json
import socket
import ssl
import struct
import sys
port_file, result_file, cert_file, key_file = sys.argv[1:]
password = "secret"
def read_exact(sock: ssl.SSLSocket, length: int) -> bytes:
chunks: list[bytes] = []
remaining = length
while remaining:
chunk = sock.recv(remaining)
if not chunk:
raise EOFError(f"expected {remaining} more bytes")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def read_socks_addr(sock: ssl.SSLSocket) -> dict[str, object]:
atyp = read_exact(sock, 1)[0]
if atyp == 1:
host = socket.inet_ntop(socket.AF_INET, read_exact(sock, 4))
elif atyp == 4:
host = socket.inet_ntop(socket.AF_INET6, read_exact(sock, 16))
elif atyp == 3:
length = read_exact(sock, 1)[0]
host = read_exact(sock, length).decode("idna")
else:
raise ValueError(f"unsupported SOCKS address type {atyp}")
port = struct.unpack("!H", read_exact(sock, 2))[0]
return {"atyp": atyp, "host": host, "port": port}
def read_http_headers(sock: ssl.SSLSocket) -> bytes:
data = bytearray()
while b"\r\n\r\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data.extend(chunk)
if len(data) > 65535:
raise ValueError("HTTP request headers exceeded 64 KiB")
return bytes(data)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(cert_file, key_file)
ctx.set_alpn_protocols(["h2", "http/1.1"])
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listener.bind(("127.0.0.1", 0))
listener.listen(1)
port = listener.getsockname()[1]
with open(port_file, "w", encoding="utf-8") as f:
f.write(str(port))
raw, peer = listener.accept()
raw.settimeout(10)
result: dict[str, object] = {"peer": str(peer)}
try:
with ctx.wrap_socket(raw, server_side=True) as tls:
tls.settimeout(10)
result["alpn"] = tls.selected_alpn_protocol()
got_hash = read_exact(tls, 56)
expected_hash = hashlib.sha224(password.encode()).hexdigest().encode()
result["password_hash_ok"] = got_hash == expected_hash
if not result["password_hash_ok"]:
raise ValueError("unexpected Trojan password hash")
if read_exact(tls, 2) != b"\r\n":
raise ValueError("missing Trojan password delimiter")
command = read_exact(tls, 1)[0]
result["command"] = command
target = read_socks_addr(tls)
result["target"] = target
if read_exact(tls, 2) != b"\r\n":
raise ValueError("missing Trojan header delimiter")
request = read_http_headers(tls)
result["request_first_line"] = request.splitlines()[0].decode("utf-8", "replace")
body = json.dumps(
{
"ok": True,
"target": target,
"request_first_line": result["request_first_line"],
},
sort_keys=True,
).encode()
tls.sendall(
b"HTTP/1.1 200 OK\r\n"
+ f"Content-Length: {len(body)}\r\n".encode()
+ b"Connection: close\r\n"
+ b"Content-Type: application/json\r\n\r\n"
+ body
)
finally:
with open(result_file, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2, sort_keys=True)
listener.close()
PY
server_pid=$!
for _ in {1..100}; do
if [[ -s "$port_file" ]]; then
break
fi
if ! kill -0 "$server_pid" >/dev/null 2>&1; then
echo "local Trojan server exited before listening" >&2
cat "$server_log" >&2 || true
exit 1
fi
sleep 0.05
done
if [[ ! -s "$port_file" ]]; then
echo "timed out waiting for local Trojan server" >&2
cat "$server_log" >&2 || true
exit 1
fi
server_port="$(cat "$port_file")"
cat >"$payload" <<EOF
{
"name": "burrow proxy selftest",
"source": { "url": "selftest://local-trojan" },
"detected_format": "uri-list",
"selected_ordinal": 1,
"selected_name": "local trojan",
"nodes": [
{
"ordinal": 1,
"name": "local trojan",
"protocol": "trojan",
"server": "127.0.0.1",
"port": $server_port,
"warnings": [],
"config": {
"type": "trojan",
"password": "secret",
"sni": "localhost",
"alpn": [],
"alpn_present": true,
"skip_cert_verify": true,
"network": "tcp",
"udp": true,
"client_fingerprint": null,
"fingerprint": 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
)
wait "$server_pid"
server_pid=""
echo
echo "Local Trojan server observed:"
cat "$server_result"
echo

4559
burrow-gtk/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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: &gtk::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: &gtk::ScrolledWindow, sender: &AsyncComponentSender
window.present();
}
fn open_proxy_subscription_window(
root: &gtk::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(&section_label("Subscription"));
content.append(&url);
content.append(&name);
content.append(&section_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: &gtk::ScrolledWindow, title: &str, width: i32, height: i32) -> gtk::Window {
let window = gtk::Window::builder()
.title(title)

View file

@ -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,12 @@ fn summarize_network(network: &Network) -> NetworkSummary {
match network.r#type() {
NetworkType::WireGuard => summarize_wireguard(network),
NetworkType::Tailnet => summarize_tailnet(network),
NetworkType::Trojan => NetworkSummary {
id: network.id,
title: "Legacy Trojan Proxy".to_owned(),
detail: "Unsupported SOCKS-era profile. Import a proxy subscription instead.".to_owned(),
},
NetworkType::ProxySubscription => summarize_proxy_subscription(network),
}
}
@ -372,6 +458,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 {

View file

@ -31,6 +31,7 @@ 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"
chacha20poly1305 = "0.10"
rand = "0.8"
@ -44,6 +45,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"
@ -78,6 +80,15 @@ 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",
] }
sha2 = "0.10"
tokio-rustls = "0.26"
webpki-roots = "0.26"
[target.'cfg(target_os = "linux")'.dependencies]
caps = "0.5"
@ -87,8 +98,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" }

View file

@ -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(())

View file

@ -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 {

View file

@ -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"));
}

View file

@ -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
}

View file

@ -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")
);
}
}

View file

@ -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::{

View file

@ -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,
})

View file

@ -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,

View file

@ -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}

View file

@ -20,6 +20,8 @@ use crate::{
TailscaleLoginStartRequest, TailscaleLoginStatus,
},
control::{discovery, TailnetConfig},
proxy_runtime,
proxy_subscription::ProxySubscriptionPayload,
wireguard::{Config, Interface as WireGuardInterface},
};
@ -46,6 +48,13 @@ pub enum ResolvedTunnel {
identity: RuntimeIdentity,
config: Config,
},
Trojan {
identity: RuntimeIdentity,
},
ProxySubscription {
identity: RuntimeIdentity,
payload: ProxySubscriptionPayload,
},
}
impl ResolvedTunnel {
@ -73,6 +82,13 @@ impl ResolvedTunnel {
let config = Config::from_content_fmt(&payload, "ini")?;
Ok(Self::WireGuard { identity, config })
}
NetworkType::Trojan => Ok(Self::Trojan { identity }),
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 +96,9 @@ impl ResolvedTunnel {
match self {
Self::Passthrough { identity }
| Self::Tailnet { identity, .. }
| Self::WireGuard { identity, .. } => identity,
| Self::WireGuard { identity, .. }
| Self::Trojan { identity, .. }
| Self::ProxySubscription { identity, .. } => identity,
}
}
@ -89,6 +107,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 +117,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 +125,10 @@ impl ResolvedTunnel {
mtu: Some(1280),
}),
Self::WireGuard { config, .. } => ServerConfig::try_from(config),
Self::Trojan { .. } => Err(anyhow::anyhow!(
"legacy Trojan SOCKS proxy networks are no longer supported; import a proxy subscription instead"
)),
Self::ProxySubscription { payload, .. } => proxy_subscription_server_config(payload),
}
}
@ -119,6 +143,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 +162,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,8 +206,62 @@ impl ResolvedTunnel {
}
}
}
Self::Trojan { .. } => Err(anyhow::anyhow!(
"legacy Trojan SOCKS proxy networks are no longer supported; import a proxy subscription instead"
)),
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 {
@ -205,6 +283,16 @@ pub enum ActiveTunnel {
interface: Arc<RwLock<WireGuardInterface>>,
task: JoinHandle<Result<()>>,
},
Trojan {
identity: RuntimeIdentity,
server_config: ServerConfig,
},
ProxySubscription {
identity: RuntimeIdentity,
server_config: ServerConfig,
packet_bridge: proxy_runtime::ProxyPacketBridge,
tun_task: Option<JoinHandle<Result<()>>>,
},
}
impl ActiveTunnel {
@ -212,7 +300,9 @@ impl ActiveTunnel {
match self {
Self::Passthrough { identity, .. }
| Self::Tailnet { identity, .. }
| Self::WireGuard { identity, .. } => identity,
| Self::WireGuard { identity, .. }
| Self::Trojan { identity, .. }
| Self::ProxySubscription { identity, .. } => identity,
}
}
@ -220,22 +310,55 @@ impl ActiveTunnel {
match self {
Self::Passthrough { server_config, .. }
| Self::Tailnet { server_config, .. }
| Self::WireGuard { server_config, .. } => server_config,
| Self::WireGuard { server_config, .. }
| Self::Trojan { 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 +391,29 @@ 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::Trojan { .. } => 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 +531,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 +741,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);

View file

@ -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,15 @@ fn validate_network_payload(network: &RPCNetwork) -> Result<()> {
NetworkType::Tailnet => {
TailnetConfig::from_slice(&network.payload)?;
}
NetworkType::Trojan => {
return Err(anyhow::anyhow!(
"legacy Trojan SOCKS proxy networks are no longer supported; import a proxy subscription instead"
));
}
NetworkType::ProxySubscription => {
let payload: ProxySubscriptionPayload = serde_json::from_slice(&network.payload)?;
crate::proxy_subscription::validate_payload(&payload)?;
}
}
Ok(())
}
@ -278,6 +364,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 +476,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 +495,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 +505,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 +568,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();

View file

@ -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;

View file

@ -5,6 +5,9 @@ use clap::{Args, Parser, Subcommand};
mod control;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
mod daemon;
mod proxy_runtime;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
mod proxy_subscription;
pub(crate) mod tracing;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
mod wireguard;
@ -80,6 +83,14 @@ enum Commands {
TailnetPing(TailnetPingArgs),
/// Send a UDP echo probe through the active Tailnet tunnel over daemon packet streaming
TailnetUdpEcho(TailnetUdpEchoArgs),
/// Send an HTTP probe through the active proxy tunnel over daemon packet streaming
ProxyTcpProbe(ProxyTcpProbeArgs),
/// Send an HTTPS probe through the active proxy tunnel over daemon packet streaming
ProxyHttpsProbe(ProxyHttpsProbeArgs),
/// Select a proxy subscription node through the daemon
ProxySubscriptionSelect(ProxySubscriptionSelectArgs),
/// Preview proxy nodes from a subscription URL
ProxySubscriptionPreview(ProxySubscriptionPreviewArgs),
#[cfg(target_os = "linux")]
/// Run a command in an unshared Linux namespace using a Burrow backend
Exec(ExecArgs),
@ -148,6 +159,49 @@ struct TailnetUdpEchoArgs {
timeout_ms: u64,
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
#[derive(Args)]
struct ProxyTcpProbeArgs {
#[arg(long, default_value = "1.1.1.1:80")]
remote: String,
#[arg(long, default_value = "one.one.one.one")]
host: String,
#[arg(long)]
dns_name: Option<String>,
#[arg(long, default_value = "100.64.0.2:53")]
dns_server: String,
#[arg(long, default_value_t = 8000)]
timeout_ms: u64,
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
#[derive(Args)]
struct ProxyHttpsProbeArgs {
#[arg(long, default_value = "1.1.1.1:443")]
remote: String,
#[arg(long)]
host: Option<String>,
#[arg(long)]
dns_name: Option<String>,
#[arg(long, default_value = "100.64.0.2:53")]
dns_server: String,
#[arg(long, default_value_t = 15000)]
timeout_ms: u64,
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
#[derive(Args)]
struct ProxySubscriptionSelectArgs {
id: i32,
selected_ordinal: i32,
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
#[derive(Args)]
struct ProxySubscriptionPreviewArgs {
url: String,
}
#[cfg(target_os = "linux")]
#[derive(Args)]
struct TorExecArgs {
@ -506,6 +560,688 @@ async fn try_tailnet_udp_echo(remote: &str, message: &str, timeout_ms: u64) -> R
Ok(())
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
async fn try_proxy_subscription_preview(url: &str) -> Result<()> {
let body = proxy_subscription::fetch_subscription(url).await?;
let preview = proxy_subscription::parse_subscription_body(&body, Some(url));
println!("Proxy subscription nodes: {}", preview.nodes.len());
println!("Rejected entries: {}", preview.rejected.len());
println!("Detected format: {}", preview.detected_format.as_str());
for node in preview.nodes.iter() {
println!(
"{}: {} {}:{} ({})",
node.ordinal,
node.name,
node.server,
node.port,
node.protocol.as_str()
);
}
Ok(())
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
async fn proxy_dns_probe(
dns_name: &str,
dns_server: &str,
timeout_ms: u64,
) -> Result<std::net::Ipv4Addr> {
use std::net::{IpAddr, SocketAddr};
use anyhow::{bail, Context};
use futures::{SinkExt, StreamExt};
use netstack_smoltcp::StackBuilder;
use rand::Rng;
use tokio::{
sync::mpsc,
time::{timeout, Duration},
};
use tokio_stream::wrappers::ReceiverStream;
use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket};
let dns_server_addr: SocketAddr = dns_server
.parse()
.with_context(|| format!("invalid DNS server socket address {dns_server}"))?;
let mut query = proxy_runtime::build_dns_query(dns_name, 1)?;
let query_id = rand::thread_rng().gen::<u16>();
query[0..2].copy_from_slice(&query_id.to_be_bytes());
let mut client = BurrowClient::from_uds().await?;
client.tunnel_client.tunnel_start(Empty {}).await?;
let mut config_stream = client
.tunnel_client
.tunnel_configuration(Empty {})
.await?
.into_inner();
let config = config_stream
.message()
.await?
.context("tunnel configuration stream ended before yielding a config")?;
let local_addr = select_tailnet_local_socket(&config.addresses, dns_server_addr.ip())?;
let (stack, runner, udp_socket, _) = StackBuilder::default()
.enable_udp(true)
.enable_tcp(true)
.build()
.context("failed to build userspace DNS probe stack")?;
let runner = runner.context("userspace DNS probe stack runner unavailable")?;
let udp_socket = udp_socket.context("userspace DNS probe socket unavailable")?;
let (mut stack_sink, mut stack_stream) = stack.split();
let (mut udp_reader, mut udp_writer) = udp_socket.split();
let (outbound_tx, outbound_rx) = mpsc::channel::<TunnelPacket>(128);
let mut tunnel_packets = client
.tunnel_client
.tunnel_packets(ReceiverStream::new(outbound_rx))
.await?
.into_inner();
let ingress_task = tokio::spawn(async move {
while let Some(packet) = tunnel_packets.message().await? {
stack_sink
.send(packet.payload)
.await
.context("failed to feed inbound DNS probe packet into userspace stack")?;
}
Result::<()>::Ok(())
});
let egress_task = tokio::spawn(async move {
while let Some(packet) = stack_stream.next().await {
let payload = packet.context("failed to read outbound DNS probe packet")?;
outbound_tx
.send(TunnelPacket { payload })
.await
.context("failed to forward outbound DNS probe packet to daemon")?;
}
Result::<()>::Ok(())
});
let runner_task = tokio::spawn(async move { runner.await.map_err(anyhow::Error::from) });
udp_writer
.send((query, local_addr, dns_server_addr))
.await
.context("failed to send DNS probe query")?;
let response = timeout(Duration::from_millis(timeout_ms), udp_reader.next())
.await
.with_context(|| format!("timed out waiting for DNS response from {dns_server_addr}"))?
.context("userspace DNS probe stack ended before returning a reply")?;
let (payload, reply_source, _reply_destination) = response;
ingress_task.abort();
egress_task.abort();
runner_task.abort();
if reply_source != dns_server_addr {
bail!("received DNS response from unexpected source {reply_source}");
}
for ip in proxy_runtime::parse_dns_response(&payload, query_id)? {
if let IpAddr::V4(ip) = ip {
return Ok(ip);
}
}
bail!("DNS response for {dns_name} did not include an A record")
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
async fn try_proxy_tcp_probe(
remote: &str,
host: &str,
dns_name: Option<&str>,
dns_server: &str,
timeout_ms: u64,
) -> Result<()> {
use std::net::{IpAddr, SocketAddr};
use anyhow::{bail, Context};
use rand::Rng;
use tokio::{
sync::mpsc,
time::{timeout, Duration},
};
use tokio_stream::wrappers::ReceiverStream;
use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket};
let mut remote_addr: SocketAddr = remote
.parse()
.with_context(|| format!("invalid remote socket address {remote}"))?;
let mut http_host = host.to_owned();
if let Some(dns_name) = dns_name {
let resolved_ip = proxy_dns_probe(dns_name, dns_server, timeout_ms).await?;
remote_addr = SocketAddr::new(IpAddr::V4(resolved_ip), remote_addr.port());
if host == "one.one.one.one" {
http_host = dns_name.to_owned();
}
println!("Proxy TCP Probe DNS Name: {dns_name}");
println!("Proxy TCP Probe DNS Resolved: {resolved_ip}");
}
let remote_ip = match remote_addr.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => bail!("proxy tcp probe currently supports IPv4 targets only"),
};
let mut client = BurrowClient::from_uds().await?;
client.tunnel_client.tunnel_start(Empty {}).await?;
let mut config_stream = client
.tunnel_client
.tunnel_configuration(Empty {})
.await?
.into_inner();
let config = config_stream
.message()
.await?
.context("tunnel configuration stream ended before yielding a config")?;
let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?;
let local_ip = match local_addr.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => bail!("proxy tcp probe selected an IPv6 local address"),
};
let local_port = local_addr.port();
let (outbound_tx, outbound_rx) = mpsc::channel::<TunnelPacket>(128);
let mut tunnel_packets = client
.tunnel_client
.tunnel_packets(ReceiverStream::new(outbound_rx))
.await?
.into_inner();
let initial_seq = rand::thread_rng().gen::<u32>();
let syn = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
initial_seq,
0,
TCP_SYN,
&[],
);
outbound_tx
.send(TunnelPacket { payload: syn })
.await
.context("failed to send proxy tcp SYN into daemon packet stream")?;
let syn_ack = timeout(Duration::from_millis(timeout_ms), async {
loop {
let packet = tunnel_packets
.message()
.await
.context("failed to read packet from daemon packet stream")?
.context("daemon packet stream ended before SYN-ACK")?;
if let Some(segment) = parse_tcp_ipv4_segment(
&packet.payload,
local_ip,
remote_ip,
local_port,
remote_addr.port(),
)? {
if segment.flags & TCP_SYN != 0 && segment.flags & TCP_ACK != 0 {
break Ok::<_, anyhow::Error>(segment);
}
if segment.flags & TCP_RST != 0 {
bail!("proxy tcp probe received RST before SYN-ACK");
}
}
}
})
.await
.with_context(|| format!("timed out waiting for proxy tcp SYN-ACK from {remote_addr}"))??;
let mut client_seq = initial_seq.wrapping_add(1);
let mut peer_ack = syn_ack.sequence.wrapping_add(1);
let ack = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
client_seq,
peer_ack,
TCP_ACK,
&[],
);
outbound_tx
.send(TunnelPacket { payload: ack })
.await
.context("failed to send proxy tcp handshake ACK")?;
let request = format!(
"GET / HTTP/1.1\r\nHost: {http_host}\r\nConnection: close\r\nUser-Agent: burrow-proxy-tcp-probe\r\n\r\n"
)
.into_bytes();
let request_packet = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
client_seq,
peer_ack,
TCP_PSH | TCP_ACK,
&request,
);
outbound_tx
.send(TunnelPacket { payload: request_packet })
.await
.context("failed to send proxy tcp HTTP request")?;
client_seq = client_seq.wrapping_add(request.len() as u32);
let response = timeout(Duration::from_millis(timeout_ms), async {
loop {
let packet = tunnel_packets
.message()
.await
.context("failed to read packet from daemon packet stream")?
.context("daemon packet stream ended before HTTP response")?;
let Some(segment) = parse_tcp_ipv4_segment(
&packet.payload,
local_ip,
remote_ip,
local_port,
remote_addr.port(),
)?
else {
continue;
};
if segment.flags & TCP_RST != 0 {
bail!("proxy tcp probe received RST before HTTP response");
}
if !segment.payload.is_empty() {
peer_ack = segment.sequence.wrapping_add(segment.payload.len() as u32);
let ack = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
client_seq,
peer_ack,
TCP_ACK,
&[],
);
outbound_tx
.send(TunnelPacket { payload: ack })
.await
.context("failed to ACK proxy tcp response")?;
break Ok::<_, anyhow::Error>(segment.payload);
}
if segment.flags & TCP_FIN != 0 {
bail!("proxy tcp probe received FIN before HTTP response bytes");
}
}
})
.await
.with_context(|| {
format!("timed out waiting for HTTP response through proxy to {remote_addr}")
})??;
let first_line = String::from_utf8_lossy(&response)
.lines()
.next()
.unwrap_or("")
.to_owned();
println!("Proxy TCP Probe Local: {local_addr}");
println!("Proxy TCP Probe Remote: {remote_addr}");
println!("Proxy TCP Probe Response Bytes: {}", response.len());
println!("Proxy TCP Probe First Line: {first_line}");
Ok(())
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
async fn open_proxy_packet_tcp_stream(
remote_addr: std::net::SocketAddr,
timeout_ms: u64,
) -> Result<(tokio::io::DuplexStream, std::net::SocketAddr)> {
use std::net::IpAddr;
use anyhow::{bail, Context};
use rand::Rng;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
sync::mpsc,
time::{timeout, Duration},
};
use tokio_stream::wrappers::ReceiverStream;
use crate::daemon::rpc::grpc_defs::{Empty, TunnelPacket};
let remote_ip = match remote_addr.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => bail!("proxy packet tcp stream currently supports IPv4 targets only"),
};
let mut client = BurrowClient::from_uds().await?;
client.tunnel_client.tunnel_start(Empty {}).await?;
let mut config_stream = client
.tunnel_client
.tunnel_configuration(Empty {})
.await?
.into_inner();
let config = config_stream
.message()
.await?
.context("tunnel configuration stream ended before yielding a config")?;
let local_addr = select_tailnet_local_socket(&config.addresses, remote_addr.ip())?;
let local_ip = match local_addr.ip() {
IpAddr::V4(ip) => ip,
IpAddr::V6(_) => bail!("proxy packet tcp stream selected an IPv6 local address"),
};
let local_port = local_addr.port();
let (outbound_tx, outbound_rx) = mpsc::channel::<TunnelPacket>(128);
let mut tunnel_packets = client
.tunnel_client
.tunnel_packets(ReceiverStream::new(outbound_rx))
.await?
.into_inner();
let initial_seq = rand::thread_rng().gen::<u32>();
let syn = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
initial_seq,
0,
TCP_SYN,
&[],
);
outbound_tx
.send(TunnelPacket { payload: syn })
.await
.context("failed to send proxy packet TCP SYN into daemon packet stream")?;
let syn_ack = timeout(Duration::from_millis(timeout_ms), async {
loop {
let packet = tunnel_packets
.message()
.await
.context("failed to read packet from daemon packet stream")?
.context("daemon packet stream ended before SYN-ACK")?;
if let Some(segment) = parse_tcp_ipv4_segment(
&packet.payload,
local_ip,
remote_ip,
local_port,
remote_addr.port(),
)? {
if segment.flags & TCP_SYN != 0 && segment.flags & TCP_ACK != 0 {
break Ok::<_, anyhow::Error>(segment);
}
if segment.flags & TCP_RST != 0 {
bail!("proxy packet TCP stream received RST before SYN-ACK");
}
}
}
})
.await
.with_context(|| format!("timed out waiting for proxy TCP SYN-ACK from {remote_addr}"))??;
let mut client_seq = initial_seq.wrapping_add(1);
let mut peer_ack = syn_ack.sequence.wrapping_add(1);
let ack = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
client_seq,
peer_ack,
TCP_ACK,
&[],
);
outbound_tx
.send(TunnelPacket { payload: ack })
.await
.context("failed to send proxy packet TCP handshake ACK")?;
let (client_stream, manager_stream) = tokio::io::duplex(128 * 1024);
let (mut app_reader, mut app_writer) = tokio::io::split(manager_stream);
tokio::spawn(async move {
let mut app_buf = vec![0u8; 8192];
loop {
tokio::select! {
read = app_reader.read(&mut app_buf) => {
let read = match read {
Ok(read) => read,
Err(error) => {
::tracing::debug!(%error, "proxy packet TCP stream app read failed");
break;
}
};
if read == 0 {
let fin = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
client_seq,
peer_ack,
TCP_FIN | TCP_ACK,
&[],
);
let _ = outbound_tx.send(TunnelPacket { payload: fin }).await;
break;
}
for chunk in app_buf[..read].chunks(1200) {
let packet = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
client_seq,
peer_ack,
TCP_PSH | TCP_ACK,
chunk,
);
if let Err(error) = outbound_tx.send(TunnelPacket { payload: packet }).await {
::tracing::debug!(%error, "proxy packet TCP stream outbound send failed");
return;
}
client_seq = client_seq.wrapping_add(chunk.len() as u32);
}
}
packet = tunnel_packets.message() => {
let packet = match packet {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(error) => {
::tracing::debug!(%error, "proxy packet TCP stream inbound read failed");
break;
}
};
let segment = match parse_tcp_ipv4_segment(
&packet.payload,
local_ip,
remote_ip,
local_port,
remote_addr.port(),
) {
Ok(Some(segment)) => segment,
Ok(None) => continue,
Err(error) => {
::tracing::debug!(%error, "proxy packet TCP stream segment parse failed");
break;
}
};
if segment.flags & TCP_RST != 0 {
::tracing::debug!("proxy packet TCP stream received RST");
break;
}
let mut ack_increment = segment.payload.len() as u32;
if segment.flags & TCP_FIN != 0 {
ack_increment = ack_increment.wrapping_add(1);
}
if ack_increment > 0 {
peer_ack = segment.sequence.wrapping_add(ack_increment);
let ack = build_tcp_ipv4_packet(
local_ip,
remote_ip,
local_port,
remote_addr.port(),
client_seq,
peer_ack,
TCP_ACK,
&[],
);
if let Err(error) = outbound_tx.send(TunnelPacket { payload: ack }).await {
::tracing::debug!(%error, "proxy packet TCP stream ACK send failed");
return;
}
}
if !segment.payload.is_empty() {
if let Err(error) = app_writer.write_all(&segment.payload).await {
::tracing::debug!(%error, "proxy packet TCP stream app write failed");
break;
}
}
if segment.flags & TCP_FIN != 0 {
let _ = app_writer.shutdown().await;
break;
}
}
}
}
});
Ok((client_stream, local_addr))
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
async fn try_proxy_https_probe(
remote: &str,
host: Option<&str>,
dns_name: Option<&str>,
dns_server: &str,
timeout_ms: u64,
) -> Result<()> {
use std::{
net::{IpAddr, SocketAddr},
sync::{Arc, Once},
};
use anyhow::{bail, Context};
use rustls::{pki_types::ServerName, ClientConfig, RootCertStore};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
time::{timeout, Duration},
};
use tokio_rustls::TlsConnector;
static INSTALL_RUSTLS_PROVIDER: Once = Once::new();
INSTALL_RUSTLS_PROVIDER.call_once(|| {
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
});
let mut remote_addr: SocketAddr = remote
.parse()
.with_context(|| format!("invalid remote socket address {remote}"))?;
let tls_host = host
.or(dns_name)
.context("proxy HTTPS probe requires --host or --dns-name for TLS SNI")?
.to_owned();
if let Some(dns_name) = dns_name {
let resolved_ip = proxy_dns_probe(dns_name, dns_server, timeout_ms).await?;
remote_addr = SocketAddr::new(IpAddr::V4(resolved_ip), remote_addr.port());
println!("Proxy HTTPS Probe DNS Name: {dns_name}");
println!("Proxy HTTPS Probe DNS Resolved: {resolved_ip}");
}
if !remote_addr.is_ipv4() {
bail!("proxy HTTPS probe currently supports IPv4 targets only");
}
let mut root_store = RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let mut tls_config = ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
tls_config.alpn_protocols = vec![b"http/1.1".to_vec()];
let connector = TlsConnector::from(Arc::new(tls_config));
let server_name = ServerName::try_from(tls_host.clone())
.with_context(|| format!("invalid TLS server name {tls_host}"))?;
let (stream, local_addr) = open_proxy_packet_tcp_stream(remote_addr, timeout_ms).await?;
let mut tls_stream = timeout(
Duration::from_millis(timeout_ms),
connector.connect(server_name, stream),
)
.await
.with_context(|| format!("timed out waiting for TLS handshake with {tls_host}"))?
.with_context(|| format!("TLS handshake with {tls_host} failed"))?;
let request = format!(
"GET / HTTP/1.1\r\nHost: {tls_host}\r\nConnection: close\r\nUser-Agent: burrow-proxy-https-probe\r\n\r\n"
);
timeout(
Duration::from_millis(timeout_ms),
tls_stream.write_all(request.as_bytes()),
)
.await
.with_context(|| format!("timed out sending HTTPS request to {tls_host}"))?
.with_context(|| format!("failed to send HTTPS request to {tls_host}"))?;
let response = timeout(Duration::from_millis(timeout_ms), async {
let mut response = Vec::new();
let mut buf = [0u8; 8192];
loop {
let read = tls_stream
.read(&mut buf)
.await
.context("failed to read HTTPS response")?;
if read == 0 {
break;
}
response.extend_from_slice(&buf[..read]);
if response.iter().any(|byte| *byte == b'\n') {
break;
}
}
if response.is_empty() {
bail!("HTTPS response ended before returning bytes");
}
Ok::<_, anyhow::Error>(response)
})
.await
.with_context(|| format!("timed out waiting for HTTPS response from {tls_host}"))??;
let first_line = String::from_utf8_lossy(&response)
.lines()
.next()
.unwrap_or("")
.to_owned();
println!("Proxy HTTPS Probe Local: {local_addr}");
println!("Proxy HTTPS Probe Remote: {remote_addr}");
println!("Proxy HTTPS Probe Host: {tls_host}");
println!("Proxy HTTPS Probe Response Bytes: {}", response.len());
println!("Proxy HTTPS Probe First Line: {first_line}");
Ok(())
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
async fn try_proxy_subscription_select(id: i32, selected_ordinal: i32) -> Result<()> {
use crate::daemon::rpc::grpc_defs::ProxySubscriptionSelectRequest;
let mut client = BurrowClient::from_uds().await?;
let response = client
.proxy_subscription_client
.select_node(ProxySubscriptionSelectRequest { id, selected_ordinal })
.await?
.into_inner();
println!(
"Proxy Subscription Selected: id={} selected_ordinal={}",
response.id, response.selected_ordinal
);
Ok(())
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
fn select_tailnet_local_ip(
addresses: &[String],
@ -547,6 +1283,24 @@ struct IcmpEchoReply {
payload: Vec<u8>,
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
const TCP_FIN: u16 = 0x01;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
const TCP_SYN: u16 = 0x02;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
const TCP_RST: u16 = 0x04;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
const TCP_PSH: u16 = 0x08;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
const TCP_ACK: u16 = 0x10;
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
struct TcpSegment {
sequence: u32,
flags: u16,
payload: Vec<u8>,
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
fn build_icmp_echo_request(
source: std::net::IpAddr,
@ -643,6 +1397,113 @@ fn parse_icmp_echo_reply(
}))
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
fn build_tcp_ipv4_packet(
source: std::net::Ipv4Addr,
destination: std::net::Ipv4Addr,
source_port: u16,
destination_port: u16,
sequence: u32,
ack: u32,
flags: u16,
payload: &[u8],
) -> Vec<u8> {
let tcp_len = 20 + payload.len();
let total_len = 20 + tcp_len;
let mut packet = Vec::with_capacity(total_len);
packet.push(0x45);
packet.push(0);
packet.extend_from_slice(&(total_len as u16).to_be_bytes());
packet.extend_from_slice(&0u16.to_be_bytes());
packet.extend_from_slice(&0x4000u16.to_be_bytes());
packet.push(64);
packet.push(6);
packet.extend_from_slice(&[0, 0]);
packet.extend_from_slice(&source.octets());
packet.extend_from_slice(&destination.octets());
let header_checksum = internet_checksum(&packet);
packet[10..12].copy_from_slice(&header_checksum.to_be_bytes());
let tcp_start = packet.len();
packet.extend_from_slice(&source_port.to_be_bytes());
packet.extend_from_slice(&destination_port.to_be_bytes());
packet.extend_from_slice(&sequence.to_be_bytes());
packet.extend_from_slice(&ack.to_be_bytes());
packet.push(5 << 4);
packet.push((flags & 0xff) as u8);
packet.extend_from_slice(&64240u16.to_be_bytes());
packet.extend_from_slice(&[0, 0]);
packet.extend_from_slice(&0u16.to_be_bytes());
packet.extend_from_slice(payload);
let checksum = tcp_ipv4_checksum(source, destination, &packet[tcp_start..]);
packet[tcp_start + 16..tcp_start + 18].copy_from_slice(&checksum.to_be_bytes());
packet
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
fn parse_tcp_ipv4_segment(
packet: &[u8],
local_ip: std::net::Ipv4Addr,
remote_ip: std::net::Ipv4Addr,
local_port: u16,
remote_port: u16,
) -> Result<Option<TcpSegment>> {
use anyhow::bail;
if packet.len() < 40 {
return Ok(None);
}
let version = packet[0] >> 4;
if version != 4 {
return Ok(None);
}
let ihl = (packet[0] & 0x0f) as usize * 4;
if packet.len() < ihl + 20 || packet[9] != 6 {
return Ok(None);
}
let source = std::net::Ipv4Addr::new(packet[12], packet[13], packet[14], packet[15]);
let destination = std::net::Ipv4Addr::new(packet[16], packet[17], packet[18], packet[19]);
if source != remote_ip || destination != local_ip {
return Ok(None);
}
let tcp = &packet[ihl..];
let source_port = u16::from_be_bytes([tcp[0], tcp[1]]);
let destination_port = u16::from_be_bytes([tcp[2], tcp[3]]);
if source_port != remote_port || destination_port != local_port {
return Ok(None);
}
let data_offset = ((tcp[12] >> 4) as usize) * 4;
if data_offset < 20 || tcp.len() < data_offset {
bail!("invalid TCP data offset in proxy tcp probe response");
}
let sequence = u32::from_be_bytes([tcp[4], tcp[5], tcp[6], tcp[7]]);
let flags = (tcp[13] as u16) & 0x3f;
Ok(Some(TcpSegment {
sequence,
flags,
payload: tcp[data_offset..].to_vec(),
}))
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
fn tcp_ipv4_checksum(
source: std::net::Ipv4Addr,
destination: std::net::Ipv4Addr,
tcp_segment: &[u8],
) -> u16 {
let mut pseudo = Vec::with_capacity(12 + tcp_segment.len());
pseudo.extend_from_slice(&source.octets());
pseudo.extend_from_slice(&destination.octets());
pseudo.push(0);
pseudo.push(6);
pseudo.extend_from_slice(&(tcp_segment.len() as u16).to_be_bytes());
pseudo.extend_from_slice(tcp_segment);
internet_checksum(&pseudo)
}
#[cfg(any(target_os = "linux", target_vendor = "apple"))]
fn internet_checksum(bytes: &[u8]) -> u16 {
let mut sum = 0u32;
@ -775,6 +1636,32 @@ async fn main() -> Result<()> {
Commands::TailnetUdpEcho(args) => {
try_tailnet_udp_echo(&args.remote, &args.message, args.timeout_ms).await?
}
Commands::ProxyTcpProbe(args) => {
try_proxy_tcp_probe(
&args.remote,
&args.host,
args.dns_name.as_deref(),
&args.dns_server,
args.timeout_ms,
)
.await?
}
Commands::ProxyHttpsProbe(args) => {
try_proxy_https_probe(
&args.remote,
args.host.as_deref(),
args.dns_name.as_deref(),
&args.dns_server,
args.timeout_ms,
)
.await?
}
Commands::ProxySubscriptionSelect(args) => {
try_proxy_subscription_select(args.id, args.selected_ordinal).await?
}
Commands::ProxySubscriptionPreview(args) => {
try_proxy_subscription_preview(&args.url).await?
}
#[cfg(target_os = "linux")]
Commands::Exec(args) => {
try_exec(

3110
burrow/src/proxy_runtime.rs Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,927 @@
use std::{collections::HashMap, time::Duration};
use anyhow::{anyhow, bail, Context, Result};
use base64::{
engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD},
Engine as _,
};
use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_yaml::Value;
const MAX_SUBSCRIPTION_BYTES: usize = 1024 * 1024;
const SUBSCRIPTION_USER_AGENT: &str = "mihomo/1.18.3";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProxySubscriptionPayload {
pub name: String,
pub source: ProxySubscriptionSource,
pub detected_format: ProxySubscriptionFormat,
pub selected_ordinal: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selected_name: Option<String>,
pub nodes: Vec<ProxyNode>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProxySubscriptionSource {
pub url: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum ProxySubscriptionFormat {
ClashYaml,
UriList,
}
impl ProxySubscriptionFormat {
pub fn as_str(self) -> &'static str {
match self {
Self::ClashYaml => "clash-yaml",
Self::UriList => "uri-list",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProxyNode {
pub ordinal: usize,
pub name: String,
pub protocol: ProxyProtocol,
pub server: String,
pub port: u16,
pub warnings: Vec<String>,
pub config: ProxyNodeConfig,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ProxyProtocol {
Trojan,
Shadowsocks,
}
impl ProxyProtocol {
pub fn as_str(self) -> &'static str {
match self {
Self::Trojan => "trojan",
Self::Shadowsocks => "ss",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ProxyNodeConfig {
Trojan(TrojanNode),
Shadowsocks(ShadowsocksNode),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrojanNode {
pub password: String,
pub sni: Option<String>,
pub alpn: Vec<String>,
#[serde(default)]
pub alpn_present: bool,
pub skip_cert_verify: bool,
pub network: TrojanNetwork,
#[serde(default = "default_true")]
pub udp: bool,
pub client_fingerprint: Option<String>,
pub fingerprint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TrojanNetwork {
Tcp,
Ws {
path: Option<String>,
host: Option<String>,
},
Grpc {
service_name: Option<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ShadowsocksNode {
pub cipher: String,
pub password: String,
pub udp: bool,
pub udp_over_tcp: bool,
pub plugin: Option<ShadowsocksPlugin>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ShadowsocksPlugin {
pub name: String,
pub opts: HashMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxySubscriptionPreview {
pub suggested_name: String,
pub detected_format: ProxySubscriptionFormat,
pub nodes: Vec<ProxyNode>,
pub rejected: Vec<ProxyRejectedEntry>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyRejectedEntry {
pub ordinal: usize,
pub scheme: Option<String>,
pub reason: String,
}
#[derive(Debug, Deserialize)]
struct ProxySchema {
proxies: Option<Vec<HashMap<String, Value>>>,
}
pub async fn fetch_subscription(url: &str) -> Result<String> {
let redacted_url = redacted_subscription_url(url);
let client = reqwest::Client::builder()
.user_agent(SUBSCRIPTION_USER_AGENT)
.redirect(reqwest::redirect::Policy::limited(5))
.timeout(Duration::from_secs(20))
.build()?;
let response = client
.get(url)
.send()
.await
.with_context(|| format!("failed to fetch {redacted_url}"))?;
let status = response.status();
let body = response
.bytes()
.await
.with_context(|| format!("failed to read subscription body from {redacted_url}"))?;
if !status.is_success() {
bail!(
"subscription request failed for {}: HTTP {}{}",
redacted_url,
status.as_u16(),
subscription_error_body(&body)
);
}
if body.len() > MAX_SUBSCRIPTION_BYTES {
bail!("subscription body exceeds {} bytes", MAX_SUBSCRIPTION_BYTES);
}
Ok(String::from_utf8_lossy(&body).into_owned())
}
fn redacted_subscription_url(raw: &str) -> String {
let Ok(mut url) = Url::parse(raw) else {
return "<invalid subscription URL>".to_owned();
};
if !url.username().is_empty() {
let _ = url.set_username("REDACTED");
}
if url.password().is_some() {
let _ = url.set_password(Some("REDACTED"));
}
if url.query().is_some() {
url.set_query(Some("REDACTED"));
}
if url.fragment().is_some() {
url.set_fragment(Some("REDACTED"));
}
url.to_string()
}
fn subscription_error_body(body: &[u8]) -> String {
let excerpt = String::from_utf8_lossy(&body[..body.len().min(160)])
.chars()
.map(|ch| if ch.is_control() { ' ' } else { ch })
.collect::<String>()
.trim()
.to_owned();
if excerpt.is_empty() {
String::new()
} else {
format!(": {excerpt}")
}
}
pub fn build_payload(
preview: ProxySubscriptionPreview,
source_url: &str,
name: Option<String>,
selected_ordinal: Option<usize>,
) -> ProxySubscriptionPayload {
let selected_name = selected_ordinal.and_then(|ordinal| {
preview
.nodes
.iter()
.find(|node| node.ordinal == ordinal)
.map(|node| node.name.clone())
});
ProxySubscriptionPayload {
name: name
.and_then(|value| non_empty(value.trim()).map(ToOwned::to_owned))
.unwrap_or_else(|| preview.suggested_name.clone()),
source: ProxySubscriptionSource { url: source_url.to_owned() },
detected_format: preview.detected_format,
selected_ordinal,
selected_name,
nodes: preview.nodes,
warnings: preview.warnings,
}
}
pub fn validate_payload(payload: &ProxySubscriptionPayload) -> Result<()> {
if payload.name.trim().is_empty() {
bail!("proxy subscription name must not be empty");
}
if payload.nodes.is_empty() {
bail!("proxy subscription must include at least one compatible node");
}
if let Some(selected) = payload.selected_ordinal {
if !payload.nodes.iter().any(|node| node.ordinal == selected) {
bail!("selected proxy node ordinal {selected} is not present in subscription");
}
}
if let Some(selected) = &payload.selected_name {
if !payload.nodes.iter().any(|node| node.name == *selected) {
bail!("selected proxy node {selected} is not present in subscription");
}
}
for node in &payload.nodes {
validate_node(node)?;
}
Ok(())
}
pub fn parse_subscription_body(body: &str, source_url: Option<&str>) -> ProxySubscriptionPreview {
match parse_yaml_subscription(body, source_url) {
Ok(preview) => preview,
Err(yaml_error) => {
let mut preview = parse_uri_subscription(body, source_url);
if preview.nodes.is_empty() && preview.rejected.is_empty() {
preview.warnings.push(format!(
"subscription is not Mihomo YAML and did not contain proxy URIs: {yaml_error}"
));
}
preview
}
}
}
pub fn summarize_payload(payload: &ProxySubscriptionPayload) -> String {
let selected = payload
.selected_name
.as_deref()
.and_then(|name| payload.nodes.iter().find(|node| node.name == name))
.or_else(|| {
payload
.selected_ordinal
.and_then(|ordinal| payload.nodes.iter().find(|node| node.ordinal == ordinal))
})
.or_else(|| payload.nodes.first());
match selected {
Some(node) => format!(
"{} - {} {}:{} ({} nodes)",
payload.name,
node.protocol.as_str(),
node.server,
node.port,
payload.nodes.len()
),
None => format!("{} - no compatible nodes", payload.name),
}
}
fn parse_yaml_subscription(
body: &str,
source_url: Option<&str>,
) -> Result<ProxySubscriptionPreview> {
let schema: ProxySchema =
serde_yaml::from_str(body).context("subscription is not proxy YAML")?;
let proxies = schema
.proxies
.ok_or_else(|| anyhow!("YAML subscription must include a `proxies` field"))?;
let mut nodes = Vec::new();
let mut rejected = Vec::new();
let mut warnings = Vec::new();
for (ordinal, mapping) in proxies.into_iter().enumerate() {
let scheme = yaml_string(&mapping, "type").map(str::to_owned);
match parse_yaml_proxy(ordinal, mapping) {
Ok(node) => nodes.push(node),
Err(error) => rejected.push(ProxyRejectedEntry {
ordinal,
scheme,
reason: error.to_string(),
}),
}
}
if nodes.is_empty() {
warnings.push("no compatible Trojan or Shadowsocks nodes found in YAML".to_owned());
}
Ok(ProxySubscriptionPreview {
suggested_name: suggested_name(source_url),
detected_format: ProxySubscriptionFormat::ClashYaml,
nodes,
rejected,
warnings,
})
}
fn parse_uri_subscription(body: &str, source_url: Option<&str>) -> ProxySubscriptionPreview {
let decoded = decode_subscription_body(body).unwrap_or_else(|| body.to_owned());
let mut nodes = Vec::new();
let mut rejected = Vec::new();
for (ordinal, raw) in decoded
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.enumerate()
{
let scheme = raw
.split_once("://")
.map(|(scheme, _)| scheme.to_ascii_lowercase());
let parsed = match scheme.as_deref() {
Some("trojan") => parse_trojan_uri(raw, ordinal),
Some("ss") => parse_shadowsocks_uri(raw, ordinal),
Some(other) => Err(anyhow!("unsupported proxy URI scheme {other}")),
None => Err(anyhow!("missing proxy URI scheme")),
};
match parsed {
Ok(node) => nodes.push(node),
Err(error) => rejected.push(ProxyRejectedEntry {
ordinal,
scheme,
reason: error.to_string(),
}),
}
}
ProxySubscriptionPreview {
suggested_name: suggested_name(source_url),
detected_format: ProxySubscriptionFormat::UriList,
nodes,
rejected,
warnings: Vec::new(),
}
}
fn parse_yaml_proxy(ordinal: usize, mapping: HashMap<String, Value>) -> Result<ProxyNode> {
let proxy_type = required_yaml_string(&mapping, "type")?.to_ascii_lowercase();
match proxy_type.as_str() {
"trojan" => parse_yaml_trojan(ordinal, &mapping),
"ss" | "shadowsocks" => parse_yaml_shadowsocks(ordinal, &mapping),
_ => bail!("unsupported proxy type {proxy_type}"),
}
}
fn parse_yaml_trojan(ordinal: usize, mapping: &HashMap<String, Value>) -> Result<ProxyNode> {
let name = required_yaml_string(mapping, "name")?.to_owned();
let server = required_yaml_string(mapping, "server")?.to_owned();
let port = required_yaml_u16(mapping, "port")?;
let password = required_yaml_string(mapping, "password")?.to_owned();
let alpn = yaml_string(mapping, "alpn")
.map(split_csv)
.or_else(|| yaml_string_vec(mapping, "alpn"))
.unwrap_or_default();
let alpn_present = mapping.contains_key("alpn");
let skip_cert_verify = yaml_bool(mapping, "skip-cert-verify").unwrap_or(false);
let network = match yaml_string(mapping, "network")
.unwrap_or("tcp")
.to_ascii_lowercase()
.as_str()
{
"tcp" | "" => TrojanNetwork::Tcp,
"ws" => {
let ws_opts = yaml_mapping(mapping, "ws-opts");
let host = ws_opts
.as_ref()
.and_then(|opts| yaml_mapping(opts, "headers"))
.and_then(|headers| {
yaml_string(&headers, "Host")
.or_else(|| yaml_string(&headers, "host"))
.map(ToOwned::to_owned)
});
TrojanNetwork::Ws {
path: ws_opts
.as_ref()
.and_then(|opts| yaml_string(opts, "path").map(ToOwned::to_owned)),
host,
}
}
"grpc" => {
let grpc_opts = yaml_mapping(mapping, "grpc-opts");
TrojanNetwork::Grpc {
service_name: grpc_opts
.as_ref()
.and_then(|opts| yaml_string(opts, "grpc-service-name"))
.map(ToOwned::to_owned),
}
}
other => bail!("unsupported Trojan network {other}"),
};
Ok(ProxyNode {
ordinal,
name,
protocol: ProxyProtocol::Trojan,
server,
port,
warnings: Vec::new(),
config: ProxyNodeConfig::Trojan(TrojanNode {
password,
sni: yaml_string(mapping, "sni").map(ToOwned::to_owned),
alpn,
alpn_present,
skip_cert_verify,
network,
udp: yaml_bool(mapping, "udp").unwrap_or(false),
client_fingerprint: yaml_string(mapping, "client-fingerprint").map(ToOwned::to_owned),
fingerprint: yaml_string(mapping, "fingerprint").map(ToOwned::to_owned),
}),
})
}
fn parse_yaml_shadowsocks(ordinal: usize, mapping: &HashMap<String, Value>) -> Result<ProxyNode> {
let name = required_yaml_string(mapping, "name")?.to_owned();
let server = required_yaml_string(mapping, "server")?.to_owned();
let port = required_yaml_u16(mapping, "port")?;
let cipher = required_yaml_string(mapping, "cipher")?.to_owned();
let password = required_yaml_string(mapping, "password")?.to_owned();
Ok(ProxyNode {
ordinal,
name,
protocol: ProxyProtocol::Shadowsocks,
server,
port,
warnings: Vec::new(),
config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode {
cipher,
password,
udp: yaml_bool(mapping, "udp").unwrap_or(true),
udp_over_tcp: yaml_bool(mapping, "udp-over-tcp").unwrap_or(false),
plugin: yaml_plugin(mapping),
}),
})
}
fn parse_trojan_uri(uri: &str, ordinal: usize) -> Result<ProxyNode> {
let url = Url::parse(uri).context("invalid Trojan URI")?;
let server = url
.host_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow!("missing Trojan server host"))?;
let port = url
.port()
.ok_or_else(|| anyhow!("missing Trojan server port"))?;
let password = percent_decode(url.username())?;
if password.is_empty() {
bail!("missing Trojan password");
}
let query = url.query_pairs().collect::<HashMap<_, _>>();
let skip_cert_verify = query
.get("allowInsecure")
.or_else(|| query.get("skip-cert-verify"))
.or_else(|| query.get("skipCertVerify"))
.map(|value| is_truthy(value))
.unwrap_or(false);
let network = query
.get("type")
.or_else(|| query.get("network"))
.map(|value| value.to_ascii_lowercase())
.unwrap_or_else(|| "tcp".to_owned());
let trojan_network = match network.as_str() {
"" | "tcp" => TrojanNetwork::Tcp,
"ws" => TrojanNetwork::Ws {
path: query
.get("path")
.and_then(|value| non_empty(value).map(ToOwned::to_owned)),
host: query
.get("host")
.or_else(|| query.get("Host"))
.and_then(|value| non_empty(value).map(ToOwned::to_owned)),
},
"grpc" => TrojanNetwork::Grpc {
service_name: query
.get("serviceName")
.or_else(|| query.get("grpc-service-name"))
.and_then(|value| non_empty(value).map(ToOwned::to_owned)),
},
other => bail!("unsupported Trojan network {other}"),
};
let fingerprint = query
.get("fp")
.and_then(|value| non_empty(value).map(ToOwned::to_owned))
.or_else(|| Some("chrome".to_owned()));
let alpn = query
.get("alpn")
.and_then(|value| non_empty(value).map(split_csv))
.unwrap_or_default();
let alpn_present = !alpn.is_empty();
Ok(ProxyNode {
ordinal,
name: uri_fragment_name(&url, "Proxy Node", ordinal)?,
protocol: ProxyProtocol::Trojan,
server,
port,
warnings: Vec::new(),
config: ProxyNodeConfig::Trojan(TrojanNode {
password,
sni: query
.get("sni")
.or_else(|| query.get("peer"))
.or_else(|| query.get("host"))
.and_then(|value| non_empty(value).map(ToOwned::to_owned)),
alpn,
alpn_present,
skip_cert_verify,
network: trojan_network,
udp: true,
client_fingerprint: fingerprint,
fingerprint: query
.get("pcs")
.and_then(|value| non_empty(value).map(ToOwned::to_owned)),
}),
})
}
fn parse_shadowsocks_uri(uri: &str, ordinal: usize) -> Result<ProxyNode> {
let mut url = Url::parse(uri).context("invalid Shadowsocks URI")?;
if url.port().is_none() {
let decoded_host = decode_base64_segment(url.host_str().unwrap_or_default())
.context("invalid legacy Shadowsocks host encoding")?;
url = Url::parse(&format!("ss://{decoded_host}"))
.context("invalid decoded legacy Shadowsocks URI")?;
}
let server = url
.host_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow!("missing Shadowsocks server host"))?;
let port = url
.port()
.ok_or_else(|| anyhow!("missing Shadowsocks server port"))?;
let user = url.username();
let (cipher, password) = match url.password() {
Some(password) => (user.to_owned(), password.to_owned()),
None => decode_base64_segment(user)
.ok()
.and_then(|decoded| {
decoded
.split_once(':')
.map(|(a, b)| (a.to_owned(), b.to_owned()))
})
.ok_or_else(|| anyhow!("missing Shadowsocks cipher/password"))?,
};
let query = url.query_pairs().collect::<HashMap<_, _>>();
let udp_over_tcp = query
.get("udp-over-tcp")
.map(|value| value == "true")
.unwrap_or(false)
|| query.get("uot").map(|value| value == "1").unwrap_or(false);
Ok(ProxyNode {
ordinal,
name: uri_fragment_name(&url, "Proxy Node", ordinal)?,
protocol: ProxyProtocol::Shadowsocks,
server,
port,
warnings: Vec::new(),
config: ProxyNodeConfig::Shadowsocks(ShadowsocksNode {
cipher,
password,
udp: true,
udp_over_tcp,
plugin: query.get("plugin").and_then(|value| parse_ss_plugin(value)),
}),
})
}
fn validate_node(node: &ProxyNode) -> Result<()> {
if node.name.trim().is_empty() {
bail!("proxy node name must not be empty");
}
if node.server.trim().is_empty() {
bail!("proxy node server must not be empty");
}
match &node.config {
ProxyNodeConfig::Trojan(config) if config.password.is_empty() => {
bail!("Trojan node password must not be empty")
}
ProxyNodeConfig::Shadowsocks(config)
if config.cipher.is_empty() || config.password.is_empty() =>
{
bail!("Shadowsocks node cipher and password must not be empty")
}
_ => Ok(()),
}
}
fn yaml_string<'a>(mapping: &'a HashMap<String, Value>, key: &str) -> Option<&'a str> {
mapping.get(key).and_then(Value::as_str)
}
fn yaml_string_vec(mapping: &HashMap<String, Value>, key: &str) -> Option<Vec<String>> {
let values = mapping.get(key)?.as_sequence()?;
Some(
values
.iter()
.filter_map(Value::as_str)
.map(ToOwned::to_owned)
.collect(),
)
}
fn yaml_mapping<'a>(
mapping: &'a HashMap<String, Value>,
key: &str,
) -> Option<HashMap<String, Value>> {
mapping.get(key)?.as_mapping().and_then(|map| {
map.iter()
.map(|(key, value)| Some((key.as_str()?.to_owned(), value.clone())))
.collect::<Option<HashMap<_, _>>>()
})
}
fn required_yaml_string<'a>(mapping: &'a HashMap<String, Value>, key: &str) -> Result<&'a str> {
yaml_string(mapping, key).ok_or_else(|| anyhow!("missing `{key}`"))
}
fn required_yaml_u16(mapping: &HashMap<String, Value>, key: &str) -> Result<u16> {
if let Some(value) = mapping.get(key).and_then(Value::as_u64) {
return u16::try_from(value).with_context(|| format!("invalid `{key}`"));
}
let value = required_yaml_string(mapping, key)?;
value
.parse::<u16>()
.with_context(|| format!("invalid `{key}`"))
}
fn yaml_bool(mapping: &HashMap<String, Value>, key: &str) -> Option<bool> {
mapping
.get(key)
.and_then(Value::as_bool)
.or_else(|| yaml_string(mapping, key).map(is_truthy))
}
fn yaml_plugin(mapping: &HashMap<String, Value>) -> Option<ShadowsocksPlugin> {
let name = yaml_string(mapping, "plugin")?.to_owned();
let opts = yaml_mapping(mapping, "plugin-opts")
.map(|opts| {
opts.iter()
.filter_map(|(key, value)| {
value
.as_str()
.map(|value| (key.clone(), value.to_owned()))
.or_else(|| {
value
.as_bool()
.map(|value| (key.clone(), value.to_string()))
})
})
.collect()
})
.unwrap_or_default();
Some(ShadowsocksPlugin { name, opts })
}
fn parse_ss_plugin(plugin: &str) -> Option<ShadowsocksPlugin> {
let (name, rest) = plugin.split_once(';').unwrap_or((plugin, ""));
let mut opts = HashMap::new();
for part in rest.split(';').filter(|part| !part.is_empty()) {
if let Some((key, value)) = part.split_once('=') {
opts.insert(key.to_owned(), value.to_owned());
}
}
non_empty(name).map(|name| ShadowsocksPlugin { name: name.to_owned(), opts })
}
fn decode_subscription_body(body: &str) -> Option<String> {
let compact: String = body.chars().filter(|ch| !ch.is_whitespace()).collect();
decode_base64_segment(&compact).ok()
}
fn decode_base64_segment(input: &str) -> Result<String> {
if let Ok(decoded) = STANDARD_NO_PAD.decode(input.as_bytes()) {
return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8");
}
if let Ok(decoded) = STANDARD.decode(input.as_bytes()) {
return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8");
}
if let Ok(decoded) = URL_SAFE_NO_PAD.decode(input.as_bytes()) {
return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8");
}
if let Ok(decoded) = URL_SAFE.decode(input.as_bytes()) {
return String::from_utf8(decoded).context("base64 decoded text is not valid UTF-8");
}
bail!("invalid base64 text")
}
fn uri_fragment_name(url: &Url, fallback: &str, ordinal: usize) -> Result<String> {
Ok(url
.fragment()
.map(percent_decode)
.transpose()?
.and_then(|fragment| non_empty(&fragment).map(ToOwned::to_owned))
.or_else(|| non_empty(url.host_str().unwrap_or_default()).map(ToOwned::to_owned))
.unwrap_or_else(|| format!("{fallback} {}", ordinal + 1)))
}
fn suggested_name(source_url: Option<&str>) -> String {
source_url
.and_then(|url| Url::parse(url).ok())
.and_then(|url| url.host_str().map(ToOwned::to_owned))
.unwrap_or_else(|| "Proxy Subscription".to_owned())
}
fn split_csv(value: &str) -> Vec<String> {
value
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.collect()
}
fn is_truthy(value: impl AsRef<str>) -> bool {
matches!(
value.as_ref().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "y"
)
}
fn default_true() -> bool {
true
}
fn non_empty(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
fn percent_decode(input: &str) -> Result<String> {
let mut out = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
bail!("invalid percent encoding");
}
let hi = hex_value(bytes[i + 1]).ok_or_else(|| anyhow!("invalid percent encoding"))?;
let lo = hex_value(bytes[i + 2]).ok_or_else(|| anyhow!("invalid percent encoding"))?;
out.push((hi << 4) | lo);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
String::from_utf8(out).context("percent-decoded text is not valid UTF-8")
}
fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_base64_uri_list_with_trojan_and_shadowsocks() {
let body = STANDARD.encode(
"trojan://secret@example.com:443?security=tls&sni=front.example&type=grpc&serviceName=edge&allowInsecure=1&fp=random#US%20Trojan\nss://YWVzLTEyOC1nY206cGFzcw@example.net:8388?uot=1#SS%20Node\n",
);
let preview = parse_subscription_body(&body, Some("https://sub.example/path?token=secret"));
assert_eq!(preview.detected_format, ProxySubscriptionFormat::UriList);
assert_eq!(preview.nodes.len(), 2);
assert_eq!(preview.rejected.len(), 0);
assert_eq!(preview.nodes[0].name, "US Trojan");
assert!(preview.nodes[0].warnings.is_empty());
let ProxyNodeConfig::Trojan(trojan) = &preview.nodes[0].config else {
panic!("expected Trojan node");
};
assert!(!trojan.alpn_present);
assert_eq!(trojan_alpn_for_test(trojan), Vec::<String>::new());
assert!(trojan.udp);
assert_eq!(preview.nodes[1].protocol, ProxyProtocol::Shadowsocks);
}
#[test]
fn parses_mihomo_yaml_proxies() {
let preview = parse_subscription_body(
r#"
proxies:
- name: trojan-ws
type: trojan
server: example.com
port: 443
password: secret
sni: edge.example.com
network: ws
ws-opts:
path: /ws
headers:
Host: edge.example.com
- name: ss
type: ss
server: example.net
port: 8388
cipher: aes-128-gcm
password: pass
"#,
None,
);
assert_eq!(preview.detected_format, ProxySubscriptionFormat::ClashYaml);
assert_eq!(preview.nodes.len(), 2);
assert_eq!(preview.rejected.len(), 0);
let ProxyNodeConfig::Trojan(trojan) = &preview.nodes[0].config else {
panic!("expected Trojan node");
};
assert!(!trojan.alpn_present);
assert!(!trojan.udp);
}
#[test]
fn tracks_explicit_trojan_alpn_presence() {
let preview = parse_subscription_body(
r#"
proxies:
- name: trojan-empty-alpn
type: trojan
server: example.com
port: 443
password: secret
alpn: []
- name: trojan-custom-alpn
type: trojan
server: example.net
port: 443
password: secret
alpn:
- h3
"#,
None,
);
let ProxyNodeConfig::Trojan(empty_alpn) = &preview.nodes[0].config else {
panic!("expected Trojan node");
};
assert!(empty_alpn.alpn_present);
assert!(empty_alpn.alpn.is_empty());
let ProxyNodeConfig::Trojan(custom_alpn) = &preview.nodes[1].config else {
panic!("expected Trojan node");
};
assert!(custom_alpn.alpn_present);
assert_eq!(custom_alpn.alpn, vec!["h3"]);
}
#[test]
fn build_payload_records_selected_node_name() {
let preview = parse_subscription_body(
"ss://YWVzLTEyOC1nY206cGFzcw@example.net:8388#SS%20Node\n",
Some("https://sub.example/path"),
);
let payload = build_payload(
preview,
"https://sub.example/path",
Some("sub".to_owned()),
Some(0),
);
assert_eq!(payload.selected_ordinal, Some(0));
assert_eq!(payload.selected_name.as_deref(), Some("SS Node"));
}
#[test]
fn redacts_subscription_url_secrets_for_errors() {
let redacted = redacted_subscription_url(
"https://user:password@sub.example/path?token=secret&user=alice#fragment",
);
assert_eq!(
redacted,
"https://REDACTED:REDACTED@sub.example/path?REDACTED#REDACTED"
);
assert!(!redacted.contains("password"));
assert!(!redacted.contains("token=secret"));
assert!(!redacted.contains("fragment"));
}
fn trojan_alpn_for_test(node: &TrojanNode) -> Vec<String> {
node.alpn.clone()
}
}

View file

@ -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()
}
}

View 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.

View file

@ -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

View file

@ -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`

View file

@ -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 AEAD nodes are dialed directly for TCP and UDP using SIP004-style salt, HKDF subkeys, and AEAD chunks/datagrams.
- 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`

View 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`

View file

@ -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,61 @@ message Network {
enum NetworkType {
WireGuard = 0;
Tailnet = 1;
Trojan = 2;
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 +194,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 {