added Malcolm
This commit is contained in:
		
							
								
								
									
										56
									
								
								Vagrant/resources/malcolm/name-map-ui/config/fpm-pool.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								Vagrant/resources/malcolm/name-map-ui/config/fpm-pool.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | ||||
| [global] | ||||
| ; Log to /dev/fd/1 for docker | ||||
| error_log = /dev/fd/1 | ||||
|  | ||||
| [www] | ||||
| ; The address on which to accept FastCGI requests. | ||||
| ; Valid syntaxes are: | ||||
| ;   'ip.add.re.ss:port'    - to listen on a TCP socket to a specific IPv4 address on | ||||
| ;                            a specific port; | ||||
| ;   '[ip:6:addr:ess]:port' - to listen on a TCP socket to a specific IPv6 address on | ||||
| ;                            a specific port; | ||||
| ;   'port'                 - to listen on a TCP socket to all addresses | ||||
| ;                            (IPv6 and IPv4-mapped) on a specific port; | ||||
| ;   '/path/to/unix/socket' - to listen on a unix socket. | ||||
| ; Note: This value is mandatory. | ||||
| listen = 127.0.0.1:9000 | ||||
|  | ||||
| ; Enable status page | ||||
| pm.status_path = /fpm-status | ||||
|  | ||||
| ; Ondemand process manager | ||||
| pm = ondemand | ||||
|  | ||||
| ; The number of child processes to be created when pm is set to 'static' and the | ||||
| ; maximum number of child processes when pm is set to 'dynamic' or 'ondemand'. | ||||
| ; This value sets the limit on the number of simultaneous requests that will be | ||||
| ; served. Equivalent to the ApacheMaxClients directive with mpm_prefork. | ||||
| ; Equivalent to the PHP_FCGI_CHILDREN environment variable in the original PHP | ||||
| ; CGI. The below defaults are based on a server without much resources. Don't | ||||
| ; forget to tweak pm.* to fit your needs. | ||||
| ; Note: Used when pm is set to 'static', 'dynamic' or 'ondemand' | ||||
| ; Note: This value is mandatory. | ||||
| pm.max_children = 100 | ||||
|  | ||||
| ; The number of seconds after which an idle process will be killed. | ||||
| ; Note: Used only when pm is set to 'ondemand' | ||||
| ; Default Value: 10s | ||||
| pm.process_idle_timeout = 10s; | ||||
|  | ||||
| ; The number of requests each child process should execute before respawning. | ||||
| ; This can be useful to work around memory leaks in 3rd party libraries. For | ||||
| ; endless request processing specify '0'. Equivalent to PHP_FCGI_MAX_REQUESTS. | ||||
| ; Default Value: 0 | ||||
| pm.max_requests = 1000 | ||||
|  | ||||
| ; Make sure the FPM workers can reach the environment variables for configuration | ||||
| clear_env = no | ||||
|  | ||||
| ; Catch output from PHP | ||||
| catch_workers_output = yes | ||||
|  | ||||
| ; Remove the 'child 10 said into stderr' prefix in the log and only show the actual message | ||||
| decorate_workers_output = no | ||||
|  | ||||
| ; Enable ping page to use in healthcheck | ||||
| ping.path = /fpm-ping | ||||
							
								
								
									
										92
									
								
								Vagrant/resources/malcolm/name-map-ui/config/nginx.conf
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								Vagrant/resources/malcolm/name-map-ui/config/nginx.conf
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,92 @@ | ||||
| worker_processes 1; | ||||
| error_log stderr warn; | ||||
| pid /tmp/nginx.pid; | ||||
|  | ||||
| events { | ||||
|     worker_connections 1024; | ||||
| } | ||||
|  | ||||
| http { | ||||
|     include mime.types; | ||||
|     default_type application/octet-stream; | ||||
|  | ||||
|     # Define custom log format to include reponse times | ||||
|     log_format main_timed '$remote_addr - $remote_user [$time_local] "$request" ' | ||||
|                           '$status $body_bytes_sent "$http_referer" ' | ||||
|                           '"$http_user_agent" "$http_x_forwarded_for" ' | ||||
|                           '$request_time $upstream_response_time $pipe $upstream_cache_status'; | ||||
|  | ||||
|     access_log /var/log/nginx/access.log main_timed; | ||||
|     error_log /var/log/nginx/error.log notice; | ||||
|  | ||||
|     keepalive_timeout 65; | ||||
|  | ||||
|     # Write temporary files to /tmp so they can be created as a non-privileged user | ||||
|     client_body_temp_path /tmp/client_temp; | ||||
|     proxy_temp_path /tmp/proxy_temp_path; | ||||
|     fastcgi_temp_path /tmp/fastcgi_temp; | ||||
|     uwsgi_temp_path /tmp/uwsgi_temp; | ||||
|     scgi_temp_path /tmp/scgi_temp; | ||||
|  | ||||
|     # Default server definition | ||||
|     server { | ||||
|         listen 8080 default_server; | ||||
|         server_name _; | ||||
|  | ||||
|         sendfile off; | ||||
|  | ||||
|         root /var/www/html; | ||||
|         index index.php index.html; | ||||
|  | ||||
|         location / { | ||||
|             # First attempt to serve request as file, then | ||||
|             # as directory, then fall back to index.php | ||||
|             try_files $uri $uri/ /index.php?q=$uri&$args; | ||||
|         } | ||||
|  | ||||
|         location ~* maps/.+\.(txt|json)$ { | ||||
|           expires -1; | ||||
|           add_header 'Cache-Control' 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0'; | ||||
|         } | ||||
|  | ||||
|         # Redirect server error pages to the static page /50x.html | ||||
|         error_page 500 502 503 504 /50x.html; | ||||
|         location = /50x.html { | ||||
|             root /var/lib/nginx/html; | ||||
|         } | ||||
|  | ||||
|         # Pass the PHP scripts to PHP-FPM listening on 127.0.0.1:9000 | ||||
|         location ~ \.php$ { | ||||
|             try_files $uri =404; | ||||
|             fastcgi_split_path_info ^(.+\.php)(/.+)$; | ||||
|             fastcgi_pass 127.0.0.1:9000; | ||||
|             fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; | ||||
|             fastcgi_param SCRIPT_NAME $fastcgi_script_name; | ||||
|             fastcgi_index index.php; | ||||
|             include fastcgi_params; | ||||
|         } | ||||
|  | ||||
|         location ~* \.(jpg|jpeg|gif|png|css|js|ico|xml)$ { | ||||
|             expires 5d; | ||||
|         } | ||||
|  | ||||
|         # Deny access to . files, for security | ||||
|         location ~ /\. { | ||||
|             log_not_found off; | ||||
|             deny all; | ||||
|         } | ||||
|  | ||||
|         # Allow fpm ping and status from localhost | ||||
|         location ~ ^/(fpm-status|fpm-ping)$ { | ||||
|             access_log off; | ||||
|             allow 127.0.0.1; | ||||
|             deny all; | ||||
|             fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; | ||||
|             include fastcgi_params; | ||||
|             fastcgi_pass 127.0.0.1:9000; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     # Include other server configs | ||||
|     include /etc/nginx/conf.d/*.conf; | ||||
| } | ||||
							
								
								
									
										7
									
								
								Vagrant/resources/malcolm/name-map-ui/config/php.ini
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								Vagrant/resources/malcolm/name-map-ui/config/php.ini
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| [PHP] | ||||
| file_uploads = On | ||||
| upload_max_filesize = 64M | ||||
| max_file_uploads = 4 | ||||
|  | ||||
| [Date] | ||||
| date.timezone="UTC" | ||||
| @@ -0,0 +1,10 @@ | ||||
| ; Copyright (c) 2021 Battelle Energy Alliance, LLC.  All rights reserved. | ||||
|  | ||||
| [supervisord] | ||||
| nodaemon=true | ||||
| logfile=/dev/null | ||||
| logfile_maxbytes=0 | ||||
| pidfile=/tmp/supervisord-logstash.pid | ||||
|  | ||||
| [supervisorctl] | ||||
| serverurl=http://logstash:9001 | ||||
| @@ -0,0 +1,72 @@ | ||||
| ; Copyright (c) 2021 Battelle Energy Alliance, LLC.  All rights reserved. | ||||
|  | ||||
| [unix_http_server] | ||||
| file=/tmp/supervisor-main.sock   ; (the path to the socket file) | ||||
| chmod=0700 | ||||
|  | ||||
| [supervisord] | ||||
| nodaemon=true | ||||
| logfile=/dev/null | ||||
| logfile_maxbytes=0 | ||||
| pidfile=/tmp/supervisor-main.pid | ||||
|  | ||||
| [rpcinterface:supervisor] | ||||
| supervisor.rpcinterface_factory=supervisor.rpcinterface:make_main_rpcinterface | ||||
|  | ||||
| [supervisorctl] | ||||
| serverurl=unix:///tmp/supervisor-main.sock | ||||
|  | ||||
| [program:php-fpm] | ||||
| command=/usr/sbin/php-fpm7 -F | ||||
| stopasgroup=true | ||||
| killasgroup=true | ||||
| stdout_logfile=/dev/fd/1 | ||||
| stdout_logfile_maxbytes=0 | ||||
| redirect_stderr=true | ||||
| autorestart=false | ||||
| startretries=0 | ||||
|  | ||||
| [program:nginx] | ||||
| command=/usr/sbin/nginx -g 'daemon off;' | ||||
| stopasgroup=true | ||||
| killasgroup=true | ||||
| stdout_logfile=/dev/fd/1 | ||||
| stdout_logfile_maxbytes=0 | ||||
| redirect_stderr=true | ||||
| autorestart=false | ||||
| startretries=0 | ||||
|  | ||||
| [program:logaccess] | ||||
| command=/usr/bin/tail -F /var/log/nginx/access.log | ||||
| startsecs=10 | ||||
| startretries=2000000000 | ||||
| stopasgroup=true | ||||
| killasgroup=true | ||||
| stdout_logfile=/dev/fd/1 | ||||
| stdout_logfile_maxbytes=0 | ||||
| stderr_logfile=/dev/null | ||||
| stdout_logfile_maxbytes=0 | ||||
| redirect_stderr=false | ||||
|  | ||||
| [program:logerrors] | ||||
| command=/usr/bin/tail -F /var/log/nginx/error.log | ||||
| startsecs=10 | ||||
| startretries=2000000000 | ||||
| stopasgroup=true | ||||
| killasgroup=true | ||||
| stdout_logfile=/dev/fd/1 | ||||
| stdout_logfile_maxbytes=0 | ||||
| stderr_logfile=/dev/null | ||||
| stdout_logfile_maxbytes=0 | ||||
| redirect_stderr=false | ||||
|  | ||||
| [program:watch-upload] | ||||
| command=/bin/bash -c "sleep 15 && /usr/local/bin/name-map-save-watch.sh" | ||||
| startsecs=20 | ||||
| startretries=1 | ||||
| stopasgroup=true | ||||
| killasgroup=true | ||||
| directory=/var/www/html/upload | ||||
| stdout_logfile=/dev/fd/1 | ||||
| stdout_logfile_maxbytes=0 | ||||
| redirect_stderr=true | ||||
							
								
								
									
										22
									
								
								Vagrant/resources/malcolm/name-map-ui/scripts/name-map-save-watch.sh
									
									
									
									
									
										Executable file
									
								
							
							
						
						
									
										22
									
								
								Vagrant/resources/malcolm/name-map-ui/scripts/name-map-save-watch.sh
									
									
									
									
									
										Executable file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| #!/bin/bash | ||||
|  | ||||
| # Copyright (c) 2021 Battelle Energy Alliance, LLC.  All rights reserved. | ||||
|  | ||||
| PROCESS_DIR=${NAME_MAP_SAVE_DIR:-/var/www/html/upload/} | ||||
| DEST_MAP=${NAME_MAP_JSON:-/var/www/html/maps/net-map.json} | ||||
|  | ||||
| inotifywait -m -e close_write --format '%w%f' "${PROCESS_DIR}" | while read NEWFILE | ||||
| do | ||||
|   sleep 0.1 | ||||
|   # verify that the file is json|txt and that it is valid JSON | ||||
|   FILEMIME=$(file -b --mime-type "$NEWFILE") | ||||
|   if ( echo "$FILEMIME" | grep -q -e "\(application/json\|text/plain\)" ) && ( python3 -mjson.tool "$NEWFILE" >/dev/null 2>&1 ); then | ||||
|     # move the new net-map.json file into its final location | ||||
|     # (done like this with "tee" since we may not be able to cp overwrite a volume-mounted file) | ||||
|     (>/dev/null tee "$DEST_MAP" < "$NEWFILE") && echo "\"$NEWFILE\" -> \"$DEST_MAP\"" | ||||
|     rm -f "$NEWFILE" | ||||
|   else | ||||
|     # invalid or unhandled file type uploaded, delete it | ||||
|     (>&2 rm -f "$NEWFILE") && echo "Removed \"$NEWFILE\" (\"$FILEMIME\"): invalid file type or format" | ||||
|   fi | ||||
| done | ||||
							
								
								
									
										523
									
								
								Vagrant/resources/malcolm/name-map-ui/site/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										523
									
								
								Vagrant/resources/malcolm/name-map-ui/site/index.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,523 @@ | ||||
| <!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> | ||||
							
								
								
									
										229
									
								
								Vagrant/resources/malcolm/name-map-ui/site/mapping.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										229
									
								
								Vagrant/resources/malcolm/name-map-ui/site/mapping.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,229 @@ | ||||
| /* Copyright (c) 2021 Battelle Energy Alliance, LLC.  All rights reserved. */ | ||||
|  | ||||
| html { | ||||
|   font-family: sans-serif; | ||||
|   line-height: 1.15; | ||||
| } | ||||
|  | ||||
| body { | ||||
|   margin: 0; | ||||
|   font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; | ||||
|   font-size: 0.9375rem; | ||||
|   font-weight: 400; | ||||
|   line-height: 1.5; | ||||
|   color: #cecece; | ||||
|   text-align: left; | ||||
|   background-color: #272B30; | ||||
| } | ||||
|  | ||||
| body, | ||||
| div, | ||||
| dl, | ||||
| dt, | ||||
| dd, | ||||
| ul, | ||||
| ol, | ||||
| li, | ||||
| h1, | ||||
| h2, | ||||
| h3, | ||||
| h4, | ||||
| h5, | ||||
| h6, | ||||
| pre, | ||||
| form, | ||||
| fieldset, | ||||
| input, | ||||
| textarea, | ||||
| p, | ||||
| blockquote, | ||||
| th, | ||||
| td { | ||||
|   margin-top: 0; | ||||
|   margin-bottom: 0.5rem | ||||
| } | ||||
|  | ||||
| table { | ||||
|   border-collapse: collapse; | ||||
|   border-spacing: 0; | ||||
| } | ||||
|  | ||||
| fieldset, | ||||
| img { | ||||
|   border: 0; | ||||
| } | ||||
|  | ||||
| img.center { | ||||
|   display: block; | ||||
|   margin-left: auto; | ||||
|   margin-right: auto; | ||||
|   width: 50%; | ||||
| } | ||||
|  | ||||
| ol, | ||||
| ul, | ||||
| dl { | ||||
|   margin-top: 0; | ||||
|   margin-bottom: 0.5rem | ||||
| } | ||||
|  | ||||
| caption, | ||||
| th { | ||||
|   text-align: left; | ||||
| } | ||||
|  | ||||
| header { | ||||
|   float: left; | ||||
|   margin-bottom: 20px; | ||||
|   width: 100%; | ||||
| } | ||||
|  | ||||
| address { | ||||
|   margin-bottom: 1rem; | ||||
|   line-height: inherit | ||||
| } | ||||
|  | ||||
| #container { | ||||
|   width: 800px; | ||||
|   margin-top: auto; | ||||
|   margin-bottom: auto; | ||||
|   margin-right: auto; | ||||
|   margin-left: auto; | ||||
| } | ||||
|  | ||||
| .c1 { | ||||
|   width: 800px; | ||||
|   float: left; | ||||
| } | ||||
|  | ||||
| .c2 { | ||||
|   width: 355px; | ||||
|   float: left; | ||||
| } | ||||
|  | ||||
| .c3 { | ||||
|   width: 235px; | ||||
|   float: left; | ||||
| } | ||||
|  | ||||
| p { | ||||
|   margin-top: 0; | ||||
|   margin-bottom: 1rem | ||||
| } | ||||
|  | ||||
| h1 { | ||||
|   letter-spacing: 1px; | ||||
|   text-align: center; | ||||
|   text-shadow: #262729 0 -1px 0; | ||||
| } | ||||
|  | ||||
| h2 { | ||||
|   color: #bbb; | ||||
|   text-shadow: #262729 0 -1px 0; | ||||
|   font-weight: 300; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| input { | ||||
|   padding: 2px 10px; | ||||
|   border-radius: 10px; | ||||
|   border: solid 1px #555; | ||||
|   float: left; | ||||
|   margin-right: 10px; | ||||
|   margin-bottom: 15px; | ||||
| } | ||||
|  | ||||
| input:focus { | ||||
|   border: none; | ||||
|   outline: none; | ||||
|   color: #007bff; | ||||
|   font-weight: bold; | ||||
|   border-top: solid 1px #aaa; | ||||
|   border-right: solid 1px #e6e6e6; | ||||
|   border-bottom: solid 1px #e6e6e6; | ||||
|   border-left: solid 1px #aaa; | ||||
| } | ||||
|  | ||||
| table { | ||||
|   width: 100%; | ||||
|   margin-bottom: 10px; | ||||
| } | ||||
|  | ||||
| th { | ||||
|   background-color: rgba(23, 25, 28, .8); | ||||
|   border: solid 1px rgba(255, 255, 255, .1); | ||||
|   padding: 10px 10px; | ||||
|   text-align: center; | ||||
|   font-weight: bold; | ||||
|   color: #ddd; | ||||
|   width: auto; | ||||
| } | ||||
|  | ||||
| tr:hover { | ||||
|   background-color: #454c54; | ||||
| } | ||||
|  | ||||
| td { | ||||
|   padding: 10px 10px; | ||||
|   border: solid 1px rgba(255, 255, 255, .1); | ||||
|   width: auto; | ||||
| } | ||||
|  | ||||
| td.update, | ||||
| td.remove, | ||||
| td.type { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| td.address { | ||||
|   font-family: monospace; | ||||
| } | ||||
|  | ||||
| button.add-btn, | ||||
| button.update-btn, | ||||
| button.cancel-btn, | ||||
| button.edit-item-btn, | ||||
| button.remove-item-btn { | ||||
|   font-size: 1.33rem; | ||||
|   border: 2px #ddd; | ||||
|   border-radius: 4px; | ||||
| } | ||||
|  | ||||
| button.save-btn, | ||||
| button.export-btn, | ||||
| button.import-btn, | ||||
| button.restart-btn { | ||||
|   font-size: 0.9rem; | ||||
| } | ||||
|  | ||||
| #container.mapping-page { | ||||
|   margin-top: 10px; | ||||
| } | ||||
|  | ||||
| #mapping { | ||||
|   margin-bottom: 20px; | ||||
| } | ||||
|  | ||||
| #mapping td.update, | ||||
| #mapping td.remove { | ||||
|   width: 130px; | ||||
| } | ||||
|  | ||||
| #mapping td.add { | ||||
|   width: 300px; | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| #mapping td.foot { | ||||
|   text-align: center; | ||||
| } | ||||
|  | ||||
| #mapping input { | ||||
|   width: 130px; | ||||
|   margin: 0; | ||||
|   border-radius: 5px; | ||||
| } | ||||
|  | ||||
| #mapping input.search { | ||||
|   width: 185px; | ||||
| } | ||||
| @@ -0,0 +1,6 @@ | ||||
| <?php | ||||
| if (isset($_POST['save-state'])) { | ||||
|   $output = shell_exec('/usr/bin/supervisorctl -c /etc/supervisor/logstash/supervisord.conf restart logstash'); | ||||
|   echo "<pre>$output</pre>"; | ||||
| } | ||||
| ?> | ||||
							
								
								
									
										8
									
								
								Vagrant/resources/malcolm/name-map-ui/site/upload.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Vagrant/resources/malcolm/name-map-ui/site/upload.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| <html> | ||||
| <body> | ||||
|   <form enctype="multipart/form-data" action="upload.php" method="post"> | ||||
|     Choose a file to upload: <input name="upfile" type="file" /> | ||||
|     <input type="submit" value="Upload" /> | ||||
|   </form> | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										57
									
								
								Vagrant/resources/malcolm/name-map-ui/site/upload.php
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								Vagrant/resources/malcolm/name-map-ui/site/upload.php
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| <?php | ||||
|  | ||||
| header('Content-Type: text/plain; charset=utf-8'); | ||||
|  | ||||
| try { | ||||
|  | ||||
|   // invalid multiple files / $_FILES corruption attack | ||||
|   if (!isset($_FILES['upfile']['error']) || | ||||
|       is_array($_FILES['upfile']['error'])) { | ||||
|     throw new RuntimeException('Invalid parameters'); | ||||
|   } | ||||
|  | ||||
|   // validate $_FILES['upfile']['error'] | ||||
|   switch ($_FILES['upfile']['error']) { | ||||
|     case UPLOAD_ERR_OK: | ||||
|       break; | ||||
|     case UPLOAD_ERR_NO_FILE: | ||||
|       throw new RuntimeException('No file sent'); | ||||
|     case UPLOAD_ERR_INI_SIZE: | ||||
|     case UPLOAD_ERR_FORM_SIZE: | ||||
|       throw new RuntimeException('Exceeded filesize limit'); | ||||
|     default: | ||||
|       throw new RuntimeException('Unknown error'); | ||||
|   } | ||||
|  | ||||
|   // maximum upload filesize | ||||
|   if ($_FILES['upfile']['size'] > 67108864) { | ||||
|     throw new RuntimeException('Exceeded filesize limit'); | ||||
|   } | ||||
|  | ||||
|   // check upload MIME type | ||||
|   $finfo = new finfo(FILEINFO_MIME_TYPE); | ||||
|   $fmime = $finfo->file($_FILES['upfile']['tmp_name']); | ||||
|   if (false === $ext = array_search($fmime, | ||||
|                                     array('json' => 'application/json', | ||||
|                                           'txt' => 'text/plain'), | ||||
|                                     true)) { | ||||
|     throw new RuntimeException(sprintf('Invalid file format: "%s"', $fmime)); | ||||
|   } | ||||
|  | ||||
|   // give file unique name based on sha | ||||
|   $ftmpname = $_FILES['upfile']['tmp_name']; | ||||
|   $fdstname = sprintf('./upload/%s.%s', | ||||
|                       sha1_file($_FILES['upfile']['tmp_name']), | ||||
|                       $ext); | ||||
|   if (!move_uploaded_file($ftmpname, $fdstname)) { | ||||
|     throw new RuntimeException(sprintf('Failed to move uploaded file ("%s" -> "%s")', $ftmpname, $fdstname)); | ||||
|   } | ||||
|  | ||||
|   echo 'Success'; | ||||
|  | ||||
| } catch (RuntimeException $e) { | ||||
|   error_log ($e->getMessage()); | ||||
|   echo $e->getMessage(); | ||||
| } | ||||
|  | ||||
| ?> | ||||
		Reference in New Issue
	
	Block a user