convert wetty from submodule to normal directory

This commit is contained in:
douboer@gmail.com
2026-03-03 16:07:18 +08:00
parent 1db76701a6
commit 0d185d2b3c
131 changed files with 15543 additions and 1 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

View 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;
}
}
}

View 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;
}
}
}

View 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;
}
}

View File

@@ -0,0 +1,6 @@
#terminal {
display: flex;
height: 100%;
position: relative;
width: 100%;
}

View File

@@ -0,0 +1,4 @@
$black: #000;
$grey: rgba(0, 0, 0, 0.75);
$white: #fff;
$lgrey: #ccc;

View 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);
}
});

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

View 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;
}

View 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,
},
]);

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

View 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',
},
},
});

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

@@ -0,0 +1,5 @@
caches.keys().then(cacheNames => {
cacheNames.forEach(cacheName => {
caches.delete(cacheName);
});
});

72
wetty/src/client/wetty.ts Normal file
View 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);
});
}

View 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);
}

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

View File

@@ -0,0 +1,4 @@
export function verifyPrompt(e: { returnValue: string }): string {
e.returnValue = 'Are you sure?';
return e.returnValue;
}

View 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');
});
});

View 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('');
}
}

View 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;
}
}

View 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');
*/
}

View 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];
}

View 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();

View 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;
}

View 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,
);
}

View 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;
}

View 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');
}
};

View 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;
}
}

View 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
View 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
View 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;
}

View 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);
}

View 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}`;
}

View 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];
}

View 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;
}

View 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
View 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();
});
});
}

View 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);
}
};

View 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');
});
});

View 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, '');

View 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] })),
),
};

View 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);
}

View 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));

View 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,
),
);
};

View 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());
}

View 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;
}
}

View 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);
};

View 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);

View 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,
});
}

View 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
View 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();
}
});
}

View 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
View 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']),
};
}

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

@@ -0,0 +1 @@
export const isDev = process.env.NODE_ENV === 'development';

View 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;
}

View 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;
}