const { R } = require("redbean-node"); const { EC2Client, DescribeInstancesCommand, DescribeRegionsCommand } = require("@aws-sdk/client-ec2"); const { CloudWatchClient, GetMetricDataCommand } = require("@aws-sdk/client-cloudwatch"); const { log } = require("../src/util"); /** * AWS Account and EC2 Resource Management Service */ class AWSAccount { /** * Build AWS Client configuration with credentials and region * @param {object} account AWS account record or plain object * @param {string} [overrideRegion] Optional region override * @returns {object} AWS client options */ static getClientConfig(account, overrideRegion) { const region = overrideRegion || account.region || "us-east-1"; const config = { region }; if (account.auth_type === "keys" && account.access_key_id && account.secret_access_key) { config.credentials = { accessKeyId: account.access_key_id.trim(), secretAccessKey: account.secret_access_key.trim(), }; if (account.session_token && account.session_token.trim()) { config.credentials.sessionToken = account.session_token.trim(); } } // If auth_type === 'iam_role', AWS SDK automatically discovers credentials // via environment variables, ECS metadata, or EC2 instance profile. return config; } /** * Save or update an AWS account * @param {object} account Account data * @param {?number} accountID Existing account ID if updating * @param {number} userID User ID * @returns {Promise} Saved account */ static async save(account, accountID, userID) { let bean; if (accountID) { bean = await R.findOne("aws_account", " id = ? ", [accountID]); if (!bean) { throw new Error("AWS Account not found"); } } else { bean = R.dispense("aws_account"); bean.created_date = R.isoDateTime(); } bean.name = (account.name || "").trim() || "AWS Account"; bean.auth_type = account.authType || account.auth_type || "keys"; bean.region = (account.region || "us-east-1").trim(); bean.user_id = userID; if (bean.auth_type === "keys") { bean.access_key_id = (account.accessKeyId || account.access_key_id || "").trim(); // Don't overwrite secret with masked string const newSecret = account.secretAccessKey || account.secret_access_key; if (newSecret && !newSecret.startsWith("••••") && !newSecret.startsWith("****")) { bean.secret_access_key = newSecret.trim(); } const newToken = account.sessionToken || account.session_token; if (newToken !== undefined) { bean.session_token = newToken ? newToken.trim() : null; } } else { bean.access_key_id = null; bean.secret_access_key = null; bean.session_token = null; } await R.store(bean); return AWSAccount.sanitizeAccount(bean.export()); } /** * Delete an AWS account * @param {number} accountID Account ID * @returns {Promise} */ static async delete(accountID) { const bean = await R.findOne("aws_account", " id = ? ", [accountID]); if (!bean) { throw new Error("AWS Account not found"); } await R.trash(bean); } /** * List all AWS accounts (with masked secrets) * @param {number} [userID] Optional user filter * @returns {Promise} */ static async list(userID) { let beans; if (userID) { beans = await R.find("aws_account", " user_id = ? ORDER BY id ASC ", [userID]); } else { beans = await R.find("aws_account", " ORDER BY id ASC "); } return beans.map((bean) => AWSAccount.sanitizeAccount(bean.export())); } /** * Get account bean including secret for server-side operations * @param {number} accountID Account ID * @returns {Promise} */ static async getAccountRaw(accountID) { const bean = await R.findOne("aws_account", " id = ? ", [accountID]); if (!bean) { throw new Error(`AWS Account #${accountID} not found`); } return bean.export(); } /** * Sanitize account object for frontend * @param {object} account Account object * @returns {object} Sanitized account */ static sanitizeAccount(account) { const sanitized = { ...account }; if (sanitized.secret_access_key) { sanitized.hasSecret = true; sanitized.secret_access_key = "••••••••••••••••"; } else { sanitized.hasSecret = false; } if (sanitized.session_token) { sanitized.hasToken = true; sanitized.session_token = "••••••••"; } return sanitized; } /** * Test connection to AWS * @param {object} account Account object * @param {string} [region] Region to test * @returns {Promise<{ ok: boolean, regionsCount: number, instancesCount: number, message: string }>} */ static async testConnection(account, region) { const testRegion = region || account.region || "us-east-1"; const config = AWSAccount.getClientConfig(account, testRegion); const ec2 = new EC2Client(config); const startTime = Date.now(); const describeCmd = new DescribeInstancesCommand({ MaxResults: 10 }); const res = await ec2.send(describeCmd); let instancesCount = 0; if (res.Reservations) { for (const r of res.Reservations) { instancesCount += (r.Instances || []).length; } } const latency = Date.now() - startTime; return { ok: true, instancesCount, latencyMs: latency, message: `Connected successfully (${latency}ms). Found ${instancesCount} instance(s) in sample.`, }; } /** * List all AWS EC2 instances for an account and region * @param {object} account Account object * @param {string} [region] Region to query * @returns {Promise} List of instances */ static async getEc2Instances(account, region) { const targetRegion = region || account.region || "us-east-1"; const config = AWSAccount.getClientConfig(account, targetRegion); const ec2 = new EC2Client(config); const cmd = new DescribeInstancesCommand({}); const response = await ec2.send(cmd); const instances = []; if (response.Reservations) { for (const res of response.Reservations) { if (!res.Instances) continue; for (const inst of res.Instances) { let name = inst.InstanceId; const tags = {}; if (inst.Tags) { for (const tag of inst.Tags) { tags[tag.Key] = tag.Value; if (tag.Key === "Name" && tag.Value) { name = tag.Value; } } } instances.push({ id: inst.InstanceId, name, state: inst.State?.Name || "unknown", stateCode: inst.State?.Code, type: inst.InstanceType, publicIp: inst.PublicIpAddress || null, privateIp: inst.PrivateIpAddress || null, az: inst.Placement?.AvailabilityZone || null, launchTime: inst.LaunchTime ? new Date(inst.LaunchTime).toISOString() : null, vpcId: inst.VpcId || null, subnetId: inst.SubnetId || null, platform: inst.Platform || "linux", architecture: inst.Architecture || "x86_64", tags, }); } } } // Sort: running first, then alphabetically by name instances.sort((a, b) => { if (a.state === "running" && b.state !== "running") return -1; if (a.state !== "running" && b.state === "running") return 1; return a.name.localeCompare(b.name); }); return instances; } /** * Fetch CloudWatch metrics for active instances (CPU, Memory via CWAgent, Network, Status) * @param {object} account Account object * @param {string} region Region * @param {string[]} instanceIds List of instance IDs * @returns {Promise>} Map of instanceId -> metrics */ static async getLiveInstanceMetrics(account, region, instanceIds) { if (!instanceIds || instanceIds.length === 0) { return {}; } const targetRegion = region || account.region || "us-east-1"; const config = AWSAccount.getClientConfig(account, targetRegion); const cw = new CloudWatchClient(config); const now = new Date(); const startTime = new Date(now.getTime() - 20 * 60 * 1000); // last 20 minutes const queries = []; instanceIds.forEach((id, idx) => { // CPU Utilization (AWS/EC2) queries.push({ Id: `cpu_${idx}`, MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "CPUUtilization", Dimensions: [{ Name: "InstanceId", Value: id }], }, Period: 300, Stat: "Average", }, ReturnData: true, }); // Memory Used Percent (CWAgent default) queries.push({ Id: `mem_${idx}`, MetricStat: { Metric: { Namespace: "CWAgent", MetricName: "mem_used_percent", Dimensions: [{ Name: "InstanceId", Value: id }], }, Period: 300, Stat: "Average", }, ReturnData: true, }); // Network In queries.push({ Id: `net_in_${idx}`, MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "NetworkIn", Dimensions: [{ Name: "InstanceId", Value: id }], }, Period: 300, Stat: "Average", }, ReturnData: true, }); // Network Out queries.push({ Id: `net_out_${idx}`, MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "NetworkOut", Dimensions: [{ Name: "InstanceId", Value: id }], }, Period: 300, Stat: "Average", }, ReturnData: true, }); // Status Check Failed queries.push({ Id: `status_${idx}`, MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "StatusCheckFailed", Dimensions: [{ Name: "InstanceId", Value: id }], }, Period: 300, Stat: "Maximum", }, ReturnData: true, }); }); const metricsByInstance = {}; instanceIds.forEach((id) => { metricsByInstance[id] = { cpuUtilization: null, memoryUtilization: null, hasMemoryAgent: false, networkInBps: null, networkOutBps: null, statusCheckFailed: 0, }; }); // CloudWatch allows up to 500 queries per call. Chunk if needed. const chunkSize = 100; for (let i = 0; i < queries.length; i += chunkSize) { const chunk = queries.slice(i, i + chunkSize); try { const cmd = new GetMetricDataCommand({ StartTime: startTime, EndTime: now, MetricDataQueries: chunk, }); const response = await cw.send(cmd); if (response.MetricDataResults) { for (const res of response.MetricDataResults) { const parts = res.Id.split("_"); const type = parts.slice(0, -1).join("_"); const idx = parseInt(parts[parts.length - 1], 10); const instId = instanceIds[idx]; if (!instId || !metricsByInstance[instId]) continue; const val = res.Values && res.Values.length > 0 ? res.Values[0] : null; if (type === "cpu") { metricsByInstance[instId].cpuUtilization = val !== null ? Math.round(val * 100) / 100 : null; } else if (type === "mem") { if (val !== null) { metricsByInstance[instId].memoryUtilization = Math.round(val * 100) / 100; metricsByInstance[instId].hasMemoryAgent = true; } } else if (type === "net_in") { // Convert 300s average sum to Bytes/sec metricsByInstance[instId].networkInBps = val !== null ? Math.round(val / 300) : null; } else if (type === "net_out") { metricsByInstance[instId].networkOutBps = val !== null ? Math.round(val / 300) : null; } else if (type === "status") { metricsByInstance[instId].statusCheckFailed = val !== null ? Math.round(val) : 0; } } } } catch (err) { log.warn("aws", `Failed to fetch metric chunk: ${err.message}`); } } return metricsByInstance; } /** * Fetch historical time-series metrics for an instance * @param {object} account Account object * @param {string} region Region * @param {string} instanceId Instance ID * @param {number} [hours=1] Historical period in hours * @returns {Promise} Time-series data */ static async getInstanceHistory(account, region, instanceId, hours = 1) { const targetRegion = region || account.region || "us-east-1"; const config = AWSAccount.getClientConfig(account, targetRegion); const cw = new CloudWatchClient(config); const now = new Date(); const startTime = new Date(now.getTime() - hours * 60 * 60 * 1000); // Period: 60s for 1h, 300s for >1h const period = hours <= 2 ? 60 : 300; const queries = [ { Id: "cpu", MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "CPUUtilization", Dimensions: [{ Name: "InstanceId", Value: instanceId }], }, Period: period, Stat: "Average", }, ReturnData: true, }, { Id: "mem", MetricStat: { Metric: { Namespace: "CWAgent", MetricName: "mem_used_percent", Dimensions: [{ Name: "InstanceId", Value: instanceId }], }, Period: period, Stat: "Average", }, ReturnData: true, }, { Id: "net_in", MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "NetworkIn", Dimensions: [{ Name: "InstanceId", Value: instanceId }], }, Period: period, Stat: "Average", }, ReturnData: true, }, { Id: "net_out", MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "NetworkOut", Dimensions: [{ Name: "InstanceId", Value: instanceId }], }, Period: period, Stat: "Average", }, ReturnData: true, }, { Id: "disk_read", MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "DiskReadBytes", Dimensions: [{ Name: "InstanceId", Value: instanceId }], }, Period: period, Stat: "Average", }, ReturnData: true, }, { Id: "disk_write", MetricStat: { Metric: { Namespace: "AWS/EC2", MetricName: "DiskWriteBytes", Dimensions: [{ Name: "InstanceId", Value: instanceId }], }, Period: period, Stat: "Average", }, ReturnData: true, }, ]; const cmd = new GetMetricDataCommand({ StartTime: startTime, EndTime: now, MetricDataQueries: queries, }); const response = await cw.send(cmd); const result = { instanceId, period, hasMemoryAgent: false, metrics: { cpu: [], mem: [], netIn: [], netOut: [], diskRead: [], diskWrite: [], }, }; if (response.MetricDataResults) { for (const r of response.MetricDataResults) { const metricKey = r.Id === "cpu" ? "cpu" : r.Id === "mem" ? "mem" : r.Id === "net_in" ? "netIn" : r.Id === "net_out" ? "netOut" : r.Id === "disk_read" ? "diskRead" : r.Id === "disk_write" ? "diskWrite" : null; if (!metricKey) continue; const points = []; const timestamps = r.Timestamps || []; const values = r.Values || []; for (let i = 0; i < timestamps.length; i++) { points.push({ time: new Date(timestamps[i]).toISOString(), value: Math.round(values[i] * 100) / 100, }); } // Sort chronologically ascending points.sort((a, b) => new Date(a.time) - new Date(b.time)); result.metrics[metricKey] = points; if (metricKey === "mem" && points.length > 0) { result.hasMemoryAgent = true; } } } return result; } } module.exports = { AWSAccount };