Compare commits

..

No commits in common. "a7cf38c382d546b4a84d1ee6c0d365a4784d3444" and "97c569fb35688a5b89f0a20f938a7bb6d1afe8d7" have entirely different histories.

56 changed files with 591 additions and 34053 deletions

View file

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

View file

@ -15,10 +15,9 @@ EXCLUDED_SOURCE_FILE_NAMES = MainMenu.xib
EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*] = EXCLUDED_SOURCE_FILE_NAMES[sdk=macosx*] =
INFOPLIST_KEY_LSUIElement[sdk=macosx*] = YES INFOPLIST_KEY_LSUIElement[sdk=macosx*] = YES
INFOPLIST_KEY_NSMainNibFile[sdk=macosx*] = MainMenu
INFOPLIST_KEY_NSPrincipalClass[sdk=macosx*] = NSApplication INFOPLIST_KEY_NSPrincipalClass[sdk=macosx*] = NSApplication
INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*] = public.app-category.utilities INFOPLIST_KEY_LSApplicationCategoryType[sdk=macosx*] = public.app-category.utilities
CODE_SIGN_ENTITLEMENTS = App/App-iOS.entitlements CODE_SIGN_ENTITLEMENTS = App/App-iOS.entitlements
CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = App/App-macOS.entitlements CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = App/App-macOS.entitlements
SWIFT_INCLUDE_PATHS = $(inherited) $(PROJECT_DIR)/NetworkExtension

View file

@ -1,17 +1,23 @@
#if os(macOS) #if os(macOS)
import AppKit import AppKit
import BurrowConfiguration
import BurrowCore
import BurrowUI import BurrowUI
import SwiftUI import SwiftUI
import libburrow
@main
@MainActor @MainActor
class AppDelegate: NSObject, NSApplicationDelegate { class AppDelegate: NSObject, NSApplicationDelegate {
private var windowController: NSWindowController? private var windowController: NSWindowController?
private let logger = Logger.logger(for: AppDelegate.self)
private let quitItem = AppDelegate.makeQuitItem() 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 lazy var openItem: NSMenuItem = { private lazy var openItem: NSMenuItem = {
let item = NSMenuItem( let item = NSMenuItem(
@ -55,85 +61,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
}() }()
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
installMainMenu()
startDaemon()
statusItem.menu = menu 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 @objc
private func openWindow() { private func openWindow() {
if let window = windowController?.window { if let window = windowController?.window {
@ -144,13 +74,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let contentView = BurrowView() let contentView = BurrowView()
let hostingController = NSHostingController(rootView: contentView) let hostingController = NSHostingController(rootView: contentView)
if #available(macOS 13.0, *) {
hostingController.sizingOptions = []
}
let window = NSWindow(contentViewController: hostingController) let window = NSWindow(contentViewController: hostingController)
window.title = "Burrow" window.title = "Burrow"
window.setContentSize(NSSize(width: 820, height: 720)) window.setContentSize(NSSize(width: 820, height: 720))
window.contentMinSize = NSSize(width: 640, height: 560)
window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable]) window.styleMask.insert([.titled, .closable, .miniaturizable, .resizable])
window.center() window.center()
@ -161,19 +87,4 @@ class AppDelegate: NSObject, NSApplicationDelegate {
NSApplication.shared.activate(ignoringOtherApps: true) 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 #endif

View file

@ -1,8 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct"> <document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="23091" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies> <dependencies>
<deployment identifier="macosx"/> <plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="23091"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
</dependencies> </dependencies>
<objects> <objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication"> <customObject id="-2" userLabel="File's Owner" customClass="NSApplication">

View file

@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
D00AA8972A4669BC005C8102 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D00AA8962A4669BC005C8102 /* AppDelegate.swift */; }; 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 */; }; 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, ); }; }; 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 */; }; D03383AD2C8E67E300F7C44E /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = D078F7E22C8DA375008A8CEC /* SwiftProtobuf */; };
@ -18,7 +19,6 @@
D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; platformFilters = (macos, ); }; D09150422B9D2AF700BE3CB0 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = D09150412B9D2AF700BE3CB0 /* MainMenu.xib */; platformFilters = (macos, ); };
D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D0B1D10F2C436152004B7823 /* AsyncAlgorithms */; }; D0B1D1102C436152004B7823 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = D0B1D10F2C436152004B7823 /* AsyncAlgorithms */; };
D0BCC6092A09A03E00AD070D /* libburrow.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D0BCC6032A09535900AD070D /* libburrow.a */; }; 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 */; }; D0BF09522C8E66F6000D8DEC /* BurrowConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; };
D0BF09552C8E66FD000D8DEC /* 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, ); }; }; D0D4E53A2C8D996F007F820A /* BurrowCore.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@ -43,14 +43,20 @@
D0D4E5A62C8D9E65007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; }; D0D4E5A62C8D9E65007F820A /* BurrowCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D0D4E5312C8D996F007F820A /* BurrowCore.framework */; };
D0F4FAD32C8DC79C0068730A /* 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 */; }; 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 */; }; D0F7597E2C8DB30500126CF3 /* CGRPCZlib in Frameworks */ = {isa = PBXBuildFile; productRef = D0F7597D2C8DB30500126CF3 /* CGRPCZlib */; };
D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0D4E4992C8D921A007F820A /* Client.swift */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
D11000022F70000100112233 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D05B9F7129E39EEC008CB1F9;
remoteInfo = App;
};
D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = { D020F65B29E4A697002790F6 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */; containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */;
@ -100,13 +106,6 @@
remoteGlobalIDString = D0D4E5302C8D996F007F820A; remoteGlobalIDString = D0D4E5302C8D996F007F820A;
remoteInfo = Core; remoteInfo = Core;
}; };
D11000022F70000100112233 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = D05B9F6A29E39EEC008CB1F9 /* Project object */;
proxyType = 1;
remoteGlobalIDString = D05B9F7129E39EEC008CB1F9;
remoteInfo = App;
};
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
@ -139,6 +138,9 @@
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
D00117422B30348D00D87C25 /* Configuration.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Configuration.xcconfig; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; }; D020F64229E4A1FF002790F6 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@ -186,14 +188,18 @@
D0D4E58E2C8D9D0A007F820A /* Constants.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Constants.h; sourceTree = "<group>"; }; 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>"; }; 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>"; }; D0D4E5902C8D9D0A007F820A /* module.modulemap */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.module-map"; path = module.modulemap; sourceTree = "<group>"; };
D0FA10032D10200100112233 /* Generated/burrow.pb.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated/burrow.pb.swift; sourceTree = "<group>"; }; D0FA10032D10200100112233 /* 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>"; }; D0FA10042D10200100112233 /* 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 */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
D11000062F70000100112233 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D020F65029E4A697002790F6 /* Frameworks */ = { D020F65029E4A697002790F6 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -212,7 +218,6 @@
D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */, D0BF09552C8E66FD000D8DEC /* BurrowConfiguration.framework in Frameworks */,
D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */, D0F4FAD32C8DC79C0068730A /* BurrowCore.framework in Frameworks */,
D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */, D0D4E5892C8D9C94007F820A /* BurrowUI.framework in Frameworks */,
D0BCC60C2A09A0C200AD070D /* libburrow.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -237,13 +242,6 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D11000062F70000100112233 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -326,6 +324,14 @@
path = App; path = App;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D11000072F70000100112233 /* AppUITests */ = {
isa = PBXGroup;
children = (
D11000042F70000100112233 /* BurrowUITests.swift */,
);
path = AppUITests;
sourceTree = "<group>";
};
D0B98FD729FDDB57004E7149 /* libburrow */ = { D0B98FD729FDDB57004E7149 /* libburrow */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -340,8 +346,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
D0D4E4952C8D921A007F820A /* burrow.proto */, D0D4E4952C8D921A007F820A /* burrow.proto */,
D0FA10032D10200100112233 /* Generated/burrow.pb.swift */, D0FA10032D10200100112233 /* burrow.pb.swift */,
D0FA10042D10200100112233 /* Generated/burrow.grpc.swift */, D0FA10042D10200100112233 /* burrow.grpc.swift */,
); );
path = Client; path = Client;
sourceTree = "<group>"; sourceTree = "<group>";
@ -395,17 +401,27 @@
path = Constants; path = Constants;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
D11000072F70000100112233 /* AppUITests */ = {
isa = PBXGroup;
children = (
D11000042F70000100112233 /* BurrowUITests.swift */,
);
path = AppUITests;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget 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 */ = { D020F65229E4A697002790F6 /* NetworkExtension */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */; buildConfigurationList = D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */;
@ -511,24 +527,6 @@
productReference = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */; productReference = D0D4E5622C8D9BF4007F820A /* BurrowConfiguration.framework */;
productType = "com.apple.product-type.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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@ -539,54 +537,19 @@
LastSwiftUpdateCheck = 1600; LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1520; LastUpgradeCheck = 1520;
TargetAttributes = { TargetAttributes = {
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 = { D11000082F70000100112233 = {
CreatedOnToolsVersion = 16.0; CreatedOnToolsVersion = 16.0;
TestTargetID = D05B9F7129E39EEC008CB1F9; TestTargetID = D05B9F7129E39EEC008CB1F9;
}; };
D020F65229E4A697002790F6 = {
CreatedOnToolsVersion = 14.3;
};
D05B9F7129E39EEC008CB1F9 = {
CreatedOnToolsVersion = 14.3;
};
D0D4E5302C8D996F007F820A = {
CreatedOnToolsVersion = 16.0;
};
}; };
}; };
buildConfigurationList = D05B9F6D29E39EEC008CB1F9 /* Build configuration list for PBXProject "Burrow" */; buildConfigurationList = D05B9F6D29E39EEC008CB1F9 /* Build configuration list for PBXProject "Burrow" */;
@ -620,6 +583,13 @@
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
D11000092F70000100112233 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
D05B9F7029E39EEC008CB1F9 /* Resources */ = { D05B9F7029E39EEC008CB1F9 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -635,13 +605,6 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D11000092F70000100112233 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */
@ -690,6 +653,14 @@
/* End PBXShellScriptBuildPhase section */ /* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
D110000A2F70000100112233 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D11000012F70000100112233 /* BurrowUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
D020F64F29E4A697002790F6 /* Sources */ = { D020F64F29E4A697002790F6 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -711,8 +682,8 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
D0FA10012D10200100112233 /* Generated/burrow.pb.swift in Sources */, D0FA10012D10200100112233 /* burrow.pb.swift in Sources */,
D0FA10022D10200100112233 /* Generated/burrow.grpc.swift in Sources */, D0FA10022D10200100112233 /* burrow.grpc.swift in Sources */,
D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */, D0F7598D2C8DB3DA00126CF3 /* Client.swift in Sources */,
D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */, D0D4E56B2C8D9C2F007F820A /* Logging.swift in Sources */,
); );
@ -745,17 +716,14 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
D110000A2F70000100112233 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D11000012F70000100112233 /* BurrowUITests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
D110000B2F70000100112233 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D05B9F7129E39EEC008CB1F9 /* App */;
targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */;
};
D020F65C29E4A697002790F6 /* PBXTargetDependency */ = { D020F65C29E4A697002790F6 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = D020F65229E4A697002790F6 /* NetworkExtension */; target = D020F65229E4A697002790F6 /* NetworkExtension */;
@ -795,19 +763,27 @@
isa = PBXTargetDependency; isa = PBXTargetDependency;
productRef = D0F759892C8DB34200126CF3 /* GRPC */; productRef = D0F759892C8DB34200126CF3 /* GRPC */;
}; };
D110000B2F70000100112233 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D05B9F7129E39EEC008CB1F9 /* App */;
targetProxy = D11000022F70000100112233 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration 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 */ = { D020F65F29E4A697002790F6 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */;
buildSettings = { buildSettings = {
DEVELOPMENT_TEAM = 2KPBQHRJ2D;
}; };
name = Debug; name = Debug;
}; };
@ -815,7 +791,6 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */; baseConfigurationReference = D020F66229E4A6E5002790F6 /* NetworkExtension.xcconfig */;
buildSettings = { buildSettings = {
DEVELOPMENT_TEAM = 2KPBQHRJ2D;
}; };
name = Release; name = Release;
}; };
@ -823,7 +798,6 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */; baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */;
buildSettings = { buildSettings = {
DEVELOPMENT_TEAM = 2KPBQHRJ2D;
}; };
name = Debug; name = Debug;
}; };
@ -831,7 +805,6 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */; baseConfigurationReference = D020F64029E4A1FF002790F6 /* Compiler.xcconfig */;
buildSettings = { buildSettings = {
DEVELOPMENT_TEAM = 2KPBQHRJ2D;
}; };
name = Release; name = Release;
}; };
@ -839,10 +812,6 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */; baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */;
buildSettings = { buildSettings = {
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 2KPBQHRJ2D;
PROVISIONING_PROFILE_SPECIFIER = "";
}; };
name = Debug; name = Debug;
}; };
@ -850,10 +819,6 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */; baseConfigurationReference = D020F64929E4A34B002790F6 /* App.xcconfig */;
buildSettings = { buildSettings = {
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 2KPBQHRJ2D;
PROVISIONING_PROFILE_SPECIFIER = "";
}; };
name = Release; name = Release;
}; };
@ -899,23 +864,18 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList 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" */ = { D020F65E29E4A697002790F6 /* Build configuration list for PBXNativeTarget "NetworkExtension" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
@ -970,15 +930,6 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
D110000E2F70000100112233 /* Build configuration list for PBXNativeTarget "BurrowUITests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
D110000C2F70000100112233 /* Debug */,
D110000D2F70000100112233 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */

View file

@ -16,13 +16,6 @@ public enum Constants {
try groupContainerURL.appending(component: "burrow.sock", directoryHint: .notDirectory) 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 { public static var databaseURL: URL {
get throws { get throws {
try groupContainerURL.appending(component: "burrow.db", directoryHint: .notDirectory) try groupContainerURL.appending(component: "burrow.db", directoryHint: .notDirectory)

View file

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

View file

@ -108,83 +108,6 @@ public struct Burrow_TailnetLoginStatusResponse: Sendable {
public init() {} 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 struct Burrow_TunnelPacket: Sendable {
public var payload = Data() public var payload = Data()
public var unknownFields = SwiftProtobuf.UnknownStorage() public var unknownFields = SwiftProtobuf.UnknownStorage()
@ -494,239 +417,6 @@ 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 struct TailnetClient: Client, GRPCClient {
public let channel: GRPCChannel public let channel: GRPCChannel
public var defaultCallOptions: CallOptions public var defaultCallOptions: CallOptions
@ -797,52 +487,6 @@ 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 struct TunnelPacketClient: Client, GRPCClient {
public let channel: GRPCChannel public let channel: GRPCChannel
public var defaultCallOptions: CallOptions public var defaultCallOptions: CallOptions

View file

@ -25,7 +25,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
public typealias RawValue = Int public typealias RawValue = Int
case wireGuard // = 0 case wireGuard // = 0
case tailnet // = 1 case tailnet // = 1
case proxySubscription // = 3
case UNRECOGNIZED(Int) case UNRECOGNIZED(Int)
public init() { public init() {
@ -36,7 +35,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
switch rawValue { switch rawValue {
case 0: self = .wireGuard case 0: self = .wireGuard
case 1: self = .tailnet case 1: self = .tailnet
case 3: self = .proxySubscription
default: self = .UNRECOGNIZED(rawValue) default: self = .UNRECOGNIZED(rawValue)
} }
} }
@ -45,7 +43,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
switch self { switch self {
case .wireGuard: return 0 case .wireGuard: return 0
case .tailnet: return 1 case .tailnet: return 1
case .proxySubscription: return 3
case .UNRECOGNIZED(let i): return i case .UNRECOGNIZED(let i): return i
} }
} }
@ -54,7 +51,6 @@ public enum Burrow_NetworkType: SwiftProtobuf.Enum, Swift.CaseIterable {
public static let allCases: [Burrow_NetworkType] = [ public static let allCases: [Burrow_NetworkType] = [
.wireGuard, .wireGuard,
.tailnet, .tailnet,
.proxySubscription,
] ]
} }
@ -221,8 +217,6 @@ public struct Burrow_TunnelConfigurationResponse: Sendable {
public var routes: [String] = [] public var routes: [String] = []
public var excludedRoutes: [String] = []
public var dnsServers: [String] = [] public var dnsServers: [String] = []
public var searchDomains: [String] = [] public var searchDomains: [String] = []
@ -242,7 +236,6 @@ extension Burrow_NetworkType: SwiftProtobuf._ProtoNameProviding {
public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [
0: .same(proto: "WireGuard"), 0: .same(proto: "WireGuard"),
1: .same(proto: "Tailnet"), 1: .same(proto: "Tailnet"),
3: .same(proto: "ProxySubscription"),
] ]
} }
@ -551,7 +544,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
4: .standard(proto: "dns_servers"), 4: .standard(proto: "dns_servers"),
5: .standard(proto: "search_domains"), 5: .standard(proto: "search_domains"),
6: .standard(proto: "include_default_route"), 6: .standard(proto: "include_default_route"),
7: .standard(proto: "excluded_routes"),
] ]
public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws { public mutating func decodeMessage<D: SwiftProtobuf.Decoder>(decoder: inout D) throws {
@ -566,7 +558,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }() case 4: try { try decoder.decodeRepeatedStringField(value: &self.dnsServers) }()
case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }() case 5: try { try decoder.decodeRepeatedStringField(value: &self.searchDomains) }()
case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }() case 6: try { try decoder.decodeSingularBoolField(value: &self.includeDefaultRoute) }()
case 7: try { try decoder.decodeRepeatedStringField(value: &self.excludedRoutes) }()
default: break default: break
} }
} }
@ -582,9 +573,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
if !self.routes.isEmpty { if !self.routes.isEmpty {
try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3) try visitor.visitRepeatedStringField(value: self.routes, fieldNumber: 3)
} }
if !self.excludedRoutes.isEmpty {
try visitor.visitRepeatedStringField(value: self.excludedRoutes, fieldNumber: 7)
}
if !self.dnsServers.isEmpty { if !self.dnsServers.isEmpty {
try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4) try visitor.visitRepeatedStringField(value: self.dnsServers, fieldNumber: 4)
} }
@ -601,7 +589,6 @@ extension Burrow_TunnelConfigurationResponse: SwiftProtobuf.Message, SwiftProtob
if lhs.addresses != rhs.addresses {return false} if lhs.addresses != rhs.addresses {return false}
if lhs.mtu != rhs.mtu {return false} if lhs.mtu != rhs.mtu {return false}
if lhs.routes != rhs.routes {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.dnsServers != rhs.dnsServers {return false}
if lhs.searchDomains != rhs.searchDomains {return false} if lhs.searchDomains != rhs.searchDomains {return false}
if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false} if lhs.includeDefaultRoute != rhs.includeDefaultRoute {return false}

View file

@ -9,5 +9,3 @@ CODE_SIGN_ENTITLEMENTS = NetworkExtension/NetworkExtension-iOS.entitlements
CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = NetworkExtension/NetworkExtension-macOS.entitlements CODE_SIGN_ENTITLEMENTS[sdk=macosx*] = NetworkExtension/NetworkExtension-macOS.entitlements
SWIFT_INCLUDE_PATHS = $(inherited) $(PROJECT_DIR)/NetworkExtension 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() } get throws { try _client.get() }
} }
private let _client: Result<TunnelClient, Swift.Error> = Result { private let _client: Result<TunnelClient, Swift.Error> = Result {
try TunnelClient.unix(socketURL: Constants.packetTunnelSocketURL) try TunnelClient.unix(socketURL: Constants.socketURL)
} }
override init() { override init() {
do { do {
libburrow.spawnInProcess( libburrow.spawnInProcess(
socketPath: try Constants.packetTunnelSocketURL.path(percentEncoded: false), socketPath: try Constants.socketURL.path(percentEncoded: false),
databasePath: try Constants.databaseURL.path(percentEncoded: false) databasePath: try Constants.databaseURL.path(percentEncoded: false)
) )
} catch { } catch {
@ -54,11 +54,6 @@ final class PacketTunnelProvider: NEPacketTunnelProvider, @unchecked Sendable {
guard let settings = configuration?.settings else { guard let settings = configuration?.settings else {
throw Error.missingTunnelConfiguration 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 await setTunnelNetworkSettings(settings)
try startPacketBridge() try startPacketBridge()
logger.log("Started tunnel with network settings: \(settings)") logger.log("Started tunnel with network settings: \(settings)")
@ -93,22 +88,15 @@ extension PacketTunnelProvider {
private func startPacketBridge() throws { private func startPacketBridge() throws {
stopPacketBridge() stopPacketBridge()
let packetClient = TunnelPacketClient.unix(socketURL: try Constants.packetTunnelSocketURL) let packetClient = TunnelPacketClient.unix(socketURL: try Constants.socketURL)
let call = packetClient.makeTunnelPacketsCall() let call = packetClient.makeTunnelPacketsCall()
self.packetCall = call self.packetCall = call
inboundPacketTask = Task { [weak self] in inboundPacketTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
var packetCount = 0
var byteCount = 0
do { do {
for try await packet in call.responseStream { for try await packet in call.responseStream {
let payload = packet.payload 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( self.packetFlow.writePackets(
[payload], [payload],
withProtocols: [Self.protocolNumber(for: payload)] withProtocols: [Self.protocolNumber(for: payload)]
@ -123,17 +111,10 @@ extension PacketTunnelProvider {
outboundPacketTask = Task { [weak self] in outboundPacketTask = Task { [weak self] in
guard let self else { return } guard let self else { return }
defer { call.requestStream.finish() } defer { call.requestStream.finish() }
var packetCount = 0
var byteCount = 0
do { do {
while !Task.isCancelled { while !Task.isCancelled {
let packets = await self.readPacketsBatch() let packets = await self.readPacketsBatch()
for (payload, _) in packets { 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() var packet = Burrow_TunnelPacket()
packet.payload = payload packet.payload = payload
try await call.requestStream.send(packet) try await call.requestStream.send(packet)
@ -147,7 +128,6 @@ extension PacketTunnelProvider {
} }
private func stopPacketBridge() { private func stopPacketBridge() {
logger.log("Stopping tunnel packet bridge")
inboundPacketTask?.cancel() inboundPacketTask?.cancel()
inboundPacketTask = nil inboundPacketTask = nil
outboundPacketTask?.cancel() outboundPacketTask?.cancel()
@ -185,9 +165,6 @@ extension Burrow_TunnelConfigurationResponse {
let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:)) let parsedRoutes = routes.compactMap(ParsedTunnelRoute.init(rawValue:))
var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route) var ipv4Routes = parsedRoutes.compactMap(\.ipv4Route)
var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route) var ipv6Routes = parsedRoutes.compactMap(\.ipv6Route)
let parsedExcludedRoutes = excludedRoutes.compactMap(ParsedTunnelRoute.init(rawValue:))
let excludedIPv4Routes = parsedExcludedRoutes.compactMap(\.ipv4Route)
let excludedIPv6Routes = parsedExcludedRoutes.compactMap(\.ipv6Route)
if includeDefaultRoute { if includeDefaultRoute {
ipv4Routes.append(.default()) ipv4Routes.append(.default())
ipv6Routes.append(.default()) ipv6Routes.append(.default())
@ -203,9 +180,6 @@ extension Burrow_TunnelConfigurationResponse {
if !ipv4Routes.isEmpty { if !ipv4Routes.isEmpty {
ipv4Settings.includedRoutes = ipv4Routes ipv4Settings.includedRoutes = ipv4Routes
} }
if !excludedIPv4Routes.isEmpty {
ipv4Settings.excludedRoutes = excludedIPv4Routes
}
settings.ipv4Settings = ipv4Settings settings.ipv4Settings = ipv4Settings
} }
if !ipv6Addresses.isEmpty { if !ipv6Addresses.isEmpty {
@ -216,9 +190,6 @@ extension Burrow_TunnelConfigurationResponse {
if !ipv6Routes.isEmpty { if !ipv6Routes.isEmpty {
ipv6Settings.includedRoutes = ipv6Routes ipv6Settings.includedRoutes = ipv6Routes
} }
if !excludedIPv6Routes.isEmpty {
ipv6Settings.excludedRoutes = excludedIPv6Routes
}
settings.ipv6Settings = ipv6Settings settings.ipv6Settings = ipv6Settings
} }
if !dnsServers.isEmpty { if !dnsServers.isEmpty {

View file

@ -3,7 +3,7 @@
# This is a build script. It is run by Xcode as a build step. # 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. # The type of build is described in various environment variables set by Xcode.
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" export PATH="${PATH}:${HOME}/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:/etc/profiles/per-user/${USER}/bin"
if ! [[ -x "$(command -v cargo)" ]]; then if ! [[ -x "$(command -v cargo)" ]]; then
echo 'error: Unable to find cargo' echo 'error: Unable to find cargo'
@ -63,13 +63,13 @@ else
fi fi
if [[ -x "$(command -v rustup)" ]]; then if [[ -x "$(command -v rustup)" ]]; then
CARGO_PATH="$(dirname "$(rustup which cargo)"):/usr/bin" CARGO_PATH="$(dirname $(rustup which cargo)):/usr/bin"
else else
CARGO_PATH="$(dirname "$(readlink -f "$(command -v cargo)")"):/usr/bin" CARGO_PATH="$(dirname $(readlink -f $(which cargo))):/usr/bin"
fi fi
PROTOC="$(readlink -f "$(command -v protoc)")" PROTOC=$(readlink -f $(which protoc))
CARGO_PATH="$(dirname "$PROTOC"):$CARGO_PATH" CARGO_PATH="$(dirname $PROTOC):$CARGO_PATH"
# Run cargo without the various environment variables set by Xcode. # Run cargo without the various environment variables set by Xcode.
# Those variables can confuse cargo and the build scripts it runs. # Those variables can confuse cargo and the build scripts it runs.

View file

@ -1,6 +1,5 @@
import BurrowConfiguration import BurrowConfiguration
import Foundation import Foundation
import GRPC
import SwiftUI import SwiftUI
#if canImport(AuthenticationServices) #if canImport(AuthenticationServices)
import AuthenticationServices import AuthenticationServices
@ -16,7 +15,6 @@ public struct BurrowView: View {
@State private var accountStore = NetworkAccountStore() @State private var accountStore = NetworkAccountStore()
@State private var activeSheet: ConfigurationSheet? @State private var activeSheet: ConfigurationSheet?
@State private var didRunAutomation = false @State private var didRunAutomation = false
@State private var expandedProxySubscriptionID: Int32?
public var body: some View { public var body: some View {
NavigationStack { NavigationStack {
@ -39,9 +37,6 @@ public struct BurrowView: View {
Button("Add WireGuard Network") { Button("Add WireGuard Network") {
activeSheet = .wireGuard activeSheet = .wireGuard
} }
Button("Import Proxy Subscription") {
activeSheet = .proxySubscription
}
Button("Save Tor Account") { Button("Save Tor Account") {
activeSheet = .tor activeSheet = .tor
} }
@ -72,22 +67,8 @@ public struct BurrowView: View {
Text(connectionError) Text(connectionError)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .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 { if showsAccountsSection {
@ -138,15 +119,6 @@ public struct BurrowView: View {
accountStore: accountStore accountStore: accountStore
) )
} }
.sheet(isPresented: proxySubscriptionSheetBinding) {
ProxySubscriptionManagerView(
networks: networkViewModel.proxySubscriptions,
networkViewModel: networkViewModel,
selectedNetworkID: $expandedProxySubscriptionID
)
.frame(width: 820, height: 600)
.padding(20)
}
.onAppear { .onAppear {
runAutomationIfNeeded() runAutomationIfNeeded()
} }
@ -185,29 +157,15 @@ public struct BurrowView: View {
} }
} }
private var proxySubscriptionSheetBinding: Binding<Bool> {
Binding(
get: { expandedProxySubscriptionID != nil },
set: { isPresented in
guard !isPresented else { return }
expandedProxySubscriptionID = nil
}
)
}
@ViewBuilder @ViewBuilder
private func sectionHeader(title: String, detail: String?) -> some View { private func sectionHeader(title: String, detail: String?) -> some View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(title) Text(title)
.font(.title2.weight(.semibold)) .font(.title2.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
if let detail, !detail.isEmpty { if let detail, !detail.isEmpty {
Text(detail) Text(detail)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(3)
.truncationMode(.tail)
} }
} }
} }
@ -232,14 +190,13 @@ public struct BurrowView: View {
#if os(iOS) #if os(iOS)
!accountStore.accounts.isEmpty !accountStore.accounts.isEmpty
#else #else
!accountStore.accounts.isEmpty || networkViewModel.networks.isEmpty true
#endif #endif
} }
} }
private enum ConfigurationSheet: String, CaseIterable, Identifiable { private enum ConfigurationSheet: String, CaseIterable, Identifiable {
case wireGuard case wireGuard
case proxySubscription
case tor case tor
case tailnet case tailnet
@ -248,7 +205,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
var kind: AccountNetworkKind { var kind: AccountNetworkKind {
switch self { switch self {
case .wireGuard: .wireGuard case .wireGuard: .wireGuard
case .proxySubscription: .proxySubscription
case .tor: .tor case .tor: .tor
case .tailnet: .tailnet case .tailnet: .tailnet
} }
@ -258,8 +214,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
switch self { switch self {
case .wireGuard: case .wireGuard:
"wave.3.right" "wave.3.right"
case .proxySubscription:
"link.badge.plus"
case .tor: case .tor:
"shield.lefthalf.filled.badge.checkmark" "shield.lefthalf.filled.badge.checkmark"
case .tailnet: case .tailnet:
@ -271,8 +225,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
switch self { switch self {
case .wireGuard: case .wireGuard:
"WireGuard" "WireGuard"
case .proxySubscription:
"Proxy Subscription"
case .tor: case .tor:
"Tor" "Tor"
case .tailnet: case .tailnet:
@ -284,8 +236,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
switch self { switch self {
case .wireGuard: case .wireGuard:
"Import a tunnel" "Import a tunnel"
case .proxySubscription:
"Import Trojan or Shadowsocks"
case .tor: case .tor:
"Save an Arti profile" "Save an Arti profile"
case .tailnet: case .tailnet:
@ -297,8 +247,6 @@ private enum ConfigurationSheet: String, CaseIterable, Identifiable {
switch self { switch self {
case .wireGuard: case .wireGuard:
.blue .blue
case .proxySubscription:
.teal
case .tor, .tailnet: case .tor, .tailnet:
kind.accentColor kind.accentColor
} }
@ -338,8 +286,6 @@ private struct AccountDraft {
var accountName = "" var accountName = ""
var identityName = "" var identityName = ""
var wireGuardConfig = "" var wireGuardConfig = ""
var proxySubscriptionURL = ""
var proxySubscriptionName = ""
var discoveryEmail = "" var discoveryEmail = ""
var authority = "" var authority = ""
@ -358,8 +304,6 @@ private struct AccountDraft {
switch sheet { switch sheet {
case .wireGuard: case .wireGuard:
break break
case .proxySubscription:
title = "Proxy Subscription"
case .tor: case .tor:
title = "Default Tor" title = "Default Tor"
accountName = "default" accountName = "default"
@ -394,11 +338,6 @@ private struct ConfigurationSheetView: View {
@State private var tailnetLoginError: String? @State private var tailnetLoginError: String?
@State private var tailnetLoginSessionID: String? @State private var tailnetLoginSessionID: String?
@State private var isStartingTailnetLogin = false @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 tailnetPresentedAuthURL: URL?
@State private var preserveTailnetLoginSession = false @State private var preserveTailnetLoginSession = false
@State private var usesCustomTailnetAuthority = false @State private var usesCustomTailnetAuthority = false
@ -435,11 +374,31 @@ private struct ConfigurationSheetView: View {
} }
} }
configurationSections 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
}
if let errorMessage { if let errorMessage {
Section { Section {
feedbackText(errorMessage, color: .red) Text(errorMessage)
.foregroundStyle(.red)
} }
} }
} }
@ -486,8 +445,7 @@ private struct ConfigurationSheetView: View {
} }
} }
#if os(macOS) #if os(macOS)
.frame(width: 520) .frame(minWidth: 520, minHeight: 620)
.frame(minHeight: 620)
#endif #endif
.safeAreaInset(edge: .bottom) { .safeAreaInset(edge: .bottom) {
if showsBottomActionButton { if showsBottomActionButton {
@ -515,12 +473,6 @@ private struct ConfigurationSheetView: View {
await cancelTailnetLoginIfNeeded() await cancelTailnetLoginIfNeeded()
} }
} }
.onChange(of: draft.proxySubscriptionURL) { _, _ in
proxySubscriptionPreview = nil
proxySubscriptionPreviewError = nil
selectedProxySubscriptionOrdinal = nil
proxySubscriptionNodeFilter = ""
}
.onDisappear { .onDisappear {
tailnetLoginPollTask?.cancel() tailnetLoginPollTask?.cancel()
tailnetDiscoveryTask?.cancel() tailnetDiscoveryTask?.cancel()
@ -534,71 +486,6 @@ 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 @ViewBuilder
private var identityFields: some View { private var identityFields: some View {
TextField("Title", text: $draft.title) TextField("Title", text: $draft.title)
@ -905,143 +792,6 @@ 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 @ViewBuilder
private var bottomActionBar: some View { private var bottomActionBar: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -1077,12 +827,6 @@ private struct ConfigurationSheetView: View {
} }
.disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) .disabled(draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
case .proxySubscription:
Button("Preview Subscription") {
previewProxySubscription()
}
.disabled(normalizedOptional(draft.proxySubscriptionURL) == nil)
case .tor: case .tor:
Menu("Presets") { Menu("Presets") {
Button("Recommended Tor Defaults") { Button("Recommended Tor Defaults") {
@ -1139,8 +883,6 @@ private struct ConfigurationSheetView: View {
switch sheet { switch sheet {
case .wireGuard: case .wireGuard:
.blue .blue
case .proxySubscription:
.teal
case .tor, .tailnet: case .tor, .tailnet:
sheet.kind.accentColor sheet.kind.accentColor
} }
@ -1150,8 +892,6 @@ private struct ConfigurationSheetView: View {
switch sheet { switch sheet {
case .wireGuard: case .wireGuard:
"Import WireGuard" "Import WireGuard"
case .proxySubscription:
"Import Proxy Subscription"
case .tor: case .tor:
"Configure Tor" "Configure Tor"
case .tailnet: case .tailnet:
@ -1171,8 +911,6 @@ private struct ConfigurationSheetView: View {
switch sheet { switch sheet {
case .wireGuard, .tor: case .wireGuard, .tor:
return true return true
case .proxySubscription:
return false
case .tailnet: case .tailnet:
return showsAdvancedTailnetSettings return showsAdvancedTailnetSettings
} }
@ -1190,8 +928,6 @@ private struct ConfigurationSheetView: View {
switch sheet { switch sheet {
case .wireGuard: case .wireGuard:
return "Add Network" return "Add Network"
case .proxySubscription:
return "Import"
case .tor: case .tor:
return "Save Account" return "Save Account"
case .tailnet: case .tailnet:
@ -1206,7 +942,7 @@ private struct ConfigurationSheetView: View {
return normalizedOptional(draft.authority) == nil return normalizedOptional(draft.authority) == nil
} }
return false return false
case .wireGuard, .tor, .proxySubscription: case .wireGuard, .tor:
return true return true
} }
} }
@ -1215,9 +951,6 @@ private struct ConfigurationSheetView: View {
switch sheet { switch sheet {
case .wireGuard: case .wireGuard:
return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty return draft.wireGuardConfig.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
case .proxySubscription:
return normalizedOptional(draft.proxySubscriptionURL) == nil
|| proxySubscriptionPreview?.nodes.contains(where: { $0.runtimeSupported }) != true
case .tor: case .tor:
return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil return normalizedOptional(draft.accountName) == nil || normalizedOptional(draft.identityName) == nil
case .tailnet: case .tailnet:
@ -1292,9 +1025,6 @@ private struct ConfigurationSheetView: View {
case .wireGuard: case .wireGuard:
try await submitWireGuard() try await submitWireGuard()
dismiss() dismiss()
case .proxySubscription:
try await submitProxySubscription()
dismiss()
case .tor: case .tor:
try submitTor() try submitTor()
dismiss() dismiss()
@ -1302,7 +1032,7 @@ private struct ConfigurationSheetView: View {
try await submitTailnet() try await submitTailnet()
} }
} catch { } catch {
errorMessage = displayError(error) errorMessage = error.localizedDescription
} }
} }
} }
@ -1332,44 +1062,6 @@ private struct ConfigurationSheetView: View {
try accountStore.upsert(record, secret: nil) 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 { private func submitTor() throws {
let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))") let title = titleOrFallback("Tor \(normalized(draft.identityName, fallback: "apple"))")
let note = [ let note = [
@ -1837,8 +1529,6 @@ private struct ConfigurationSheetView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(value) Text(value)
.font(.body.monospaced()) .font(.body.monospaced())
.lineLimit(3)
.truncationMode(.middle)
} }
} }
} }
@ -1853,11 +1543,9 @@ private struct AccountRowView: View {
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(account.title) Text(account.title)
.font(.headline) .font(.headline)
.lineLimit(1)
.truncationMode(.tail)
Text(account.kind.title) Text(account.kind.title)
.font(.subheadline) .font(.subheadline)
.foregroundStyle(account.kind.accentColor) .foregroundStyle(account.kind.accentColor)
} }
Spacer() Spacer()
if hasSecret { if hasSecret {
@ -1890,8 +1578,6 @@ private struct AccountRowView: View {
Text(note) Text(note)
.font(.footnote) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(3)
.truncationMode(.middle)
} }
} }
.padding() .padding()
@ -1910,378 +1596,11 @@ private struct AccountRowView: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(value) Text(value)
.font(.body.monospaced()) .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 { private extension View {
@ViewBuilder
func burrowGlassControl() -> some View {
if #available(macOS 26.0, iOS 26.0, *) {
self.buttonStyle(.glass)
} else {
self.buttonStyle(.bordered)
}
}
@ViewBuilder @ViewBuilder
func burrowLoginField() -> some View { func burrowLoginField() -> some View {
#if os(iOS) #if os(iOS)
@ -2355,13 +1674,6 @@ private final class TailnetBrowserAuthenticator {
} }
#endif #endif
private func displayError(_ error: Error) -> String {
if let status = error as? GRPCStatus {
return status.message ?? status.description
}
return error.localizedDescription
}
private struct BurrowAutomationConfig { private struct BurrowAutomationConfig {
enum Action: String { enum Action: String {
case tailnetLogin = "tailnet-login" case tailnetLogin = "tailnet-login"

View file

@ -2,18 +2,6 @@ import SwiftUI
struct NetworkCarouselView: View { struct NetworkCarouselView: View {
var networks: [NetworkCardModel] 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 { var body: some View {
Group { Group {
@ -41,19 +29,10 @@ struct NetworkCarouselView: View {
.frame(maxWidth: .infinity, minHeight: 175) .frame(maxWidth: .infinity, minHeight: 175)
#endif #endif
} else { } 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) { ScrollView(.horizontal) {
LazyHStack { LazyHStack {
ForEach(networks) { network in ForEach(networks) { network in
networkCard(network) NetworkView(network: network)
.containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center) .containerRelativeFrame(.horizontal, count: 10, span: 7, spacing: 0, alignment: .center)
.scrollTransition(.interactive, axis: .horizontal) { content, phase in .scrollTransition(.interactive, axis: .horizontal) { content, phase in
content content
@ -68,33 +47,9 @@ struct NetworkCarouselView: View {
.defaultScrollAnchor(.center) .defaultScrollAnchor(.center)
.scrollTargetBehavior(.viewAligned) .scrollTargetBehavior(.viewAligned)
.containerRelativeFrame(.horizontal) .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 #if DEBUG

View file

@ -98,33 +98,23 @@ public final class NetworkExtensionTunnel: Tunnel {
} }
} }
let manager = managers.first ?? NETunnelProviderManager() guard managers.isEmpty else { return }
var needsSave = managers.isEmpty
if manager.localizedDescription != "Burrow" { let manager = NETunnelProviderManager()
manager.localizedDescription = "Burrow" manager.localizedDescription = "Burrow"
needsSave = true
}
let proto = NETunnelProviderProtocol() let proto = NETunnelProviderProtocol()
proto.providerBundleIdentifier = bundleIdentifier proto.providerBundleIdentifier = bundleIdentifier
proto.serverAddress = "burrow.rs" proto.serverAddress = "burrow.rs"
proto.proxySettings = nil
if !manager.protocolConfiguration.isBurrowEquivalent(to: proto) { manager.protocolConfiguration = proto
manager.protocolConfiguration = proto try await manager.save()
needsSave = true
}
if needsSave {
try await manager.save()
}
} }
public func start() { public func start() {
Task { Task {
guard let manager = try await NETunnelProviderManager.managers.first else { return }
do { do {
try await configure()
guard let manager = try await NETunnelProviderManager.managers.first else { return }
if !manager.isEnabled { if !manager.isEnabled {
manager.isEnabled = true manager.isEnabled = true
try await manager.save() try await manager.save()
@ -159,17 +149,6 @@ 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 { extension NEVPNConnection {
fileprivate var tunnelStatus: TunnelStatus { fileprivate var tunnelStatus: TunnelStatus {
switch status { switch status {

View file

@ -53,456 +53,6 @@ struct TailnetLoginStatus: Sendable {
var health: [String] var health: [String]
} }
struct ProxySubscriptionNodePreview: Sendable, Identifiable {
var id: Int32 { ordinal }
var ordinal: Int32
var name: String
var proxyProtocol: String
var server: String
var port: Int32
var warnings: [String]
var runtimeSupported: Bool
}
struct ProxySubscriptionPreview: Sendable {
var suggestedName: String
var detectedFormat: String
var compatibleCount: Int32
var rejectedCount: Int32
var nodes: [ProxySubscriptionNodePreview]
var warnings: [String]
}
struct ProxySubscriptionNetwork: Sendable, Identifiable, Hashable {
var id: Int32
var name: String
var sourceURL: String
var detectedFormat: String
var selectedOrdinal: Int32?
var selectedName: String?
var nodes: [ProxySubscriptionNetworkNode]
var warnings: [String]
var selectedNode: ProxySubscriptionNetworkNode? {
selectedName
.flatMap { name in nodes.first { $0.name == name && $0.runtimeSupported } }
?? selectedOrdinal
.flatMap { ordinal in nodes.first { $0.ordinal == ordinal && $0.runtimeSupported } }
?? nodes.first { $0.runtimeSupported }
?? nodes.first
}
var runtimeSupportedNodes: [ProxySubscriptionNetworkNode] {
nodes.filter(\.runtimeSupported)
}
init?(network: Burrow_Network) {
guard network.type == .proxySubscription,
let payload = try? JSONDecoder().decode(StoredProxySubscriptionPayload.self, from: network.payload)
else {
return nil
}
id = network.id
name = payload.name
sourceURL = payload.source.url
detectedFormat = payload.detectedFormat
selectedOrdinal = payload.selectedOrdinal.map(Int32.init)
selectedName = payload.selectedName
nodes = payload.nodes.map(ProxySubscriptionNetworkNode.init)
warnings = payload.warnings
}
}
struct ProxySubscriptionNetworkNode: Sendable, Identifiable, Hashable {
var id: Int32 { ordinal }
var ordinal: Int32
var name: String
var proxyProtocol: String
var server: String
var port: Int32
var warnings: [String]
var runtimeSupported: Bool
fileprivate init(stored: StoredProxySubscriptionNode) {
ordinal = Int32(stored.ordinal)
name = stored.name
proxyProtocol = stored.proxyProtocol
server = stored.server
port = Int32(stored.port)
warnings = stored.warnings
runtimeSupported = stored.config.runtimeSupported
}
}
private struct StoredProxySubscriptionPayload: Decodable {
var name: String
var source: StoredProxySubscriptionSource
var detectedFormat: String
var selectedOrdinal: Int?
var selectedName: String?
var nodes: [StoredProxySubscriptionNode]
var warnings: [String]
enum CodingKeys: String, CodingKey {
case name
case source
case detectedFormat = "detected_format"
case selectedOrdinal = "selected_ordinal"
case selectedName = "selected_name"
case nodes
case warnings
}
}
private struct StoredProxySubscriptionSource: Decodable {
var url: String
}
private struct StoredProxySubscriptionNode: Decodable {
var ordinal: Int
var name: String
var proxyProtocol: String
var server: String
var port: Int
var warnings: [String]
var config: StoredProxySubscriptionNodeConfig
enum CodingKeys: String, CodingKey {
case ordinal
case name
case proxyProtocol = "protocol"
case server
case port
case warnings
case config
}
}
private struct StoredProxySubscriptionNodeConfig: Decodable {
var type: String
var network: StoredProxySubscriptionNetworkTransport?
var cipher: String?
var plugin: StoredJSONValue?
var smux: StoredJSONValue?
var udp: Bool?
var udpOverTCP: Bool?
var clientFingerprint: String?
var runtimeSupported: Bool {
switch type {
case "trojan":
return network?.isTrojanTCP ?? true
case "shadowsocks":
guard Self.supportedShadowsocksSmux(smux) else {
return false
}
return Self.supportedShadowsocksPlugin(plugin, clientFingerprint: clientFingerprint)
&& Self.supportedShadowsocksCiphers.contains(cipher?.lowercased() ?? "")
default:
return false
}
}
enum CodingKeys: String, CodingKey {
case type
case network
case cipher
case plugin
case smux
case udp
case udpOverTCP = "udp_over_tcp"
case clientFingerprint = "client_fingerprint"
}
private static let supportedShadowsocksCiphers: Set<String> = [
"none",
"plain",
"table",
"rc4-md5",
"rc4",
"aes-128-ctr",
"aes-192-ctr",
"aes-256-ctr",
"aes-128-cfb",
"aes-128-cfb1",
"aes-128-cfb8",
"aes-128-cfb128",
"aes-192-cfb",
"aes-192-cfb1",
"aes-192-cfb8",
"aes-192-cfb128",
"aes-256-cfb",
"aes-256-cfb1",
"aes-256-cfb8",
"aes-256-cfb128",
"aes-128-ofb",
"aes-192-ofb",
"aes-256-ofb",
"camellia-128-ctr",
"camellia-192-ctr",
"camellia-256-ctr",
"camellia-128-cfb",
"camellia-128-cfb1",
"camellia-128-cfb8",
"camellia-128-cfb128",
"camellia-192-cfb",
"camellia-192-cfb1",
"camellia-192-cfb8",
"camellia-192-cfb128",
"camellia-256-cfb",
"camellia-256-cfb1",
"camellia-256-cfb8",
"camellia-256-cfb128",
"camellia-128-ofb",
"camellia-192-ofb",
"camellia-256-ofb",
"chacha20",
"chacha20-ietf",
"xchacha20",
"aes-128-gcm",
"aead_aes_128_gcm",
"aes-192-gcm",
"aes-256-gcm",
"aead_aes_256_gcm",
"aes-128-ccm",
"aes-192-ccm",
"aes-256-ccm",
"aes-128-gcm-siv",
"aes-256-gcm-siv",
"chacha8-ietf-poly1305",
"chacha20-ietf-poly1305",
"chacha20-poly1305",
"aead_chacha20_poly1305",
"rabbit128-poly1305",
"xchacha8-ietf-poly1305",
"xchacha20-ietf-poly1305",
"sm4-gcm",
"sm4-ccm",
"aegis-128l",
"aegis-256",
"aez-384",
"deoxys-ii-256-128",
"ascon128",
"ascon128a",
"lea-128-gcm",
"lea-192-gcm",
"lea-256-gcm",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"2022-blake3-aes-128-ccm",
"2022-blake3-aes-256-ccm",
"2022-blake3-chacha20-poly1305",
"2022-blake3-chacha8-poly1305",
]
private static func supportedShadowsocksPlugin(
_ plugin: StoredJSONValue?,
clientFingerprint: String?
) -> Bool {
guard let plugin else {
return true
}
guard case .object(let object) = plugin,
case .string(let name)? = object["name"]
else {
return false
}
let normalizedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
let opts: [String: StoredJSONValue]
if case .object(let decodedOpts)? = object["opts"] {
opts = decodedOpts
} else {
opts = [:]
}
if normalizedName == "restls",
let clientFingerprint,
Self.shadowsocksClientFingerprintIsActive(clientFingerprint)
{
return false
}
if normalizedName == "v2ray-plugin" || normalizedName == "gost-plugin" {
guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else {
return false
}
guard mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "websocket" else {
return false
}
return true
}
if normalizedName == "shadow-tls" {
let version = opts["version"]?.stringValue ?? "2"
let normalizedVersion = version.trimmingCharacters(in: .whitespacesAndNewlines)
switch normalizedVersion {
case "1":
return true
case "2", "3":
if let clientFingerprint,
Self.shadowsocksClientFingerprintIsActive(clientFingerprint)
{
return false
}
return true
default:
return false
}
}
if normalizedName == "kcptun" {
guard let smuxVersion = opts["smuxver"]?.stringValue else {
return true
}
guard let version = Double(smuxVersion.trimmingCharacters(in: .whitespacesAndNewlines)) else {
return false
}
return version >= 0
}
if normalizedName == "restls" {
let versionHint = opts["version-hint"]?.stringValue ?? ""
switch versionHint.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "tls12", "tls13":
return true
default:
return false
}
}
guard normalizedName == "obfs" else {
return true
}
guard let mode = opts["mode"]?.stringValue ?? opts["obfs"]?.stringValue else {
return false
}
switch mode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "http", "tls":
return true
default:
return false
}
}
private static func shadowsocksClientFingerprintIsActive(_ value: String) -> Bool {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return [
"chrome",
"firefox",
"safari",
"ios",
"android",
"edge",
"360",
"qq",
"random",
"chrome120",
"firefox120",
"safari16",
"chrome_psk",
"chrome_psk_shuffle",
"chrome_padding_psk_shuffle",
"chrome_pq",
"chrome_pq_psk",
"randomized",
].contains(normalized)
}
private static func supportedShadowsocksSmux(_ smux: StoredJSONValue?) -> Bool {
guard case .object(let object)? = smux else {
return true
}
guard object["enabled"]?.boolValue ?? false else {
return true
}
let protocolName = object["protocol"]?.stringValue?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? "h2mux"
guard protocolName == "h2mux" || protocolName == "smux" || protocolName == "yamux" else {
return false
}
return true
}
}
private enum StoredProxySubscriptionNetworkTransport: Decodable, Hashable {
case named(String)
case keyed(String)
var isTrojanTCP: Bool {
switch self {
case .named(let value), .keyed(let value):
return value == "tcp"
}
}
init(from decoder: Swift.Decoder) throws {
let container = try decoder.singleValueContainer()
if let value = try? container.decode(String.self) {
self = .named(value)
return
}
let object = try container.decode([String: StoredJSONValue].self)
self = .keyed(object.keys.first ?? "")
}
}
private enum StoredJSONValue: Decodable, Hashable {
case string(String)
case number(Double)
case bool(Bool)
case object([String: StoredJSONValue])
case array([StoredJSONValue])
case null
var stringValue: String? {
switch self {
case .string(let value):
return value
case .number(let value):
if value.rounded(.towardZero) == value {
return String(Int(value))
}
return String(value)
case .bool(let value):
return String(value)
default:
return nil
}
}
var boolValue: Bool? {
switch self {
case .bool(let value):
return value
case .string(let value):
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "1", "true", "yes", "y":
return true
case "0", "false", "no", "n":
return false
default:
return nil
}
case .number(let value):
return value != 0
default:
return nil
}
}
init(from decoder: Swift.Decoder) throws {
let container = try decoder.singleValueContainer()
if container.decodeNil() {
self = .null
} else if let value = try? container.decode(Bool.self) {
self = .bool(value)
} else if let value = try? container.decode(Double.self) {
self = .number(value)
} else if let value = try? container.decode(String.self) {
self = .string(value)
} else if let value = try? container.decode([StoredJSONValue].self) {
self = .array(value)
} else {
self = .object(try container.decode([String: StoredJSONValue].self))
}
}
}
enum TailnetDiscoveryClient { enum TailnetDiscoveryClient {
static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse { static func discover(email: String, socketURL: URL) async throws -> TailnetDiscoveryResponse {
var request = Burrow_TailnetDiscoverRequest() var request = Burrow_TailnetDiscoverRequest()
@ -589,76 +139,6 @@ 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 @Observable
@MainActor @MainActor
final class NetworkViewModel: Sendable { final class NetworkViewModel: Sendable {
@ -681,16 +161,6 @@ final class NetworkViewModel: Sendable {
networks.map(Self.makeCard(for:)) 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 { var nextNetworkID: Int32 {
(networks.map(\.id).max() ?? 0) + 1 (networks.map(\.id).max() ?? 0) + 1
} }
@ -703,83 +173,13 @@ final class NetworkViewModel: Sendable {
try await addNetwork(type: .tailnet, payload: payload.encoded()) 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 { func discoverTailnet(email: String) async throws -> TailnetDiscoveryResponse {
let socketURL = try daemonSocketURL() let socketURL = try socketURLResult.get()
return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL) return try await TailnetDiscoveryClient.discover(email: email, socketURL: socketURL)
} }
func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus { func probeTailnetAuthority(_ authority: String) async throws -> TailnetAuthorityProbeStatus {
let socketURL = try daemonSocketURL() let socketURL = try socketURLResult.get()
return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL) return try await TailnetAuthorityProbeClient.probe(authority: authority, socketURL: socketURL)
} }
@ -789,7 +189,7 @@ final class NetworkViewModel: Sendable {
hostname: String?, hostname: String?,
authority: String authority: String
) async throws -> TailnetLoginStatus { ) async throws -> TailnetLoginStatus {
let socketURL = try daemonSocketURL() let socketURL = try socketURLResult.get()
return try await TailnetLoginClient.start( return try await TailnetLoginClient.start(
accountName: accountName, accountName: accountName,
identityName: identityName, identityName: identityName,
@ -800,17 +200,17 @@ final class NetworkViewModel: Sendable {
} }
func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus { func tailnetLoginStatus(sessionID: String) async throws -> TailnetLoginStatus {
let socketURL = try daemonSocketURL() let socketURL = try socketURLResult.get()
return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL) return try await TailnetLoginClient.status(sessionID: sessionID, socketURL: socketURL)
} }
func cancelTailnetLogin(sessionID: String) async throws { func cancelTailnetLogin(sessionID: String) async throws {
let socketURL = try daemonSocketURL() let socketURL = try socketURLResult.get()
try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL) try await TailnetLoginClient.cancel(sessionID: sessionID, socketURL: socketURL)
} }
private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 { private func addNetwork(type: Burrow_NetworkType, payload: Data) async throws -> Int32 {
let socketURL = try daemonSocketURL() let socketURL = try socketURLResult.get()
let networkID = nextNetworkID let networkID = nextNetworkID
let request = Burrow_Network.with { let request = Burrow_Network.with {
$0.id = networkID $0.id = networkID
@ -823,25 +223,12 @@ final class NetworkViewModel: Sendable {
return networkID 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() { private func startStreaming() {
task?.cancel() task?.cancel()
let socketURLResult = self.socketURLResult let socketURLResult = self.socketURLResult
task = Task { [weak self] in task = Task { [weak self] in
do { do {
let socketURL = try Self.daemonSocketURL(from: socketURLResult) let socketURL = try socketURLResult.get()
let client = NetworksClient.unix(socketURL: socketURL) let client = NetworksClient.unix(socketURL: socketURL)
for try await response in client.networkList(.init()) { for try await response in client.networkList(.init()) {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
@ -867,8 +254,6 @@ final class NetworkViewModel: Sendable {
WireGuardCard(network: network).card WireGuardCard(network: network).card
case .tailnet: case .tailnet:
TailnetCard(network: network).card TailnetCard(network: network).card
case .proxySubscription:
proxySubscriptionCard(network: network)
case .UNRECOGNIZED(let rawValue): case .UNRECOGNIZED(let rawValue):
unsupportedCard( unsupportedCard(
id: network.id, id: network.id,
@ -884,76 +269,6 @@ 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 { private static func unsupportedCard(id: Int32, title: String, detail: String) -> NetworkCardModel {
NetworkCardModel( NetworkCardModel(
id: id, id: id,
@ -963,13 +278,9 @@ final class NetworkViewModel: Sendable {
Text(title) Text(title)
.font(.title3.weight(.semibold)) .font(.title3.weight(.semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(2)
.truncationMode(.tail)
Text(detail) Text(detail)
.font(.body) .font(.body)
.foregroundStyle(.white.opacity(0.9)) .foregroundStyle(.white.opacity(0.9))
.lineLimit(4)
.truncationMode(.tail)
Spacer() Spacer()
Text("Network #\(id)") Text("Network #\(id)")
.font(.footnote.monospaced()) .font(.footnote.monospaced())
@ -1047,7 +358,6 @@ enum TailnetProvider: String, CaseIterable, Codable, Identifiable, Sendable {
enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable { enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
case wireGuard case wireGuard
case proxySubscription
case tor case tor
case tailnet case tailnet
@ -1056,7 +366,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var title: String { var title: String {
switch self { switch self {
case .wireGuard: "WireGuard" case .wireGuard: "WireGuard"
case .proxySubscription: "Proxy Subscription"
case .tor: "Tor" case .tor: "Tor"
case .tailnet: "Tailnet" case .tailnet: "Tailnet"
} }
@ -1065,7 +374,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var subtitle: String { var subtitle: String {
switch self { switch self {
case .wireGuard: "Import a tunnel and optional account metadata." 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 .tor: "Store Arti account and identity preferences."
case .tailnet: "Save Tailnet authority, identity defaults, and login material." case .tailnet: "Save Tailnet authority, identity defaults, and login material."
} }
@ -1074,7 +382,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var accentColor: Color { var accentColor: Color {
switch self { switch self {
case .wireGuard: .init("WireGuard") case .wireGuard: .init("WireGuard")
case .proxySubscription: .teal
case .tor: .orange case .tor: .orange
case .tailnet: .mint case .tailnet: .mint
} }
@ -1083,7 +390,6 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var actionTitle: String { var actionTitle: String {
switch self { switch self {
case .wireGuard: "Add Network" case .wireGuard: "Add Network"
case .proxySubscription: "Import"
case .tor: "Save Account" case .tor: "Save Account"
case .tailnet: "Save Account" case .tailnet: "Save Account"
} }
@ -1091,7 +397,7 @@ enum AccountNetworkKind: String, CaseIterable, Codable, Identifiable, Sendable {
var availabilityNote: String? { var availabilityNote: String? {
switch self { switch self {
case .wireGuard, .proxySubscription: case .wireGuard:
nil nil
case .tor: case .tor:
"Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet." "Tor account preferences are stored on Apple now. The managed Tor runtime is not wired on Apple in this branch yet."

1630
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,434 +0,0 @@
#!/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' "$?"; }

View file

@ -1,576 +0,0 @@
#!/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())

File diff suppressed because it is too large Load diff

View file

@ -1,504 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: Scripts/burrow-shadowsocks-singmux-interop
Run controlled Burrow Shadowsocks top-level sing-mux interop tests against the
same github.com/metacubex/sing-mux Go module used by Mihomo. By default this
checks smux, yamux, and h2mux with and without Mihomo's padded sing-mux
preface/framing. The test does not change system proxy settings or use the
user's Burrow database.
EOF
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
exit 0
fi
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
burrow_bin="${BURROW_BIN:-$repo_root/target/debug/burrow}"
sing_mux_dir="${SING_MUX_DIR:-/Users/jettchen/go/pkg/mod/github.com/metacubex/sing-mux@v0.3.9}"
sing_dir="${SING_DIR:-/Users/jettchen/go/pkg/mod/github.com/metacubex/sing@v0.5.7}"
if [[ -z "${BURROW_SINGMUX_INTEROP_SINGLE:-}" ]]; then
for protocol in ${BURROW_SINGMUX_PROTOCOLS:-smux yamux h2mux}; do
for padding in ${BURROW_SINGMUX_PADDING_MODES:-false true}; do
echo "=== Mihomo sing-mux interop: $protocol padding=$padding ==="
BURROW_SINGMUX_INTEROP_SINGLE=1 \
BURROW_SINGMUX_PROTOCOL="$protocol" \
BURROW_SINGMUX_PADDING="$padding" \
"$0"
done
done
exit 0
fi
protocol="${BURROW_SINGMUX_PROTOCOL:-smux}"
case "$protocol" in
smux | yamux | h2mux) ;;
*)
echo "unsupported sing-mux protocol: $protocol" >&2
exit 2
;;
esac
padding="${BURROW_SINGMUX_PADDING:-false}"
case "$padding" in
false | true) ;;
*)
echo "unsupported sing-mux padding mode: $padding" >&2
exit 2
;;
esac
node_udp="${BURROW_SINGMUX_NODE_UDP:-false}"
case "$node_udp" in
false | true) ;;
*)
echo "unsupported Shadowsocks node udp flag: $node_udp" >&2
exit 2
;;
esac
if [[ -z "${BURROW_BIN:-}" ]]; then
(cd "$repo_root" && cargo build -p burrow)
elif [[ ! -x "$burrow_bin" ]]; then
echo "BURROW_BIN is not executable: $burrow_bin" >&2
exit 2
fi
if ! command -v go >/dev/null 2>&1; then
echo "go is required for the Mihomo sing-mux interop peer" >&2
exit 2
fi
if ! command -v uv >/dev/null 2>&1; then
echo "uv is required for result assertions" >&2
exit 2
fi
if [[ ! -d "$sing_mux_dir" ]]; then
echo "sing-mux module directory not found: $sing_mux_dir" >&2
exit 2
fi
if [[ ! -d "$sing_dir" ]]; then
echo "sing module directory not found: $sing_dir" >&2
exit 2
fi
tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/burrow-singmux-interop.XXXXXX")"
daemon_pid=""
server_pid=""
status=0
cleanup() {
if [[ -n "$server_pid" ]]; then
kill "$server_pid" >/dev/null 2>&1 || true
fi
if [[ -n "$daemon_pid" ]]; then
kill "$daemon_pid" >/dev/null 2>&1 || true
fi
if [[ "${BURROW_SELFTEST_KEEP:-}" == "1" || "$status" -ne 0 ]]; then
echo "preserving sing-mux interop artifacts: $tmpdir" >&2
else
rm -rf "$tmpdir"
fi
}
trap 'status=$?; cleanup' EXIT
socket="$tmpdir/burrow.sock"
port_file="$tmpdir/singmux.port"
result_file="$tmpdir/singmux-result.json"
payload="$tmpdir/proxy-payload.json"
server_log="$tmpdir/singmux-server.log"
daemon_log="$tmpdir/daemon.log"
go_dir="$tmpdir/go-peer"
mkdir -p "$go_dir"
cat >"$go_dir/go.mod" <<EOF
module burrow-singmux-interop
go 1.23
require (
github.com/metacubex/sing v0.5.7
github.com/metacubex/sing-mux v0.3.9
)
replace github.com/metacubex/sing => $sing_dir
replace github.com/metacubex/sing-mux => $sing_mux_dir
EOF
cat >"$go_dir/main.go" <<'EOF'
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"os"
"strings"
"sync"
"time"
mux "github.com/metacubex/sing-mux"
"github.com/metacubex/sing/common/buf"
"github.com/metacubex/sing/common/bufio"
"github.com/metacubex/sing/common/logger"
M "github.com/metacubex/sing/common/metadata"
N "github.com/metacubex/sing/common/network"
)
type noopLogger struct{}
func (noopLogger) Trace(args ...any) {}
func (noopLogger) Debug(args ...any) {}
func (noopLogger) Info(args ...any) {}
func (noopLogger) Warn(args ...any) {}
func (noopLogger) Error(args ...any) {}
func (noopLogger) Fatal(args ...any) {}
func (noopLogger) Panic(args ...any) {}
func (noopLogger) TraceContext(context.Context, ...any) {}
func (noopLogger) DebugContext(context.Context, ...any) {}
func (noopLogger) InfoContext(context.Context, ...any) {}
func (noopLogger) WarnContext(context.Context, ...any) {}
func (noopLogger) ErrorContext(context.Context, ...any) {}
func (noopLogger) FatalContext(context.Context, ...any) {}
func (noopLogger) PanicContext(context.Context, ...any) {}
var _ logger.ContextLogger = noopLogger{}
type observed struct {
MuxTarget map[string]any `json:"mux_target,omitempty"`
TCPDestination map[string]any `json:"tcp_destination,omitempty"`
TCPFirstLine string `json:"tcp_first_line,omitempty"`
UDPDestination map[string]any `json:"udp_destination,omitempty"`
UDPPayload string `json:"udp_payload,omitempty"`
Protocol string `json:"protocol,omitempty"`
Padding bool `json:"padding,omitempty"`
PacketReplyCount int `json:"packet_reply_count,omitempty"`
}
type handler struct {
mu sync.Mutex
result observed
done chan struct{}
doneOnce sync.Once
}
func (h *handler) markDoneIfReady() {
if h.result.TCPFirstLine != "" && h.result.UDPPayload != "" {
h.doneOnce.Do(func() { close(h.done) })
}
}
func (h *handler) NewConnection(ctx context.Context, conn net.Conn, metadata M.Metadata) error {
defer conn.Close()
request, err := readHTTPHeaders(conn)
if err != nil {
return err
}
firstLine := strings.SplitN(string(request), "\r\n", 2)[0]
body := []byte("burrow sing-mux tcp ok\n")
response := fmt.Sprintf(
"HTTP/1.1 200 OK\r\nContent-Length: %d\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n%s",
len(body),
body,
)
if _, err := conn.Write([]byte(response)); err != nil {
return err
}
h.mu.Lock()
h.result.TCPDestination = socksaddrJSON(metadata.Destination)
h.result.TCPFirstLine = firstLine
h.markDoneIfReady()
h.mu.Unlock()
return nil
}
func (h *handler) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata M.Metadata) error {
defer conn.Close()
packet := buf.NewPacket()
defer packet.Release()
destination, err := conn.ReadPacket(packet)
if err != nil {
return err
}
payload := append([]byte(nil), packet.Bytes()...)
if _, err := bufio.WritePacketBuffer(conn, buf.As(payload), destination); err != nil {
return err
}
h.mu.Lock()
h.result.UDPDestination = socksaddrJSON(metadata.Destination)
h.result.UDPPayload = string(payload)
h.result.PacketReplyCount++
h.markDoneIfReady()
h.mu.Unlock()
return nil
}
func main() {
portFile := os.Args[1]
resultFile := os.Args[2]
protocol := os.Args[3]
padding := os.Args[4] == "true"
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
panic(err)
}
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
if err := os.WriteFile(portFile, []byte(fmt.Sprint(port)), 0o644); err != nil {
panic(err)
}
h := &handler{done: make(chan struct{})}
h.result.Protocol = protocol
h.result.Padding = padding
service, err := mux.NewService(mux.ServiceOptions{
NewStreamContext: func(ctx context.Context, conn net.Conn) context.Context { return ctx },
Logger: noopLogger{},
Handler: h,
})
if err != nil {
panic(err)
}
raw, err := listener.Accept()
if err != nil {
panic(err)
}
raw.SetDeadline(time.Now().Add(20 * time.Second))
target, err := readSocksAddr(raw)
if err != nil {
panic(err)
}
h.mu.Lock()
h.result.MuxTarget = target
h.mu.Unlock()
go func() {
if err := service.NewConnection(context.Background(), raw, M.Metadata{}); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}()
select {
case <-h.done:
case <-time.After(20 * time.Second):
panic("timed out waiting for sing-mux TCP and UDP probes")
}
h.mu.Lock()
output, err := json.MarshalIndent(h.result, "", " ")
h.mu.Unlock()
if err != nil {
panic(err)
}
if err := os.WriteFile(resultFile, append(output, '\n'), 0o644); err != nil {
panic(err)
}
}
func readHTTPHeaders(reader io.Reader) ([]byte, error) {
data := make([]byte, 0, 512)
tmp := make([]byte, 1)
for !strings.Contains(string(data), "\r\n\r\n") {
if _, err := io.ReadFull(reader, tmp); err != nil {
return nil, err
}
data = append(data, tmp[0])
if len(data) > 65535 {
return nil, fmt.Errorf("HTTP headers exceeded 64 KiB")
}
}
return data, nil
}
func readSocksAddr(reader io.Reader) (map[string]any, error) {
destination, err := M.SocksaddrSerializer.ReadAddrPort(reader)
if err != nil {
return nil, err
}
return socksaddrJSON(destination), nil
}
func socksaddrJSON(destination M.Socksaddr) map[string]any {
if destination.IsFqdn() {
return map[string]any{
"host": destination.Fqdn,
"port": destination.Port,
}
}
return map[string]any{
"host": destination.Addr.String(),
"port": destination.Port,
}
}
func (h *handler) NewPacket(ctx context.Context, conn N.PacketConn, buffer *buf.Buffer, metadata M.Metadata) error {
return fmt.Errorf("unexpected packet handler path")
}
EOF
(
cd "$go_dir"
go mod tidy
)
(
cd "$go_dir"
go run . "$port_file" "$result_file" "$protocol" "$padding"
) >"$server_log" 2>&1 &
server_pid=$!
for _ in {1..600}; do
if [[ -s "$port_file" ]]; then
break
fi
if ! kill -0 "$server_pid" >/dev/null 2>&1; then
echo "sing-mux peer exited before listening" >&2
cat "$server_log" >&2 || true
exit 1
fi
sleep 0.05
done
if [[ ! -s "$port_file" ]]; then
echo "timed out waiting for sing-mux peer" >&2
cat "$server_log" >&2 || true
exit 1
fi
server_port="$(cat "$port_file")"
cat >"$payload" <<EOF
{
"name": "burrow sing-mux interop",
"source": { "url": "selftest://sing-mux" },
"detected_format": "uri-list",
"selected_ordinal": 1,
"selected_name": "local shadowsocks sing-mux $protocol padding $padding",
"nodes": [
{
"ordinal": 1,
"name": "local shadowsocks sing-mux $protocol padding $padding",
"protocol": "shadowsocks",
"server": "127.0.0.1",
"port": $server_port,
"warnings": [],
"config": {
"type": "shadowsocks",
"cipher": "none",
"password": "unused",
"udp": $node_udp,
"udp_over_tcp": false,
"udp_over_tcp_version": 1,
"client_fingerprint": null,
"plugin": null,
"smux": {
"enabled": true,
"protocol": "$protocol",
"max_connections": 1,
"min_streams": 0,
"max_streams": 16,
"padding": $padding,
"statistic": false,
"only_tcp": false,
"brutal": null
}
}
}
],
"warnings": []
}
EOF
(
cd "$tmpdir"
BURROW_SOCKET_PATH="$socket" "$burrow_bin" daemon >"$daemon_log" 2>&1
) &
daemon_pid=$!
for _ in {1..100}; do
if [[ -S "$socket" ]]; then
break
fi
if ! kill -0 "$daemon_pid" >/dev/null 2>&1; then
echo "Burrow daemon exited before creating socket" >&2
cat "$daemon_log" >&2 || true
exit 1
fi
sleep 0.05
done
if [[ ! -S "$socket" ]]; then
echo "timed out waiting for Burrow daemon socket" >&2
cat "$daemon_log" >&2 || true
exit 1
fi
(
cd "$tmpdir"
BURROW_SOCKET_PATH="$socket" "$burrow_bin" network-add 1 3 "$payload"
BURROW_SOCKET_PATH="$socket" "$burrow_bin" tunnel-config
BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-tcp-probe \
--dns-name selftest.invalid \
--remote 1.1.1.1:80 \
--timeout-ms 8000
BURROW_SOCKET_PATH="$socket" "$burrow_bin" proxy-udp-echo \
--message burrow-singmux-udp-selftest \
--timeout-ms 8000 \
9.9.9.9:5353
)
wait "$server_pid"
server_pid=""
echo
echo "Mihomo sing-mux peer observed:"
cat "$result_file"
echo
uv run python - "$result_file" "$protocol" "$padding" <<'PY'
from __future__ import annotations
import json
import sys
with open(sys.argv[1], "r", encoding="utf-8") as f:
result = json.load(f)
expected_protocol = sys.argv[2]
expected_padding = sys.argv[3] == "true"
def expect(value: object, expected: object, label: str) -> None:
if value != expected:
raise AssertionError(f"{label}: expected {expected!r}, got {value!r}")
def expect_target(key: str, host: str, port: int) -> None:
target = result.get(key)
if not isinstance(target, dict):
raise AssertionError(f"{key}: missing target object")
expect(target.get("host"), host, f"{key}.host")
expect(target.get("port"), port, f"{key}.port")
expect(result.get("protocol"), expected_protocol, "sing-mux protocol")
expect(result.get("padding", False), expected_padding, "sing-mux padding")
expect_target("mux_target", "sp.mux.sing-box.arpa", 444)
expect_target("tcp_destination", "selftest.invalid", 80)
expect(result.get("tcp_first_line"), "GET / HTTP/1.1", "tcp first line")
expect_target("udp_destination", "9.9.9.9", 5353)
expect(result.get("udp_payload"), "burrow-singmux-udp-selftest", "udp payload")
expect(result.get("packet_reply_count"), 1, "packet reply count")
padding_label = "padded" if expected_padding else "unpadded"
print(f"Mihomo sing-mux {expected_protocol} {padding_label} interop assertions passed")
PY

4535
burrow-gtk/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
use super::*; use super::*;
use crate::account_store::{self, AccountKind, AccountRecord}; use crate::account_store::{self, AccountKind, AccountRecord};
use std::{cell::RefCell, rc::Rc, time::Duration}; use std::time::Duration;
pub struct HomeScreen { pub struct HomeScreen {
daemon_banner: adw::Banner, daemon_banner: adw::Banner,
@ -23,7 +23,6 @@ pub enum HomeScreenMsg {
OpenWireGuard, OpenWireGuard,
OpenTor, OpenTor,
OpenTailnet, OpenTailnet,
OpenProxySubscription,
AddWireGuard { AddWireGuard {
title: String, title: String,
account: String, account: String,
@ -53,14 +52,6 @@ pub enum HomeScreenMsg {
hostname: Option<String>, hostname: Option<String>,
tailnet: Option<String>, tailnet: Option<String>,
}, },
PreviewProxySubscription {
url: String,
},
AddProxySubscription {
url: String,
name: String,
selected_ordinal: Option<i32>,
},
} }
#[relm4::component(pub, async)] #[relm4::component(pub, async)]
@ -243,7 +234,7 @@ impl AsyncComponent for HomeScreen {
configure_add_popover(&widgets.add_button, &sender); configure_add_popover(&widgets.add_button, &sender);
let refresh_sender = sender.input_sender().clone(); let refresh_sender = sender.input_sender().clone();
gtk::glib::MainContext::default().spawn_local(async move { relm4::spawn(async move {
loop { loop {
tokio::time::sleep(Duration::from_secs(5)).await; tokio::time::sleep(Duration::from_secs(5)).await;
refresh_sender.emit(HomeScreenMsg::Refresh); refresh_sender.emit(HomeScreenMsg::Refresh);
@ -281,7 +272,6 @@ impl AsyncComponent for HomeScreen {
HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender), HomeScreenMsg::OpenWireGuard => open_wireguard_window(root, &sender),
HomeScreenMsg::OpenTor => open_tor_window(root, &sender), HomeScreenMsg::OpenTor => open_tor_window(root, &sender),
HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender), HomeScreenMsg::OpenTailnet => open_tailnet_window(root, &sender),
HomeScreenMsg::OpenProxySubscription => open_proxy_subscription_window(root, &sender),
HomeScreenMsg::AddWireGuard { HomeScreenMsg::AddWireGuard {
title, title,
account, account,
@ -314,13 +304,6 @@ impl AsyncComponent for HomeScreen {
self.add_tailnet(authority, account, identity, hostname, tailnet) self.add_tailnet(authority, account, identity, hostname, tailnet)
.await; .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;
}
} }
} }
} }
@ -684,85 +667,6 @@ 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) { fn apply_login_status(&mut self, status: &daemon_api::TailnetLoginStatus) {
self.tailnet_session_id = Some(status.session_id.clone()); self.tailnet_session_id = Some(status.session_id.clone());
self.tailnet_running = status.running; self.tailnet_running = status.running;
@ -831,10 +735,6 @@ fn configure_add_popover(button: &gtk::MenuButton, sender: &AsyncComponentSender
for (label, msg) in [ for (label, msg) in [
("Add WireGuard Network", HomeScreenMsg::OpenWireGuard), ("Add WireGuard Network", HomeScreenMsg::OpenWireGuard),
(
"Import Proxy Subscription",
HomeScreenMsg::OpenProxySubscription,
),
("Save Tor Account", HomeScreenMsg::OpenTor), ("Save Tor Account", HomeScreenMsg::OpenTor),
("Add Tailnet Account", HomeScreenMsg::OpenTailnet), ("Add Tailnet Account", HomeScreenMsg::OpenTailnet),
] { ] {
@ -855,7 +755,6 @@ fn msg_from_template(msg: &HomeScreenMsg) -> HomeScreenMsg {
HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard, HomeScreenMsg::OpenWireGuard => HomeScreenMsg::OpenWireGuard,
HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor, HomeScreenMsg::OpenTor => HomeScreenMsg::OpenTor,
HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet, HomeScreenMsg::OpenTailnet => HomeScreenMsg::OpenTailnet,
HomeScreenMsg::OpenProxySubscription => HomeScreenMsg::OpenProxySubscription,
_ => unreachable!(), _ => unreachable!(),
} }
} }
@ -1192,169 +1091,6 @@ fn open_tailnet_window(root: &gtk::ScrolledWindow, sender: &AsyncComponentSender
window.present(); 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 { fn sheet_window(root: &gtk::ScrolledWindow, title: &str, width: i32, height: i32) -> gtk::Window {
let window = gtk::Window::builder() let window = gtk::Window::builder()
.title(title) .title(title)

View file

@ -4,9 +4,7 @@ use burrow::{
grpc_defs::{ grpc_defs::{
Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest, Empty, Network, NetworkType, State, TailnetDiscoverRequest, TailnetLoginCancelRequest,
TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest, TailnetLoginStartRequest, TailnetLoginStatusRequest, TailnetProbeRequest,
ProxySubscriptionApplyRequest, ProxySubscriptionImportRequest,
}, },
proxy_subscription::ProxySubscriptionPayload,
BurrowClient, BurrowClient,
}; };
use std::{path::PathBuf, sync::OnceLock}; use std::{path::PathBuf, sync::OnceLock};
@ -56,27 +54,6 @@ pub struct TailnetLoginStatus {
pub health: Vec<String>, 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 { pub fn default_tailnet_authority() -> &'static str {
MANAGED_TAILSCALE_AUTHORITY MANAGED_TAILSCALE_AUTHORITY
} }
@ -239,63 +216,6 @@ pub async fn add_tailnet(
add_network(NetworkType::Tailnet, payload).await 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> { pub async fn discover_tailnet(email: String) -> Result<TailnetDiscovery> {
let mut client = BurrowClient::from_uds().await?; let mut client = BurrowClient::from_uds().await?;
let response = timeout( let response = timeout(
@ -408,7 +328,6 @@ fn summarize_network(network: &Network) -> NetworkSummary {
match network.r#type() { match network.r#type() {
NetworkType::WireGuard => summarize_wireguard(network), NetworkType::WireGuard => summarize_wireguard(network),
NetworkType::Tailnet => summarize_tailnet(network), NetworkType::Tailnet => summarize_tailnet(network),
NetworkType::ProxySubscription => summarize_proxy_subscription(network),
} }
} }
@ -453,21 +372,6 @@ 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( fn decode_tailnet_status(
response: burrow::grpc_defs::TailnetLoginStatusResponse, response: burrow::grpc_defs::TailnetLoginStatusResponse,
) -> TailnetLoginStatus { ) -> TailnetLoginStatus {

View file

@ -31,24 +31,12 @@ tracing-subscriber = { version = "0.3", features = ["std", "env-filter"] }
log = "0.4" log = "0.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9"
blake2 = "0.10" blake2 = "0.10"
blake3 = "1"
chacha20 = "0.9"
chacha20poly1305 = "0.10" chacha20poly1305 = "0.10"
rand = "0.8" rand = "0.8"
bytes = "1" bytes = "1"
rand_core = "0.6" rand_core = "0.6"
aead = "0.5" aead = "0.5"
aegis = { version = "0.9.9", default-features = false, features = ["pure-rust"] }
aes = "0.8"
aes-gcm = "0.10"
ccm = "0.5"
deoxys = { version = "0.2.0-rc.3", default-features = false }
lea = "0.5.4"
poly1305 = "0.8"
rabbit = "0.5"
zears = "0.2.1"
x25519-dalek = { version = "2.0", features = [ x25519-dalek = { version = "2.0", features = [
"reusable_secrets", "reusable_secrets",
"static_secrets", "static_secrets",
@ -56,7 +44,6 @@ x25519-dalek = { version = "2.0", features = [
ring = "0.17" ring = "0.17"
parking_lot = "0.12" parking_lot = "0.12"
hmac = "0.12" hmac = "0.12"
md-5 = "0.10"
base64 = "0.21" base64 = "0.21"
fehler = "1.0" fehler = "1.0"
ip_network_table = "0.2" ip_network_table = "0.2"
@ -70,7 +57,6 @@ arti-client = "0.40.0"
hickory-proto = "0.25.2" hickory-proto = "0.25.2"
netstack-smoltcp = "0.2.1" netstack-smoltcp = "0.2.1"
tokio-util = { version = "0.7.18", features = ["compat"] } tokio-util = { version = "0.7.18", features = ["compat"] }
tokio_smux = "0.2"
tor-rtcompat = "0.40.0" tor-rtcompat = "0.40.0"
console-subscriber = { version = "0.2.0", optional = true } console-subscriber = { version = "0.2.0", optional = true }
console = "0.15.8" console = "0.15.8"
@ -92,48 +78,6 @@ hyper-util = "0.1.6"
toml = "0.8.15" toml = "0.8.15"
rust-ini = "0.21.0" rust-ini = "0.21.0"
subtle = "2.6" subtle = "2.6"
rustls = { version = "0.23", default-features = false, features = [
"aws_lc_rs",
"logging",
"std",
"tls12",
] }
rustls-pemfile = "2"
sha2 = "0.10"
tokio-rustls = "0.26"
rama-tls-boring = { version = "0.3.0-alpha.4", default-features = false, optional = true }
rama-ua = { version = "0.3.0-alpha.4", default-features = false, optional = true, features = [
"embed-profiles",
"tls",
] }
rama-net = { version = "0.3.0-alpha.4", default-features = false, optional = true, features = ["tls"] }
webpki-roots = "0.26"
webpki-roots-legacy = { package = "webpki-roots", version = "0.22" }
rust_tokio_kcp = { version = "0.2.5", default-features = false, features = [
"aes",
"tea",
"xtea",
"simple_xor",
"blowfish",
"cast5",
"triple_des",
"twofish",
"salsa20",
"sm4",
"aes-gcm",
] }
smux_rust = "0.2.1"
tokio-snappy = "0.2.0"
shadowsocks = { version = "1.24.0", default-features = false, features = [
"aead-cipher",
"aead-cipher-extra",
"aead-cipher-2022",
"aead-cipher-2022-extra",
"stream-cipher",
] }
yamux = "0.13.10"
h2 = "0.4.12"
http = "1.3.1"
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
caps = "0.5" caps = "0.5"
@ -143,11 +87,8 @@ nix = { version = "0.27", features = ["fs", "socket", "uio"] }
tracing-journald = "0.3" tracing-journald = "0.3"
[target.'cfg(target_vendor = "apple")'.dependencies] [target.'cfg(target_vendor = "apple")'.dependencies]
libc = "0.2"
native-tls = { version = "0.2", features = ["alpn"] }
nix = { version = "0.27" } nix = { version = "0.27" }
rusqlite = { version = "0.38.0", features = ["bundled", "blob"] } rusqlite = { version = "0.38.0", features = ["bundled", "blob"] }
tokio-native-tls = "0.3"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" } tracing-oslog = { git = "https://github.com/Stormshield-robinc/tracing-oslog" }
@ -168,11 +109,6 @@ pre_uninstall_script = "../package/rpm/pre_uninstall"
[features] [features]
tokio-console = ["dep:console-subscriber"] tokio-console = ["dep:console-subscriber"]
bundled = ["rusqlite/bundled"] bundled = ["rusqlite/bundled"]
boring-browser-fingerprints = [
"dep:rama-tls-boring",
"dep:rama-ua",
"dep:rama-net",
]
[build-dependencies] [build-dependencies]

View file

@ -147,10 +147,7 @@ pub fn build_router(config: AuthServerConfig) -> Router {
.route("/v1/control/map", post(control_map)) .route("/v1/control/map", post(control_map))
.route("/v1/tailnet/discover", get(tailnet_discover)) .route("/v1/tailnet/discover", get(tailnet_discover))
.route("/v1/tailscale/login/start", post(tailscale_login_start)) .route("/v1/tailscale/login/start", post(tailscale_login_start))
.route( .route("/v1/tailscale/login/:session_id", get(tailscale_login_status))
"/v1/tailscale/login/:session_id",
get(tailscale_login_status),
)
.with_state(AppState { .with_state(AppState {
config, config,
tailscale: tailscale::TailscaleBridgeManager::default(), tailscale: tailscale::TailscaleBridgeManager::default(),
@ -250,12 +247,7 @@ async fn tailscale_login_status(
.await .await
.map_err(internal_error)? .map_err(internal_error)?
.map(Json) .map(Json)
.ok_or_else(|| { .ok_or_else(|| (StatusCode::NOT_FOUND, "unknown tailscale login session".to_owned()))
(
StatusCode::NOT_FOUND,
"unknown tailscale login session".to_owned(),
)
})
} }
async fn healthz() -> impl IntoResponse { async fn healthz() -> impl IntoResponse {
@ -427,7 +419,10 @@ mod tests {
async fn tailnet_discover_requires_email() -> Result<()> { async fn tailnet_discover_requires_email() -> Result<()> {
let app = build_router(AuthServerConfig::default()); let app = build_router(AuthServerConfig::default());
let response = app let response = app
.oneshot(Request::get("/v1/tailnet/discover?email=").body(Body::empty())?) .oneshot(
Request::get("/v1/tailnet/discover?email=")
.body(Body::empty())?,
)
.await?; .await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!(response.status(), StatusCode::BAD_REQUEST);
Ok(()) Ok(())

View file

@ -291,10 +291,7 @@ impl TailscaleHelperProcess {
} }
async fn shutdown_with_client(&self, client: &Client) -> Result<()> { async fn shutdown_with_client(&self, client: &Client) -> Result<()> {
let _ = client let _ = client.post(format!("{}/shutdown", self.listen_url)).send().await;
.post(format!("{}/shutdown", self.listen_url))
.send()
.await;
for _ in 0..10 { for _ in 0..10 {
let mut child = self.child.lock().await; let mut child = self.child.lock().await;
@ -405,9 +402,7 @@ fn helper_command(request: &TailscaleLoginStartRequest, state_dir: &Path) -> Res
} }
pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf { pub(crate) fn packet_socket_path(request: &TailscaleLoginStartRequest) -> PathBuf {
state_root() state_root().join(session_dir_name(request)).join("packet.sock")
.join(session_dir_name(request))
.join("packet.sock")
} }
pub(crate) fn state_root() -> PathBuf { pub(crate) fn state_root() -> PathBuf {
@ -509,10 +504,7 @@ mod tests {
control_url: None, control_url: None,
packet_socket: None, packet_socket: None,
}; };
assert_eq!( assert_eq!(session_dir_name(&request), "default-apple-tailscale-managed");
session_dir_name(&request),
"default-apple-tailscale-managed"
);
assert_eq!(default_hostname(&request), "burrow-apple"); assert_eq!(default_hostname(&request), "burrow-apple");
let custom_request = TailscaleLoginStartRequest { let custom_request = TailscaleLoginStartRequest {

View file

@ -92,13 +92,8 @@ pub async fn probe_tailnet_authority(authority: &str) -> Result<TailnetAuthority
.build() .build()
.context("failed to build tailnet authority probe client")?; .context("failed to build tailnet authority probe client")?;
if let Some(status) = probe_url( if let Some(status) =
&client, probe_url(&client, base_url.join("/health")?, &authority, "Tailnet server reachable").await?
base_url.join("/health")?,
&authority,
"Tailnet server reachable",
)
.await?
{ {
return Ok(status); return Ok(status);
} }
@ -193,17 +188,11 @@ async fn discover_well_known(client: &Client, base_url: &Url) -> Result<Option<T
.context("invalid tailnet discovery document") .context("invalid tailnet discovery document")
.map(Some), .map(Some),
StatusCode::NOT_FOUND => Ok(None), StatusCode::NOT_FOUND => Ok(None),
status => Err(anyhow!( status => Err(anyhow!("tailnet well-known lookup failed with HTTP {status}")),
"tailnet well-known lookup failed with HTTP {status}"
)),
} }
} }
async fn discover_webfinger( async fn discover_webfinger(client: &Client, email: &str, base_url: &Url) -> Result<Option<String>> {
client: &Client,
email: &str,
base_url: &Url,
) -> Result<Option<String>> {
let mut url = base_url let mut url = base_url
.join(WEBFINGER_PATH) .join(WEBFINGER_PATH)
.context("failed to build webfinger URL")?; .context("failed to build webfinger URL")?;
@ -233,9 +222,7 @@ async fn discover_webfinger(
.filter(|href| !href.trim().is_empty())) .filter(|href| !href.trim().is_empty()))
} }
StatusCode::NOT_FOUND => Ok(None), StatusCode::NOT_FOUND => Ok(None),
status => Err(anyhow!( status => Err(anyhow!("tailnet webfinger lookup failed with HTTP {status}")),
"tailnet webfinger lookup failed with HTTP {status}"
)),
} }
} }
@ -287,9 +274,7 @@ mod tests {
#[test] #[test]
fn detects_managed_tailscale_authority() { fn detects_managed_tailscale_authority() {
assert!(is_managed_tailscale_authority("controlplane.tailscale.com")); assert!(is_managed_tailscale_authority("controlplane.tailscale.com"));
assert!(is_managed_tailscale_authority( assert!(is_managed_tailscale_authority("https://controlplane.tailscale.com/"));
"https://controlplane.tailscale.com/"
));
assert!(!is_managed_tailscale_authority("https://ts.burrow.net")); assert!(!is_managed_tailscale_authority("https://ts.burrow.net"));
} }

View file

@ -1,5 +1,3 @@
#[cfg(unix)]
use std::os::unix::net::UnixStream;
use std::{ use std::{
ffi::{c_char, CStr}, ffi::{c_char, CStr},
path::PathBuf, path::PathBuf,
@ -36,17 +34,12 @@ 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>) { 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(); crate::tracing::initialize();
let _guard = BURROW_SPAWN_LOCK.lock().unwrap(); let _guard = BURROW_SPAWN_LOCK.lock().unwrap();
if BURROW_READY.get().is_some() { if BURROW_READY.get().is_some() {
return; return;
} }
if socket_is_reachable(path_buf.as_ref()) {
let _ = BURROW_READY.set(());
return;
}
let notify = Arc::new(Notify::new()); let notify = Arc::new(Notify::new());
let handle = BURROW_HANDLE.get_or_init(|| { let handle = BURROW_HANDLE.get_or_init(|| {
@ -81,28 +74,3 @@ pub fn spawn_in_process_with_paths(path_buf: Option<PathBuf>, db_path_buf: Optio
handle.block_on(async move { receiver.notified().await }); handle.block_on(async move { receiver.notified().await });
let _ = BURROW_READY.set(()); 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,20 +8,16 @@ use rusqlite::Connection;
use tokio::sync::{mpsc, watch, RwLock}; use tokio::sync::{mpsc, watch, RwLock};
use tokio_stream::wrappers::ReceiverStream; use tokio_stream::wrappers::ReceiverStream;
use tonic::{Request, Response, Status as RspStatus}; use tonic::{Request, Response, Status as RspStatus};
use tracing::{debug, error, info, warn}; use tracing::{debug, info, warn};
use tun::tokio::TunInterface; use tun::tokio::TunInterface;
use super::{ use super::{
rpc::grpc_defs::{ rpc::grpc_defs::{
networks_server::Networks, proxy_subscriptions_server::ProxySubscriptions, networks_server::Networks, tailnet_control_server::TailnetControl, tunnel_server::Tunnel,
tailnet_control_server::TailnetControl, tunnel_server::Tunnel, Empty, Network, Empty, Network, NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest,
NetworkDeleteRequest, NetworkListResponse, NetworkReorderRequest, NetworkType, State as RPCTunnelState, TailnetDiscoverRequest, TailnetDiscoverResponse,
ProxySubscriptionApplyRequest, ProxySubscriptionApplyResponse, TailnetProbeRequest, TailnetProbeResponse, TunnelConfigurationResponse, TunnelPacket,
ProxySubscriptionImportRequest, ProxySubscriptionNodePreview, TunnelStatusResponse,
ProxySubscriptionPreviewResponse, ProxySubscriptionRejectedEntry,
ProxySubscriptionSelectRequest, ProxySubscriptionSelectResponse, State as RPCTunnelState,
TailnetDiscoverRequest, TailnetDiscoverResponse, TailnetProbeRequest, TailnetProbeResponse,
TunnelConfigurationResponse, TunnelPacket, TunnelStatusResponse,
}, },
runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel}, runtime::{tailnet_helper_request, ActiveTunnel, ResolvedTunnel},
}; };
@ -32,11 +28,7 @@ use crate::{
}, },
control::discovery, control::discovery,
daemon::rpc::ServerConfig, daemon::rpc::ServerConfig,
database::{ database::{add_network, delete_network, get_connection, list_networks, reorder_network},
add_network, delete_network, get_connection, list_networks, proxy_subscription_payload,
reorder_network, select_proxy_subscription_node, upsert_network,
},
proxy_subscription,
}; };
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -97,7 +89,9 @@ impl DaemonRPCServer {
async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> { async fn current_tunnel_configuration(&self) -> Result<TunnelConfigurationResponse, RspStatus> {
let config = { let config = {
let active = self.active_tunnel.read().await; 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 { let config = match config {
Some(config) => config, Some(config) => config,
@ -110,22 +104,6 @@ impl DaemonRPCServer {
Ok(configuration_rsp(config)) 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> { async fn stop_active_tunnel(&self) -> Result<bool, RspStatus> {
let current = { self.active_tunnel.write().await.take() }; let current = { self.active_tunnel.write().await.take() };
let Some(current) = current else { let Some(current) = current else {
@ -240,7 +218,9 @@ impl Tunnel for DaemonRPCServer {
return Err(RspStatus::failed_precondition("no active tunnel")); return Err(RspStatus::failed_precondition("no active tunnel"));
}; };
active.packet_stream().ok_or_else(|| { 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",
)
})? })?
}; };
@ -393,98 +373,6 @@ 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] #[tonic::async_trait]
impl TailnetControl for DaemonRPCServer { impl TailnetControl for DaemonRPCServer {
async fn discover( async fn discover(
@ -612,9 +500,7 @@ impl TailnetControl for DaemonRPCServer {
} }
fn proc_err(err: impl ToString) -> RspStatus { fn proc_err(err: impl ToString) -> RspStatus {
let message = err.to_string(); RspStatus::internal(err.to_string())
error!(error = %message, "daemon RPC failed");
RspStatus::internal(message)
} }
fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse { fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
@ -622,7 +508,6 @@ fn configuration_rsp(config: ServerConfig) -> TunnelConfigurationResponse {
addresses: config.address, addresses: config.address,
mtu: config.mtu.unwrap_or(1000), mtu: config.mtu.unwrap_or(1000),
routes: config.routes, routes: config.routes,
excluded_routes: config.excluded_routes,
dns_servers: config.dns_servers, dns_servers: config.dns_servers,
search_domains: config.search_domains, search_domains: config.search_domains,
include_default_route: config.include_default_route, include_default_route: config.include_default_route,
@ -636,107 +521,6 @@ 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( fn tailnet_login_rsp(
session_id: String, session_id: String,
status: TailscaleLoginStatus, status: TailscaleLoginStatus,
@ -754,54 +538,3 @@ fn tailnet_login_rsp(
health: status.health, 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::{io, os::unix::net::UnixStream as StdUnixStream, path::Path, sync::Arc}; use std::{path::Path, sync::Arc};
pub mod apple; pub mod apple;
mod instance; mod instance;
@ -17,8 +17,8 @@ use tracing::info;
use crate::{ use crate::{
daemon::rpc::grpc_defs::{ daemon::rpc::grpc_defs::{
networks_server::NetworksServer, proxy_subscriptions_server::ProxySubscriptionsServer, networks_server::NetworksServer, tailnet_control_server::TailnetControlServer,
tailnet_control_server::TailnetControlServer, tunnel_server::TunnelServer, tunnel_server::TunnelServer,
}, },
database::get_connection, database::get_connection,
}; };
@ -33,23 +33,16 @@ pub async fn daemon_main(
let spp = socket_path.clone(); let spp = socket_path.clone();
let tmp = get_socket_path(); let tmp = get_socket_path();
let sock_path = spp.unwrap_or(Path::new(tmp.as_str())); let sock_path = spp.unwrap_or(Path::new(tmp.as_str()));
let uds = match bind_daemon_socket(sock_path)? { if sock_path.exists() {
Some(uds) => uds, std::fs::remove_file(sock_path)?;
None => { }
if let Some(n) = notify_ready { let uds = UnixListener::bind(sock_path)?;
n.notify_one();
}
info!(path = %sock_path.display(), "daemon socket already served by another process");
return Ok(());
}
};
let serve_job = tokio::spawn(async move { let serve_job = tokio::spawn(async move {
let uds_stream = UnixListenerStream::new(uds); let uds_stream = UnixListenerStream::new(uds);
let tailnet_server = burrow_server.clone(); let tailnet_server = burrow_server.clone();
let _srv = Server::builder() let _srv = Server::builder()
.add_service(TunnelServer::new(burrow_server.clone())) .add_service(TunnelServer::new(burrow_server.clone()))
.add_service(NetworksServer::new(burrow_server.clone())) .add_service(NetworksServer::new(burrow_server))
.add_service(ProxySubscriptionsServer::new(burrow_server.clone()))
.add_service(TailnetControlServer::new(tailnet_server)) .add_service(TailnetControlServer::new(tailnet_server))
.serve_with_incoming(uds_stream) .serve_with_incoming(uds_stream)
.await?; .await?;
@ -67,24 +60,6 @@ pub async fn daemon_main(
.map_err(|e| e.into()) .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)] #[cfg(test)]
mod tests { mod tests {
use std::{ use std::{

View file

@ -6,14 +6,13 @@ use tonic::transport::{Endpoint, Uri};
use tower::service_fn; use tower::service_fn;
use super::grpc_defs::{ use super::grpc_defs::{
networks_client::NetworksClient, proxy_subscriptions_client::ProxySubscriptionsClient, networks_client::NetworksClient, tailnet_control_client::TailnetControlClient,
tailnet_control_client::TailnetControlClient, tunnel_client::TunnelClient, tunnel_client::TunnelClient,
}; };
use crate::daemon::get_socket_path; use crate::daemon::get_socket_path;
pub struct BurrowClient<T> { pub struct BurrowClient<T> {
pub networks_client: NetworksClient<T>, pub networks_client: NetworksClient<T>,
pub proxy_subscription_client: ProxySubscriptionsClient<T>,
pub tailnet_client: TailnetControlClient<T>, pub tailnet_client: TailnetControlClient<T>,
pub tunnel_client: TunnelClient<T>, pub tunnel_client: TunnelClient<T>,
} }
@ -36,12 +35,10 @@ impl BurrowClient<tonic::transport::Channel> {
})) }))
.await?; .await?;
let nw_client = NetworksClient::new(channel.clone()); let nw_client = NetworksClient::new(channel.clone());
let proxy_subscription_client = ProxySubscriptionsClient::new(channel.clone());
let tailnet_client = TailnetControlClient::new(channel.clone()); let tailnet_client = TailnetControlClient::new(channel.clone());
let tun_client = TunnelClient::new(channel.clone()); let tun_client = TunnelClient::new(channel.clone());
Ok(BurrowClient { Ok(BurrowClient {
networks_client: nw_client, networks_client: nw_client,
proxy_subscription_client,
tailnet_client, tailnet_client,
tunnel_client: tun_client, tunnel_client: tun_client,
}) })

View file

@ -71,8 +71,6 @@ pub struct ServerConfig {
#[serde(default)] #[serde(default)]
pub routes: Vec<String>, pub routes: Vec<String>,
#[serde(default)] #[serde(default)]
pub excluded_routes: Vec<String>,
#[serde(default)]
pub dns_servers: Vec<String>, pub dns_servers: Vec<String>,
#[serde(default)] #[serde(default)]
pub search_domains: Vec<String>, pub search_domains: Vec<String>,
@ -93,7 +91,6 @@ impl TryFrom<&Config> for ServerConfig {
.iter() .iter()
.flat_map(|peer| peer.allowed_ips.iter().cloned()) .flat_map(|peer| peer.allowed_ips.iter().cloned())
.collect(), .collect(),
excluded_routes: Vec::new(),
dns_servers: config.interface.dns.clone(), dns_servers: config.interface.dns.clone(),
search_domains: Vec::new(), search_domains: Vec::new(),
include_default_route: false, include_default_route: false,
@ -108,7 +105,6 @@ impl Default for ServerConfig {
Self { Self {
address: vec!["10.13.13.2".to_string()], // Dummy remote address address: vec!["10.13.13.2".to_string()], // Dummy remote address
routes: Vec::new(), routes: Vec::new(),
excluded_routes: Vec::new(),
dns_servers: Vec::new(), dns_servers: Vec::new(),
search_domains: Vec::new(), search_domains: Vec::new(),
include_default_route: false, include_default_route: false,

View file

@ -2,4 +2,4 @@
source: burrow/src/daemon/rpc/response.rs source: burrow/src/daemon/rpc/response.rs
expression: "serde_json::to_string(&DaemonResponse::new(Ok::<DaemonResponseData,\n String>(DaemonResponseData::ServerConfig(ServerConfig::default()))))?" 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":[],"excluded_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":[],"dns_servers":[],"search_domains":[],"include_default_route":false,"name":null,"mtu":null}},"id":0}

View file

@ -20,8 +20,6 @@ use crate::{
TailscaleLoginStartRequest, TailscaleLoginStatus, TailscaleLoginStartRequest, TailscaleLoginStatus,
}, },
control::{discovery, TailnetConfig}, control::{discovery, TailnetConfig},
proxy_runtime,
proxy_subscription::ProxySubscriptionPayload,
wireguard::{Config, Interface as WireGuardInterface}, wireguard::{Config, Interface as WireGuardInterface},
}; };
@ -48,10 +46,6 @@ pub enum ResolvedTunnel {
identity: RuntimeIdentity, identity: RuntimeIdentity,
config: Config, config: Config,
}, },
ProxySubscription {
identity: RuntimeIdentity,
payload: ProxySubscriptionPayload,
},
} }
impl ResolvedTunnel { impl ResolvedTunnel {
@ -79,12 +73,6 @@ impl ResolvedTunnel {
let config = Config::from_content_fmt(&payload, "ini")?; let config = Config::from_content_fmt(&payload, "ini")?;
Ok(Self::WireGuard { identity, config }) Ok(Self::WireGuard { identity, config })
} }
NetworkType::ProxySubscription => {
let payload: ProxySubscriptionPayload = serde_json::from_slice(&network.payload)
.context("proxy subscription payload must be valid JSON")?;
crate::proxy_subscription::validate_payload(&payload)?;
Ok(Self::ProxySubscription { identity, payload })
}
} }
} }
@ -92,8 +80,7 @@ impl ResolvedTunnel {
match self { match self {
Self::Passthrough { identity } Self::Passthrough { identity }
| Self::Tailnet { identity, .. } | Self::Tailnet { identity, .. }
| Self::WireGuard { identity, .. } | Self::WireGuard { identity, .. } => identity,
| Self::ProxySubscription { identity, .. } => identity,
} }
} }
@ -102,7 +89,6 @@ impl ResolvedTunnel {
Self::Passthrough { .. } => Ok(ServerConfig { Self::Passthrough { .. } => Ok(ServerConfig {
address: Vec::new(), address: Vec::new(),
routes: Vec::new(), routes: Vec::new(),
excluded_routes: Vec::new(),
dns_servers: Vec::new(), dns_servers: Vec::new(),
search_domains: Vec::new(), search_domains: Vec::new(),
include_default_route: false, include_default_route: false,
@ -112,7 +98,6 @@ impl ResolvedTunnel {
Self::Tailnet { .. } => Ok(ServerConfig { Self::Tailnet { .. } => Ok(ServerConfig {
address: Vec::new(), address: Vec::new(),
routes: tailnet_routes(), routes: tailnet_routes(),
excluded_routes: Vec::new(),
dns_servers: tailnet_dns_servers(), dns_servers: tailnet_dns_servers(),
search_domains: Vec::new(), search_domains: Vec::new(),
include_default_route: false, include_default_route: false,
@ -120,7 +105,6 @@ impl ResolvedTunnel {
mtu: Some(1280), mtu: Some(1280),
}), }),
Self::WireGuard { config, .. } => ServerConfig::try_from(config), Self::WireGuard { config, .. } => ServerConfig::try_from(config),
Self::ProxySubscription { payload, .. } => proxy_subscription_server_config(payload),
} }
} }
@ -135,7 +119,6 @@ impl ResolvedTunnel {
server_config: ServerConfig { server_config: ServerConfig {
address: Vec::new(), address: Vec::new(),
routes: Vec::new(), routes: Vec::new(),
excluded_routes: Vec::new(),
dns_servers: Vec::new(), dns_servers: Vec::new(),
search_domains: Vec::new(), search_domains: Vec::new(),
include_default_route: false, include_default_route: false,
@ -154,9 +137,10 @@ impl ResolvedTunnel {
}; };
let status = wait_for_tailnet_ready(helper.as_ref()).await?; let status = wait_for_tailnet_ready(helper.as_ref()).await?;
let server_config = tailnet_server_config(&status); let server_config = tailnet_server_config(&status);
let packet_socket = helper.packet_socket().map(PathBuf::from).ok_or_else(|| { let packet_socket = helper
anyhow::anyhow!("tailnet helper did not report a packet socket") .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?; let packet_bridge = connect_tailnet_packet_bridge(packet_socket).await?;
#[cfg(target_vendor = "apple")] #[cfg(target_vendor = "apple")]
let tun_task = None; let tun_task = None;
@ -198,61 +182,10 @@ impl ResolvedTunnel {
} }
} }
} }
Self::ProxySubscription { identity, payload } => {
let runtime_config = proxy_runtime::proxy_runtime_config(&payload)?;
let server_config = proxy_subscription_server_config_for_selected(
&payload,
&runtime_config.selected_name,
)?;
let packet_bridge =
proxy_runtime::start_proxy_packet_bridge(runtime_config.outbound);
#[cfg(target_vendor = "apple")]
let tun_task = None;
#[cfg(not(target_vendor = "apple"))]
let tun_task = {
let tun = TunOptions::new().open()?;
tun_interface.write().await.replace(tun);
Some(tokio::spawn(proxy_runtime::run_proxy_tun_bridge(
tun_interface.clone(),
packet_bridge.outbound_sender(),
packet_bridge.subscribe(),
)))
};
Ok(ActiveTunnel::ProxySubscription {
identity,
server_config,
packet_bridge,
tun_task,
})
}
} }
} }
} }
fn proxy_subscription_server_config(payload: &ProxySubscriptionPayload) -> Result<ServerConfig> {
let runtime_config = proxy_runtime::proxy_runtime_config(payload)?;
proxy_subscription_server_config_for_selected(payload, &runtime_config.selected_name)
}
fn proxy_subscription_server_config_for_selected(
payload: &ProxySubscriptionPayload,
selected_name: &str,
) -> Result<ServerConfig> {
let dns_servers = proxy_runtime::proxy_dns_servers();
let mut excluded_routes = proxy_runtime::proxy_excluded_routes(payload)?;
excluded_routes.extend(proxy_runtime::proxy_dns_excluded_routes(&dns_servers));
Ok(ServerConfig {
address: proxy_runtime::proxy_server_addresses(),
routes: Vec::new(),
excluded_routes,
dns_servers,
search_domains: Vec::new(),
include_default_route: true,
name: Some(format!("{} ({})", payload.name, selected_name)),
mtu: Some(1500),
})
}
pub enum ActiveTunnel { pub enum ActiveTunnel {
Passthrough { Passthrough {
identity: RuntimeIdentity, identity: RuntimeIdentity,
@ -272,12 +205,6 @@ pub enum ActiveTunnel {
interface: Arc<RwLock<WireGuardInterface>>, interface: Arc<RwLock<WireGuardInterface>>,
task: JoinHandle<Result<()>>, task: JoinHandle<Result<()>>,
}, },
ProxySubscription {
identity: RuntimeIdentity,
server_config: ServerConfig,
packet_bridge: proxy_runtime::ProxyPacketBridge,
tun_task: Option<JoinHandle<Result<()>>>,
},
} }
impl ActiveTunnel { impl ActiveTunnel {
@ -285,8 +212,7 @@ impl ActiveTunnel {
match self { match self {
Self::Passthrough { identity, .. } Self::Passthrough { identity, .. }
| Self::Tailnet { identity, .. } | Self::Tailnet { identity, .. }
| Self::WireGuard { identity, .. } | Self::WireGuard { identity, .. } => identity,
| Self::ProxySubscription { identity, .. } => identity,
} }
} }
@ -294,54 +220,22 @@ impl ActiveTunnel {
match self { match self {
Self::Passthrough { server_config, .. } Self::Passthrough { server_config, .. }
| Self::Tailnet { server_config, .. } | Self::Tailnet { server_config, .. }
| Self::WireGuard { server_config, .. } | Self::WireGuard { server_config, .. } => 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 { match self {
Self::Tailnet { packet_bridge, .. } => { Self::Tailnet { packet_bridge, .. } => Some((
Some((packet_bridge.outbound_sender(), packet_bridge.subscribe())) packet_bridge.outbound_sender(),
} packet_bridge.subscribe(),
Self::ProxySubscription { packet_bridge, .. } => { )),
Some((packet_bridge.outbound_sender(), packet_bridge.subscribe()))
}
_ => None, _ => 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<()> { pub async fn shutdown(self, tun_interface: &Arc<RwLock<Option<TunInterface>>>) -> Result<()> {
match self { match self {
Self::Passthrough { .. } => Ok(()), Self::Passthrough { .. } => Ok(()),
@ -374,28 +268,17 @@ impl ActiveTunnel {
} }
Ok(()) Ok(())
} }
Self::WireGuard { interface, task, .. } => { Self::WireGuard {
interface,
task,
..
} => {
interface.read().await.remove_tun().await; interface.read().await.remove_tun().await;
let task_result = task.await; let task_result = task.await;
tun_interface.write().await.take(); tun_interface.write().await.take();
task_result??; task_result??;
Ok(()) 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(())
}
} }
} }
} }
@ -513,7 +396,6 @@ fn tailnet_server_config(status: &TailscaleLoginStatus) -> ServerConfig {
.map(|ip| tailnet_cidr(ip)) .map(|ip| tailnet_cidr(ip))
.collect(), .collect(),
routes: tailnet_routes(), routes: tailnet_routes(),
excluded_routes: Vec::new(),
dns_servers: tailnet_dns_servers(), dns_servers: tailnet_dns_servers(),
search_domains, search_domains,
include_default_route: false, include_default_route: false,
@ -723,10 +605,7 @@ mod tests {
fn tailnet_server_config_uses_host_prefixes() { fn tailnet_server_config_uses_host_prefixes() {
let status = TailscaleLoginStatus { let status = TailscaleLoginStatus {
running: true, running: true,
tailscale_ips: vec![ tailscale_ips: vec!["100.101.102.103".to_owned(), "fd7a:115c:a1e0::123".to_owned()],
"100.101.102.103".to_owned(),
"fd7a:115c:a1e0::123".to_owned(),
],
..Default::default() ..Default::default()
}; };
let config = tailnet_server_config(&status); let config = tailnet_server_config(&status);

View file

@ -1,6 +1,6 @@
use std::path::Path; use std::path::Path;
use anyhow::{anyhow, Result}; use anyhow::Result;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use crate::{ use crate::{
@ -8,7 +8,6 @@ use crate::{
daemon::rpc::grpc_defs::{ daemon::rpc::grpc_defs::{
Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType, Network as RPCNetwork, NetworkDeleteRequest, NetworkReorderRequest, NetworkType,
}, },
proxy_subscription::ProxySubscriptionPayload,
wireguard::config::{Config, Interface, Peer}, wireguard::config::{Config, Interface, Peer},
}; };
@ -139,18 +138,6 @@ pub fn add_network(conn: &Connection, network: &RPCNetwork) -> Result<()> {
Ok(()) 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>> { 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 mut stmt = conn.prepare("SELECT id, type, payload FROM network ORDER BY idx, id")?;
let networks: Vec<RPCNetwork> = stmt let networks: Vec<RPCNetwork> = stmt
@ -196,70 +183,6 @@ pub fn delete_network(conn: &Connection, req: NetworkDeleteRequest) -> Result<()
renumber_networks(conn, &ordered_ids) 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> { fn parse_lst(s: &str) -> Vec<String> {
if s.is_empty() { if s.is_empty() {
return vec![]; return vec![];
@ -283,10 +206,6 @@ fn validate_network_payload(network: &RPCNetwork) -> Result<()> {
NetworkType::Tailnet => { NetworkType::Tailnet => {
TailnetConfig::from_slice(&network.payload)?; TailnetConfig::from_slice(&network.payload)?;
} }
NetworkType::ProxySubscription => {
let payload: ProxySubscriptionPayload = serde_json::from_slice(&network.payload)?;
crate::proxy_subscription::validate_payload(&payload)?;
}
} }
Ok(()) Ok(())
} }
@ -359,79 +278,6 @@ Endpoint = wg.burrow.rs:51820
.to_vec() .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] #[test]
fn test_db() { fn test_db() {
let conn = Connection::open_in_memory().unwrap(); let conn = Connection::open_in_memory().unwrap();
@ -471,16 +317,6 @@ Endpoint = wg.burrow.rs:51820
&conn, &conn,
&RPCNetwork { &RPCNetwork {
id: 3, id: 3,
r#type: NetworkType::ProxySubscription.into(),
payload: sample_proxy_subscription_payload(),
},
)
.unwrap();
add_network(
&conn,
&RPCNetwork {
id: 4,
r#type: NetworkType::WireGuard.into(), r#type: NetworkType::WireGuard.into(),
payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380), payload: sample_wireguard_payload_with_address("10.42.0.2/32", 1380),
}, },
@ -490,7 +326,7 @@ Endpoint = wg.burrow.rs:51820
assert!(add_network( assert!(add_network(
&conn, &conn,
&RPCNetwork { &RPCNetwork {
id: 5, id: 4,
r#type: NetworkType::WireGuard.into(), r#type: NetworkType::WireGuard.into(),
payload: b"not-a-config".to_vec(), payload: b"not-a-config".to_vec(),
}, },
@ -500,29 +336,19 @@ Endpoint = wg.burrow.rs:51820
assert!(add_network( assert!(add_network(
&conn, &conn,
&RPCNetwork { &RPCNetwork {
id: 6, id: 5,
r#type: NetworkType::Tailnet.into(), r#type: NetworkType::Tailnet.into(),
payload: b"not-a-tailnet-config".to_vec(), payload: b"not-a-tailnet-config".to_vec(),
}, },
) )
.is_err()); .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) let ids: Vec<i32> = list_networks(&conn)
.unwrap() .unwrap()
.into_iter() .into_iter()
.map(|n| n.id) .map(|n| n.id)
.collect(); .collect();
assert_eq!(ids, vec![1, 2, 3, 4]); assert_eq!(ids, vec![1, 2, 3]);
} }
#[test] #[test]
@ -563,87 +389,6 @@ Endpoint = wg.burrow.rs:51820
assert_eq!(ids, vec![3, 2]); 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] #[test]
fn get_connection_does_not_seed_a_default_interface() { fn get_connection_does_not_seed_a_default_interface() {
let dir = tempdir().unwrap(); let dir = tempdir().unwrap();

View file

@ -1,9 +1,6 @@
#[cfg(any(target_os = "linux", target_vendor = "apple"))] #[cfg(any(target_os = "linux", target_vendor = "apple"))]
pub mod control; 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"))] #[cfg(any(target_os = "linux", target_vendor = "apple"))]
pub mod wireguard; pub mod wireguard;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,961 +0,0 @@
impl ProxyServerEndpoint {
fn resolve(host: &str, port: u16) -> Result<Self> {
let addresses = resolve_server_addresses(host, port)?;
let endpoint = Self {
host: host.to_owned(),
port,
addresses: Arc::from(addresses),
};
if endpoint
.addresses
.iter()
.any(|address| is_fake_or_benchmark_ip(address.ip()))
{
tracing::warn!(
server = %endpoint.host,
port = endpoint.port,
resolved_addresses = ?endpoint.addresses,
"proxy server resolved to a fake-IP or benchmarking range; outbound dial may recurse or fail"
);
} else {
tracing::debug!(
server = %endpoint.host,
port = endpoint.port,
resolved_addresses = ?endpoint.addresses,
"resolved proxy server addresses"
);
}
Ok(endpoint)
}
}
async fn connect_proxy_tcp(endpoint: &ProxyServerEndpoint) -> Result<(TcpStream, SocketAddr)> {
let mut last_error = None;
for address in endpoint.addresses.iter().copied() {
tracing::debug!(
server = %endpoint.host,
port = endpoint.port,
%address,
"dialing proxy tcp server"
);
let socket = match address {
SocketAddr::V4(_) => TcpSocket::new_v4(),
SocketAddr::V6(_) => TcpSocket::new_v6(),
}
.with_context(|| format!("failed to create tcp socket for {address}"))?;
if let Err(err) = bind_proxy_outbound_socket(socket.as_raw_fd(), address.ip()) {
tracing::warn!(
server = %endpoint.host,
port = endpoint.port,
%address,
error = ?err,
"failed to bind proxy tcp socket to physical interface"
);
last_error = Some(err);
continue;
}
match socket.connect(address).await {
Ok(stream) => {
tracing::debug!(
server = %endpoint.host,
port = endpoint.port,
%address,
"connected proxy tcp server"
);
return Ok((stream, address));
}
Err(err) => {
tracing::warn!(
server = %endpoint.host,
port = endpoint.port,
%address,
error = %err,
"failed to connect proxy tcp server"
);
last_error = Some(anyhow!(err).context(format!("failed to connect {address}")));
}
}
}
Err(last_error.unwrap_or_else(|| {
anyhow!(
"no cached addresses available for {}:{}",
endpoint.host,
endpoint.port
)
}))
}
async fn connect_proxy_udp(endpoint: &ProxyServerEndpoint) -> Result<(UdpSocket, SocketAddr)> {
let mut last_error = None;
for address in endpoint.addresses.iter().copied() {
tracing::debug!(
server = %endpoint.host,
port = endpoint.port,
%address,
"dialing proxy udp server"
);
let bind_addr = match address {
SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
};
let socket = match std::net::UdpSocket::bind(bind_addr) {
Ok(socket) => socket,
Err(err) => {
tracing::warn!(
server = %endpoint.host,
port = endpoint.port,
%address,
error = %err,
"failed to bind proxy udp socket"
);
last_error =
Some(anyhow!(err).context(format!("failed to bind udp socket for {address}")));
continue;
}
};
if let Err(err) = bind_proxy_outbound_socket(socket.as_raw_fd(), address.ip()) {
tracing::warn!(
server = %endpoint.host,
port = endpoint.port,
%address,
error = ?err,
"failed to bind proxy udp socket to physical interface"
);
last_error = Some(err);
continue;
}
if let Err(err) = socket.set_nonblocking(true) {
tracing::warn!(
server = %endpoint.host,
port = endpoint.port,
%address,
error = %err,
"failed to make proxy udp socket nonblocking"
);
last_error = Some(anyhow!(err).context("failed to make proxy udp socket nonblocking"));
continue;
}
match UdpSocket::from_std(socket) {
Ok(socket) => {
tracing::debug!(
server = %endpoint.host,
port = endpoint.port,
%address,
"connected proxy udp server"
);
return Ok((socket, address));
}
Err(err) => {
tracing::warn!(
server = %endpoint.host,
port = endpoint.port,
%address,
error = %err,
"failed to wrap proxy udp socket"
);
last_error = Some(anyhow!(err).context("failed to wrap proxy udp socket"));
}
}
}
Err(last_error.unwrap_or_else(|| {
anyhow!(
"no cached addresses available for {}:{}",
endpoint.host,
endpoint.port
)
}))
}
fn resolve_server_addresses(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
let addresses: Vec<_> = (host, port)
.to_socket_addrs()
.with_context(|| format!("failed to resolve {host}:{port}"))?
.collect();
if addresses.is_empty() {
bail!("no addresses resolved for {host}:{port}");
}
if addresses
.iter()
.all(|address| is_fake_or_benchmark_ip(address.ip()))
{
tracing::warn!(
server = %host,
port,
resolved_addresses = ?addresses,
"system DNS returned only fake-IP proxy server addresses; trying direct DNS"
);
let direct_addresses = resolve_server_addresses_direct(host, port)?;
tracing::info!(
server = %host,
port,
resolved_addresses = ?direct_addresses,
"direct DNS resolved proxy server addresses"
);
return Ok(direct_addresses);
}
Ok(addresses)
}
fn resolve_server_addresses_direct(host: &str, port: u16) -> Result<Vec<SocketAddr>> {
if let Ok(ip) = host.parse::<IpAddr>() {
return Ok(vec![SocketAddr::new(ip, port)]);
}
let mut dns_servers = Vec::new();
for server in platform_proxy_dns_servers()
.into_iter()
.chain(PUBLIC_DNS_SERVERS.iter().map(|server| (*server).to_owned()))
{
if dns_servers.iter().any(|existing| existing == &server) {
continue;
}
let Ok(ip) = server.parse::<IpAddr>() else {
continue;
};
if is_usable_proxy_dns_ip(ip) {
dns_servers.push(server);
}
}
let mut addresses = Vec::new();
let mut last_error = None;
for dns_server in dns_servers {
let Ok(ip) = dns_server.parse::<IpAddr>() else {
continue;
};
let dns_addr = SocketAddr::new(ip, 53);
match direct_dns_lookup(host, dns_addr) {
Ok(resolved) => {
for ip in resolved {
if is_fake_or_benchmark_ip(ip) {
continue;
}
let address = SocketAddr::new(ip, port);
if !addresses.contains(&address) {
addresses.push(address);
}
}
if !addresses.is_empty() {
break;
}
}
Err(err) => {
tracing::debug!(
server = %host,
%dns_addr,
error = ?err,
"direct DNS lookup failed"
);
last_error = Some(err);
}
}
}
if addresses.is_empty() {
return Err(last_error.unwrap_or_else(|| {
anyhow!("direct DNS did not resolve any usable addresses for {host}:{port}")
}));
}
Ok(addresses)
}
fn direct_dns_lookup(host: &str, dns_server: SocketAddr) -> Result<Vec<IpAddr>> {
let mut addresses = Vec::new();
let mut last_error = None;
for query_type in [1u16, 28u16] {
let mut query = build_dns_query(host, query_type)?;
let id = (OsRng.next_u32() & 0xffff) as u16;
query[0..2].copy_from_slice(&id.to_be_bytes());
let bind_addr = match dns_server {
SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
};
let socket = std::net::UdpSocket::bind(bind_addr)
.with_context(|| format!("failed to bind DNS socket for {dns_server}"))?;
bind_proxy_outbound_socket(socket.as_raw_fd(), dns_server.ip()).with_context(|| {
format!("failed to bind DNS socket to physical interface for {dns_server}")
})?;
socket
.set_read_timeout(Some(DIRECT_DNS_TIMEOUT))
.context("failed to set DNS read timeout")?;
socket
.set_write_timeout(Some(DIRECT_DNS_TIMEOUT))
.context("failed to set DNS write timeout")?;
if let Err(err) = socket.send_to(&query, dns_server) {
last_error =
Some(anyhow!(err).context(format!("failed to send DNS query to {dns_server}")));
continue;
}
let mut response = [0u8; 1500];
match socket.recv_from(&mut response) {
Ok((len, _)) => match parse_dns_response(&response[..len], id)
.with_context(|| format!("failed to parse DNS response from {dns_server}"))
{
Ok(mut resolved) => addresses.append(&mut resolved),
Err(err) => last_error = Some(err),
},
Err(err) => {
last_error = Some(
anyhow!(err)
.context(format!("failed to receive DNS response from {dns_server}")),
);
}
}
}
if addresses.is_empty() {
return Err(last_error.unwrap_or_else(|| anyhow!("DNS lookup returned no answers")));
}
Ok(addresses)
}
fn direct_dns_https_ech_config_list(host: &str, dns_server: SocketAddr) -> Result<Option<Vec<u8>>> {
let mut query = build_dns_query(host, DNS_TYPE_HTTPS)?;
let id = (OsRng.next_u32() & 0xffff) as u16;
query[0..2].copy_from_slice(&id.to_be_bytes());
let bind_addr = match dns_server {
SocketAddr::V4(_) => SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0),
SocketAddr::V6(_) => SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0),
};
let socket = std::net::UdpSocket::bind(bind_addr)
.with_context(|| format!("failed to bind DNS socket for {dns_server}"))?;
bind_proxy_outbound_socket(socket.as_raw_fd(), dns_server.ip()).with_context(|| {
format!("failed to bind DNS socket to physical interface for {dns_server}")
})?;
socket
.set_read_timeout(Some(DIRECT_DNS_TIMEOUT))
.context("failed to set DNS read timeout")?;
socket
.set_write_timeout(Some(DIRECT_DNS_TIMEOUT))
.context("failed to set DNS write timeout")?;
socket
.send_to(&query, dns_server)
.with_context(|| format!("failed to send DNS HTTPS query to {dns_server}"))?;
let mut response = [0u8; 1500];
let (len, _) = socket
.recv_from(&mut response)
.with_context(|| format!("failed to receive DNS HTTPS response from {dns_server}"))?;
parse_dns_https_ech_config_list(&response[..len], id)
.with_context(|| format!("failed to parse DNS HTTPS response from {dns_server}"))
}
pub(crate) fn build_dns_query(host: &str, query_type: u16) -> Result<Vec<u8>> {
let mut query = Vec::with_capacity(512);
query.extend_from_slice(&0u16.to_be_bytes());
query.extend_from_slice(&0x0100u16.to_be_bytes());
query.extend_from_slice(&1u16.to_be_bytes());
query.extend_from_slice(&0u16.to_be_bytes());
query.extend_from_slice(&0u16.to_be_bytes());
query.extend_from_slice(&0u16.to_be_bytes());
for label in host.trim_end_matches('.').split('.') {
if label.is_empty() || label.len() > 63 {
bail!("invalid DNS label in {host}");
}
query.push(label.len() as u8);
query.extend_from_slice(label.as_bytes());
}
query.push(0);
query.extend_from_slice(&query_type.to_be_bytes());
query.extend_from_slice(&1u16.to_be_bytes());
Ok(query)
}
pub(crate) fn parse_dns_response(response: &[u8], expected_id: u16) -> Result<Vec<IpAddr>> {
if response.len() < 12 {
bail!("truncated DNS response");
}
let id = u16::from_be_bytes([response[0], response[1]]);
if id != expected_id {
bail!("DNS response id mismatch");
}
let flags = u16::from_be_bytes([response[2], response[3]]);
if flags & 0x8000 == 0 {
bail!("DNS response is not marked as a response");
}
let rcode = flags & 0x000f;
if rcode != 0 {
bail!("DNS response error code {rcode}");
}
let question_count = u16::from_be_bytes([response[4], response[5]]) as usize;
let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize;
let mut offset = 12;
for _ in 0..question_count {
offset = skip_dns_name(response, offset)?;
if response.len() < offset + 4 {
bail!("truncated DNS question");
}
offset += 4;
}
let mut addresses = Vec::new();
for _ in 0..answer_count {
offset = skip_dns_name(response, offset)?;
if response.len() < offset + 10 {
bail!("truncated DNS answer");
}
let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]);
let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]);
let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize;
offset += 10;
if response.len() < offset + data_len {
bail!("truncated DNS answer data");
}
if record_class == 1 && record_type == 1 && data_len == 4 {
addresses.push(IpAddr::V4(Ipv4Addr::new(
response[offset],
response[offset + 1],
response[offset + 2],
response[offset + 3],
)));
} else if record_class == 1 && record_type == 28 && data_len == 16 {
let mut octets = [0u8; 16];
octets.copy_from_slice(&response[offset..offset + 16]);
addresses.push(IpAddr::V6(Ipv6Addr::from(octets)));
}
offset += data_len;
}
Ok(addresses)
}
fn parse_dns_https_ech_config_list(response: &[u8], expected_id: u16) -> Result<Option<Vec<u8>>> {
if response.len() < 12 {
bail!("truncated DNS response");
}
let id = u16::from_be_bytes([response[0], response[1]]);
if id != expected_id {
bail!("DNS response id mismatch");
}
let flags = u16::from_be_bytes([response[2], response[3]]);
if flags & 0x8000 == 0 {
bail!("DNS response is not marked as a response");
}
let rcode = flags & 0x000f;
if rcode != 0 {
bail!("DNS response error code {rcode}");
}
let question_count = u16::from_be_bytes([response[4], response[5]]) as usize;
let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize;
let mut offset = 12;
for _ in 0..question_count {
offset = skip_dns_name(response, offset)?;
if response.len() < offset + 4 {
bail!("truncated DNS question");
}
offset += 4;
}
for _ in 0..answer_count {
offset = skip_dns_name(response, offset)?;
if response.len() < offset + 10 {
bail!("truncated DNS answer");
}
let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]);
let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]);
let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize;
offset += 10;
if response.len() < offset + data_len {
bail!("truncated DNS answer data");
}
if record_class == 1 && record_type == DNS_TYPE_HTTPS {
if let Some(config_list) = parse_https_rr_ech_config_list(response, offset, data_len)? {
return Ok(Some(config_list));
}
}
offset += data_len;
}
Ok(None)
}
fn parse_https_rr_ech_config_list(
response: &[u8],
offset: usize,
data_len: usize,
) -> Result<Option<Vec<u8>>> {
let end = offset
.checked_add(data_len)
.ok_or_else(|| anyhow!("DNS HTTPS record offset overflow"))?;
if response.len() < end || data_len < 3 {
bail!("truncated DNS HTTPS record");
}
let (_target, mut cursor) = read_dns_name(response, offset + 2)?;
if cursor > end {
bail!("DNS HTTPS record target exceeded RDATA");
}
while cursor < end {
if response.len() < cursor + 4 || cursor + 4 > end {
bail!("truncated DNS HTTPS service parameter");
}
let key = u16::from_be_bytes([response[cursor], response[cursor + 1]]);
let value_len = u16::from_be_bytes([response[cursor + 2], response[cursor + 3]]) as usize;
cursor += 4;
let value_end = cursor
.checked_add(value_len)
.ok_or_else(|| anyhow!("DNS HTTPS parameter offset overflow"))?;
if value_end > end {
bail!("truncated DNS HTTPS service parameter value");
}
if key == HTTPS_SVC_PARAM_ECH {
return Ok(Some(response[cursor..value_end].to_vec()));
}
cursor = value_end;
}
Ok(None)
}
async fn record_dns_mappings(cache: &DnsHostnameCache, response: &[u8]) {
let mappings = match dns_response_host_mappings(response) {
Ok(mappings) => mappings,
Err(_) => return,
};
if mappings.is_empty() {
return;
}
let mut guard = cache.write().await;
for (ip, hostname) in mappings {
tracing::debug!(%ip, %hostname, "cached proxy DNS hostname mapping");
guard.insert_mapping(ip, hostname);
}
}
fn dns_response_host_mappings(response: &[u8]) -> Result<Vec<(IpAddr, String)>> {
if response.len() < 12 {
bail!("truncated DNS response");
}
let flags = u16::from_be_bytes([response[2], response[3]]);
if flags & 0x8000 == 0 {
bail!("DNS packet is not a response");
}
if flags & 0x000f != 0 {
bail!("DNS response returned an error");
}
let question_count = u16::from_be_bytes([response[4], response[5]]) as usize;
let answer_count = u16::from_be_bytes([response[6], response[7]]) as usize;
let mut offset = 12;
let mut question_names = Vec::new();
for _ in 0..question_count {
let (name, next_offset) = read_dns_name(response, offset)?;
offset = next_offset;
if response.len() < offset + 4 {
bail!("truncated DNS question");
}
if !name.is_empty() {
question_names.push(name);
}
offset += 4;
}
let mut mappings = Vec::new();
for _ in 0..answer_count {
let (answer_name, next_offset) = read_dns_name(response, offset)?;
offset = next_offset;
if response.len() < offset + 10 {
bail!("truncated DNS answer");
}
let record_type = u16::from_be_bytes([response[offset], response[offset + 1]]);
let record_class = u16::from_be_bytes([response[offset + 2], response[offset + 3]]);
let data_len = u16::from_be_bytes([response[offset + 8], response[offset + 9]]) as usize;
offset += 10;
if response.len() < offset + data_len {
bail!("truncated DNS answer data");
}
let hostname = question_names
.first()
.cloned()
.filter(|name| !name.is_empty())
.unwrap_or(answer_name);
if record_class == 1 && record_type == 1 && data_len == 4 {
mappings.push((
IpAddr::V4(Ipv4Addr::new(
response[offset],
response[offset + 1],
response[offset + 2],
response[offset + 3],
)),
hostname,
));
} else if record_class == 1 && record_type == 28 && data_len == 16 {
let mut octets = [0u8; 16];
octets.copy_from_slice(&response[offset..offset + 16]);
mappings.push((IpAddr::V6(Ipv6Addr::from(octets)), hostname));
}
offset += data_len;
}
Ok(mappings)
}
fn read_dns_name(response: &[u8], offset: usize) -> Result<(String, usize)> {
let mut labels = Vec::new();
let mut cursor = offset;
let mut next_offset = None;
let mut jumps = 0usize;
loop {
if cursor >= response.len() {
bail!("truncated DNS name");
}
let len = response[cursor];
if len & 0xc0 == 0xc0 {
if response.len() < cursor + 2 {
bail!("truncated compressed DNS name");
}
if next_offset.is_none() {
next_offset = Some(cursor + 2);
}
let pointer = (((len & 0x3f) as usize) << 8) | response[cursor + 1] as usize;
cursor = pointer;
jumps += 1;
if jumps > 16 {
bail!("too many compressed DNS name jumps");
}
continue;
}
if len & 0xc0 != 0 {
bail!("unsupported DNS label encoding");
}
cursor += 1;
if len == 0 {
return Ok((labels.join("."), next_offset.unwrap_or(cursor)));
}
let end = cursor
.checked_add(len as usize)
.ok_or_else(|| anyhow!("DNS name offset overflow"))?;
if end > response.len() {
bail!("truncated DNS label");
}
let label =
std::str::from_utf8(&response[cursor..end]).context("DNS label is not valid UTF-8")?;
labels.push(label.to_owned());
cursor = end;
}
}
fn skip_dns_name(response: &[u8], mut offset: usize) -> Result<usize> {
loop {
if offset >= response.len() {
bail!("truncated DNS name");
}
let len = response[offset];
if len & 0xc0 == 0xc0 {
if response.len() < offset + 2 {
bail!("truncated compressed DNS name");
}
return Ok(offset + 2);
}
if len & 0xc0 != 0 {
bail!("unsupported DNS label encoding");
}
offset += 1;
if len == 0 {
return Ok(offset);
}
offset = offset
.checked_add(len as usize)
.ok_or_else(|| anyhow!("DNS name offset overflow"))?;
}
}
fn is_fake_or_benchmark_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
let octets = ip.octets();
octets[0] == 198 && matches!(octets[1], 18 | 19)
}
IpAddr::V6(_) => false,
}
}
#[cfg(target_vendor = "apple")]
fn platform_proxy_dns_servers() -> Vec<String> {
match std::fs::read_to_string("/etc/resolv.conf") {
Ok(contents) => {
let servers = parse_resolv_conf_dns_servers(&contents);
if !servers.is_empty() {
return servers;
}
}
Err(err) => {
tracing::warn!(error = %err, "failed to read /etc/resolv.conf");
}
}
match Command::new("scutil").arg("--dns").output() {
Ok(output) if output.status.success() => {
parse_apple_physical_dns_servers(&String::from_utf8_lossy(&output.stdout))
}
Ok(output) => {
tracing::warn!(
status = ?output.status.code(),
stderr = %String::from_utf8_lossy(&output.stderr),
"failed to read macOS DNS configuration"
);
Vec::new()
}
Err(err) => {
tracing::warn!(error = %err, "failed to run scutil --dns");
Vec::new()
}
}
}
#[cfg(not(target_vendor = "apple"))]
fn platform_proxy_dns_servers() -> Vec<String> {
Vec::new()
}
fn parse_apple_physical_dns_servers(output: &str) -> Vec<String> {
let mut servers = Vec::new();
let mut resolver_servers = Vec::<String>::new();
let mut resolver_interface = None::<String>;
let mut flush_resolver =
|resolver_servers: &mut Vec<String>, resolver_interface: &mut Option<String>| {
let is_tunnel_resolver = resolver_interface
.as_deref()
.is_some_and(should_skip_dns_resolver_interface);
if !is_tunnel_resolver {
for server in resolver_servers.drain(..) {
if !servers.contains(&server) {
servers.push(server);
}
}
} else {
resolver_servers.clear();
}
*resolver_interface = None;
};
for line in output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("resolver #") {
flush_resolver(&mut resolver_servers, &mut resolver_interface);
continue;
}
if trimmed.starts_with("nameserver[") {
if let Some((_, value)) = trimmed.split_once(':') {
let server = value.trim();
if let Ok(ip) = server.parse::<IpAddr>() {
if is_usable_proxy_dns_ip(ip) {
resolver_servers.push(server.to_owned());
}
}
}
continue;
}
if trimmed.starts_with("if_index") {
if let (Some(start), Some(end)) = (trimmed.find('('), trimmed.find(')')) {
resolver_interface = Some(trimmed[start + 1..end].to_owned());
}
}
}
flush_resolver(&mut resolver_servers, &mut resolver_interface);
servers
}
fn parse_resolv_conf_dns_servers(contents: &str) -> Vec<String> {
let mut servers = Vec::new();
for line in contents.lines() {
let line = line.split('#').next().unwrap_or("").trim();
let mut fields = line.split_whitespace();
if fields.next() != Some("nameserver") {
continue;
}
let Some(server) = fields.next() else {
continue;
};
let Ok(ip) = server.parse::<IpAddr>() else {
continue;
};
if is_usable_proxy_dns_ip(ip) && !servers.iter().any(|existing| existing == server) {
servers.push(server.to_owned());
}
}
servers
}
fn should_skip_dns_resolver_interface(interface: &str) -> bool {
interface.starts_with("utun")
|| interface.starts_with("lo")
|| interface.starts_with("awdl")
|| interface.starts_with("llw")
}
fn is_usable_proxy_dns_ip(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(ip) => {
!ip.is_unspecified()
&& !ip.is_loopback()
&& !ip.is_multicast()
&& !is_fake_or_benchmark_ip(IpAddr::V4(ip))
}
IpAddr::V6(ip) => !ip.is_unspecified() && !ip.is_loopback() && !ip.is_multicast(),
}
}
#[cfg(target_vendor = "apple")]
fn bind_proxy_outbound_socket(fd: std::os::fd::RawFd, ip: IpAddr) -> Result<()> {
if !should_bind_proxy_outbound_socket(ip) {
return Ok(());
}
let interface_index = default_physical_interface_index().ok_or_else(|| {
anyhow!("no non-tunnel default interface found for proxy outbound socket")
})?;
let interface_index = interface_index as libc::c_uint;
let (level, option) = match ip {
IpAddr::V4(_) => (libc::IPPROTO_IP, libc::IP_BOUND_IF),
IpAddr::V6(_) => (libc::IPPROTO_IPV6, libc::IPV6_BOUND_IF),
};
let result = unsafe {
libc::setsockopt(
fd,
level,
option,
(&interface_index as *const libc::c_uint).cast::<libc::c_void>(),
std::mem::size_of_val(&interface_index) as libc::socklen_t,
)
};
if result != 0 {
return Err(std::io::Error::last_os_error()).with_context(|| {
format!("failed to bind proxy outbound socket to interface index {interface_index}")
});
}
Ok(())
}
#[cfg(not(target_vendor = "apple"))]
fn bind_proxy_outbound_socket(_fd: std::os::fd::RawFd, _ip: IpAddr) -> Result<()> {
Ok(())
}
fn should_bind_proxy_outbound_socket(ip: IpAddr) -> bool {
!ip.is_loopback()
}
#[cfg(target_vendor = "apple")]
fn default_physical_interface_index() -> Option<u32> {
#[derive(Clone)]
struct Candidate {
name: String,
index: u32,
score: i32,
}
let mut addrs = std::ptr::null_mut();
if unsafe { libc::getifaddrs(&mut addrs) } != 0 || addrs.is_null() {
return None;
}
struct IfAddrs(*mut libc::ifaddrs);
impl Drop for IfAddrs {
fn drop(&mut self) {
unsafe { libc::freeifaddrs(self.0) };
}
}
let _guard = IfAddrs(addrs);
let mut candidates = Vec::<Candidate>::new();
let mut current = addrs;
while !current.is_null() {
let ifa = unsafe { &*current };
current = ifa.ifa_next;
if ifa.ifa_addr.is_null() || ifa.ifa_name.is_null() {
continue;
}
let flags = ifa.ifa_flags as libc::c_uint;
if flags & libc::IFF_UP as libc::c_uint == 0
|| flags & libc::IFF_RUNNING as libc::c_uint == 0
|| flags & libc::IFF_LOOPBACK as libc::c_uint != 0
|| flags & libc::IFF_POINTOPOINT as libc::c_uint != 0
{
continue;
}
let family = unsafe { (*ifa.ifa_addr).sa_family as libc::c_int };
if family != libc::AF_INET && family != libc::AF_INET6 {
continue;
}
let name = unsafe { CStr::from_ptr(ifa.ifa_name) }
.to_string_lossy()
.into_owned();
if should_skip_outbound_interface(&name) {
continue;
}
let index = unsafe { libc::if_nametoindex(ifa.ifa_name) };
if index == 0 {
continue;
}
let mut score = if name.starts_with("en") { 100 } else { 10 };
if family == libc::AF_INET {
score += 20;
}
if name == "en0" {
score += 10;
}
candidates.push(Candidate { name, index, score });
}
candidates.sort_by_key(|candidate| (Reverse(candidate.score), candidate.name.clone()));
candidates.first().map(|candidate| {
tracing::debug!(
interface = %candidate.name,
index = candidate.index,
score = candidate.score,
"selected proxy outbound physical interface"
);
candidate.index
})
}
#[cfg(target_vendor = "apple")]
fn should_skip_outbound_interface(name: &str) -> bool {
name.starts_with("lo")
|| name.starts_with("utun")
|| name.starts_with("awdl")
|| name.starts_with("llw")
|| name.starts_with("bridge")
|| name.starts_with("gif")
|| name.starts_with("stf")
|| name.starts_with("p2p")
}
fn excluded_routes_for_server(host: &str, port: u16) -> Result<Vec<String>> {
let mut routes = Vec::new();
for address in resolve_server_addresses(host, port)
.with_context(|| format!("failed to resolve proxy server {host}:{port}"))?
{
let route = host_route_for_ip(address.ip());
if !routes.contains(&route) {
routes.push(route);
}
}
if routes.is_empty() {
bail!("no addresses resolved for proxy server {host}:{port}");
}
Ok(routes)
}
fn host_route_for_ip(ip: IpAddr) -> String {
match ip {
IpAddr::V4(ip) => format!("{ip}/32"),
IpAddr::V6(ip) => format!("{ip}/128"),
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,7 @@
use std::{ use std::sync::Once;
fs::{File, OpenOptions},
io::{self, Write},
path::PathBuf,
sync::{Arc, Mutex, Once},
};
use tracing::{error, info}; use tracing::{error, info};
use tracing_subscriber::{ use tracing_subscriber::{
fmt::writer::MakeWriter,
layer::{Layer, SubscriberExt}, layer::{Layer, SubscriberExt},
EnvFilter, Registry, EnvFilter, Registry,
}; };
@ -26,30 +20,14 @@ pub fn initialize() {
.with_writer(std::io::stderr) .with_writer(std::io::stderr)
.with_line_number(true) .with_line_number(true)
.compact() .compact()
.with_filter(env_filter()) .with_filter(EnvFilter::from_default_env())
};
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")] #[cfg(target_os = "windows")]
let subscriber = { let subscriber = {
let system_log = Some(tracing_subscriber::fmt::layer()); let system_log = Some(tracing_subscriber::fmt::layer());
let stderr = let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
(console::user_attended_stderr() || system_log.is_none()).then(make_stderr); Registry::default().with(stderr).with(system_log)
Registry::default()
.with(stderr)
.with(system_log)
.with(make_file())
}; };
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@ -63,12 +41,8 @@ pub fn initialize() {
None None
} }
}; };
let stderr = let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
(console::user_attended_stderr() || system_log.is_none()).then(make_stderr); Registry::default().with(stderr).with(system_log)
Registry::default()
.with(stderr)
.with(system_log)
.with(make_file())
}; };
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@ -80,20 +54,15 @@ pub fn initialize() {
std::env::var("BURROW_ENABLE_OSLOG").as_deref(), std::env::var("BURROW_ENABLE_OSLOG").as_deref(),
Ok("1" | "true" | "TRUE" | "yes" | "YES") Ok("1" | "true" | "TRUE" | "yes" | "YES")
); );
let system_log = enable_oslog let system_log = enable_oslog.then(|| {
.then(|| tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing")); tracing_oslog::OsLogger::new("com.hackclub.burrow", "tracing")
let stderr = });
(console::user_attended_stderr() || system_log.is_none()).then(make_stderr); let stderr = (console::user_attended_stderr() || system_log.is_none()).then(make_stderr);
Registry::default() Registry::default().with(stderr).with(system_log)
.with(stderr)
.with(system_log)
.with(make_file())
}; };
#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
let subscriber = Registry::default() let subscriber = Registry::default().with(Some(make_stderr()));
.with(Some(make_stderr()))
.with(make_file());
#[cfg(feature = "tokio-console")] #[cfg(feature = "tokio-console")]
let subscriber = subscriber.with( let subscriber = subscriber.with(
@ -111,50 +80,3 @@ pub fn initialize() {
info!("Initialized logging") 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

@ -1,6 +0,0 @@
FROM ghcr.io/sagernet/sing-box:v1.13.12
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,68 +0,0 @@
# Railway sing-box Shadowsocks Test Node
This is a minimal sing-box Shadowsocks server for testing Burrow against a real
internet-hosted node on Railway.
Railway public networking exposes raw TCP through TCP Proxy. That is enough for
plain Shadowsocks TCP testing. It is not a full UDP validation environment, so
use Burrow's local self-tests or a VPS for native UDP testing.
## Deploy
1. Create a new Railway service from this repo.
2. Set the service root directory to:
```text
deploy/railway-sing-box
```
3. Add Railway variables:
```text
SS_PASSWORD=<strong random password>
SS_METHOD=chacha20-ietf-poly1305
SS_LISTEN_HOST=0.0.0.0
SS_LISTEN_PORT=8388
```
`SS_METHOD`, `SS_LISTEN_HOST`, and `SS_LISTEN_PORT` are optional; those are
the defaults.
4. Deploy the service.
5. In the service settings, create a TCP Proxy with internal port `8388`.
6. Railway will show an external proxy host and port, for example:
```text
shuttle.proxy.rlwy.net:15140
```
## Burrow Test URI
Build the Shadowsocks URI with:
```sh
uv run python - <<'PY'
import base64
import urllib.parse
method = "chacha20-ietf-poly1305"
password = "<SS_PASSWORD>"
host = "<RAILWAY_TCP_PROXY_DOMAIN>"
port = "<RAILWAY_TCP_PROXY_PORT>"
name = "railway-sing-box-ss"
userinfo = base64.urlsafe_b64encode(f"{method}:{password}".encode()).decode().rstrip("=")
print(f"ss://{userinfo}@{host}:{port}#{urllib.parse.quote(name)}")
PY
```
Use the generated `ss://` URI in a Burrow proxy subscription or local test
payload.
## Notes
- Railway's TCP Proxy external port is assigned by Railway. Use that external
port in the client URI, not `8388`.
- This is intentionally a plain Shadowsocks node. Once plain TCP works, add
extra protocol features in separate test deployments so failures stay easy to
isolate.

View file

@ -1,47 +0,0 @@
#!/bin/sh
set -eu
: "${SS_PASSWORD:?Set SS_PASSWORD to a strong test password in Railway variables}"
SS_METHOD="${SS_METHOD:-chacha20-ietf-poly1305}"
SS_LISTEN_HOST="${SS_LISTEN_HOST:-0.0.0.0}"
SS_LISTEN_PORT="${SS_LISTEN_PORT:-8388}"
LOG_LEVEL="${LOG_LEVEL:-info}"
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
SS_METHOD_JSON="$(json_escape "$SS_METHOD")"
SS_PASSWORD_JSON="$(json_escape "$SS_PASSWORD")"
LOG_LEVEL_JSON="$(json_escape "$LOG_LEVEL")"
SS_LISTEN_HOST_JSON="$(json_escape "$SS_LISTEN_HOST")"
mkdir -p /etc/sing-box
cat >/etc/sing-box/config.json <<EOF
{
"log": {
"level": "$LOG_LEVEL_JSON",
"timestamp": true
},
"inbounds": [
{
"type": "shadowsocks",
"tag": "ss-test",
"listen": "$SS_LISTEN_HOST_JSON",
"listen_port": $SS_LISTEN_PORT,
"method": "$SS_METHOD_JSON",
"password": "$SS_PASSWORD_JSON"
}
],
"outbounds": [
{
"type": "direct",
"tag": "direct"
}
]
}
EOF
echo "starting sing-box Shadowsocks test server on $SS_LISTEN_HOST:$SS_LISTEN_PORT"
exec sing-box run -c /etc/sing-box/config.json

View file

@ -1,10 +0,0 @@
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "DOCKERFILE"
},
"deploy": {
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 10
}
}

View file

@ -1,601 +0,0 @@
# 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,11 +31,6 @@ Burrow should formalize one Apple/runtime boundary: Apple clients speak only to
- login/session lifecycle brokering - login/session lifecycle brokering
- runtime start/stop/reconcile - runtime start/stop/reconcile
- translating helper or bridge processes into stable daemon RPCs - 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. - `burrow/src/control/` owns transport-neutral control-plane semantics such as discovery, authority normalization, and request/response shaping.
- Apple UI owns presentation only: - Apple UI owns presentation only:
- forms - forms
@ -48,7 +43,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. - 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. - The daemon boundary makes testing and kill-switch behavior tractable because runtime integration is localized.
- 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. - 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.
- 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. - 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 ## Contributor Playbook

View file

@ -1,131 +0,0 @@
# `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

@ -1,271 +0,0 @@
# `BEP-0010` - Proxy Subscriptions as Packet Tunnel Networks
```text
Status: Draft
Proposal: BEP-0010
Authors: Codex
Coordinator: Codex
Reviewers: Pending
Constitution Sections: II, IV, V
Implementation PRs: Pending
Decision Date: Pending
```
## Summary
Burrow should support Trojan and Shadowsocks subscription imports as packet-tunnel networks, not as local SOCKS or system proxy mode. The user-facing add action should be named `Import Proxy Subscription` on every UI surface. Imported proxy subscriptions should participate in Burrow's existing tunnel model: platform tunnel capture feeds daemon-owned routing and proxy execution, and a selected proxy outbound carries TCP and UDP flows.
This proposal supersedes `BEP-0009`. The earlier SOCKS-backed Trojan runtime was useful as a smoke-test path, but Burrow's product semantics are VPN-shaped. Keeping a local SOCKS mode would add another behavior surface, confuse how proxy networks relate to WireGuard and Tailnet, and delay the packet-tunnel work Burrow actually needs.
## Motivation
- Users expect the main Burrow tunnel switch to affect full-device traffic, as it does for packet-shaped WireGuard and Tailnet integrations.
- Trojan and Shadowsocks subscriptions are commonly distributed as Clash/Mihomo YAML, proxy-provider content, plain URI lists, or base64 URI lists. Burrow's current Trojan parser only covers a narrow `trojan://` URI-list subset.
- Mihomo's TUN mode is the closest reference behavior: it captures packets, runs a userspace IP stack, hijacks DNS, resolves rules and proxy selections, then dials protocol-specific outbounds.
- Burrow should avoid a second user-facing "proxy mode" based on `HTTP_PROXY`, SOCKS settings, or a loopback listener because that is not equivalent to VPN behavior and creates platform-specific state outside Burrow's tunnel contract.
- Subscription URLs and proxy node credentials are secret-bearing. Fetching, parsing, storing, refreshing, and redacting them belong in the daemon boundary described by `BEP-0005`.
## Detailed Design
### User Model
- Add a UI action named `Import Proxy Subscription`.
- Imported subscriptions are stored as `Network` records because they represent routeable connectivity sources.
- Proxy nodes inside a subscription are selectable endpoints within that network.
- Node selection should mirror Mihomo selector behavior in `adapter/outboundgroup/selector.go`: persist an explicit selected node when the user chooses one, validate that it exists, and fall back to the first runtime-supported node only when no explicit supported selection is available.
- Proxy subscriptions do not create `Account` records by default. Accounts remain for identity and sign-in state such as Tailnet login or future provider identity flows.
- Apple SwiftUI, Linux GTK, and future UI surfaces should expose the same import, preview, warning, selection, refresh, and node status behavior over the daemon API.
- UI clients may show subscription name, refresh state, selected node, node health, warnings, and import preview results. They must not fetch subscription URLs or open Trojan/Shadowsocks sockets directly.
- Import preview UIs must expose the runtime-supported Trojan and Shadowsocks nodes as a selectable list and pass the selected ordinal to the daemon `ApplyImport` request. Parsed-but-unsupported nodes should remain visible as warnings or counts, not selectable runtime choices.
### Removed SOCKS Mode
- Remove the daemon runtime path that starts a stored Trojan profile as a local SOCKS5 proxy.
- Remove CLI and smoke entrypoints whose primary purpose is operating Burrow as a SOCKS proxy.
- Keep parser tests and sanitized live-comparison tooling only when they validate subscription import or packet-tunnel behavior.
- Do not expose system `HTTP_PROXY`, system SOCKS, PAC, or "set system proxy" behavior as part of this proposal.
- If a temporary developer-only loopback proxy is needed for debugging, it must be clearly separate from product behavior and must not be the stored network runtime.
### Subscription Import Compatibility
Implement the importer in the daemon and mirror Mihomo's behavior as closely as Burrow's narrower protocol scope allows. Path references in this section are relative to the Mihomo repository root. When behavior is ambiguous, contributors should inspect the referenced Mihomo code path and either match it or document a deliberate deviation in this BEP before implementation.
- Full config parsing should recognize subscription-relevant top-level fields analogous to `proxies`, `proxy-providers`, and `proxy-groups` in `config/config.go`.
- Provider parsing should support `file`, `http`, and `inline` style sources analogous to `adapter/provider/parser.go`.
- Provider content parsing should first try YAML with a `proxies` field. If YAML cannot be parsed, fall back to V2Ray-style URI conversion, matching the shape in `adapter/provider/provider.go`.
- Plain and base64 URI-list inputs should follow Mihomo's behavior in `common/convert/converter.go` and `common/convert/base64.go`: attempt raw/std base64 decoding, otherwise treat the body as plaintext.
- Trojan URI conversion should closely follow Mihomo's `trojan` case in `common/convert/converter.go` and the accepted outbound fields in `adapter/outbound/trojan.go`. Burrow should preserve Mihomo-compatible names and semantics internally where practical, then adapt them into Burrow's storage model. It should map at least:
- URI fragment to display name
- host, port, and username to server, port, and password
- `allowInsecure` to certificate verification policy
- `sni`, `alpn`, `type=ws`, `type=grpc`, client fingerprint, and pinned certificate fingerprint where supported
- Mihomo's default client fingerprint behavior where applicable
- Mihomo's ws and grpc option shapes before translating them into Burrow-owned structs
- Shadowsocks URI conversion should map at least:
- legacy base64 host form
- base64 `cipher:password` userinfo
- server, port, cipher, password, and node name
- `udp-over-tcp` or `uot`
- common `obfs` and `v2ray-plugin` plugin options when support is implemented
- Unsupported protocols, unsupported plugin modes, malformed nodes, duplicate node names, and insecure TLS flags must be returned in import preview warnings without logging credentials.
Burrow should not claim full Clash or full Mihomo compatibility in this proposal. The compatibility target is subscription import for Trojan and Shadowsocks nodes plus the minimum provider and group metadata needed to select and refresh outbounds. Within that target, subscription parsing and Trojan normalization should be intentionally Mihomo-like so a subscription that imports cleanly in Mihomo has the same node set, labels, security warnings, and supported Trojan transport metadata in Burrow unless the BEP records a specific exception.
### Data Model
Add a proxy-subscription network payload with:
- subscription display name
- redacted subscription URL or a credential reference to the full URL
- provider format detected during import
- refresh policy and last refresh result
- ordered proxy nodes
- selected node or selection strategy
- certificate and transport warnings
- per-node protocol type, server metadata, transport metadata, and secret references
Proxy node secrets must move out of plain network payload blobs when Burrow's secret-storage path exists. Until then, payload storage must be treated as sensitive and logs/tests must use redacted fixtures.
### Daemon APIs
Add daemon gRPC APIs before platform UI work:
- preview import from subscription URL
- apply selected preview result as a network
- refresh an existing subscription network
- list nodes for a proxy subscription network
- set selected node or selection strategy
Preview responses should include compatible count, rejected count, detected format, sanitized warnings, suggested network name, and redacted node labels. Apply and refresh must preserve local node order and selected-node state where possible.
### Packet Tunnel Runtime
Burrow should route proxy subscriptions through the existing tunnel architecture:
1. A platform tunnel provider starts the tunnel and applies daemon-provided network settings.
2. Platform packet capture forwards packets through the daemon packet interface. Apple uses `PacketTunnelProvider` and the existing `TunnelPackets` gRPC stream against the packet-tunnel daemon socket owned by the Network Extension; Linux should use the daemon-owned TUN path already aligned with the GTK client.
3. The daemon owns a proxy packet engine for active proxy-subscription networks.
4. The packet engine reconstructs TCP and UDP flows from IP packets.
5. DNS packets are handled by a daemon-owned DNS path so hostname metadata can be preserved for rule selection and proxy dialing.
6. Each flow is matched to the selected proxy node or selection strategy.
7. The daemon opens a Trojan or Shadowsocks outbound connection and relays flow bytes.
8. Return packets are serialized back to Apple through `TunnelPackets`.
Mihomo's equivalent kernel path is:
- TUN recreation and listener lifecycle in `listener/listener.go`
- TUN option construction and stack startup in `listener/sing_tun/server.go`
- DNS hijack in `listener/sing_tun/dns.go`
- metadata conversion in `listener/sing/sing.go`
- TCP and UDP handling in `tunnel/tunnel.go`
- outbound protocol construction in `adapter/parser.go`, `adapter/outbound/trojan.go`, and `adapter/outbound/shadowsocks.go`
Burrow should use these as architecture references, not as code to vendor blindly. The Burrow implementation must fit the daemon IPC boundary, Rust runtime, Apple NetworkExtension path, Linux daemon/TUN path, GTK client, and existing network selection model.
### Implemented Runtime Shape
The first packet-tunnel implementation follows the Mihomo adapter shape but keeps Burrow's narrower protocol surface:
- `burrow/src/proxy_runtime.rs` owns the daemon packet engine.
- Apple uses the existing `TunnelPackets` stream, matching the Tailnet packet-stream boundary.
- Linux uses the daemon TUN bridge path.
- Tunnel configuration excludes resolved selected proxy server host routes from the captured default route so proxy-server dials cannot be trapped by the full tunnel.
- On Apple, the proxy packet runtime binds Trojan and Shadowsocks outbound sockets to a non-tunnel physical interface before connect and also installs resolved Network Extension excluded routes for the selected proxy server. Loopback proxy-server addresses are not physical-interface bound so controlled local tests and developer-only local endpoints can still connect. The excluded route is intentionally redundant: it prevents proxy-server dials and server DNS artifacts from recursively entering the full tunnel when socket binding is unavailable, delayed, or ignored by a platform path.
- On Apple, node selection and refresh are applied to both the app-facing control daemon and, while connected, the packet-tunnel daemon over `burrow-packet-tunnel.sock`. The packet daemon swaps the active proxy outbound inside the existing packet bridge, so new TCP and UDP flows use the selected node without restarting the Network Extension. Existing flows may continue on the outbound they opened with until they close.
- TCP and UDP packets are reconstructed with the existing smoltcp userspace stack.
- Trojan TCP nodes are dialed over TLS and use Trojan TCP and UDP request framing directly.
- Shadowsocks nodes are dialed directly for TCP and UDP through Burrow's Shadowsocks runtime adapter. `BEP-0012` refines the Mihomo-compatible Shadowsocks runtime path, including expanded cipher families, UDP gating, and staged plugin/UDP-over-TCP support.
- Unsupported runtime transports such as Trojan `ws`/`grpc`, Shadowsocks plugins, and Shadowsocks UDP-over-TCP are still parsed and preserved from subscriptions, but they are rejected by runtime selection until those adapter transports are implemented. If no node is explicitly selected, Burrow chooses the first runtime-supported node instead of starting an unsupported transport.
- Apple SwiftUI and Linux GTK import flows preview nodes, restrict the picker to runtime-supported nodes, and pass the chosen node ordinal to the daemon when applying an import.
- Proxy-subscription tunnel settings point DNS at the daemon-owned tunnel address. The packet runtime answers ordinary A queries with fake IPs and records the fake-IP to hostname mapping so later TCP flows can send hostname-form proxy requests. This avoids making website DNS depend on outbound UDP support while preserving hostname metadata for Trojan and Shadowsocks dialing.
### Routing, DNS, and Apple Runtime Ownership
For full-tunnel proxy networks, daemon tunnel configuration should include:
- a virtual IPv4 address from a documentation or carrier-grade NAT range suitable for the packet engine
- a virtual IPv6 address once the packet engine is enabled for IPv6 flows
- MTU selected for userspace packet handling
- default IPv4 route when full-tunnel is enabled
- default IPv6 route when the platform tunnel surface supports it
- a daemon-owned DNS server address reachable inside the tunnel
DNS handling should be explicit. Proxy-subscription tunnel configuration should use the daemon-owned DNS address inside the tunnel, not ambient physical resolvers and not a public upstream that requires outbound UDP before TCP can start. The daemon DNS path may answer with fake IPs and must keep those mappings scoped to the active proxy runtime so hostname-preserving proxy dialing does not leak between networks.
On Apple, the app-facing daemon and packet-tunnel daemon are intentionally separate in-process daemon instances over different app-group Unix sockets. The app-facing daemon handles subscription preview, import, refresh, node selection, network list, and account/login control through `burrow.sock`. The Network Extension hosts the packet-tunnel daemon over `burrow-packet-tunnel.sock` while the VPN session is active; `TunnelStart`, `TunnelConfiguration`, `TunnelPackets`, and `TunnelStop` for the active session use that socket.
This split keeps macOS packet runtime ownership aligned with `NEPacketTunnelProvider`: routes, DNS settings, packet stream lifetime, and proxy runtime are created from the same selected network state when the extension starts. UI mutations persist desired state in the shared app-group database. If a mutation changes active packet-tunnel behavior, the Apple client should update the packet daemon's desired state and only restart or reassert the Network Extension when the platform tunnel settings themselves change.
Proxy-subscription routing must avoid recursively routing the proxy outbound through the full tunnel. Burrow uses daemon-side outbound socket binding, analogous to Mihomo's default-interface outbound behavior, and installs resolved proxy-server excluded routes as a second guardrail. Selector changes remain daemon-local when they do not require platform route changes; if the selected node's resolved proxy-server routes change, the Network Extension should be restarted or have its settings reapplied so the excluded routes match the active outbound.
Selector changes should be daemon-local whenever socket binding is available. The Network Extension packet stream and route/DNS settings should stay up; the packet daemon updates its proxy outbound handle, and future flows read the new selection from that shared handle. This mirrors Mihomo's selector model more closely than restarting TUN for every node change.
Daemon-owned DNS/fake-IP metadata is part of the proxy packet runtime. The proxy-server route exclusion protects the selected outbound itself; ordinary website DNS is answered by the daemon and converted into hostname metadata for future flows. Mihomo-style rule selection can build on the same mapping without depending on ambient system DNS after the tunnel is active.
### Rollout Stages
1. Proposal and model cleanup.
- Supersede `BEP-0009`.
- Remove SOCKS runtime from the target design.
2. Parser and preview.
- Implement daemon-owned import preview for Mihomo-compatible YAML and URI-list inputs.
- Add fixtures for Trojan and Shadowsocks.
3. Storage and refresh.
- Store proxy subscriptions as networks.
- Preserve selected node and local ordering across refreshes.
4. Packet engine foundation.
- Add daemon packet-stream support for proxy networks.
- Reconstruct TCP flows first with controlled DNS behavior.
5. Protocol outbounds.
- Add Trojan outbound.
- Add Shadowsocks outbound.
- Add UDP once NAT, timeout, and protocol support are explicit.
6. Platform UI.
- Add `Import Proxy Subscription` to Apple SwiftUI and Linux GTK add flows.
- Add preview, warning, selection, refresh, and node status views to Apple SwiftUI, Linux GTK, and any future first-party UI surface.
7. Compatibility and operations.
- Add Mihomo comparison fixtures and sanitized live-check scripts.
- Document remaining incompatibilities.
## Security and Operational Considerations
- Subscription URLs frequently contain bearer tokens. Full URLs must not appear in logs, screenshots, fixtures, crash reports, or generated docs.
- Proxy node credentials must be redacted in all errors and diagnostics.
- Insecure TLS settings imported from subscriptions must be visible in preview and stored as explicit risk metadata.
- Refresh must have timeout, redirect, response-size, and content-type limits.
- Parser failures must be non-fatal per node; malformed input should never panic the daemon.
- Packet-tunnel activation must have a kill-switch path: when the tunnel stops or a proxy network is disabled, packet handling, DNS state, NAT state, and outbound sockets must be torn down.
- If DNS mapping is fake-IP based, stale mappings must expire and must not leak between networks in ways that route traffic through the wrong node.
- Full-tunnel mode must avoid recursive routing of the proxy server connection through the tunnel. On Apple, preferred bypass behavior is outbound socket binding to a non-tunnel interface. Route exclusions are fallback behavior for platforms or environments where socket binding is not available.
- Removing SOCKS mode simplifies the threat model by leaving one supported runtime behavior for proxy networks.
## Contributor Playbook
1. Keep all UI subscription operations behind daemon gRPC.
2. Update or remove current Trojan SOCKS runtime code instead of layering the packet-tunnel runtime beside it.
3. Add parser fixtures for:
- Clash/Mihomo YAML with `proxies`
- proxy-provider content
- base64 URI list
- plaintext URI list
- Trojan TCP, ws, grpc metadata
- Shadowsocks legacy and SIP002-style links
- unsupported protocols and malformed nodes
- Mihomo parity cases copied from the behavior of `common/convert/converter.go`, especially Trojan `allowInsecure`, `sni`, `alpn`, `type=ws`, `type=grpc`, `fp`, and `pcs`
4. Add packet-engine tests with synthetic TCP and DNS packets before full platform UI integration.
5. Add a migration or cleanup path for any existing stored `NetworkType::Trojan` payloads created by the SOCKS-era implementation.
6. Validate metadata and affected builds:
```bash
uv run python Scripts/check-bep-metadata.py
cargo test -p burrow proxy_subscription
cargo check -p burrow
```
7. For platform UI work, regenerate bindings after proto changes and verify Apple SwiftUI and Linux GTK still talk only to daemon gRPC for subscription operations.
## Alternatives Considered
- Keep local SOCKS mode. Rejected because it is not Burrow's VPN behavior, adds another support surface, and makes imported proxy subscriptions feel unlike WireGuard and Tailnet.
- Use system `HTTP_PROXY` or SOCKS settings. Rejected because only proxy-aware apps participate, DNS behavior is inconsistent, and cleanup/rollback becomes OS-setting specific.
- Shell out to Mihomo. Rejected as the default implementation because Burrow would inherit a large external runtime and a separate control plane. Mihomo remains the behavioral reference for import and TUN semantics.
- Claim full Mihomo compatibility immediately. Rejected because Burrow should first support Trojan and Shadowsocks imports plus packet-tunnel routing correctly.
- Treat every proxy node as a separate account. Rejected because nodes are endpoints under one imported network source, not identities.
## Impact on Other Work
- Supersedes `BEP-0009`.
- Depends on the daemon IPC boundary in `BEP-0005`.
- Aligns with the route and policy direction in `BEP-0003`.
- May require a follow-up secret-storage BEP if existing network payload storage remains insufficient for long-lived proxy credentials.
- Creates shared import and packet-engine foundations for future proxy protocols, but this proposal only covers Trojan and Shadowsocks.
## Decision
Pending.
## References
- Burrow daemon IPC boundary: `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md`
- Burrow route and negotiation roadmap: `evolution/proposals/BEP-0003-connect-ip-and-negotiation-roadmap.md`
- Superseded Trojan SOCKS proposal: `evolution/proposals/BEP-0009-trojan-proxy-and-subscription-import.md`
- Burrow tunnel schema: `proto/burrow.proto`
- Burrow Apple tunnel provider: `Apple/NetworkExtension/PacketTunnelProvider.swift`
- Burrow Apple UI: `Apple/UI/`
- Burrow GTK UI: `burrow-gtk/src/`
- Burrow current Trojan parser/runtime: `burrow/src/trojan.rs`
- Burrow daemon runtime: `burrow/src/daemon/runtime.rs`
- Mihomo config model: `config/config.go`
- Mihomo proxy provider parsing: `adapter/provider/parser.go`
- Mihomo provider content parsing: `adapter/provider/provider.go`
- Mihomo URI conversion: `common/convert/converter.go`
- Mihomo base64 fallback: `common/convert/base64.go`
- Mihomo TUN listener lifecycle: `listener/listener.go`
- Mihomo TUN stack setup: `listener/sing_tun/server.go`
- Mihomo DNS hijack: `listener/sing_tun/dns.go`
- Mihomo listener metadata bridge: `listener/sing/sing.go`
- Mihomo TCP/UDP tunnel handling: `tunnel/tunnel.go`
- Mihomo outbound parser: `adapter/parser.go`
- Mihomo selector group: `adapter/outboundgroup/selector.go`
- Mihomo Trojan outbound: `adapter/outbound/trojan.go`
- Mihomo Shadowsocks outbound: `adapter/outbound/shadowsocks.go`

View file

@ -1,110 +0,0 @@
# `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

@ -1,356 +0,0 @@
# `BEP-0012` - Mihomo-Compatible Shadowsocks Runtime
```text
Status: Draft
Proposal: BEP-0012
Authors: Codex
Coordinator: Jett Chen
Reviewers: Pending
Constitution Sections: 2, 4, 5
Implementation PRs: Pending
Decision Date: Pending
```
## Summary
Burrow's proxy subscription runtime should move from a narrow hand-written
Shadowsocks AEAD implementation toward Mihomo-compatible Shadowsocks behavior
for imported nodes. Mihomo remains the reference for accepted ciphers, URI
conversion, plugins, UDP-over-TCP, and packet tunnel outbound semantics, while
Burrow keeps the daemon-owned packet runtime and Apple gRPC boundary described
by `BEP-0005` and `BEP-0010`.
This proposal covers the compatibility path for Shadowsocks outbounds. It starts
by delegating protocol crypto and stream/datagram framing to a maintained Rust
Shadowsocks implementation, adds Burrow-owned adapters for Mihomo cipher gaps
where Rust crates are available, and implements Mihomo-compatible UDP-over-TCP.
Plugin transports remain staged behind explicit lifecycle and security controls.
## Motivation
- Subscriptions that work in Mihomo commonly use more than `aes-128-gcm`,
`aes-256-gcm`, or `chacha20-ietf-poly1305`.
- Mihomo supports legacy stream ciphers, AEAD extra ciphers, AEAD 2022 methods,
SIP003-style plugins, ShadowTLS/restls/kcptun transports, and UDP-over-TCP.
- Burrow's previous hand-written Shadowsocks runtime made unsupported ciphers
appear like permanent product choices rather than implementation gaps.
- Protocol crypto and framing are security-sensitive. Burrow should use a
maintained Rust crate where it can do so without shelling out to Mihomo or
weakening the daemon packet-runtime boundary.
## Detailed Design
- Use the Rust `shadowsocks` crate as Burrow's default Shadowsocks protocol
adapter for supported cipher families, TCP stream framing, and UDP datagram
framing.
- Add Burrow-owned AEAD adapters backed by Rust crypto crates for Mihomo methods
missing from the default adapter when their wire behavior is straightforward.
The first such methods are `aes-192-gcm`, `aes-192-ccm`,
`chacha8-ietf-poly1305`, `xchacha8-ietf-poly1305`, `rabbit128-poly1305`,
`aegis-128l`, `aegis-256`, `aez-384`, `deoxys-ii-256-128`, `ascon128`,
`ascon128a`, `lea-*-gcm`, `2022-blake3-aes-128-ccm`, and
`2022-blake3-aes-256-ccm`.
- Add Burrow-owned legacy stream adapters for Mihomo's `chacha20` and
`xchacha20` methods. They use the same Shadowsocks v1 IV-plus-stream shape as
Mihomo's `sing-shadowsocks2` implementation, backed by Rust `chacha20`
primitives.
- Keep Burrow-owned socket creation, route exclusion, and Apple physical
interface binding behavior. The Shadowsocks adapter wraps already-connected
Burrow sockets where needed instead of taking over tunnel ownership.
- Runtime-supported ciphers include the compiled Rust crate feature set:
legacy stream methods, AEAD methods, AEAD extra methods, and AEAD 2022
methods, plus an explicit Mihomo-compatible `none` transport, custom
ChaCha20/XChaCha20 stream, AES-192 GCM/CCM, ChaCha8-Poly1305,
Rabbit128-Poly1305, AEGIS, AEZ-384, Deoxys-II-256-128, Ascon128, Ascon128a,
LEA-GCM, and AEAD 2022 AES-CCM adapters. `none` stays Burrow-owned because
the upstream Rust crate accepts the method name during validation but cannot
build a client runtime for it.
AEAD 2022 password handling follows Mihomo's base64 key parsing rule:
every decoded password segment must be exactly the method key length, with
no hashing or truncation fallback for oversized material. Burrow pre-validates
the delegated GCM/ChaCha 2022 methods so the upstream Rust adapter cannot
accept unpadded key material that Mihomo's `base64.StdEncoding` would reject.
Burrow keeps legacy aliases such as `aead_aes_128_gcm` and
`chacha20-poly1305` for previously imported payloads.
Burrow also preserves Mihomo's top-level Shadowsocks `smux` option during
import. The packet runtime supports TCP and UDP `protocol: h2mux`, `protocol:
smux`, and `protocol: yamux` paths for delegated standard ciphers, `none`, Burrow's custom ciphers,
and AEAD 2022 CCM by dialing Mihomo's `sp.mux.sing-box.arpa:444` control
destination through Shadowsocks, writing the sing-mux protocol preface, and
opening mux streams with Mihomo's per-stream SOCKS destination request. Burrow
also supports Mihomo's top-level sing-mux padding shape: the version 1 session
preface carries a padding flag and 256-767 bytes of skipped padding, then the
first 16 mux reads and writes are length-prefixed padded frames before the
stream falls back to raw mux bytes. UDP-over-sing-mux uses Mihomo's packet
stream shape: UDP stream flags, one SOCKS destination, one status byte, and
length-prefixed datagrams. Brutal mode performs Mihomo's `_BrutalBwExchange`
stream handshake and negotiates the same send-rate cap; OS-level TCP Brutal
congestion control remains a runtime best-effort because it is
Linux-kernel-module dependent in Mihomo and unavailable through every Burrow
plugin wrapper.
The remaining Mihomo compatibility gaps for Shadowsocks are advanced plugin
transports and live interop coverage for additional external permutations,
not base Shadowsocks cipher, UDP-over-TCP framing, or top-level sing-mux
negotiation.
- Honor the Shadowsocks `udp` flag at runtime. Clash/Mihomo YAML imports default
omitted `udp` to false like Mihomo's `ShadowSocksOption` zero value, while
`ss://` URI conversion keeps Mihomo's converter behavior of setting `udp` to
true. UDP-disabled nodes must reject UDP sessions instead of silently
attempting native UDP relay.
- Honor `udp-over-tcp` for Shadowsocks nodes by opening a Shadowsocks TCP stream
to Mihomo's UOT magic destination. Burrow supports both Mihomo's legacy
version 1 destination, `sp.udp-over-tcp.arpa`, and version 2 destination,
`sp.v2.udp-over-tcp.arpa`; absent or zero versions normalize to legacy v1 to
match Mihomo config defaults. Runtime UDP sessions are allowed when
`udp-over-tcp` is active even if native UDP is disabled, because packets are
carried over the selected TCP plugin path instead of a native Shadowsocks UDP
relay.
- Support Mihomo's `obfs` plugin for HTTP and TLS simple-obfs by wrapping the
already-connected TCP socket before the Shadowsocks stream handshake. Native
UDP remains direct Shadowsocks UDP, while `udp-over-tcp` uses the same wrapped
TCP path. The plugin mode must be explicit, matching Mihomo's `obfs` option
decoder: simple-obfs accepts only `http` and `tls`. Burrow normalizes SIP002
URI plugin names that contain `obfs` into Mihomo's YAML/runtime plugin name
`obfs` during import only when the plugin string has semicolon-delimited
SIP002 options; bare `ss://` plugin names and non-Mihomo-converted URI plugin
names are ignored like Mihomo's converter. Daemon runtime and Apple usability
checks intentionally require Mihomo's exact, case-sensitive plugin names. YAML
`plugin: obfs-local`, `plugin: Obfs`, `plugin: shadowtls`, and other
unrecognized plugin names therefore fall through to plain Shadowsocks like
Mihomo's outbound constructor instead of being treated as supported plugin
transports.
- Support Mihomo's `v2ray-plugin` websocket mode, including the lightweight
v2ray-plugin mux preface enabled by Mihomo's default `mux: true`, optional
TLS, custom path, host, and `v2ray-http-upgrade` raw-upgrade mode. Burrow also supports Mihomo's
`v2ray-http-upgrade-fast-open` behavior by returning the upgraded raw stream
after sending the HTTP request and validating the 101 response on first read.
Imported `ss://` v2ray-plugin strings preserve Mihomo's SIP002 aliases such
as `obfs=websocket`, `obfs-host`, and bare `tls` flags, while also
materializing Mihomo's converted `mode`, `host`, and boolean `tls` option
shape.
For websocket paths with Mihomo's `ed=` query option, Burrow removes `ed`
from the request path and moves the first payload bytes into the
`Sec-WebSocket-Protocol` header with unpadded URL-safe base64 encoding.
When TLS is enabled, Burrow accepts Mihomo's `certificate` and `private-key`
plugin options as a PEM client
certificate/key pair for mTLS, and enforces Mihomo's `fingerprint`
certificate pin option on both Rustls and Apple native TLS paths. Burrow also
supports Mihomo's `ech-opts` for websocket TLS by passing inline ECH config
lists to Rustls, or by resolving HTTPS records through the physical DNS
resolver when `ech-opts.enable` is set without an inline config.
- Support Mihomo's `gost-plugin` websocket mode, including the Go-smux client
stream enabled by Mihomo's default `mux: true` and `mux=false` raw websocket
mode. `v2ray-plugin` and `gost-plugin` require explicit `mode: websocket` or
the imported `obfs=websocket` alias, because Mihomo rejects those plugin nodes
when the mode field is omitted. TLS-enabled gost websocket nodes use the same
Mihomo-compatible client certificate/key and certificate pin handling,
including websocket ECH support.
- Support Mihomo's `shadow-tls` plugin for versions 1, 2, and 3 by performing the
cover TLS handshake in-process with Rustls, then exposing the post-handshake
stream to the Shadowsocks adapter. Version 2 wraps application data in
ShadowTLS application-data records and prepends the first client payload with
the handshake HMAC, matching Mihomo's `sing-shadowtls` client behavior.
Version 3 uses a ShadowTLS-capable Rustls fork for the ClientHello session ID
HMAC, then verifies and emits the four-byte HMAC application-data frames used
by Mihomo's `sing-shadowtls` v3 client. Version 1 follows Mihomo's option
shape and does not require a plugin password.
Burrow also parses and enforces Mihomo's ShadowTLS `fingerprint` certificate
pin option and supports Mihomo's `certificate` and `private-key` mTLS client
certificate options. `client-fingerprint` is preserved from subscriptions.
ShadowTLS v1 ignores `client-fingerprint` like Mihomo because it always uses a
normal TLS 1.2 handshake. For ShadowTLS v2/v3, unknown fingerprint names fall
back to the normal TLS ClientHello, while names that Mihomo maps to uTLS
profiles are rejected by the default runtime and Apple usability filter until
Burrow has uTLS-style ClientHello impersonation. Burrow carries an optional
`boring-browser-fingerprints` feature that wires ShadowTLS v2 active browser
fingerprints through Rama's BoringSSL-backed embedded browser TLS profiles.
ShadowTLS ALPN defaults to `h2,http/1.1` only when the option is absent;
explicitly empty `alpn` remains empty like Mihomo's decoded option struct.
The same feature also provides the ShadowTLS v3 browser-profile path by
signing the first ClientHello record's 32-byte session ID before it reaches
the wire, matching Mihomo's uTLS `SessionIDGenerator` shape. The BoringSSL
browser-profile paths also load Mihomo-compatible `certificate` and
`private-key` client-auth material, so ShadowTLS v2/v3 browser fingerprints
and mTLS can be combined the same way Mihomo passes those options into
`sing-shadowtls`. For ShadowTLS `client-fingerprint: random`, Burrow follows
Mihomo's initial random selection shape by choosing one process-wide browser
profile with the same chrome/safari/ios/firefox weights instead of re-rolling
each connection. The ShadowTLS v2 browser-profile path also removes the
`X25519MLKEM768` supported group before building the BoringSSL connector,
matching Mihomo's v2-only workaround for servers that fail with that hybrid
key-share. These paths are intentionally not default yet because they add
a CMake/BoringSSL toolchain requirement, still need CI coverage, and still need
live interop coverage. The optional browser-profile path covers Mihomo's
common `chrome`, `firefox`, `safari`, `ios`, `android`, `edge`, `random`, and
`safari16` names where Rama has matching embedded profiles; older fixed
aliases such as `chrome120` and `firefox120` remain gated until Burrow has
exact matching ClientHello profiles instead of approximate fallbacks.
- Support Mihomo's `kcptun` plugin in-process with Rust KCP, snappy, and smux
crates. Burrow parses Mihomo's kcptun option names and defaults, forces
UDP-over-TCP for kcptun nodes like Mihomo, and opens Shadowsocks TCP streams
over KCP plus optional snappy plus smux. Burrow maintains a Mihomo-style
round-robin smux session pool for `conn` and rotates expired sessions using
`autoexpire` plus delayed `scavengettl` cleanup. Burrow mirrors Mihomo's smux
keepalive timeout shape: the default timeout remains 30 seconds unless the
keepalive interval is at least that value, in which case timeout becomes three
times the interval. Burrow also applies Mihomo-style best-effort `dscp` and
`sockbuf` settings to the underlying UDP socket. For `ratelimit`, Burrow
honors Mihomo's bytes-per-second value with
the same `64 * 1500` byte burst shape at the KCP stream boundary; this is the
closest in-process equivalent available through the selected Rust KCP crate,
which does not expose kcp-go's exact queued UDP packet transmit hook.
- Plugin support landed in stages and must stay covered:
- `obfs`, matching Mihomo URI conversion and option shapes for
`mode`/`obfs`, `host`/`obfs-host`, and the default `bing.com` host.
- `v2ray-plugin` websocket and its lightweight mux.
- `gost-plugin` websocket and smux.
- TLS client certificate/key support for websocket plugins and `shadow-tls`
v1/v2.
- `kcptun` with Rust KCP, snappy, and smux transport code.
- ShadowTLS v3 with a Rustls fork that exposes the session ID generator
required by the protocol.
- Websocket `ech-opts` on Rustls-backed TLS paths.
- `v2ray-http-upgrade-fast-open` for v2ray-plugin raw upgrade mode.
- Websocket early data via the `ed=` path query option.
- `restls` after its TLS ClientHello, authentication, and traffic-shaping
behavior are mapped to audited Rust code. Burrow now parses Mihomo's
`restls` option shape, including `host`, `password`, `version-hint`,
`restls-script`, top-level `client-fingerprint` preservation, Mihomo's
case-sensitive Restls client ID lookup with Chrome fallback, Mihomo's TLS
1.3 session ticket disablement rule, and the BLAKE3-derived traffic secret.
The default runtime still rejects non-`none` uTLS fingerprints, while the
optional `boring-browser-fingerprints` feature can run TLS 1.3 Restls over a
BoringSSL-backed browser profile and signs the first ClientHello record's
session ID using Mihomo's Restls TLS 1.3 auth material. It also
ports and tests Mihomo's BLAKE3-authenticated ClientHello session-ID
material for TLS 1.2 and TLS 1.3, extraction of TLS 1.3 key-share and PSK
identity labels from the serialized ClientHello shape exposed by Burrow's
Rustls session-ID hook, the server-auth record unmasking offsets, a
handshake stream shim that captures ServerHello random, unmasks the first
server-auth record, and captures the encrypted ClientFinished record for
later application-data authentication, the application-data record header,
masked length/command bytes, one-shot ClientFinished binding on the first
client application record,
script-driven padding target, TLS 1.2 GCM explicit counter shape, Mihomo's
signed one-byte response-interrupt count behavior, and the post-auth stream
state machine for buffering blocked writes, resuming them after server data,
and emitting response-interrupt records. The Restls
post-handshake state machine is also covered by an async stream wrapper that
turns user writes into authenticated TLS application-data records, decodes
server records back into plaintext reads, and flushes resumed uploads plus
response-interrupt records before surfacing server plaintext. Burrow now
wires TLS 1.3 Restls nodes into the Shadowsocks outbound path through the
Rustls session-ID hook, the server-auth handshake shim, and the async Restls
stream wrapper. Burrow vendors the ShadowTLS Rustls fork and its Tokio
adapter so TLS 1.2 Restls can pre-generate the ECDHE keys used in Mihomo's
session-ID HMAC material and then reuse the matching private key when
emitting ClientKeyExchange. TLS 1.2 Restls nodes are now accepted by runtime
support checks, surfaced as selectable Apple subscription nodes, and use the
TLS 1.2 GCM Restls application-record codec.
Restls still needs end-to-end interop coverage before it can be claimed as
full Mihomo parity.
- `Scripts/burrow-proxy-selftest` exercises daemon packet streaming against a
controlled local Trojan/Shadowsocks harness and asserts observed TCP, native
UDP, legacy UDP-over-TCP, version 2 UDP-over-TCP, simple-obfs HTTP/TLS,
v2ray HTTP-upgrade/websocket/mux/early-data, and gost raw websocket shapes.
- `Scripts/burrow-shadowsocks-singmux-interop` drives Burrow's daemon against a
temporary Go peer built from Mihomo's `github.com/metacubex/sing-mux` module
and asserts top-level Shadowsocks `smux`, `yamux`, and `h2mux` TCP and UDP
behavior through the daemon with both unpadded and padded sing-mux sessions,
including the `sp.mux.sing-box.arpa:444` control destination, per-stream TCP
destination metadata, UDP packet destination metadata, and UDP packet echo
delivery. The interop matrix intentionally leaves the underlying Shadowsocks
`udp` flag false so Burrow matches Mihomo's behavior: top-level sing-mux
carries UDP unless `only-tcp` is set.
Remaining compatibility evidence still needs live interop against full
Mihomo/sing-box deployments for plugin combinations that the local harness
does not cover, especially Restls post-handshake behavior and
kernel-dependent TCP Brutal socket behavior.
- Keep UDP-over-TCP packet framing compatible with Mihomo's
`udp-over-tcp-version` handling, including the version 2 request prefix and
per-packet address framing.
- UI runtime-support checks must not hard-code stale cipher lists that disagree
with the daemon. When local decoding is unavoidable, the local list must be
updated with the daemon-supported cipher families or replaced with daemon
preview/list-node data.
## Security and Operational Considerations
- Shadowsocks passwords and subscription URLs remain secrets. Error messages and
logs must not include decoded credentials or bearer tokens.
- Plugin support must stay within Burrow's daemon-owned socket lifecycle unless
a later BEP explicitly introduces subprocess management. Shelling out to
SIP003 plugins would add lifecycle and supply-chain risk and must include
teardown and selected-network scoping if introduced.
- `none` and legacy stream methods exist for compatibility, not as a security
recommendation. UI warnings may be added later for weak ciphers without
rejecting Mihomo-compatible imports.
- AEAD 2022 passwords may be encoded keys. Validation must report malformed keys
without logging the raw password.
- Rollback is a code rollback plus re-importing affected subscriptions if stored
payload shape changes.
## Contributor Playbook
1. Compare behavior against `/Users/jettchen/dev/vpn-ref/mihomo` on the `Alpha`
branch before changing Shadowsocks protocol semantics.
2. Keep Apple UI subscription operations behind daemon gRPC.
3. Prefer maintained Rust crates for Shadowsocks crypto/framing rather than
hand-rolled protocol code.
4. Preserve Burrow socket binding and proxy-server route exclusion behavior.
5. Add tests for each parity expansion:
- cipher acceptance and legacy aliases
- `udp` gating
- plugin parsing and runtime gating
- UDP-over-TCP version handling and packet framing
- AEAD 2022 TCP/UDP packet framing for custom adapters
- end-to-end local Shadowsocks TCP, native UDP, UDP-over-TCP, and plugin
selftests; the proxy selftest must switch the daemon from a Trojan node to
Shadowsocks nodes, prove both selected outbounds can carry a TCP probe,
prove the selected Shadowsocks outbounds can carry native UDP, legacy
UDP-over-TCP, and version 2 UDP-over-TCP echo probes, and prove the
simple-obfs HTTP/TLS plugins, v2ray-plugin websocket/default-mux/
HTTP-upgrade/early-data modes, and gost-plugin raw websocket mode wrap
selected Shadowsocks TCP probes
- top-level sing-mux Shadowsocks TCP and UDP interop against Mihomo's Go
`sing-mux` module
6. Run:
```bash
uv run python Scripts/check-bep-metadata.py
cargo fmt -p burrow
cargo test -p burrow proxy_subscription::tests::
cargo test -p burrow proxy_runtime::tests::
cargo check -p burrow
Scripts/burrow-proxy-selftest
Scripts/burrow-shadowsocks-singmux-interop
```
## Alternatives Considered
- Keep the hand-written AEAD-only implementation. Rejected because it locks
Burrow into a narrow subset and makes Mihomo parity much slower.
- Shell out to Mihomo. Rejected for the same reasons recorded in `BEP-0010`:
Burrow would inherit a separate runtime, control plane, and lifecycle surface.
- Implement plugins before cipher parity. Rejected because cipher parity is a
lower-risk foundation and reduces custom crypto code first.
## Impact on Other Work
- Refines the Shadowsocks runtime portion of `BEP-0010`.
- Complements `BEP-0011`, which covers Trojan-specific Mihomo compatibility.
- Future plugin work may require a dedicated subprocess lifecycle BEP if the
runtime cannot stay within the existing daemon teardown model.
## Decision
Pending review.
## References
- `evolution/proposals/BEP-0005-daemon-ipc-and-apple-boundary.md`
- `evolution/proposals/BEP-0010-proxy-subscriptions-as-packet-tunnel-networks.md`
- `evolution/proposals/BEP-0011-mihomo-compatible-trojan-runtime.md`
- `/Users/jettchen/dev/vpn-ref/mihomo/adapter/outbound/shadowsocks.go`
- `/Users/jettchen/dev/vpn-ref/mihomo/common/convert/converter.go`
- Rust `shadowsocks` crate: https://crates.io/crates/shadowsocks

View file

@ -26,12 +26,6 @@ service TailnetControl {
rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty); rpc LoginCancel (TailnetLoginCancelRequest) returns (Empty);
} }
service ProxySubscriptions {
rpc PreviewImport (ProxySubscriptionImportRequest) returns (ProxySubscriptionPreviewResponse);
rpc ApplyImport (ProxySubscriptionApplyRequest) returns (ProxySubscriptionApplyResponse);
rpc SelectNode (ProxySubscriptionSelectRequest) returns (ProxySubscriptionSelectResponse);
}
message NetworkReorderRequest { message NetworkReorderRequest {
int32 id = 1; int32 id = 1;
int32 index = 2; int32 index = 2;
@ -61,62 +55,6 @@ message Network {
enum NetworkType { enum NetworkType {
WireGuard = 0; WireGuard = 0;
Tailnet = 1; Tailnet = 1;
reserved 2;
reserved "Trojan";
ProxySubscription = 3;
}
message ProxySubscriptionImportRequest {
string url = 1;
}
message ProxySubscriptionRejectedEntry {
int32 ordinal = 1;
string reason = 2;
string scheme = 3;
}
message ProxySubscriptionNodePreview {
int32 ordinal = 1;
string name = 2;
string protocol = 3;
string server = 4;
int32 port = 5;
repeated string warnings = 6;
bool runtime_supported = 7;
}
message ProxySubscriptionPreviewResponse {
string suggested_name = 1;
string detected_format = 2;
int32 compatible_count = 3;
int32 rejected_count = 4;
repeated ProxySubscriptionNodePreview node = 5;
repeated ProxySubscriptionRejectedEntry rejected = 6;
repeated string warnings = 7;
}
message ProxySubscriptionApplyRequest {
int32 id = 1;
string url = 2;
string name = 3;
int32 selected_ordinal = 4;
}
message ProxySubscriptionApplyResponse {
int32 id = 1;
int32 imported_count = 2;
int32 selected_ordinal = 3;
}
message ProxySubscriptionSelectRequest {
int32 id = 1;
int32 selected_ordinal = 2;
}
message ProxySubscriptionSelectResponse {
int32 id = 1;
int32 selected_ordinal = 2;
} }
message NetworkListResponse { message NetworkListResponse {
@ -195,7 +133,6 @@ message TunnelConfigurationResponse {
repeated string dns_servers = 4; repeated string dns_servers = 4;
repeated string search_domains = 5; repeated string search_domains = 5;
bool include_default_route = 6; bool include_default_route = 6;
repeated string excluded_routes = 7;
} }
message TunnelPacket { message TunnelPacket {