From b85b79cffb698b11fdf75f5fbae22e565bb49a97 Mon Sep 17 00:00:00 2001 From: Mike Johanson Date: Thu, 15 May 2025 14:34:16 -0700 Subject: [PATCH 1/2] feat(api): add export endpoint for local --- package-lock.json | 3 +- package.json | 1 + src/routes/admin/profiles/export.ts | 120 ++++++++++++++++++++++++++++ src/routes/admin/profiles/index.ts | 4 +- src/utils/encryptWithRandomKey.ts | 25 ++++++ 5 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 src/routes/admin/profiles/export.ts create mode 100644 src/utils/encryptWithRandomKey.ts diff --git a/package-lock.json b/package-lock.json index 4248f1d02..92385e78a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "express-ws": "^5.0.2", "got": "^14.4.7", "http-z": "^7.0.0", + "js-yaml": "^4.1.0", "minimist": "^1.2.8", "mqtt": "^5.13.0", "node-forge": "^1.3.1", @@ -2580,7 +2581,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -6165,7 +6165,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" diff --git a/package.json b/package.json index 3d4e95fc9..e1a463529 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "express-ws": "^5.0.2", "got": "^14.4.7", "http-z": "^7.0.0", + "js-yaml": "^4.1.0", "minimist": "^1.2.8", "mqtt": "^5.13.0", "node-forge": "^1.3.1", diff --git a/src/routes/admin/profiles/export.ts b/src/routes/admin/profiles/export.ts new file mode 100644 index 000000000..a5e680c9a --- /dev/null +++ b/src/routes/admin/profiles/export.ts @@ -0,0 +1,120 @@ +import { WirelessConfig } from 'models/RCS.Config.js' +import Logger from '../../../Logger.js' +import { NOT_FOUND_EXCEPTION, NOT_FOUND_MESSAGE } from '../../../utils/constants.js' +import handleError from '../../../utils/handleError.js' +import { RPSError } from '../../../utils/RPSError.js' +import { type Request, type Response } from 'express' +import yaml from 'js-yaml' +import { encryptWithRandomKey } from '../../../utils/encryptWithRandomKey.js' + +export async function exportProfile(req: Request, res: Response): Promise { + const log = new Logger('exportProfile') + const { profileName, domainName } = req.params + try { + const result = await req.db.profiles.getByName(profileName, req.tenantId) + if (result == null) { + throw new RPSError(NOT_FOUND_MESSAGE('AMT', profileName), NOT_FOUND_EXCEPTION) + } + if (result.ciraConfigName && result.ciraConfigName !== '') { + result.ciraConfigObject = await req.db.ciraConfigs.getByName(result.ciraConfigName, req.tenantId) + if (result.ciraConfigObject == null) { + throw new RPSError(NOT_FOUND_MESSAGE('CIRA', result.ciraConfigName), NOT_FOUND_EXCEPTION) + } + } + if (result.activation === 'acmactivate') { + const domainStuff = await req.db.domains.getByName(domainName, req.tenantId) + ;(result as any).domainObject = domainStuff + } + const wifiConfigs: any[] = [] + if (result.wifiConfigs && result.wifiConfigs.length > 0) { + for (const wifiConfig of result.wifiConfigs) { + const wifiConfigObject = await req.db.wirelessProfiles.getByName(wifiConfig.profileName, req.tenantId) + if (wifiConfigObject == null) { + throw new RPSError(NOT_FOUND_MESSAGE('Wireless', wifiConfig.profileName), NOT_FOUND_EXCEPTION) + } + wifiConfigs.push({ + profileName: wifiConfigObject.profileName, + ssid: wifiConfigObject.ssid, + priority: wifiConfig.priority, + authenticationMethod: wifiConfigObject.authenticationMethod, + encryptionMethod: wifiConfigObject.encryptionMethod, + pskPassphrase: wifiConfigObject.pskPassphrase, + ieee8021xProfileName: wifiConfigObject.ieee8021xProfileName + }) + } + } + let ieee8021xConfigs: any[] = [] + if (result.ieee8021xProfileName && result.ieee8021xProfileName !== '') { + const ieee8021xProfile = await req.db.ieee8021xProfiles.getByName(result.ieee8021xProfileName, req.tenantId) + if (ieee8021xProfile == null) { + throw new RPSError(NOT_FOUND_MESSAGE('802.1x', result.ieee8021xProfileName), NOT_FOUND_EXCEPTION) + } + ieee8021xConfigs = [ + { + profileName: ieee8021xProfile.profileName, + username: ieee8021xProfile.username, + password: ieee8021xProfile.password, + authenticationProtocol: ieee8021xProfile.authenticationProtocol + // clientCert: ieee8021xProfile.clientCert, + // caCert: ieee8021xProfile.caCert, + // privateKey: ieee8021xProfile.privateKey + } + ] + } + + // Map to Go struct shape + const output = { + password: result.amtPassword ?? '', // Adjust as needed + tlsConfig: { + //delay: result.tlsDelay ?? 3, + mode: result.tlsMode ?? '' + }, + wiredConfig: { + dhcp: result.dhcpEnabled ?? true, + static: !result.dhcpEnabled, + ipsync: result.ipSyncEnabled ?? false, + // ipaddress: result.ipAddress ?? '', + // subnetmask: result.subnetMask ?? '', + // gateway: result.gateway ?? '', + // primarydns: result.primaryDns ?? '', + // secondarydns: result.secondaryDns ?? '', + ieee8021xProfileName: result.ieee8021xProfileName ?? '' + }, + wifiConfigs, + wifiSyncEnabled: result.localWifiSyncEnabled ?? false, + ieee8021xConfigs, + acmactivate: + result.activation === 'acmactivate' + ? { + amtPassword: result.amtPassword ?? '', + provisioningCert: '', + provisioningCertPwd: '' + } + : null, + enterpriseAssistant: { + eaAddress: '', //result.eaAddress ?? '', + eaUsername: '', //result.eaUsername ?? '', + eaPassword: '', //result.eaPassword ?? '', + eaConfigured: '' //result.eaConfigured ?? false + }, + // stopConfig: result.stopConfig ?? false, + ccmactivate: + result.activation === 'ccmactivate' + ? { + amtPassword: result.amtPassword ?? '' + } + : null + } + + const yamlStr = yaml.dump(output) + // Encrypt the YAML string and provide the key + const { cipherText, key } = encryptWithRandomKey(yamlStr) + res.status(200).send({ + filename: `${profileName}.yaml.enc`, + content: cipherText, + key + }) + } catch (error) { + handleError(log, profileName, req, res, error) + } +} diff --git a/src/routes/admin/profiles/index.ts b/src/routes/admin/profiles/index.ts index a9470b0f3..cfd0e036f 100644 --- a/src/routes/admin/profiles/index.ts +++ b/src/routes/admin/profiles/index.ts @@ -4,7 +4,6 @@ **********************************************************************/ import { deleteProfile } from './delete.js' - import { Router } from 'express' import { allProfiles } from './all.js' import { getProfile } from './get.js' @@ -14,6 +13,8 @@ import { amtProfileValidator, profileUpdateValidator } from './amtProfileValidat import { odataValidator } from '../odataValidator.js' import validateMiddleware from '../../../middleware/validate.js' import ifMatchMiddleware from '../../../middleware/if-match.js' +import { exportProfile } from './export.js' + const profileRouter: Router = Router() profileRouter.get('/', odataValidator(), validateMiddleware, allProfiles) @@ -21,5 +22,6 @@ profileRouter.get('/:profileName', getProfile) profileRouter.post('/', amtProfileValidator(), validateMiddleware, createProfile) profileRouter.patch('/', profileUpdateValidator(), validateMiddleware, ifMatchMiddleware, editProfile) profileRouter.delete('/:profileName', deleteProfile) +profileRouter.get('/export/:profileName', exportProfile) export default profileRouter diff --git a/src/utils/encryptWithRandomKey.ts b/src/utils/encryptWithRandomKey.ts new file mode 100644 index 000000000..0922f40d5 --- /dev/null +++ b/src/utils/encryptWithRandomKey.ts @@ -0,0 +1,25 @@ +import crypto from 'crypto' + +/** + * Encrypts a string using AES-256-GCM with a random key and random nonce, returns base64 ciphertext and key. + * @param {string} plainText - The plaintext to encrypt. + * @returns {{ cipherText: string, key: string }} + */ +export function encryptWithRandomKey(plainText: string): { cipherText: string, key: string } { + // Generate a random 32-byte key (256 bits) + const key = crypto.randomBytes(32) + // Generate a random 12-byte nonce (GCM standard) + const nonce = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce) + const encrypted = Buffer.concat([ + cipher.update(plainText, 'utf8'), + cipher.final() + ]) + const tag = cipher.getAuthTag() + // Prepend nonce and tag to ciphertext for transport + const cipherText = Buffer.concat([nonce, tag, encrypted]).toString('base64') + return { + cipherText, + key: key.toString('base64') + } +} From c675283521ca4121660cbaefdd335fc1d010a369 Mon Sep 17 00:00:00 2001 From: Mike Johanson Date: Mon, 23 Jun 2025 11:00:11 -0700 Subject: [PATCH 2/2] feat: enable export endpoint --- .rpsrc | 1 + src/models/index.ts | 81 ++++++++++ src/routes/admin/profiles/export.ts | 236 +++++++++++++++++++--------- src/utils/encryptWithRandomKey.ts | 24 ++- 4 files changed, 263 insertions(+), 79 deletions(-) diff --git a/.rpsrc b/.rpsrc index 898b06002..a98d55229 100644 --- a/.rpsrc +++ b/.rpsrc @@ -14,6 +14,7 @@ "delay_timer": 12, "mqtt_address": "", "disable_cira_domain_name": "", + "enterprise_assistant_url": "http://localhost:8000", "consul_enabled": false, "consul_host": "localhost", "consul_port": "8500", diff --git a/src/models/index.ts b/src/models/index.ts index 930e07528..180a773fc 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -102,6 +102,7 @@ export interface RPSConfig { delay_tls_put_data_sync: number mqtt_address?: string disable_cira_domain_name?: string + enterprise_assistant_url?: string jwt_token_header: string jwt_tenant_property: string } @@ -274,3 +275,83 @@ export interface OpenAMTEvent { guid?: string timestamp: number } + +// Export-specific interfaces for profile export functionality +export interface ExportWifiConfig { + profileName: string + ssid: string + priority: number + authenticationMethod: number + encryptionMethod: number + pskPassphrase: string + ieee8021xProfileName: string +} + +export interface ExportConfiguration { + id: number + name: string + configuration: { + generalSettings: { + sharedFQDN: boolean + networkInterfaceEnabled: number + pingResponseEnabled: boolean + } + network: { + wired: { + dhcpEnabled: boolean + ipSyncEnabled: boolean + sharedStaticIP: boolean + ipAddress: string + subnetMask: string + defaultGateway: string + primaryDNS: string + secondaryDNS: string + authentication: string + ieee8021x: Ieee8021xConfig | null + } + wireless: { + wifiSyncEnabled: boolean + profiles: ExportWifiConfig[] + } + } + tls: { + mutualAuthentication: boolean + enabled: boolean + trustedCN: string[] + allowNonTLS: boolean + } + redirection: { + enabled: boolean + services: { + kvm: boolean + sol: boolean + ider: boolean + } + userConsent: string + } + userAccounts: { + userAccounts: any[] + } + enterpriseAssistant: { + url: string + username: string + password: string + } + amtSpecific: { + controlMode: string + adminPassword: string + provisioningCert: string + provisioningCertPwd: string + mebxPassword: string + } + bmcSpecific: { + adminPassword: string + } + dashSpecific: { + adminPassword: string + } + redfishSpecific: { + adminPassword: string + } + } +} diff --git a/src/routes/admin/profiles/export.ts b/src/routes/admin/profiles/export.ts index a5e680c9a..ab0e69028 100644 --- a/src/routes/admin/profiles/export.ts +++ b/src/routes/admin/profiles/export.ts @@ -1,4 +1,5 @@ -import { WirelessConfig } from 'models/RCS.Config.js' +import { WirelessConfig, Ieee8021xConfig, ProfileWifiConfigs } from 'models/RCS.Config.js' +import { type ExportWifiConfig, type ExportConfiguration } from '../../../models/index.js' import Logger from '../../../Logger.js' import { NOT_FOUND_EXCEPTION, NOT_FOUND_MESSAGE } from '../../../utils/constants.js' import handleError from '../../../utils/handleError.js' @@ -6,10 +7,16 @@ import { RPSError } from '../../../utils/RPSError.js' import { type Request, type Response } from 'express' import yaml from 'js-yaml' import { encryptWithRandomKey } from '../../../utils/encryptWithRandomKey.js' +import { type CertCredentials } from '../../../interfaces/ISecretManagerService.js' +import { Environment } from '../../../utils/Environment.js' + +// Constants for export configuration +const DEFAULT_USER_CONSENT = 'None' export async function exportProfile(req: Request, res: Response): Promise { const log = new Logger('exportProfile') - const { profileName, domainName } = req.params + const { profileName } = req.params + const { domainName } = req.query as { domainName: string } try { const result = await req.db.profiles.getByName(profileName, req.tenantId) if (result == null) { @@ -21,29 +28,30 @@ export async function exportProfile(req: Request, res: Response): Promise throw new RPSError(NOT_FOUND_MESSAGE('CIRA', result.ciraConfigName), NOT_FOUND_EXCEPTION) } } - if (result.activation === 'acmactivate') { - const domainStuff = await req.db.domains.getByName(domainName, req.tenantId) - ;(result as any).domainObject = domainStuff - } - const wifiConfigs: any[] = [] - if (result.wifiConfigs && result.wifiConfigs.length > 0) { - for (const wifiConfig of result.wifiConfigs) { - const wifiConfigObject = await req.db.wirelessProfiles.getByName(wifiConfig.profileName, req.tenantId) - if (wifiConfigObject == null) { - throw new RPSError(NOT_FOUND_MESSAGE('Wireless', wifiConfig.profileName), NOT_FOUND_EXCEPTION) - } - wifiConfigs.push({ - profileName: wifiConfigObject.profileName, - ssid: wifiConfigObject.ssid, - priority: wifiConfig.priority, - authenticationMethod: wifiConfigObject.authenticationMethod, - encryptionMethod: wifiConfigObject.encryptionMethod, - pskPassphrase: wifiConfigObject.pskPassphrase, - ieee8021xProfileName: wifiConfigObject.ieee8021xProfileName - }) + + // Helper function to get WiFi configuration + const getWifiConfiguration = async (wifiConfig: ProfileWifiConfigs): Promise => { + const wifiConfigObject = await req.db.wirelessProfiles.getByName(wifiConfig.profileName, req.tenantId) + if (wifiConfigObject == null) { + throw new RPSError(NOT_FOUND_MESSAGE('Wireless', wifiConfig.profileName), NOT_FOUND_EXCEPTION) + } + return { + profileName: wifiConfigObject.profileName, + ssid: wifiConfigObject.ssid, + priority: wifiConfig.priority, + authenticationMethod: wifiConfigObject.authenticationMethod, + encryptionMethod: wifiConfigObject.encryptionMethod, + pskPassphrase: wifiConfigObject.pskPassphrase, + ieee8021xProfileName: wifiConfigObject.ieee8021xProfileName } } - let ieee8021xConfigs: any[] = [] + + // Get WiFi configurations + const wifiConfigs: ExportWifiConfig[] = + result.wifiConfigs && result.wifiConfigs.length > 0 + ? await Promise.all(result.wifiConfigs.map(getWifiConfiguration)) + : [] + let ieee8021xConfigs: Ieee8021xConfig[] = [] if (result.ieee8021xProfileName && result.ieee8021xProfileName !== '') { const ieee8021xProfile = await req.db.ieee8021xProfiles.getByName(result.ieee8021xProfileName, req.tenantId) if (ieee8021xProfile == null) { @@ -54,63 +62,149 @@ export async function exportProfile(req: Request, res: Response): Promise profileName: ieee8021xProfile.profileName, username: ieee8021xProfile.username, password: ieee8021xProfile.password, - authenticationProtocol: ieee8021xProfile.authenticationProtocol - // clientCert: ieee8021xProfile.clientCert, - // caCert: ieee8021xProfile.caCert, - // privateKey: ieee8021xProfile.privateKey + authenticationProtocol: ieee8021xProfile.authenticationProtocol, + pxeTimeout: ieee8021xProfile.pxeTimeout, + wiredInterface: ieee8021xProfile.wiredInterface, + tenantId: ieee8021xProfile.tenantId } ] } - // Map to Go struct shape - const output = { - password: result.amtPassword ?? '', // Adjust as needed - tlsConfig: { - //delay: result.tlsDelay ?? 3, - mode: result.tlsMode ?? '' - }, - wiredConfig: { - dhcp: result.dhcpEnabled ?? true, - static: !result.dhcpEnabled, - ipsync: result.ipSyncEnabled ?? false, - // ipaddress: result.ipAddress ?? '', - // subnetmask: result.subnetMask ?? '', - // gateway: result.gateway ?? '', - // primarydns: result.primaryDns ?? '', - // secondarydns: result.secondaryDns ?? '', - ieee8021xProfileName: result.ieee8021xProfileName ?? '' - }, - wifiConfigs, - wifiSyncEnabled: result.localWifiSyncEnabled ?? false, - ieee8021xConfigs, - acmactivate: - result.activation === 'acmactivate' - ? { - amtPassword: result.amtPassword ?? '', - provisioningCert: '', - provisioningCertPwd: '' - } - : null, - enterpriseAssistant: { - eaAddress: '', //result.eaAddress ?? '', - eaUsername: '', //result.eaUsername ?? '', - eaPassword: '', //result.eaPassword ?? '', - eaConfigured: '' //result.eaConfigured ?? false - }, - // stopConfig: result.stopConfig ?? false, - ccmactivate: - result.activation === 'ccmactivate' - ? { - amtPassword: result.amtPassword ?? '' - } - : null + // Map wireless profiles to the new format (this mapping is now redundant) + const wirelessProfiles: ExportWifiConfig[] = wifiConfigs + + // Get domain object for ACM activation with secrets + let domainObject: any = null + const passwords = { + amt: '', + mebx: '', + mps: '' + } + + if (result.activation === 'acmactivate' && domainName) { + domainObject = await req.db.domains.getByName(domainName, req.tenantId) + + // Get provisioning cert and password from secret manager if available + if (domainObject && req.secretsManager) { + try { + const certCredentials = (await req.secretsManager.getSecretAtPath( + `certs/${domainObject.profileName}` + )) as CertCredentials + if (certCredentials?.CERT) { + domainObject.provisioningCert = certCredentials.CERT || domainObject.provisioningCert || '' + domainObject.provisioningCertPassword = + certCredentials.CERT_PASSWORD || domainObject.provisioningCertPassword || '' + } + } catch (error) { + log.error(`Failed to get cert credentials from secret manager: ${error}`) + } + } + } + + // Helper function to get password from secret manager or fallback + const getPassword = async (secretKey: string, fallbackValue: string | null | undefined): Promise => { + if (req.secretsManager) { + try { + return ( + (await req.secretsManager.getSecretFromKey(`profiles/${profileName}`, secretKey)) || fallbackValue || '' + ) + } catch (error) { + log.error(`Failed to get ${secretKey}: ${error}`) + return fallbackValue || '' + } + } + return fallbackValue || '' + } + + // Get passwords from secret manager or profile + passwords.amt = await getPassword('AMT_PASSWORD', result.amtPassword) + passwords.mebx = await getPassword('MEBX_PASSWORD', result.mebxPassword) + + // Get MPS password - this is typically generated randomly + if (result.ciraConfigObject && req.secretsManager) { + try { + passwords.mps = (await req.secretsManager.getSecretFromKey(`profiles/${profileName}`, 'MPS_PASSWORD')) || '' + } catch (error) { + log.error(`Failed to get MPS password: ${error}`) + } + } + + // Create the new structured output + const output: ExportConfiguration = { + id: 0, + name: profileName, + configuration: { + generalSettings: { + sharedFQDN: false, + networkInterfaceEnabled: 0, + pingResponseEnabled: false + }, + network: { + wired: { + dhcpEnabled: result.dhcpEnabled ?? true, + ipSyncEnabled: result.ipSyncEnabled ?? false, + sharedStaticIP: false, + ipAddress: '', + subnetMask: '', + defaultGateway: '', + primaryDNS: '', + secondaryDNS: '', + authentication: '', + ieee8021x: ieee8021xConfigs.length > 0 ? ieee8021xConfigs[0] : null + }, + wireless: { + wifiSyncEnabled: result.localWifiSyncEnabled ?? false, + profiles: wirelessProfiles + } + }, + tls: { + mutualAuthentication: false, + enabled: result.tlsMode ? true : false, + trustedCN: [], + allowNonTLS: false + }, + redirection: { + enabled: false, + services: { + kvm: true, + sol: true, + ider: true + }, + userConsent: DEFAULT_USER_CONSENT + }, + userAccounts: { + userAccounts: [] + }, + enterpriseAssistant: { + url: Environment.Config.enterprise_assistant_url ?? 'http://localhost:8000', + username: '', + password: '' + }, + amtSpecific: { + controlMode: result.activation ?? '', + adminPassword: passwords.amt, + provisioningCert: domainObject?.provisioningCert ?? '', + provisioningCertPwd: domainObject?.provisioningCertPassword ?? '', + mebxPassword: passwords.mebx + }, + bmcSpecific: { + adminPassword: '' + }, + dashSpecific: { + adminPassword: '' + }, + redfishSpecific: { + adminPassword: '' + } + } } const yamlStr = yaml.dump(output) // Encrypt the YAML string and provide the key const { cipherText, key } = encryptWithRandomKey(yamlStr) - res.status(200).send({ - filename: `${profileName}.yaml.enc`, + + res.status(200).json({ + filename: `${profileName}.yaml`, content: cipherText, key }) diff --git a/src/utils/encryptWithRandomKey.ts b/src/utils/encryptWithRandomKey.ts index 0922f40d5..dd9f4635d 100644 --- a/src/utils/encryptWithRandomKey.ts +++ b/src/utils/encryptWithRandomKey.ts @@ -5,21 +5,29 @@ import crypto from 'crypto' * @param {string} plainText - The plaintext to encrypt. * @returns {{ cipherText: string, key: string }} */ -export function encryptWithRandomKey(plainText: string): { cipherText: string, key: string } { - // Generate a random 32-byte key (256 bits) - const key = crypto.randomBytes(32) - // Generate a random 12-byte nonce (GCM standard) - const nonce = crypto.randomBytes(12) +export function encryptWithRandomKey(plainText: string): { cipherText: string; key: string } { + // 24 bytes key for AES-192 to match Go's default (or use 32 bytes for AES-256 if you fix Go) + const rawKey = crypto.randomBytes(24) // <-- AES-192, matches Go default! + const keyString = rawKey.toString('base64') // This is what Go stores/uses + const key = Buffer.from(keyString, 'ascii') // <-- This is what Go *actually* uses as key + + const nonce = crypto.randomBytes(12) // GCM standard const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce) const encrypted = Buffer.concat([ cipher.update(plainText, 'utf8'), cipher.final() ]) const tag = cipher.getAuthTag() - // Prepend nonce and tag to ciphertext for transport - const cipherText = Buffer.concat([nonce, tag, encrypted]).toString('base64') + + // [nonce][ciphertext][tag] for Go compatibility + const cipherText = Buffer.concat([ + nonce, + encrypted, + tag + ]).toString('base64') + return { cipherText, - key: key.toString('base64') + key: keyString } }