Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,9 @@ public actor NativeDeviceEnrollment {
guard let device = binding?.device ?? record?.device else { self.saved = nil; return }
let bearer = try await sessionBearer(saved.context)
let response = try await native.send(method: "DELETE", path: "/v1/devices/\(device)", body: nil, bearer: bearer, tenant: scope.tenant)
guard (200..<300).contains(response.status) || response.status == 404 else { throw NativeDeviceEnrollmentError.refused(response.status) }
guard (200..<300).contains(response.status) || response.status == 404 else {
throw NativeDeviceEnrollmentError.refused(response.status, step: "DELETE /v1/devices/{device}")
}
if var record, record.device == device {
let publicKey = try record.key.publicKey.rawRepresentation.base64EncodedString()
if binding == nil || publicKey == binding?.wgPublicKey {
Expand Down Expand Up @@ -253,14 +255,16 @@ public actor NativeDeviceEnrollment {
}
private func owner<T: Decodable & Sendable, P: Encodable & Sendable>(_ op: String, _ params: P, key: String, context: NativeInstallContext) async throws -> T {
let response = try await identity.nativeOwnerPost(JSONEncoder().encode(NativeOwnerCall(op: op, params: params, idempotency_key: key)), context: context)
guard response.status == 200, response.body.count <= 1_048_576 else { throw NativeDeviceEnrollmentError.refused(response.status) }
guard response.status == 200, response.body.count <= 1_048_576 else { throw NativeDeviceEnrollmentError.refused(response.status, step: op) }
let decoded = try JSONDecoder().decode(NativeOwnerReply<T>.self, from: response.body)
guard decoded.ok, let value = decoded.value else { throw NativeDeviceEnrollmentError.invalidIdentity }
return value
}
private func readNative<T: Decodable & Sendable>(_ method: String, _ path: String, body: Data? = nil, bearer: String? = nil) async throws -> T {
let reply = try await native.send(method: method, path: path, body: body, bearer: bearer, tenant: bearer == nil ? nil : scope.tenant)
guard reply.status == 200 || reply.status == 201, reply.body.count <= 1_048_576 else { throw NativeDeviceEnrollmentError.refused(reply.status) }
guard reply.status == 200 || reply.status == 201, reply.body.count <= 1_048_576 else {
throw NativeDeviceEnrollmentError.refused(reply.status, step: NativeDeviceScope.step(method, path))
}
return try JSONDecoder().decode(T.self, from: reply.body)
}
private func hash(_ value: String) -> String { SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() }
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
public import Foundation

public enum NativeDeviceEnrollmentError: Error, Equatable, Sendable {
public enum NativeDeviceEnrollmentError: Error, Equatable, Sendable, CustomStringConvertible {
case invalidScope, invalidState, invalidIdentity, busy, signedOut, revoked
case refused(Int)
/// An HTTP status the backend answered with, and the step that got it: an owner op name or a
/// `METHOD /path` template with ids replaced (never an id or a token).
case refused(Int, step: String)
case secureStorage(Int32)

public var description: String {
switch self {
case .invalidScope: "invalidScope"
case .invalidState: "invalidState"
case .invalidIdentity: "invalidIdentity"
case .busy: "busy"
case .signedOut: "signedOut"
case .revoked: "revoked"
case .refused(let status, let step): "refused(\(status)) at \(step)"
case .secureStorage(let status): "secureStorage(\(status))"
}
}
}

/// Explicit backend team and Native tenant/mesh. No endpoint or credential comes from enrollment replies.
Expand All @@ -20,6 +35,16 @@ public struct NativeDeviceScope: Encodable, Hashable, Sendable {
}

static func isDevice(_ value: String) -> Bool { isID(value, prefix: "dev_") }
/// `METHOD /path` with device and mesh ids replaced by `{device}` and `{mesh}`, for error detail.
static func step(_ method: String, _ path: String) -> String {
let template = path.split(separator: "/", omittingEmptySubsequences: false).map { segment -> String in
let value = String(segment)
if isDevice(value) { return "{device}" }
if isID(value, prefix: "mesh_") { return "{mesh}" }
return value
}.joined(separator: "/")
return "\(method) \(template)"
}
static func canonicalOrigin(_ origin: URL) throws -> URL {
guard var c = URLComponents(url: origin, resolvingAgainstBaseURL: false), c.scheme?.lowercased() == "https", c.host?.isEmpty == false,
c.user == nil, c.password == nil, c.query == nil, c.fragment == nil, c.path.isEmpty || c.path == "/" else { throw NativeDeviceEnrollmentError.invalidScope }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ extension MacInstallIdentity {
let context = try await nativeContext(team: team)
let body = try JSONEncoder().encode(NativeNetworkTargetCall(params: .init(mesh: mesh)))
let reply = try await nativeOwnerRead(body, context: context)
guard reply.status == 200, reply.body.count <= 1_048_576 else { throw NativeDeviceEnrollmentError.refused(reply.status) }
guard reply.status == 200, reply.body.count <= 1_048_576 else { throw NativeDeviceEnrollmentError.refused(reply.status, step: "host.network.target") }
let decoded = try JSONDecoder().decode(NativeNetworkTargetEnvelope.self, from: reply.body)
guard decoded.op == "host.network.target" else { throw NativeDeviceEnrollmentError.invalidIdentity }
let target = try NativeNetworkTarget(decoded.value, team: team, mesh: mesh, context: context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ struct CloudNativeFirstLauncherTests {
}

@Test func aFailedNativeRouteFallsBackToCmuxLinkUntilSignOut() async throws {
let native = FakeLauncher(status: "native", failure: NativeDeviceEnrollmentError.refused(400))
let native = FakeLauncher(status: "native", failure: NativeDeviceEnrollmentError.refused(400, step: "host.network.target"))
let link = FakeLauncher(status: "link")
var reasons: [String] = []
let launcher = CloudNativeFirstLauncher(native: native, link: link) { reasons.append(String(describing: $0)) }
Expand Down Expand Up @@ -63,13 +63,13 @@ struct CloudNativeFirstLauncherTests {
#expect(asked == 1 && noHolder.launcher as? FakeLauncher === link)
#expect(noHolder.route() == "link (native asked; no Native route holder for this app)")

let native = FakeLauncher(status: "native", failure: NativeDeviceEnrollmentError.refused(400))
let native = FakeLauncher(status: "native", failure: NativeDeviceEnrollmentError.refused(400, step: "host.network.target"))
var reasons = 0
let first = CloudAppServerLink(appServer: true, link: link, choice: .native, native: { native }) { _ in reasons += 1 }
#expect(first.launcher is CloudNativeFirstLauncher && first.route() == "native")
#expect(try await first.launcher?.ensure(install: "i").status == "link")
#expect(reasons == 1)
#expect(first.route() == "link (native failed: \(NativeDeviceEnrollmentError.refused(400)))")
#expect(first.route() == "link (native failed: refused(400) at host.network.target)")
}

@Test func signedOutIsNotARouteFailure() async {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import Testing
defer { try? FileManager.default.removeItem(at: root) }
let (scope, world, identity) = try await fixture(root: root)
await world.refuseTarget(503)
await #expect(throws: NativeDeviceEnrollmentError.refused(503)) { try await identity.nativeNetworkTarget(team: scope.team) }
await #expect(throws: NativeDeviceEnrollmentError.refused(503, step: "host.network.target")) { try await identity.nativeNetworkTarget(team: scope.team) }
#expect(await world.enrollments == 0)
#expect(await world.auth.requestedTeams == [scope.team])
}
Expand Down
Loading