@marcusrbrown/infra 0.11.4 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,293 @@
1
+ import type {goke} from 'goke'
2
+ import type {ActionCtx} from '../../lib/action-ctx'
3
+ import type {StatusSummary} from '../status'
4
+ import {z} from 'zod'
5
+
6
+ import {buildKnownHostsArgs} from '../../lib/known-hosts'
7
+ import {redactHost} from '../../lib/redact'
8
+ import {buildIdentityArgs} from '../../lib/ssh-identity'
9
+ import {validateDashboardHost} from './host'
10
+
11
+ declare const process: {
12
+ env: Record<string, string | undefined>
13
+ exitCode?: number
14
+ }
15
+
16
+ const COMPOSE_PROJECT_DIR = '/opt/dashboard'
17
+
18
+ // ─── Types ───────────────────────────────────────────────────────────────────
19
+
20
+ export interface ComposePsEntry {
21
+ Name: string
22
+ State: string
23
+ Health: string
24
+ }
25
+
26
+ export type HealthStatus = 'healthy' | 'unhealthy' | 'starting' | 'n-a'
27
+
28
+ export interface ServiceRow {
29
+ service: string
30
+ state: string
31
+ health: HealthStatus
32
+ }
33
+
34
+ export interface DashboardStatusResult {
35
+ ok: boolean
36
+ services: ServiceRow[]
37
+ error?: string
38
+ }
39
+
40
+ export type SpawnFn = (
41
+ cmd: string[],
42
+ opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
43
+ ) => {
44
+ stdout: ReadableStream<Uint8Array>
45
+ stderr: ReadableStream<Uint8Array>
46
+ exited: Promise<number>
47
+ }
48
+
49
+ // ─── Pure helpers ─────────────────────────────────────────────────────────────
50
+
51
+ export function parseComposePsOutput(stdoutText: string): ComposePsEntry[] {
52
+ const trimmed = stdoutText.trim()
53
+ if (trimmed.length === 0) return []
54
+
55
+ // Legacy compose may emit a single JSON array; current versions emit NDJSON.
56
+ if (trimmed.startsWith('[')) {
57
+ const parsed: unknown = JSON.parse(trimmed)
58
+ return Array.isArray(parsed) ? (parsed as ComposePsEntry[]) : []
59
+ }
60
+
61
+ // NDJSON: one JSON object per non-empty line.
62
+ return trimmed
63
+ .split('\n')
64
+ .map(line => line.trim())
65
+ .filter(line => line.length > 0)
66
+ .map(line => JSON.parse(line) as ComposePsEntry)
67
+ }
68
+
69
+ function normalizeHealth(raw: string): HealthStatus {
70
+ if (raw === 'healthy' || raw === 'unhealthy' || raw === 'starting') {
71
+ return raw
72
+ }
73
+
74
+ return 'n-a'
75
+ }
76
+
77
+ export function parseComposePs(entries: ComposePsEntry[]): ServiceRow[] {
78
+ return entries.map(entry => ({
79
+ service: entry.Name,
80
+ state: entry.State,
81
+ health: normalizeHealth(entry.Health),
82
+ }))
83
+ }
84
+
85
+ export function isAllRunning(rows: ServiceRow[]): boolean {
86
+ return rows.every(row => row.state === 'running' && (row.health === 'healthy' || row.health === 'n-a'))
87
+ }
88
+
89
+ // ─── SSH-backed status fetch ──────────────────────────────────────────────────
90
+
91
+ function defaultSpawn(
92
+ cmd: string[],
93
+ opts: {env: Record<string, string>; stdout: 'pipe'; stderr: 'pipe'},
94
+ ): ReturnType<SpawnFn> {
95
+ return Bun.spawn(cmd, opts) as ReturnType<SpawnFn>
96
+ }
97
+
98
+ export async function getDashboardComposeStatus(
99
+ host: string,
100
+ spawn: SpawnFn = defaultSpawn,
101
+ ): Promise<DashboardStatusResult> {
102
+ validateDashboardHost(host)
103
+
104
+ const knownHostsArgs = buildKnownHostsArgs()
105
+ const {args: identityArgs, cleanup} = buildIdentityArgs(process.env.DASHBOARD_SSH_KEY)
106
+
107
+ const sshCmd = [
108
+ 'ssh',
109
+ '-o',
110
+ 'BatchMode=yes',
111
+ '-o',
112
+ 'ConnectTimeout=10',
113
+ '-o',
114
+ 'StrictHostKeyChecking=yes',
115
+ ...knownHostsArgs,
116
+ ...identityArgs,
117
+ `root@${host}`,
118
+ `docker compose --project-directory ${COMPOSE_PROJECT_DIR} ps --format json`,
119
+ ]
120
+
121
+ const env: Record<string, string> = {
122
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
123
+ HOME: process.env.HOME ?? '/tmp',
124
+ ...(process.env.SSH_AUTH_SOCK ? {SSH_AUTH_SOCK: process.env.SSH_AUTH_SOCK} : {}),
125
+ }
126
+
127
+ let stdoutText: string
128
+ let stderrText: string
129
+ let exitCode: number
130
+
131
+ try {
132
+ const child = spawn(sshCmd, {env, stdout: 'pipe', stderr: 'pipe'})
133
+ ;[stdoutText, stderrText, exitCode] = await Promise.all([
134
+ new Response(child.stdout).text(),
135
+ new Response(child.stderr).text(),
136
+ child.exited,
137
+ ])
138
+ } finally {
139
+ cleanup()
140
+ }
141
+
142
+ if (exitCode !== 0) {
143
+ return {
144
+ ok: false,
145
+ services: [],
146
+ error: `SSH command failed (exit ${exitCode}): ${redactHost(stderrText.trim(), host) || 'unknown error'}`,
147
+ }
148
+ }
149
+
150
+ let entries: ComposePsEntry[]
151
+
152
+ try {
153
+ entries = parseComposePsOutput(stdoutText)
154
+ } catch {
155
+ return {
156
+ ok: false,
157
+ services: [],
158
+ error: `Failed to parse docker compose ps output: ${redactHost(stdoutText.slice(0, 200), host)}`,
159
+ }
160
+ }
161
+
162
+ const services = parseComposePs(entries)
163
+ const ok = isAllRunning(services)
164
+
165
+ return {ok, services}
166
+ }
167
+
168
+ // ─── Unified status aggregator export ────────────────────────────────────────
169
+
170
+ export async function getDashboardStatusSummary(host: string, spawn?: SpawnFn): Promise<StatusSummary> {
171
+ let result: DashboardStatusResult
172
+
173
+ try {
174
+ result = await getDashboardComposeStatus(host, spawn)
175
+ } catch (error) {
176
+ const errorMsg = error instanceof Error ? error.message : String(error)
177
+ return {
178
+ app: 'dashboard',
179
+ http: `ERROR: ${errorMsg}`,
180
+ lastDeploy: '—',
181
+ version: '—',
182
+ contentHash: '—',
183
+ usageStats: '—',
184
+ }
185
+ }
186
+
187
+ // Three-way split: no services → ERROR, services present but degraded → DEGRADED, all healthy → OK
188
+ if (result.services.length === 0) {
189
+ const errorMsg = result.error ?? 'No services reported'
190
+ return {
191
+ app: 'dashboard',
192
+ http: `ERROR: ${errorMsg}`,
193
+ lastDeploy: '—',
194
+ version: '—',
195
+ contentHash: '—',
196
+ usageStats: '—',
197
+ }
198
+ }
199
+
200
+ const rows = result.services.map(s => `${s.service}:${s.state}/${s.health}`).join(', ')
201
+
202
+ if (!result.ok) {
203
+ return {
204
+ app: 'dashboard',
205
+ http: `DEGRADED: ${rows}`,
206
+ lastDeploy: '—',
207
+ version: '—',
208
+ contentHash: '—',
209
+ usageStats: '—',
210
+ }
211
+ }
212
+
213
+ return {
214
+ app: 'dashboard',
215
+ http: `OK: ${rows}`,
216
+ lastDeploy: '—',
217
+ version: '—',
218
+ contentHash: '—',
219
+ usageStats: '—',
220
+ }
221
+ }
222
+
223
+ // ─── Action (exported for direct testing) ────────────────────────────────────
224
+
225
+ export async function dashboardStatusAction(
226
+ options: {key?: string | undefined},
227
+ ctx: ActionCtx,
228
+ spawn?: SpawnFn,
229
+ ): Promise<void> {
230
+ const hostEnvKey = options.key ?? 'DASHBOARD_DOMAIN'
231
+ const host = process.env[hostEnvKey]
232
+
233
+ if (!host) {
234
+ ctx.console.error(
235
+ 'Dashboard host not set. Export DASHBOARD_DOMAIN or pass --key <env-name> pointing to a set variable.',
236
+ )
237
+ ctx.process.exit(1)
238
+ return
239
+ }
240
+
241
+ ctx.console.log('Dashboard status')
242
+ ctx.console.log('')
243
+
244
+ let result: DashboardStatusResult
245
+
246
+ try {
247
+ result = await getDashboardComposeStatus(host, spawn)
248
+ } catch (error) {
249
+ ctx.console.error(`Error: ${error instanceof Error ? error.message : String(error)}`)
250
+ ctx.process.exit(1)
251
+ return
252
+ }
253
+
254
+ if (result.services.length === 0) {
255
+ const msg = result.error ?? 'No services reported by docker compose ps'
256
+ ctx.console.error(`Error: ${msg}`)
257
+ ctx.process.exit(1)
258
+ return
259
+ }
260
+
261
+ ctx.console.log('Service State Health')
262
+ ctx.console.log('─────────────────────────────────────')
263
+
264
+ for (const row of result.services) {
265
+ const svc = row.service.padEnd(16)
266
+ const state = row.state.padEnd(10)
267
+ ctx.console.log(`${svc} ${state} ${row.health}`)
268
+ }
269
+
270
+ ctx.console.log('')
271
+
272
+ if (result.ok) {
273
+ ctx.console.log('Status: OK')
274
+ } else {
275
+ ctx.console.log('Status: DEGRADED (one or more services not running)')
276
+ ctx.process.exit(1)
277
+ }
278
+ }
279
+
280
+ // ─── Command registration ─────────────────────────────────────────────────────
281
+
282
+ export function registerDashboardStatus(cli: ReturnType<typeof goke>): void {
283
+ cli
284
+ .command('dashboard status', 'Show operational health of the dashboard deployment via docker compose ps.')
285
+ .option(
286
+ '--key [key]',
287
+ z
288
+ .string()
289
+ .optional()
290
+ .describe('Environment variable name holding the SSH host. Falls back to DASHBOARD_DOMAIN when omitted.'),
291
+ )
292
+ .action((options, ctx) => dashboardStatusAction(options, ctx))
293
+ }
@@ -30,6 +30,7 @@ import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test} from
30
30
  import {goke} from 'goke'
31
31
 
32
32
  import {registerCliproxyCommands} from './cliproxy'
33
+ import {registerDashboardCommands} from './dashboard'
33
34
  import {registerGatewayCommands} from './gateway'
34
35
  import {registerKeewebCommands} from './keeweb'
35
36
  import {MCP_ALLOWLIST, registerMcp} from './mcp'
@@ -47,6 +48,7 @@ import {registerVpnCommands} from './vpn'
47
48
  const EXPECTED_TOOLS = [
48
49
  'cliproxy_models',
49
50
  'cliproxy_status',
51
+ 'dashboard_status',
50
52
  'gateway_status',
51
53
  'keeweb_status',
52
54
  'status',
@@ -65,6 +67,8 @@ const CLI_ONLY_TOOLS = [
65
67
  'cliproxy_login',
66
68
  'cliproxy_open',
67
69
  'cliproxy_setup',
70
+ 'dashboard_deploy',
71
+ 'dashboard_logs',
68
72
  'gateway_deploy',
69
73
  'gateway_logs',
70
74
  'gateway_restore',
@@ -96,6 +100,7 @@ function buildTestCli(): ReturnType<typeof goke> {
96
100
  registerCliproxyCommands(cli)
97
101
  registerGatewayCommands(cli)
98
102
  registerUmamiCommands(cli)
103
+ registerDashboardCommands(cli)
99
104
  registerVpnCommands(cli)
100
105
  registerStatus(cli)
101
106
  registerMcp(cli)
@@ -16,6 +16,8 @@ import {createMcpAction} from '@goke/mcp'
16
16
  * - `keeweb open` — host-machine side effect (spawns local browser, requires user intent)
17
17
  * - `umami deploy` — intentionally CLI-only: mutates live deployment and requires environment approval
18
18
  * - `umami logs` — intentionally CLI-only: streams logs that may emit sensitive data (DB passwords, app secrets)
19
+ * - `dashboard deploy` — intentionally CLI-only: mutates live deployment and requires environment approval
20
+ * - `dashboard logs` — intentionally CLI-only: streams logs that may emit sensitive data (DB passwords, app secrets)
19
21
  *
20
22
  * Source-gated sensitive commands (security — CLI-only, never MCP-exposed):
21
23
  * - `gateway backup` — secret-bearing: writes CA private key material to a tarball; CLI-only
@@ -41,6 +43,7 @@ export const MCP_ALLOWLIST: ReadonlySet<string> = new Set([
41
43
  'cliproxy models',
42
44
  'keeweb status',
43
45
  'umami status',
46
+ 'dashboard status',
44
47
  'vpn status',
45
48
  'status',
46
49
  ])
@@ -4,6 +4,8 @@ import {goke} from 'goke'
4
4
  import {createCapturedCtx, expectCapturedToInclude} from '../__test__/mcp-ctx-fixture'
5
5
  import {registerStatus, unifiedStatusAction} from './status'
6
6
 
7
+ // NOTE: dashboard is included in the unified status table alongside the other apps.
8
+
7
9
  const healthyKeeweb: StatusSummary = {
8
10
  app: 'keeweb',
9
11
  http: 'OK',
@@ -49,12 +51,22 @@ const healthyVpn: StatusSummary = {
49
51
  usageStats: '—',
50
52
  }
51
53
 
54
+ const healthyDashboard: StatusSummary = {
55
+ app: 'dashboard',
56
+ http: 'OK: dashboard:running/healthy',
57
+ lastDeploy: '—',
58
+ version: '—',
59
+ contentHash: '—',
60
+ usageStats: '—',
61
+ }
62
+
52
63
  function makeDeps(overrides?: Partial<Parameters<typeof registerStatus>[1]>): Parameters<typeof registerStatus>[1] {
53
64
  return {
54
65
  getKeewebStatusSummary: async () => healthyKeeweb,
55
66
  getCliproxyStatusSummary: async () => healthyCliproxy,
56
67
  getGatewayStatusSummary: async () => healthyGateway,
57
68
  getUmamiStatusSummary: async () => healthyUmami,
69
+ getDashboardStatusSummary: async () => healthyDashboard,
58
70
  getVpnStatusSummary: async () => healthyVpn,
59
71
  ...overrides,
60
72
  }
@@ -73,6 +85,9 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
73
85
  expect(expectCapturedToInclude(captured, '| cliproxy | OK | — | v1.2.3 | — | 12 req / 0 fail |')).toBe(true)
74
86
  expect(expectCapturedToInclude(captured, '| gateway | OK: gateway:running/healthy | — | — | — | — |')).toBe(true)
75
87
  expect(expectCapturedToInclude(captured, '| umami | OK: umami:running/healthy | — | — | — | — |')).toBe(true)
88
+ expect(expectCapturedToInclude(captured, '| dashboard | OK: dashboard:running/healthy | — | — | — | — |')).toBe(
89
+ true,
90
+ )
76
91
  expect(expectCapturedToInclude(captured, '| vpn | OK: wg0 up, pubkey:TESTKEY== 1 peer(s) | — | — | — | — |')).toBe(
77
92
  true,
78
93
  )
@@ -171,6 +186,7 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
171
186
  cliproxy: {version: string}
172
187
  gateway: {http: string}
173
188
  umami: {http: string}
189
+ dashboard: {http: string}
174
190
  vpn: {http: string}
175
191
  }
176
192
 
@@ -178,6 +194,7 @@ describe('top-level status command (Tier-2 ctx capture)', () => {
178
194
  expect(parsed.cliproxy.version).toBe('v1.2.3')
179
195
  expect(parsed.gateway.http).toBe('OK: gateway:running/healthy')
180
196
  expect(parsed.umami.http).toBe('OK: umami:running/healthy')
197
+ expect(parsed.dashboard.http).toBe('OK: dashboard:running/healthy')
181
198
  expect(parsed.vpn.http).toBe('OK: wg0 up, pubkey:TESTKEY== 1 peer(s)')
182
199
  })
183
200
 
@@ -5,6 +5,7 @@ import type {ActionCtx} from '../lib/action-ctx'
5
5
  import {z} from 'zod'
6
6
 
7
7
  import {getCliproxyStatusSummary} from './cliproxy/status'
8
+ import {getDashboardStatusSummary} from './dashboard'
8
9
  import {getGatewayStatusSummary} from './gateway'
9
10
  import {getKeewebStatusSummary} from './keeweb/status'
10
11
  import {getUmamiStatusSummary} from './umami'
@@ -15,7 +16,7 @@ declare const process: {
15
16
  }
16
17
 
17
18
  export interface StatusSummary {
18
- app: 'keeweb' | 'cliproxy' | 'gateway' | 'umami' | 'vpn'
19
+ app: 'keeweb' | 'cliproxy' | 'gateway' | 'umami' | 'dashboard' | 'vpn'
19
20
  http: string
20
21
  lastDeploy: string
21
22
  version: string
@@ -30,6 +31,7 @@ interface StatusDependencies {
30
31
  getCliproxyStatusSummary: (baseUrl: string, key: string, verbose: boolean) => Promise<StatusSummary>
31
32
  getGatewayStatusSummary: (host: string) => Promise<StatusSummary>
32
33
  getUmamiStatusSummary: (host: string) => Promise<StatusSummary>
34
+ getDashboardStatusSummary: (host: string) => Promise<StatusSummary>
33
35
  getVpnStatusSummary: (host: string) => Promise<StatusSummary>
34
36
  }
35
37
 
@@ -68,6 +70,7 @@ function toJsonPayload(rows: StatusSummary[]): Record<AppName, StatusSummary> {
68
70
  cliproxy: rows.find(row => row.app === 'cliproxy') ?? errorSummary('cliproxy', 'missing result'),
69
71
  gateway: rows.find(row => row.app === 'gateway') ?? errorSummary('gateway', 'missing result'),
70
72
  umami: rows.find(row => row.app === 'umami') ?? errorSummary('umami', 'missing result'),
73
+ dashboard: rows.find(row => row.app === 'dashboard') ?? errorSummary('dashboard', 'missing result'),
71
74
  vpn: rows.find(row => row.app === 'vpn') ?? errorSummary('vpn', 'missing result'),
72
75
  }
73
76
  }
@@ -85,6 +88,7 @@ export async function unifiedStatusAction(
85
88
  getCliproxyStatusSummary,
86
89
  getGatewayStatusSummary,
87
90
  getUmamiStatusSummary,
91
+ getDashboardStatusSummary,
88
92
  getVpnStatusSummary,
89
93
  },
90
94
  ): Promise<void> {
@@ -93,6 +97,7 @@ export async function unifiedStatusAction(
93
97
  const cliproxyKey = process.env.CLIPROXY_MANAGEMENT_KEY ?? ''
94
98
  const gatewayHost = process.env.GATEWAY_HOST ?? ''
95
99
  const umamiHost = process.env.UMAMI_DOMAIN ?? ''
100
+ const dashboardHost = process.env.DASHBOARD_DOMAIN ?? ''
96
101
  const vpnHost = process.env.VPN_HOST ?? ''
97
102
 
98
103
  const results = await Promise.allSettled([
@@ -100,10 +105,11 @@ export async function unifiedStatusAction(
100
105
  dependencies.getCliproxyStatusSummary(cliproxyBaseUrl, cliproxyKey, verbose),
101
106
  dependencies.getGatewayStatusSummary(gatewayHost),
102
107
  dependencies.getUmamiStatusSummary(umamiHost),
108
+ dependencies.getDashboardStatusSummary(dashboardHost),
103
109
  dependencies.getVpnStatusSummary(vpnHost),
104
110
  ])
105
111
 
106
- const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway', 'umami', 'vpn']
112
+ const appNames: AppName[] = ['keeweb', 'cliproxy', 'gateway', 'umami', 'dashboard', 'vpn']
107
113
  const rows: StatusSummary[] = results.map((result, index) => {
108
114
  const app = appNames[index] ?? 'keeweb'
109
115
  if (result.status === 'fulfilled') {
@@ -133,6 +139,7 @@ export function registerStatus(
133
139
  getCliproxyStatusSummary,
134
140
  getGatewayStatusSummary,
135
141
  getUmamiStatusSummary,
142
+ getDashboardStatusSummary,
136
143
  getVpnStatusSummary,
137
144
  },
138
145
  ): void {
@@ -142,7 +149,9 @@ export function registerStatus(
142
149
  '--json',
143
150
  z
144
151
  .boolean()
145
- .describe('Output machine-readable JSON with keeweb, cliproxy, gateway, umami, and vpn summary objects.'),
152
+ .describe(
153
+ 'Output machine-readable JSON with keeweb, cliproxy, gateway, umami, dashboard, and vpn summary objects.',
154
+ ),
146
155
  )
147
156
  .option(
148
157
  '--verbose',
@@ -44,3 +44,11 @@ metrics.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAy
44
44
  |1|6g8lttzHI3BCW6rIf1/Bteo1KcM=|bNbGP2QK2pUdPmhplw6eVzspe5g= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILA4LGgnvBjq/yoOKLjzFefGzo9gAspxwfbY8aACj/3q
45
45
  # 52.208.116.13:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.15
46
46
  # 52.208.116.13:22 SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.15
47
+
48
+ # dashboard droplet (206.189.198.47 / dashboard.fro.bot)
49
+ dashboard.fro.bot ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDOO1TZlem4z3y9QPJ5GNYT+uwH1rkPQzBNC4GFM3pmFpQrikJSKNsB8m6ugvT30BChVPI2bOKmPUAfdstCQks2Creql9yYYkmo2PHnICNuj66saoOr6O+aACZYMJzjyLr3hcpxxFlLCYiu3fWkCoR8U+Zej3Fd+rjkSSm35jiAH/9y6pzLYO0HeIWGnDqg/qkTfQPwdGzlp+/PtWdClgECWO/dv2Op55cnoXBw9V3+DTvi9Dz8SqHs61InonZIkbGhleOF+JYzMinFkn9zcLTqZsnLhJwqF/xnhqjBGIpKUfL+cHAge5S2BVkudULQ93T/s7Ef6i0NyWNs3355D1XlaXPqIKQX7wZ95pfnAfdaMtHBNmb87YjzhFmyjjK+WewMDREPkYCmK5sP+CGgZTesFvEBM3R/3KbrHwqctFkZmjYVMhQ+AjYOCcXrqQNbqRbhYxKjQxJ4+JOIEmrvkO7/sN+2Ln/ieUeiNNwHQI3u2y3gh/86+b5hkkSfiM36Guk=
50
+ dashboard.fro.bot ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIbi0QIPYdxKt7NyZaQqRzqybeLaLWGpRz0HmJ/LlqCZvDyMKpgRPBbXdrX4VLJ7yjHMMPTE3c6AuESPaREAskQ=
51
+ dashboard.fro.bot ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICfh7XC2RI9+cjSQYilRUuTJ6LyYNemiqwpIrrtuGem4
52
+ |1|BE8EGlm+K9zcHciIkmp8myb/qaI=|Q8zNDhvS1r7zKIGzI54O/v9ev1I= ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDOO1TZlem4z3y9QPJ5GNYT+uwH1rkPQzBNC4GFM3pmFpQrikJSKNsB8m6ugvT30BChVPI2bOKmPUAfdstCQks2Creql9yYYkmo2PHnICNuj66saoOr6O+aACZYMJzjyLr3hcpxxFlLCYiu3fWkCoR8U+Zej3Fd+rjkSSm35jiAH/9y6pzLYO0HeIWGnDqg/qkTfQPwdGzlp+/PtWdClgECWO/dv2Op55cnoXBw9V3+DTvi9Dz8SqHs61InonZIkbGhleOF+JYzMinFkn9zcLTqZsnLhJwqF/xnhqjBGIpKUfL+cHAge5S2BVkudULQ93T/s7Ef6i0NyWNs3355D1XlaXPqIKQX7wZ95pfnAfdaMtHBNmb87YjzhFmyjjK+WewMDREPkYCmK5sP+CGgZTesFvEBM3R/3KbrHwqctFkZmjYVMhQ+AjYOCcXrqQNbqRbhYxKjQxJ4+JOIEmrvkO7/sN+2Ln/ieUeiNNwHQI3u2y3gh/86+b5hkkSfiM36Guk=
53
+ |1|ecn8h6NS6U7O4Trw2TYxOU/NRBA=|reMYqISi8FjcMseG/m8hL2gZeh8= ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIbi0QIPYdxKt7NyZaQqRzqybeLaLWGpRz0HmJ/LlqCZvDyMKpgRPBbXdrX4VLJ7yjHMMPTE3c6AuESPaREAskQ=
54
+ |1|MDYlzZB3WsZTegoHcaekI74yObo=|/l2M4GVvjP43mFxedVBB2aBkTQk= ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICfh7XC2RI9+cjSQYilRUuTJ6LyYNemiqwpIrrtuGem4