diff --git a/README.md b/README.md index 129ed45..efbae80 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,27 @@ -# WurmTerm πŸ› +# WurmTerm Backend πŸ› -Linux Terminal companion app discovering services and issues on your servers and kubernetes namespaces. - -![Screenshot WurmTerm](https://user-images.githubusercontent.com/3315368/118046621-dde32e00-b379-11eb-8400-7942eb401e86.png) - -WurmTerm -- watches over the state of servers you are connected to via SSH sessions -- alerts you of issues detected -- provides you with an overview of detected services (web servers, databases, RAID ...) -- allows you to create CPU flame graphs - -By using the same password-less SSH connect commands you use WurmTerm can be open -as a browser tab alongside you multiple terminal (tabs) and it will visually notify -you about issues on hosts you "travel" to faster and more comprehensive than you -could debug issues yourself. It will often uncover problem you do not notice at all. - -Probing does not happen via brute-force, but depending on services detected via -a `netstat`/`ss` listing. +WurmTerm is an addon for the progressive web app lzone.de. It allows observing +connected systems as well as exectuting runbooks against those. This repo contains +only the backend part. ## Installation -Backend - npm i wurmterm-backend -From source - - cd backend && npm install - cd frontend && npm install - -## Usage - -Start the backend in your work environment: - - npx wurm - -To always start the backend consider adding above line to your `~/.profile`. - -To start the frontend locally - - cd frontend - npx serve -S - -Alternatively host the frontend code on a website of your choice by -providing proper CORS, COEP, COOP headers in your webserver config to allow -the PWA to access the local backend and to allow iframe same origin embedding -for the notebook code. +## Backend Usage -## Remote Host Monitoring via SSH + npx wurm start + npx wurm stop -The WurmTerm backend uses Node.js and StatefulCommandProxy to issue -SSH commands. It will check which SSH connections you have open at any time -and will start run probes to the same nodes. + npx wurm configure # to change settings / password ## Assumptions -WurmTerm assumes +WurmTerm backend assumes - that you use bash - that it can connect to all those hosts via SSH without credentials +- that is allowed to discover connected kubernetes contexts - that you use `ssh ` only and handle all private key switching in your SSH config - that it is always allowed to sudo (but won't complain if it does not succeed) diff --git a/backend/config/default.json b/backend/config/default.json deleted file mode 100644 index aef5f1b..0000000 --- a/backend/config/default.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "server": { - "host": "localhost", - "port": 8181 - } -} diff --git a/backend/server.js b/backend/server.js deleted file mode 100644 index dabab57..0000000 --- a/backend/server.js +++ /dev/null @@ -1,297 +0,0 @@ -// vim: set ts=4 sw=4: -/*jshint esversion: 6 */ -/* - Copyright (C) 2015-2023 Lars Windolf - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . -*/ - -const http = require('http'), - config = require('config'), - express = require('express'), - cors = require('cors'), - path = require('path'), - fs = require('fs'), - app = express(), - StatefulProcessCommandProxy = require("stateful-process-command-proxy"), - WebSocketServer = require('websocket').server; - -const { exec } = require("child_process"); - -var probes = require('./probes/default.json'); -var proxies = {}; -var filters = {}; - -process.title = 'WurmTermBackend'; -process.on('uncaughtException', function(err) { - // dirty catch of broken SSH pipes - console.log(err.stack); -}); - -// Hostname matching based on https://stackoverflow.com/questions/106179/regular-expression-to-match-dns-hostname-or-ip-address -const validIpAddressRegex = /^([a-zA-Z0-9]+@){0,1}(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$/; -const validHostnameRegex = /^([a-zA-Z0-9]+@){0,1}(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/; - -// get history of SSH commands -function get_history(connection) { - try { - const cmd = `cat ~/.bash_history | awk '{if (\$1 == \"ssh\" && \$2 ~ /^[a-z0-9]/) {print \$2}}' | tail -50 | sort -u`; - exec(cmd, (error, stdout, stderr) => { - if (error) - throw(error); - - connection.sendUTF(JSON.stringify({ - cmd: 'history', - result: stdout - .split(/\n/) - .filter(h => h.match(validHostnameRegex) || h.match(validIpAddressRegex)) - .filter(s => s.length > 1) - })); - }); - } catch(e) { - return {cmd: 'history', error:e}; - } -} - -// get all hosts currently SSH connected -function get_hosts(connection) { - try { - const cmd = `pgrep -fla "^ssh " || true`; - exec(cmd, (error, stdout, stderr) => { - if (error) - throw(error); - - var hosts = stdout.split(/\n/) - .filter(h => h.match(validHostnameRegex) || h.match(validIpAddressRegex)) - .map(s => s.replace(/^[0-9]+ +ssh +/, '')) - .filter(s => s.length > 1); - hosts.push('localhost'); - connection.sendUTF(JSON.stringify({ - cmd: 'hosts', - result: hosts - })); - }); - } catch(e) { - return {cmd: 'hosts', error:e}; - } -} - -// Return all probes including initial flag so a frontend knows where to start -function get_probes(connection) { - var output = {}; - Object.keys(probes).forEach(function(probe) { - var p = probes[probe]; - output[probe] = { - name : p.name, - initial : p.initial, - refresh : p.refresh, - local : p.local, - localOnly : p.localOnly, - localFilter : p.localFilter - }; - }); - connection.sendUTF(JSON.stringify({ - cmd: 'probes', - result: output - })); -} - -function runFilter(connection, msg) { - // Use only a single file here as we allow only one filter run at a time below - var tmpfile = '/tmp/wurmterm_localhost_filter'; - fs.writeFile(tmpfile, msg.stdout, function (err) { - if (err) { - console.log(err); - msg.stdout = ""; - msg.stderr = "Local filter execution failed, writing temporary file failed!"; - connection.sendUTF(JSON.stringify(msg)); - } - }); - - if(undefined === proxies[':localhost_filter']) { - proxies[':localhost_filter'] = new StatefulProcessCommandProxy({ - name: ':localhost_filter', - max: 1, - min: 1, - idleTimeoutMS: 60000, - logFunction: function(severity,origin,msg) { - //console.log(severity.toUpperCase() + " " +origin+" "+ msg); - }, - processCommand: "/bin/bash", - processArgs: [], - processRetainMaxCmdHistory : 0, - processInvalidateOnRegex : { - //'stderr':[{regex:'.*error.*',flags:'ig'}] - }, - processCwd : './', - processUid : null, - processGid : null, - initCommands : ['LANG=C;echo'], // to catch banners and pseudo-terminal warnings - validateFunction: function(processProxy) { - return processProxy.isValid(); - }, - }); - } - proxies[':localhost_filter'].executeCommands([ - `cat ${tmpfile} | ${probes[msg.probe].localFilter}`, - `rm ${tmpfile}` - ]).then(function(res) { - msg.stdout = res[0].stdout; - msg.stderr = res[0].stderr; - connection.sendUTF(JSON.stringify(msg)); - }); -} - -function getProxy(host) { - if(undefined === proxies[host]) { - proxies[host] = new StatefulProcessCommandProxy({ - name: "proxy_"+host, - max: 1, - min: 1, - idleTimeoutMS: 15000, - logFunction: function(severity,origin,msg) { - //console.log(severity.toUpperCase() + " " +origin+" "+ msg); - }, - processCommand: 'scripts/generic.sh', - processArgs: [host], - processRetainMaxCmdHistory : 0, - processInvalidateOnRegex : { - 'stderr':[{regex:'.*error.*',flags:'ig'}] - }, - processCwd : './', - processUid : null, - processGid : null, - initCommands : ['LANG=C;echo'], // to catch banners and pseudo-terminal warnings - validateFunction: function(processProxy) { - return processProxy.isValid(); - }, - }); - } - return proxies[host]; -} - -function probeWS(connection, host, probe) { - try { - if(!(probe in probes)) { - return {host: host, probe: probe, error:'No such probe'}; - } - - getProxy(host).executeCommands([probes[probe].command]).then(function(res) { - var msg = { - cmd : 'probe', - host : host, - probe : probe, - stdout : res[0].stdout, - stderr : res[0].stderr, - next : [] - }; - - if('name' in probes[probe]) msg.name = probes[probe].name; - if('render' in probes[probe]) msg.render = probes[probe].render; - if('type' in probes[probe]) msg.type = probes[probe].type; - // Suggest followup probes - for(var p in probes) { - if(probes[p]['if'] === probe && -1 !== res[0].stdout.indexOf(probes[p].matches)) - msg.next.push(p); - } - - if(undefined !== probes[probe].localFilter) { - runFilter(connection, msg); - } else { - connection.sendUTF(JSON.stringify(msg)); - } - return; - }).catch(function(e) { - return {cmd: 'probe', host: host, probe: probe, error:e}; - }); - } catch(e) { - return {cmd: 'probe', host: host, probe: probe, error:e}; - } -} - -function run(connection, host, id, cmd) { - try { - getProxy(host).executeCommands([cmd]).then(function(res) { - var msg = { - cmd : 'run', - shell : cmd, - host : host, - id : id, - stdout : res[0].stdout, - stderr : res[0].stderr - }; - - connection.sendUTF(JSON.stringify(msg)); - return; - }).catch(function(e) { - return {cmd: 'run', host: host, id: id, error:e}; - }); - } catch(e) { - return {cmd: 'run', host: host, id: id, error:e}; - } -} - -// Setup CORS '*' to support PWAs -var corsOptions = { - origin: "*", - optionsSuccessStatus: 200, - methods: "GET, PUT" -}; - -app.use(cors(corsOptions)); - -const server = http.createServer(app).listen(config.get('server.port')); -const wsServer = new WebSocketServer({ - httpServer: server -}); - -// Websocket endpoint -wsServer.on('request', function(request) { - const connection = request.accept(null, request.origin); - connection.on('message', function(message) { - // General syntax is "[ ]" - var m = message.utf8Data.match(/^(\w+)( (.+))?$/); - if(m) { - var cmd = m[1]; - var params = m[3]; - - if(cmd === 'hosts') - return get_hosts(connection); - if(cmd === 'probes') - return get_probes(connection); - if(cmd === 'history') - return get_history(connection); - - if(cmd === 'run') { - // we expect message in format "run ::::::" - let tmp = params.split(/:::/); - return run(connection, tmp[0], tmp[1], tmp[2]); - } - - if(cmd === 'probe') { - // we expect message in format "probe :::" - let tmp = params.split(/:::/); - return probeWS(connection, tmp[0], tmp[1]); - } - } - connection.sendUTF(JSON.stringify({ - cmd: m, - error: 'Unsupported command' - })); - }); - connection.on('close', function(reasonCode, description) { - }); -}); - -console.log(`Server running at ws://${config.get('server.host')}:${config.get('server.port')}/`); diff --git a/backend/wurm b/backend/wurm deleted file mode 100755 index e8e9e57..0000000 --- a/backend/wurm +++ /dev/null @@ -1,36 +0,0 @@ -#!/bin/bash - -# Start nodejs server if needed, always start in background -start() { - if ! pgrep -f WurmTermBacken >/dev/null; then - # Locate source (either in /../lib/node_modules or in same dir) - if [ "$(dirname $0)" = "/usr/local/bin" ]; then - cd /usr/local/lib/node_modules/wurmterm-backend - else - cd "$(dirname $0)" - fi - - (nohup node server.js >/dev/null 2>&1 &) - echo "WurmTerm backend started." - else - echo "Already running." - fi -} - -stop() { - if pgrep -f WurmTermBacken >/dev/null; then - echo "Stopping..." - pkill -fe WurmTermBacken - else - echo "Not running." - fi -} - -case "${1-start}" in - start) - start - ;; - stop) - stop - ;; -esac diff --git a/frontend/babel-precompile.chunk.js b/frontend/babel-precompile.chunk.js deleted file mode 120000 index b0ee01d..0000000 --- a/frontend/babel-precompile.chunk.js +++ /dev/null @@ -1 +0,0 @@ -node_modules/starboard-notebook/dist/babel-precompile.chunk.js \ No newline at end of file diff --git a/frontend/codemirrorEditor.chunk.js b/frontend/codemirrorEditor.chunk.js deleted file mode 120000 index c6fcbe2..0000000 --- a/frontend/codemirrorEditor.chunk.js +++ /dev/null @@ -1 +0,0 @@ -node_modules/starboard-notebook/dist/codemirrorEditor.chunk.js \ No newline at end of file diff --git a/frontend/codemirrorHighlight.chunk.js b/frontend/codemirrorHighlight.chunk.js deleted file mode 120000 index 8c0dfbb..0000000 --- a/frontend/codemirrorHighlight.chunk.js +++ /dev/null @@ -1 +0,0 @@ -node_modules/starboard-notebook/dist/codemirrorHighlight.chunk.js \ No newline at end of file diff --git a/frontend/console-output.chunk.js b/frontend/console-output.chunk.js deleted file mode 120000 index 1c0915f..0000000 --- a/frontend/console-output.chunk.js +++ /dev/null @@ -1 +0,0 @@ -node_modules/starboard-notebook/dist/console-output.chunk.js \ No newline at end of file diff --git a/frontend/css/styles.css b/frontend/css/styles.css deleted file mode 100644 index 911d38e..0000000 --- a/frontend/css/styles.css +++ /dev/null @@ -1,298 +0,0 @@ -body { - font-family: Helvetica; - font-size:10pt; - background:#444; - color:#cce; - margin: 0; - padding: 0; -} - -.main { - padding: 12px; - display: none; -} - -#info { - background: #eee; - color: black; - padding: 6px 12px; -} - -.clearfix::after { - content: ""; - clear: both; - display: table; -} - -.emoji { - vertical-align: middle; -} - -img.emojione { - margin: 0px !important; - display: inline !important; - height: auto; -} - -#visualizedHost { - font-weight: bold; - color: #333; -} -#visualContainer { - display: none; -} -#visual { - height: 33%; - border: 1px solid silver; - background: white; - margin-bottom: 12px; - margin-top: 6px; - overflow: auto; -} - -#visual .label { - background: white; -} - -#visual .node rect { - stroke: #999; - fill: #fff; - stroke-width: 1.5px; -} -#visual .edgePath path { - stroke: #333; - stroke-width: 1.5px; -} -#visual .local > rect { - fill: #ffc; - stroke-width: 2px; -} -#visual .local_unused > rect { - fill: #fff; - stroke-width: 2px; -} -#visual .null > rect, .null > foreignobject { - stroke-width: 0; -} -#visual .previousNode { - background: #ccf; -} -#visual a.resolve { - color:brown; - text-decoration:none; -} - -.rendererForm { - padding:12px; - background: #eee; - color: black; -} - -#nodes { - display: flex; - flex-direction: row; - flex-wrap: wrap; -} - -#nodes .node { - margin-right:12px; - margin-bottom:12px; -} - -#nodes .node.disconnected { - filter: grayscale(60%); -} -#nodes .node.disconnected .name { - color: silver; -} -#nodes .node.disconnected .name::after { - content: ' (disconnected)'; -} - -#nodes > div { - width: 300px; -} - -#nodes .node .name { - color:#333; - font-weight:bold; - cursor:pointer; -} - -#boxes { - clear:both; -} - -.box { - margin-bottom:2px; - clear: both; -} - -.box .head { - background: #77A; - position: relative; - display: block; - clear: both; - cursor: pointer; -} - -.box .head .reload { - float: right; - width: auto; -} - -.box .head .reload a { - color: #77A; - text-decoration: none; - padding-right: 6px; - padding-left: 24px; -} - -.box .reload a:hover { - color: silver; -} - -.box .title { - float: left; - width: auto; - color: black; - padding: 3px; -} -.box.collapsed .title:hover { - color: silver; -} - -.box.empty .head { - background: #557; -} - -.box.severity_warning .head { - background: #ccc; -} -.box.severity_critical .head { - background: #ccc; -} -.box.severity_warning .emoji:before { - content: '⚠ ' -} -.box.severity_critical .emoji:before { - content: '⚑ ' -} -.box.severity_invalid .head { - background: black; - color: silver; -} - -.box.collapsed.severity_warning .head { - background: yellow; -} -.box.collapsed.severity_critical .head { - background: red; -} -.box.collapsed .reload { - display: none; -} -.box.collapsed { - float: left; - margin-right: 3px; - clear: none; -} - -.box .content { - clear: both; - padding: 3px; - overflow-x: auto; - border:1px solid #77A; -} - -.box .content table { - margin:0; - padding:0; - border-collapse: collapse; -} - -.box .content table td { - border:1px solid black; -} - -span.severity_critical { - background: red; - color: white; -} - -span.severity_warning { - background: yellow; - color: black; -} - -.title { - color: white; - margin-right: 12px; -} - -#menu { - background: #222; - padding: 12px 6px; -} - -#menu a { - color: #AAC; - text-decoration: none; - cursor: pointer; -} -#menu a:hover { - text-decoration: underline; -} -#menu a:active { - color: white; - font-weight: bold; -} - -#footer { - background: #777; - border: 1px solid lightgray; - padding: 12px; - position: relative; - bottom: 0px; -} - -#history { - margin: 3px; - margin-block-start: 0; - padding-inline-start: 0; -} -#history li { - display:inline-block; - padding:3px; - margin-right:3px; - margin-bottom:3px; - background: #ddd; - color: black; -} -#history li:hover { - text-decoration: underline; - cursor: pointer; - color: white; -} - -#settings h2 { - border-bottom: 1px solid #ddd; -} - -#settings .probe { - display: inline-block; - background: #bbb; - margin:3px; - padding:3px 6px; - border-radius: 3px; - cursor: pointer; -} - -.settingsBox { - border: 1px solid #eee; - border-radius: 4px; - background: #eee; - padding: 6px; - margin:6px 0; - min-height: 100px; -} \ No newline at end of file diff --git a/frontend/default.json b/frontend/default.json deleted file mode 120000 index d03617e..0000000 --- a/frontend/default.json +++ /dev/null @@ -1 +0,0 @@ -../backend/config/default.json \ No newline at end of file diff --git a/frontend/favicon.ico b/frontend/favicon.ico deleted file mode 100644 index ec21ab8..0000000 Binary files a/frontend/favicon.ico and /dev/null differ diff --git a/frontend/index.html b/frontend/index.html deleted file mode 100644 index 6d412e4..0000000 --- a/frontend/index.html +++ /dev/null @@ -1,110 +0,0 @@ - - - - - WurmTerm - - - - - - - - - - -
-
- -
-
- Visualization - - Host - -
-
-
-
-
- -
- -
- Host - - - - Notebook - -
- -
-

Refresh Interval

- -

How often WurmTerm will look for new or disconnected hosts.

- - - Seconds (min 5s) - -

Active Probes

- -

Control which probes you want to run. Toggle their status by clicking.

- - Enabled: -
- Disabled: -
- -

Backend Endpoint

- - - -

Websocket endpoint of the WurmTerm backend (e.g. ws://localhost:8181). Please refresh the page after changing this value!

-
- - - - diff --git a/frontend/inter.var.woff2 b/frontend/inter.var.woff2 deleted file mode 120000 index d036b4a..0000000 --- a/frontend/inter.var.woff2 +++ /dev/null @@ -1 +0,0 @@ -node_modules/starboard-notebook/dist/inter.var.woff2 \ No newline at end of file diff --git a/frontend/js/app.js b/frontend/js/app.js deleted file mode 100644 index 82b6fdb..0000000 --- a/frontend/js/app.js +++ /dev/null @@ -1,335 +0,0 @@ -// vim: set ts=4 sw=4: -/* jshint esversion: 6 */ - -import { setupNotebook } from './notebook.js'; -import { settingsDialog, settingsLoad } from './settings.js'; -import { ProbeAPI } from './probeapi.js'; - -import { perfRenderer } from './renderer/perf-flamegraph.js'; -import { netmapRenderer } from './renderer/netmap.js'; - -const params = new URLSearchParams(window.location.search); -const renderers = { - 'netmap': netmapRenderer, - 'perfFlameGraph': perfRenderer -}; -var extraHosts = []; // list of hosts manually added - -function multiMatch(text, severities) { - var matchResult; - $.each(['critical','warning'], function(i, name) { - if(severities[name] === undefined) - return; - - var re = new RegExp(severities[name]); - var matches = re.exec(text); - if(matches !== null) { - matchResult = name; - return; - } - }); - return matchResult; -} - -// Note: mutates d.probeSeverity -function markSeverity(s, d) { - if(d.render === undefined || d.render.severity === undefined) - return s; - - switch(multiMatch(s, d.render.severity)) { - case 'critical': - d.probeSeverity = 'critical'; - return ""+s+""; - case 'warning': - if(d.probeSeverity === undefined) - d.probeSeverity = 'warning'; - return ""+s+""; - default: - return s; - } -} - -function renderString(d) { - var res = []; - $.each(JSON.stringify(d.stdout).split(/\\n/), function(i, line) { - res.push(markSeverity(line.replace(/\"/g, ""), d)); - }); - return res.join('
'); -} - -function renderTable(d) { - var res = ""; - var re = new RegExp(d.render.split); - $.each(d.stdout.split(/\n/), function(i, line) { - res += ""; - $.each(line.split(re), function(j, column) { - res += ""; - }); - res += ""; - }); - return res + "
"+markSeverity(column, d)+"
"; -} - -function triggerProbe(host, name) { - ProbeAPI.probe(host, name, probeResultCb, probeErrorCb); -} - -function probeErrorCb(e, probe, h) { - var hId = strToId(h); - var id = "box_"+hId+"_"+probe.replace(/[. ]/g, "_"); - addProbe(h, id, probe, probe); - $(`${id} .error`).html(e); - console.error(`probe Error: host=${h} probe=${probe} ${e}`); -} - -function strToId(h) { - return h.replace(/[^a-zA-Z0-9]/g, ''); -} - -function resortBoxes(list) { - var result = [...list.children] - .sort(function (a,b) { - var ac = 0, bc = 0; - if ($(a).attr('collapsed') !== "1") - ac += 40; - if ($(b).attr('collapsed') !== "1") - bc += 40; - if ($(a).hasClass('severity_critical')) - ac += 20; - if ($(a).hasClass('severity_warning')) - ac += 10; - if ($(b).hasClass('severity_critical')) - bc += 20; - if ($(b).hasClass('severity_warning')) - bc += 10; - - if (a.innerTextlist.appendChild(node)); -} - -function visualizeHost(host, renderer) { - $('#visualContainer').show(); - $('#visualizedHost').html(host); - $('#renderer').val(renderer); - $('#visual').empty().height(600); - try { - renderers[renderer](ProbeAPI, '#visual', host); - } catch(e) { - $('#visual').html('ERROR: Rendering failed!'); - console.error(`render Error: host=${host} ${e}`); - } -} - -function addHost(h) { - ProbeAPI.start(h, probeResultCb, probeErrorCb); - - var hId = strToId(h); - if(!$(`#${hId}`).length) { - $('#nodes').append(`
-
-
-
`); - } - $(`#${hId}.node .name`).html(h); - $(`#${hId}.node`).removeClass('disconnected'); - - $('.node .name').on('click', function() { - visualizeHost($(this).parent().data('host'), 'netmap'); - }); -} - -function addProbe(h, id, probe, title) { - var hId = strToId(h); - if(!$(`#${id}`).length) { - $(`#${hId} .boxes`).append(` -