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> |