convert wetty from submodule to normal directory
This commit is contained in:
BIN
wetty/src/assets/favicon.ico
Normal file
BIN
wetty/src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 165 KiB |
111
wetty/src/assets/scss/options.scss
Normal file
111
wetty/src/assets/scss/options.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
@use './variables';
|
||||
|
||||
#options {
|
||||
height: 16px;
|
||||
position: absolute;
|
||||
right: 1em;
|
||||
top: 1em;
|
||||
width: 16px;
|
||||
z-index: 20;
|
||||
|
||||
.toggler {
|
||||
color: variables.$lgrey;
|
||||
display: inline-block;
|
||||
font-size: 16px;
|
||||
position: absolute;
|
||||
right: 1em;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
|
||||
:hover {
|
||||
color: variables.$white;
|
||||
}
|
||||
}
|
||||
|
||||
.editor {
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
border-color: rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0.3em;
|
||||
color: #eee;
|
||||
display: none;
|
||||
font-size: 24px;
|
||||
height: 100%;
|
||||
padding: 0.5em;
|
||||
position: relative;
|
||||
right: 2em;
|
||||
top: 1em;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#options.opened {
|
||||
height: max(min(300px, 75vh), 50vh);
|
||||
width: max(min(500px, 75vw), 50vw);
|
||||
|
||||
.editor {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
|
||||
#functions {
|
||||
position: fixed;
|
||||
right: 2em;
|
||||
top: 6em;
|
||||
z-index: 20;
|
||||
|
||||
> a {
|
||||
padding: 10px;
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: -40px;
|
||||
color: variables.$lgrey;
|
||||
|
||||
:hover {
|
||||
color: variables.$white;
|
||||
}
|
||||
}
|
||||
|
||||
.onscreen-buttons {
|
||||
display: none;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border: solid 2px rgba(255, 255, 255, 0.25);
|
||||
border-radius: 0.3em;
|
||||
background-color: rgba(0, 0, 0, 0.85);
|
||||
> a {
|
||||
bottom: 1em;
|
||||
right: 2em;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
> div {
|
||||
padding: 5px;
|
||||
outline: 2px solid white;
|
||||
margin: 10px;
|
||||
display: inline-block;
|
||||
font-weight: bold;
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.onscreen-buttons > a:active {
|
||||
> div {
|
||||
background-color: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
#onscreen-ctrl.active {
|
||||
display: inline-block;
|
||||
> div {
|
||||
background-color: lightgray;
|
||||
}
|
||||
}
|
||||
}
|
||||
28
wetty/src/assets/scss/overlay.scss
Normal file
28
wetty/src/assets/scss/overlay.scss
Normal file
@@ -0,0 +1,28 @@
|
||||
@use './variables';
|
||||
|
||||
#overlay {
|
||||
background-color: variables.$grey;
|
||||
display: none;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
z-index: 100;
|
||||
|
||||
.error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
#msg {
|
||||
align-self: center;
|
||||
color: variables.$white;
|
||||
}
|
||||
|
||||
input {
|
||||
align-self: center;
|
||||
margin: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
wetty/src/assets/scss/styles.scss
Normal file
25
wetty/src/assets/scss/styles.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
@use '@xterm/xterm/css/xterm.css';
|
||||
@use 'toastify-js/src/toastify.css';
|
||||
@use './variables';
|
||||
@use './overlay';
|
||||
@use './options';
|
||||
@use './terminal';
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: variables.$black;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.toastify {
|
||||
border-radius: 0;
|
||||
color: variables.$black;
|
||||
}
|
||||
}
|
||||
|
||||
.xterm {
|
||||
.xterm-viewport {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
}
|
||||
6
wetty/src/assets/scss/terminal.scss
Normal file
6
wetty/src/assets/scss/terminal.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
#terminal {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
4
wetty/src/assets/scss/variables.scss
Normal file
4
wetty/src/assets/scss/variables.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
$black: #000;
|
||||
$grey: rgba(0, 0, 0, 0.75);
|
||||
$white: #fff;
|
||||
$lgrey: #ccc;
|
||||
164
wetty/src/assets/xterm_config/functionality.js
Normal file
164
wetty/src/assets/xterm_config/functionality.js
Normal file
@@ -0,0 +1,164 @@
|
||||
function optionGenericGet() {
|
||||
return this.el.querySelector('input').value;
|
||||
}
|
||||
function optionGenericSet(value) {
|
||||
this.el.querySelector('input').value = value;
|
||||
}
|
||||
function optionEnumGet() {
|
||||
return this.el.querySelector('select').value;
|
||||
}
|
||||
function optionEnumSet(value) {
|
||||
this.el.querySelector('select').value = value;
|
||||
}
|
||||
function optionBoolGet() {
|
||||
return this.el.querySelector('input').checked;
|
||||
}
|
||||
function optionBoolSet(value) {
|
||||
this.el.querySelector('input').checked = value;
|
||||
}
|
||||
function optionNumberGet() {
|
||||
let value = (this.float === true ? parseFloat : parseInt)(
|
||||
this.el.querySelector('input').value,
|
||||
);
|
||||
if (Number.isNaN(value) || typeof value !== 'number') value = 0;
|
||||
if (typeof this.min === 'number') value = Math.max(value, this.min);
|
||||
if (typeof this.max === 'number') value = Math.min(value, this.max);
|
||||
return value;
|
||||
}
|
||||
function optionNumberSet(value) {
|
||||
this.el.querySelector('input').value = value;
|
||||
}
|
||||
|
||||
const allOptions = [];
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
|
||||
function inflateOptions(optionsSchema) {
|
||||
const booleanOption = document.querySelector('#boolean_option.templ');
|
||||
const enumOption = document.querySelector('#enum_option.templ');
|
||||
const textOption = document.querySelector('#text_option.templ');
|
||||
const numberOption = document.querySelector('#number_option.templ');
|
||||
const colorOption = document.querySelector('#color_option.templ');
|
||||
|
||||
function copyOver({ children }) {
|
||||
while (children.length > 0) document.body.append(children[0]);
|
||||
}
|
||||
|
||||
optionsSchema.forEach(option => {
|
||||
let el;
|
||||
option.get = optionGenericGet.bind(option);
|
||||
option.set = optionGenericSet.bind(option);
|
||||
|
||||
switch (option.type) {
|
||||
case 'boolean':
|
||||
el = booleanOption.cloneNode(true);
|
||||
option.get = optionBoolGet.bind(option);
|
||||
option.set = optionBoolSet.bind(option);
|
||||
break;
|
||||
|
||||
case 'enum':
|
||||
el = enumOption.cloneNode(true);
|
||||
option.enum.forEach(varriant => {
|
||||
const optionEl = document.createElement('option');
|
||||
optionEl.innerText = varriant;
|
||||
optionEl.value = varriant;
|
||||
el.querySelector('select').appendChild(optionEl);
|
||||
});
|
||||
option.get = optionEnumGet.bind(option);
|
||||
option.set = optionEnumSet.bind(option);
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
el = textOption.cloneNode(true);
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
el = numberOption.cloneNode(true);
|
||||
if (option.float === true)
|
||||
el.querySelector('input').setAttribute('step', '0.001');
|
||||
option.get = optionNumberGet.bind(option);
|
||||
option.set = optionNumberSet.bind(option);
|
||||
if (typeof option.min === 'number')
|
||||
el.querySelector('input').setAttribute('min', option.min.toString());
|
||||
if (typeof option.max === 'number')
|
||||
el.querySelector('input').setAttribute('max', option.max.toString());
|
||||
break;
|
||||
|
||||
case 'color':
|
||||
el = colorOption.cloneNode(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown option type ${option.type}`);
|
||||
}
|
||||
|
||||
el.querySelector('.title').innerText = option.name;
|
||||
el.querySelector('.desc').innerText = option.description;
|
||||
[option.el] = el.children;
|
||||
copyOver(el);
|
||||
allOptions.push(option);
|
||||
});
|
||||
}
|
||||
|
||||
function getItem(json, path) {
|
||||
const mypath = path[0];
|
||||
if (path.length === 1) return json[mypath];
|
||||
if (json[mypath] != null) return getItem(json[mypath], path.slice(1));
|
||||
return null;
|
||||
}
|
||||
function setItem(json, path, item) {
|
||||
const mypath = path[0];
|
||||
if (path.length === 1) json[mypath] = item;
|
||||
else {
|
||||
if (json[mypath] == null) json[mypath] = {};
|
||||
setItem(json[mypath], path.slice(1), item);
|
||||
}
|
||||
}
|
||||
|
||||
window.loadOptions = config => {
|
||||
allOptions.forEach(option => {
|
||||
let value = getItem(config, option.path);
|
||||
if (option.nullable === true && option.type === 'text' && value == null)
|
||||
value = null;
|
||||
else if (
|
||||
option.nullable === true &&
|
||||
option.type === 'number' &&
|
||||
value == null
|
||||
)
|
||||
value = -1;
|
||||
else if (value == null) return;
|
||||
if (option.json === true && option.type === 'text')
|
||||
value = JSON.stringify(value);
|
||||
option.set(value);
|
||||
option.el.classList.remove('unbounded');
|
||||
});
|
||||
};
|
||||
|
||||
if (window.top === window)
|
||||
// eslint-disable-next-line no-alert
|
||||
alert(
|
||||
'Error: Page is top level. This page is supposed to be accessed from inside WeTTY.',
|
||||
);
|
||||
|
||||
function saveConfig() {
|
||||
const newConfig = {};
|
||||
allOptions.forEach(option => {
|
||||
let newValue = option.get();
|
||||
if (
|
||||
option.nullable === true &&
|
||||
((option.type === 'text' && newValue === '') ||
|
||||
(option.type === 'number' && newValue < 0))
|
||||
)
|
||||
return;
|
||||
if (option.json === true && option.type === 'text')
|
||||
newValue = JSON.parse(newValue);
|
||||
setItem(newConfig, option.path, newValue);
|
||||
});
|
||||
window.wetty_save_config(newConfig);
|
||||
}
|
||||
|
||||
window.addEventListener('input', () => {
|
||||
const els = document.querySelectorAll('input, select');
|
||||
for (let i = 0; i < els.length; i += 1) {
|
||||
els[i].addEventListener('input', saveConfig);
|
||||
}
|
||||
});
|
||||
71
wetty/src/assets/xterm_config/index.html
Normal file
71
wetty/src/assets/xterm_config/index.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Wetty XTerm Configuration</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Configure</h1>
|
||||
</header>
|
||||
|
||||
<div class="templ" id="boolean_option">
|
||||
<div class="boolean_option unbounded">
|
||||
<p>
|
||||
<b class="title"></b><br />
|
||||
<span class="desc"></span>
|
||||
</p>
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="templ" id="enum_option">
|
||||
<div class="enum_option unbounded">
|
||||
<p>
|
||||
<b class="title"></b><br />
|
||||
<span class="desc"></span>
|
||||
</p>
|
||||
<select></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="templ" id="text_option">
|
||||
<div class="text_option unbounded">
|
||||
<p>
|
||||
<b class="title"></b><br />
|
||||
<span class="desc"></span>
|
||||
</p>
|
||||
<input type="text" />
|
||||
</div>
|
||||
<div class="error_reporting"></div>
|
||||
</div>
|
||||
<div class="templ" id="number_option">
|
||||
<div class="number_option unbounded">
|
||||
<p>
|
||||
<b class="title"></b><br />
|
||||
<span class="desc"></span>
|
||||
</p>
|
||||
<input type="number" size="10" />
|
||||
</div>
|
||||
<div class="error_reporting"></div>
|
||||
</div>
|
||||
<div class="templ" id="color_option">
|
||||
<div class="color_option unbounded">
|
||||
<p>
|
||||
<b class="title"></b><br />
|
||||
<span class="desc"></span>
|
||||
</p>
|
||||
<input type="color" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="./functionality.js"></script>
|
||||
|
||||
<h2>General Options</h2>
|
||||
<script src="./xterm_general_options.js"></script>
|
||||
<h2>Color Theme</h2>
|
||||
<script src="./xterm_color_theme.js"></script>
|
||||
<h2>Advanced XTerm Options</h2>
|
||||
<script src="./xterm_advanced_options.js"></script>
|
||||
|
||||
<script src="./xterm_defaults.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
78
wetty/src/assets/xterm_config/style.css
Normal file
78
wetty/src/assets/xterm_config/style.css
Normal file
@@ -0,0 +1,78 @@
|
||||
html {
|
||||
background-color: black;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
overflow: hidden auto;
|
||||
}
|
||||
body {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
font-family: monospace;
|
||||
font-size: 1rem;
|
||||
color: white;
|
||||
}
|
||||
.templ {
|
||||
display: none;
|
||||
}
|
||||
h2 {
|
||||
text-align: center;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
header button {
|
||||
padding: 0.5em;
|
||||
font-size: 1em;
|
||||
margin: 0.5em;
|
||||
border-radius: 0.5em;
|
||||
}
|
||||
|
||||
.boolean_option,
|
||||
.number_option,
|
||||
.color_option,
|
||||
.enum_option,
|
||||
.text_option {
|
||||
display: grid;
|
||||
grid-template-columns: 100fr min(30em, 50%);
|
||||
grid-template-rows: auto;
|
||||
align-items: center;
|
||||
}
|
||||
.boolean_option input,
|
||||
.number_option input,
|
||||
.color_option input,
|
||||
.text_option input,
|
||||
.enum_option select {
|
||||
margin: 0 0.5em;
|
||||
font-size: 1em;
|
||||
background-color: hsl(0, 0%, 20%);
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.number_option input,
|
||||
.text_option input,
|
||||
.enum_option select {
|
||||
padding: 0.4em;
|
||||
}
|
||||
.boolean_option input {
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
font-size: 0.75em;
|
||||
justify-self: center;
|
||||
}
|
||||
.color_option input {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: lightgray;
|
||||
}
|
||||
|
||||
.unbounded .title::before {
|
||||
content: 'UNBOUND OPTION ';
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
}
|
||||
127
wetty/src/assets/xterm_config/xterm_advanced_options.js
Normal file
127
wetty/src/assets/xterm_config/xterm_advanced_options.js
Normal file
@@ -0,0 +1,127 @@
|
||||
window.inflateOptions([
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Allow Proposed XTerm APIs',
|
||||
description:
|
||||
'When set to false, any experimental/proposed APIs will throw errors.',
|
||||
path: ['xterm', 'allowProposedApi'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Allow Transparent Background',
|
||||
description: 'Whether the background is allowed to be a non-opaque color.',
|
||||
path: ['xterm', 'allowTransparency'],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'Bell Sound URI',
|
||||
description: 'URI for a custom bell character sound.',
|
||||
path: ['xterm', 'bellSound'],
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
type: 'enum',
|
||||
name: 'Bell Style',
|
||||
description: 'How the terminal will react to the bell character',
|
||||
path: ['xterm', 'bellStyle'],
|
||||
enum: ['none', 'sound'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Force End-Of-Line',
|
||||
description:
|
||||
'When enabled, any new-line characters (\\n) will be interpreted as carriage-return new-line. (\\r\\n) Typically this is done by the shell program.',
|
||||
path: ['xterm', 'convertEol'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Disable Stdin',
|
||||
description: 'Whether input should be disabled',
|
||||
path: ['xterm', 'disableStdin'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Letter Spacing',
|
||||
description: 'The spacing in whole pixels between characters.',
|
||||
path: ['xterm', 'letterSpacing'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Line Height',
|
||||
description:
|
||||
'Line height, multiplied by the font size to get the height of terminal rows.',
|
||||
path: ['xterm', 'lineHeight'],
|
||||
float: true,
|
||||
},
|
||||
{
|
||||
type: 'enum',
|
||||
name: 'XTerm Log Level',
|
||||
description: 'Log level for the XTerm library.',
|
||||
path: ['xterm', 'logLevel'],
|
||||
enum: ['debug', 'info', 'warn', 'error', 'off'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Macintosh Option Key as Meta Key',
|
||||
description:
|
||||
'When enabled, the Option key on Macs will be interpreted as the Meta key.',
|
||||
path: ['xterm', 'macOptionIsMeta'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Macintosh Option Click Forces Selection',
|
||||
description:
|
||||
"Whether holding a modifier key will force normal selection behavior, regardless of whether the terminal is in mouse events mode. This will also prevent mouse events from being emitted by the terminal. For example, this allows you to use xterm.js' regular selection inside tmux with mouse mode enabled.",
|
||||
path: ['xterm', 'macOptionClickForcesSelection'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Forced Contrast Ratio',
|
||||
description:
|
||||
'Miminum contrast ratio for terminal text. This will alter the foreground color dynamically to ensure the ratio is met. Goes from 1 (do nothing) to 21 (strict black and white).',
|
||||
path: ['xterm', 'minimumContrastRatio'],
|
||||
float: true,
|
||||
},
|
||||
{
|
||||
type: 'enum',
|
||||
name: 'Renderer Type',
|
||||
description:
|
||||
'The terminal renderer to use. Canvas is preferred, but a DOM renderer is also available. Note: Letter spacing and cursor blink do not work in the DOM renderer.',
|
||||
path: ['xterm', 'rendererType'],
|
||||
enum: ['canvas', 'dom'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Right Click Selects Words',
|
||||
description: 'Whether to select the word under the cursor on right click.',
|
||||
path: ['xterm', 'rightClickSelectsWord'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Screen Reader Support',
|
||||
description:
|
||||
'Whether screen reader support is enabled. When on this will expose supporting elements in the DOM to support NVDA on Windows and VoiceOver on macOS.',
|
||||
path: ['xterm', 'screenReaderMode'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Tab Stop Width',
|
||||
description: 'The size of tab stops in the terminal.',
|
||||
path: ['xterm', 'tabStopWidth'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Windows Mode',
|
||||
description:
|
||||
"\"Whether 'Windows mode' is enabled. Because Windows backends winpty and conpty operate by doing line wrapping on their side, xterm.js does not have access to wrapped lines. When Windows mode is enabled the following changes will be in effect:\n- Reflow is disabled.\n- Lines are assumed to be wrapped if the last character of the line is not whitespace.",
|
||||
path: ['xterm', 'windowsMode'],
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
name: 'Word Separator',
|
||||
description:
|
||||
'All characters considered word separators. Used for double-click to select word logic. Encoded as JSON in this editor for editing convienience.',
|
||||
path: ['xterm', 'wordSeparator'],
|
||||
json: true,
|
||||
},
|
||||
]);
|
||||
152
wetty/src/assets/xterm_config/xterm_color_theme.js
Normal file
152
wetty/src/assets/xterm_config/xterm_color_theme.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const selectionColorOption = {
|
||||
type: 'color',
|
||||
name: 'Selection Color',
|
||||
description: 'Background color for selected text. Can be transparent.',
|
||||
path: ['xterm', 'theme', 'selection'],
|
||||
};
|
||||
const selectionColorOpacityOption = {
|
||||
type: 'number',
|
||||
name: 'Selection Color Opacity',
|
||||
description:
|
||||
'Opacity of the selection highlight. A value between 1 (fully opaque) and 0 (fully transparent).',
|
||||
path: ['wettyVoid'],
|
||||
float: true,
|
||||
min: 0,
|
||||
max: 1,
|
||||
};
|
||||
|
||||
window.inflateOptions([
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Foreground Color',
|
||||
description: 'The default foreground (text) color.',
|
||||
path: ['xterm', 'theme', 'foreground'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Background Color',
|
||||
description: 'The default background color.',
|
||||
path: ['xterm', 'theme', 'background'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Cursor Color',
|
||||
description: 'Color of the cursor.',
|
||||
path: ['xterm', 'theme', 'cursor'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Block Cursor Accent Color',
|
||||
description:
|
||||
'The accent color of the cursor, used as the foreground color for block cursors.',
|
||||
path: ['xterm', 'theme', 'cursorAccent'],
|
||||
},
|
||||
selectionColorOption,
|
||||
selectionColorOpacityOption,
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Black',
|
||||
description: 'Color for ANSI Black text.',
|
||||
path: ['xterm', 'theme', 'black'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Red',
|
||||
description: 'Color for ANSI Red text.',
|
||||
path: ['xterm', 'theme', 'red'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Green',
|
||||
description: 'Color for ANSI Green text.',
|
||||
path: ['xterm', 'theme', 'green'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Yellow',
|
||||
description: 'Color for ANSI Yellow text.',
|
||||
path: ['xterm', 'theme', 'yellow'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Blue',
|
||||
description: 'Color for ANSI Blue text.',
|
||||
path: ['xterm', 'theme', 'blue'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Magenta',
|
||||
description: 'Color for ANSI Magenta text.',
|
||||
path: ['xterm', 'theme', 'magenta'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Cyan',
|
||||
description: 'Color for ANSI Cyan text.',
|
||||
path: ['xterm', 'theme', 'cyan'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'White',
|
||||
description: 'Color for ANSI White text.',
|
||||
path: ['xterm', 'theme', 'white'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Bright Black',
|
||||
description: 'Color for ANSI Bright Black text.',
|
||||
path: ['xterm', 'theme', 'brightBlack'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Bright Red',
|
||||
description: 'Color for ANSI Bright Red text.',
|
||||
path: ['xterm', 'theme', 'brightRed'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Bright Green',
|
||||
description: 'Color for ANSI Bright Green text.',
|
||||
path: ['xterm', 'theme', 'brightGreen'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Bright Yellow',
|
||||
description: 'Color for ANSI Bright Yellow text.',
|
||||
path: ['xterm', 'theme', 'brightYellow'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Bright Blue',
|
||||
description: 'Color for ANSI Bright Blue text.',
|
||||
path: ['xterm', 'theme', 'brightBlue'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Bright Magenta',
|
||||
description: 'Color for ANSI Bright Magenta text.',
|
||||
path: ['xterm', 'theme', 'brightMagenta'],
|
||||
},
|
||||
{
|
||||
type: 'color',
|
||||
name: 'Bright White',
|
||||
description: 'Color for ANSI Bright White text.',
|
||||
path: ['xterm', 'theme', 'brightWhite'],
|
||||
},
|
||||
]);
|
||||
|
||||
selectionColorOption.get = function getInput() {
|
||||
return (
|
||||
this.el.querySelector('input').value +
|
||||
Math.round(
|
||||
selectionColorOpacityOption.el.querySelector('input').value * 255,
|
||||
).toString(16)
|
||||
);
|
||||
};
|
||||
selectionColorOption.set = function setInput(value) {
|
||||
this.el.querySelector('input').value = value.substring(0, 7);
|
||||
selectionColorOpacityOption.el.querySelector('input').value =
|
||||
Math.round((parseInt(value.substring(7), 16) / 255) * 100) / 100;
|
||||
};
|
||||
selectionColorOpacityOption.get = () => 0;
|
||||
selectionColorOpacityOption.set = () => 0;
|
||||
70
wetty/src/assets/xterm_config/xterm_defaults.js
Normal file
70
wetty/src/assets/xterm_config/xterm_defaults.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const DEFAULT_BELL_SOUND =
|
||||
'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjMyLjEwNAAAAAAAAAAAAAAA//tQxAADB8AhSmxhIIEVCSiJrDCQBTcu3UrAIwUdkRgQbFAZC1CQEwTJ9mjRvBA4UOLD8nKVOWfh+UlK3z/177OXrfOdKl7pyn3Xf//WreyTRUoAWgBgkOAGbZHBgG1OF6zM82DWbZaUmMBptgQhGjsyYqc9ae9XFz280948NMBWInljyzsNRFLPWdnZGWrddDsjK1unuSrVN9jJsK8KuQtQCtMBjCEtImISdNKJOopIpBFpNSMbIHCSRpRR5iakjTiyzLhchUUBwCgyKiweBv/7UsQbg8isVNoMPMjAAAA0gAAABEVFGmgqK////9bP/6XCykxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq';
|
||||
window.loadOptions({
|
||||
wettyFitTerminal: true,
|
||||
wettyVoid: 0,
|
||||
|
||||
xterm: {
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cursorBlink: false,
|
||||
cursorStyle: 'block',
|
||||
cursorWidth: 1,
|
||||
bellSound: DEFAULT_BELL_SOUND,
|
||||
bellStyle: 'none',
|
||||
drawBoldTextInBrightColors: true,
|
||||
fastScrollModifier: 'alt',
|
||||
fastScrollSensitivity: 5,
|
||||
fontFamily: 'courier-new, courier, monospace',
|
||||
fontSize: 15,
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
lineHeight: 1.0,
|
||||
linkTooltipHoverDuration: 500,
|
||||
letterSpacing: 0,
|
||||
logLevel: 'info',
|
||||
scrollback: 1000,
|
||||
scrollSensitivity: 1,
|
||||
screenReaderMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
minimumContrastRatio: 1,
|
||||
disableStdin: false,
|
||||
allowProposedApi: true,
|
||||
allowTransparency: false,
|
||||
tabStopWidth: 8,
|
||||
rightClickSelectsWord: false,
|
||||
rendererType: 'canvas',
|
||||
windowOptions: {},
|
||||
windowsMode: false,
|
||||
wordSeparator: ' ()[]{}\',"`',
|
||||
convertEol: false,
|
||||
termName: 'xterm',
|
||||
cancelEvents: false,
|
||||
|
||||
theme: {
|
||||
foreground: '#ffffff',
|
||||
background: '#000000',
|
||||
cursor: '#ffffff',
|
||||
cursorAccent: '#000000',
|
||||
selection: '#FFFFFF4D',
|
||||
|
||||
black: '#2e3436',
|
||||
red: '#cc0000',
|
||||
green: '#4e9a06',
|
||||
yellow: '#c4a000',
|
||||
blue: '#3465a4',
|
||||
magenta: '#75507b',
|
||||
cyan: '#06989a',
|
||||
white: '#d3d7cf',
|
||||
brightBlack: '#555753',
|
||||
brightRed: '#ef2929',
|
||||
brightGreen: '#8ae234',
|
||||
brightYellow: '#fce94f',
|
||||
brightBlue: '#729fcf',
|
||||
brightMagenta: '#ad7fa8',
|
||||
brightCyan: '#34e2e2',
|
||||
brightWhite: '#eeeeec',
|
||||
},
|
||||
},
|
||||
});
|
||||
136
wetty/src/assets/xterm_config/xterm_general_options.js
Normal file
136
wetty/src/assets/xterm_config/xterm_general_options.js
Normal file
@@ -0,0 +1,136 @@
|
||||
window.inflateOptions([
|
||||
{
|
||||
type: 'text',
|
||||
name: 'Font Family',
|
||||
description: 'The font family for terminal text.',
|
||||
path: ['xterm', 'fontFamily'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Font Size',
|
||||
description: 'The font size in CSS pixels for terminal text.',
|
||||
path: ['xterm', 'fontSize'],
|
||||
min: 4,
|
||||
},
|
||||
{
|
||||
type: 'enum',
|
||||
name: 'Regular Font Weight',
|
||||
description: 'The font weight for non-bold text.',
|
||||
path: ['xterm', 'fontWeight'],
|
||||
enum: [
|
||||
'normal',
|
||||
'bold',
|
||||
'100',
|
||||
'200',
|
||||
'300',
|
||||
'400',
|
||||
'500',
|
||||
'600',
|
||||
'700',
|
||||
'800',
|
||||
'900',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'enum',
|
||||
name: 'Bold Font Weight',
|
||||
description: 'The font weight for bold text.',
|
||||
path: ['xterm', 'fontWeightBold'],
|
||||
enum: [
|
||||
'normal',
|
||||
'bold',
|
||||
'100',
|
||||
'200',
|
||||
'300',
|
||||
'400',
|
||||
'500',
|
||||
'600',
|
||||
'700',
|
||||
'800',
|
||||
'900',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Fit Terminal',
|
||||
description:
|
||||
'Automatically fits the terminal to the page, overriding terminal columns and rows.',
|
||||
path: ['wettyFitTerminal'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Terminal Columns',
|
||||
description:
|
||||
'The number of columns in the terminal. Overridden by the Fit Terminal option.',
|
||||
path: ['xterm', 'cols'],
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Terminal Rows',
|
||||
description:
|
||||
'The number of rows in the terminal. Overridden by the Fit Terminal option.',
|
||||
path: ['xterm', 'rows'],
|
||||
nullable: true,
|
||||
},
|
||||
{
|
||||
type: 'enum',
|
||||
name: 'Cursor Style',
|
||||
description: 'The style of the cursor',
|
||||
path: ['xterm', 'cursorStyle'],
|
||||
enum: ['block', 'underline', 'bar'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Blinking Cursor',
|
||||
description: 'Whether the cursor blinks',
|
||||
path: ['xterm', 'cursorBlink'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Bar Cursor Width',
|
||||
description:
|
||||
"The width of the cursor in CSS pixels. Only applies when Cursor Style is set to 'bar'.",
|
||||
path: ['xterm', 'cursorWidth'],
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'Draw Bold Text In Bright Colors',
|
||||
description: 'Whether to draw bold text in bright colors',
|
||||
path: ['xterm', 'drawBoldTextInBrightColors'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Scroll Sensitivity',
|
||||
description: 'The scroll speed multiplier for regular scrolling.',
|
||||
path: ['xterm', 'scrollSensitivity'],
|
||||
float: true,
|
||||
},
|
||||
{
|
||||
type: 'enum',
|
||||
name: 'Fast Scroll Key',
|
||||
description: 'The modifier key to hold to multiply scroll speed.',
|
||||
path: ['xterm', 'fastScrollModifier'],
|
||||
enum: ['none', 'alt', 'shift', 'ctrl'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Fast Scroll Multiplier',
|
||||
description: 'The scroll speed multiplier used for fast scrolling.',
|
||||
path: ['xterm', 'fastScrollSensitivity'],
|
||||
float: true,
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Scrollback Rows',
|
||||
description:
|
||||
'The amount of scrollback rows, rows you can scroll up to after they leave the viewport, to keep.',
|
||||
path: ['xterm', 'scrollback'],
|
||||
},
|
||||
{
|
||||
type: 'number',
|
||||
name: 'Tab Stop Width',
|
||||
description: 'The size of tab stops in the terminal.',
|
||||
path: ['xterm', 'tabStopWidth'],
|
||||
},
|
||||
]);
|
||||
16
wetty/src/buffer.ts
Normal file
16
wetty/src/buffer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createInterface } from 'readline';
|
||||
|
||||
ask('Enter your username');
|
||||
|
||||
function ask(question: string): Promise<string> {
|
||||
const rlp = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
return new Promise(resolve => {
|
||||
rlp.question(`${question}: `, answer => {
|
||||
rlp.close();
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
5
wetty/src/client/dev.ts
Normal file
5
wetty/src/client/dev.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
caches.keys().then(cacheNames => {
|
||||
cacheNames.forEach(cacheName => {
|
||||
caches.delete(cacheName);
|
||||
});
|
||||
});
|
||||
72
wetty/src/client/wetty.ts
Normal file
72
wetty/src/client/wetty.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { dom, library } from '@fortawesome/fontawesome-svg-core';
|
||||
import { faCogs, faKeyboard } from '@fortawesome/free-solid-svg-icons';
|
||||
import _ from 'lodash';
|
||||
|
||||
import '../assets/scss/styles.scss';
|
||||
|
||||
import { disconnect } from './wetty/disconnect';
|
||||
import { overlay } from './wetty/disconnect/elements';
|
||||
import { verifyPrompt } from './wetty/disconnect/verify';
|
||||
import { FileDownloader } from './wetty/download';
|
||||
import { mobileKeyboard } from './wetty/mobile';
|
||||
import { socket } from './wetty/socket';
|
||||
import { terminal, Term } from './wetty/term';
|
||||
|
||||
// Setup for fontawesome
|
||||
library.add(faCogs);
|
||||
library.add(faKeyboard);
|
||||
dom.watch();
|
||||
|
||||
function onResize(term: Term): () => void {
|
||||
return function resize() {
|
||||
term.resizeTerm();
|
||||
};
|
||||
}
|
||||
|
||||
const term = terminal(socket);
|
||||
if (_.isUndefined(term)) {
|
||||
disconnect('终端初始化失败:未找到 #terminal 容器');
|
||||
} else {
|
||||
window.addEventListener('beforeunload', verifyPrompt, false);
|
||||
window.addEventListener('resize', onResize(term), false);
|
||||
|
||||
term.resizeTerm();
|
||||
term.focus();
|
||||
mobileKeyboard();
|
||||
const fileDownloader = new FileDownloader();
|
||||
|
||||
socket
|
||||
.on('connect', () => {
|
||||
if (!_.isNull(overlay)) overlay.style.display = 'none';
|
||||
})
|
||||
.on('data', (data: string) => {
|
||||
const remainingData = fileDownloader.buffer(data);
|
||||
if (remainingData) {
|
||||
term.write(remainingData);
|
||||
}
|
||||
})
|
||||
.on('login', () => {
|
||||
if (!_.isNull(overlay)) overlay.style.display = 'none';
|
||||
term.writeln('');
|
||||
term.resizeTerm();
|
||||
term.focus();
|
||||
})
|
||||
.on('logout', disconnect)
|
||||
.on('disconnect', disconnect)
|
||||
.on('error', (err: string | null) => {
|
||||
if (err) disconnect(err);
|
||||
});
|
||||
|
||||
term.onData((data: string) => {
|
||||
socket.emit('input', data);
|
||||
});
|
||||
term.onResize((size: { cols: number; rows: number }) => {
|
||||
socket.emit('resize', size);
|
||||
});
|
||||
|
||||
socket.connect().catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '连接网关失败,请检查配置';
|
||||
disconnect(message);
|
||||
});
|
||||
}
|
||||
11
wetty/src/client/wetty/disconnect.ts
Normal file
11
wetty/src/client/wetty/disconnect.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import _ from 'lodash';
|
||||
import { overlay } from './disconnect/elements';
|
||||
import { verifyPrompt } from './disconnect/verify';
|
||||
|
||||
export function disconnect(reason: string): void {
|
||||
if (_.isNull(overlay)) return;
|
||||
overlay.style.display = 'block';
|
||||
const msg = document.getElementById('msg');
|
||||
if (!_.isUndefined(reason) && !_.isNull(msg)) msg.innerHTML = reason;
|
||||
window.removeEventListener('beforeunload', verifyPrompt, false);
|
||||
}
|
||||
5
wetty/src/client/wetty/disconnect/elements.ts
Normal file
5
wetty/src/client/wetty/disconnect/elements.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const overlay = document.getElementById('overlay');
|
||||
export const terminal = document.getElementById('terminal');
|
||||
export const editor = document.querySelector(
|
||||
'#options .editor',
|
||||
) as HTMLIFrameElement;
|
||||
4
wetty/src/client/wetty/disconnect/verify.ts
Normal file
4
wetty/src/client/wetty/disconnect/verify.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function verifyPrompt(e: { returnValue: string }): string {
|
||||
e.returnValue = 'Are you sure?';
|
||||
return e.returnValue;
|
||||
}
|
||||
236
wetty/src/client/wetty/download.spec.ts
Normal file
236
wetty/src/client/wetty/download.spec.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { expect } from 'chai';
|
||||
import 'mocha';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
import { FileDownloader } from './download';
|
||||
|
||||
const noop = (): void => {}; // eslint-disable-line @typescript-eslint/no-empty-function
|
||||
|
||||
describe('FileDownloader', () => {
|
||||
const FILE_BEGIN = 'BEGIN';
|
||||
const FILE_END = 'END';
|
||||
let fileDownloader: FileDownloader;
|
||||
|
||||
beforeEach(() => {
|
||||
const { window } = new JSDOM(`...`);
|
||||
global.document = window.document;
|
||||
fileDownloader = new FileDownloader(noop, FILE_BEGIN, FILE_END);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
|
||||
it('should return data before file markers', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(
|
||||
fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}`),
|
||||
).to.equal('DATA AT THE LEFT');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should return data after file markers', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(
|
||||
fileDownloader.buffer(`${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`),
|
||||
).to.equal('DATA AT THE RIGHT');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should return data before and after file markers', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(
|
||||
fileDownloader.buffer(
|
||||
`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`,
|
||||
),
|
||||
).to.equal('DATA AT THE LEFTDATA AT THE RIGHT');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should return data before a beginning marker found', () => {
|
||||
sinon.stub(fileDownloader, 'onCompleteFileCallback');
|
||||
expect(fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE`)).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return data after an ending marker found', () => {
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(fileDownloader.buffer(`${FILE_BEGIN}FI`)).to.equal('');
|
||||
expect(fileDownloader.buffer(`LE${FILE_END}DATA AT THE RIGHT`)).to.equal(
|
||||
'DATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence on two calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('BEG')).to.equal('');
|
||||
expect(fileDownloader.buffer('INFILEEND')).to.equal('');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence on n calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('B')).to.equal('');
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
expect(fileDownloader.buffer('I')).to.equal('');
|
||||
expect(fileDownloader.buffer('NFILEEND')).to.equal('');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence with data on the left and right on multiple calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
expect(fileDownloader.buffer('I')).to.equal('');
|
||||
expect(fileDownloader.buffer('NFILEENDDATA AT THE RIGHT')).to.equal(
|
||||
'DATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file begin marker sequence then handle false positive', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
// This isn't part of the file_begin marker and should trigger the partial
|
||||
// file begin marker to be returned with the normal data
|
||||
expect(fileDownloader.buffer('ZDATA AT THE RIGHT')).to.equal(
|
||||
'BEGZDATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.called).to.be.false;
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file end marker sequence on two calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const mockFilePart1 = 'DATA AT THE LEFTBEGINFILEE';
|
||||
const mockFilePart2 = 'NDDATA AT THE RIGHT';
|
||||
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
expect(fileDownloader.buffer(mockFilePart1)).to.equal('DATA AT THE LEFT');
|
||||
expect(fileDownloader.buffer(mockFilePart2)).to.equal('DATA AT THE RIGHT');
|
||||
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should buffer across incomplete file end and file begin marker sequence with data on the left and right on multiple calls', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTBE')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(fileDownloader.buffer('G')).to.equal('');
|
||||
expect(fileDownloader.buffer('I')).to.equal('');
|
||||
expect(fileDownloader.buffer('NFILEE')).to.equal('');
|
||||
expect(fileDownloader.buffer('N')).to.equal('');
|
||||
expect(fileDownloader.buffer('DDATA AT THE RIGHT')).to.equal(
|
||||
'DATA AT THE RIGHT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE');
|
||||
});
|
||||
|
||||
it('should be able to handle multiple files', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(
|
||||
fileDownloader.buffer(
|
||||
'DATA AT THE LEFT' +
|
||||
'BEGIN' +
|
||||
'FILE1' +
|
||||
'END' +
|
||||
'SECOND DATA' +
|
||||
'BEGIN',
|
||||
),
|
||||
).to.equal('DATA AT THE LEFTSECOND DATA');
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
|
||||
|
||||
expect(fileDownloader.buffer('FILE2')).to.equal('');
|
||||
expect(fileDownloader.buffer('E')).to.equal('');
|
||||
expect(fileDownloader.buffer('NDRIGHT')).to.equal('RIGHT');
|
||||
expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
|
||||
});
|
||||
|
||||
it('should be able to handle multiple files with an ending marker', () => {
|
||||
fileDownloader = new FileDownloader(noop, 'BEGIN', 'END');
|
||||
const onCompleteFileCallbackStub = sinon.stub(
|
||||
fileDownloader,
|
||||
'onCompleteFileCallback',
|
||||
);
|
||||
|
||||
expect(fileDownloader.buffer('DATA AT THE LEFTBEGINFILE1EN')).to.equal(
|
||||
'DATA AT THE LEFT',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.false;
|
||||
expect(fileDownloader.buffer('DSECOND DATABEGINFILE2EN')).to.equal(
|
||||
'SECOND DATA',
|
||||
);
|
||||
expect(onCompleteFileCallbackStub.calledOnce).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1');
|
||||
expect(fileDownloader.buffer('D')).to.equal('');
|
||||
expect(onCompleteFileCallbackStub.calledTwice).to.be.true;
|
||||
expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2');
|
||||
});
|
||||
});
|
||||
169
wetty/src/client/wetty/download.ts
Normal file
169
wetty/src/client/wetty/download.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import fileType from 'file-type';
|
||||
import Toastify from 'toastify-js';
|
||||
|
||||
const DEFAULT_FILE_BEGIN = '\u001b[5i';
|
||||
const DEFAULT_FILE_END = '\u001b[4i';
|
||||
|
||||
type OnCompleteFile = (bufferCharacters: string) => void;
|
||||
|
||||
function onCompleteFile(bufferCharacters: string): void {
|
||||
let fileNameBase64;
|
||||
let fileCharacters = bufferCharacters;
|
||||
if (bufferCharacters.includes(":")) {
|
||||
[fileNameBase64, fileCharacters] = bufferCharacters.split(":");
|
||||
}
|
||||
// Try to decode it as base64, if it fails we assume it's not base64
|
||||
try {
|
||||
fileCharacters = window.atob(fileCharacters);
|
||||
} catch (err) {
|
||||
// Assuming it's not base64...
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(fileCharacters.length);
|
||||
for (let i = 0; i < fileCharacters.length; i += 1) {
|
||||
bytes[i] = fileCharacters.charCodeAt(i);
|
||||
}
|
||||
|
||||
let mimeType = 'application/octet-stream';
|
||||
let fileExt = '';
|
||||
const typeData = fileType(bytes);
|
||||
if (typeData) {
|
||||
mimeType = typeData.mime;
|
||||
fileExt = typeData.ext;
|
||||
}
|
||||
// Check if the buffer is ASCII
|
||||
// Ref: https://stackoverflow.com/a/14313213
|
||||
// eslint-disable-next-line no-control-regex
|
||||
else if (/^[\x00-\xFF]*$/.test(fileCharacters)) {
|
||||
mimeType = 'text/plain';
|
||||
fileExt = 'txt';
|
||||
}
|
||||
let fileName;
|
||||
try {
|
||||
if (fileNameBase64 !== undefined) {
|
||||
fileName = window.atob(fileNameBase64);
|
||||
}
|
||||
} catch (err) {
|
||||
// Filename wasn't base64-encoded so let's ignore it
|
||||
}
|
||||
|
||||
if (fileName === undefined) {
|
||||
fileName = `file-${new Date()
|
||||
.toISOString()
|
||||
.split('.')[0]
|
||||
.replace(/-/g, '')
|
||||
.replace('T', '')
|
||||
.replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`;
|
||||
}
|
||||
|
||||
const blob = new Blob([new Uint8Array(bytes.buffer)], {
|
||||
type: mimeType,
|
||||
});
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
Toastify({
|
||||
text: `Download ready: <a href="${blobUrl}" target="_blank" download="${fileName}">${fileName}</a>`,
|
||||
duration: 10000,
|
||||
newWindow: true,
|
||||
gravity: 'bottom',
|
||||
position: 'right',
|
||||
backgroundColor: '#fff',
|
||||
stopOnFocus: true,
|
||||
escapeMarkup: false,
|
||||
}).showToast();
|
||||
}
|
||||
|
||||
export class FileDownloader {
|
||||
fileBuffer: string[];
|
||||
fileBegin: string;
|
||||
fileEnd: string;
|
||||
partialFileBegin: string;
|
||||
onCompleteFileCallback: OnCompleteFile;
|
||||
|
||||
constructor(
|
||||
onCompleteFileCallback: OnCompleteFile = onCompleteFile,
|
||||
fileBegin: string = DEFAULT_FILE_BEGIN,
|
||||
fileEnd: string = DEFAULT_FILE_END,
|
||||
) {
|
||||
this.fileBuffer = [];
|
||||
this.fileBegin = fileBegin;
|
||||
this.fileEnd = fileEnd;
|
||||
this.partialFileBegin = '';
|
||||
this.onCompleteFileCallback = onCompleteFileCallback;
|
||||
}
|
||||
|
||||
bufferCharacter(character: string): string {
|
||||
// If we are not currently buffering a file.
|
||||
if (this.fileBuffer.length === 0) {
|
||||
// If we are not currently expecting the rest of the fileBegin sequences.
|
||||
if (this.partialFileBegin.length === 0) {
|
||||
// If the character is the first character of fileBegin we know to start
|
||||
// expecting the rest of the fileBegin sequence.
|
||||
if (character === this.fileBegin[0]) {
|
||||
this.partialFileBegin = character;
|
||||
return '';
|
||||
}
|
||||
// Otherwise, we just return the character for printing to the terminal.
|
||||
|
||||
return character;
|
||||
}
|
||||
// We're currently in the state of buffering a beginner marker...
|
||||
|
||||
const nextExpectedCharacter =
|
||||
this.fileBegin[this.partialFileBegin.length];
|
||||
// If the next character *is* the next character in the fileBegin sequence.
|
||||
if (character === nextExpectedCharacter) {
|
||||
this.partialFileBegin += character;
|
||||
// Do we now have the complete fileBegin sequence.
|
||||
if (this.partialFileBegin === this.fileBegin) {
|
||||
this.partialFileBegin = '';
|
||||
this.fileBuffer = this.fileBuffer.concat(this.fileBegin.split(''));
|
||||
return '';
|
||||
}
|
||||
// Otherwise, we just wait until the next character.
|
||||
|
||||
return '';
|
||||
}
|
||||
// If the next expected character wasn't found for the fileBegin sequence,
|
||||
// we need to return all the data that was bufferd in the partialFileBegin
|
||||
// back to the terminal.
|
||||
|
||||
const dataToReturn = this.partialFileBegin + character;
|
||||
this.partialFileBegin = '';
|
||||
return dataToReturn;
|
||||
}
|
||||
// If we are currently in the state of buffering a file.
|
||||
|
||||
this.fileBuffer.push(character);
|
||||
// If we now have an entire fileEnd marker, we have a complete file!
|
||||
if (
|
||||
this.fileBuffer.length >= this.fileBegin.length + this.fileEnd.length &&
|
||||
this.fileBuffer.slice(-this.fileEnd.length).join('') === this.fileEnd
|
||||
) {
|
||||
this.onCompleteFileCallback(
|
||||
this.fileBuffer
|
||||
.slice(
|
||||
this.fileBegin.length,
|
||||
this.fileBuffer.length - this.fileEnd.length,
|
||||
)
|
||||
.join(''),
|
||||
);
|
||||
this.fileBuffer = [];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
buffer(data: string): string {
|
||||
// This is a optimization to quickly return if we know for
|
||||
// sure we don't need to loop over each character.
|
||||
if (
|
||||
this.fileBuffer.length === 0 &&
|
||||
this.partialFileBegin.length === 0 &&
|
||||
data.indexOf(this.fileBegin[0]) === -1
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
return data.split('').map(this.bufferCharacter.bind(this)).join('');
|
||||
}
|
||||
}
|
||||
24
wetty/src/client/wetty/flowcontrol.ts
Normal file
24
wetty/src/client/wetty/flowcontrol.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Flow control client side.
|
||||
* For low impact on overall throughput simply commits every `ackBytes`
|
||||
* (default 2^18).
|
||||
*/
|
||||
export class FlowControlClient {
|
||||
public counter = 0;
|
||||
public ackBytes = 262144;
|
||||
|
||||
constructor(ackBytes?: number) {
|
||||
if (ackBytes) {
|
||||
this.ackBytes = ackBytes;
|
||||
}
|
||||
}
|
||||
|
||||
public needsCommit(length: number): boolean {
|
||||
this.counter += length;
|
||||
if (this.counter >= this.ackBytes) {
|
||||
this.counter -= this.ackBytes;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
14
wetty/src/client/wetty/mobile.ts
Normal file
14
wetty/src/client/wetty/mobile.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import _ from 'lodash';
|
||||
|
||||
export function mobileKeyboard(): void {
|
||||
const [screen] = Array.from(document.getElementsByClassName('xterm-screen'));
|
||||
if (_.isNull(screen)) return;
|
||||
screen.setAttribute('contenteditable', 'true');
|
||||
screen.setAttribute('spellcheck', 'false');
|
||||
screen.setAttribute('autocorrect', 'false');
|
||||
screen.setAttribute('autocomplete', 'false');
|
||||
screen.setAttribute('autocapitalize', 'false');
|
||||
/*
|
||||
term.scrollPort_.screen_.setAttribute('contenteditable', 'false');
|
||||
*/
|
||||
}
|
||||
118
wetty/src/client/wetty/runtimeConfig.ts
Normal file
118
wetty/src/client/wetty/runtimeConfig.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export type GatewayAuthType = 'password' | 'privateKey' | 'certificate';
|
||||
|
||||
export interface TerminalServerConfig {
|
||||
id: string;
|
||||
name?: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
transportMode?: string;
|
||||
authType: GatewayAuthType;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
certificate?: string;
|
||||
knownHostFingerprint?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export interface TerminalRuntimeConfig {
|
||||
gatewayUrl?: string;
|
||||
gatewayToken?: string;
|
||||
selectedServerId?: string;
|
||||
servers: TerminalServerConfig[];
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' ? value : undefined;
|
||||
}
|
||||
|
||||
function parseServer(input: unknown): TerminalServerConfig | undefined {
|
||||
if (!isObject(input)) return undefined;
|
||||
|
||||
const id = asString(input.id);
|
||||
const host = asString(input.host);
|
||||
const username = asString(input.username);
|
||||
const authType = asString(input.authType) as GatewayAuthType | undefined;
|
||||
const port = asNumber(input.port);
|
||||
|
||||
if (!id || !host || !username || !authType || !port) return undefined;
|
||||
if (!['password', 'privateKey', 'certificate'].includes(authType)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
authType,
|
||||
name: asString(input.name),
|
||||
transportMode: asString(input.transportMode),
|
||||
password: asString(input.password),
|
||||
privateKey: asString(input.privateKey),
|
||||
passphrase: asString(input.passphrase),
|
||||
certificate: asString(input.certificate),
|
||||
knownHostFingerprint: asString(input.knownHostFingerprint),
|
||||
cols: asNumber(input.cols),
|
||||
rows: asNumber(input.rows),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载终端运行配置:
|
||||
* 1) 统一从项目根目录 `terminal.config.json` 读取;
|
||||
* 2) 使用 `no-store` 避免开发阶段被浏览器缓存;
|
||||
* 3) 只返回通过最小字段校验的服务器条目,减少运行时协议错误。
|
||||
*/
|
||||
export async function loadRuntimeConfig(): Promise<TerminalRuntimeConfig> {
|
||||
const response = await fetch('/terminal.config.json', {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 terminal.config.json 失败: HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as unknown;
|
||||
if (!isObject(data)) {
|
||||
throw new Error('terminal.config.json 格式错误: 顶层必须是对象');
|
||||
}
|
||||
|
||||
const serversInput = Array.isArray(data.servers) ? data.servers : [];
|
||||
const servers = serversInput
|
||||
.map(parseServer)
|
||||
.filter((item): item is TerminalServerConfig => Boolean(item));
|
||||
|
||||
if (servers.length === 0) {
|
||||
throw new Error('terminal.config.json 格式错误: servers 不能为空');
|
||||
}
|
||||
|
||||
return {
|
||||
gatewayUrl: asString(data.gatewayUrl),
|
||||
gatewayToken: asString(data.gatewayToken),
|
||||
selectedServerId: asString(data.selectedServerId),
|
||||
servers,
|
||||
};
|
||||
}
|
||||
|
||||
export function selectServer(
|
||||
config: TerminalRuntimeConfig,
|
||||
): TerminalServerConfig {
|
||||
if (config.selectedServerId) {
|
||||
const selected = config.servers.find(
|
||||
(server) => server.id === config.selectedServerId,
|
||||
);
|
||||
if (selected) return selected;
|
||||
}
|
||||
|
||||
return config.servers[0];
|
||||
}
|
||||
273
wetty/src/client/wetty/socket.ts
Normal file
273
wetty/src/client/wetty/socket.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { loadRuntimeConfig, selectServer } from './runtimeConfig';
|
||||
|
||||
type SocketEvent =
|
||||
| 'connect'
|
||||
| 'login'
|
||||
| 'data'
|
||||
| 'logout'
|
||||
| 'disconnect'
|
||||
| 'error';
|
||||
|
||||
type EventPayloadMap = {
|
||||
connect: [];
|
||||
login: [];
|
||||
data: [string];
|
||||
logout: [string];
|
||||
disconnect: [string];
|
||||
error: [string];
|
||||
};
|
||||
|
||||
type EventHandler<K extends SocketEvent> = (...args: EventPayloadMap[K]) => void;
|
||||
|
||||
type EmitEvent = 'input' | 'resize' | 'commit';
|
||||
|
||||
interface ResizePayload {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
interface GatewayFrame {
|
||||
type: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface GatewaySocketOptions {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TermSocketLike {
|
||||
emit(event: EmitEvent, payload: string | number | ResizePayload): void;
|
||||
}
|
||||
|
||||
export class GatewaySocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private options: GatewaySocketOptions = { cols: 80, rows: 24 };
|
||||
private initialized = false;
|
||||
private handlers = new Map<SocketEvent, Set<(...args: unknown[]) => void>>();
|
||||
|
||||
public on<K extends SocketEvent>(event: K, handler: EventHandler<K>): this {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set<(...args: unknown[]) => void>());
|
||||
}
|
||||
this.handlers.get(event)?.add(handler as (...args: unknown[]) => void);
|
||||
return this;
|
||||
}
|
||||
|
||||
public emit(
|
||||
event: EmitEvent,
|
||||
payload: string | number | ResizePayload,
|
||||
): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
if (!this.initialized && event !== 'resize') return;
|
||||
|
||||
if (event === 'input' && typeof payload === 'string') {
|
||||
this.sendFrame({
|
||||
type: 'stdin',
|
||||
payload: { data: payload, meta: { source: 'keyboard' } },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event === 'resize' &&
|
||||
typeof payload === 'object' &&
|
||||
payload !== null &&
|
||||
typeof (payload as ResizePayload).cols === 'number' &&
|
||||
typeof (payload as ResizePayload).rows === 'number'
|
||||
) {
|
||||
const size = payload as ResizePayload;
|
||||
this.options = { cols: size.cols, rows: size.rows };
|
||||
this.sendFrame({
|
||||
type: 'resize',
|
||||
payload: size as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
try {
|
||||
const runtime = await loadRuntimeConfig();
|
||||
const selectedServer = selectServer(runtime);
|
||||
this.options = {
|
||||
cols: selectedServer.cols ?? 80,
|
||||
rows: selectedServer.rows ?? 24,
|
||||
};
|
||||
|
||||
const wsUrl = GatewaySocket.buildGatewayWsUrl(
|
||||
runtime.gatewayUrl,
|
||||
runtime.gatewayToken,
|
||||
);
|
||||
this.ws = new WebSocket(wsUrl);
|
||||
|
||||
this.ws.addEventListener('open', () => {
|
||||
this.fire('connect');
|
||||
this.sendFrame({
|
||||
type: 'init',
|
||||
payload: this.buildInitPayload(selectedServer),
|
||||
});
|
||||
});
|
||||
|
||||
this.ws.addEventListener('message', (event) => {
|
||||
this.handleMessage(event.data);
|
||||
});
|
||||
|
||||
this.ws.addEventListener('close', (event) => {
|
||||
const reason = event.reason || `连接已关闭 (code=${event.code})`;
|
||||
if (this.initialized) {
|
||||
this.fire('logout', reason);
|
||||
}
|
||||
this.fire('disconnect', reason);
|
||||
this.initialized = false;
|
||||
this.ws = null;
|
||||
});
|
||||
|
||||
this.ws.addEventListener('error', () => {
|
||||
this.fire('error', '网关连接异常');
|
||||
});
|
||||
} catch (error) {
|
||||
this.fire(
|
||||
'error',
|
||||
error instanceof Error ? error.message : '初始化网关连接失败',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private static buildGatewayWsUrl(
|
||||
rawUrl: string | undefined,
|
||||
token?: string,
|
||||
): string {
|
||||
const defaultProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const defaultUrl = `${defaultProtocol}//${window.location.hostname}:8787`;
|
||||
const normalized = rawUrl && rawUrl.trim() ? rawUrl : defaultUrl;
|
||||
|
||||
const url = new URL(normalized);
|
||||
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
|
||||
url.protocol = defaultProtocol;
|
||||
}
|
||||
url.pathname = '/ws/terminal';
|
||||
if (token) {
|
||||
url.searchParams.set('token', token);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 gateway init 帧:
|
||||
* 1) 认证信息按 authType 映射到协议要求字段;
|
||||
* 2) `clientSessionKey` 固定化,支持网关侧短时断线续接;
|
||||
* 3) 初始窗口大小从配置注入,后续再由 xterm resize 覆盖。
|
||||
*/
|
||||
private buildInitPayload(
|
||||
server: Awaited<ReturnType<typeof selectServer>>,
|
||||
): Record<string, unknown> {
|
||||
const clientSessionKey = `${server.id}:${server.username}@${server.host}:${server.port}`;
|
||||
const payload: Record<string, unknown> = {
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
clientSessionKey,
|
||||
pty: {
|
||||
cols: this.options.cols,
|
||||
rows: this.options.rows,
|
||||
},
|
||||
};
|
||||
|
||||
if (server.knownHostFingerprint) {
|
||||
payload.knownHostFingerprint = server.knownHostFingerprint;
|
||||
}
|
||||
|
||||
if (server.authType === 'password') {
|
||||
payload.credential = {
|
||||
type: 'password',
|
||||
password: server.password || '',
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
if (server.authType === 'privateKey') {
|
||||
payload.credential = {
|
||||
type: 'privateKey',
|
||||
privateKey: server.privateKey || '',
|
||||
...server.passphrase ? { passphrase: server.passphrase } : {},
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload.credential = {
|
||||
type: 'certificate',
|
||||
privateKey: server.privateKey || '',
|
||||
certificate: server.certificate || '',
|
||||
...server.passphrase ? { passphrase: server.passphrase } : {},
|
||||
};
|
||||
return payload;
|
||||
}
|
||||
|
||||
private handleMessage(raw: string | Blob | ArrayBuffer): void {
|
||||
let data = '';
|
||||
|
||||
if (typeof raw === 'string') {
|
||||
data = raw;
|
||||
} else if (raw instanceof ArrayBuffer) {
|
||||
data = new TextDecoder().decode(raw);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
let frame: GatewayFrame;
|
||||
try {
|
||||
frame = JSON.parse(data) as GatewayFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!frame || typeof frame.type !== 'string') return;
|
||||
|
||||
if (frame.type === 'stdout' || frame.type === 'stderr') {
|
||||
const text = `${frame.payload?.data || ''}`;
|
||||
if (text) this.fire('data', text);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'error') {
|
||||
const msg = `${frame.payload?.message || '网关返回错误'}`;
|
||||
this.fire('error', msg);
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === 'control') {
|
||||
const action = `${frame.payload?.action || ''}`;
|
||||
if (action === 'ping') {
|
||||
this.sendFrame({ type: 'control', payload: { action: 'pong' } });
|
||||
return;
|
||||
}
|
||||
if (action === 'connected') {
|
||||
if (!this.initialized) {
|
||||
this.initialized = true;
|
||||
this.fire('login');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === 'disconnect') {
|
||||
const reason = `${frame.payload?.reason || '连接已断开'}`;
|
||||
this.fire('logout', reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sendFrame(frame: GatewayFrame): void {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
this.ws.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
private fire<K extends SocketEvent>(
|
||||
event: K,
|
||||
...args: EventPayloadMap[K]
|
||||
): void {
|
||||
this.handlers.get(event)?.forEach((handler) => {
|
||||
handler(...args);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const socket = new GatewaySocket();
|
||||
236
wetty/src/client/wetty/term.ts
Normal file
236
wetty/src/client/wetty/term.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { ImageAddon } from '@xterm/addon-image';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { terminal as termElement } from './disconnect/elements';
|
||||
import { configureTerm } from './term/confiruragtion';
|
||||
import { loadOptions } from './term/load';
|
||||
import type { TermSocketLike } from './socket';
|
||||
import type { Options } from './term/options';
|
||||
|
||||
export class Term extends Terminal {
|
||||
socket: TermSocketLike;
|
||||
fitAddon: FitAddon;
|
||||
loadOptions: () => Options;
|
||||
|
||||
constructor(socket: TermSocketLike) {
|
||||
super({ allowProposedApi: true });
|
||||
this.socket = socket;
|
||||
this.fitAddon = new FitAddon();
|
||||
this.loadAddon(this.fitAddon);
|
||||
this.loadAddon(new WebLinksAddon());
|
||||
this.loadAddon(new ImageAddon());
|
||||
this.loadOptions = loadOptions;
|
||||
}
|
||||
|
||||
resizeTerm(): void {
|
||||
this.refresh(0, this.rows - 1);
|
||||
if (this.shouldFitTerm) this.fitAddon.fit();
|
||||
this.socket.emit('resize', { cols: this.cols, rows: this.rows });
|
||||
}
|
||||
|
||||
get shouldFitTerm(): boolean {
|
||||
return this.loadOptions().wettyFitTerminal ?? true;
|
||||
}
|
||||
}
|
||||
|
||||
const ctrlButton = document.getElementById('onscreen-ctrl');
|
||||
let ctrlFlag = false; // This indicates whether the CTRL key is pressed or not
|
||||
|
||||
/**
|
||||
* Toggles the state of the `ctrlFlag` variable and updates the visual state
|
||||
* of the `ctrlButton` element accordingly. If `ctrlFlag` is set to `true`,
|
||||
* the `active` class is added to the `ctrlButton`; otherwise, it is removed.
|
||||
* After toggling, the terminal (`wetty_term`) is focused if it exists.
|
||||
*/
|
||||
const toggleCTRL = (): void => {
|
||||
ctrlFlag = !ctrlFlag;
|
||||
if (ctrlButton) {
|
||||
if (ctrlFlag) {
|
||||
ctrlButton.classList.add('active');
|
||||
} else {
|
||||
ctrlButton.classList.remove('active');
|
||||
}
|
||||
}
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a backspace key press by sending the backspace character
|
||||
* (ASCII code 127) to the terminal. This function is intended to be used
|
||||
* in conjunction with the `simulateCTRLAndKey` function to handle
|
||||
* keyboard shortcuts.
|
||||
*
|
||||
*/
|
||||
const simulateBackspace = (): void => {
|
||||
window.wetty_term?.input('\x7F', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a CTRL + key press by sending the corresponding character
|
||||
* (converted from the key's ASCII code) to the terminal. This function
|
||||
* is intended to be used in conjunction with the `toggleCTRL` function
|
||||
* to handle keyboard shortcuts.
|
||||
*
|
||||
* @param key - The key that was pressed, which will be converted to
|
||||
* its corresponding character code.
|
||||
*/
|
||||
const simulateCTRLAndKey = (key: string): void => {
|
||||
window.wetty_term?.input(`${String.fromCharCode(key.toUpperCase().charCodeAt(0) - 64)}`, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the keydown event for the CTRL key. When the CTRL key is pressed,
|
||||
* it sets the `ctrlFlag` variable to true and updates the visual state of
|
||||
* the `ctrlButton` element. If the CTRL key is released, it sets `ctrlFlag`
|
||||
* to false and updates the visual state of the `ctrlButton` element.
|
||||
*
|
||||
* @param e - The keyboard event object.
|
||||
*/
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (ctrlFlag) {
|
||||
// if key is a character
|
||||
if (e.key.length === 1 && e.key.match(/^[a-zA-Z0-9]$/)) {
|
||||
simulateCTRLAndKey(e.key);
|
||||
// delayed backspace is needed to remove the character added to the terminal
|
||||
// when CTRL + key is pressed.
|
||||
// this is a workaround because e.preventDefault() cannot be used.
|
||||
_.debounce(() => {
|
||||
simulateBackspace();
|
||||
}, 100)();
|
||||
}
|
||||
toggleCTRL();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Simulates pressing the ESC key by sending the ESC character (ASCII code 27)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the ESC character, the terminal is focused.
|
||||
*/
|
||||
const pressESC = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the UP arrow key by sending the UP character (ASCII code 65)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the UP character, the terminal is focused.
|
||||
*/
|
||||
const pressUP = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[A', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the DOWN arrow key by sending the DOWN character (ASCII code 66)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the DOWN character, the terminal is focused.
|
||||
*/
|
||||
const pressDOWN = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[B', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the TAB key by sending the TAB character (ASCII code 9)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the TAB character, the terminal is focused.
|
||||
*/
|
||||
const pressTAB = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x09', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the LEFT arrow key by sending the LEFT character (ASCII code 68)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the LEFT character, the terminal is focused.
|
||||
*/
|
||||
const pressLEFT = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[D', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates pressing the RIGHT arrow key by sending the RIGHT character (ASCII code 67)
|
||||
* to the terminal. If the CTRL key is active, it toggles the CTRL state off.
|
||||
* After sending the RIGHT character, the terminal is focused.
|
||||
*/
|
||||
const pressRIGHT = (): void => {
|
||||
if (ctrlFlag) {
|
||||
toggleCTRL();
|
||||
}
|
||||
window.wetty_term?.input('\x1B[C', false);
|
||||
window.wetty_term?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the visibility of the onscreen buttons by adding or removing
|
||||
* the 'active' class to the element with the ID 'onscreen-buttons'.
|
||||
*/
|
||||
const toggleFunctions = (): void => {
|
||||
const element = document.querySelector('div#functions > div.onscreen-buttons')
|
||||
if (element?.classList.contains('active')) {
|
||||
element?.classList.remove('active');
|
||||
} else {
|
||||
element?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
wetty_term?: Term;
|
||||
wetty_close_config?: () => void;
|
||||
wetty_save_config?: (newConfig: Options) => void;
|
||||
clipboardData: DataTransfer;
|
||||
loadOptions: (conf: Options) => void;
|
||||
toggleFunctions?: () => void;
|
||||
toggleCTRL? : () => void;
|
||||
pressESC?: () => void;
|
||||
pressUP?: () => void;
|
||||
pressDOWN?: () => void;
|
||||
pressTAB?: () => void;
|
||||
pressLEFT?: () => void;
|
||||
pressRIGHT?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export function terminal(socket: TermSocketLike): Term | undefined {
|
||||
const term = new Term(socket);
|
||||
if (_.isNull(termElement)) return undefined;
|
||||
termElement.innerHTML = '';
|
||||
term.open(termElement);
|
||||
configureTerm(term);
|
||||
window.onresize = function onResize() {
|
||||
term.resizeTerm();
|
||||
};
|
||||
window.wetty_term = term;
|
||||
window.toggleFunctions = toggleFunctions;
|
||||
window.toggleCTRL = toggleCTRL;
|
||||
window.pressESC = pressESC;
|
||||
window.pressUP = pressUP;
|
||||
window.pressDOWN = pressDOWN;
|
||||
window.pressTAB = pressTAB;
|
||||
window.pressLEFT = pressLEFT;
|
||||
window.pressRIGHT = pressRIGHT;
|
||||
return term;
|
||||
}
|
||||
77
wetty/src/client/wetty/term/confiruragtion.ts
Normal file
77
wetty/src/client/wetty/term/confiruragtion.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { editor } from '../disconnect/elements';
|
||||
import { copySelected, copyShortcut } from './confiruragtion/clipboard';
|
||||
import { onInput } from './confiruragtion/editor';
|
||||
import { loadOptions } from './load';
|
||||
import type { Options } from './options';
|
||||
import type { Term } from '../term';
|
||||
|
||||
export function configureTerm(term: Term): void {
|
||||
const options = loadOptions();
|
||||
try {
|
||||
term.options = options.xterm;
|
||||
} catch {
|
||||
/* Do nothing */
|
||||
}
|
||||
|
||||
const toggle = document.querySelector('#options .toggler');
|
||||
const optionsElem = document.getElementById('options');
|
||||
if (editor == null || toggle == null || optionsElem == null) {
|
||||
throw new Error("Couldn't initialize configuration menu");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前配置同步给 iframe 配置面板,并注入回调钩子。
|
||||
* 返回值表示 iframe 是否已准备好接收配置(loadOptions 已可调用)。
|
||||
*/
|
||||
const syncEditor = (): boolean => {
|
||||
const editorWindow = editor.contentWindow;
|
||||
if (!editorWindow || typeof editorWindow.loadOptions !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
editorWindow.loadOptions(loadOptions());
|
||||
editorWindow.wetty_close_config = () => {
|
||||
optionsElem?.classList.toggle('opened');
|
||||
};
|
||||
editorWindow.wetty_save_config = (newConfig: Options) => {
|
||||
onInput(term, newConfig);
|
||||
};
|
||||
return true;
|
||||
};
|
||||
|
||||
function editorOnLoad() {
|
||||
if (syncEditor()) return;
|
||||
|
||||
// 某些浏览器/开发模式下,iframe 的脚本初始化会略晚于 load 事件。
|
||||
setTimeout(() => {
|
||||
syncEditor();
|
||||
}, 50);
|
||||
}
|
||||
if (
|
||||
(
|
||||
editor.contentDocument ||
|
||||
(editor.contentWindow?.document ?? {
|
||||
readyState: '',
|
||||
})
|
||||
).readyState === 'complete'
|
||||
) {
|
||||
editorOnLoad();
|
||||
}
|
||||
editor.addEventListener('load', editorOnLoad);
|
||||
|
||||
toggle.addEventListener('click', e => {
|
||||
syncEditor();
|
||||
optionsElem.classList.toggle('opened');
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
term.attachCustomKeyEventHandler(copyShortcut);
|
||||
|
||||
document.addEventListener(
|
||||
'mouseup',
|
||||
() => {
|
||||
if (term.hasSelection()) copySelected(term.getSelection());
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
40
wetty/src/client/wetty/term/confiruragtion/clipboard.ts
Normal file
40
wetty/src/client/wetty/term/confiruragtion/clipboard.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
Copy text selection to clipboard on double click or select
|
||||
@param text - the selected text to copy
|
||||
@returns boolean to indicate success or failure
|
||||
*/
|
||||
export function copySelected(text: string): boolean {
|
||||
if (window.clipboardData?.setData) {
|
||||
window.clipboardData.setData('Text', text);
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
document.queryCommandSupported &&
|
||||
document.queryCommandSupported('copy')
|
||||
) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.textContent = text;
|
||||
textarea.style.position = 'fixed';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
return true;
|
||||
} catch (ex) {
|
||||
return false;
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function copyShortcut(e: KeyboardEvent): boolean {
|
||||
// Ctrl + Shift + C
|
||||
if (e.ctrlKey && e.shiftKey && e.keyCode === 67) {
|
||||
e.preventDefault();
|
||||
document.execCommand('copy');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
23
wetty/src/client/wetty/term/confiruragtion/editor.ts
Normal file
23
wetty/src/client/wetty/term/confiruragtion/editor.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { editor } from '../../disconnect/elements';
|
||||
import type { Term } from '../../term';
|
||||
import type { Options } from '../options';
|
||||
|
||||
export const onInput = (term: Term, updated: Options) => {
|
||||
try {
|
||||
const updatedConf = JSON.stringify(updated, null, 2);
|
||||
if (localStorage.options === updatedConf) return;
|
||||
term.options = updated.xterm;
|
||||
if (
|
||||
!updated.wettyFitTerminal &&
|
||||
updated.xterm.cols != null &&
|
||||
updated.xterm.rows != null
|
||||
)
|
||||
term.resize(updated.xterm.cols, updated.xterm.rows);
|
||||
term.resizeTerm();
|
||||
editor.classList.remove('error');
|
||||
localStorage.options = updatedConf;
|
||||
} catch (e) {
|
||||
console.error('Configuration Error', e); // eslint-disable-line no-console
|
||||
editor.classList.add('error');
|
||||
}
|
||||
};
|
||||
25
wetty/src/client/wetty/term/load.ts
Normal file
25
wetty/src/client/wetty/term/load.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import _ from 'lodash';
|
||||
import type { XTerm, Options } from './options';
|
||||
|
||||
export const defaultOptions: Options = {
|
||||
xterm: { fontSize: 14 },
|
||||
wettyVoid: 0,
|
||||
wettyFitTerminal: true,
|
||||
};
|
||||
|
||||
export function loadOptions(): Options {
|
||||
try {
|
||||
let options = _.isUndefined(localStorage.options)
|
||||
? defaultOptions
|
||||
: JSON.parse(localStorage.options);
|
||||
// Convert old options to new options
|
||||
if (!('xterm' in options)) {
|
||||
const xterm = options;
|
||||
options = defaultOptions;
|
||||
options.xterm = xterm as unknown as XTerm;
|
||||
}
|
||||
return options;
|
||||
} catch {
|
||||
return defaultOptions;
|
||||
}
|
||||
}
|
||||
11
wetty/src/client/wetty/term/options.ts
Normal file
11
wetty/src/client/wetty/term/options.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type XTerm = {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
fontSize: number;
|
||||
} & Record<string, unknown>;
|
||||
|
||||
export interface Options {
|
||||
xterm: XTerm;
|
||||
wettyFitTerminal: boolean;
|
||||
wettyVoid: number;
|
||||
}
|
||||
160
wetty/src/main.ts
Normal file
160
wetty/src/main.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Create WeTTY server
|
||||
* @module WeTTy
|
||||
*
|
||||
* This is the cli Interface for wetty.
|
||||
*/
|
||||
import { unlinkSync, existsSync, lstatSync } from 'fs';
|
||||
import { createRequire } from 'module';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import { start } from './server.js';
|
||||
import { loadConfigFile, mergeCliConf } from './shared/config.js';
|
||||
import { setLevel, logger } from './shared/logger.js';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
const require = createRequire(import.meta.url);
|
||||
const packageJson = require('../package.json');
|
||||
|
||||
const opts = yargs(hideBin(process.argv))
|
||||
.scriptName(packageJson.name)
|
||||
.version(packageJson.version)
|
||||
.options('conf', {
|
||||
type: 'string',
|
||||
description: 'config file to load config from',
|
||||
})
|
||||
.option('ssl-key', {
|
||||
type: 'string',
|
||||
description: 'path to SSL key',
|
||||
})
|
||||
.option('ssl-cert', {
|
||||
type: 'string',
|
||||
description: 'path to SSL certificate',
|
||||
})
|
||||
.option('ssh-host', {
|
||||
description: 'ssh server host',
|
||||
type: 'string',
|
||||
})
|
||||
.option('ssh-port', {
|
||||
description: 'ssh server port',
|
||||
type: 'number',
|
||||
})
|
||||
.option('ssh-user', {
|
||||
description: 'ssh user',
|
||||
type: 'string',
|
||||
})
|
||||
.option('title', {
|
||||
description: 'window title',
|
||||
type: 'string',
|
||||
})
|
||||
.option('ssh-auth', {
|
||||
description:
|
||||
'defaults to "password", you can use "publickey,password" instead',
|
||||
type: 'string',
|
||||
})
|
||||
.option('ssh-pass', {
|
||||
description: 'ssh password',
|
||||
type: 'string',
|
||||
})
|
||||
.option('ssh-key', {
|
||||
demand: false,
|
||||
description:
|
||||
'path to an optional client private key (connection will be password-less and insecure!)',
|
||||
type: 'string',
|
||||
})
|
||||
.option('ssh-config', {
|
||||
description:
|
||||
'Specifies an alternative ssh configuration file. For further details see "-F" option in ssh(1)',
|
||||
type: 'string',
|
||||
})
|
||||
.option('force-ssh', {
|
||||
description: 'Connecting through ssh even if running as root',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('known-hosts', {
|
||||
description: 'path to known hosts file',
|
||||
type: 'string',
|
||||
})
|
||||
.option('base', {
|
||||
alias: 'b',
|
||||
description: 'base path to wetty',
|
||||
type: 'string',
|
||||
})
|
||||
.option('port', {
|
||||
alias: 'p',
|
||||
description: 'wetty listen port',
|
||||
type: 'number',
|
||||
})
|
||||
.option('host', {
|
||||
description: 'wetty listen host',
|
||||
type: 'string',
|
||||
})
|
||||
.option('socket', {
|
||||
description: 'Make wetty listen on unix socket',
|
||||
type: 'string',
|
||||
})
|
||||
.option('command', {
|
||||
alias: 'c',
|
||||
description: 'command to run in shell',
|
||||
type: 'string',
|
||||
})
|
||||
.option('allow-iframe', {
|
||||
description:
|
||||
'Allow WeTTY to be embedded in an iframe, defaults to allowing same origin',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('allow-remote-hosts', {
|
||||
description:
|
||||
'Allow WeTTY to use the `host` and `port` params in a url as ssh destination',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('allow-remote-command', {
|
||||
description:
|
||||
'Allow WeTTY to use the `command` and `path` params in a url as command and working directory on ssh host',
|
||||
type: 'boolean',
|
||||
})
|
||||
.option('log-level', {
|
||||
description: 'set log level of wetty server',
|
||||
type: 'string',
|
||||
})
|
||||
.option('help', {
|
||||
alias: 'h',
|
||||
type: 'boolean',
|
||||
description: 'Print help message',
|
||||
})
|
||||
.conflicts('host', 'socket')
|
||||
.conflicts('port', 'socket')
|
||||
.boolean('allow_discovery')
|
||||
.parseSync();
|
||||
|
||||
function cleanup() {
|
||||
if (opts.socket) {
|
||||
const socket = opts.socket.toString();
|
||||
if (existsSync(socket) && lstatSync(socket).isSocket()) {
|
||||
unlinkSync(socket);
|
||||
}
|
||||
}
|
||||
}
|
||||
function exit() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!opts.help) {
|
||||
process.on('SIGINT', exit);
|
||||
process.on('exit', cleanup);
|
||||
loadConfigFile(opts.conf)
|
||||
.then((config) => mergeCliConf(opts, config))
|
||||
.then((conf) => {
|
||||
setLevel(conf.logLevel);
|
||||
start(conf.ssh, conf.server, conf.command, conf.forceSSH, conf.ssl);
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
logger().error('error in server', { err });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
} else {
|
||||
yargs.showHelp();
|
||||
process.exitCode = 0;
|
||||
}
|
||||
88
wetty/src/server.ts
Normal file
88
wetty/src/server.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Create WeTTY server
|
||||
* @module WeTTy
|
||||
*/
|
||||
import express from 'express';
|
||||
import gc from 'gc-stats';
|
||||
import { Gauge, collectDefaultMetrics } from 'prom-client';
|
||||
import { getCommand } from './server/command.js';
|
||||
import { gcMetrics } from './server/metrics.js';
|
||||
import { server } from './server/socketServer.js';
|
||||
import { spawn } from './server/spawn.js';
|
||||
import {
|
||||
sshDefault,
|
||||
serverDefault,
|
||||
forceSSHDefault,
|
||||
defaultCommand,
|
||||
} from './shared/defaults.js';
|
||||
import { logger as getLogger } from './shared/logger.js';
|
||||
import type { SSH, SSL, Server } from './shared/interfaces.js';
|
||||
import type { Express } from 'express';
|
||||
import type SocketIO from 'socket.io';
|
||||
|
||||
export * from './shared/interfaces.js';
|
||||
export { logger as getLogger } from './shared/logger.js';
|
||||
|
||||
const wettyConnections = new Gauge({
|
||||
name: 'wetty_connections',
|
||||
help: 'number of active socket connections to wetty',
|
||||
});
|
||||
|
||||
/**
|
||||
* Starts WeTTy Server
|
||||
* @name startServer
|
||||
* @returns Promise that resolves SocketIO server
|
||||
*/
|
||||
export const start = (
|
||||
ssh: SSH = sshDefault,
|
||||
serverConf: Server = serverDefault,
|
||||
command: string = defaultCommand,
|
||||
forcessh: boolean = forceSSHDefault,
|
||||
ssl: SSL | undefined = undefined,
|
||||
): Promise<SocketIO.Server> =>
|
||||
decorateServerWithSsh(express(), ssh, serverConf, command, forcessh, ssl);
|
||||
|
||||
export async function decorateServerWithSsh(
|
||||
app: Express,
|
||||
ssh: SSH = sshDefault,
|
||||
serverConf: Server = serverDefault,
|
||||
command: string = defaultCommand,
|
||||
forcessh: boolean = forceSSHDefault,
|
||||
ssl: SSL | undefined = undefined,
|
||||
): Promise<SocketIO.Server> {
|
||||
const logger = getLogger();
|
||||
if (ssh.key) {
|
||||
logger.warn(`!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
! Password-less auth enabled using private key from ${ssh.key}.
|
||||
! This is dangerous, anything that reaches the wetty server
|
||||
! will be able to run remote operations without authentication.
|
||||
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
|
||||
}
|
||||
|
||||
collectDefaultMetrics();
|
||||
gc().on('stats', gcMetrics);
|
||||
|
||||
const io = await server(app, serverConf, ssl);
|
||||
/**
|
||||
* Wetty server connected too
|
||||
* @fires WeTTy#connnection
|
||||
*/
|
||||
io.on('connection', async (socket: SocketIO.Socket) => {
|
||||
/**
|
||||
* @event wetty#connection
|
||||
* @name connection
|
||||
*/
|
||||
logger.info('Connection accepted.');
|
||||
wettyConnections.inc();
|
||||
|
||||
try {
|
||||
const args = await getCommand(socket, ssh, command, forcessh);
|
||||
logger.debug('Command Generated', { cmd: args.join(' ') });
|
||||
await spawn(socket, args);
|
||||
} catch (error) {
|
||||
logger.info('Disconnect signal sent', { err: error });
|
||||
wettyConnections.dec();
|
||||
}
|
||||
});
|
||||
return io;
|
||||
}
|
||||
70
wetty/src/server/command.ts
Normal file
70
wetty/src/server/command.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import process from 'node:process';
|
||||
import url from 'url';
|
||||
import _ from 'lodash';
|
||||
import { address } from './command/address.js';
|
||||
import { loginOptions } from './command/login.js';
|
||||
import { sshOptions } from './command/ssh.js';
|
||||
import type { SSH } from '../shared/interfaces';
|
||||
import type { Socket } from 'socket.io';
|
||||
|
||||
const localhost = (host: string): boolean =>
|
||||
!_.isUndefined(process.getuid) &&
|
||||
process.getuid() === 0 &&
|
||||
(host === 'localhost' || host === '0.0.0.0' || host === '127.0.0.1');
|
||||
|
||||
const urlArgs = (
|
||||
referer: string | undefined,
|
||||
{
|
||||
allowRemoteCommand,
|
||||
allowRemoteHosts,
|
||||
}: {
|
||||
allowRemoteCommand: boolean;
|
||||
allowRemoteHosts: boolean;
|
||||
},
|
||||
): { [s: string]: string } =>
|
||||
_.pick(
|
||||
_.pickBy(url.parse(referer || '', true).query, _.isString),
|
||||
['pass'],
|
||||
allowRemoteCommand ? ['command', 'path'] : [],
|
||||
allowRemoteHosts ? ['port', 'host'] : [],
|
||||
);
|
||||
|
||||
export async function getCommand(
|
||||
socket: Socket,
|
||||
{
|
||||
user,
|
||||
host,
|
||||
port,
|
||||
auth,
|
||||
pass,
|
||||
key,
|
||||
knownHosts,
|
||||
config,
|
||||
allowRemoteHosts,
|
||||
allowRemoteCommand,
|
||||
}: SSH,
|
||||
command: string,
|
||||
forcessh: boolean
|
||||
): Promise<string[]> {
|
||||
const {
|
||||
request: { headers: { referer } },
|
||||
client: { conn: { remoteAddress } },
|
||||
} = socket;
|
||||
|
||||
if (!forcessh && localhost(host)) {
|
||||
return loginOptions(command, remoteAddress);
|
||||
}
|
||||
|
||||
const sshAddress = await address(socket, user, host);
|
||||
const args = {
|
||||
host: sshAddress,
|
||||
port: `${port}`,
|
||||
pass: pass || '',
|
||||
command,
|
||||
auth,
|
||||
knownHosts,
|
||||
config: config || '',
|
||||
...urlArgs(referer, { allowRemoteHosts, allowRemoteCommand }),
|
||||
};
|
||||
return sshOptions(args, key);
|
||||
}
|
||||
32
wetty/src/server/command/address.ts
Normal file
32
wetty/src/server/command/address.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import _ from 'lodash';
|
||||
import { Socket } from 'socket.io';
|
||||
import { login } from '../login.js';
|
||||
import { escapeShell } from '../shared/shell.js';
|
||||
|
||||
export async function address(
|
||||
socket: Socket,
|
||||
user: string,
|
||||
host: string,
|
||||
): Promise<string> {
|
||||
// Check request-header for username
|
||||
const { request: { headers: {
|
||||
'remote-user': userFromHeader,
|
||||
referer
|
||||
} } } = socket;
|
||||
|
||||
let username: string | undefined;
|
||||
if (!_.isUndefined(userFromHeader) && !Array.isArray(userFromHeader)) {
|
||||
username = userFromHeader;
|
||||
} else {
|
||||
const userFromPathMatch = referer?.match('.+/ssh/([^/]+)$');
|
||||
if (userFromPathMatch) {
|
||||
// eslint-disable-next-line prefer-destructuring
|
||||
username = userFromPathMatch[1].split('?')[0];
|
||||
} else if (user) {
|
||||
username = user;
|
||||
} else {
|
||||
username = await login(socket);
|
||||
}
|
||||
}
|
||||
return `${escapeShell(username)}@${host}`;
|
||||
}
|
||||
12
wetty/src/server/command/login.ts
Normal file
12
wetty/src/server/command/login.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import isUndefined from 'lodash/isUndefined.js';
|
||||
|
||||
const getRemoteAddress = (remoteAddress: string): string =>
|
||||
isUndefined(remoteAddress.split(':')[3])
|
||||
? 'localhost'
|
||||
: remoteAddress.split(':')[3];
|
||||
|
||||
export function loginOptions(command: string, remoteAddress: string): string[] {
|
||||
return command === 'login'
|
||||
? [command, '-h', getRemoteAddress(remoteAddress)]
|
||||
: [command];
|
||||
}
|
||||
43
wetty/src/server/command/ssh.ts
Normal file
43
wetty/src/server/command/ssh.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import isUndefined from 'lodash/isUndefined.js';
|
||||
import { logger } from '../../shared/logger.js';
|
||||
|
||||
export function sshOptions(
|
||||
{
|
||||
pass,
|
||||
path,
|
||||
command,
|
||||
host,
|
||||
port,
|
||||
auth,
|
||||
knownHosts,
|
||||
config,
|
||||
}: Record<string, string>,
|
||||
key?: string,
|
||||
): string[] {
|
||||
const cmd = parseCommand(command, path);
|
||||
const hostChecking = knownHosts !== '/dev/null' ? 'yes' : 'no';
|
||||
logger().info(`Authentication Type: ${auth}`);
|
||||
|
||||
return [
|
||||
...pass ? ['sshpass', '-p', pass] : [],
|
||||
'ssh',
|
||||
'-t',
|
||||
...config ? ['-F', config] : [],
|
||||
...port ? ['-p', port] : [],
|
||||
...key ? ['-i', key] : [],
|
||||
...auth !== 'none' ? ['-o', `PreferredAuthentications=${auth}`] : [],
|
||||
'-o', `UserKnownHostsFile=${knownHosts}`,
|
||||
'-o', `StrictHostKeyChecking=${hostChecking}`,
|
||||
'-o', 'EscapeChar=none',
|
||||
'--',
|
||||
host,
|
||||
...cmd ? [cmd] : [],
|
||||
];
|
||||
}
|
||||
|
||||
function parseCommand(command: string, path?: string): string {
|
||||
if (command === 'login' && isUndefined(path)) return '';
|
||||
return !isUndefined(path)
|
||||
? `$SHELL -c "cd ${path};${command === 'login' ? '$SHELL' : command}"`
|
||||
: command;
|
||||
}
|
||||
81
wetty/src/server/flowcontrol.ts
Normal file
81
wetty/src/server/flowcontrol.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
/**
|
||||
* tinybuffer to lower message pressure on the websocket.
|
||||
* Incoming data from PTY will be held back at most for `timeout` microseconds.
|
||||
* If the accumulated data exceeds `maxSize` the message will be sent
|
||||
* immediately.
|
||||
*/
|
||||
export function tinybuffer(socket: Socket, timeout: number, maxSize: number) {
|
||||
const s: string[] = [];
|
||||
let length = 0;
|
||||
let sender: NodeJS.Timeout | null = null;
|
||||
return (data: string) => {
|
||||
s.push(data);
|
||||
length += data.length;
|
||||
if (length > maxSize) {
|
||||
socket.emit('data', s.join(''));
|
||||
s.length = 0;
|
||||
length = 0;
|
||||
if (sender) {
|
||||
clearTimeout(sender);
|
||||
sender = null;
|
||||
}
|
||||
}
|
||||
else if (!sender) {
|
||||
sender = setTimeout(() => {
|
||||
socket.emit('data', s.join(''));
|
||||
s.length = 0;
|
||||
length = 0;
|
||||
sender = null;
|
||||
}, timeout);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flow control - server side.
|
||||
* Does basic low to high watermark flow control.
|
||||
*
|
||||
* `account` should be fed by new chunk length and returns `true`,
|
||||
* if the underlying PTY should be paused.
|
||||
*
|
||||
* `commit` should be fed by the length value of an 'ack' message
|
||||
* indicating its final processing on xtermjs side. Returns `true`
|
||||
* if the underlying PTY should be resumed.
|
||||
*
|
||||
* Note: Chosen values for low and high must be within reach of the
|
||||
* chosen value of ackBytes on client side, otherwise
|
||||
* flow control may block forever sooner or later.
|
||||
*
|
||||
* The default values are chosen quite high to lower negative impact on overall
|
||||
* throughput. If you need snappier keyboard response under high data pressure
|
||||
* (e.g. pressing Ctrl-C while `yes` is running), lower the values.
|
||||
* This furthermore depends a lot on the general latency of your connection.
|
||||
*/
|
||||
export class FlowControlServer {
|
||||
public counter = 0;
|
||||
public low = 524288; // 2^19 --> 2x ackBytes from frontend
|
||||
public high = 2097152; // 2^21 --> 8x ackBytes from frontend
|
||||
|
||||
constructor(low?: number, high?: number) {
|
||||
if (low) {
|
||||
this.low = low;
|
||||
}
|
||||
if (high) {
|
||||
this.high = high;
|
||||
}
|
||||
}
|
||||
|
||||
public account(length: number): boolean {
|
||||
const old = this.counter;
|
||||
this.counter += length;
|
||||
return old < this.high && this.counter > this.high;
|
||||
}
|
||||
|
||||
public commit(length: number): boolean {
|
||||
const old = this.counter;
|
||||
this.counter -= length;
|
||||
return old > this.low && this.counter < this.low;
|
||||
}
|
||||
}
|
||||
37
wetty/src/server/login.ts
Normal file
37
wetty/src/server/login.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { dirname, resolve as resolvePath } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pty from 'node-pty';
|
||||
import { xterm } from './shared/xterm.js';
|
||||
import type SocketIO from 'socket.io';
|
||||
|
||||
const executable = resolvePath(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'buffer.js',
|
||||
);
|
||||
|
||||
export function login(socket: SocketIO.Socket): Promise<string> {
|
||||
// Request carries no username information
|
||||
// Create terminal and ask user for username
|
||||
const term = pty.spawn('/usr/bin/env', ['node', executable], xterm);
|
||||
let buf = '';
|
||||
return new Promise((resolve, reject) => {
|
||||
term.onExit(({ exitCode }) => {
|
||||
console.error(`Process exited with code: ${exitCode}`);
|
||||
resolve(buf);
|
||||
});
|
||||
term.onData((data: string) => {
|
||||
socket.emit('data', data);
|
||||
});
|
||||
socket
|
||||
.on('input', (input: string) => {
|
||||
term.write(input);
|
||||
// eslint-disable-next-line no-control-regex
|
||||
buf = /\x0177/.exec(input) ? buf.slice(0, -1) : buf + input;
|
||||
})
|
||||
.on('disconnect', () => {
|
||||
term.kill();
|
||||
reject();
|
||||
});
|
||||
});
|
||||
}
|
||||
42
wetty/src/server/metrics.ts
Normal file
42
wetty/src/server/metrics.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Counter } from 'prom-client';
|
||||
import type { GCStatistics } from 'gc-stats';
|
||||
|
||||
const gcLabelNames = ['gctype'];
|
||||
const gcTypes = {
|
||||
0: 'Unknown',
|
||||
1: 'Scavenge',
|
||||
2: 'MarkSweepCompact',
|
||||
3: 'ScavengeAndMarkSweepCompact',
|
||||
4: 'IncrementalMarking',
|
||||
8: 'WeakPhantom',
|
||||
15: 'All',
|
||||
};
|
||||
|
||||
const gcCount = new Counter({
|
||||
name: `nodejs_gc_runs_total`,
|
||||
help: 'Count of total garbage collections.',
|
||||
labelNames: gcLabelNames,
|
||||
});
|
||||
|
||||
const gcTimeCount = new Counter({
|
||||
name: `nodejs_gc_pause_seconds_total`,
|
||||
help: 'Time spent in GC Pause in seconds.',
|
||||
labelNames: gcLabelNames,
|
||||
});
|
||||
|
||||
const gcReclaimedCount = new Counter({
|
||||
name: `nodejs_gc_reclaimed_bytes_total`,
|
||||
help: 'Total number of bytes reclaimed by GC.',
|
||||
labelNames: gcLabelNames,
|
||||
});
|
||||
|
||||
export const gcMetrics = ({ gctype, diff, pause }: GCStatistics): void => {
|
||||
const gcType = gcTypes[gctype];
|
||||
|
||||
gcCount.labels(gcType).inc();
|
||||
gcTimeCount.labels(gcType).inc(pause / 1e9);
|
||||
|
||||
if (diff.usedHeapSize < 0) {
|
||||
gcReclaimedCount.labels(gcType).inc(diff.usedHeapSize * -1);
|
||||
}
|
||||
};
|
||||
25
wetty/src/server/shared/shell.spec.ts
Normal file
25
wetty/src/server/shared/shell.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'mocha';
|
||||
import { expect } from 'chai';
|
||||
import { escapeShell } from './shell';
|
||||
|
||||
describe('Values passed to escapeShell should be safe to pass woth sub processes', () => {
|
||||
it('should escape remove subcommands', () => {
|
||||
const cmd = escapeShell('test`echo hello`');
|
||||
expect(cmd).to.equal('testechohello');
|
||||
});
|
||||
|
||||
it('should allow usernames with special characters', () => {
|
||||
const cmd = escapeShell('bob.jones\\COM@ultra-machine_dir');
|
||||
expect(cmd).to.equal('bob.jones\\COM@ultra-machine_dir');
|
||||
});
|
||||
|
||||
it('should ensure args cant be flags', () => {
|
||||
const cmd = escapeShell("-oProxyCommand='bash' -c `wget localhost:2222`");
|
||||
expect(cmd).to.equal('oProxyCommandbash-cwgetlocalhost2222');
|
||||
});
|
||||
|
||||
it('should remove dashes even when there are illegal characters before them', () => {
|
||||
const cmd = escapeShell("`-oProxyCommand='bash' -c `wget localhost:2222`");
|
||||
expect(cmd).to.equal('oProxyCommandbash-cwgetlocalhost2222');
|
||||
});
|
||||
});
|
||||
3
wetty/src/server/shared/shell.ts
Normal file
3
wetty/src/server/shared/shell.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const escapeShell = (username: string): string =>
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
username.replace(/[^a-zA-Z0-9_\\\-\.\@-]/g, '').replace(/^-+/g, '');
|
||||
15
wetty/src/server/shared/xterm.ts
Normal file
15
wetty/src/server/shared/xterm.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import isUndefined from 'lodash/isUndefined.js';
|
||||
import type { IPtyForkOptions } from 'node-pty';
|
||||
|
||||
export const xterm: IPtyForkOptions = {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
cwd: process.cwd(),
|
||||
env: Object.assign(
|
||||
{},
|
||||
...Object.keys(process.env)
|
||||
.filter((key: string) => !isUndefined(process.env[key]))
|
||||
.map((key: string) => ({ [key]: process.env[key] })),
|
||||
),
|
||||
};
|
||||
51
wetty/src/server/socketServer.ts
Normal file
51
wetty/src/server/socketServer.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import compression from 'compression';
|
||||
import winston from 'express-winston';
|
||||
import { logger } from '../shared/logger.js';
|
||||
import { serveStatic, trim } from './socketServer/assets.js';
|
||||
import { html } from './socketServer/html.js';
|
||||
import { metricMiddleware, metricRoute } from './socketServer/metrics.js';
|
||||
import { favicon, redirect } from './socketServer/middleware.js';
|
||||
import { policies } from './socketServer/security.js';
|
||||
import { listen } from './socketServer/socket.js';
|
||||
import { loadSSL } from './socketServer/ssl.js';
|
||||
import type { SSL, SSLBuffer, Server } from '../shared/interfaces.js';
|
||||
import type { Express } from 'express';
|
||||
import type SocketIO from 'socket.io';
|
||||
|
||||
export async function server(
|
||||
app: Express,
|
||||
{ base, port, host, title, allowIframe, socket }: Server,
|
||||
ssl?: SSL,
|
||||
): Promise<SocketIO.Server> {
|
||||
const basePath = trim(base);
|
||||
logger().info('Starting server', {
|
||||
ssl,
|
||||
port,
|
||||
base,
|
||||
title,
|
||||
});
|
||||
|
||||
const client = html(basePath, title);
|
||||
app
|
||||
.disable('x-powered-by')
|
||||
.use(metricMiddleware(basePath))
|
||||
.use(`${basePath}/metrics`, metricRoute)
|
||||
.use(`${basePath}/client`, serveStatic('client'))
|
||||
.use(
|
||||
winston.logger({
|
||||
winstonInstance: logger(),
|
||||
expressFormat: true,
|
||||
level: 'http',
|
||||
}),
|
||||
)
|
||||
.use(compression())
|
||||
.use(await favicon(basePath))
|
||||
.use(redirect)
|
||||
.use(policies(allowIframe))
|
||||
.get(basePath, client)
|
||||
.get(`${basePath}/ssh/:user`, client);
|
||||
|
||||
const sslBuffer: SSLBuffer = await loadSSL(ssl);
|
||||
|
||||
return listen(app, host, port, basePath, sslBuffer, socket);
|
||||
}
|
||||
5
wetty/src/server/socketServer/assets.ts
Normal file
5
wetty/src/server/socketServer/assets.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import serve from 'serve-static';
|
||||
import { assetsPath } from './shared/path.js';
|
||||
|
||||
export const trim = (str: string): string => str.replace(/\/*$/, '');
|
||||
export const serveStatic = (path: string) => serve(assetsPath(path));
|
||||
124
wetty/src/server/socketServer/html.ts
Normal file
124
wetty/src/server/socketServer/html.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { isDev } from '../../shared/env.js';
|
||||
import type { Request, Response, RequestHandler } from 'express';
|
||||
|
||||
const jsFiles = isDev ? ['dev.js', 'wetty.js'] : ['wetty.js'];
|
||||
|
||||
const render = (
|
||||
title: string,
|
||||
base: string,
|
||||
): string => `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<link rel="icon" type="image/x-icon" href="${base}/client/favicon.ico">
|
||||
<title>${title}</title>
|
||||
<link rel="stylesheet" href="${base}/client/wetty.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="overlay">
|
||||
<div class="error">
|
||||
<div id="msg"></div>
|
||||
<input type="button" onclick="location.reload();" value="reconnect" />
|
||||
</div>
|
||||
</div>
|
||||
<div id="options">
|
||||
<a class="toggler"
|
||||
href="#"
|
||||
alt="Toggle options"
|
||||
><i class="fas fa-cogs"></i></a>
|
||||
<iframe class="editor" src="${base}/client/xterm_config/index.html"></iframe>
|
||||
</div>
|
||||
<div id="functions">
|
||||
<a class="toggler"
|
||||
href="#"
|
||||
alt="Toggle options"
|
||||
onclick="window.toggleFunctions()"
|
||||
><i class="fas fa-keyboard"></i></a>
|
||||
<div class="onscreen-buttons">
|
||||
<a
|
||||
href="#"
|
||||
alt="Up"
|
||||
onclick="window.pressUP()"
|
||||
>
|
||||
<div>
|
||||
Up
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
alt="Down"
|
||||
onclick="window.pressDOWN()"
|
||||
>
|
||||
<div>
|
||||
Down
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
alt="Left"
|
||||
onclick="window.pressLEFT()"
|
||||
>
|
||||
<div>
|
||||
Left
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
alt="Right"
|
||||
onclick="window.pressRIGHT()"
|
||||
>
|
||||
<div>
|
||||
Right
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
alt="Esc"
|
||||
onclick="window.pressESC()"
|
||||
>
|
||||
<div>
|
||||
Esc
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
id="onscreen-ctrl"
|
||||
href="#"
|
||||
alt="Ctrl"
|
||||
onclick="window.toggleCTRL()"
|
||||
>
|
||||
<div>
|
||||
Ctrl
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
alt="Tab"
|
||||
onclick="window.pressTAB()"
|
||||
>
|
||||
<div>
|
||||
Tab
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="terminal"></div>
|
||||
${jsFiles
|
||||
.map(file => ` <script type="module" src="${base}/client/${file}"></script>`)
|
||||
.join('\n')
|
||||
}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export const html = (base: string, title: string): RequestHandler => (
|
||||
_req: Request,
|
||||
res: Response,
|
||||
): void => {
|
||||
res.send(
|
||||
render(
|
||||
title,
|
||||
base,
|
||||
),
|
||||
);
|
||||
};
|
||||
126
wetty/src/server/socketServer/metrics.ts
Normal file
126
wetty/src/server/socketServer/metrics.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import url from 'url';
|
||||
import { register, Counter, Histogram } from 'prom-client';
|
||||
import ResponseTime from 'response-time';
|
||||
import UrlValueParser from 'url-value-parser';
|
||||
import type { Request, Response, RequestHandler } from 'express';
|
||||
|
||||
const requestLabels = ['route', 'method', 'status'];
|
||||
|
||||
const requestCount = new Counter({
|
||||
name: 'http_requests_total',
|
||||
help: 'Counter for total requests received',
|
||||
labelNames: requestLabels,
|
||||
});
|
||||
|
||||
const requestDuration = new Histogram({
|
||||
name: 'http_request_duration_seconds',
|
||||
help: 'Duration of HTTP requests in seconds',
|
||||
labelNames: requestLabels,
|
||||
buckets: [0.01, 0.1, 0.5, 1, 1.5],
|
||||
});
|
||||
|
||||
const requestLength = new Histogram({
|
||||
name: 'http_request_length_bytes',
|
||||
help: 'Content-Length of HTTP request',
|
||||
labelNames: requestLabels,
|
||||
buckets: [512, 1024, 5120, 10240, 51200, 102400],
|
||||
});
|
||||
|
||||
const responseLength = new Histogram({
|
||||
name: 'http_response_length_bytes',
|
||||
help: 'Content-Length of HTTP response',
|
||||
labelNames: requestLabels,
|
||||
buckets: [512, 1024, 5120, 10240, 51200, 102400],
|
||||
});
|
||||
|
||||
/**
|
||||
* Normalizes urls paths.
|
||||
*
|
||||
* This function replaces route params like ids, with a placeholder, so we can
|
||||
* set the metrics label, correctly. E.g., both routes
|
||||
*
|
||||
* - /api/v1/user/1
|
||||
* - /api/v1/user/2
|
||||
*
|
||||
* represents the same logical route, and we want to group them together,
|
||||
* hence the need for the normalization.
|
||||
*
|
||||
* @param {!string} path - url path.
|
||||
* @param {string} [placeholder='#val'] - the placeholder that will replace id like params in the url path.
|
||||
* @returns {string} a normalized path, withoud ids.
|
||||
*/
|
||||
function normalizePath(originalUrl: string, placeholder = '#val'): string {
|
||||
const { pathname } = url.parse(originalUrl);
|
||||
const urlParser = new UrlValueParser();
|
||||
return urlParser.replacePathValues(pathname || '', placeholder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes http status codes.
|
||||
*
|
||||
* Returns strings in the format (2|3|4|5)XX.
|
||||
*/
|
||||
function normalizeStatusCode(status: number): string {
|
||||
if (status >= 200 && status < 300) {
|
||||
return '2XX';
|
||||
}
|
||||
|
||||
if (status >= 300 && status < 400) {
|
||||
return '3XX';
|
||||
}
|
||||
|
||||
if (status >= 400 && status < 500) {
|
||||
return '4XX';
|
||||
}
|
||||
|
||||
return '5XX';
|
||||
}
|
||||
|
||||
export function metricMiddleware(basePath: string): RequestHandler {
|
||||
const metricsPath = `${basePath}/metrics`;
|
||||
|
||||
/**
|
||||
* Corresponds to the R(equest rate), E(error rate), and D(uration of requests),
|
||||
* of the RED metrics.
|
||||
*/
|
||||
return ResponseTime((req: Request, res: Response, time: number): void => {
|
||||
const { originalUrl, method } = req;
|
||||
// will replace ids from the route with `#val` placeholder this serves to
|
||||
// measure the same routes, e.g., /image/id1, and /image/id2, will be
|
||||
// treated as the same route
|
||||
const route = normalizePath(originalUrl);
|
||||
|
||||
if (route !== metricsPath) {
|
||||
const labels = {
|
||||
route,
|
||||
method,
|
||||
status: normalizeStatusCode(res.statusCode),
|
||||
};
|
||||
|
||||
requestCount.inc(labels);
|
||||
|
||||
// observe normalizing to seconds
|
||||
requestDuration.observe(labels, time / 1000);
|
||||
|
||||
// observe request length
|
||||
const reqLength = req.get('Content-Length');
|
||||
if (reqLength) {
|
||||
requestLength.observe(labels, Number(reqLength));
|
||||
}
|
||||
|
||||
// observe response length
|
||||
const resLength = res.get('Content-Length');
|
||||
if (resLength) {
|
||||
responseLength.observe(labels, Number(resLength));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Metrics route to be used by prometheus to scrape metrics
|
||||
*/
|
||||
export async function metricRoute(_req: Request, res: Response): Promise<void> {
|
||||
res.set('Content-Type', register.contentType);
|
||||
res.end(await register.metrics());
|
||||
}
|
||||
99
wetty/src/server/socketServer/middleware.ts
Normal file
99
wetty/src/server/socketServer/middleware.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import etag from 'etag';
|
||||
import fresh from 'fresh';
|
||||
import fs from 'fs-extra';
|
||||
import parseUrl from 'parseurl';
|
||||
import { assetsPath } from './shared/path.js';
|
||||
import type { Request, Response, NextFunction, RequestHandler } from 'express';
|
||||
|
||||
const ONE_YEAR_MS = 60 * 60 * 24 * 365 * 1000; // 1 year
|
||||
|
||||
/**
|
||||
* Determine if the cached representation is fresh.
|
||||
* @param req - server request
|
||||
* @param res - server response
|
||||
* @returns if the cache is fresh or not
|
||||
*/
|
||||
const isFresh = (req: Request, res: Response): boolean =>
|
||||
fresh(req.headers, {
|
||||
etag: res.getHeader('ETag'),
|
||||
'last-modified': res.getHeader('Last-Modified'),
|
||||
});
|
||||
|
||||
/**
|
||||
* redirect requests with trailing / to remove it
|
||||
*
|
||||
* @param req - server request
|
||||
* @param res - server response
|
||||
* @param next - next middleware to call on finish
|
||||
*/
|
||||
export function redirect(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
if (req.path.substr(-1) === '/' && req.path.length > 1)
|
||||
res.redirect(301, req.path.slice(0, -1) + req.url.slice(req.path.length));
|
||||
else next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serves the favicon located by the given `path`.
|
||||
*
|
||||
* @param basePath - server base path
|
||||
* @returns middleware
|
||||
*/
|
||||
export async function favicon(basePath: string): Promise<RequestHandler> {
|
||||
const path = assetsPath('client', 'favicon.ico');
|
||||
|
||||
try {
|
||||
const icon = await fs.readFile(path);
|
||||
return (req: Request, res: Response, next: NextFunction): void => {
|
||||
if (getPathName(req) !== `${basePath}/client/favicon.ico`) {
|
||||
next();
|
||||
} else if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.statusCode = req.method === 'OPTIONS' ? 200 : 405;
|
||||
res.setHeader('Allow', 'GET, HEAD, OPTIONS');
|
||||
res.setHeader('Content-Length', '0');
|
||||
res.end();
|
||||
} else {
|
||||
Object.entries({
|
||||
'Cache-Control': `public, max-age=${Math.floor(ONE_YEAR_MS / 1000)}`,
|
||||
ETag: etag(icon),
|
||||
}).forEach(([key, value]) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
|
||||
// Validate freshness
|
||||
if (isFresh(req, res)) {
|
||||
res.statusCode = 304;
|
||||
res.end();
|
||||
} else {
|
||||
// Send icon
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Length', icon.length);
|
||||
res.setHeader('Content-Type', 'image/x-icon');
|
||||
res.end(icon);
|
||||
}
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
return (_req: Request, _res: Response, next: NextFunction): void =>
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the request pathname.
|
||||
*
|
||||
* @param requests
|
||||
* @returns path name or undefined
|
||||
*/
|
||||
|
||||
function getPathName(req: Request): string | undefined {
|
||||
try {
|
||||
const url = parseUrl(req);
|
||||
return url?.pathname ? url.pathname : undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
26
wetty/src/server/socketServer/security.ts
Normal file
26
wetty/src/server/socketServer/security.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import helmet from 'helmet';
|
||||
import type { Request, Response } from 'express';
|
||||
|
||||
export const policies =
|
||||
(allowIframe: boolean) =>
|
||||
(req: Request, res: Response, next: (err?: unknown) => void): void => {
|
||||
const args: Record<string, unknown> = {
|
||||
referrerPolicy: { policy: ['no-referrer-when-downgrade'] },
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: [
|
||||
"'self'",
|
||||
(req.protocol === 'http' ? 'ws://' : 'wss://') + req.get('host'),
|
||||
],
|
||||
},
|
||||
},
|
||||
frameguard: false
|
||||
};
|
||||
if (!allowIframe) args.frameguard = { action: 'sameorigin' };
|
||||
|
||||
helmet(args)(req, res, next);
|
||||
};
|
||||
12
wetty/src/server/socketServer/shared/path.ts
Normal file
12
wetty/src/server/socketServer/shared/path.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import findUp from 'find-up';
|
||||
|
||||
const filePath = dirname(
|
||||
findUp.sync('package.json', {
|
||||
cwd: dirname(fileURLToPath(import.meta.url)),
|
||||
}) || process.cwd(),
|
||||
);
|
||||
|
||||
export const assetsPath = (...args: string[]) =>
|
||||
resolve(filePath, 'build', ...args);
|
||||
43
wetty/src/server/socketServer/socket.ts
Normal file
43
wetty/src/server/socketServer/socket.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import http from 'http';
|
||||
import https from 'https';
|
||||
import isUndefined from 'lodash/isUndefined.js';
|
||||
import { Server } from 'socket.io';
|
||||
|
||||
import { logger } from '../../shared/logger.js';
|
||||
import type { SSLBuffer } from '../../shared/interfaces.js';
|
||||
import type express from 'express';
|
||||
|
||||
export const listen = (
|
||||
app: express.Express,
|
||||
host: string,
|
||||
port: number,
|
||||
path: string,
|
||||
{ key, cert }: SSLBuffer,
|
||||
socket?: string | boolean
|
||||
): Server =>{
|
||||
// Create the base HTTP/HTTPS server
|
||||
const server = !isUndefined(key) && !isUndefined(cert)
|
||||
? https.createServer({ key, cert }, app)
|
||||
: http.createServer(app);
|
||||
|
||||
// Start listening on either Unix socket or TCP
|
||||
if (socket) {
|
||||
server.listen(socket, () => {
|
||||
logger().info('Server listening on Unix socket', { socket });
|
||||
});
|
||||
} else {
|
||||
server.listen(port, host, () => {
|
||||
logger().info('Server started', {
|
||||
port,
|
||||
connection: !isUndefined(key) && !isUndefined(cert) ? 'https' : 'http',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Create Socket.IO server
|
||||
return new Server(server, {
|
||||
path: `${path}/socket.io`,
|
||||
pingInterval: 3000,
|
||||
pingTimeout: 7000,
|
||||
});
|
||||
}
|
||||
14
wetty/src/server/socketServer/ssl.ts
Normal file
14
wetty/src/server/socketServer/ssl.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { resolve } from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import isUndefined from 'lodash/isUndefined.js';
|
||||
import type { SSL, SSLBuffer } from '../../shared/interfaces';
|
||||
|
||||
export async function loadSSL(ssl?: SSL): Promise<SSLBuffer> {
|
||||
if (isUndefined(ssl) || isUndefined(ssl.key) || isUndefined(ssl.cert))
|
||||
return {};
|
||||
const [key, cert]: Buffer[] = await Promise.all([
|
||||
fs.readFile(resolve(ssl.key)),
|
||||
fs.readFile(resolve(ssl.cert)),
|
||||
]);
|
||||
return { key, cert };
|
||||
}
|
||||
54
wetty/src/server/spawn.ts
Normal file
54
wetty/src/server/spawn.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import isUndefined from 'lodash/isUndefined.js';
|
||||
import pty from 'node-pty';
|
||||
import { logger as getLogger } from '../shared/logger.js';
|
||||
import { tinybuffer, FlowControlServer } from './flowcontrol.js';
|
||||
import { xterm } from './shared/xterm.js';
|
||||
import { envVersionOr } from './spawn/env.js';
|
||||
import type SocketIO from 'socket.io';
|
||||
|
||||
export async function spawn(
|
||||
socket: SocketIO.Socket,
|
||||
args: string[],
|
||||
): Promise<void> {
|
||||
const logger = getLogger();
|
||||
const version = await envVersionOr(0);
|
||||
const cmd = version >= 9 ? ['-S', ...args] : args;
|
||||
logger.debug('Spawning PTY', { cmd });
|
||||
const term = pty.spawn('/usr/bin/env', cmd, xterm);
|
||||
const { pid } = term;
|
||||
const address = args[0] === 'ssh' ? args[1] : 'localhost';
|
||||
logger.info('Process Started on behalf of user', { pid, address });
|
||||
socket.emit('login');
|
||||
term.onExit(({exitCode}) => {
|
||||
logger.info('Process exited', { exitCode, pid });
|
||||
socket.emit('logout');
|
||||
socket
|
||||
.removeAllListeners('disconnect')
|
||||
.removeAllListeners('resize')
|
||||
.removeAllListeners('input');
|
||||
});
|
||||
const send = tinybuffer(socket, 2, 524288);
|
||||
const fcServer = new FlowControlServer();
|
||||
term.onData((data: string) => {
|
||||
send(data);
|
||||
if (fcServer.account(data.length)) {
|
||||
term.pause();
|
||||
}
|
||||
});
|
||||
socket
|
||||
.on('resize', ({ cols, rows }) => {
|
||||
term.resize(cols, rows);
|
||||
})
|
||||
.on('input', input => {
|
||||
if (!isUndefined(term)) term.write(input);
|
||||
})
|
||||
.on('disconnect', () => {
|
||||
term.kill();
|
||||
logger.info('Process exited', { code: 0, pid });
|
||||
})
|
||||
.on('commit', size => {
|
||||
if (fcServer.commit(size)) {
|
||||
term.resume();
|
||||
}
|
||||
});
|
||||
}
|
||||
22
wetty/src/server/spawn/env.ts
Normal file
22
wetty/src/server/spawn/env.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const envVersion = (): Promise<number> =>
|
||||
new Promise((resolve, reject) => {
|
||||
exec('/usr/bin/env --version', (error, stdout, stderr): void => {
|
||||
if (error) {
|
||||
return reject(Error(`error getting env version: ${error.message}`));
|
||||
}
|
||||
if (stderr) {
|
||||
return reject(Error(`error getting env version: ${stderr}`));
|
||||
}
|
||||
return resolve(
|
||||
parseInt(
|
||||
stdout.split(/\r?\n/)[0].split(' (GNU coreutils) ')[1].split('.')[0],
|
||||
10,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export const envVersionOr = (fallback: number): Promise<number> =>
|
||||
envVersion().catch(() => fallback);
|
||||
160
wetty/src/shared/config.ts
Normal file
160
wetty/src/shared/config.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import JSON5 from 'json5';
|
||||
import isUndefined from 'lodash/isUndefined.js';
|
||||
import {
|
||||
sshDefault,
|
||||
serverDefault,
|
||||
forceSSHDefault,
|
||||
defaultCommand,
|
||||
defaultLogLevel,
|
||||
} from './defaults.js';
|
||||
import type { Config, SSH, Server, SSL } from './interfaces';
|
||||
import type winston from 'winston';
|
||||
import type { Arguments } from 'yargs';
|
||||
|
||||
type confValue =
|
||||
| boolean
|
||||
| string
|
||||
| number
|
||||
| undefined
|
||||
| unknown
|
||||
| SSH
|
||||
| Server
|
||||
| SSL;
|
||||
|
||||
/**
|
||||
* Cast given value to boolean
|
||||
*
|
||||
* @param value - variable to cast
|
||||
* @returns variable cast to boolean
|
||||
*/
|
||||
function ensureBoolean(value: confValue): boolean {
|
||||
switch (value) {
|
||||
case true:
|
||||
case 'true':
|
||||
case 1:
|
||||
case '1':
|
||||
case 'on':
|
||||
case 'yes':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLogLevel(
|
||||
confLevel: typeof winston.level,
|
||||
optsLevel: unknown,
|
||||
): typeof winston.level {
|
||||
const logLevel = isUndefined(optsLevel) ? confLevel : `${optsLevel}`;
|
||||
return [
|
||||
'error',
|
||||
'warn',
|
||||
'info',
|
||||
'http',
|
||||
'verbose',
|
||||
'debug',
|
||||
'silly',
|
||||
].includes(logLevel)
|
||||
? (logLevel as typeof winston.level)
|
||||
: defaultLogLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load JSON5 config from file and merge with default args
|
||||
* If no path is provided the default config is returned
|
||||
*
|
||||
* @param filepath - path to config to load
|
||||
* @returns variable cast to boolean
|
||||
*/
|
||||
export async function loadConfigFile(filepath?: string): Promise<Config> {
|
||||
if (isUndefined(filepath)) {
|
||||
return {
|
||||
ssh: sshDefault,
|
||||
server: serverDefault,
|
||||
command: defaultCommand,
|
||||
forceSSH: forceSSHDefault,
|
||||
logLevel: defaultLogLevel,
|
||||
};
|
||||
}
|
||||
const content = await fs.readFile(path.resolve(filepath));
|
||||
const parsed = JSON5.parse(content.toString()) as Config;
|
||||
return {
|
||||
ssh: isUndefined(parsed.ssh)
|
||||
? sshDefault
|
||||
: Object.assign(sshDefault, parsed.ssh),
|
||||
server: isUndefined(parsed.server)
|
||||
? serverDefault
|
||||
: Object.assign(serverDefault, parsed.server),
|
||||
command: isUndefined(parsed.command) ? defaultCommand : `${parsed.command}`,
|
||||
forceSSH: isUndefined(parsed.forceSSH)
|
||||
? forceSSHDefault
|
||||
: ensureBoolean(parsed.forceSSH),
|
||||
ssl: parsed.ssl,
|
||||
logLevel: parseLogLevel(defaultLogLevel, parsed.logLevel),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge 2 objects removing undefined fields
|
||||
*
|
||||
* @param target - base object
|
||||
* @param source - object to get new values from
|
||||
* @returns merged object
|
||||
*
|
||||
*/
|
||||
const objectAssign = (
|
||||
target: SSH | Server,
|
||||
source: Record<string, confValue>,
|
||||
): SSH | Server =>
|
||||
Object.fromEntries(
|
||||
Object.entries(source).map(([key, value]) => [
|
||||
key,
|
||||
isUndefined(source[key]) ? target[key] : value,
|
||||
]),
|
||||
) as SSH | Server;
|
||||
|
||||
/**
|
||||
* Merge cli arguemens with config object
|
||||
*
|
||||
* @param opts - Object containing cli args
|
||||
* @param config - Config object
|
||||
* @returns merged configuration
|
||||
*
|
||||
*/
|
||||
export function mergeCliConf(opts: Arguments, config: Config): Config {
|
||||
const ssl = {
|
||||
key: opts['ssl-key'],
|
||||
cert: opts['ssl-cert'],
|
||||
...config.ssl,
|
||||
} as SSL;
|
||||
return {
|
||||
ssh: objectAssign(config.ssh, {
|
||||
user: opts['ssh-user'],
|
||||
host: opts['ssh-host'],
|
||||
auth: opts['ssh-auth'],
|
||||
port: opts['ssh-port'],
|
||||
pass: opts['ssh-pass'],
|
||||
key: opts['ssh-key'],
|
||||
allowRemoteHosts: opts['allow-remote-hosts'],
|
||||
allowRemoteCommand: opts['allow-remote-command'],
|
||||
config: opts['ssh-config'],
|
||||
knownHosts: opts['known-hosts'],
|
||||
}) as SSH,
|
||||
server: objectAssign(config.server, {
|
||||
base: opts.base,
|
||||
host: opts.host,
|
||||
socket: opts.socket,
|
||||
port: opts.port,
|
||||
title: opts.title,
|
||||
allowIframe: opts['allow-iframe'],
|
||||
}) as Server,
|
||||
command: isUndefined(opts.command) ? config.command : `${opts.command}`,
|
||||
forceSSH: isUndefined(opts['force-ssh'])
|
||||
? config.forceSSH
|
||||
: ensureBoolean(opts['force-ssh']),
|
||||
ssl: isUndefined(ssl.key) || isUndefined(ssl.cert) ? undefined : ssl,
|
||||
logLevel: parseLogLevel(config.logLevel, opts['log-level']),
|
||||
};
|
||||
}
|
||||
28
wetty/src/shared/defaults.ts
Normal file
28
wetty/src/shared/defaults.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { isDev } from './env.js';
|
||||
import type { SSH, Server } from './interfaces';
|
||||
|
||||
export const sshDefault: SSH = {
|
||||
user: process.env.SSHUSER || '',
|
||||
host: process.env.SSHHOST || 'localhost',
|
||||
auth: process.env.SSHAUTH || 'password',
|
||||
pass: process.env.SSHPASS || undefined,
|
||||
key: process.env.SSHKEY || undefined,
|
||||
port: parseInt(process.env.SSHPORT || '22', 10),
|
||||
knownHosts: process.env.KNOWNHOSTS || '/dev/null',
|
||||
allowRemoteHosts: false,
|
||||
allowRemoteCommand: false,
|
||||
config: process.env.SSHCONFIG || undefined,
|
||||
};
|
||||
|
||||
export const serverDefault: Server = {
|
||||
base: process.env.BASE || '/wetty/',
|
||||
port: parseInt(process.env.PORT || '3000', 10),
|
||||
host: '0.0.0.0',
|
||||
socket: false,
|
||||
title: process.env.TITLE || 'WeTTY - The Web Terminal Emulator',
|
||||
allowIframe: process.env.ALLOWIFRAME === 'true' || false,
|
||||
};
|
||||
|
||||
export const forceSSHDefault = process.env.FORCESSH === 'true' || false;
|
||||
export const defaultCommand = process.env.COMMAND || 'login';
|
||||
export const defaultLogLevel = isDev ? 'debug' : 'http';
|
||||
1
wetty/src/shared/env.ts
Normal file
1
wetty/src/shared/env.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const isDev = process.env.NODE_ENV === 'development';
|
||||
44
wetty/src/shared/interfaces.ts
Normal file
44
wetty/src/shared/interfaces.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type winston from 'winston';
|
||||
|
||||
export interface SSH {
|
||||
[s: string]: string | number | boolean | undefined;
|
||||
user: string;
|
||||
host: string;
|
||||
auth: string;
|
||||
port: number;
|
||||
knownHosts: string;
|
||||
allowRemoteHosts: boolean;
|
||||
allowRemoteCommand: boolean;
|
||||
pass?: string;
|
||||
key?: string;
|
||||
config?: string;
|
||||
}
|
||||
|
||||
export interface SSL {
|
||||
key: string;
|
||||
cert: string;
|
||||
}
|
||||
|
||||
export interface SSLBuffer {
|
||||
key?: Buffer;
|
||||
cert?: Buffer;
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
[s: string]: string | number | boolean;
|
||||
port: number;
|
||||
host: string;
|
||||
socket: string | boolean;
|
||||
title: string;
|
||||
base: string;
|
||||
allowIframe: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
ssh: SSH;
|
||||
server: Server;
|
||||
forceSSH: boolean;
|
||||
command: string;
|
||||
logLevel: typeof winston.level;
|
||||
ssl?: SSL;
|
||||
}
|
||||
40
wetty/src/shared/logger.ts
Normal file
40
wetty/src/shared/logger.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import winston from 'winston';
|
||||
import { defaultLogLevel } from './defaults.js';
|
||||
import { isDev } from './env.js';
|
||||
|
||||
const { combine, timestamp, label, simple, json, colorize } = winston.format;
|
||||
|
||||
const dev = combine(
|
||||
colorize(),
|
||||
label({ label: 'Wetty' }),
|
||||
timestamp(),
|
||||
simple(),
|
||||
);
|
||||
|
||||
const prod = combine(label({ label: 'Wetty' }), timestamp(), json());
|
||||
|
||||
let globalLogger = winston.createLogger({
|
||||
format: isDev ? dev : prod,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
level: defaultLogLevel,
|
||||
handleExceptions: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export function setLevel(level: typeof winston.level): void {
|
||||
globalLogger = winston.createLogger({
|
||||
format: isDev ? dev : prod,
|
||||
transports: [
|
||||
new winston.transports.Console({
|
||||
level,
|
||||
handleExceptions: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function logger(): winston.Logger {
|
||||
return globalLogger;
|
||||
}
|
||||
Reference in New Issue
Block a user