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/package-lock.json b/package-lock.json index d22523227..75c2cfa27 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.1", "node-forge": "^1.3.1", @@ -2797,7 +2798,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": { @@ -6303,7 +6303,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 449310f08..569d8f916 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.1", "node-forge": "^1.3.1", 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 new file mode 100644 index 000000000..ab0e69028 --- /dev/null +++ b/src/routes/admin/profiles/export.ts @@ -0,0 +1,214 @@ +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' +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 } = req.params + const { domainName } = req.query as { domainName: string } + 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) + } + } + + // 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 + } + } + + // 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) { + 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, + pxeTimeout: ieee8021xProfile.pxeTimeout, + wiredInterface: ieee8021xProfile.wiredInterface, + tenantId: ieee8021xProfile.tenantId + } + ] + } + + // 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).json({ + filename: `${profileName}.yaml`, + 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..dd9f4635d --- /dev/null +++ b/src/utils/encryptWithRandomKey.ts @@ -0,0 +1,33 @@ +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 } { + // 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() + + // [nonce][ciphertext][tag] for Go compatibility + const cipherText = Buffer.concat([ + nonce, + encrypted, + tag + ]).toString('base64') + + return { + cipherText, + key: keyString + } +}