Keyboard-interactive for Metrics
This commit is contained in:
1 parent
2f2c800843
commit
32aed0a610
13 files changed
+682
-25
No files matched your search
@@ -60,7 +60,7 @@ struct MetricsJourneyTests {
|
||||
hosts.upsert(host)
|
||||
clock.advance(2)
|
||||
|
||||
try await wait { server.name == host.name }
|
||||
try await wait { server.name == host.name && server.collectionState == .failed }
|
||||
#expect(server.collectionState == .failed)
|
||||
|
||||
model.suspend()
|
||||
@@ -170,6 +170,31 @@ struct MetricsJourneyTests {
|
||||
#expect(model.servers.isEmpty)
|
||||
}
|
||||
|
||||
@Test func suspendingHostKeyConfirmationRequiresAFreshChallengeOnResume() async throws {
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let host = TermindDomain.Host(name: "Monitor", host: "example.invalid", identityID: identity.id)
|
||||
let hosts = HostLibraryModel(hosts: [host], identities: [identity])
|
||||
try hosts.upsert(identity, passwordUpdate: .replace("test-only"))
|
||||
let sampler = PortMetricsSampler()
|
||||
let model = ServerMetricsModel(
|
||||
hosts: hosts, preferences: PortMetricsPreferences(), makeSampler: { sampler })
|
||||
defer { model.shutdown() }
|
||||
#expect(model.add(host))
|
||||
let server = try #require(model.servers.first)
|
||||
try await wait { server.hostKeyPrompt != nil }
|
||||
let observation = try #require(server.hostKey)
|
||||
model.suspend()
|
||||
#expect(server.hostKeyPrompt == nil && server.hostKey == nil && !server.needsAttention)
|
||||
model.acceptHostKey(server, observation: observation, remember: true)
|
||||
#expect(hosts.knownHosts(for: host).isEmpty)
|
||||
model.resume()
|
||||
try await wait { server.hostKeyPrompt != nil }
|
||||
#expect(await sampler.hostKeyChallenges == 2)
|
||||
model.acceptHostKey(server, observation: try #require(server.hostKey), remember: false)
|
||||
try await wait { server.point != nil }
|
||||
#expect(await sampler.connections == 1)
|
||||
}
|
||||
|
||||
@Test func checkpointAndLogsSurviveAuthenticationContinuation() {
|
||||
let host = TermindDomain.Host(name: "Target", host: "example.invalid")
|
||||
let journey = ConnectionJourney()
|
||||
@@ -200,6 +225,7 @@ struct MetricsJourneyTests {
|
||||
|
||||
private actor PortMetricsSampler: MetricsSampling {
|
||||
var connections = 0
|
||||
var hostKeyChallenges = 0
|
||||
var processRequests = 0
|
||||
func connect(_ configuration: SSHConnectionConfiguration) async throws { connections += 1 }
|
||||
func connect(_ request: SSHConnectionRequest) async throws {
|
||||
@@ -209,6 +235,7 @@ private actor PortMetricsSampler: MetricsSampling {
|
||||
algorithm: .ed25519, fingerprint: "SHA256:test"
|
||||
)
|
||||
if !configuration.expectedHostKeys.contains(observation.identity) {
|
||||
hostKeyChallenges += 1
|
||||
guard let authorizeHostKey = request.authorizeHostKey else {
|
||||
throw TermindSSHError.untrustedHost
|
||||
}
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import Foundation
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@Suite @MainActor
|
||||
struct MetricsKeyboardInteractiveTests {
|
||||
@Test func multipleRoundsContinueOneConnectionWithoutRecordingAnswers() async throws {
|
||||
let sampler = JourneyMetricsSampler(rounds: [
|
||||
[], [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)],
|
||||
[.init(text: "Second code", echo: false)],
|
||||
])
|
||||
let fixture = try Fixture(sampler: sampler)
|
||||
defer { fixture.model.shutdown() }
|
||||
let server = try fixture.add()
|
||||
try await wait { server.keyboardAuthentication.current?.round == 2 }
|
||||
let first = try #require(server.keyboardAuthentication.current)
|
||||
#expect(server.requiresAuthentication && server.showsConnectionJourney && server.needsAttention)
|
||||
#expect(!server.isConnecting)
|
||||
#expect(server.journey.phase == .waiting)
|
||||
#expect(server.journey.checkpoint?.kind == .authentication)
|
||||
#expect(fixture.model.snapshot(for: server.id)?.state == .awaitingAuthentication)
|
||||
#expect(server.status == String(localized: "Authentication required"))
|
||||
#expect(await sampler.attempts == 1)
|
||||
server.keyboardAuthentication.respond(first.id, answers: [" deploy ", "private-answer"])
|
||||
try await wait { server.keyboardAuthentication.current?.round == 3 }
|
||||
let second = try #require(server.keyboardAuthentication.current)
|
||||
#expect(second.id != first.id && second.connectionID == first.connectionID)
|
||||
server.keyboardAuthentication.respond(first.id, answers: ["stale", "stale"])
|
||||
#expect(server.keyboardAuthentication.current?.id == second.id)
|
||||
server.keyboardAuthentication.respond(second.id, answers: [""])
|
||||
server.keyboardAuthentication.respond(second.id, answers: ["duplicate"])
|
||||
try await wait { server.point != nil }
|
||||
#expect(await sampler.attempts == 1)
|
||||
#expect(await sampler.answers == [[], [" deploy ", "private-answer"], [""]])
|
||||
#expect(!server.needsAttention && !server.requiresAuthentication)
|
||||
#expect(fixture.model.snapshot(for: server.id)?.state == .live)
|
||||
#expect(!server.journey.plainTextLog.contains("private-answer"))
|
||||
#expect(!server.journey.plainTextLog.contains(first.instruction))
|
||||
#expect(!server.journey.plainTextLog.contains(first.prompts[1].text))
|
||||
#expect(fixture.library.identities.count == 1)
|
||||
}
|
||||
|
||||
@Test func informationalRoundsFinishWithoutOpeningDetails() async throws {
|
||||
let sampler = JourneyMetricsSampler(rounds: [[], []])
|
||||
let fixture = try Fixture(sampler: sampler)
|
||||
defer { fixture.model.shutdown() }
|
||||
let server = try fixture.add()
|
||||
try await wait { server.point != nil }
|
||||
#expect(server.keyboardAuthentication.pending.isEmpty)
|
||||
#expect(!server.requiresAuthentication)
|
||||
#expect(await sampler.answers == [[], []])
|
||||
#expect(!server.journey.plainTextLog.contains(String(localized: "Waiting for authentication")))
|
||||
}
|
||||
|
||||
@Test func monitorsSharingAJumpHostKeepTheirChallengesIndependent() async throws {
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let jump = Host(name: "Gateway", host: "gateway.invalid", identityID: identity.id)
|
||||
let firstHost = Host(name: "First", host: "first.invalid", identityID: identity.id, jumpHostID: jump.id)
|
||||
let secondHost = Host(name: "Second", host: "second.invalid", identityID: identity.id, jumpHostID: jump.id)
|
||||
let library = HostLibraryModel(hosts: [jump, firstHost, secondHost], identities: [identity])
|
||||
try library.upsert(identity, passwordUpdate: .replace("test-only"))
|
||||
let sampler = JourneyMetricsSampler(challengeHost: jump)
|
||||
let model = ServerMetricsModel(
|
||||
hosts: library, preferences: JourneyMetricsPreferences(), makeSampler: { sampler })
|
||||
defer { model.shutdown() }
|
||||
#expect(model.add(firstHost))
|
||||
#expect(model.add(secondHost))
|
||||
let first = try #require(model.servers.first { $0.id == firstHost.id })
|
||||
let second = try #require(model.servers.first { $0.id == secondHost.id })
|
||||
try await wait { first.keyboardAuthentication.current != nil && second.keyboardAuthentication.current != nil }
|
||||
let a = try #require(first.keyboardAuthentication.current)
|
||||
let b = try #require(second.keyboardAuthentication.current)
|
||||
#expect(a.hostID == jump.id && b.hostID == jump.id)
|
||||
#expect(a.connectionID != b.connectionID)
|
||||
#expect(first.journey.checkpoint?.endpointIndex == 1)
|
||||
#expect(second.journey.checkpoint?.endpointIndex == 1)
|
||||
first.keyboardAuthentication.respond(b.id, answers: ["wrong-monitor"])
|
||||
#expect(first.keyboardAuthentication.current?.id == a.id)
|
||||
first.keyboardAuthentication.respond(a.id, answers: ["first-answer"])
|
||||
try await wait { first.point != nil }
|
||||
#expect(second.keyboardAuthentication.current?.id == b.id)
|
||||
model.setEnabled(false, for: second)
|
||||
#expect(second.keyboardAuthentication.pending.isEmpty)
|
||||
#expect(first.selection.enabled && first.collectionState == .live)
|
||||
}
|
||||
|
||||
@Test(arguments: Retirement.allCases)
|
||||
func retiringAttemptsCancelsPromptsAndRejectsLateCallbacks(_ retirement: Retirement) async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.model.shutdown() }
|
||||
let server = try fixture.add()
|
||||
try await wait { server.keyboardAuthentication.current != nil }
|
||||
let oldChallenge = try #require(server.keyboardAuthentication.current)
|
||||
let oldRequest = try #require(await fixture.sampler.requests.first)
|
||||
let retiredHandler = try #require(oldRequest.keyboardInteractive)
|
||||
switch retirement {
|
||||
case .pause: fixture.model.setEnabled(false, for: server)
|
||||
case .remove: fixture.model.remove(server.id)
|
||||
case .retry: fixture.model.retry(server)
|
||||
case .configuration:
|
||||
var host = fixture.host
|
||||
host.port += 1
|
||||
fixture.library.upsert(host)
|
||||
fixture.model.reconcile()
|
||||
case .suspend: fixture.model.suspend()
|
||||
case .shutdown: fixture.model.shutdown()
|
||||
}
|
||||
#expect(server.keyboardAuthentication.pending.isEmpty)
|
||||
#expect(!server.needsAttention)
|
||||
server.keyboardAuthentication.respond(oldChallenge.id, answers: ["stale"])
|
||||
await #expect(throws: CancellationError.self) { try await retiredHandler(oldChallenge) }
|
||||
if retirement == .suspend { fixture.model.resume() }
|
||||
if retirement == .pause { fixture.model.setEnabled(true, for: server) }
|
||||
if retirement == .remove {
|
||||
#expect(fixture.model.servers.isEmpty)
|
||||
} else if retirement == .shutdown {
|
||||
#expect(server.journey.phase == .hidden)
|
||||
} else {
|
||||
try await wait { server.keyboardAuthentication.current != nil }
|
||||
let next = try #require(server.keyboardAuthentication.current)
|
||||
#expect(next.id != oldChallenge.id)
|
||||
await #expect(throws: CancellationError.self) { try await retiredHandler(oldChallenge) }
|
||||
#expect(server.keyboardAuthentication.current?.id == next.id)
|
||||
#expect(server.journey.phase == .waiting && server.needsAttention)
|
||||
server.keyboardAuthentication.respond(next.id, answers: ["new-answer"])
|
||||
try await wait { server.point != nil }
|
||||
#expect(await fixture.sampler.answers == [["new-answer"]])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func transportCancellationRemovesThePendingPrompt() async throws {
|
||||
let fixture = try Fixture()
|
||||
defer { fixture.model.shutdown() }
|
||||
let server = try fixture.add()
|
||||
try await wait { server.keyboardAuthentication.current != nil }
|
||||
// A transport may cancel its responder independently of the monitor's worker.
|
||||
await fixture.sampler.cancelResponder()
|
||||
try await wait { server.keyboardAuthentication.pending.isEmpty && !server.needsAttention }
|
||||
#expect(server.journey.phase != .waiting)
|
||||
}
|
||||
|
||||
enum Retirement: CaseIterable { case pause, remove, retry, configuration, suspend, shutdown }
|
||||
|
||||
private func wait(_ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation) async throws {
|
||||
for _ in 0..<400 {
|
||||
if condition() { return }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
try #require(condition(), sourceLocation: sourceLocation)
|
||||
}
|
||||
|
||||
private struct Fixture {
|
||||
let host: Host
|
||||
let library: HostLibraryModel
|
||||
let sampler: JourneyMetricsSampler
|
||||
let model: ServerMetricsModel
|
||||
|
||||
init(sampler: JourneyMetricsSampler = JourneyMetricsSampler()) throws {
|
||||
self.sampler = sampler
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
host = Host(name: "Monitor", host: "metrics.invalid", identityID: identity.id)
|
||||
library = HostLibraryModel(hosts: [host], identities: [identity])
|
||||
try library.upsert(identity, passwordUpdate: .replace("test-only"))
|
||||
model = ServerMetricsModel(
|
||||
hosts: library, preferences: JourneyMetricsPreferences(), makeSampler: { sampler })
|
||||
}
|
||||
|
||||
func add() throws -> ServerMetric {
|
||||
#expect(model.add(host))
|
||||
return try #require(model.servers.first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class JourneyMetricsPreferences: MetricsConfigurationStoring {
|
||||
func load() -> [MetricsSelection] { [] }
|
||||
func save(_ selections: [MetricsSelection]) {}
|
||||
}
|
||||
|
||||
private actor JourneyMetricsSampler: MetricsSampling {
|
||||
let rounds: [[SSHKeyboardInteractiveChallenge.Prompt]]
|
||||
let challengeHost: Host?
|
||||
var attempts = 0
|
||||
var answers: [[String]] = []
|
||||
var requests: [SSHConnectionRequest] = []
|
||||
private var responder: Task<[String], any Error>?
|
||||
|
||||
init(
|
||||
rounds: [[SSHKeyboardInteractiveChallenge.Prompt]] = [[.init(text: "Verification code", echo: false)]],
|
||||
challengeHost: Host? = nil
|
||||
) {
|
||||
self.rounds = rounds
|
||||
self.challengeHost = challengeHost
|
||||
}
|
||||
|
||||
func connect(_ configuration: SSHConnectionConfiguration) async throws {
|
||||
Issue.record("Metrics must supply the request containing its own authentication handler")
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
func connect(_ request: SSHConnectionRequest) async throws {
|
||||
attempts += 1
|
||||
requests.append(request)
|
||||
let handler = try #require(request.keyboardInteractive)
|
||||
let connectionID = UUID()
|
||||
for (index, prompts) in rounds.enumerated() {
|
||||
let challenge = SSHKeyboardInteractiveChallenge(
|
||||
connectionID: connectionID, hostID: challengeHost?.id ?? request.configuration.hostID,
|
||||
hostname: challengeHost?.host ?? request.configuration.host, port: request.configuration.port,
|
||||
username: request.configuration.username, round: index + 1, name: "PAM",
|
||||
instruction: "Private server instruction", prompts: prompts)
|
||||
let task = Task { try await handler(challenge) }
|
||||
responder = task
|
||||
let answer = try await withTaskCancellationHandler {
|
||||
try await task.value
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
answers.append(answer)
|
||||
}
|
||||
}
|
||||
|
||||
func cancelResponder() { responder?.cancel() }
|
||||
func sample() async throws -> MetricsSample {
|
||||
.init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100)
|
||||
}
|
||||
func gpu() async throws -> NvidiaSnapshot? { nil }
|
||||
func disconnect() async {}
|
||||
}
|
||||
@@ -22,10 +22,13 @@ public final class ServerMetric: Identifiable {
|
||||
public var hostKey: SSHHostKeyObservation?
|
||||
public var needsAttention = false
|
||||
public let journey = ConnectionJourney()
|
||||
public let keyboardAuthentication = SSHKeyboardInteractiveQueue()
|
||||
public var authenticationPrompt: SSHAuthenticationPrompt?
|
||||
public var hostKeyPrompt: SSHHostKeyPrompt?
|
||||
public var hostKeyHostID: UUID?
|
||||
public var requiresAuthentication: Bool { hostKey != nil || authenticationPrompt != nil }
|
||||
public var requiresAuthentication: Bool {
|
||||
hostKey != nil || authenticationPrompt != nil || keyboardAuthentication.current != nil
|
||||
}
|
||||
public var showsConnectionJourney: Bool { requiresAuthentication || journey.isPresented }
|
||||
public var processes: [MetricsProcess] = []
|
||||
public var processesDate: Date?
|
||||
@@ -71,6 +74,10 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
var id: UUID
|
||||
var continuation: AsyncStream<SSHHostKeyDecision>.Continuation
|
||||
}
|
||||
private struct PendingKeyboardAuthentication {
|
||||
var id: UUID
|
||||
var task: Task<[String], any Error>
|
||||
}
|
||||
private nonisolated struct RunAttempt: Sendable {
|
||||
var host: Host
|
||||
var preparation: SSHConnectionPreparation
|
||||
@@ -84,8 +91,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
case stop, attention, retry
|
||||
}
|
||||
private var pendingHostKeyAuthorizations: [UUID: PendingHostKeyAuthorization] = [:]
|
||||
|
||||
private nonisolated let keyboardInteractive: SSHKeyboardInteractiveHandler?
|
||||
private var pendingKeyboardAuthentications: [UUID: PendingKeyboardAuthentication] = [:]
|
||||
|
||||
public var keepsRunning: Bool { servers.contains { $0.selection.enabled } }
|
||||
|
||||
@@ -94,10 +100,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
makeSampler: @escaping @Sendable () -> any MetricsSampling,
|
||||
clock: MetricsClock = MetricsClock(),
|
||||
cardLimit: @escaping () -> Int? = { ServerMetricsModel.freeCardLimit },
|
||||
limitReached: @escaping () -> Void = {},
|
||||
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil
|
||||
limitReached: @escaping () -> Void = {}
|
||||
) {
|
||||
self.keyboardInteractive = keyboardInteractive
|
||||
self.hosts = hosts
|
||||
self.preferences = preferences
|
||||
self.makeSampler = makeSampler
|
||||
@@ -466,9 +470,21 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
}
|
||||
|
||||
private func cancel(_ id: UUID) {
|
||||
finishHostKeyAuthorization(for: id, decision: .reject)
|
||||
generations[id] = nil
|
||||
workers.removeValue(forKey: id)?.cancel()
|
||||
let hadHostKeyAuthorization = pendingHostKeyAuthorizations[id] != nil
|
||||
finishHostKeyAuthorization(for: id, decision: .reject)
|
||||
let authentication = pendingKeyboardAuthentications.removeValue(forKey: id)
|
||||
authentication?.task.cancel()
|
||||
if let server = servers.first(where: { $0.id == id }) {
|
||||
if hadHostKeyAuthorization {
|
||||
server.hostKey = nil
|
||||
server.hostKeyHostID = nil
|
||||
server.hostKeyPrompt = nil
|
||||
}
|
||||
if hadHostKeyAuthorization || authentication != nil { server.needsAttention = false }
|
||||
server.keyboardAuthentication.cancelAll()
|
||||
}
|
||||
if let sampler = samplers.removeValue(forKey: id) {
|
||||
Task { await sampler.disconnect() }
|
||||
}
|
||||
@@ -547,7 +563,11 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
serverID, challenge: challenge, token: token
|
||||
)
|
||||
},
|
||||
keyboardInteractive: keyboardInteractive
|
||||
keyboardInteractive: { [weak self] challenge in
|
||||
guard let self else { throw CancellationError() }
|
||||
return try await self.answerKeyboardInteractiveFromWorker(
|
||||
serverID, challenge: challenge, token: token)
|
||||
}
|
||||
)
|
||||
)
|
||||
guard await publishConnected(serverID, token: token, hostID: attempt.host.id) else {
|
||||
@@ -766,6 +786,61 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
|
||||
return .retry
|
||||
}
|
||||
|
||||
private nonisolated func answerKeyboardInteractiveFromWorker(
|
||||
_ serverID: UUID, challenge: SSHKeyboardInteractiveChallenge, token: UUID
|
||||
) async throws -> [String] {
|
||||
guard await isCurrent(serverID, token: token) else { throw CancellationError() }
|
||||
// Informational rounds must complete even when no Metrics detail is visible.
|
||||
guard !challenge.prompts.isEmpty else { return [] }
|
||||
guard let task = await beginKeyboardAuthentication(serverID, challenge: challenge, token: token) else {
|
||||
throw CancellationError()
|
||||
}
|
||||
do {
|
||||
let answers = try await withTaskCancellationHandler {
|
||||
try await task.value
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
guard await finishKeyboardAuthentication(serverID, requestID: challenge.id, token: token) else {
|
||||
throw CancellationError()
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
return answers
|
||||
} catch {
|
||||
_ = await finishKeyboardAuthentication(serverID, requestID: challenge.id, token: token)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func beginKeyboardAuthentication(
|
||||
_ serverID: UUID, challenge: SSHKeyboardInteractiveChallenge, token: UUID
|
||||
) -> Task<[String], any Error>? {
|
||||
guard current(serverID, token), let server = servers.first(where: { $0.id == serverID }) else { return nil }
|
||||
let queue = server.keyboardAuthentication
|
||||
// Retain the responder before yielding so retiring this attempt also cancels a not-yet-enqueued request.
|
||||
let task = Task { try await queue.request(challenge) }
|
||||
pendingKeyboardAuthentications[serverID] = PendingKeyboardAuthentication(id: challenge.id, task: task)
|
||||
server.needsAttention = true
|
||||
server.isConnecting = false
|
||||
server.status = String(localized: "Authentication required")
|
||||
server.collectionState = .awaitingAuthentication
|
||||
server.journey.waitForAuthentication(at: challenge.hostID)
|
||||
return task
|
||||
}
|
||||
|
||||
private func finishKeyboardAuthentication(_ serverID: UUID, requestID: UUID, token: UUID) -> Bool {
|
||||
guard generations[serverID] == token, pendingKeyboardAuthentications[serverID]?.id == requestID,
|
||||
let server = servers.first(where: { $0.id == serverID })
|
||||
else { return false }
|
||||
pendingKeyboardAuthentications[serverID] = nil
|
||||
server.needsAttention = false
|
||||
server.isConnecting = true
|
||||
server.status = String(localized: "Connecting…")
|
||||
server.collectionState = .connecting
|
||||
server.journey.resume()
|
||||
return true
|
||||
}
|
||||
|
||||
private nonisolated func authorizeHostKeyFromWorker(
|
||||
_ serverID: UUID, challenge: SSHHostKeyChallenge, token: UUID
|
||||
) async -> SSHHostKeyDecision {
|
||||
|
||||
@@ -118,6 +118,7 @@ public struct MetricsInstrumentBody: View {
|
||||
}
|
||||
|
||||
private var connectionLabel: String {
|
||||
if server.keyboardAuthentication.current != nil { return String(localized: "Authentication required") }
|
||||
if server.requiresAuthentication { return String(localized: "Server Identity Verification Required") }
|
||||
return server.status
|
||||
}
|
||||
|
||||
+11
-3
@@ -192,8 +192,9 @@ scene. Terminal file drops and all additional transfer connections forbid Termin
|
||||
credential, passphrase, MFA and host-key forms, including fallback responders on jump
|
||||
hosts. System key authorization such as Touch ID remains allowed. Username-only and
|
||||
SSH `none` authentication remain eligible; actual connection success decides availability.
|
||||
No upload-specific mobile sheet exists; the app-owned MFA sheet still serves Metrics
|
||||
and forwarding. Cancellation interrupts opening, and temporary-file cleanup must also
|
||||
No upload-specific mobile sheet exists; the app-owned MFA sheet serves forwarding,
|
||||
while Metrics owns its challenges inside Connection Journey on both platforms.
|
||||
Cancellation interrupts opening, and temporary-file cleanup must also
|
||||
remain silent. Cleanup requiring new interaction retains the existing failure report.
|
||||
Forwarding rules sharing a host session share authentication; a new connection must
|
||||
answer fresh challenges. An Identity may contain only a username: preparation
|
||||
@@ -767,7 +768,14 @@ processes to 20. Monitoring itself does not depend on AgentKit.
|
||||
Metrics order syncs independently of Vault order; local layout, process sorting,
|
||||
search, detail navigation, and menu-bar visibility do not. Detail observation alone
|
||||
enables process/filesystem collection. Authentication opens the shared Connection
|
||||
Journey while the metrics coordinator retains transport ownership. Temporary trust
|
||||
Journey while the metrics coordinator retains transport ownership. Each monitor owns
|
||||
its keyboard-interactive queue, including challenges from its jump hosts. Metrics
|
||||
always supplies its own responder, never falling back to an app-owned sheet or external
|
||||
authentication window. Cards indicate required authentication without changing navigation;
|
||||
opening a card presents its Journey. Leaving details retains the challenge, while explicit
|
||||
authentication cancellation pauses the monitor and returns that window to the list.
|
||||
Retiring an attempt cancels both queued and not-yet-enqueued responders; generation and
|
||||
request IDs prevent stale answers or cleanup from affecting another attempt. Temporary trust
|
||||
is cleared on pause/removal/configuration change; rejection pauses the monitor.
|
||||
Free access admits at most three cards (paused included), atomically and without
|
||||
deleting restored cards after downgrade; Pro/trial access is unlimited.
|
||||
|
||||
@@ -177,8 +177,7 @@ final class MobileAppModel {
|
||||
storage: GRDBMemoryStore(directory: GRDBMemoryStore.defaultDirectory), defaults: settingsDefaults)
|
||||
library = HostLibraryModel(persistence: persistence, secrets: secrets)
|
||||
metrics = MobileMetricsModel(
|
||||
preferences: metricsPersistence, hosts: library, services: dependencies.metrics,
|
||||
keyboardInteractive: keyboardAuthentication.handler)
|
||||
preferences: metricsPersistence, hosts: library, services: dependencies.metrics)
|
||||
let speechAudio = IOSSpeechAudioInput()
|
||||
let speech = SpeechTranscriptionCoordinator(
|
||||
service: AppleSpeechTranscriptionService(audioInput: speechAudio)
|
||||
|
||||
@@ -16,15 +16,14 @@ final class MobileMetricsModel {
|
||||
init(
|
||||
preferences: any MetricsConfigurationStoring,
|
||||
hosts: any RemoteSessionHostStore,
|
||||
services: MobileMetricsServices,
|
||||
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil
|
||||
services: MobileMetricsServices
|
||||
) {
|
||||
self.hosts = hosts
|
||||
let limit = limit
|
||||
core = ServerMetricsModel(
|
||||
hosts: hosts, preferences: preferences,
|
||||
makeSampler: services.makeSampler,
|
||||
cardLimit: { limit.maximum }, keyboardInteractive: keyboardInteractive)
|
||||
cardLimit: { limit.maximum })
|
||||
}
|
||||
|
||||
func add(_ id: UUID, pro: Bool) -> Bool {
|
||||
|
||||
@@ -98,10 +98,9 @@ struct MobileMetricsDetailView: View {
|
||||
}
|
||||
}, retry: { app.metrics.core.retry(server) },
|
||||
retryReconnectNow: {}, stopReconnecting: {},
|
||||
cancel: {
|
||||
app.metrics.core.setEnabled(false, for: server)
|
||||
dismiss()
|
||||
})
|
||||
cancel: cancelAuthentication,
|
||||
keyboardAuthentication: server.keyboardAuthentication,
|
||||
cancelKeyboardAuthentication: cancelAuthentication)
|
||||
} else {
|
||||
GeometryReader { geometry in
|
||||
if MobileMetricsDetailPage.showsBothColumns(width: geometry.size.width) {
|
||||
@@ -128,7 +127,7 @@ struct MobileMetricsDetailView: View {
|
||||
.toolbar(removing: tabBarPlacement == .topBar ? .title : nil)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
if !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) {
|
||||
if !server.showsConnectionJourney && !MobileMetricsDetailPage.showsBothColumns(width: availableWidth) {
|
||||
Picker("Details", selection: $page) {
|
||||
Image(systemName: "gauge.with.dots.needle.33percent").accessibilityLabel("Status").tag(
|
||||
MobileMetricsDetailPage.status)
|
||||
@@ -156,6 +155,11 @@ struct MobileMetricsDetailView: View {
|
||||
.onDisappear { app.metrics.core.stopObservingDetails(observer) }
|
||||
}
|
||||
|
||||
private func cancelAuthentication() {
|
||||
app.metrics.core.setEnabled(false, for: server)
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func observe() {
|
||||
if server.showsConnectionJourney {
|
||||
app.metrics.core.stopObservingDetails(observer)
|
||||
|
||||
@@ -16,6 +16,7 @@ struct MobileConnectionJourneyView: View {
|
||||
let stopReconnecting: () -> Void
|
||||
let cancel: () -> Void
|
||||
var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil
|
||||
var cancelKeyboardAuthentication: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@@ -51,7 +52,14 @@ struct MobileConnectionJourneyView: View {
|
||||
} else if let queue = keyboardAuthentication, let challenge = queue.current {
|
||||
SSHKeyboardInteractiveForm(
|
||||
challenge: challenge, submit: { queue.respond(challenge.id, answers: $0) },
|
||||
cancel: { queue.cancel(challenge.id) }, usesContainerChrome: true
|
||||
cancel: {
|
||||
guard queue.current?.id == challenge.id else { return }
|
||||
if let cancelKeyboardAuthentication {
|
||||
cancelKeyboardAuthentication()
|
||||
} else {
|
||||
queue.cancel(challenge.id)
|
||||
}
|
||||
}, usesContainerChrome: true
|
||||
)
|
||||
.id(challenge.id)
|
||||
} else if let authenticationPrompt {
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
import UIKit
|
||||
|
||||
@testable import TermindIOS
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct MobileMetricsJourneyTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func authenticationStaysInMetricsAcrossNavigationAndSizeChanges(_ language: String) async throws {
|
||||
let sampler = MobileJourneyMetricsSampler()
|
||||
let app = try MobileAppModel(dependencies: .testing(metrics: MobileMetricsServices(makeSampler: { sampler })))
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let host = TermindDomain.Host(
|
||||
name: "Production Gateway", host: "gateway.example.invalid", identityID: identity.id)
|
||||
try app.library.upsert(identity, passwordUpdate: .replace("test-only"))
|
||||
app.library.upsert(host)
|
||||
let scene = MobileSceneModel(app: app)
|
||||
scene.selectedDestination = .metrics
|
||||
#expect(app.metrics.add(host.id, pro: true))
|
||||
let server = try #require(app.metrics.servers.first)
|
||||
let route = MetricsJourneyTestRoute()
|
||||
var features = MobileFeatureRegistry()
|
||||
features.register(
|
||||
MobileFeatureRegistration(id: .metrics, title: "Metrics", symbol: "waveform.path.ecg") { app, _ in
|
||||
AnyView(
|
||||
Text("Metrics")
|
||||
.navigationTitle("Metrics")
|
||||
.navigationDestination(
|
||||
isPresented: Binding(get: { route.showsDetails }, set: { route.showsDetails = $0 })
|
||||
) { MobileMetricsDetailView(app: app, server: server) })
|
||||
})
|
||||
let windowScene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
||||
let previous = windowScene.keyWindow
|
||||
let window = UIWindow(windowScene: windowScene)
|
||||
let container = UIViewController()
|
||||
let controller = UIHostingController(
|
||||
rootView: MobileRootView(app: app, scene: scene, features: features)
|
||||
.environment(\.locale, Locale(identifier: language))
|
||||
.environment(\.scenePhase, .active))
|
||||
container.addChild(controller)
|
||||
container.view.addSubview(controller.view)
|
||||
controller.didMove(toParent: container)
|
||||
window.rootViewController = container
|
||||
window.windowLevel = .alert + 3
|
||||
window.makeKeyAndVisible()
|
||||
defer {
|
||||
app.metrics.core.shutdown()
|
||||
window.isHidden = true
|
||||
window.rootViewController = nil
|
||||
previous?.makeKey()
|
||||
}
|
||||
resize(controller, width: 430)
|
||||
try await wait { server.keyboardAuthentication.current != nil }
|
||||
let challenge = try #require(server.keyboardAuthentication.current)
|
||||
#expect(!route.showsDetails)
|
||||
#expect(app.keyboardAuthentication.pending.isEmpty)
|
||||
route.showsDetails = true
|
||||
for width in [430.0, 600.0, 1024.0] {
|
||||
resize(controller, width: width)
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
let fields = descendants(UITextField.self, in: controller.view)
|
||||
#expect(fields.count == 2)
|
||||
#expect(fields.filter(\.isSecureTextEntry).count == 1)
|
||||
#expect(descendants(UISegmentedControl.self, in: controller.view).isEmpty)
|
||||
#expect(presented(in: controller) == nil)
|
||||
#expect(server.keyboardAuthentication.current?.id == challenge.id)
|
||||
#expect(app.keyboardAuthentication.pending.isEmpty)
|
||||
try capture(controller.view, name: "metrics-journey-\(language)-\(Int(width))")
|
||||
}
|
||||
route.showsDetails = false
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
#expect(server.keyboardAuthentication.current?.id == challenge.id)
|
||||
route.showsDetails = true
|
||||
resize(controller, width: 430)
|
||||
// Native traits exercise large text without replacing the feature navigation.
|
||||
controller.traitOverrides.preferredContentSizeCategory = .accessibilityExtraExtraExtraLarge
|
||||
try await Task.sleep(for: .milliseconds(500))
|
||||
#expect(descendants(UITextField.self, in: controller.view).count == 2)
|
||||
try capture(controller.view, name: "metrics-journey-\(language)-large-type")
|
||||
#expect(await sampler.connections == 1)
|
||||
controller.traitOverrides.preferredContentSizeCategory = .large
|
||||
let fields = descendants(UITextField.self, in: controller.view)
|
||||
let account = try #require(fields.first { !$0.isSecureTextEntry })
|
||||
let code = try #require(fields.first { $0.isSecureTextEntry })
|
||||
account.text = "deploy"
|
||||
account.sendActions(for: .editingChanged)
|
||||
code.text = "test-code"
|
||||
code.sendActions(for: .editingChanged)
|
||||
#expect(code.becomeFirstResponder())
|
||||
code.sendActions(for: .editingDidEndOnExit)
|
||||
try await wait { server.point != nil && !server.showsConnectionJourney }
|
||||
try await Task.sleep(for: .milliseconds(400))
|
||||
#expect(descendants(UITextField.self, in: controller.view).isEmpty)
|
||||
#expect(app.keyboardAuthentication.pending.isEmpty)
|
||||
#expect(presented(in: controller) == nil)
|
||||
#expect(await sampler.answers == ["deploy", "test-code"])
|
||||
#expect(await sampler.connections == 1)
|
||||
try capture(controller.view, name: "metrics-journey-\(language)-live")
|
||||
}
|
||||
|
||||
private func wait(_ condition: () -> Bool, sourceLocation: SourceLocation = #_sourceLocation) async throws {
|
||||
for _ in 0..<400 {
|
||||
if condition() { return }
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
try #require(condition(), sourceLocation: sourceLocation)
|
||||
}
|
||||
|
||||
private func resize(_ controller: UIViewController, width: CGFloat) {
|
||||
let frame = CGRect(x: 0, y: 0, width: width, height: 900)
|
||||
controller.view.window?.frame = frame
|
||||
controller.parent?.view.frame = frame
|
||||
controller.traitOverrides.horizontalSizeClass = width >= 600 ? .regular : .compact
|
||||
controller.view.frame = frame
|
||||
controller.view.setNeedsLayout()
|
||||
controller.view.layoutIfNeeded()
|
||||
}
|
||||
|
||||
private func descendants<T: UIView>(_ type: T.Type, in view: UIView) -> [T] {
|
||||
(view as? T).map { [$0] } ?? view.subviews.flatMap { descendants(type, in: $0) }
|
||||
}
|
||||
|
||||
private func presented(in controller: UIViewController) -> UIViewController? {
|
||||
controller.presentedViewController ?? controller.children.lazy.compactMap { presented(in: $0) }.first
|
||||
}
|
||||
|
||||
private func capture(_ view: UIView, name: String) throws {
|
||||
let data = UIGraphicsImageRenderer(bounds: view.bounds).pngData { _ in
|
||||
view.drawHierarchy(in: view.bounds, afterScreenUpdates: true)
|
||||
}
|
||||
let device = UIDevice.current.userInterfaceIdiom == .pad ? "ipad" : "iphone"
|
||||
Attachment.record(data, named: "\(device)-\(name).png")
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-metrics-journey")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: directory.appending(path: "\(device)-\(name).png"))
|
||||
print("Metrics Journey snapshot: \(directory.path)/\(device)-\(name).png")
|
||||
}
|
||||
}
|
||||
|
||||
@Observable @MainActor private final class MetricsJourneyTestRoute {
|
||||
var showsDetails = false
|
||||
}
|
||||
|
||||
private actor MobileJourneyMetricsSampler: MetricsSampling {
|
||||
var connections = 0
|
||||
var answers: [String] = []
|
||||
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
|
||||
func connect(_ request: SSHConnectionRequest) async throws {
|
||||
connections += 1
|
||||
let handler = try #require(request.keyboardInteractive)
|
||||
answers = try await handler(
|
||||
SSHKeyboardInteractiveChallenge(
|
||||
connectionID: UUID(), hostID: request.configuration.hostID,
|
||||
hostname: request.configuration.host, port: request.configuration.port,
|
||||
username: request.configuration.username, round: 1, name: "PAM",
|
||||
instruction: "Enter your account and verification code.",
|
||||
prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)]))
|
||||
}
|
||||
func sample() async throws -> MetricsSample {
|
||||
.init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100)
|
||||
}
|
||||
func gpu() async throws -> NvidiaSnapshot? { nil }
|
||||
func disconnect() async {}
|
||||
}
|
||||
@@ -24,7 +24,9 @@ struct MetricsJourneyView: View {
|
||||
trustHostKey: { model.acceptHostKey(server, observation: $0, remember: true) },
|
||||
denyHostKey: cancel,
|
||||
retry: { model.retry(server) },
|
||||
cancel: cancel
|
||||
cancel: cancel,
|
||||
keyboardAuthentication: server.keyboardAuthentication,
|
||||
cancelKeyboardAuthentication: cancel
|
||||
)
|
||||
.environment(app)
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ struct ConnectionJourneyView: View {
|
||||
var failureRetryLabel: LocalizedStringKey = "Retry"
|
||||
var compactFailureActions = false
|
||||
var keyboardAuthentication: SSHKeyboardInteractiveQueue? = nil
|
||||
var cancelKeyboardAuthentication: (() -> Void)? = nil
|
||||
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
@@ -195,7 +196,15 @@ struct ConnectionJourneyView: View {
|
||||
SSHKeyboardInteractiveForm(
|
||||
challenge: challenge,
|
||||
submit: { queue.respond(challenge.id, answers: $0) },
|
||||
cancel: { queue.cancel(challenge.id) }, keyboard: DesktopConnectionFormKeyboard()
|
||||
cancel: {
|
||||
guard queue.current?.id == challenge.id else { return }
|
||||
if let cancelKeyboardAuthentication {
|
||||
cancelKeyboardAuthentication()
|
||||
} else {
|
||||
queue.cancel(challenge.id)
|
||||
}
|
||||
},
|
||||
keyboard: DesktopConnectionFormKeyboard()
|
||||
)
|
||||
.id(challenge.id)
|
||||
.connectionFormChrome(isEnabled: true)
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import TermindApplication
|
||||
import TermindDomain
|
||||
import Testing
|
||||
|
||||
import struct TermindDomain.Host
|
||||
|
||||
@testable import Termind
|
||||
|
||||
@Suite(.serialized) @MainActor
|
||||
struct MetricsJourneyRenderingTests {
|
||||
@Test(arguments: ["en", "zh-Hans", "zh-Hant"])
|
||||
func metricsAuthenticateInsideTheirJourneyWithoutOpeningAnExternalWindow(_ language: String) async throws {
|
||||
let identity = Identity(name: "Deploy", username: "deploy")
|
||||
let host = Host(name: "Production Gateway", host: "gateway.example.invalid", identityID: identity.id)
|
||||
let library = HostLibraryModel(hosts: [host], identities: [identity])
|
||||
try library.upsert(identity, passwordUpdate: .replace("test-only"))
|
||||
let sampler = DesktopJourneyMetricsSampler()
|
||||
let model = ServerMetricsModel(
|
||||
hosts: library, preferences: DesktopJourneyPreferences(), makeSampler: { sampler })
|
||||
let app = AppModel(hosts: library, serverMetrics: model)
|
||||
await sampler.setFallback(app.externalAuthentication.keyboardInteractive)
|
||||
let navigation = WindowModel()
|
||||
navigation.selectedDestination = .metrics
|
||||
navigation.metricsModel = model
|
||||
let controller = NSHostingController(
|
||||
rootView: ServerMetricsView().environment(app).environment(navigation)
|
||||
.environment(\.locale, Locale(identifier: language)))
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 800, height: 740),
|
||||
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.contentViewController = controller
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
defer {
|
||||
model.shutdown()
|
||||
window.orderOut(nil)
|
||||
window.contentViewController = nil
|
||||
window.close()
|
||||
}
|
||||
#expect(model.add(host))
|
||||
let server = try #require(model.servers.first)
|
||||
for _ in 0..<400 where server.keyboardAuthentication.current == nil {
|
||||
try await Task.sleep(for: .milliseconds(5))
|
||||
}
|
||||
let challenge = try #require(server.keyboardAuthentication.current)
|
||||
#expect(navigation.metricsHostID == nil)
|
||||
#expect(app.externalAuthentication.pending.isEmpty)
|
||||
#expect(app.externalAuthentication.presentationRevision == 0)
|
||||
navigation.metricsHostID = host.id
|
||||
for width in [600.0, 1000.0] {
|
||||
window.setContentSize(NSSize(width: width, height: 740))
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
controller.view.layoutSubtreeIfNeeded()
|
||||
let fields = editableFields(in: controller.view)
|
||||
#expect(fields.count == 2)
|
||||
#expect(fields.filter { $0 is NSSecureTextField }.count == 1)
|
||||
#expect(window.attachedSheet == nil)
|
||||
#expect(!navigation.showsCompactMetricsDetail)
|
||||
try capture(controller.view, name: "macos-metrics-journey-\(language)-\(Int(width))")
|
||||
}
|
||||
navigation.metricsHostID = nil
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
#expect(server.keyboardAuthentication.current?.id == challenge.id)
|
||||
navigation.metricsHostID = host.id
|
||||
try await Task.sleep(for: .milliseconds(300))
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
let escape = try #require(
|
||||
NSEvent.keyEvent(
|
||||
with: .keyDown, location: .zero, modifierFlags: [], timestamp: 0,
|
||||
windowNumber: window.windowNumber, context: nil, characters: "\u{1b}",
|
||||
charactersIgnoringModifiers: "\u{1b}", isARepeat: false, keyCode: 53))
|
||||
NSApp.sendEvent(escape)
|
||||
for _ in 0..<100 where server.selection.enabled { try await Task.sleep(for: .milliseconds(5)) }
|
||||
#expect(!server.selection.enabled)
|
||||
#expect(navigation.metricsHostID == nil)
|
||||
#expect(server.keyboardAuthentication.pending.isEmpty)
|
||||
#expect(app.externalAuthentication.pending.isEmpty)
|
||||
#expect(await sampler.connections == 1)
|
||||
}
|
||||
|
||||
private func editableFields(in view: NSView) -> [NSTextField] {
|
||||
if let field = view as? NSTextField, field.isEditable { return [field] }
|
||||
return view.subviews.flatMap { editableFields(in: $0) }
|
||||
}
|
||||
|
||||
private func capture(_ view: NSView, name: String) throws {
|
||||
let bitmap = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
|
||||
view.cacheDisplay(in: view.bounds, to: bitmap)
|
||||
let data = try #require(bitmap.representation(using: .png, properties: [:]))
|
||||
Attachment.record(data, named: "\(name).png")
|
||||
let directory = FileManager.default.temporaryDirectory.appending(path: "termind-metrics-journey")
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
try data.write(to: directory.appending(path: "\(name).png"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor private final class DesktopJourneyPreferences: MetricsConfigurationStoring {
|
||||
func load() -> [MetricsSelection] { [] }
|
||||
func save(_ selections: [MetricsSelection]) {}
|
||||
}
|
||||
|
||||
private actor DesktopJourneyMetricsSampler: MetricsSampling {
|
||||
var connections = 0
|
||||
private var fallback: SSHKeyboardInteractiveHandler?
|
||||
func setFallback(_ handler: @escaping SSHKeyboardInteractiveHandler) { fallback = handler }
|
||||
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
|
||||
func connect(_ request: SSHConnectionRequest) async throws {
|
||||
connections += 1
|
||||
// Match the live exec session's fallback path: Metrics must always override it.
|
||||
let handler = try #require(request.withKeyboardInteractiveFallback(fallback).keyboardInteractive)
|
||||
_ = try await handler(
|
||||
SSHKeyboardInteractiveChallenge(
|
||||
connectionID: UUID(), hostID: request.configuration.hostID,
|
||||
hostname: request.configuration.host, port: request.configuration.port,
|
||||
username: request.configuration.username, round: 1, name: "PAM",
|
||||
instruction: "Enter your account and verification code.",
|
||||
prompts: [.init(text: "Account", echo: true), .init(text: "Verification code", echo: false)]))
|
||||
}
|
||||
func sample() async throws -> MetricsSample {
|
||||
.init(uptime: 1, cpu: [10, 0, 10, 80], memoryUsed: 50, memoryTotal: 100)
|
||||
}
|
||||
func gpu() async throws -> NvidiaSnapshot? { nil }
|
||||
func disconnect() async {}
|
||||
}
|
||||
Reference in new issue
Block a user