diff --git a/KeeperSdk/src/index.ts b/KeeperSdk/src/index.ts index 33ce69a..b62df02 100644 --- a/KeeperSdk/src/index.ts +++ b/KeeperSdk/src/index.ts @@ -37,6 +37,7 @@ export { AuditReportErrorCode, ActionReportErrorCode, PasswordReportErrorCode, + PamErrorCode, KEEPER_PUBLIC_HOSTS, isBoolean, isString, @@ -652,6 +653,67 @@ export type { NsfResolvedShareRecipient, } from './nestedShareFolders' +export { + PamManager, + GatewayManager, + listGateways, + formatGatewaysTable, + renderGatewaysAsciiTable, + formatGatewaysJson, + formatGatewaysOutput, + createGateway, + formatCreateGatewayOutput, + editGateway, + formatEditGatewayOutput, + GatewayListFormat, + GatewayStatus, + GatewayConfigInitFormat, + KSM_APP_RECORD_VERSION, + APP_NOT_ACCESSIBLE_LABEL, + KSM_CLIENT_ID_MESSAGE, + DEFAULT_GATEWAY_TOKEN_EXPIRES_IN_MIN, + MAX_GATEWAY_TOKEN_EXPIRES_IN_MIN, + EMPTY_GATEWAYS_MESSAGE, + GATEWAY_LIST_DEFAULT_HEADERS, + GATEWAY_LIST_VERBOSE_HEADERS, + getKeeperRouterBaseUrl, + webSafeUidFromBytes, + toFiniteNumber, + formatTimestampMs, + parseGatewayVersionString, + getKsmApplicationDisplayInfo, + resolveKsmApplication, + getKeeperRegionAbbreviation, + formatGatewayOneTimeToken, + findEnterpriseGatewayByUidOrName, + groupOnlineGatewaysByControllerUid, + isKeeperRouterConnectionError, +} from './pam' +export type { + ListGatewaysOptions, + GatewayCounts, + GatewayOsMetadata, + GatewayVersionParts, + GatewayPoolInstance, + GatewayListRow, + GatewayConnectivityStatus, + ListGatewaysResult, + FormattedGatewaysTable, + FormatGatewaysTableOptions, + RenderGatewaysAsciiTableOptions, + GatewayListFormatInput, + GatewayConfigInitFormatInput, + KsmApplicationDisplayInfo, + ResolvedKsmApplication, + CreateGatewayInput, + CreateGatewayResult, + EditGatewayInput, + EditGatewayResult, + GatewayJsonPoolInstance, + GatewayJsonEntry, + GatewaysJsonPayload, +} from './pam' + export type { DRecord, DRecordMetadata, diff --git a/KeeperSdk/src/pam/PamManager.ts b/KeeperSdk/src/pam/PamManager.ts new file mode 100644 index 0000000..bdc4202 --- /dev/null +++ b/KeeperSdk/src/pam/PamManager.ts @@ -0,0 +1,73 @@ +import type { Auth } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../storage/InMemoryStorage' +import { GatewayManager } from './gateway/GatewayManager' +import type { + CreateGatewayInput, + CreateGatewayResult, + EditGatewayInput, + EditGatewayResult, + FormatGatewaysTableOptions, + FormattedGatewaysTable, + ListGatewaysOptions, + ListGatewaysResult, + RenderGatewaysAsciiTableOptions, +} from './gateway/gatewayTypes' + +export type AuthProvider = () => Auth + +export class PamManager { + private readonly gatewayManager: GatewayManager + + constructor(storage: InMemoryStorage, authProvider: AuthProvider) { + this.gatewayManager = new GatewayManager(storage, authProvider) + } + + public getGatewayManager(): GatewayManager { + return this.gatewayManager + } + + public async listGateways(options: ListGatewaysOptions = {}): Promise { + return this.gatewayManager.listGateways(options) + } + + public async createGateway(input: CreateGatewayInput & { returnValue: true }): Promise + public async createGateway(input: CreateGatewayInput & { returnValue?: false }): Promise + public async createGateway(input: CreateGatewayInput): Promise + public async createGateway(input: CreateGatewayInput): Promise { + return this.gatewayManager.createGateway(input) + } + + public formatCreateGatewayOutput(result: CreateGatewayResult): string { + return this.gatewayManager.formatCreateGatewayOutput(result) + } + + public async editGateway(input: EditGatewayInput): Promise { + return this.gatewayManager.editGateway(input) + } + + public formatEditGatewayOutput(result: EditGatewayResult): string { + return this.gatewayManager.formatEditGatewayOutput(result) + } + + public formatGatewaysTable( + result: ListGatewaysResult, + options: FormatGatewaysTableOptions = {} + ): FormattedGatewaysTable { + return this.gatewayManager.formatGatewaysTable(result, options) + } + + public renderGatewaysAsciiTable( + table: FormattedGatewaysTable, + options: RenderGatewaysAsciiTableOptions = {} + ): string { + return this.gatewayManager.renderGatewaysAsciiTable(table, options) + } + + public formatGatewaysJson(result: ListGatewaysResult, options: ListGatewaysOptions = {}): string { + return this.gatewayManager.formatGatewaysJson(result, options) + } + + public formatGatewaysOutput(result: ListGatewaysResult, options: ListGatewaysOptions = {}): string { + return this.gatewayManager.formatGatewaysOutput(result, options) + } +} diff --git a/KeeperSdk/src/pam/gateway/GatewayManager.ts b/KeeperSdk/src/pam/gateway/GatewayManager.ts new file mode 100644 index 0000000..a4568a1 --- /dev/null +++ b/KeeperSdk/src/pam/gateway/GatewayManager.ts @@ -0,0 +1,88 @@ +import type { Auth } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { KeeperSdkError, ResultCodes } from '../../utils' +import { createGateway, formatCreateGatewayOutput } from './createGateway' +import { editGateway, formatEditGatewayOutput } from './editGateway' +import { + formatGatewaysJson, + formatGatewaysOutput, + formatGatewaysTable, + listGateways, + renderGatewaysAsciiTable, +} from './listGateways' +import type { + CreateGatewayInput, + CreateGatewayResult, + EditGatewayInput, + EditGatewayResult, + FormatGatewaysTableOptions, + FormattedGatewaysTable, + ListGatewaysOptions, + ListGatewaysResult, + RenderGatewaysAsciiTableOptions, +} from './gatewayTypes' + +export type AuthProvider = () => Auth + +export class GatewayManager { + private readonly storage: InMemoryStorage + private readonly authProvider: AuthProvider + + constructor(storage: InMemoryStorage, authProvider: AuthProvider) { + this.storage = storage + this.authProvider = authProvider + } + + private requireAuth(): Auth { + const auth = this.authProvider() + if (!auth?.sessionToken) { + throw new KeeperSdkError('Not logged in. Call login() first.', ResultCodes.NOT_LOGGED_IN) + } + return auth + } + + public async listGateways(options: ListGatewaysOptions = {}): Promise { + return listGateways(this.requireAuth(), this.storage, options) + } + + public async createGateway(input: CreateGatewayInput & { returnValue: true }): Promise + public async createGateway(input: CreateGatewayInput & { returnValue?: false }): Promise + public async createGateway(input: CreateGatewayInput): Promise + public async createGateway(input: CreateGatewayInput): Promise { + return createGateway(this.requireAuth(), this.storage, input) + } + + public formatCreateGatewayOutput(result: CreateGatewayResult): string { + return formatCreateGatewayOutput(result) + } + + public async editGateway(input: EditGatewayInput): Promise { + return editGateway(this.requireAuth(), input) + } + + public formatEditGatewayOutput(result: EditGatewayResult): string { + return formatEditGatewayOutput(result) + } + + public formatGatewaysTable( + result: ListGatewaysResult, + options: FormatGatewaysTableOptions = {} + ): FormattedGatewaysTable { + return formatGatewaysTable(result, options) + } + + public renderGatewaysAsciiTable( + table: FormattedGatewaysTable, + options: RenderGatewaysAsciiTableOptions = {} + ): string { + return renderGatewaysAsciiTable(table, options) + } + + public formatGatewaysJson(result: ListGatewaysResult, options: ListGatewaysOptions = {}): string { + return formatGatewaysJson(result, options) + } + + public formatGatewaysOutput(result: ListGatewaysResult, options: ListGatewaysOptions = {}): string { + return formatGatewaysOutput(result, options) + } +} diff --git a/KeeperSdk/src/pam/gateway/createGateway.ts b/KeeperSdk/src/pam/gateway/createGateway.ts new file mode 100644 index 0000000..8655302 --- /dev/null +++ b/KeeperSdk/src/pam/gateway/createGateway.ts @@ -0,0 +1,266 @@ +import { createHmac, randomBytes } from 'crypto' +import type { Auth } from '@keeper-security/keeperapi' +import { + Enterprise, + addAppClientMessage, + normal64Bytes, + platform, + webSafe64FromBytes, +} from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../../utils' +import { + DEFAULT_GATEWAY_TOKEN_EXPIRES_IN_MIN, + KSM_CLIENT_ID_MESSAGE, + MAX_GATEWAY_TOKEN_EXPIRES_IN_MIN, +} from './gatewayConstants' +import { formatGatewayOneTimeToken, formatTimestampMs, resolveKsmApplication } from './gatewayHelpers' +import { + GatewayConfigInitFormat, + type CreateGatewayInput, + type CreateGatewayResult, + type GatewayConfigInitFormatInput, +} from './gatewayTypes' + +type SecretsManagerStorage = { + getString: (key: string) => Promise + saveString: (key: string, value: string) => Promise + getStringSync?: (key: string) => string | undefined + saveStringSync?: (key: string, value: string) => void + snapshot: () => Record +} + +type SecretsManagerCoreModule = { + initializeStorage: (storage: SecretsManagerStorage, token: string, hostname?: string) => Promise + getSecrets: (options: { storage: SecretsManagerStorage }) => Promise +} + +function createSecretsManagerStorage(): SecretsManagerStorage { + const map = new Map() + return { + async getString(key) { + return map.get(key) + }, + async saveString(key, value) { + map.set(key, value) + }, + getStringSync(key) { + return map.get(key) + }, + saveStringSync(key, value) { + map.set(key, value) + }, + snapshot() { + return Object.fromEntries(map) + }, + } +} + +function normalizeConfigInit(input?: GatewayConfigInitFormatInput): GatewayConfigInitFormat | undefined { + if (!input) return undefined + const value = String(input).toLowerCase() + if (value === GatewayConfigInitFormat.Json) return GatewayConfigInitFormat.Json + if (value === GatewayConfigInitFormat.B64) return GatewayConfigInitFormat.B64 + throw new KeeperSdkError( + `Invalid configInit '${input}'. Use 'json' or 'b64'.`, + ResultCodes.PAM_GATEWAY_CREATE_FAILED + ) +} + +function resolveTokenExpiresInMin(raw: number | undefined): number { + const value = raw == null ? DEFAULT_GATEWAY_TOKEN_EXPIRES_IN_MIN : Number(raw) + if (!Number.isFinite(value) || value <= 0 || value > MAX_GATEWAY_TOKEN_EXPIRES_IN_MIN) { + throw new KeeperSdkError( + `tokenExpiresInMin must be between 1 and ${MAX_GATEWAY_TOKEN_EXPIRES_IN_MIN} minutes.`, + ResultCodes.PAM_INVALID_TOKEN_EXPIRY + ) + } + return Math.floor(value) +} + +function snapshotValue(snapshot: Record, camel: string, upper: string, fallback = ''): string { + return snapshot[camel] || snapshot[upper] || fallback +} + +async function initKsmConfigFromToken( + oneTimeToken: string, + host: string, + format: GatewayConfigInitFormat +): Promise { + let ksm: Partial + try { + ksm = require('@keeper-security/secrets-manager-core') as SecretsManagerCoreModule + } catch { + throw new KeeperSdkError( + 'configInit requires optional package "@keeper-security/secrets-manager-core". Install it to initialize gateway config from the one-time token.', + ResultCodes.PAM_CONFIG_INIT_UNAVAILABLE + ) + } + + if (typeof ksm.initializeStorage !== 'function' || typeof ksm.getSecrets !== 'function') { + throw new KeeperSdkError( + 'Installed @keeper-security/secrets-manager-core does not expose initializeStorage/getSecrets.', + ResultCodes.PAM_CONFIG_INIT_UNAVAILABLE + ) + } + + const storage = createSecretsManagerStorage() + try { + await ksm.initializeStorage(storage, oneTimeToken, host) + try { + await ksm.getSecrets({ storage }) + } catch { + // First access may fail looking up a dummy UID; config keys should still populate. + } + } catch (err) { + throw new KeeperSdkError( + `Failed to initialize KSM config: ${extractErrorMessage(err)}`, + ResultCodes.PAM_CONFIG_INIT_FAILED + ) + } + + const snapshot = storage.snapshot() + const configDict: Record = { + hostname: snapshotValue(snapshot, 'hostname', 'HOSTNAME', host), + clientId: snapshotValue(snapshot, 'clientId', 'CLIENT_ID'), + privateKey: snapshotValue(snapshot, 'privateKey', 'PRIVATE_KEY'), + serverPublicKeyId: snapshotValue(snapshot, 'serverPublicKeyId', 'SERVER_PUBLIC_KEY_ID'), + appKey: snapshotValue(snapshot, 'appKey', 'APP_KEY'), + } + const ownerPublicKey = snapshotValue(snapshot, 'ownerPublicKey', 'OWNER_PUBLIC_KEY') + if (ownerPublicKey) configDict.ownerPublicKey = ownerPublicKey + + for (const key of ['hostname', 'clientId', 'privateKey', 'serverPublicKeyId', 'appKey'] as const) { + if (!configDict[key]) { + throw new KeeperSdkError( + `Generated KSM config is invalid: "${key}" is missing or empty.`, + ResultCodes.PAM_CONFIG_INIT_FAILED + ) + } + } + + const json = JSON.stringify(configDict) + return format === GatewayConfigInitFormat.B64 ? Buffer.from(json, 'utf8').toString('base64') : json +} + +function buildCreateGatewayMessage( + appLabel: string, + gatewayName: string, + tokenExpiresInMin: number, + isInitializedConfig: boolean +): string { + const base = `The one-time token was created in application [${appLabel}]. The new Gateway named ${gatewayName} will show up in the gateway list once it is initialized.` + if (isInitializedConfig) { + return `The one-time token was created in application [${appLabel}]. Use the initialized config in the Gateway. The new Gateway named ${gatewayName} will show up in the gateway list once it is initialized.` + } + return `${base} Token expires in ${tokenExpiresInMin} minutes.` +} + +export async function createGateway( + auth: Auth, + storage: InMemoryStorage, + input: CreateGatewayInput & { returnValue: true } +): Promise +export async function createGateway( + auth: Auth, + storage: InMemoryStorage, + input: CreateGatewayInput & { returnValue?: false } +): Promise +export async function createGateway( + auth: Auth, + storage: InMemoryStorage, + input: CreateGatewayInput +): Promise +export async function createGateway( + auth: Auth, + storage: InMemoryStorage, + input: CreateGatewayInput +): Promise { + const gatewayName = input.name?.trim() || '' + if (!gatewayName) { + throw new KeeperSdkError('Gateway name is required.', ResultCodes.PAM_GATEWAY_NAME_REQUIRED) + } + + const application = input.application?.trim() || '' + if (!application) { + throw new KeeperSdkError('KSM application name or UID is required.', ResultCodes.PAM_KSM_APP_REQUIRED) + } + + const tokenExpiresInMin = resolveTokenExpiresInMin(input.tokenExpiresInMin) + const configInit = normalizeConfigInit(input.configInit) + const returnValue = input.returnValue === true + const app = await resolveKsmApplication(storage, application) + + const secretBytes = randomBytes(32) + const clientId = createHmac('sha512', secretBytes).update(KSM_CLIENT_ID_MESSAGE).digest() + const encryptedAppKey = await platform.aesGcmEncrypt(app.recordKey, secretBytes) + const firstAccessExpireOn = Date.now() + tokenExpiresInMin * 60 * 1000 + + try { + const device = await auth.executeRest( + addAppClientMessage({ + appRecordUid: normal64Bytes(app.uid), + encryptedAppKey, + clientId, + lockIp: false, + firstAccessExpireOn, + id: gatewayName, + appClientType: Enterprise.AppClientType.DISCOVERY_AND_ROTATION_CONTROLLER, + }) + ) + + const host = String(auth.options.host || '') + const oneTimeToken = formatGatewayOneTimeToken(host, secretBytes) + const deviceToken = + device.encryptedDeviceToken && device.encryptedDeviceToken.length > 0 + ? webSafe64FromBytes(device.encryptedDeviceToken) + : undefined + + const isInitializedConfig = configInit != null + const tokenOrConfig = isInitializedConfig + ? await initKsmConfigFromToken(oneTimeToken, host, configInit) + : oneTimeToken + + // Automation: return only the OTT / initialized config string (Commander -r). + if (returnValue) { + return tokenOrConfig + } + + return { + success: true, + gatewayName, + applicationUid: app.uid, + applicationTitle: app.title, + tokenOrConfig, + isInitializedConfig, + configInit, + tokenExpiresInMin, + tokenExpiresOn: formatTimestampMs(firstAccessExpireOn), + deviceToken, + message: buildCreateGatewayMessage( + app.title || app.uid, + gatewayName, + tokenExpiresInMin, + isInitializedConfig + ), + } + } catch (err) { + if (err instanceof KeeperSdkError) throw err + throw new KeeperSdkError( + `Failed to create gateway: ${extractErrorMessage(err)}`, + ResultCodes.PAM_GATEWAY_CREATE_FAILED + ) + } +} + +export function formatCreateGatewayOutput(result: CreateGatewayResult): string { + return [ + result.message, + '', + result.isInitializedConfig ? 'Use the following initialized config in the Gateway:' : 'One-time token:', + '-----------------------------------------------', + result.tokenOrConfig, + '-----------------------------------------------', + `Token expires on: ${result.tokenExpiresOn}`, + ].join('\n') +} diff --git a/KeeperSdk/src/pam/gateway/editGateway.ts b/KeeperSdk/src/pam/gateway/editGateway.ts new file mode 100644 index 0000000..9dfd81f --- /dev/null +++ b/KeeperSdk/src/pam/gateway/editGateway.ts @@ -0,0 +1,147 @@ +import type { Auth, PAM } from '@keeper-security/keeperapi' +import { createInMessage, getControllers, normal64Bytes, PAM as PamProto } from '@keeper-security/keeperapi' +import { EnterpriseDataInclude, EnterpriseDataManager } from '../../teams/enterpriseData' +import { applyDecryptedNodeNames, resolveParentNode } from '../../teams/teamUtils' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../../utils' +import { findEnterpriseGatewayByUidOrName, toFiniteNumber, webSafeUidFromBytes } from './gatewayHelpers' +import type { EditGatewayInput, EditGatewayResult } from './gatewayTypes' + +function modifyControllerMessage(data: PAM.IPAMController) { + return createInMessage(data, 'pam/modify_controller', PamProto.PAMController) +} + +function hasNodeArgument(nodeIdOrName: EditGatewayInput['nodeIdOrName']): boolean { + return ( + nodeIdOrName !== undefined && + nodeIdOrName !== null && + !(typeof nodeIdOrName === 'string' && nodeIdOrName.trim() === '') + ) +} + +async function resolveEnterpriseNodeId(auth: Auth, nodeIdOrName: string | number): Promise { + try { + const enterpriseData = new EnterpriseDataManager(auth) + const [response, displayNames] = await Promise.all([ + enterpriseData.getData([EnterpriseDataInclude.Nodes]), + enterpriseData.getDisplayNames(), + ]) + const nodes = response.nodes || [] + applyDecryptedNodeNames(nodes, displayNames.nodes) + return resolveParentNode(nodes, nodeIdOrName).node_id + } catch (err) { + if (err instanceof KeeperSdkError) { + if (err.resultCode === ResultCodes.PARENT_NODE_NOT_FOUND) { + throw new KeeperSdkError(err.message, ResultCodes.PAM_GATEWAY_NODE_NOT_FOUND) + } + if (err.resultCode === ResultCodes.MULTIPLE_PARENT_NODE_MATCHES) { + throw new KeeperSdkError(err.message, ResultCodes.PAM_MULTIPLE_GATEWAY_NODE_MATCHES) + } + throw err + } + throw new KeeperSdkError( + `Failed to resolve enterprise node: ${extractErrorMessage(err)}`, + ResultCodes.PAM_GATEWAY_EDIT_FAILED + ) + } +} + +function buildEditResult( + partial: Omit & { unchanged?: boolean } +): EditGatewayResult { + const { unchanged, ...rest } = partial + return { + success: true, + ...rest, + message: unchanged ? `Gateway ${rest.gatewayUid} is unchanged.` : `Gateway ${rest.gatewayUid} has been edited.`, + } +} + +export async function editGateway(auth: Auth, input: EditGatewayInput): Promise { + const gatewayUidOrName = input.gatewayUidOrName?.trim() || '' + if (!gatewayUidOrName) { + throw new KeeperSdkError('Gateway UID or name is required.', ResultCodes.PAM_GATEWAY_REQUIRED) + } + + const newNameRaw = input.name == null ? '' : String(input.name).trim() + const hasName = newNameRaw.length > 0 + const hasNode = hasNodeArgument(input.nodeIdOrName) + + if (!hasName && !hasNode) { + throw new KeeperSdkError( + 'Nothing to do. At least one of name or nodeIdOrName is required.', + ResultCodes.PAM_GATEWAY_EDIT_NOTHING_TO_DO + ) + } + + let controllers: PAM.IPAMController[] + try { + const response = await auth.executeRest(getControllers()) + controllers = response.controllers ?? [] + } catch (err) { + throw new KeeperSdkError( + `Failed to list enterprise gateways: ${extractErrorMessage(err)}`, + ResultCodes.PAM_GATEWAY_EDIT_FAILED + ) + } + + const gateway = findEnterpriseGatewayByUidOrName(controllers, gatewayUidOrName) + if (!gateway?.controllerUid?.length) { + throw new KeeperSdkError(`Gateway "${gatewayUidOrName}" not found.`, ResultCodes.PAM_GATEWAY_NOT_FOUND) + } + + const gatewayUid = webSafeUidFromBytes(gateway.controllerUid) + const previousName = gateway.controllerName || '' + const previousNodeId = toFiniteNumber(gateway.nodeId) + const gatewayName = hasName ? newNameRaw : previousName + const nodeId = hasNode ? await resolveEnterpriseNodeId(auth, input.nodeIdOrName as string | number) : previousNodeId + + const nameChanged = gatewayName !== previousName + const nodeChanged = nodeId !== previousNodeId + if (!nameChanged && !nodeChanged) { + return buildEditResult({ + gatewayUid, + previousName, + gatewayName, + previousNodeId, + nodeId, + nameChanged: false, + nodeChanged: false, + unchanged: true, + }) + } + + try { + await auth.executeRestAction( + modifyControllerMessage({ + controllerUid: normal64Bytes(gatewayUid), + controllerName: gatewayName, + nodeId, + }) + ) + } catch (err) { + throw new KeeperSdkError( + `Failed to edit gateway: ${extractErrorMessage(err)}`, + ResultCodes.PAM_GATEWAY_EDIT_FAILED + ) + } + + return buildEditResult({ + gatewayUid, + previousName, + gatewayName, + previousNodeId, + nodeId, + nameChanged, + nodeChanged, + }) +} + +export function formatEditGatewayOutput(result: EditGatewayResult): string { + return [ + result.message, + result.nameChanged + ? `Name: ${result.previousName || '(none)'} → ${result.gatewayName}` + : `Name: ${result.gatewayName}`, + result.nodeChanged ? `Node ID: ${result.previousNodeId} → ${result.nodeId}` : `Node ID: ${result.nodeId}`, + ].join('\n') +} diff --git a/KeeperSdk/src/pam/gateway/gatewayConstants.ts b/KeeperSdk/src/pam/gateway/gatewayConstants.ts new file mode 100644 index 0000000..8860cf7 --- /dev/null +++ b/KeeperSdk/src/pam/gateway/gatewayConstants.ts @@ -0,0 +1,41 @@ +export const KSM_APP_RECORD_VERSION = 5 + +export const APP_NOT_ACCESSIBLE_LABEL = '[APP NOT ACCESSIBLE OR DELETED]' as const + +export const KSM_CLIENT_ID_MESSAGE = 'KEEPER_SECRETS_MANAGER_CLIENT_ID' as const + +export const DEFAULT_GATEWAY_TOKEN_EXPIRES_IN_MIN = 60 +export const MAX_GATEWAY_TOKEN_EXPIRES_IN_MIN = 1440 + +export const EMPTY_GATEWAYS_MESSAGE = + 'This Enterprise does not have Gateways yet. To create a new Gateway, use `pam gateway new`. NOTE: If you have added a new Gateway, you might still need to initialize it before it is listed.' as const + +export const GATEWAY_LIST_DEFAULT_HEADERS = [ + 'KSM Application Name (UID)', + 'Gateway Name', + 'Gateway UID', + 'Status', + 'Gateway Version', +] as const + +export const GATEWAY_LIST_VERBOSE_HEADERS = [ + 'Device Name', + 'Device Token', + 'Created On', + 'Last Modified', + 'Node ID', + 'OS', + 'OS Release', + 'Machine Type', + 'OS Version', +] as const + +export const ROUTER_CONNECTION_ERROR_CODES = [ + 'ECONNREFUSED', + 'ENOTFOUND', + 'ETIMEDOUT', + 'ECONNRESET', + 'ENETUNREACH', +] as const + +export type RouterConnectionErrorCode = (typeof ROUTER_CONNECTION_ERROR_CODES)[number] diff --git a/KeeperSdk/src/pam/gateway/gatewayHelpers.ts b/KeeperSdk/src/pam/gateway/gatewayHelpers.ts new file mode 100644 index 0000000..98165bc --- /dev/null +++ b/KeeperSdk/src/pam/gateway/gatewayHelpers.ts @@ -0,0 +1,242 @@ +import type { DRecord, PAM } from '@keeper-security/keeperapi' +import { getKeeperRouterUrl, webSafe64FromBytes } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { VaultObjectKind } from '../../folders/folderHelpers' +import { getRecordTitle } from '../../records/RecordUtils' +import { KEEPER_PUBLIC_HOSTS, KeeperSdkError, ResultCodes } from '../../utils' +import { + APP_NOT_ACCESSIBLE_LABEL, + KSM_APP_RECORD_VERSION, + ROUTER_CONNECTION_ERROR_CODES, + type RouterConnectionErrorCode, +} from './gatewayConstants' +import type { GatewayVersionParts, KsmApplicationDisplayInfo, ResolvedKsmApplication } from './gatewayTypes' + +type NetworkErrorLike = { + code?: string + errno?: string + message?: string + cause?: { code?: string } +} + +export function getKeeperRouterBaseUrl(host: string): string { + return getKeeperRouterUrl(host, '').replace(/\/$/, '') +} + +export function webSafeUidFromBytes(bytes: Uint8Array | null | undefined): string { + if (!bytes || bytes.length === 0) return '' + return webSafe64FromBytes(bytes) +} + +export function toFiniteNumber(value: unknown): number { + if (value == null) return 0 + if (typeof value === 'number') return Number.isFinite(value) ? value : 0 + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed : 0 +} + +export function formatTimestampMs(value: unknown): string { + const ms = toFiniteNumber(value) + if (!ms) return '' + const date = new Date(ms) + if (Number.isNaN(date.getTime())) return '' + const pad = (n: number): string => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad( + date.getMinutes() + )}:${pad(date.getSeconds())}` +} + +export function parseGatewayVersionString(version: string | null | undefined): GatewayVersionParts { + if (!version) { + return { gatewayVersion: '', os: '', osRelease: '', machineType: '', osVersion: '' } + } + const parts = version.split(';') + return { + gatewayVersion: parts[0] || version, + os: parts[1] || '', + osRelease: parts[2] || '', + machineType: parts[3] || '', + osVersion: parts[4] || '', + } +} + +function isKsmApplicationRecord(record: DRecord): boolean { + if (record.version !== KSM_APP_RECORD_VERSION) return false + const data: unknown = record.data + if (!data || typeof data !== 'object') return false + return (data as { type?: unknown }).type === 'app' +} + +export function getKsmApplicationDisplayInfo( + storage: InMemoryStorage, + applicationUid: string +): KsmApplicationDisplayInfo { + if (!applicationUid) { + return { + name: null, + accessible: false, + display: `${APP_NOT_ACCESSIBLE_LABEL} ()`, + } + } + + const byUid = storage.getByUid(VaultObjectKind.Record, applicationUid) + if (byUid) { + const title = getRecordTitle(byUid) + const name = title && title !== '(untitled)' && title !== '(no data)' ? title : applicationUid + return { + name, + accessible: true, + display: `${name} (${applicationUid})`, + } + } + + return { + name: null, + accessible: false, + display: `${APP_NOT_ACCESSIBLE_LABEL} (${applicationUid})`, + } +} + +async function requireRecordKey(storage: InMemoryStorage, record: DRecord, label: string): Promise { + const recordKey = await storage.getKeyBytes(record.uid) + if (!recordKey) { + throw new KeeperSdkError( + `KSM application "${label}" key is not available. Sync the vault and try again.`, + ResultCodes.PAM_KSM_APP_NOT_FOUND + ) + } + return recordKey +} + +export async function resolveKsmApplication( + storage: InMemoryStorage, + applicationNameOrUid: string +): Promise { + const trimmed = applicationNameOrUid.trim() + if (!trimmed) { + throw new KeeperSdkError('KSM application name or UID is required.', ResultCodes.PAM_KSM_APP_REQUIRED) + } + + const byUid = storage.getByUid(VaultObjectKind.Record, trimmed) + if (byUid && isKsmApplicationRecord(byUid)) { + return { + uid: byUid.uid, + title: getRecordTitle(byUid), + record: byUid, + recordKey: await requireRecordKey(storage, byUid, trimmed), + } + } + + const lower = trimmed.toLowerCase() + const matches = storage + .getRecords() + .filter((record) => isKsmApplicationRecord(record) && getRecordTitle(record).toLowerCase() === lower) + + if (matches.length === 0) { + throw new KeeperSdkError( + `KSM Application "${trimmed}" not found. Run sync and verify the application exists in your vault.`, + ResultCodes.PAM_KSM_APP_NOT_FOUND + ) + } + if (matches.length > 1) { + throw new KeeperSdkError( + `Multiple KSM applications named "${trimmed}". Use the application UID instead.`, + ResultCodes.PAM_MULTIPLE_KSM_APP_MATCHES + ) + } + + const record = matches[0] + return { + uid: record.uid, + title: getRecordTitle(record), + record, + recordKey: await requireRecordKey(storage, record, trimmed), + } +} + +export function getKeeperRegionAbbreviation(host: string): string | null { + let normalized = host.trim().toLowerCase() + if (normalized.startsWith('http://') || normalized.startsWith('https://')) { + try { + normalized = new URL(normalized).hostname.toLowerCase() + } catch { + /* keep as-is */ + } + } + for (const [abbrev, publicHost] of Object.entries(KEEPER_PUBLIC_HOSTS)) { + if (publicHost.toLowerCase() === normalized) return abbrev + } + return null +} + +export function formatGatewayOneTimeToken(host: string, secretBytes: Uint8Array): string { + const token = webSafe64FromBytes(secretBytes) + const abbrev = getKeeperRegionAbbreviation(host) + if (abbrev) return `${abbrev}:${token}` + const bareHost = host + .replace(/^https?:\/\//i, '') + .split('/')[0] + .toLowerCase() + return `${bareHost}:${token}` +} + +export function findEnterpriseGatewayByUidOrName( + controllers: readonly PAM.IPAMController[], + gatewayUidOrName: string +): PAM.IPAMController | undefined { + const trimmed = gatewayUidOrName.trim() + if (!trimmed) return undefined + const lowered = trimmed.toLowerCase() + return controllers.find((controller) => { + const uid = webSafeUidFromBytes(controller.controllerUid) + if (uid === trimmed) return true + return (controller.controllerName || '').toLowerCase() === lowered + }) +} + +export function groupOnlineGatewaysByControllerUid( + controllers: readonly PAM.IPAMOnlineController[] +): Map { + const map = new Map() + for (const controller of controllers) { + const uid = webSafeUidFromBytes(controller.controllerUid) + if (!uid) continue + const list = map.get(uid) + if (list) list.push(controller) + else map.set(uid, [controller]) + } + return map +} + +function isRouterConnectionErrorCode(code: string | undefined): code is RouterConnectionErrorCode { + return !!code && (ROUTER_CONNECTION_ERROR_CODES as readonly string[]).includes(code) +} + +export function isKeeperRouterConnectionError(err: unknown): boolean { + if (err == null) return false + + let code: string | undefined + let message: string | undefined + if (typeof err === 'string') { + message = err + } else if (typeof err === 'object') { + const e = err as NetworkErrorLike + code = e.code || e.errno || e.cause?.code + message = typeof e.message === 'string' ? e.message : undefined + } else { + return false + } + + if (isRouterConnectionErrorCode(code)) return true + if (!message) return false + + const msg = message.toLowerCase() + return ( + msg.includes('econnrefused') || + msg.includes('enotfound') || + msg.includes('etimedout') || + msg.includes('network') || + msg.includes('fetch failed') || + msg.includes('socket hang up') + ) +} diff --git a/KeeperSdk/src/pam/gateway/gatewayTypes.ts b/KeeperSdk/src/pam/gateway/gatewayTypes.ts new file mode 100644 index 0000000..beec60a --- /dev/null +++ b/KeeperSdk/src/pam/gateway/gatewayTypes.ts @@ -0,0 +1,195 @@ +import type { DRecord } from '@keeper-security/keeperapi' + +export enum GatewayListFormat { + Table = 'table', + Json = 'json', +} + +export type GatewayListFormatInput = GatewayListFormat | `${GatewayListFormat}` + +export enum GatewayStatus { + Online = 'ONLINE', + Offline = 'OFFLINE', + Unknown = 'UNKNOWN', +} + +/** Connectivity label; pool gateways use values like `ONLINE (2 instances)`. */ +export type GatewayConnectivityStatus = GatewayStatus | `${typeof GatewayStatus.Online} (${number} instances)` + +export enum GatewayConfigInitFormat { + Json = 'json', + B64 = 'b64', +} + +export type GatewayConfigInitFormatInput = GatewayConfigInitFormat | `${GatewayConfigInitFormat}` + +export type ListGatewaysOptions = { + force?: boolean + verbose?: boolean + format?: GatewayListFormatInput + onlineOnly?: boolean +} + +export type GatewayCounts = { + online: number + offline: number + total: number +} + +export type GatewayOsMetadata = { + os: string + osRelease: string + machineType: string + osVersion: string +} + +export type GatewayVersionParts = GatewayOsMetadata & { + gatewayVersion: string +} + +export type GatewayPoolInstance = GatewayOsMetadata & { + instanceNumber: number + status: typeof GatewayStatus.Online + gatewayVersion: string + ipAddress: string + connectedOn?: number + connectedOnDisplay?: string +} + +/** One list row; pool instance rows set `isPoolInstanceRow: true`. */ +export type GatewayListRow = { + ksmApplicationName: string | null + ksmApplicationUid: string + ksmApplicationAccessible: boolean + ksmApplicationDisplay: string + gatewayName: string + gatewayUid: string + status: GatewayConnectivityStatus | string + gatewayVersion: string + deviceName?: string + deviceToken?: string + createdOn?: string + lastModified?: string + nodeId?: number + os?: string + osRelease?: string + machineType?: string + osVersion?: string + poolInstances?: GatewayPoolInstance[] + isPoolInstanceRow?: boolean + poolInstanceConnectedOnDisplay?: string +} + +export type ListGatewaysResult = { + gateways: GatewayListRow[] + routerDown: boolean + routerHost: string + gatewayCounts: GatewayCounts + aborted: boolean + message?: string +} + +export type FormattedGatewaysTable = { + headers: string[] + rows: string[][] +} + +export type FormatGatewaysTableOptions = { + verbose?: boolean +} + +export type RenderGatewaysAsciiTableOptions = { + minColWidth?: number +} + +export type KsmApplicationDisplayInfo = { + name: string | null + accessible: boolean + display: string +} + +export type ResolvedKsmApplication = { + uid: string + title: string + record: DRecord + recordKey: Uint8Array +} + +export type CreateGatewayInput = { + name: string + application: string + tokenExpiresInMin?: number + configInit?: GatewayConfigInitFormatInput + returnValue?: boolean +} + +export type CreateGatewayResult = { + success: boolean + gatewayName: string + applicationUid: string + applicationTitle: string | null + tokenOrConfig: string + isInitializedConfig: boolean + configInit?: GatewayConfigInitFormat + tokenExpiresInMin: number + tokenExpiresOn: string + deviceToken?: string + message: string +} + +export type EditGatewayInput = { + gatewayUidOrName: string + name?: string | null + nodeIdOrName?: string | number | null +} + +export type EditGatewayResult = { + success: boolean + gatewayUid: string + previousName: string + gatewayName: string + previousNodeId: number + nodeId: number + nameChanged: boolean + nodeChanged: boolean + message: string +} + +export type GatewayJsonPoolInstance = { + instance_number: number + status: typeof GatewayStatus.Online + gateway_version: string + ip_address: string + connected_on?: number + os?: string + os_release?: string + machine_type?: string + os_version?: string +} + +export type GatewayJsonEntry = { + ksm_app_name: string | null + ksm_app_uid: string + ksm_app_accessible: boolean + gateway_name: string + gateway_uid: string + status: string + gateway_version?: string + instances?: GatewayJsonPoolInstance[] + device_name?: string + device_token?: string + created_on?: string + last_modified?: string + node_id?: number + os?: string + os_release?: string + machine_type?: string + os_version?: string +} + +export type GatewaysJsonPayload = { + gateways: GatewayJsonEntry[] + router_host?: string + gateway_counts?: GatewayCounts + message?: string +} diff --git a/KeeperSdk/src/pam/gateway/index.ts b/KeeperSdk/src/pam/gateway/index.ts new file mode 100644 index 0000000..dc71b9a --- /dev/null +++ b/KeeperSdk/src/pam/gateway/index.ts @@ -0,0 +1,65 @@ +export { GatewayManager } from './GatewayManager' +export type { AuthProvider } from './GatewayManager' + +export { + listGateways, + formatGatewaysTable, + renderGatewaysAsciiTable, + formatGatewaysJson, + formatGatewaysOutput, +} from './listGateways' + +export { createGateway, formatCreateGatewayOutput } from './createGateway' +export { editGateway, formatEditGatewayOutput } from './editGateway' + +export { GatewayListFormat, GatewayStatus, GatewayConfigInitFormat } from './gatewayTypes' +export type { + GatewayListFormatInput, + GatewayConfigInitFormatInput, + GatewayConnectivityStatus, + ListGatewaysOptions, + GatewayCounts, + GatewayOsMetadata, + GatewayVersionParts, + GatewayPoolInstance, + GatewayListRow, + ListGatewaysResult, + FormattedGatewaysTable, + FormatGatewaysTableOptions, + RenderGatewaysAsciiTableOptions, + KsmApplicationDisplayInfo, + ResolvedKsmApplication, + CreateGatewayInput, + CreateGatewayResult, + EditGatewayInput, + EditGatewayResult, + GatewayJsonPoolInstance, + GatewayJsonEntry, + GatewaysJsonPayload, +} from './gatewayTypes' + +export { + KSM_APP_RECORD_VERSION, + APP_NOT_ACCESSIBLE_LABEL, + KSM_CLIENT_ID_MESSAGE, + DEFAULT_GATEWAY_TOKEN_EXPIRES_IN_MIN, + MAX_GATEWAY_TOKEN_EXPIRES_IN_MIN, + EMPTY_GATEWAYS_MESSAGE, + GATEWAY_LIST_DEFAULT_HEADERS, + GATEWAY_LIST_VERBOSE_HEADERS, +} from './gatewayConstants' + +export { + getKeeperRouterBaseUrl, + webSafeUidFromBytes, + toFiniteNumber, + formatTimestampMs, + parseGatewayVersionString, + getKsmApplicationDisplayInfo, + resolveKsmApplication, + getKeeperRegionAbbreviation, + formatGatewayOneTimeToken, + findEnterpriseGatewayByUidOrName, + groupOnlineGatewaysByControllerUid, + isKeeperRouterConnectionError, +} from './gatewayHelpers' diff --git a/KeeperSdk/src/pam/gateway/listGateways.ts b/KeeperSdk/src/pam/gateway/listGateways.ts new file mode 100644 index 0000000..11868b5 --- /dev/null +++ b/KeeperSdk/src/pam/gateway/listGateways.ts @@ -0,0 +1,448 @@ +import type { Auth, PAM } from '@keeper-security/keeperapi' +import { getControllers, pamGetOnlineControllersMessage } from '@keeper-security/keeperapi' +import type { InMemoryStorage } from '../../storage/InMemoryStorage' +import { extractErrorMessage, KeeperSdkError, ResultCodes } from '../../utils' +import { EMPTY_GATEWAYS_MESSAGE, GATEWAY_LIST_DEFAULT_HEADERS, GATEWAY_LIST_VERBOSE_HEADERS } from './gatewayConstants' +import { + formatTimestampMs, + getKeeperRouterBaseUrl, + getKsmApplicationDisplayInfo, + groupOnlineGatewaysByControllerUid, + isKeeperRouterConnectionError, + parseGatewayVersionString, + toFiniteNumber, + webSafeUidFromBytes, +} from './gatewayHelpers' +import { + GatewayListFormat, + GatewayStatus, + type FormatGatewaysTableOptions, + type FormattedGatewaysTable, + type GatewayCounts, + type GatewayJsonEntry, + type GatewayJsonPoolInstance, + type GatewayListRow, + type GatewayPoolInstance, + type GatewayVersionParts, + type GatewaysJsonPayload, + type KsmApplicationDisplayInfo, + type ListGatewaysOptions, + type ListGatewaysResult, + type RenderGatewaysAsciiTableOptions, +} from './gatewayTypes' + +const EMPTY_VERSION_PARTS = parseGatewayVersionString(undefined) +const EMPTY_GATEWAY_COUNTS: GatewayCounts = { online: 0, offline: 0, total: 0 } + +type ControllerListFields = { + controllerName: string + deviceName?: string + deviceToken?: string + created: number | null + lastModified: number | null + nodeId: number | null +} + +function emptyListResult( + partial: Pick & { + gatewayCounts?: GatewayCounts + } +): ListGatewaysResult { + return { + gateways: [], + gatewayCounts: partial.gatewayCounts ?? EMPTY_GATEWAY_COUNTS, + routerDown: partial.routerDown, + routerHost: partial.routerHost, + aborted: partial.aborted, + message: partial.message, + } +} + +function controllerListFields(controller: PAM.IPAMController): ControllerListFields { + const nodeIdRaw = controller.nodeId == null ? null : toFiniteNumber(controller.nodeId) + return { + controllerName: controller.controllerName || '', + deviceName: controller.deviceName || undefined, + deviceToken: controller.deviceToken || undefined, + created: toFiniteNumber(controller.created) || null, + lastModified: toFiniteNumber(controller.lastModified) || null, + nodeId: nodeIdRaw || null, + } +} + +async function loadOnlineControllers( + auth: Auth, + force: boolean, + routerHost: string +): Promise<{ controllers: PAM.IPAMOnlineController[]; routerDown: boolean; abort?: ListGatewaysResult }> { + try { + const response = await auth.executeRouterRest(pamGetOnlineControllersMessage()) + return { controllers: response.controllers ?? [], routerDown: false } + } catch (err) { + if (!isKeeperRouterConnectionError(err)) { + throw new KeeperSdkError( + `Unhandled error during retrieval of connected gateways: ${extractErrorMessage(err)}`, + ResultCodes.PAM_GATEWAY_LIST_FAILED + ) + } + if (!force) { + return { + controllers: [], + routerDown: true, + abort: emptyListResult({ + routerDown: true, + routerHost, + aborted: true, + message: `Looks like router is down. Use force (-f) to retrieve gateways associated with your enterprise. Router URL [${routerHost}]`, + }), + } + } + return { controllers: [], routerDown: true } + } +} + +async function loadEnterpriseControllers(auth: Auth): Promise { + try { + const response = await auth.executeRest(getControllers()) + return response.controllers ?? [] + } catch (err) { + throw new KeeperSdkError( + `Failed to list enterprise gateways: ${extractErrorMessage(err)}`, + ResultCodes.PAM_GATEWAY_LIST_FAILED + ) + } +} + +function resolveConnectivityStatus(routerDown: boolean, connectedCount: number): GatewayListRow['status'] { + if (routerDown) return GatewayStatus.Unknown + if (connectedCount === 0) return GatewayStatus.Offline + if (connectedCount === 1) return GatewayStatus.Online + return `${GatewayStatus.Online} (${connectedCount} instances)` +} + +function toPoolInstances(connectedInstances: PAM.IPAMOnlineController[]): GatewayPoolInstance[] { + return connectedInstances.map((instance, index) => { + const versionParts = parseGatewayVersionString(instance.version) + const connectedOn = toFiniteNumber(instance.connectedOn) + return { + instanceNumber: index + 1, + status: GatewayStatus.Online, + gatewayVersion: versionParts.gatewayVersion, + ipAddress: instance.ipAddress || '', + connectedOn: connectedOn || undefined, + connectedOnDisplay: connectedOn ? formatTimestampMs(connectedOn) : '', + os: versionParts.os || undefined, + osRelease: versionParts.osRelease || undefined, + machineType: versionParts.machineType || undefined, + osVersion: versionParts.osVersion || undefined, + } + }) +} + +function toPoolInstanceRows(poolInstances: GatewayPoolInstance[]): GatewayListRow[] { + return poolInstances.map((instance) => ({ + ksmApplicationName: null, + ksmApplicationUid: '', + ksmApplicationAccessible: false, + ksmApplicationDisplay: '', + gatewayName: `|- Instance ${instance.instanceNumber} (connected: ${instance.connectedOnDisplay || ''})`, + gatewayUid: instance.ipAddress, + status: GatewayStatus.Online, + gatewayVersion: instance.gatewayVersion, + os: instance.os, + osRelease: instance.osRelease, + machineType: instance.machineType, + osVersion: instance.osVersion, + isPoolInstanceRow: true, + poolInstanceConnectedOnDisplay: instance.connectedOnDisplay, + })) +} + +function buildGatewayListRow(args: { + fields: ControllerListFields + gatewayUid: string + ksmApplicationUid: string + ksmApplication: KsmApplicationDisplayInfo + status: GatewayListRow['status'] + gatewayVersion: string + verbose: boolean + versionParts: GatewayVersionParts +}): GatewayListRow { + const { fields, ksmApplication, verbose, versionParts } = args + const row: GatewayListRow = { + ksmApplicationName: ksmApplication.name, + ksmApplicationUid: args.ksmApplicationUid, + ksmApplicationAccessible: ksmApplication.accessible, + ksmApplicationDisplay: ksmApplication.display, + gatewayName: fields.controllerName, + gatewayUid: args.gatewayUid, + status: args.status, + gatewayVersion: args.gatewayVersion, + } + + if (verbose) { + row.deviceName = fields.deviceName || '' + row.deviceToken = fields.deviceToken || '' + row.createdOn = formatTimestampMs(fields.created) + row.lastModified = formatTimestampMs(fields.lastModified) + row.nodeId = fields.nodeId == null ? undefined : fields.nodeId + row.os = versionParts.os || undefined + row.osRelease = versionParts.osRelease || undefined + row.machineType = versionParts.machineType || undefined + row.osVersion = versionParts.osVersion || undefined + } + + return row +} + +function sortGatewayListRows(gateways: GatewayListRow[]): GatewayListRow[] { + const groups: GatewayListRow[][] = [] + let current: GatewayListRow[] = [] + + for (const row of gateways) { + if (!row.isPoolInstanceRow) { + if (current.length) groups.push(current) + current = [row] + } else { + current.push(row) + } + } + if (current.length) groups.push(current) + + groups.sort((a, b) => { + const statusCmp = (a[0].status || '').localeCompare(b[0].status || '') + if (statusCmp !== 0) return statusCmp + return (a[0].ksmApplicationDisplay || '') + .toLowerCase() + .localeCompare((b[0].ksmApplicationDisplay || '').toLowerCase()) + }) + + return groups.flat() +} + +function appendOsFields( + target: { os?: string; os_release?: string; machine_type?: string; os_version?: string }, + source: { os?: string; osRelease?: string; machineType?: string; osVersion?: string } +): void { + target.os = source.os || '' + target.os_release = source.osRelease || '' + target.machine_type = source.machineType || '' + target.os_version = source.osVersion || '' +} + +export async function listGateways( + auth: Auth, + storage: InMemoryStorage, + options: ListGatewaysOptions = {} +): Promise { + const force = options.force === true + const verbose = options.verbose === true + const onlineOnly = options.onlineOnly === true + const routerHost = getKeeperRouterBaseUrl(String(auth.options.host || '')) + + const online = await loadOnlineControllers(auth, force, routerHost) + if (online.abort) return online.abort + + const enterpriseControllers = await loadEnterpriseControllers(auth) + if (!enterpriseControllers.length) { + return emptyListResult({ + routerDown: online.routerDown, + routerHost, + aborted: false, + message: EMPTY_GATEWAYS_MESSAGE, + }) + } + + const connectedByUid = groupOnlineGatewaysByControllerUid(online.controllers) + const gatewayCounts: GatewayCounts = { + online: 0, + offline: 0, + total: enterpriseControllers.length, + } + const gateways: GatewayListRow[] = [] + + for (const controller of enterpriseControllers) { + const gatewayUid = webSafeUidFromBytes(controller.controllerUid) + const connectedInstances = connectedByUid.get(gatewayUid) ?? [] + const isOnline = !online.routerDown && connectedInstances.length > 0 + + if (!online.routerDown) { + if (isOnline) gatewayCounts.online += 1 + else gatewayCounts.offline += 1 + } + + if (onlineOnly && !isOnline) continue + + const ksmApplicationUid = webSafeUidFromBytes(controller.applicationUid) + const ksmApplication = getKsmApplicationDisplayInfo(storage, ksmApplicationUid) + const fields = controllerListFields(controller) + const status = resolveConnectivityStatus(online.routerDown, connectedInstances.length) + const isPool = connectedInstances.length > 1 + + if (!isPool) { + const versionParts = parseGatewayVersionString(connectedInstances[0]?.version) + gateways.push( + buildGatewayListRow({ + fields, + gatewayUid, + ksmApplicationUid, + ksmApplication, + status, + gatewayVersion: versionParts.gatewayVersion, + verbose, + versionParts, + }) + ) + continue + } + + const poolInstances = toPoolInstances(connectedInstances) + const parent = buildGatewayListRow({ + fields, + gatewayUid, + ksmApplicationUid, + ksmApplication, + status, + gatewayVersion: '', + verbose, + versionParts: EMPTY_VERSION_PARTS, + }) + parent.poolInstances = poolInstances + gateways.push(parent, ...toPoolInstanceRows(poolInstances)) + } + + return { + gateways: sortGatewayListRows(gateways), + routerDown: online.routerDown, + routerHost, + gatewayCounts, + aborted: false, + } +} + +export function formatGatewaysTable( + result: ListGatewaysResult, + options: FormatGatewaysTableOptions = {} +): FormattedGatewaysTable { + const verbose = options.verbose === true + const headers: string[] = [...GATEWAY_LIST_DEFAULT_HEADERS] + if (verbose) headers.push(...GATEWAY_LIST_VERBOSE_HEADERS) + + const rows = result.gateways.map((gateway) => { + const isInstance = gateway.isPoolInstanceRow === true + const row: string[] = [ + isInstance ? '' : gateway.ksmApplicationDisplay, + gateway.gatewayName, + gateway.gatewayUid, + gateway.status, + gateway.gatewayVersion, + ] + if (verbose) { + row.push( + isInstance ? '' : gateway.deviceName || '', + isInstance ? '' : gateway.deviceToken || '', + isInstance ? gateway.poolInstanceConnectedOnDisplay || '' : gateway.createdOn || '', + isInstance ? '' : gateway.lastModified || '', + isInstance ? '' : gateway.nodeId != null ? String(gateway.nodeId) : '', + gateway.os || '', + gateway.osRelease || '', + gateway.machineType || '', + gateway.osVersion || '' + ) + } + return row + }) + + return { headers, rows } +} + +export function renderGatewaysAsciiTable( + table: FormattedGatewaysTable, + options: RenderGatewaysAsciiTableOptions = {} +): string { + const minColWidth = options.minColWidth ?? 2 + const widths = table.headers.map((header, col) => { + let width = Math.max(header.length, minColWidth) + for (const row of table.rows) { + width = Math.max(width, (row[col] || '').length) + } + return width + }) + + const formatRow = (cells: string[]): string => cells.map((cell, i) => (cell || '').padEnd(widths[i])).join(' ') + + return [ + formatRow([...table.headers]), + widths.map((w) => '-'.repeat(w)).join(' '), + ...table.rows.map(formatRow), + ].join('\n') +} + +export function formatGatewaysJson(result: ListGatewaysResult, options: ListGatewaysOptions = {}): string { + const verbose = options.verbose === true + const onlineOnly = options.onlineOnly === true + + const gateways: GatewayJsonEntry[] = result.gateways + .filter((g) => !g.isPoolInstanceRow) + .map((g) => { + const entry: GatewayJsonEntry = { + ksm_app_name: g.ksmApplicationName, + ksm_app_uid: g.ksmApplicationUid, + ksm_app_accessible: g.ksmApplicationAccessible, + gateway_name: g.gatewayName, + gateway_uid: g.gatewayUid, + status: g.status, + } + + if (g.poolInstances?.length) { + entry.instances = g.poolInstances.map((instance): GatewayJsonPoolInstance => { + const inst: GatewayJsonPoolInstance = { + instance_number: instance.instanceNumber, + status: instance.status, + gateway_version: instance.gatewayVersion, + ip_address: instance.ipAddress, + connected_on: instance.connectedOn, + } + if (verbose) appendOsFields(inst, instance) + return inst + }) + } else { + entry.gateway_version = g.gatewayVersion + } + + if (verbose) { + entry.device_name = g.deviceName || '' + entry.device_token = g.deviceToken || '' + entry.created_on = g.createdOn || '' + entry.last_modified = g.lastModified || '' + entry.node_id = g.nodeId + if (!g.poolInstances?.length) appendOsFields(entry, g) + } + return entry + }) + + const payload: GatewaysJsonPayload = { gateways } + if (verbose) payload.router_host = result.routerHost + if (onlineOnly) payload.gateway_counts = result.gatewayCounts + if (result.message) payload.message = result.message + + return JSON.stringify(payload, null, 2) +} + +export function formatGatewaysOutput(result: ListGatewaysResult, options: ListGatewaysOptions = {}): string { + const format = String(options.format || GatewayListFormat.Table).toLowerCase() + if (format === GatewayListFormat.Json) return formatGatewaysJson(result, options) + + const parts: string[] = [] + if (options.verbose) parts.push(`Router Host: ${result.routerHost}`, '') + if (result.message && result.gateways.length === 0) { + parts.push(result.message) + return parts.join('\n') + } + parts.push(renderGatewaysAsciiTable(formatGatewaysTable(result, { verbose: options.verbose }))) + if (options.onlineOnly) { + const { online, offline, total } = result.gatewayCounts + parts.push('', `Gateways: Online: ${online}, Offline: ${offline}, Total: ${total}`) + } + return parts.join('\n') +} diff --git a/KeeperSdk/src/pam/index.ts b/KeeperSdk/src/pam/index.ts new file mode 100644 index 0000000..f6adf65 --- /dev/null +++ b/KeeperSdk/src/pam/index.ts @@ -0,0 +1,63 @@ +export { PamManager } from './PamManager' +export type { AuthProvider as PamAuthProvider } from './PamManager' + +export { + GatewayManager, + listGateways, + formatGatewaysTable, + renderGatewaysAsciiTable, + formatGatewaysJson, + formatGatewaysOutput, + createGateway, + formatCreateGatewayOutput, + editGateway, + formatEditGatewayOutput, + GatewayListFormat, + GatewayStatus, + GatewayConfigInitFormat, + KSM_APP_RECORD_VERSION, + APP_NOT_ACCESSIBLE_LABEL, + KSM_CLIENT_ID_MESSAGE, + DEFAULT_GATEWAY_TOKEN_EXPIRES_IN_MIN, + MAX_GATEWAY_TOKEN_EXPIRES_IN_MIN, + EMPTY_GATEWAYS_MESSAGE, + GATEWAY_LIST_DEFAULT_HEADERS, + GATEWAY_LIST_VERBOSE_HEADERS, + getKeeperRouterBaseUrl, + webSafeUidFromBytes, + toFiniteNumber, + formatTimestampMs, + parseGatewayVersionString, + getKsmApplicationDisplayInfo, + resolveKsmApplication, + getKeeperRegionAbbreviation, + formatGatewayOneTimeToken, + findEnterpriseGatewayByUidOrName, + groupOnlineGatewaysByControllerUid, + isKeeperRouterConnectionError, +} from './gateway' +export type { + AuthProvider as GatewayAuthProvider, + GatewayListFormatInput, + GatewayConfigInitFormatInput, + GatewayConnectivityStatus, + ListGatewaysOptions, + GatewayCounts, + GatewayOsMetadata, + GatewayVersionParts, + GatewayPoolInstance, + GatewayListRow, + ListGatewaysResult, + FormattedGatewaysTable, + FormatGatewaysTableOptions, + RenderGatewaysAsciiTableOptions, + KsmApplicationDisplayInfo, + ResolvedKsmApplication, + CreateGatewayInput, + CreateGatewayResult, + EditGatewayInput, + EditGatewayResult, + GatewayJsonPoolInstance, + GatewayJsonEntry, + GatewaysJsonPayload, +} from './gateway' diff --git a/KeeperSdk/src/utils/constants.ts b/KeeperSdk/src/utils/constants.ts index 524b6f0..2aeffe2 100644 --- a/KeeperSdk/src/utils/constants.ts +++ b/KeeperSdk/src/utils/constants.ts @@ -142,6 +142,25 @@ export enum UserErrorCode { TeamUserRemoveFailed = 'team_user_remove_failed', } +export enum PamErrorCode { + RouterUnavailable = 'pam_router_unavailable', + GatewayListFailed = 'pam_gateway_list_failed', + GatewayCreateFailed = 'pam_gateway_create_failed', + GatewayNameRequired = 'pam_gateway_name_required', + KsmAppRequired = 'pam_ksm_app_required', + KsmAppNotFound = 'pam_ksm_app_not_found', + MultipleKsmAppMatches = 'pam_multiple_ksm_app_matches', + InvalidTokenExpiry = 'pam_invalid_token_expiry', + ConfigInitFailed = 'pam_config_init_failed', + ConfigInitUnavailable = 'pam_config_init_unavailable', + GatewayRequired = 'pam_gateway_required', + GatewayNotFound = 'pam_gateway_not_found', + GatewayEditNothingToDo = 'pam_gateway_edit_nothing_to_do', + GatewayEditFailed = 'pam_gateway_edit_failed', + GatewayNodeNotFound = 'pam_gateway_node_not_found', + MultipleGatewayNodeMatches = 'pam_multiple_gateway_node_matches', +} + export const ResultCodes = { INVALID_CREDENTIALS: AuthErrorCode.InvalidCredentials, MISSING_USERNAME: AuthErrorCode.MissingUsername, @@ -221,6 +240,22 @@ export const ResultCodes = { NO_TEAMS_FOR_USER_OP: UserErrorCode.NoTeamsForUserOp, TEAM_USER_ADD_FAILED: UserErrorCode.TeamUserAddFailed, TEAM_USER_REMOVE_FAILED: UserErrorCode.TeamUserRemoveFailed, + PAM_ROUTER_UNAVAILABLE: PamErrorCode.RouterUnavailable, + PAM_GATEWAY_LIST_FAILED: PamErrorCode.GatewayListFailed, + PAM_GATEWAY_CREATE_FAILED: PamErrorCode.GatewayCreateFailed, + PAM_GATEWAY_NAME_REQUIRED: PamErrorCode.GatewayNameRequired, + PAM_KSM_APP_REQUIRED: PamErrorCode.KsmAppRequired, + PAM_KSM_APP_NOT_FOUND: PamErrorCode.KsmAppNotFound, + PAM_MULTIPLE_KSM_APP_MATCHES: PamErrorCode.MultipleKsmAppMatches, + PAM_INVALID_TOKEN_EXPIRY: PamErrorCode.InvalidTokenExpiry, + PAM_CONFIG_INIT_FAILED: PamErrorCode.ConfigInitFailed, + PAM_CONFIG_INIT_UNAVAILABLE: PamErrorCode.ConfigInitUnavailable, + PAM_GATEWAY_REQUIRED: PamErrorCode.GatewayRequired, + PAM_GATEWAY_NOT_FOUND: PamErrorCode.GatewayNotFound, + PAM_GATEWAY_EDIT_NOTHING_TO_DO: PamErrorCode.GatewayEditNothingToDo, + PAM_GATEWAY_EDIT_FAILED: PamErrorCode.GatewayEditFailed, + PAM_GATEWAY_NODE_NOT_FOUND: PamErrorCode.GatewayNodeNotFound, + PAM_MULTIPLE_GATEWAY_NODE_MATCHES: PamErrorCode.MultipleGatewayNodeMatches, AUDIT_INVALID_REPORT_TYPE: AuditReportErrorCode.InvalidReportType, AUDIT_INVALID_CREATED_FILTER: AuditReportErrorCode.InvalidCreatedFilter, AUDIT_INVALID_FILTER: AuditReportErrorCode.InvalidFilter, diff --git a/KeeperSdk/src/utils/index.ts b/KeeperSdk/src/utils/index.ts index 65ea9f2..ee7a2b3 100644 --- a/KeeperSdk/src/utils/index.ts +++ b/KeeperSdk/src/utils/index.ts @@ -12,6 +12,7 @@ export { ActionReportErrorCode, PasswordReportErrorCode, NsfErrorCode, + PamErrorCode, KEEPER_PUBLIC_HOSTS, } from './constants' export { Logger, ConsoleLogger, LogLevel, logger, setLogger, getLogger, resetLogger, writeOutput } from './Logger' diff --git a/KeeperSdk/src/vault/KeeperVault.ts b/KeeperSdk/src/vault/KeeperVault.ts index 7f8f81a..f3782a4 100644 --- a/KeeperSdk/src/vault/KeeperVault.ts +++ b/KeeperSdk/src/vault/KeeperVault.ts @@ -86,6 +86,7 @@ import { } from '../enterpriseReport' import { UserManager } from '../users/UserManager' import { NestedShareFolderManager } from '../nestedShareFolders/NestedShareFolderManager' +import { PamManager } from '../pam/PamManager' import { isNestedShareFolder } from '../nestedShareFolders/nsfHelpers' import { formatListNsfTable, renderListNsfAsciiTable, formatListNsfOutput } from '../nestedShareFolders/listNsf' import { formatNsfDetail } from '../nestedShareFolders/getNsf' @@ -142,6 +143,17 @@ import type { UpdateNsfRecordResult, UpdateNsfRecordResultItem, } from '../nestedShareFolders/nsfTypes' +import type { + ListGatewaysOptions, + ListGatewaysResult, + FormattedGatewaysTable, + FormatGatewaysTableOptions, + RenderGatewaysAsciiTableOptions, + CreateGatewayInput, + CreateGatewayResult, + EditGatewayInput, + EditGatewayResult, +} from '../pam/gateway/gatewayTypes' import type { ListUserRow, ListUsersOptions, @@ -211,6 +223,7 @@ export class KeeperVault { private readonly enterpriseReportManager: EnterpriseReportManager private readonly userManager: UserManager private readonly nestedShareFolderManager: NestedShareFolderManager + private readonly pamManager: PamManager constructor(config?: KeeperVaultConfig) { this.config = { @@ -236,12 +249,21 @@ export class KeeperVault { this.enterpriseReportManager = new EnterpriseReportManager(authProvider) this.userManager = new UserManager(authProvider) this.nestedShareFolderManager = new NestedShareFolderManager(this.storage, authProvider) + this.pamManager = new PamManager(this.storage, authProvider) } public getNestedShareFolderManager(): NestedShareFolderManager { return this.nestedShareFolderManager } + public getPamManager(): PamManager { + return this.pamManager + } + + public getGatewayManager() { + return this.pamManager.getGatewayManager() + } + public getFolderManager(): FolderManager { return this.folderManager } @@ -979,6 +1001,48 @@ export class KeeperVault { return formatNsfRecordPermissionFailures(failures, kind) } + public async listGateways(options?: ListGatewaysOptions): Promise { + return this.pamManager.listGateways(options ?? {}) + } + + public async createGateway(input: CreateGatewayInput & { returnValue: true }): Promise + public async createGateway(input: CreateGatewayInput & { returnValue?: false }): Promise + public async createGateway(input: CreateGatewayInput): Promise + public async createGateway(input: CreateGatewayInput): Promise { + return this.pamManager.createGateway(input) + } + + public formatCreateGatewayOutput(result: CreateGatewayResult): string { + return this.pamManager.formatCreateGatewayOutput(result) + } + + public async editGateway(input: EditGatewayInput): Promise { + return this.pamManager.editGateway(input) + } + + public formatEditGatewayOutput(result: EditGatewayResult): string { + return this.pamManager.formatEditGatewayOutput(result) + } + + public formatGatewaysTable( + result: ListGatewaysResult, + options?: FormatGatewaysTableOptions + ): FormattedGatewaysTable { + return this.pamManager.formatGatewaysTable(result, options ?? {}) + } + + public renderGatewaysAsciiTable(table: FormattedGatewaysTable, options?: RenderGatewaysAsciiTableOptions): string { + return this.pamManager.renderGatewaysAsciiTable(table, options ?? {}) + } + + public formatGatewaysJson(result: ListGatewaysResult, options?: ListGatewaysOptions): string { + return this.pamManager.formatGatewaysJson(result, options ?? {}) + } + + public formatGatewaysOutput(result: ListGatewaysResult, options?: ListGatewaysOptions): string { + return this.pamManager.formatGatewaysOutput(result, options ?? {}) + } + public async shareFolder(input: ShareFolderInput): Promise { const result = await this.sharedFolderManager.shareFolder(input) if (result.success) await this.syncIfNeeded() diff --git a/examples/sdk_example/package.json b/examples/sdk_example/package.json index 740b72f..0502df9 100644 --- a/examples/sdk_example/package.json +++ b/examples/sdk_example/package.json @@ -60,6 +60,9 @@ "reports:audit-report": "ts-node src/enterpriseReport/audit_report.ts", "reports:action-report": "ts-node src/enterpriseReport/action_report.ts", "reports:password-report": "ts-node src/enterpriseReport/password_report.ts", + "pam:gateway:list": "ts-node src/pam/gateway/list_gateways.ts", + "pam:gateway:new": "ts-node src/pam/gateway/create_gateway.ts", + "pam:gateway:edit": "ts-node src/pam/gateway/edit_gateway.ts", "link-local": "cd ../../KeeperSdk && npm link ../keeperapi && cd ../examples/sdk_example && npm link ../../keeperapi", "types": "tsc --watch", "types:ci": "tsc" diff --git a/examples/sdk_example/src/pam/gateway/create_gateway.ts b/examples/sdk_example/src/pam/gateway/create_gateway.ts new file mode 100644 index 0000000..1b521fe --- /dev/null +++ b/examples/sdk_example/src/pam/gateway/create_gateway.ts @@ -0,0 +1,77 @@ +import { + cleanup, + extractErrorMessage, + GatewayConfigInitFormat, + login, + logger, + prompt, + suppressLogs, + type CreateGatewayResult, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../../utils/runner' +import { isYes } from '../../utils/format' + +async function createGatewayExample() { + const vault = await login() + + try { + const name = (await prompt('Gateway name: ')).trim() + if (!name) { + logger.info('Gateway name is required.') + return + } + + const application = (await prompt('KSM application name or UID: ')).trim() + if (!application) { + logger.info('KSM application is required.') + return + } + + const expireRaw = (await prompt('Token expires in minutes [60]: ')).trim() + const tokenExpiresInMin = expireRaw ? Number(expireRaw) : 60 + + const wantConfig = isYes(await prompt('Initialize config (json/b64) instead of raw OTT? [y/N]: ')) + let configInit: GatewayConfigInitFormat | undefined + if (wantConfig) { + const format = (await prompt('Config format — json or b64 [json]: ')).trim().toLowerCase() || 'json' + configInit = + format === GatewayConfigInitFormat.B64 || format === 'b64' + ? GatewayConfigInitFormat.B64 + : GatewayConfigInitFormat.Json + } + + // Commander: --return_value / -r — return token/config string for automation (skip banner). + const returnValue = isYes(await prompt('Return value only (automation / -r)? [y/N]: ')) + + let result: CreateGatewayResult | string + const restore = suppressLogs() + try { + result = await vault.createGateway({ + name, + application, + tokenExpiresInMin, + configInit, + returnValue, + }) + } finally { + restore() + } + + if (returnValue) { + // Same as Commander: no banner — just the OTT or initialized config string. + logger.info(result as string) + return + } + + logger.info('') + logger.info(vault.formatCreateGatewayOutput(result as CreateGatewayResult)) + logger.info('') + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(createGatewayExample) diff --git a/examples/sdk_example/src/pam/gateway/edit_gateway.ts b/examples/sdk_example/src/pam/gateway/edit_gateway.ts new file mode 100644 index 0000000..53e46e7 --- /dev/null +++ b/examples/sdk_example/src/pam/gateway/edit_gateway.ts @@ -0,0 +1,49 @@ +import { + cleanup, + extractErrorMessage, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../../utils/runner' + +async function editGatewayExample() { + const vault = await login() + + try { + const gatewayUidOrName = (await prompt('Gateway UID or name: ')).trim() + if (!gatewayUidOrName) { + logger.info('Gateway UID or name is required.') + return + } + + const name = (await prompt('New name (Enter to keep current): ')).trim() || undefined + const nodeIdRaw = (await prompt('New node ID or name (Enter to keep current): ')).trim() + const nodeIdOrName = nodeIdRaw || undefined + + if (!name && !nodeIdOrName) { + logger.info('Nothing to do. Provide at least a new name or node.') + return + } + + let result + const restore = suppressLogs() + try { + result = await vault.editGateway({ gatewayUidOrName, name, nodeIdOrName }) + } finally { + restore() + } + + logger.info('') + logger.info(vault.formatEditGatewayOutput(result)) + logger.info('') + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(editGatewayExample) diff --git a/examples/sdk_example/src/pam/gateway/list_gateways.ts b/examples/sdk_example/src/pam/gateway/list_gateways.ts new file mode 100644 index 0000000..42d75af --- /dev/null +++ b/examples/sdk_example/src/pam/gateway/list_gateways.ts @@ -0,0 +1,58 @@ +import { + cleanup, + extractErrorMessage, + GatewayListFormat, + login, + logger, + prompt, + suppressLogs, +} from '@keeper-security/keeper-sdk-javascript' +import { runExample } from '../../utils/runner' +import { isYes } from '../../utils/format' + +async function listGatewaysExample() { + const vault = await login() + + try { + const force = isYes(await prompt('Force list if router is down? [y/N]: ')) + const verbose = isYes(await prompt('Verbose output? [y/N]: ')) + const onlineOnly = isYes(await prompt('Online gateways only? [y/N]: ')) + const asJson = isYes(await prompt('Output as JSON? [y/N]: ')) + + const options = { + force, + verbose, + onlineOnly, + format: asJson ? GatewayListFormat.Json : GatewayListFormat.Table, + } + + let result + const restore = suppressLogs() + try { + result = await vault.listGateways(options) + } finally { + restore() + } + + if (result.aborted) { + logger.info(result.message || 'Router unavailable. Re-run with force to list gateways.') + return + } + + if (result.gateways.length === 0) { + logger.info(result.message || 'No gateways found.') + return + } + + logger.info('') + logger.info(vault.formatGatewaysOutput(result, options)) + logger.info('') + } catch (err) { + logger.error(`Operation failed: ${extractErrorMessage(err)}`) + process.exitCode = 1 + } finally { + cleanup(vault) + } +} + +runExample(listGatewaysExample) diff --git a/keeperapi/src/restMessages.ts b/keeperapi/src/restMessages.ts index e830174..ccaa8f4 100644 --- a/keeperapi/src/restMessages.ts +++ b/keeperapi/src/restMessages.ts @@ -961,6 +961,9 @@ export const automatorAdminResetMessage = ( export const getControllers = (): RestOutMessage => createOutMessage('pam/get_controllers', PAM.PAMControllersResponse) +export const modifyControllerMessage = (data: PAM.IPAMController): RestInMessage => + createInMessage(data, 'pam/modify_controller', PAM.PAMController) + export const getConfigurationControllerMessage = ( data: PAM.IPAMGenericUidRequest ): RestMessage =>