Skip to content

chore: enforce minimum coder server version of v2.20.0 #90

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Coder Desktop/Coder Desktop/Views/LoginForm.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CoderSDK
import SwiftUI
import VPNLib

struct LoginForm: View {
@EnvironmentObject var state: AppState
Expand Down Expand Up @@ -78,6 +79,22 @@ struct LoginForm: View {
loginError = .failedAuth(error)
return
}
let buildInfo: BuildInfoResponse
do {
buildInfo = try await client.buildInfo()
} catch {
loginError = .failedAuth(error)
return
}
guard let semver = buildInfo.semver else {
loginError = .missingServerVersion
return
}
// x.compare(y) is .orderedDescending if x > y
guard SignatureValidator.minimumCoderVersion.compare(semver, options: .numeric) != .orderedDescending else {
loginError = .outdatedCoderVersion
return
}
state.login(baseAccessURL: url, sessionToken: sessionToken)
dismiss()
}
Expand Down Expand Up @@ -190,6 +207,8 @@ enum LoginError: Error {
case httpsRequired
case noHost
case invalidURL
case outdatedCoderVersion
case missingServerVersion
case failedAuth(ClientError)

var description: String {
Expand All @@ -200,8 +219,15 @@ enum LoginError: Error {
"URL must have a host"
case .invalidURL:
"Invalid URL"
case .outdatedCoderVersion:
"""
The Coder deployment must be version \(SignatureValidator.minimumCoderVersion)
or higher to use Coder Desktop.
"""
case let .failedAuth(err):
"Could not authenticate with Coder deployment:\n\(err.localizedDescription)"
case .missingServerVersion:
"Coder deployment did not provide a server version"
}
}

Expand Down
41 changes: 41 additions & 0 deletions Coder Desktop/Coder DesktopTests/LoginFormTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ struct LoginTests {
@Test
func testFailedAuthentication() async throws {
let url = URL(string: "https://testFailedAuthentication.com")!
let buildInfo = BuildInfoResponse(
version: "v2.20.0"
)
try Mock(
url: url.appendingPathComponent("/api/v2/buildinfo"),
statusCode: 200,
data: [.get: Client.encoder.encode(buildInfo)]
).register()
Mock(url: url.appendingPathComponent("/api/v2/users/me"), statusCode: 401, data: [.get: Data()]).register()

try await ViewHosting.host(view) {
Expand All @@ -87,6 +95,30 @@ struct LoginTests {
}
}

@Test
func testOutdatedServer() async throws {
let url = URL(string: "https://testOutdatedServer.com")!
let buildInfo = BuildInfoResponse(
version: "v2.19.0"
)
try Mock(
url: url.appendingPathComponent("/api/v2/buildinfo"),
statusCode: 200,
data: [.get: Client.encoder.encode(buildInfo)]
).register()

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput(url.absoluteString)
try view.find(button: "Next").tap()
#expect(throws: Never.self) { try view.find(text: "Session Token") }
try view.find(ViewType.SecureField.self).setInput("valid-token")
try await view.actualView().submit()
#expect(throws: Never.self) { try view.find(ViewType.Alert.self) }
}
}
}

@Test
func testSuccessfulLogin() async throws {
let url = URL(string: "https://testSuccessfulLogin.com")!
Expand All @@ -95,13 +127,22 @@ struct LoginTests {
id: UUID(),
username: "admin"
)
let buildInfo = BuildInfoResponse(
version: "v2.20.0"
)

try Mock(
url: url.appendingPathComponent("/api/v2/users/me"),
statusCode: 200,
data: [.get: Client.encoder.encode(user)]
).register()

try Mock(
url: url.appendingPathComponent("/api/v2/buildinfo"),
statusCode: 200,
data: [.get: Client.encoder.encode(buildInfo)]
).register()

try await ViewHosting.host(view) {
try await sut.inspection.inspect { view in
try view.find(ViewType.TextField.self).setInput(url.absoluteString)
Expand Down
4 changes: 2 additions & 2 deletions Coder Desktop/VPN/Manager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ actor Manager {
// The tunnel might be asked to start before the network interfaces have woken up from sleep
sessionConfig.waitsForConnectivity = true
// URLSession's waiting for connectivity sometimes hangs even when
// the network is up so this is deliberately short (15s) to avoid a
// the network is up so this is deliberately short (30s) to avoid a
// poor UX where it appears stuck.
sessionConfig.timeoutIntervalForResource = 15
sessionConfig.timeoutIntervalForResource = 30
try await download(src: dylibPath, dest: dest, urlSession: URLSession(configuration: sessionConfig))
} catch {
throw .download(error)
Expand Down
24 changes: 23 additions & 1 deletion Coder Desktop/VPNLib/Download.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public enum ValidationError: Error {
case invalidTeamIdentifier(identifier: String?)
case missingInfoPList
case invalidVersion(version: String?)
case belowMinimumCoderVersion

public var description: String {
switch self {
Expand All @@ -29,13 +30,21 @@ public enum ValidationError: Error {
"Invalid team identifier: \(identifier ?? "unknown")."
case .missingInfoPList:
"Info.plist is not embedded within the dylib."
case .belowMinimumCoderVersion:
"""
The Coder deployment must be version \(SignatureValidator.minimumCoderVersion)
or higher to use Coder Desktop.
"""
}
}

public var localizedDescription: String { description }
}

public class SignatureValidator {
// Whilst older dylibs exist, this app assumes v2.20 or later.
public static let minimumCoderVersion = "2.20.0"

private static let expectedName = "CoderVPN"
private static let expectedIdentifier = "com.coder.Coder-Desktop.VPN.dylib"
private static let expectedTeamIdentifier = "4399GN35BJ"
Expand Down Expand Up @@ -87,6 +96,10 @@ public class SignatureValidator {
throw .missingInfoPList
}

try validateInfo(infoPlist: infoPlist, expectedVersion: expectedVersion)
}

private static func validateInfo(infoPlist: [String: AnyObject], expectedVersion: String) throws(ValidationError) {
guard let plistIdent = infoPlist[infoIdentifierKey] as? String, plistIdent == expectedIdentifier else {
throw .invalidIdentifier(identifier: infoPlist[infoIdentifierKey] as? String)
}
Expand All @@ -95,11 +108,20 @@ public class SignatureValidator {
throw .invalidIdentifier(identifier: infoPlist[infoNameKey] as? String)
}

// Downloaded dylib must match the version of the server
guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
expectedVersion.compare(dylibVersion, options: .numeric) != .orderedDescending
expectedVersion == dylibVersion
else {
throw .invalidVersion(version: infoPlist[infoShortVersionKey] as? String)
}

// Downloaded dylib must be at least the minimum Coder server version
guard let dylibVersion = infoPlist[infoShortVersionKey] as? String,
// x.compare(y) is .orderedDescending if x > y
minimumCoderVersion.compare(dylibVersion, options: .numeric) != .orderedDescending
else {
throw .belowMinimumCoderVersion
}
}
}

Expand Down
Loading