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 @@ -11,6 +11,15 @@ public protocol InstallAuthTransport: Sendable {
/// bearer and extra request `headers` (for example the client version).
/// Returns the status and body.
func post(_ path: String, json: Data, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data)
/// GETs `path` (relative to the API Worker, query included) with an optional bearer and extra `headers`.
func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data)
}

extension InstallAuthTransport {
/// A transport that only posts (the install-auth exchange needs no GET) answers a GET as unsupported.
public func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) {
throw URLError(.unsupportedURL)
}
}

/// What the install keeps between launches (never the token).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ final class CloudService {
signedIn: { [auth] in await MainActor.run { auth.isSignedIn } },
allowedOps: { await CloudCredentialRelay.bundledServedOps() })
credentialProvider = CloudCredentialProvider(relay: relay)
// The app server link: this Mac's `cmux link`, or with `CMUX_NEXT_CLOUD_NATIVE_LINK=1` the Native
// device route (#28947) with `cmux link` as its fallback when Native does not start.
// The app server link: the Native device route (#28947) with `cmux link` as its fallback when Native
// does not start, or with `CMUX_NEXT_CLOUD_NATIVE_LINK=0` this Mac's `cmux link` alone.
let linkBuild = LaunchIdentity.current().build
let appServerLink = CloudAppServerLink(appServer: configuration.linkSource == .appServer, link: CloudLinkLauncher.forApp(build: linkBuild, relay: relay),
native: { CloudNativeRouteHolder.forApp(identity: identity, auth: auth, paths: paths, build: linkBuild, deviceName: Self.deviceName) }) { error in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,22 @@ public struct InstallHTTPTransport: InstallAuthTransport {
let (data, response) = try await session.data(for: request)
return ((response as? HTTPURLResponse)?.statusCode ?? 0, data)
}

public func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) {
// The path joins the base as `post` does; a query after "?" is kept as given.
let parts = path.split(separator: "?", maxSplits: 1, omittingEmptySubsequences: false)
guard var components = URLComponents(url: baseURL.appendingPathComponent(String(parts[0])), resolvingAgainstBaseURL: false)
else { throw URLError(.badURL) }
if parts.count == 2 { components.percentEncodedQuery = String(parts[1]) }
guard let url = components.url else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("application/json", forHTTPHeaderField: "accept")
if let bearer { request.setValue("Bearer \(bearer)", forHTTPHeaderField: "authorization") }
for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
let (data, response) = try await session.data(for: request)
return ((response as? HTTPURLResponse)?.statusCode ?? 0, data)
}
}

/// Answers every redirect with "do not follow" (the completion-handler form:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@ public actor MacInstallIdentity {
return reply
}

/// A GET on the API Worker under the install's token (`path` includes its query).
func nativeOwnerGet(_ path: String, context: NativeInstallContext) async throws -> (status: Int, body: Data) {
try await nativeCheck(context)
let reply = try await transport.get(path, bearer: context.token, headers: requestHeaders)
try await nativeCheck(context)
return reply
}

func nativeOwnerRead(_ body: Data, context: NativeInstallContext) async throws -> (status: Int, body: Data) {
try await nativeCheck(context)
let reply = try await transport.post("/v1/read", json: body, bearer: context.token, headers: requestHeaders)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -255,15 +255,25 @@ 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, step: op) }
guard response.status == 200, response.body.count <= 1_048_576 else {
NativeNetworkRefusal.log(op, status: response.status, body: response.body)
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 }
guard decoded.ok, let value = decoded.value else {
// An owner refusal comes back as 200 with `ok: false` and its code under `error`.
let refused = try? JSONDecoder().decode(NativeOwnerRefusal.self, from: response.body)
NativeNetworkRefusal.log(op, status: response.status, code: refused?.error?.code, message: refused?.error?.message)
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, step: NativeDeviceScope.step(method, path))
let step = NativeDeviceScope.step(method, path)
NativeNetworkRefusal.log(step, status: reply.status, body: reply.body)
throw NativeDeviceEnrollmentError.refused(reply.status, step: step)
}
return try JSONDecoder().decode(T.self, from: reply.body)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import CryptoKit
public import Foundation
import os

/// Routing metadata verified by the original owner transport, not transport or serving authority.
public struct NativeNetworkTarget: Equatable, Sendable {
Expand Down Expand Up @@ -39,12 +40,18 @@ extension MacInstallIdentity {
// Validate caller selectors before token minting or any read.
_ = try NativeDeviceScope(team: team, tenant: "selector", mesh: mesh ?? "mesh_" + String(repeating: "0", count: 26))
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, 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)
// The platform serves this read on its own route (cmux-platform NativeNetwork.ts), not as an op on
// /v1/read: TeamDO answers that with "unknown read host.network.target". The mesh is a validated
// NativeMeshId (above), so it needs no escaping.
let reply = try await nativeOwnerGet(mesh.map { "/v1/host/network/target?mesh=\($0)" } ?? "/v1/host/network/target",
context: context)
guard reply.status == 200, reply.body.count <= 1_048_576 else {
// validation.invalid here is a tenant with more than one mesh; selector.not_found is none.
NativeNetworkRefusal.log("host.network.target", status: reply.status, body: reply.body)
throw NativeDeviceEnrollmentError.refused(reply.status, step: "host.network.target")
}
let decoded = try JSONDecoder().decode(NativeNetworkTargetReply.self, from: reply.body)
let target = try NativeNetworkTarget(decoded, team: team, mesh: mesh, context: context)
try await nativeCheck(context)
return target
}
Expand All @@ -58,6 +65,23 @@ struct NativeNetworkTargetReply: Decodable, Sendable {
let mesh: String
let ipv4_cidr: String
}
private struct NativeNetworkTargetEnvelope: Decodable { let op: String; let value: NativeNetworkTargetReply }
private struct NativeNetworkTargetParams: Encodable { let mesh: String? }
private struct NativeNetworkTargetCall: Encodable { let op = "host.network.target"; let params: NativeNetworkTargetParams }
/// A Native refusal's stable code and message (server text, no credential), logged so a route that does not
/// start says why instead of a bare HTTP status.
struct NativeNetworkRefusal: Decodable {
let code: String?
let message: String?

static func log(_ step: String, status: Int, body: Data) {
let refusal = try? JSONDecoder().decode(Self.self, from: body.prefix(4096))
log(step, status: status, code: refusal?.code, message: refusal?.message)
}

static func log(_ step: String, status: Int, code: String?, message: String?) {
let code = code ?? "no code", message = String((message ?? "").prefix(300))
Logger(subsystem: "com.cmuxterm.app.next", category: "cloud.native")
.error("\(step, privacy: .public) refused \(status): \(code, privacy: .public): \(message, privacy: .public)")
}
}

/// An owner op's refusal body: `{"ok": false, "error": {"code", "message"}}`.
struct NativeOwnerRefusal: Decodable { let error: NativeNetworkRefusal? }
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,13 @@ public final class CloudNativeRouteHolder: CloudLinkLaunching {
}


/// The app server link's Native device route, opt-in with `CMUX_NEXT_CLOUD_NATIVE_LINK=1`, with this Mac's
/// `cmux link` for an install whose Native route fails to start (a team the Worker has no Native mesh for
/// answers 400), so turning Native on never leaves such a team with no machine. The fallback holds until
/// sign-out (``archive(install:)``), and `onFallback` says why it was taken. Not the default: a team with
/// a Native mesh can still hold machines made on the direct transport, whose `connect_info` has no Native
/// network, and the Native link cannot dial those (cmux#28950 review at 30a7996aabee).
/// The app server link's Native device route, the default (`CMUX_NEXT_CLOUD_NATIVE_LINK=0` turns it off),
/// with this Mac's `cmux link` for an install whose Native route fails to start (a team the Worker has no
/// Native mesh for answers 400), so Native never leaves such a team with no machine. The fallback holds until
/// sign-out (``archive(install:)``), and `onFallback` says why it was taken. Native is the default because it
/// is the only route that reaches a Worker machine today: `connect_info` answers no gateway, VPC endpoint or
/// public address, so `cmux link` over the relay finds no route to any machine ("refused the dial:
/// unreachable"), while a vm-api machine's Native network routes through the enrolled mesh tunnel.
@MainActor
public final class CloudNativeFirstLauncher: CloudLinkLaunching {
private let native: any CloudLinkLaunching
Expand All @@ -84,9 +85,9 @@ public final class CloudNativeFirstLauncher: CloudLinkLaunching {
return bundled?[key] ?? ProcessInfo.processInfo.environment[key]
}

/// Which route the app server link takes: `=1` Native first, anything else `cmux link`.
/// Which route the app server link takes: `=0` `cmux link`, anything else (unset included) Native first.
public nonisolated static func choice(setting: String? = CloudNativeFirstLauncher.setting) -> Choice {
setting == "1" ? .native : .link
setting == "0" ? .link : .native
}

public init(native: any CloudLinkLaunching, link: (any CloudLinkLaunching)?,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ private final class FakeLauncher: CloudLinkLaunching {

@MainActor
struct CloudNativeFirstLauncherTests {
@Test func nativeIsOptInAndCmuxLinkIsTheDefault() {
#expect(CloudNativeFirstLauncher.choice(setting: nil) == .link)
#expect(CloudNativeFirstLauncher.choice(setting: "0") == .link)
@Test func nativeIsTheDefaultAndZeroTurnsItOff() {
#expect(CloudNativeFirstLauncher.choice(setting: nil) == .native)
#expect(CloudNativeFirstLauncher.choice(setting: "1") == .native)
#expect(CloudNativeFirstLauncher.choice(setting: "0") == .link)
}

@Test func aWorkingNativeRouteIsUsed() async throws {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ actor NativeDeviceTestWorld: InstallAuthTransport, NativeDeviceTransport {
private var badTarget: String?
private var targetStatus = 200
private(set) var requests: [Data] = []
/// Each GET of the Native target route, query included.
private(set) var targetPaths: [String] = []
private(set) var enrollments = 0
private(set) var deletions = 0
private(set) var deletedDevices: [String] = []
Expand Down Expand Up @@ -97,17 +99,6 @@ actor NativeDeviceTestWorld: InstallAuthTransport, NativeDeviceTransport {
return (200, try data(["ok": true, "value": ["host": target, "token": "dial-once-\(cloudMints)",
"expires_at": 1792430000000 as Int64, "epoch": 1, "services": services]]))
}
if op == "host.network.target" {
guard path == "/v1/read", body["idempotency_key"] == nil, body["origin"] == nil,
params["mesh"] == nil || params["mesh"] as? String == scope.mesh else { throw Failure.invalid }
let action = beforeTarget; beforeTarget = nil
await action?()
if targetStatus != 200 { return (targetStatus, try data(["code": "owner.unreachable"])) }
var value = ["team": scope.team, "tenant": scope.tenant, "project": Self.project,
"origin": origin.absoluteString, "mesh": scope.mesh, "ipv4_cidr": "10.128.0.0/20"]
if let badTarget { value[badTarget] = badTarget == "origin" ? "http://attacker.test" : badTarget == "ipv4_cidr" ? "0.0.0.0/0" : "wrong-identity" }
return (200, try data(["op": op, "value": value]))
}
if op == "host.enroll" {
var value: [String: Any] = ["id": host, "owner_user": Self.user, "enrolled_by": install]
if let network = networks[scope.team] { value["network"] = network; value["wg_public_key"] = network["wg"] }
Expand All @@ -126,6 +117,25 @@ actor NativeDeviceTestWorld: InstallAuthTransport, NativeDeviceTransport {
return (200, try data(["ok": true, "value": ["host": badAck ? "host_wrong" : host, "network": network]]))
}

/// The platform's Native target route (cmux-platform NativeNetwork.ts): `GET /v1/host/network/target[?mesh=]`,
/// the plain target as its body. A `/v1/read` of the op is refused, as TeamDO refuses it.
func get(_ path: String, bearer: String?, headers: [String: String]) async throws -> (status: Int, body: Data) {
let parts = path.split(separator: "?", maxSplits: 1).map(String.init)
guard parts[0] == "/v1/host/network/target" else { throw Failure.invalid }
let token = try claims(bearer ?? "")
guard let scope = scopes.first(where: { $0.team == token["team"] }), token["sub"] == Self.user else { throw Failure.invalid }
let query = parts.count == 2 ? parts[1] : ""
guard query.isEmpty || query == "mesh=\(scope.mesh)" else { throw Failure.invalid }
targetPaths.append(path)
let action = beforeTarget; beforeTarget = nil
await action?()
if targetStatus != 200 { return (targetStatus, try data(["code": "owner.unreachable"])) }
var value = ["team": scope.team, "tenant": scope.tenant, "project": Self.project,
"origin": origin.absoluteString, "mesh": scope.mesh, "ipv4_cidr": "10.128.0.0/20"]
if let badTarget { value[badTarget] = badTarget == "origin" ? "http://attacker.test" : badTarget == "ipv4_cidr" ? "0.0.0.0/0" : "wrong-identity" }
return (200, try data(value))
}

func send(method: String, path: String, body: Data?, bearer: String?, tenant: String?) async throws -> (status: Int, body: Data) {
if let body { requests.append(body) }
if path.hasSuffix("/signed/tunnel") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import Testing
#expect(target.scope == scope)
#expect(target.origin == world.origin && target.project == NativeDeviceTestWorld.project)
#expect(target.ipv4CIDR == "10.128.0.0/20")
// The platform's route, not a /v1/read of the op (TeamDO answers that "unknown read").
#expect(await world.targetPaths == [explicit ? "/v1/host/network/target?mesh=\(scope.mesh)" : "/v1/host/network/target"])
#expect(await world.enrollments == 0)
#expect(await world.binds == 0)
let enrollment = try NativeDeviceEnrollment(scope: target.scope, identity: identity, paths: CloudPaths(root: root.appendingPathComponent("network")), native: world,
Expand Down
4 changes: 2 additions & 2 deletions backend/apps/api/src/platform-forward.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ export type PlatformSurface = "cloud"
/** The surface of each op owner whose ops (on /v1/ops and /v1/read) can move. Owners not listed always stay. */
export const OWNER_SURFACE: Readonly<Partial<Record<string, PlatformSurface>>> = { "cloud:CloudDO": "cloud" }

/** NEXT Cloud's machine routes outside the op path (the ones index.ts sends to cloud-link, cloud-acpmux, cloud-client-session, cloud-machine-guest and cloud-trajectory). */
const CLOUD_PATHS: ReadonlyArray<RegExp> = [/^\/v1\/cloud\/bind$/, /^\/v1\/cloud\/keyset$/, ACPMUX_PATH, CLIENT_SESSION_PATH, GUEST_PATH, TRAJECTORY_PATH]
/** NEXT Cloud's machine routes outside the op path (the ones index.ts sends to cloud-link, cloud-acpmux, cloud-client-session, cloud-machine-guest and cloud-trajectory), and the platform's Native network target read (cmux-platform NativeNetwork.ts), which has no op and no route here. */
const CLOUD_PATHS: ReadonlyArray<RegExp> = [/^\/v1\/cloud\/bind$/, /^\/v1\/cloud\/keyset$/, ACPMUX_PATH, CLIENT_SESSION_PATH, GUEST_PATH, TRAJECTORY_PATH, /^\/v1\/host\/network\/target$/]

/** The surfaces a `PLATFORM_SURFACES` value turns on; unknown names are ignored. */
export const platformSurfaces = (value: string | undefined): ReadonlySet<PlatformSurface> => {
Expand Down
Loading
Loading