523 lines
19 KiB
HTML
523 lines
19 KiB
HTML
<!doctype html>
|
|
|
|
<!-- Copyright (c) 2021 Battelle Energy Alliance, LLC. All rights reserved. -->
|
|
|
|
<!--[if lt IE 7 ]> <html lang="en" class="no-js ie6"> <![endif]-->
|
|
<!--[if IE 7 ]> <html lang="en" class="no-js ie7"> <![endif]-->
|
|
<!--[if IE 8 ]> <html lang="en" class="no-js ie8"> <![endif]-->
|
|
<!--[if IE 9 ]> <html lang="en" class="no-js ie9"> <![endif]-->
|
|
<!--[if (gt IE 9)|!(IE)]><!--> <html lang="en" class="no-js"> <!--<![endif]-->
|
|
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
|
<link rel="stylesheet" href="name-map-ui/mapping.css">
|
|
<script src="name-map-ui/jquery.min.js"></script>
|
|
<title>Host and Network Segment Name Mapping</title>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<div id="container" class="mapping-page">
|
|
<div id="main">
|
|
<div class="c1">
|
|
<img class="center" src="name-map-ui/Malcolm_banner.png" alt="">
|
|
<h1>Host and Network Segment Name Mapping</h1>
|
|
<div id="mapping">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="sort" title="Mapping type (host or network segment)" data-sort="type">Type</th>
|
|
<th class="sort" title="Host address (IP or MAC) or CIDR-formatted IP range" data-sort="address">Address</th>
|
|
<th class="sort" title="Host or network segment name" data-sort="name">Name</th>
|
|
<th class="sort" title="Assign name only if the event contains this tag (optional)" data-sort="tag">Tag</th>
|
|
<th colspan="2">
|
|
<input type="text" class="search" placeholder="🔎 Search mappings" />
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody class="list"/>
|
|
<tfoot>
|
|
<tr>
|
|
<td class="type">
|
|
<input type="hidden" id="id-field" />
|
|
<select id="type-field">
|
|
<option value="host">🖳 host</option>
|
|
<option value="segment">🖧 segment</option>
|
|
</select>
|
|
</td>
|
|
<td class="address">
|
|
<input type="text" id="address-field" placeholder="Address" />
|
|
</td>
|
|
<td class="name">
|
|
<input type="text" id="name-field" placeholder="Name" />
|
|
</td>
|
|
<td class="tag">
|
|
<input type="text" id="tag-field" placeholder="Tag (optional)" />
|
|
</td>
|
|
<td class="add" colspan="2">
|
|
<button title="Create new mapping" class="add-btn" id="add-btn">💾</button>
|
|
<button title="Modify mapping" class="update-btn" id="update-btn">💾</button>
|
|
<button title="Cancel changes" class="cancel-btn" id="cancel-btn">❌</button>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="foot">
|
|
<input type="file" id="import-file" name="import-file" style="display:none;"/>
|
|
<button title="Import mappings from a JSON file" class="import-btn" id="import-btn">📥 Import</button>
|
|
</td>
|
|
<td class="foot">
|
|
<button title="Download mappings as a JSON file" class="export-btn" id="export-btn">📤 Export</button>
|
|
</td>
|
|
<td class="foot" colspan="2">
|
|
<button title="Write mappings to Malcolm's net-map.json" class="save-btn" id="save-btn">💾 Save Mappings</button>
|
|
<input type="hidden" value=0 id="save-state"/>
|
|
</td>
|
|
<td class="foot" colspan="2">
|
|
<button title="Restart log ingestion, parsing and enrichment" class="restart-btn" id="restart-btn">🔁 Restart Logstash</button>
|
|
</td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div> <!-- end of #container .mapping-page -->
|
|
|
|
<script src="name-map-ui/list.min.js"></script>
|
|
<script type="text/javascript">
|
|
|
|
// define value names and template for new list items
|
|
var options = {
|
|
valueNames: [ 'id', 'type', 'address', 'name', 'tag' ],
|
|
item: '<tr><td class="id" style="display:none;"><td class="type"></td></td><td class="address"></td><td class="name"></td><td class="tag"></td><td class="update"><button title="Modify this mapping" class="edit-item-btn">📝</button></td><td class="remove"><button title="Remove this entry" class="remove-item-btn">🚫</button></td></tr>'
|
|
};
|
|
|
|
// initialize list and other elements
|
|
var mappingList = new List('mapping', options);
|
|
|
|
var idField = $('#id-field'),
|
|
typeField = $('#type-field'),
|
|
addressField = $('#address-field'),
|
|
nameField = $('#name-field'),
|
|
tagField = $('#tag-field'),
|
|
addBtn = $('#add-btn'),
|
|
updateBtn = $('#update-btn').hide(),
|
|
cancelBtn = $('#cancel-btn').hide(),
|
|
saveBtn = $('#save-btn'),
|
|
saveState = $('#save-state'),
|
|
exportBtn = $('#export-btn'),
|
|
importBtn = $('#import-btn'),
|
|
importFile = $('#import-file'),
|
|
restartBtn = $('#restart-btn'),
|
|
removeBtns = $('.remove-item-btn'),
|
|
editBtns = $('.edit-item-btn');
|
|
|
|
// sets callbacks to the buttons in the list
|
|
refreshCallbacks();
|
|
|
|
function focusItem(itemId) {
|
|
// scroll back up to item that was updated
|
|
// todo: is there a more efficient way to do this? there's got to be with list.js
|
|
for (const editBtnKey in editBtns) {
|
|
if (editBtnKey && (editBtns[editBtnKey])) {
|
|
entry = (editBtns[editBtnKey].closest) ? editBtns[editBtnKey].closest('tr').firstChild : null;
|
|
if (entry && entry.firstChild && entry.firstChild.data && (String(entry.firstChild.data) === String(itemId))) {
|
|
editBtns[editBtnKey].focus();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} // focusItem
|
|
|
|
// store a new value from the edit inputs into the list
|
|
addBtn.click(function() {
|
|
const type = typeField.val();
|
|
const address = addressField.val().trim();
|
|
const name = nameField.val().trim();
|
|
const tag = tagField.val().trim();
|
|
if ((file_types_valid.has(type)) &&
|
|
(address) && (address.length > 0) &&
|
|
(name) && (name.length > 0)) {
|
|
mappingList.add({
|
|
// todo: better random ID generator (if necessary)
|
|
id: Math.floor(Math.random()*110000),
|
|
type: type,
|
|
address: address,
|
|
name: name,
|
|
tag: tag
|
|
});
|
|
saveState.val(parseInt(saveState.val()) + 1);
|
|
clearFields();
|
|
refreshCallbacks();
|
|
}
|
|
});
|
|
|
|
// update an item being edited back into the list
|
|
updateBtn.click(function() {
|
|
const itemId = idField.val();
|
|
const type = typeField.val();
|
|
const address = addressField.val().trim();
|
|
const name = nameField.val().trim();
|
|
const tag = tagField.val().trim();
|
|
if ((file_types_valid.has(type)) &&
|
|
(address) && (address.length > 0) &&
|
|
(name) && (name.length > 0)) {
|
|
var item = mappingList.get('id', itemId)[0];
|
|
item.values({
|
|
id: itemId,
|
|
type: type,
|
|
address: addressField.val(),
|
|
name: nameField.val(),
|
|
tag: tagField.val()
|
|
});
|
|
saveState.val(parseInt(saveState.val()) + 1);
|
|
clearFields();
|
|
updateBtn.hide();
|
|
cancelBtn.hide();
|
|
addBtn.show();
|
|
focusItem(itemId);
|
|
}
|
|
});
|
|
|
|
// revert edits without updating
|
|
cancelBtn.click(function() {
|
|
const itemId = idField.val();
|
|
clearFields();
|
|
updateBtn.hide();
|
|
cancelBtn.hide();
|
|
addBtn.show();
|
|
focusItem(itemId);
|
|
});
|
|
|
|
function hasJsonStructure(str) {
|
|
if (typeof str !== 'string') return false;
|
|
try {
|
|
const result = JSON.parse(str);
|
|
const type = Object.prototype.toString.call(result);
|
|
return type === '[object Object]'
|
|
|| type === '[object Array]';
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function download(filename, text, contentType="text/plain") {
|
|
var element = document.createElement('a');
|
|
element.setAttribute('href', 'data:' + contentType + ';charset=utf-8,' + encodeURIComponent(text));
|
|
element.setAttribute('download', filename);
|
|
element.style.display = 'none';
|
|
document.body.appendChild(element);
|
|
element.click();
|
|
document.body.removeChild(element);
|
|
}
|
|
|
|
async function uploadNameMap(jsonStr) {
|
|
let formData = new FormData();
|
|
let upBlob = new Blob([jsonStr], { type: 'application/json' });
|
|
formData.append("upfile", upBlob);
|
|
try {
|
|
let r = await fetch('name-map-ui/upload.php', {method: "POST", body: formData});
|
|
} catch(e) {
|
|
console.log('uploadNameMap error: ', e);
|
|
}
|
|
}
|
|
|
|
async function restartLogstash() {
|
|
let formData = new FormData();
|
|
formData.append("save-state", saveState.val());
|
|
try {
|
|
let r = await fetch('name-map-ui/restart-logstash.php', {method: "POST", body: formData});
|
|
} catch(e) {
|
|
console.log('restartLogstash error: ', e);
|
|
}
|
|
}
|
|
|
|
saveBtn.click(function() {
|
|
if ((mappingList) && (confirm("Save name mappings?") === true )) {
|
|
// create list of all items (minus the random "id" index field)
|
|
let items = mappingList.items.map(
|
|
function(item) { return (({ id, ...o }) => o)(item.values()); }
|
|
);
|
|
uploadNameMap(JSON.stringify(items, null, 2));
|
|
saveState.val(0);
|
|
}
|
|
});
|
|
|
|
exportBtn.click(function() {
|
|
if (mappingList) {
|
|
// create list of all items (minus the random "id" index field)
|
|
let items = mappingList.items.map(
|
|
function(item) { return (({ id, ...o }) => o)(item.values()); }
|
|
);
|
|
download('net-map.json', JSON.stringify(items, null, 2), "application/json");
|
|
}
|
|
});
|
|
|
|
importFile.change(function (event) {
|
|
if ((event) && (event.target) && (event.target.files) && (event.target.files.length > 0)) {
|
|
let f = event.target.files[0];
|
|
if (((f.type === 'application/json') || (f.type === 'text/plain')) && (f.size <= 67108864)) {
|
|
let reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
if (hasJsonStructure(e.target.result)) {
|
|
// replace current map list with that from imported file
|
|
populateMapList([{fileType: null,
|
|
fileSrc: file_spec_in_memory,
|
|
filePath: e.target.result}], -1);
|
|
|
|
} else {
|
|
alert('Invalid file format: ' + f.name + ', ' + f.type + ', ' + f.size + ' bytes');
|
|
}
|
|
};
|
|
reader.readAsText(f);
|
|
} else {
|
|
alert('Invalid file: ' + f.name + ', ' + f.type + ', ' + f.size + ' bytes');
|
|
}
|
|
}
|
|
});
|
|
|
|
importBtn.click(function() {
|
|
if ((mappingList) && (confirm("Clear and replace current name mappings with those from a file?") === true )) {
|
|
importFile.click();
|
|
}
|
|
});
|
|
|
|
restartBtn.click(function() {
|
|
let confirmMsg = '';
|
|
if (parseInt(saveState.val()) > 0) {
|
|
saveBtn.click();
|
|
}
|
|
confirmMsg = confirmMsg.concat("Apply the saved name mappings and restart Logstash?");
|
|
if (confirm(confirmMsg) == true ) {
|
|
restartLogstash();
|
|
alert("Logstash is restarting in the background.\nLog ingestion will be resumed in a few minutes.");
|
|
}
|
|
});
|
|
|
|
// apply callbacks for the buttons on the list items
|
|
function refreshCallbacks() {
|
|
|
|
removeBtns = $(removeBtns.selector);
|
|
editBtns = $(editBtns.selector);
|
|
|
|
// delete item
|
|
removeBtns.click(function() {
|
|
var itemId = $(this).closest('tr').find('.id').text();
|
|
mappingList.remove('id', itemId);
|
|
saveState.val(parseInt(saveState.val()) + 1);
|
|
});
|
|
|
|
// populate the edit inputs from the values of row where Edit was clicked
|
|
editBtns.click(function() {
|
|
var itemId = $(this).closest('tr').find('.id').text();
|
|
var itemValues = mappingList.get('id', itemId)[0].values();
|
|
idField.val(itemValues.id);
|
|
typeField.val(itemValues.type);
|
|
addressField.val(itemValues.address);
|
|
nameField.val(itemValues.name);
|
|
tagField.val(itemValues.tag);
|
|
|
|
updateBtn.show();
|
|
cancelBtn.show();
|
|
addBtn.hide();
|
|
window.scrollTo(0,document.body.scrollHeight);
|
|
// focus and scroll to editing fields
|
|
addressField.focus();
|
|
addressField.select();
|
|
});
|
|
}
|
|
|
|
// clear edit inputs
|
|
function clearFields() {
|
|
typeField.val('host');
|
|
addressField.val('');
|
|
nameField.val('');
|
|
tagField.val('');
|
|
}
|
|
|
|
const file_type_txt_cidr = 'segment';
|
|
const file_type_txt_host = 'host';
|
|
const file_types_valid = new Set([file_type_txt_cidr, file_type_txt_host]);
|
|
|
|
const file_spec_on_server = 'SERVER';
|
|
const file_spec_in_memory = 'LOCALSTR';
|
|
const file_specs_valid = new Set([file_spec_on_server, file_spec_in_memory]);
|
|
|
|
|
|
// given the text of a mapping file (eg., net-map.json, cidr-map.txt, host-map.txt)
|
|
// populate an array containing the mapping entries from that file
|
|
function parseMapFileText(fileTxt, mapType=null) {
|
|
let result;
|
|
|
|
if (hasJsonStructure(fileTxt)) {
|
|
// is already JSON text, should be the format we want
|
|
result = JSON.parse(fileTxt);
|
|
|
|
} else {
|
|
// parse the lines from the cidr-map.txt/host-map.txt format
|
|
result = [];
|
|
const lines = fileTxt.split(/\r?\n/);
|
|
for (lineIdx in lines) {
|
|
let line = lines[lineIdx];
|
|
if (!line.startsWith("#")) {
|
|
const vals = line.split("|");
|
|
const valsLen = vals.length;
|
|
if ((valsLen >= 2) && (valsLen < 4)) {
|
|
const name = vals[1].trim();
|
|
const tag = (valsLen > 2) ? vals[2].trim() : "";
|
|
const addrs = vals[0].trim().split(",");
|
|
for (addrIdx in addrs) {
|
|
result.push({
|
|
type: mapType,
|
|
address: addrs[addrIdx],
|
|
name: name,
|
|
tag: tag
|
|
});
|
|
}
|
|
} // line has the right number of delimited fields
|
|
} // line is not a # comment
|
|
} // for (lineIdx in lines)
|
|
} // if/else hasJsonStructure
|
|
|
|
if (!Array.isArray(result)) {
|
|
result = null;
|
|
}
|
|
return result;
|
|
|
|
} // parseMapFileText
|
|
|
|
|
|
// given an array of filespecs (eg.,
|
|
// [{fileType: file_type_txt_cidr, filePath: 'maps/cidr-map.txt'},
|
|
// {fileType: file_type_txt_host, filePath: 'maps/host-map.txt'},
|
|
// {fileType: null, filePath: 'maps/net-map.json'}]
|
|
// read and return an array of the mappings within those files
|
|
|
|
function loadMapsFromFiles(fileSpecs, cb) {
|
|
|
|
let result = []
|
|
|
|
if ((fileSpecs.length > 0) && (fileSpecs[0]) && (file_specs_valid.has(fileSpecs[0].fileSrc))) {
|
|
|
|
// if this is a delimited file (not JSON) mark the type
|
|
let mapType = null;
|
|
if (fileSpecs[0].fileType === file_type_txt_cidr) {
|
|
mapType = "segment";
|
|
} else if (fileSpecs[0].fileType === file_type_txt_host) {
|
|
mapType = "host";
|
|
}
|
|
// else the type is per-item in the JSON
|
|
|
|
if (fileSpecs[0].fileSrc === file_spec_in_memory) {
|
|
// the text of the file is already in memory
|
|
result = parseMapFileText(fileSpecs[0].filePath, mapType);
|
|
return cb(result);
|
|
|
|
} else {
|
|
// GET the file from the server
|
|
var txtFile = new XMLHttpRequest();
|
|
txtFile.open("GET", fileSpecs[0].filePath, true);
|
|
txtFile.send();
|
|
txtFile.onreadystatechange = function() {
|
|
if (txtFile.status === 200) {
|
|
if (txtFile.readyState === 4) {
|
|
result = parseMapFileText(txtFile.responseText, mapType);
|
|
if (fileSpecs.length > 1) {
|
|
// we have processed this fileSpec, process the next
|
|
loadMapsFromFiles(fileSpecs.slice(1), function(nextFileResult) {
|
|
return cb(result.concat(nextFileResult));
|
|
});
|
|
} else {
|
|
// we have processed this fileSpec, and there are no more to process
|
|
return cb(result);
|
|
}
|
|
} // txtFile.readyState is ready
|
|
|
|
} else if (fileSpecs.length > 1) {
|
|
// the GET returned an error, process the next fileSpec
|
|
loadMapsFromFiles(fileSpecs.slice(1), function(nextFileResult) {
|
|
return cb(result.concat(nextFileResult));
|
|
});
|
|
|
|
} else {
|
|
// the GET returned an error, and there are no more fileSpecs to process
|
|
return cb(result);
|
|
}
|
|
|
|
} // txtFile.onreadystatechange
|
|
}
|
|
|
|
} else if (fileSpecs.length > 1) {
|
|
// the first fileSpec is invalid, process the next
|
|
loadMapsFromFiles(fileSpecs.slice(1), function(nextFileResult) {
|
|
return cb(result.concat(nextFileResult));
|
|
});
|
|
|
|
} else {
|
|
// the first fileSpec is missing or invalid, and there are no more to process
|
|
return cb(result);
|
|
}
|
|
|
|
} // loadMapsFromFiles
|
|
|
|
function populateMapList(fileSpecs, newSaveState=0) {
|
|
// load old delimited plain text format
|
|
// IP or MAC address to host name map:
|
|
// address|host name|required tag
|
|
// CIDR to network segment format:
|
|
// IP(s)|segment name|required tag
|
|
// and JSON-formatted native format:
|
|
// [
|
|
// {
|
|
// "type": "segment",
|
|
// "address": "172.16.0.0/24",
|
|
// "name": "home",
|
|
// "tag": ""
|
|
// }, ...
|
|
mappingList.clear();
|
|
loadMapsFromFiles(fileSpecs, function (mapsArray) {
|
|
|
|
// convert to a hash to resolve any duplicates
|
|
var mapsHash = mapsArray.reduce(function(acc, cur) {
|
|
acc[cur.type + '|' + cur.address] = cur;
|
|
return acc;
|
|
}, {});
|
|
|
|
// populate the list.js List object
|
|
for (mapKey in mapsHash) {
|
|
let map = mapsHash[mapKey];
|
|
if ((file_types_valid.has(map.type)) &&
|
|
(map.address) && (map.address.length > 0) &&
|
|
(map.name) && (map.name.length > 0)) {
|
|
mappingList.add({
|
|
// todo: better random ID generator (if necessary)
|
|
id: Math.floor(Math.random()*110000),
|
|
type: map.type,
|
|
address: map.address,
|
|
name: map.name,
|
|
tag: map.tag ? map.tag : ""
|
|
});
|
|
}
|
|
}
|
|
mappingList.sort('address');
|
|
refreshCallbacks();
|
|
saveState.val((newSaveState >= 0) ? newSaveState : Math.max(Object.keys(mapsHash).length, 1));
|
|
});
|
|
} // populateMapList
|
|
|
|
// initial page load from cidr-map.txt, host-map.txt, and/or net-map.json
|
|
populateMapList([{fileType: file_type_txt_cidr,
|
|
fileSrc: file_spec_on_server,
|
|
filePath: 'name-map-ui/maps/cidr-map.txt',},
|
|
{fileType: file_type_txt_host,
|
|
fileSrc: file_spec_on_server,
|
|
filePath: 'name-map-ui/maps/host-map.txt'},
|
|
{fileType: null,
|
|
fileSrc: file_spec_on_server,
|
|
filePath: 'name-map-ui/maps/net-map.json'}]);
|
|
|
|
</script> <!-- end of ListJS-related scripts -->
|
|
|
|
</body>
|
|
</html> |