Skip to content

Commit eb61235

Browse files
authoredDec 13, 2024
feat: VPN protocol (#11)
Adds support for the VPN Protocol, up to a generic, abstract base `Speaker` class. We'll subclass this and override the handlers for the real `Manager` class in the network extension.
1 parent c070ac7 commit eb61235

File tree

12 files changed

+3085
-2
lines changed

12 files changed

+3085
-2
lines changed
 

Diff for: ‎Coder Desktop/Coder Desktop.xcodeproj/project.pbxproj

+168
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */; };
1414
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */; };
1515
AAD720D02D0816B200F6304D /* Alamofire in Frameworks */ = {isa = PBXBuildFile; productRef = AAD720CF2D0816B200F6304D /* Alamofire */; };
16+
961679532CFF207900B2B6DF /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = 961679522CFF207900B2B6DF /* SwiftProtobuf */; };
17+
961679552CFF207900B2B6DF /* SwiftProtobufPluginLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 961679542CFF207900B2B6DF /* SwiftProtobufPluginLibrary */; };
1618
/* End PBXBuildFile section */
1719

1820
/* Begin PBXContainerItemProxy section */
@@ -37,6 +39,13 @@
3739
remoteGlobalIDString = 9616792F2CFF117300B2B6DF;
3840
remoteInfo = VPN;
3941
};
42+
961679DD2D030E1D00B2B6DF /* PBXContainerItemProxy */ = {
43+
isa = PBXContainerItemProxy;
44+
containerPortal = 961678F42CFF100D00B2B6DF /* Project object */;
45+
proxyType = 1;
46+
remoteGlobalIDString = 961678FB2CFF100D00B2B6DF;
47+
remoteInfo = "Coder Desktop";
48+
};
4049
/* End PBXContainerItemProxy section */
4150

4251
/* Begin PBXCopyFilesBuildPhase section */
@@ -59,6 +68,7 @@
5968
961679192CFF100E00B2B6DF /* Coder DesktopUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Coder DesktopUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
6069
961679302CFF117300B2B6DF /* com.coder.Coder-Desktop.VPN.systemextension */ = {isa = PBXFileReference; explicitFileType = "wrapper.system-extension"; includeInIndex = 0; path = "com.coder.Coder-Desktop.VPN.systemextension"; sourceTree = BUILT_PRODUCTS_DIR; };
6170
961679322CFF117300B2B6DF /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };
71+
961679D92D030E1D00B2B6DF /* ProtoTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ProtoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
6272
/* End PBXFileReference section */
6373

6474
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -69,6 +79,13 @@
6979
);
7080
target = 9616792F2CFF117300B2B6DF /* VPN */;
7181
};
82+
961679472CFF14EA00B2B6DF /* Exceptions for "Proto" folder in "VPN" target */ = {
83+
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
84+
membershipExceptions = (
85+
Sender.swift,
86+
);
87+
target = 9616792F2CFF117300B2B6DF /* VPN */;
88+
};
7289
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
7390

7491
/* Begin PBXFileSystemSynchronizedRootGroup section */
@@ -95,6 +112,19 @@
95112
path = VPN;
96113
sourceTree = "<group>";
97114
};
115+
961679432CFF149000B2B6DF /* Proto */ = {
116+
isa = PBXFileSystemSynchronizedRootGroup;
117+
exceptions = (
118+
961679472CFF14EA00B2B6DF /* Exceptions for "Proto" folder in "VPN" target */,
119+
);
120+
path = Proto;
121+
sourceTree = "<group>";
122+
};
123+
961679DA2D030E1D00B2B6DF /* ProtoTests */ = {
124+
isa = PBXFileSystemSynchronizedRootGroup;
125+
path = ProtoTests;
126+
sourceTree = "<group>";
127+
};
98128
/* End PBXFileSystemSynchronizedRootGroup section */
99129

100130
/* Begin PBXFrameworksBuildPhase section */
@@ -105,6 +135,8 @@
105135
AAD720D02D0816B200F6304D /* Alamofire in Frameworks */,
106136
AA8BC4CF2D00A4B700E1ABAA /* KeychainAccess in Frameworks */,
107137
AA8BC33F2D0061F200E1ABAA /* FluidMenuBarExtra in Frameworks */,
138+
961679552CFF207900B2B6DF /* SwiftProtobufPluginLibrary in Frameworks */,
139+
961679532CFF207900B2B6DF /* SwiftProtobuf in Frameworks */,
108140
);
109141
runOnlyForDeploymentPostprocessing = 0;
110142
};
@@ -127,7 +159,16 @@
127159
isa = PBXFrameworksBuildPhase;
128160
buildActionMask = 2147483647;
129161
files = (
162+
961679E52D03144C00B2B6DF /* SwiftProtobufPluginLibrary in Frameworks */,
130163
961679332CFF117300B2B6DF /* NetworkExtension.framework in Frameworks */,
164+
961679E32D03144900B2B6DF /* SwiftProtobuf in Frameworks */,
165+
);
166+
runOnlyForDeploymentPostprocessing = 0;
167+
};
168+
961679D62D030E1D00B2B6DF /* Frameworks */ = {
169+
isa = PBXFrameworksBuildPhase;
170+
buildActionMask = 2147483647;
171+
files = (
131172
);
132173
runOnlyForDeploymentPostprocessing = 0;
133174
};
@@ -137,6 +178,8 @@
137178
961678F32CFF100D00B2B6DF = {
138179
isa = PBXGroup;
139180
children = (
181+
961679432CFF149000B2B6DF /* Proto */,
182+
961679DA2D030E1D00B2B6DF /* ProtoTests */,
140183
961678FE2CFF100D00B2B6DF /* Coder Desktop */,
141184
961679122CFF100E00B2B6DF /* Coder DesktopTests */,
142185
9616791C2CFF100E00B2B6DF /* Coder DesktopUITests */,
@@ -153,6 +196,7 @@
153196
9616790F2CFF100E00B2B6DF /* Coder DesktopTests.xctest */,
154197
961679192CFF100E00B2B6DF /* Coder DesktopUITests.xctest */,
155198
961679302CFF117300B2B6DF /* com.coder.Coder-Desktop.VPN.systemextension */,
199+
961679D92D030E1D00B2B6DF /* ProtoTests.xctest */,
156200
);
157201
name = Products;
158202
sourceTree = "<group>";
@@ -185,12 +229,15 @@
185229
);
186230
fileSystemSynchronizedGroups = (
187231
961678FE2CFF100D00B2B6DF /* Coder Desktop */,
232+
961679432CFF149000B2B6DF /* Proto */,
188233
);
189234
name = "Coder Desktop";
190235
packageProductDependencies = (
191236
AA8BC33E2D0061F200E1ABAA /* FluidMenuBarExtra */,
192237
AA8BC4CE2D00A4B700E1ABAA /* KeychainAccess */,
193238
AAD720CF2D0816B200F6304D /* Alamofire */,
239+
961679522CFF207900B2B6DF /* SwiftProtobuf */,
240+
961679542CFF207900B2B6DF /* SwiftProtobufPluginLibrary */,
194241
);
195242
productName = "Coder Desktop";
196243
productReference = 961678FC2CFF100D00B2B6DF /* Coder Desktop.app */;
@@ -260,11 +307,36 @@
260307
);
261308
name = VPN;
262309
packageProductDependencies = (
310+
961679E22D03144900B2B6DF /* SwiftProtobuf */,
311+
961679E42D03144C00B2B6DF /* SwiftProtobufPluginLibrary */,
263312
);
264313
productName = VPN;
265314
productReference = 961679302CFF117300B2B6DF /* com.coder.Coder-Desktop.VPN.systemextension */;
266315
productType = "com.apple.product-type.system-extension";
267316
};
317+
961679D82D030E1D00B2B6DF /* ProtoTests */ = {
318+
isa = PBXNativeTarget;
319+
buildConfigurationList = 961679DF2D030E1D00B2B6DF /* Build configuration list for PBXNativeTarget "ProtoTests" */;
320+
buildPhases = (
321+
961679D52D030E1D00B2B6DF /* Sources */,
322+
961679D62D030E1D00B2B6DF /* Frameworks */,
323+
961679D72D030E1D00B2B6DF /* Resources */,
324+
);
325+
buildRules = (
326+
);
327+
dependencies = (
328+
961679DE2D030E1D00B2B6DF /* PBXTargetDependency */,
329+
);
330+
fileSystemSynchronizedGroups = (
331+
961679DA2D030E1D00B2B6DF /* ProtoTests */,
332+
);
333+
name = ProtoTests;
334+
packageProductDependencies = (
335+
);
336+
productName = ProtoTests;
337+
productReference = 961679D92D030E1D00B2B6DF /* ProtoTests.xctest */;
338+
productType = "com.apple.product-type.bundle.unit-test";
339+
};
268340
/* End PBXNativeTarget section */
269341

270342
/* Begin PBXProject section */
@@ -289,6 +361,10 @@
289361
9616792F2CFF117300B2B6DF = {
290362
CreatedOnToolsVersion = 16.1;
291363
};
364+
961679D82D030E1D00B2B6DF = {
365+
CreatedOnToolsVersion = 16.1;
366+
TestTargetID = 961678FB2CFF100D00B2B6DF;
367+
};
292368
};
293369
};
294370
buildConfigurationList = 961678F72CFF100D00B2B6DF /* Build configuration list for PBXProject "Coder Desktop" */;
@@ -306,6 +382,7 @@
306382
AA8BC33D2D0061F200E1ABAA /* XCRemoteSwiftPackageReference "fluid-menu-bar-extra" */,
307383
AA8BC4CD2D00A4B700E1ABAA /* XCRemoteSwiftPackageReference "KeychainAccess" */,
308384
AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */,
385+
961679512CFF207900B2B6DF /* XCRemoteSwiftPackageReference "swift-protobuf" */,
309386
);
310387
preferredProjectObjectVersion = 77;
311388
productRefGroup = 961678FD2CFF100D00B2B6DF /* Products */;
@@ -316,6 +393,7 @@
316393
9616790E2CFF100E00B2B6DF /* Coder DesktopTests */,
317394
961679182CFF100E00B2B6DF /* Coder DesktopUITests */,
318395
9616792F2CFF117300B2B6DF /* VPN */,
396+
961679D82D030E1D00B2B6DF /* ProtoTests */,
319397
);
320398
};
321399
/* End PBXProject section */
@@ -349,6 +427,13 @@
349427
);
350428
runOnlyForDeploymentPostprocessing = 0;
351429
};
430+
961679D72D030E1D00B2B6DF /* Resources */ = {
431+
isa = PBXResourcesBuildPhase;
432+
buildActionMask = 2147483647;
433+
files = (
434+
);
435+
runOnlyForDeploymentPostprocessing = 0;
436+
};
352437
/* End PBXResourcesBuildPhase section */
353438

354439
/* Begin PBXSourcesBuildPhase section */
@@ -380,6 +465,13 @@
380465
);
381466
runOnlyForDeploymentPostprocessing = 0;
382467
};
468+
961679D52D030E1D00B2B6DF /* Sources */ = {
469+
isa = PBXSourcesBuildPhase;
470+
buildActionMask = 2147483647;
471+
files = (
472+
);
473+
runOnlyForDeploymentPostprocessing = 0;
474+
};
383475
/* End PBXSourcesBuildPhase section */
384476

385477
/* Begin PBXTargetDependency section */
@@ -402,6 +494,11 @@
402494
isa = PBXTargetDependency;
403495
productRef = AA8BC33B2D0060E700E1ABAA /* SwiftLintBuildToolPlugin */;
404496
};
497+
961679DE2D030E1D00B2B6DF /* PBXTargetDependency */ = {
498+
isa = PBXTargetDependency;
499+
target = 961678FB2CFF100D00B2B6DF /* Coder Desktop */;
500+
targetProxy = 961679DD2D030E1D00B2B6DF /* PBXContainerItemProxy */;
501+
};
405502
/* End PBXTargetDependency section */
406503

407504
/* Begin XCBuildConfiguration section */
@@ -701,6 +798,40 @@
701798
};
702799
name = Release;
703800
};
801+
961679E02D030E1D00B2B6DF /* Debug */ = {
802+
isa = XCBuildConfiguration;
803+
buildSettings = {
804+
BUNDLE_LOADER = "$(TEST_HOST)";
805+
CODE_SIGN_STYLE = Automatic;
806+
CURRENT_PROJECT_VERSION = 1;
807+
DEVELOPMENT_TEAM = 4399GN35BJ;
808+
GENERATE_INFOPLIST_FILE = YES;
809+
MARKETING_VERSION = 1.0;
810+
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop.ProtoTests";
811+
PRODUCT_NAME = "$(TARGET_NAME)";
812+
SWIFT_EMIT_LOC_STRINGS = NO;
813+
SWIFT_VERSION = 5.0;
814+
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop";
815+
};
816+
name = Debug;
817+
};
818+
961679E12D030E1D00B2B6DF /* Release */ = {
819+
isa = XCBuildConfiguration;
820+
buildSettings = {
821+
BUNDLE_LOADER = "$(TEST_HOST)";
822+
CODE_SIGN_STYLE = Automatic;
823+
CURRENT_PROJECT_VERSION = 1;
824+
DEVELOPMENT_TEAM = 4399GN35BJ;
825+
GENERATE_INFOPLIST_FILE = YES;
826+
MARKETING_VERSION = 1.0;
827+
PRODUCT_BUNDLE_IDENTIFIER = "com.coder.Coder-Desktop.ProtoTests";
828+
PRODUCT_NAME = "$(TARGET_NAME)";
829+
SWIFT_EMIT_LOC_STRINGS = NO;
830+
SWIFT_VERSION = 5.0;
831+
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Coder Desktop.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Coder Desktop";
832+
};
833+
name = Release;
834+
};
704835
/* End XCBuildConfiguration section */
705836

706837
/* Begin XCConfigurationList section */
@@ -749,6 +880,15 @@
749880
defaultConfigurationIsVisible = 0;
750881
defaultConfigurationName = Release;
751882
};
883+
961679DF2D030E1D00B2B6DF /* Build configuration list for PBXNativeTarget "ProtoTests" */ = {
884+
isa = XCConfigurationList;
885+
buildConfigurations = (
886+
961679E02D030E1D00B2B6DF /* Debug */,
887+
961679E12D030E1D00B2B6DF /* Release */,
888+
);
889+
defaultConfigurationIsVisible = 0;
890+
defaultConfigurationName = Release;
891+
};
752892
/* End XCConfigurationList section */
753893

754894
/* Begin XCRemoteSwiftPackageReference section */
@@ -792,6 +932,14 @@
792932
minimumVersion = 5.10.2;
793933
};
794934
};
935+
961679512CFF207900B2B6DF /* XCRemoteSwiftPackageReference "swift-protobuf" */ = {
936+
isa = XCRemoteSwiftPackageReference;
937+
repositoryURL = "https://github.com/apple/swift-protobuf.git";
938+
requirement = {
939+
kind = exactVersion;
940+
version = 1.28.2;
941+
};
942+
};
795943
/* End XCRemoteSwiftPackageReference section */
796944

797945
/* Begin XCSwiftPackageProductDependency section */
@@ -820,6 +968,26 @@
820968
package = AAD720CE2D0816B200F6304D /* XCRemoteSwiftPackageReference "Alamofire" */;
821969
productName = Alamofire;
822970
};
971+
961679522CFF207900B2B6DF /* SwiftProtobuf */ = {
972+
isa = XCSwiftPackageProductDependency;
973+
package = 961679512CFF207900B2B6DF /* XCRemoteSwiftPackageReference "swift-protobuf" */;
974+
productName = SwiftProtobuf;
975+
};
976+
961679542CFF207900B2B6DF /* SwiftProtobufPluginLibrary */ = {
977+
isa = XCSwiftPackageProductDependency;
978+
package = 961679512CFF207900B2B6DF /* XCRemoteSwiftPackageReference "swift-protobuf" */;
979+
productName = SwiftProtobufPluginLibrary;
980+
};
981+
961679E22D03144900B2B6DF /* SwiftProtobuf */ = {
982+
isa = XCSwiftPackageProductDependency;
983+
package = 961679512CFF207900B2B6DF /* XCRemoteSwiftPackageReference "swift-protobuf" */;
984+
productName = SwiftProtobuf;
985+
};
986+
961679E42D03144C00B2B6DF /* SwiftProtobufPluginLibrary */ = {
987+
isa = XCSwiftPackageProductDependency;
988+
package = 961679512CFF207900B2B6DF /* XCRemoteSwiftPackageReference "swift-protobuf" */;
989+
productName = SwiftProtobufPluginLibrary;
990+
};
823991
/* End XCSwiftPackageProductDependency section */
824992
};
825993
rootObject = 961678F42CFF100D00B2B6DF /* Project object */;

Diff for: ‎Coder Desktop/Coder Desktop.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

+11-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@
2727
"revision" : "e0c7eebc5a4465a3c4680764f26b7a61f567cdaf"
2828
}
2929
},
30+
{
31+
"identity" : "swift-protobuf",
32+
"kind" : "remoteSourceControl",
33+
"location" : "https://github.com/apple/swift-protobuf.git",
34+
"state" : {
35+
"revision" : "ebc7251dd5b37f627c93698e4374084d98409633",
36+
"version" : "1.28.2"
37+
}
38+
},
3039
{
3140
"identity" : "swiftlintplugins",
3241
"kind" : "remoteSourceControl",
@@ -41,8 +50,8 @@
4150
"kind" : "remoteSourceControl",
4251
"location" : "https://github.com/nalexn/ViewInspector",
4352
"state" : {
44-
"revision" : "5acfa0a3c095ac9ad050abe51c60d1831e8321da",
45-
"version" : "0.10.0"
53+
"revision" : "788e7879d38a839c4e348ab0762dcc0364e646a2",
54+
"version" : "0.10.1"
4655
}
4756
}
4857
],

Diff for: ‎Coder Desktop/Coder Desktop.xcodeproj/xcshareddata/xcschemes/Coder Desktop.xcscheme

+11
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,17 @@
5757
ReferencedContainer = "container:Coder Desktop.xcodeproj">
5858
</BuildableReference>
5959
</TestableReference>
60+
<TestableReference
61+
skipped = "NO"
62+
parallelizable = "YES">
63+
<BuildableReference
64+
BuildableIdentifier = "primary"
65+
BlueprintIdentifier = "961679D82D030E1D00B2B6DF"
66+
BuildableName = "ProtoTests.xctest"
67+
BlueprintName = "ProtoTests"
68+
ReferencedContainer = "container:Coder Desktop.xcodeproj">
69+
</BuildableReference>
70+
</TestableReference>
6071
</Testables>
6172
</TestAction>
6273
<LaunchAction
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<Scheme
3+
LastUpgradeVersion = "1610"
4+
version = "1.7">
5+
<BuildAction
6+
parallelizeBuildables = "YES"
7+
buildImplicitDependencies = "YES"
8+
buildArchitectures = "Automatic">
9+
</BuildAction>
10+
<TestAction
11+
buildConfiguration = "Debug"
12+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
13+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
14+
shouldUseLaunchSchemeArgsEnv = "YES"
15+
shouldAutocreateTestPlan = "YES">
16+
<Testables>
17+
<TestableReference
18+
skipped = "NO"
19+
parallelizable = "YES">
20+
<BuildableReference
21+
BuildableIdentifier = "primary"
22+
BlueprintIdentifier = "961679D82D030E1D00B2B6DF"
23+
BuildableName = "ProtoTests.xctest"
24+
BlueprintName = "ProtoTests"
25+
ReferencedContainer = "container:Coder Desktop.xcodeproj">
26+
</BuildableReference>
27+
</TestableReference>
28+
</Testables>
29+
</TestAction>
30+
<LaunchAction
31+
buildConfiguration = "Debug"
32+
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
33+
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
34+
launchStyle = "0"
35+
useCustomWorkingDirectory = "NO"
36+
ignoresPersistentStateOnLaunch = "NO"
37+
debugDocumentVersioning = "YES"
38+
debugServiceExtension = "internal"
39+
allowLocationSimulation = "YES">
40+
<BuildableProductRunnable
41+
runnableDebuggingMode = "0">
42+
<BuildableReference
43+
BuildableIdentifier = "primary"
44+
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
45+
BuildableName = "Coder Desktop.app"
46+
BlueprintName = "Coder Desktop"
47+
ReferencedContainer = "container:Coder Desktop.xcodeproj">
48+
</BuildableReference>
49+
</BuildableProductRunnable>
50+
</LaunchAction>
51+
<ProfileAction
52+
buildConfiguration = "Release"
53+
shouldUseLaunchSchemeArgsEnv = "YES"
54+
savedToolIdentifier = ""
55+
useCustomWorkingDirectory = "NO"
56+
debugDocumentVersioning = "YES">
57+
<MacroExpansion>
58+
<BuildableReference
59+
BuildableIdentifier = "primary"
60+
BlueprintIdentifier = "961678FB2CFF100D00B2B6DF"
61+
BuildableName = "Coder Desktop.app"
62+
BlueprintName = "Coder Desktop"
63+
ReferencedContainer = "container:Coder Desktop.xcodeproj">
64+
</BuildableReference>
65+
</MacroExpansion>
66+
</ProfileAction>
67+
<AnalyzeAction
68+
buildConfiguration = "Debug">
69+
</AnalyzeAction>
70+
<ArchiveAction
71+
buildConfiguration = "Release"
72+
revealArchiveInOrganizer = "YES">
73+
</ArchiveAction>
74+
</Scheme>

Diff for: ‎Coder Desktop/Proto/Receiver.swift

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import Foundation
2+
import SwiftProtobuf
3+
import os
4+
5+
/// An actor that reads data from a `DispatchIO` channel, and deserializes it into VPN protocol messages.
6+
actor Receiver<RecvMsg: Message> {
7+
private let dispatch: DispatchIO
8+
private let queue: DispatchQueue
9+
private var running = false
10+
private let logger = Logger(subsystem: "com.coder.Coder-Desktop", category: "proto")
11+
12+
/// Creates an instance using the given `DispatchIO` channel and queue.
13+
init(dispatch: DispatchIO, queue: DispatchQueue) {
14+
self.dispatch = dispatch
15+
self.queue = queue
16+
}
17+
18+
/// Reads the protobuf message length from the `DispatchIO`, decodes it and returns it.
19+
private func readLen() async throws -> UInt32 {
20+
let lenD: Data = try await withCheckedThrowingContinuation { continuation in
21+
var lenData = Data()
22+
dispatch.read(offset: 0, length: 4, queue: queue) {done, data, error in
23+
guard error == 0 else {
24+
let errStrPtr = strerror(error)
25+
let errStr = String(validatingUTF8: errStrPtr!)!
26+
continuation.resume(throwing: ReceiveError.readError(errStr))
27+
return
28+
}
29+
lenData.append(contentsOf: data!)
30+
if done {
31+
continuation.resume(returning: lenData)
32+
}
33+
}
34+
}
35+
return try deserializeLen(lenD)
36+
}
37+
38+
/// Reads a protobuf message from the `DispatchIO` of the given length, then decodes it and returns it.
39+
private func readMsg(_ length: UInt32) async throws -> RecvMsg {
40+
let msgData: Data = try await withCheckedThrowingContinuation { continuation in
41+
var msgData = Data()
42+
dispatch.read(offset: 0, length: Int(length), queue: queue) {done, data, error in
43+
guard error == 0 else {
44+
let errStrPtr = strerror(error)
45+
let errStr = String(validatingUTF8: errStrPtr!)!
46+
continuation.resume(throwing: ReceiveError.readError(errStr))
47+
return
48+
}
49+
msgData.append(contentsOf: data!)
50+
if done {
51+
continuation.resume(returning: msgData)
52+
}
53+
}
54+
}
55+
return try RecvMsg(serializedBytes: msgData)
56+
}
57+
58+
/// Starts reading protocol messages from the `DispatchIO` channel and returns them as an `AsyncStream` of messages.
59+
/// On read or decoding error, it logs and closes the stream.
60+
func messages() throws -> AsyncStream<RecvMsg> {
61+
if running {
62+
throw ReceiveError.alreadyRunning
63+
}
64+
running = true
65+
return AsyncStream(
66+
unfolding: {
67+
do {
68+
let length = try await self.readLen()
69+
return try await self.readMsg(length)
70+
} catch {
71+
self.logger.error("failed to read proto message: \(error)")
72+
return nil
73+
}
74+
},
75+
onCancel: {
76+
self.logger.debug("async stream canceled")
77+
self.dispatch.close()
78+
}
79+
)
80+
}
81+
}
82+
83+
enum ReceiveError: Error {
84+
case readError(String)
85+
case invalidLength
86+
case alreadyRunning
87+
}
88+
89+
func deserializeLen(_ data: Data) throws -> UInt32 {
90+
if data.count != 4 {
91+
throw ReceiveError.invalidLength
92+
}
93+
return UInt32(data[0] << 24 | data[1] << 16 | data[2] << 8 | data[3])
94+
}

Diff for: ‎Coder Desktop/Proto/Sender.swift

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import Foundation
2+
import SwiftProtobuf
3+
4+
/// A actor that serializes and sends VPN protocol messages over a `FileHandle`, which is typically
5+
/// the write-side of a `Pipe`.
6+
actor Sender<SendMsg: Message> {
7+
private let writeFD: FileHandle
8+
9+
init(writeFD: FileHandle) {
10+
self.writeFD = writeFD
11+
}
12+
13+
func send(_ msg: SendMsg) throws {
14+
let data = try msg.serializedData()
15+
let length = serializeLen(UInt32(data.count))
16+
try writeFD.write(contentsOf: length)
17+
try writeFD.write(contentsOf: data)
18+
}
19+
20+
func close() throws {
21+
try writeFD.close()
22+
}
23+
}
24+
25+
/// Returns the given length as Data suitable to be serialized. Encodes as an unsigned 32-bit big-endian integer.
26+
func serializeLen(_ len: UInt32) -> Data {
27+
var out = Data(count: 4)
28+
out[0] = UInt8(len >> 24 & 0xFF)
29+
out[1] = UInt8(len >> 16 & 0xFF)
30+
out[2] = UInt8(len >> 8 & 0xFF)
31+
out[3] = UInt8(len & 0xFF)
32+
return out
33+
}

Diff for: ‎Coder Desktop/Proto/Speaker.swift

+339
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
import Foundation
2+
import SwiftProtobuf
3+
import os
4+
5+
let newLine = 0x0a
6+
let headerPreamble = "codervpn"
7+
8+
/// A message that has the `rpc` property for recording participation in a unary RPC.
9+
protocol RPCMessage {
10+
var rpc: Vpn_RPC {get set}
11+
/// Returns true if `rpc` has been explicitly set.
12+
var hasRpc: Bool {get}
13+
}
14+
15+
extension Vpn_TunnelMessage: RPCMessage {}
16+
extension Vpn_ManagerMessage: RPCMessage {}
17+
18+
/// A role within the VPN protocol. Determines what message types are allowed to be sent and recieved.
19+
enum ProtoRole: String {
20+
case manager
21+
case tunnel
22+
}
23+
24+
/// A version of the VPN protocol that can be negotiated.
25+
struct ProtoVersion: CustomStringConvertible, Equatable, Codable {
26+
let major: Int
27+
let minor: Int
28+
29+
var description: String {"\(major).\(minor)"}
30+
31+
init(_ major: Int, _ minor: Int) {
32+
self.major = major
33+
self.minor = minor
34+
}
35+
36+
init(parse str: String) throws {
37+
let parts = str.split(separator: ".").map({Int($0)})
38+
if parts.count != 2 {
39+
throw HandshakeError.invalidVersion(str)
40+
}
41+
guard let major = parts[0] else {
42+
throw HandshakeError.invalidVersion(str)
43+
}
44+
guard let minor = parts[1] else {
45+
throw HandshakeError.invalidVersion(str)
46+
}
47+
self.major = major
48+
self.minor = minor
49+
}
50+
}
51+
52+
/// An abstract base class for implementations that need to communicate using the VPN protocol.
53+
class Speaker<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage & Message> {
54+
private let logger = Logger(subsystem: "com.coder.Coder-Desktop", category: "proto")
55+
private let writeFD: FileHandle
56+
private let readFD: FileHandle
57+
private let dispatch: DispatchIO
58+
private let queue: DispatchQueue = .global(qos: .utility)
59+
private let sender: Sender<SendMsg>
60+
private let receiver: Receiver<RecvMsg>
61+
private let secretary = RPCSecretary<RecvMsg>()
62+
let role: ProtoRole
63+
64+
/// Creates an instance that communicates over the provided file handles.
65+
init(writeFD: FileHandle, readFD: FileHandle) {
66+
self.writeFD = writeFD
67+
self.readFD = readFD
68+
self.sender = Sender(writeFD: writeFD)
69+
self.dispatch = DispatchIO(
70+
type: .stream,
71+
fileDescriptor: readFD.fileDescriptor,
72+
queue: queue,
73+
cleanupHandler: {_ in
74+
do {
75+
try readFD.close()
76+
} catch {
77+
// TODO
78+
}
79+
})
80+
self.receiver = Receiver(dispatch: self.dispatch, queue: self.queue)
81+
if SendMsg.self == Vpn_TunnelMessage.self {
82+
self.role = .tunnel
83+
} else {
84+
self.role = .manager
85+
}
86+
}
87+
88+
/// Does the VPN Protocol handshake and validates the result
89+
func handshake() async throws {
90+
let hndsh = Handshaker(writeFD: writeFD, dispatch: dispatch, queue: queue, role: role)
91+
// ignore the version for now because we know it can only be 1.0
92+
try _ = await hndsh.handshake()
93+
}
94+
95+
/// Reads and handles protocol messages.
96+
func readLoop() async throws {
97+
for try await msg in try await self.receiver.messages() {
98+
guard msg.hasRpc else {
99+
self.handleMessage(msg)
100+
continue
101+
}
102+
guard msg.rpc.msgID == 0 else {
103+
let req = RPCRequest<SendMsg, RecvMsg>(req: msg, sender: self.sender)
104+
self.handleRPC(req)
105+
continue
106+
}
107+
guard msg.rpc.responseTo == 0 else {
108+
self.logger.debug("got RPC reply for msgID \(msg.rpc.responseTo)")
109+
do throws(RPCError) {
110+
try await self.secretary.route(reply: msg)
111+
} catch {
112+
self.logger.error(
113+
"couldn't route RPC reply for \(msg.rpc.responseTo): \(error)")
114+
}
115+
continue
116+
}
117+
}
118+
}
119+
120+
/// Handles a single non-RPC message. It is expected that subclasses override this method with their own handlers.
121+
func handleMessage(_ msg: RecvMsg) {
122+
// just log
123+
self.logger.debug("got non-RPC message \(msg.textFormatString())")
124+
}
125+
126+
/// Handle a single RPC request. It is expected that subclasses override this method with their own handlers.
127+
func handleRPC(_ req: RPCRequest<SendMsg, RecvMsg>) {
128+
// just log
129+
self.logger.debug("got RPC message \(req.msg.textFormatString())")
130+
}
131+
132+
/// Send a unary RPC message and handle the response
133+
func unaryRPC(_ req: SendMsg) async throws -> RecvMsg {
134+
return try await withCheckedThrowingContinuation { continuation in
135+
Task {
136+
let msgID = await self.secretary.record(continuation: continuation)
137+
var req = req
138+
req.rpc = Vpn_RPC()
139+
req.rpc.msgID = msgID
140+
do {
141+
self.logger.debug("sending RPC with msgID: \(msgID)")
142+
try await self.sender.send(req)
143+
} catch {
144+
self.logger.warning("failed to send RPC with msgID: \(msgID): \(error)")
145+
await self.secretary.erase(id: req.rpc.msgID)
146+
continuation.resume(throwing: error)
147+
}
148+
self.logger.debug("sent RPC with msgID: \(msgID)")
149+
}
150+
}
151+
}
152+
153+
func closeWrite() {
154+
do {
155+
try self.writeFD.close()
156+
} catch {
157+
logger.error("failed to close write file handle: \(error)")
158+
}
159+
}
160+
161+
func closeRead() {
162+
do {
163+
try self.readFD.close()
164+
} catch {
165+
logger.error("failed to close read file handle: \(error)")
166+
}
167+
}
168+
}
169+
170+
/// A class that performs the initial VPN protocol handshake and version negotiation.
171+
class Handshaker {
172+
private let writeFD: FileHandle
173+
private let dispatch: DispatchIO
174+
private var theirData: Data = Data()
175+
private let versions: [ProtoVersion]
176+
private let role: ProtoRole
177+
private var continuation: CheckedContinuation<Data, any Error>?
178+
private let queue: DispatchQueue
179+
180+
init (writeFD: FileHandle, dispatch: DispatchIO, queue: DispatchQueue,
181+
role: ProtoRole,
182+
versions: [ProtoVersion] = [.init(1, 0)]
183+
) {
184+
self.writeFD = writeFD
185+
self.dispatch = dispatch
186+
self.role = role
187+
self.queue = queue
188+
self.versions = versions
189+
}
190+
191+
/// Performs the initial VPN protocol handshake, returning the negotiated `ProtoVersion` that we should use.
192+
func handshake() async throws -> ProtoVersion {
193+
// kick off the read async before we try to write, synchronously, so we don't deadlock, both
194+
// waiting to write with nobody reading.
195+
async let theirs = try withCheckedThrowingContinuation { cont in
196+
continuation = cont
197+
// send in a nil read to kick us off
198+
handleRead(false, nil, 0)
199+
}
200+
201+
let vStr = versions.map({$0.description}).joined(separator: ",")
202+
let ours = String(format: "\(headerPreamble) \(role) \(vStr)\n")
203+
try writeFD.write(contentsOf: ours.data(using: .utf8)!)
204+
205+
let theirData = try await theirs
206+
guard let theirsString = String(bytes: theirData, encoding: .utf8) else {
207+
throw HandshakeError.invalidHeader("<unparsable: \(theirData)")
208+
}
209+
do {
210+
return try validateHeader(theirsString)
211+
} catch {
212+
writeFD.closeFile()
213+
dispatch.close()
214+
throw error
215+
}
216+
}
217+
218+
private func handleRead(_: Bool, _ data: DispatchData?, _ error: Int32) {
219+
guard error == 0 else {
220+
let errStrPtr = strerror(error)
221+
let errStr = String(validatingUTF8: errStrPtr!)!
222+
continuation?.resume(throwing: HandshakeError.readError(errStr))
223+
return
224+
}
225+
if let ddd = data, !ddd.isEmpty {
226+
guard ddd[0] != newLine else {
227+
continuation?.resume(returning: theirData)
228+
return
229+
}
230+
theirData.append(contentsOf: ddd)
231+
}
232+
233+
// read another byte, one at a time, so we don't read beyond the header.
234+
dispatch.read(offset: 0, length: 1, queue: queue, ioHandler: handleRead)
235+
}
236+
237+
private func validateHeader(_ header: String) throws -> ProtoVersion {
238+
let parts = header.split(separator: " ")
239+
guard parts.count == 3 else {
240+
throw HandshakeError.invalidHeader("expected 3 parts: \(header)")
241+
}
242+
guard parts[0] == headerPreamble else {
243+
throw HandshakeError.invalidHeader("expected \(headerPreamble) but got \(parts[0])")
244+
}
245+
var expectedRole = ProtoRole.manager
246+
if self.role == .manager {
247+
expectedRole = .tunnel
248+
}
249+
guard parts[1] == expectedRole.rawValue else {
250+
throw HandshakeError.wrongRole("expected \(expectedRole) but got \(parts[1])")
251+
}
252+
let theirVersions = try parts[2]
253+
.split(separator: ",")
254+
.map({try ProtoVersion(parse: String($0))})
255+
return try pickVersion(ours: versions, theirs: theirVersions)
256+
}
257+
}
258+
259+
func pickVersion(ours: [ProtoVersion], theirs: [ProtoVersion]) throws -> ProtoVersion {
260+
for our in ours.reversed() {
261+
for their in theirs.reversed() where our.major == their.major {
262+
if our.minor < their.minor {
263+
return our
264+
}
265+
return their
266+
}
267+
}
268+
throw HandshakeError.unsupportedVersion(theirs)
269+
}
270+
271+
enum HandshakeError: Error {
272+
case readError(String)
273+
case invalidHeader(String)
274+
case wrongRole(String)
275+
case invalidVersion(String)
276+
case unsupportedVersion([ProtoVersion])
277+
}
278+
279+
struct RPCRequest<SendMsg: RPCMessage & Message, RecvMsg: RPCMessage> {
280+
let msg: RecvMsg
281+
private let sender: Sender<SendMsg>
282+
283+
public init(req: RecvMsg, sender: Sender<SendMsg>) {
284+
self.msg = req
285+
self.sender = sender
286+
}
287+
288+
func sendReply(_ reply: SendMsg) async throws {
289+
var reply = reply
290+
reply.rpc.responseTo = msg.rpc.msgID
291+
try await sender.send(reply)
292+
}
293+
}
294+
295+
enum RPCError: Error {
296+
case missingRPC
297+
case notARequest
298+
case notAResponse
299+
case unknownResponseID(UInt64)
300+
case shutdown
301+
}
302+
303+
/// An actor to record outgoing RPCs and route their replies to the original sender
304+
actor RPCSecretary<RecvMsg: RPCMessage> {
305+
private var continuations: [UInt64: CheckedContinuation<RecvMsg, Error>] = [:]
306+
private var nextMsgID: UInt64 = 1
307+
308+
func record(continuation: CheckedContinuation<RecvMsg, Error>) -> UInt64 {
309+
let id = nextMsgID
310+
nextMsgID += 1
311+
continuations[id] = continuation
312+
return id
313+
}
314+
315+
func erase(id: UInt64) {
316+
continuations[id] = nil
317+
}
318+
319+
func shutdown() {
320+
for cont in continuations.values {
321+
cont.resume(throwing: RPCError.shutdown)
322+
}
323+
continuations = [:]
324+
}
325+
326+
func route(reply: RecvMsg) throws(RPCError) {
327+
guard reply.hasRpc else {
328+
throw RPCError.missingRPC
329+
}
330+
guard reply.rpc.responseTo != 0 else {
331+
throw RPCError.notAResponse
332+
}
333+
guard let cont = continuations[reply.rpc.responseTo] else {
334+
throw RPCError.unknownResponseID(reply.rpc.responseTo)
335+
}
336+
continuations[reply.rpc.responseTo] = nil
337+
cont.resume(returning: reply)
338+
}
339+
}

Diff for: ‎Coder Desktop/Proto/vpn.pb.swift

+1,766
Large diffs are not rendered by default.

Diff for: ‎Coder Desktop/Proto/vpn.proto

+198
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
syntax = "proto3";
2+
option go_package = "github.com/coder/coder/v2/vpn";
3+
option csharp_namespace = "Coder.Desktop.Vpn.Proto";
4+
5+
import "google/protobuf/timestamp.proto";
6+
7+
package vpn;
8+
9+
// The CoderVPN protocol operates over a bidirectional stream between a "manager" and a "tunnel."
10+
// The manager is part of the Coder Desktop application and written in OS native code. It handles
11+
// configuring the VPN and displaying status to the end user. The tunnel is written in Go and
12+
// handles operating the actual tunnel, including reading and writing packets, & communicating with
13+
// the Coder server control plane.
14+
15+
16+
// RPC allows a very simple unary request/response RPC mechanism. The requester generates a unique
17+
// msg_id which it sets on the request, the responder sets response_to that msg_id on the response
18+
// message
19+
message RPC {
20+
uint64 msg_id = 1;
21+
uint64 response_to = 2;
22+
}
23+
24+
// ManagerMessage is a message from the manager (to the tunnel).
25+
message ManagerMessage {
26+
RPC rpc = 1;
27+
oneof msg {
28+
GetPeerUpdate get_peer_update = 2;
29+
NetworkSettingsResponse network_settings = 3;
30+
StartRequest start = 4;
31+
StopRequest stop = 5;
32+
}
33+
}
34+
35+
// TunnelMessage is a message from the tunnel (to the manager).
36+
message TunnelMessage {
37+
RPC rpc = 1;
38+
oneof msg {
39+
Log log = 2;
40+
PeerUpdate peer_update = 3;
41+
NetworkSettingsRequest network_settings = 4;
42+
StartResponse start = 5;
43+
StopResponse stop = 6;
44+
}
45+
}
46+
47+
// Log is a log message generated by the tunnel. The manager should log it to the system log. It is
48+
// one-way tunnel -> manager with no response.
49+
message Log {
50+
enum Level {
51+
// these are designed to match slog levels
52+
DEBUG = 0;
53+
INFO = 1;
54+
WARN = 2;
55+
ERROR = 3;
56+
CRITICAL = 4;
57+
FATAL = 5;
58+
}
59+
Level level = 1;
60+
61+
string message = 2;
62+
repeated string logger_names = 3;
63+
64+
message Field {
65+
string name = 1;
66+
string value = 2;
67+
}
68+
repeated Field fields = 4;
69+
}
70+
71+
// GetPeerUpdate asks for a PeerUpdate with a full set of data.
72+
message GetPeerUpdate {}
73+
74+
// PeerUpdate is an update about workspaces and agents connected via the tunnel. It is generated in
75+
// response to GetPeerUpdate (which dumps the full set). It is also generated on any changes (not in
76+
// response to any request).
77+
message PeerUpdate {
78+
repeated Workspace upserted_workspaces = 1;
79+
repeated Agent upserted_agents = 2;
80+
repeated Workspace deleted_workspaces = 3;
81+
repeated Agent deleted_agents = 4;
82+
}
83+
84+
message Workspace {
85+
bytes id = 1; // UUID
86+
string name = 2;
87+
88+
enum Status {
89+
UNKNOWN = 0;
90+
PENDING = 1;
91+
STARTING = 2;
92+
RUNNING = 3;
93+
STOPPING = 4;
94+
STOPPED = 5;
95+
FAILED = 6;
96+
CANCELING = 7;
97+
CANCELED = 8;
98+
DELETING = 9;
99+
DELETED = 10;
100+
}
101+
Status status = 3;
102+
}
103+
104+
message Agent {
105+
bytes id = 1; // UUID
106+
string name = 2;
107+
bytes workspace_id = 3; // UUID
108+
string fqdn = 4;
109+
repeated string ip_addrs = 5;
110+
// last_handshake is the primary indicator of whether we are connected to a peer. Zero value or
111+
// anything longer than 5 minutes ago means there is a problem.
112+
google.protobuf.Timestamp last_handshake = 6;
113+
}
114+
115+
// NetworkSettingsRequest is based on
116+
// https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for
117+
// macOS. It is a request/response message with response NetworkSettingsResponse
118+
message NetworkSettingsRequest {
119+
uint32 tunnel_overhead_bytes = 1;
120+
uint32 mtu = 2;
121+
122+
message DNSSettings {
123+
repeated string servers = 1;
124+
repeated string search_domains = 2;
125+
// domain_name is the primary domain name of the tunnel
126+
string domain_name = 3;
127+
repeated string match_domains = 4;
128+
// match_domains_no_search specifies if the domains in the matchDomains list should not be
129+
// appended to the resolver’s list of search domains.
130+
bool match_domains_no_search = 5;
131+
}
132+
DNSSettings dns_settings = 3;
133+
134+
string tunnel_remote_address = 4;
135+
136+
message IPv4Settings {
137+
repeated string addrs = 1;
138+
repeated string subnet_masks = 2;
139+
// router is the next-hop router in dotted-decimal format
140+
string router = 3;
141+
142+
message IPv4Route {
143+
string destination = 1;
144+
string mask = 2;
145+
// router is the next-hop router in dotted-decimal format
146+
string router = 3;
147+
}
148+
repeated IPv4Route included_routes = 4;
149+
repeated IPv4Route excluded_routes = 5;
150+
}
151+
IPv4Settings ipv4_settings = 5;
152+
153+
message IPv6Settings {
154+
repeated string addrs = 1;
155+
repeated uint32 prefix_lengths = 2;
156+
157+
message IPv6Route {
158+
string destination = 1;
159+
uint32 prefix_length = 2;
160+
// router is the address of the next-hop
161+
string router = 3;
162+
}
163+
repeated IPv6Route included_routes = 3;
164+
repeated IPv6Route excluded_routes = 4;
165+
}
166+
IPv6Settings ipv6_settings = 6;
167+
}
168+
169+
// NetworkSettingsResponse is the response from the manager to the tunnel for a
170+
// NetworkSettingsRequest
171+
message NetworkSettingsResponse {
172+
bool success = 1;
173+
string error_message = 2;
174+
}
175+
176+
// StartRequest is a request from the manager to start the tunnel. The tunnel replies with a
177+
// StartResponse.
178+
message StartRequest {
179+
int32 tunnel_file_descriptor = 1;
180+
string coder_url = 2;
181+
string api_token = 3;
182+
}
183+
184+
message StartResponse {
185+
bool success = 1;
186+
string error_message = 2;
187+
}
188+
189+
// StopRequest is a request from the manager to stop the tunnel. The tunnel replies with a
190+
// StopResponse.
191+
message StopRequest {}
192+
193+
// StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes
194+
// its side of the bidirectional stream for writing.
195+
message StopResponse {
196+
bool success = 1;
197+
string error_message = 2;
198+
}

Diff for: ‎Coder Desktop/ProtoTests/ProtoTests.swift

+253
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
import Testing
2+
import Foundation
3+
@testable import Coder_Desktop
4+
5+
@Suite(.timeLimit(.minutes(1)))
6+
struct SenderReceiverTests {
7+
let pipe = Pipe()
8+
let dispatch: DispatchIO
9+
let queue: DispatchQueue = .global(qos: .utility)
10+
11+
init() {
12+
self.dispatch = DispatchIO(
13+
type: .stream,
14+
fileDescriptor: pipe.fileHandleForReading.fileDescriptor,
15+
queue: queue,
16+
cleanupHandler: {error in print("cleanupHandler: \(error)")}
17+
)
18+
}
19+
20+
@Test func sendOne() async throws {
21+
let s = Sender<Vpn_TunnelMessage>(writeFD: pipe.fileHandleForWriting)
22+
let r = Receiver<Vpn_TunnelMessage>(dispatch: dispatch, queue: queue)
23+
var msg = Vpn_TunnelMessage()
24+
msg.log = Vpn_Log()
25+
msg.log.message = "test log"
26+
Task {
27+
try await s.send(msg)
28+
try await s.close()
29+
}
30+
var count = 0
31+
for try await got in try await r.messages() {
32+
#expect(got.log.message == "test log")
33+
count += 1
34+
}
35+
#expect(count == 1)
36+
}
37+
38+
@Test func sendMany() async throws {
39+
let s = Sender<Vpn_ManagerMessage>(writeFD: pipe.fileHandleForWriting)
40+
let r = Receiver<Vpn_ManagerMessage>(dispatch: dispatch, queue: queue)
41+
var msg = Vpn_ManagerMessage()
42+
msg.networkSettings.errorMessage = "test error"
43+
Task {
44+
for _ in 0..<10 {
45+
try await s.send(msg)
46+
}
47+
try await s.close()
48+
}
49+
var count = 0
50+
for try await got in try await r.messages() {
51+
#expect(got.networkSettings.errorMessage == "test error")
52+
count += 1
53+
}
54+
#expect(count == 10)
55+
}
56+
}
57+
58+
@Suite(.timeLimit(.minutes(1)))
59+
struct HandshakerTests {
60+
let pipeMT = Pipe()
61+
let pipeTM = Pipe()
62+
let dispatchT: DispatchIO
63+
let dispatchM: DispatchIO
64+
let queue: DispatchQueue = .global(qos: .utility)
65+
66+
init() {
67+
self.dispatchT = DispatchIO(
68+
type: .stream,
69+
fileDescriptor: pipeMT.fileHandleForReading.fileDescriptor,
70+
queue: queue,
71+
cleanupHandler: {error in print("cleanupHandler: \(error)")}
72+
)
73+
self.dispatchM = DispatchIO(
74+
type: .stream,
75+
fileDescriptor: pipeTM.fileHandleForReading.fileDescriptor,
76+
queue: queue,
77+
cleanupHandler: {error in print("cleanupHandler: \(error)")}
78+
)
79+
}
80+
81+
@Test("Default versions")
82+
func mainline() async throws {
83+
let uutTun = Handshaker(
84+
writeFD: pipeTM.fileHandleForWriting, dispatch: dispatchT, queue: queue, role: .tunnel)
85+
let uutMgr = Handshaker(
86+
writeFD: pipeMT.fileHandleForWriting, dispatch: dispatchM, queue: queue, role: .manager)
87+
let taskTun = Task {
88+
try await uutTun.handshake()
89+
}
90+
let taskMgr = Task {
91+
try await uutMgr.handshake()
92+
}
93+
let versionTun = try await taskTun.value
94+
#expect(versionTun == ProtoVersion(1, 0))
95+
let versionMgr = try await taskMgr.value
96+
#expect(versionMgr == ProtoVersion(1, 0))
97+
}
98+
99+
100+
struct versionCase : CustomStringConvertible {
101+
let tun: [ProtoVersion]
102+
let mgr: [ProtoVersion]
103+
let result: ProtoVersion
104+
105+
var description: String {
106+
return "\(tun) vs \(mgr) -> \(result)"
107+
}
108+
}
109+
110+
@Test("explicit versions", arguments: [
111+
versionCase(
112+
tun: [ProtoVersion(1, 0)],
113+
mgr: [ProtoVersion(1, 1)],
114+
result: ProtoVersion(1,0)),
115+
versionCase(
116+
tun: [ProtoVersion(1, 1)],
117+
mgr: [ProtoVersion(1, 7)],
118+
result: ProtoVersion(1,1)),
119+
versionCase(
120+
tun: [ProtoVersion(1, 7), ProtoVersion(2, 1)],
121+
mgr: [ProtoVersion(1, 7)],
122+
result: ProtoVersion(1, 7)),
123+
versionCase(
124+
tun: [ProtoVersion(1, 7)],
125+
mgr: [ProtoVersion(1, 7), ProtoVersion(2, 1)],
126+
result: ProtoVersion(1, 7)),
127+
versionCase(
128+
tun: [ProtoVersion(1, 3), ProtoVersion(2, 1)],
129+
mgr: [ProtoVersion(1, 7)],
130+
result: ProtoVersion(1, 3)),
131+
])
132+
func explictVersions(tc: versionCase) async throws {
133+
let uutTun = Handshaker(
134+
writeFD: pipeTM.fileHandleForWriting, dispatch: dispatchT, queue: queue, role: .tunnel,
135+
versions: tc.tun
136+
)
137+
let uutMgr = Handshaker(
138+
writeFD: pipeMT.fileHandleForWriting, dispatch: dispatchM, queue: queue, role: .manager,
139+
versions: tc.mgr
140+
)
141+
let taskTun = Task {
142+
try await uutTun.handshake()
143+
}
144+
let taskMgr = Task {
145+
try await uutMgr.handshake()
146+
}
147+
let versionTun = try await taskTun.value
148+
#expect(versionTun == tc.result)
149+
let versionMgr = try await taskMgr.value
150+
#expect(versionMgr == tc.result)
151+
}
152+
153+
@Test
154+
func incompatible() async throws {
155+
let uutTun = Handshaker(
156+
writeFD: pipeTM.fileHandleForWriting, dispatch: dispatchT, queue: queue, role: .tunnel,
157+
versions: [ProtoVersion(1,8)]
158+
)
159+
let uutMgr = Handshaker(
160+
writeFD: pipeMT.fileHandleForWriting, dispatch: dispatchM, queue: queue, role: .manager,
161+
versions: [ProtoVersion(2,8)]
162+
)
163+
let taskTun = Task {
164+
try await uutTun.handshake()
165+
}
166+
let taskMgr = Task {
167+
try await uutMgr.handshake()
168+
}
169+
await #expect(throws: HandshakeError.self) {
170+
try await taskTun.value
171+
}
172+
await #expect(throws: HandshakeError.self) {
173+
try await taskMgr.value
174+
}
175+
}
176+
}
177+
178+
@Suite(.timeLimit(.minutes(1)))
179+
struct OneSidedHandshakerTests {
180+
let pipeMT = Pipe()
181+
let pipeTM = Pipe()
182+
let queue: DispatchQueue = .global(qos: .utility)
183+
let dispatchT: DispatchIO
184+
let uut: Handshaker
185+
186+
init() {
187+
self.dispatchT = DispatchIO(
188+
type: .stream,
189+
fileDescriptor: pipeMT.fileHandleForReading.fileDescriptor,
190+
queue: queue,
191+
cleanupHandler: {error in print("cleanupHandler: \(error)")}
192+
)
193+
self.uut = Handshaker(
194+
writeFD: pipeTM.fileHandleForWriting, dispatch: dispatchT, queue: queue, role: .tunnel
195+
)
196+
}
197+
198+
@Test()
199+
func badPreamble() async throws {
200+
let taskTun = Task {
201+
try await uut.handshake()
202+
}
203+
pipeMT.fileHandleForWriting.write(Data("something manager 1.0\n".utf8))
204+
let tunHdr = try pipeTM.fileHandleForReading.readToEnd()
205+
#expect(tunHdr == Data("codervpn tunnel 1.0\n".utf8))
206+
await #expect(throws: HandshakeError.self) {
207+
try await taskTun.value
208+
}
209+
}
210+
211+
@Test(.timeLimit(.minutes(1)))
212+
func badRole() async throws {
213+
let taskTun = Task {
214+
try await uut.handshake()
215+
}
216+
pipeMT.fileHandleForWriting.write(Data("codervpn head-honcho 1.0\n".utf8))
217+
let tunHdr = try pipeTM.fileHandleForReading.readToEnd()
218+
#expect(tunHdr == Data("codervpn tunnel 1.0\n".utf8))
219+
await #expect(throws: HandshakeError.self) {
220+
try await taskTun.value
221+
}
222+
}
223+
224+
@Test(.timeLimit(.minutes(1)))
225+
func badVersion() async throws {
226+
let taskTun = Task {
227+
try await uut.handshake()
228+
}
229+
pipeMT.fileHandleForWriting.write(Data("codervpn manager one-dot-oh\n".utf8))
230+
let tunHdr = try pipeTM.fileHandleForReading.readToEnd()
231+
#expect(tunHdr == Data("codervpn tunnel 1.0\n".utf8))
232+
await #expect(throws: HandshakeError.self) {
233+
try await taskTun.value
234+
}
235+
}
236+
237+
@Test(.timeLimit(.minutes(1)))
238+
func mainline() async throws {
239+
let taskTun = Task {
240+
let v = try await uut.handshake()
241+
// close our pipe so that `readToEnd()` below succeeds.
242+
try pipeTM.fileHandleForWriting.close()
243+
return v
244+
}
245+
pipeMT.fileHandleForWriting.write(Data("codervpn manager 1.0\n".utf8))
246+
let tunHdr = try pipeTM.fileHandleForReading.readToEnd()
247+
#expect(tunHdr == Data("codervpn tunnel 1.0\n".utf8))
248+
249+
let v = try await taskTun.value
250+
#expect(v == ProtoVersion(1,0))
251+
}
252+
}
253+

Diff for: ‎Coder Desktop/ProtoTests/SpeakerTests.swift

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import Testing
2+
import Foundation
3+
@testable import Coder_Desktop
4+
5+
/// A concrete, test class for the abstract Speaker, which overrides the handlers to send things to
6+
/// continuations we set in the test.
7+
class TestTunnel: Speaker<Vpn_TunnelMessage, Vpn_ManagerMessage> {
8+
var msgHandler: CheckedContinuation<Vpn_ManagerMessage, Error>?
9+
override func handleMessage(_ msg: Vpn_ManagerMessage) {
10+
msgHandler?.resume(returning: msg)
11+
}
12+
13+
var rpcHandler: CheckedContinuation<RPCRequest<Vpn_TunnelMessage, Vpn_ManagerMessage>, Error>?
14+
override func handleRPC(_ req: RPCRequest<Vpn_TunnelMessage, Vpn_ManagerMessage>) {
15+
rpcHandler?.resume(returning: req)
16+
}
17+
}
18+
19+
@Suite(.timeLimit(.minutes(1)))
20+
struct SpeakerTests {
21+
let pipeMT = Pipe()
22+
let pipeTM = Pipe()
23+
let uut: TestTunnel
24+
let sender: Sender<Vpn_ManagerMessage>
25+
let dispatch: DispatchIO
26+
let receiver: Receiver<Vpn_TunnelMessage>
27+
let handshaker: Handshaker
28+
29+
init() {
30+
let queue = DispatchQueue.global(qos: .utility)
31+
self.uut = TestTunnel(
32+
writeFD: pipeTM.fileHandleForWriting,
33+
readFD: pipeMT.fileHandleForReading
34+
)
35+
self.dispatch = DispatchIO(
36+
type: .stream,
37+
fileDescriptor: pipeTM.fileHandleForReading.fileDescriptor,
38+
queue: queue,
39+
cleanupHandler: {error in print("cleanupHandler: \(error)")}
40+
)
41+
self.sender = Sender(writeFD: pipeMT.fileHandleForWriting)
42+
self.receiver = Receiver(dispatch: dispatch, queue: queue)
43+
self.handshaker = Handshaker(
44+
writeFD: pipeMT.fileHandleForWriting,
45+
dispatch: self.dispatch, queue: queue,
46+
role: .manager)
47+
}
48+
49+
@Test func handshake() async throws {
50+
async let v = handshaker.handshake()
51+
try await uut.handshake()
52+
#expect(try await v == ProtoVersion(1, 0))
53+
}
54+
55+
@Test func handleSingleMessage() async throws {
56+
async let readDone: () = try uut.readLoop()
57+
58+
let got = try await withCheckedThrowingContinuation { continuation in
59+
uut.msgHandler = continuation
60+
Task {
61+
var s = Vpn_ManagerMessage()
62+
s.start = Vpn_StartRequest()
63+
await #expect(throws: Never.self) {
64+
try await sender.send(s)
65+
}
66+
}
67+
}
68+
#expect(got.msg == .start(Vpn_StartRequest()))
69+
try await sender.close()
70+
try await readDone
71+
}
72+
73+
@Test func handleRPC() async throws {
74+
async let readDone: () = try uut.readLoop()
75+
76+
let got = try await withCheckedThrowingContinuation { continuation in
77+
uut.rpcHandler = continuation
78+
Task {
79+
var s = Vpn_ManagerMessage()
80+
s.start = Vpn_StartRequest()
81+
s.rpc = Vpn_RPC()
82+
s.rpc.msgID = 33
83+
await #expect(throws: Never.self) {
84+
try await sender.send(s)
85+
}
86+
}
87+
}
88+
#expect(got.msg.msg == .start(Vpn_StartRequest()))
89+
#expect(got.msg.rpc.msgID == 33)
90+
var reply = Vpn_TunnelMessage()
91+
reply.start = Vpn_StartResponse()
92+
reply.rpc.responseTo = 33
93+
try await got.sendReply(reply)
94+
uut.closeWrite()
95+
96+
var count = 0
97+
await #expect(throws: Never.self) {
98+
for try await reply in try await receiver.messages() {
99+
count += 1
100+
#expect(reply.rpc.responseTo == 33)
101+
}
102+
#expect(count == 1)
103+
}
104+
try await sender.close()
105+
try await readDone
106+
}
107+
108+
@Test func sendRPCs() async throws {
109+
async let readDone: () = try uut.readLoop()
110+
111+
async let managerDone = Task {
112+
var count = 0
113+
for try await req in try await receiver.messages() {
114+
#expect(req.msg == .networkSettings(Vpn_NetworkSettingsRequest()))
115+
try #require(req.rpc.msgID != 0)
116+
var reply = Vpn_ManagerMessage()
117+
reply.networkSettings = Vpn_NetworkSettingsResponse()
118+
reply.networkSettings.errorMessage = "test \(count)"
119+
reply.rpc.responseTo = req.rpc.msgID
120+
try await sender.send(reply)
121+
count += 1
122+
}
123+
#expect(count == 2)
124+
}
125+
for i in 0..<2 {
126+
var req = Vpn_TunnelMessage()
127+
req.networkSettings = Vpn_NetworkSettingsRequest()
128+
let got = try await uut.unaryRPC(req)
129+
#expect(got.networkSettings.errorMessage == "test \(i)")
130+
}
131+
uut.closeWrite()
132+
_ = await managerDone
133+
try await sender.close()
134+
try await readDone
135+
}
136+
}

Diff for: ‎Makefile

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
proto:
2+
protoc --swift_out=. 'Coder Desktop/Proto/vpn.proto'

0 commit comments

Comments
 (0)
Please sign in to comment.