Browse Source

rust server

rust
butlerx 4 years ago
parent
commit
c61a7ce097
No known key found for this signature in database GPG Key ID: B37CA765BAA89170
  1. 6
      .eslintrc.json
  2. 5
      .gitignore
  3. 1772
      Cargo.lock
  4. 25
      Cargo.toml
  5. 0
      assets/favicon.ico
  6. 0
      assets/scss/options.scss
  7. 0
      assets/scss/overlay.scss
  8. 0
      assets/scss/styles.scss
  9. 0
      assets/scss/terminal.scss
  10. 0
      assets/scss/variables.scss
  11. 0
      client/dev.ts
  12. 0
      client/shared/elements.ts
  13. 0
      client/shared/verify.ts
  14. 0
      client/wetty.ts
  15. 0
      client/wetty/disconnect.ts
  16. 0
      client/wetty/download.spec.ts
  17. 0
      client/wetty/download.ts
  18. 0
      client/wetty/mobile.ts
  19. 0
      client/wetty/shared/type.ts
  20. 0
      client/wetty/socket.ts
  21. 0
      client/wetty/term.ts
  22. 0
      client/wetty/term/confiruragtion.ts
  23. 0
      client/wetty/term/confiruragtion/clipboard.ts
  24. 0
      client/wetty/term/confiruragtion/editor.ts
  25. 0
      client/wetty/term/confiruragtion/load.ts
  26. 0
      configs/config.json5
  27. 0
      configs/config.toml
  28. 0
      configs/nginx.template
  29. 0
      configs/wetty.conf
  30. 0
      configs/wetty.service
  31. 38
      package.json
  32. 16
      src/buffer.ts
  33. 114
      src/config.rs
  34. 85
      src/main.rs
  35. 114
      src/main.ts
  36. 48
      src/middlewares.rs
  37. 6
      src/routes/health.rs
  38. 50
      src/routes/html.rs
  39. 20
      src/routes/metrics.rs
  40. 8
      src/routes/mod.rs
  41. 45
      src/routes/socket.rs
  42. 73
      src/server.ts
  43. 50
      src/server/command.ts
  44. 14
      src/server/command/address.ts
  45. 12
      src/server/command/login.ts
  46. 52
      src/server/command/ssh.ts
  47. 35
      src/server/login.ts
  48. 15
      src/server/shared/xterm.ts
  49. 44
      src/server/socketServer.ts
  50. 5
      src/server/socketServer/assets.ts
  51. 55
      src/server/socketServer/html.ts
  52. 99
      src/server/socketServer/middleware.ts
  53. 25
      src/server/socketServer/security.ts
  54. 12
      src/server/socketServer/shared/path.ts
  55. 36
      src/server/socketServer/socket.ts
  56. 14
      src/server/socketServer/ssl.ts
  57. 38
      src/server/spawn.ts
  58. 134
      src/shared/config.ts
  59. 23
      src/shared/defaults.ts
  60. 1
      src/shared/env.ts
  61. 38
      src/shared/interfaces.ts
  62. 24
      src/shared/logger.ts
  63. 0
      src/spawn/mod.rs
  64. 6
      tsconfig.browser.json
  65. 3
      tsconfig.json
  66. 14
      tsconfig.node.json
  67. 798
      yarn.lock

6
.eslintrc.json

@ -3,7 +3,6 @@
"plugins": ["@typescript-eslint", "prettier", "mocha"], "plugins": ["@typescript-eslint", "prettier", "mocha"],
"env": { "env": {
"es6": true, "es6": true,
"node": true,
"browser": true "browser": true
}, },
"root": true, "root": true,
@ -61,10 +60,7 @@
"settings": { "settings": {
"import/resolver": { "import/resolver": {
"typescript": { "typescript": {
"project": ["./tsconfig.browser.json", "./tsconfig.node.json"] "project": ["./tsconfig.json"]
},
"node": {
"extensions": [".ts", ".js"]
} }
} }
} }

5
.gitignore

@ -116,3 +116,8 @@ dist
.vscode-test .vscode-test
# End of https://www.toptal.com/developers/gitignore/api/node # End of https://www.toptal.com/developers/gitignore/api/node
# Added by cargo
/target

1772
Cargo.lock

File diff suppressed because it is too large

25
Cargo.toml

@ -0,0 +1,25 @@
[package]
name = "wetty"
version = "2.1.0"
authors = ["butlerx <butlerx@notthe.cloud>"]
edition = "2018"
description = "WeTTY = Web + TTY. Terminal access in browser over http/https"
repository = "https://github.com/butlerx/wetty"
license = "MIT"
readme = "README.md"
include = ["src/**/*", "Cargo.toml", "configs/**/*.yaml", "client/**/*"]
[dependencies]
futures = "0.3"
handlebars = "3.5"
lazy_static = "1.4.0"
log = "0.4"
prometheus = { version = "0.11.0", features = ["process"] }
pty = "0.2"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
stderrlog = "0.4.3"
structopt = "0.3.21"
tokio = { version = "1", features = ["full"] }
toml = "0.5"
warp = "0.3"

0
src/assets/favicon.ico → assets/favicon.ico

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

0
src/assets/scss/options.scss → assets/scss/options.scss

0
src/assets/scss/overlay.scss → assets/scss/overlay.scss

0
src/assets/scss/styles.scss → assets/scss/styles.scss

0
src/assets/scss/terminal.scss → assets/scss/terminal.scss

0
src/assets/scss/variables.scss → assets/scss/variables.scss

0
src/client/dev.ts → client/dev.ts

0
src/client/shared/elements.ts → client/shared/elements.ts

0
src/client/shared/verify.ts → client/shared/verify.ts

0
src/client/wetty.ts → client/wetty.ts

0
src/client/wetty/disconnect.ts → client/wetty/disconnect.ts

0
src/client/wetty/download.spec.ts → client/wetty/download.spec.ts

0
src/client/wetty/download.ts → client/wetty/download.ts

0
src/client/wetty/mobile.ts → client/wetty/mobile.ts

0
src/client/wetty/shared/type.ts → client/wetty/shared/type.ts

0
src/client/wetty/socket.ts → client/wetty/socket.ts

0
src/client/wetty/term.ts → client/wetty/term.ts

0
src/client/wetty/term/confiruragtion.ts → client/wetty/term/confiruragtion.ts

0
src/client/wetty/term/confiruragtion/clipboard.ts → client/wetty/term/confiruragtion/clipboard.ts

0
src/client/wetty/term/confiruragtion/editor.ts → client/wetty/term/confiruragtion/editor.ts

0
src/client/wetty/term/confiruragtion/load.ts → client/wetty/term/confiruragtion/load.ts

0
conf/config.json5 → configs/config.json5

0
configs/config.toml

0
conf/nginx.template → configs/nginx.template

0
conf/wetty.conf → configs/wetty.conf

0
conf/wetty.service → configs/wetty.service

38
package.json

@ -65,26 +65,24 @@
"installTypes": true "installTypes": true
}, },
"mount": { "mount": {
"src/client": "/client", "client": "/client",
"src/assets": "/assets" "assets": "/assets"
}, },
"exclude": [ "exclude": [
"src/server/**/*.ts", "src/client/**/*.spec.ts"
"src/client/**/*.spec.ts",
"src/*.ts"
], ],
"plugins": [ "plugins": [
[ [
"@snowpack/plugin-run-script", "@snowpack/plugin-run-script",
{ {
"cmd": "tsc -p tsconfig.browser.json --noEmit", "cmd": "tsc -p tsconfig.json --noEmit",
"watch": "$1 --watch" "watch": "$1 --watch"
} }
], ],
[ [
"@snowpack/plugin-run-script", "@snowpack/plugin-run-script",
{ {
"cmd": "sass src/assets/scss:build/assets/css --load-path=node_modules -s compressed --no-source-map", "cmd": "sass assets/scss:build/assets/css --load-path=node_modules -s compressed --no-source-map",
"watch": "$1 --watch" "watch": "$1 --watch"
} }
], ],
@ -100,48 +98,24 @@
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.25", "@fortawesome/fontawesome-svg-core": "^1.2.25",
"@fortawesome/free-solid-svg-icons": "^5.11.2", "@fortawesome/free-solid-svg-icons": "^5.11.2",
"compression": "^1.7.4",
"etag": "^1.8.1",
"express": "^4.17.1",
"express-winston": "^4.0.5",
"file-type": "^12.3.0", "file-type": "^12.3.0",
"find-up": "^5.0.0",
"fresh": "^0.5.2",
"fs-extra": "^9.0.1",
"helmet": "^4.1.0",
"json5": "^2.1.3", "json5": "^2.1.3",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"node-pty": "^0.9.0",
"parseurl": "^1.3.3",
"sass": "^1.26.10", "sass": "^1.26.10",
"socket.io": "^2.3.0",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"toastify-js": "^1.9.1", "toastify-js": "^1.9.1",
"winston": "^3.3.3",
"xterm": "^4.8.1", "xterm": "^4.8.1",
"xterm-addon-fit": "^0.4.0", "xterm-addon-fit": "^0.4.0",
"xterm-addon-web-links": "^0.4.0", "xterm-addon-web-links": "^0.4.0"
"yargs": "^15.4.1"
}, },
"devDependencies": { "devDependencies": {
"@types/chai": "^4.2.12", "@types/chai": "^4.2.12",
"@types/compression": "^1.7.0",
"@types/etag": "^1.8.0",
"@types/express": "^4.17.8",
"@types/fresh": "^0.5.0",
"@types/fs-extra": "^9.0.1",
"@types/helmet": "^0.0.48",
"@types/jsdom": "^12.2.4", "@types/jsdom": "^12.2.4",
"@types/lodash": "^4.14.161", "@types/lodash": "^4.14.161",
"@types/mocha": "^8.0.3", "@types/mocha": "^8.0.3",
"@types/morgan": "^1.7.37",
"@types/node": "^14.6.3",
"@types/parseurl": "^1.3.1", "@types/parseurl": "^1.3.1",
"@types/sinon": "^7.5.1", "@types/sinon": "^7.5.1",
"@types/socket.io": "^2.1.11",
"@types/socket.io-client": "^1.4.33", "@types/socket.io-client": "^1.4.33",
"@types/winston": "^2.4.4",
"@types/yargs": "^15.0.5",
"@typescript-eslint/eslint-plugin": "^2.5.0", "@typescript-eslint/eslint-plugin": "^2.5.0",
"@typescript-eslint/parser": "^4.3.0", "@typescript-eslint/parser": "^4.3.0",
"all-contributors-cli": "^6.17.2", "all-contributors-cli": "^6.17.2",

16
src/buffer.ts

@ -1,16 +0,0 @@
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);
});
});
}

114
src/config.rs

@ -0,0 +1,114 @@
extern crate toml;
use serde::{Deserialize, Serialize};
use std::{
env, fs,
net::{IpAddr, Ipv4Addr, SocketAddr},
path::PathBuf,
str::FromStr,
};
use toml::de::Error;
fn get_env<T: FromStr>(key: &str, default: T) -> T {
match env::var(key) {
Ok(val) => val.parse::<T>().unwrap_or(default),
Err(_) => default,
}
}
#[derive(Clone, Deserialize, Debug, Serialize)]
pub struct SSH {
/// Server to ssh to
pub host: String,
/// default user to use when ssh-ing
pub user: String,
/// shh authentication, method. Defaults to "password", you can use "publickey,password" instead'
pub auth: String,
/// Password to use when sshing
pub pass: Option<String>,
/// path to an optional client private key, connection will be password-less and insecure!
pub key: Option<String>,
/// Port to ssh to
pub port: i16,
/// ssh knownHosts file to use
pub known_hosts: String,
/// alternative ssh configuration file, see "-F" option in ssh(1)
pub config: Option<String>,
}
impl Default for SSH {
fn default() -> Self {
SSH {
user: get_env("SSHUSER", "".to_string()),
host: get_env("SSHHOST", "localhost".to_string()),
auth: get_env("SSHAUTH", "password".to_string()),
port: get_env("SSHPORT", 22),
known_hosts: get_env("KNOWNHOSTS", "/dev/null".to_string()),
config: None,
key: None,
pass: None,
}
}
}
#[derive(Clone, Deserialize, Debug, Serialize)]
pub struct Server {
/// URL base to serve resources from
pub base: String,
/// address to listen on including the port eg: 0.0.0.0:3000
pub address: SocketAddr,
// Page title
pub title: String,
/// allow wetty to be embedded in an iframe
pub allow_iframe: bool,
}
impl Default for Server {
fn default() -> Self {
Server {
base: get_env("BASE", "wetty".to_string()),
address: get_env(
"ADDRESS",
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 3030),
),
title: get_env("TITLE", "WeTTy - The Web Terminal Emulator".to_string()),
allow_iframe: false,
}
}
}
fn default_command() -> String {
get_env("COMMAND", "login".to_string())
}
fn force_ssh() -> bool {
get_env("FORCESSH", false)
}
#[derive(Clone, Deserialize, Debug, Serialize)]
pub struct Config {
#[serde(default)]
pub server: Server,
#[serde(default)]
pub ssh: SSH,
#[serde(default = "force_ssh")]
/// Force sshing to local machine over login if running as root
pub force_ssh: bool,
#[serde(default = "default_command")]
/// Command to run on server. Login will use ssh if connecting to different server
pub default_command: String,
}
impl Config {
pub fn from_file(filename: &PathBuf) -> Result<Self, Error> {
debug!("loading config; path={:?}", filename);
let contents = fs::read_to_string(filename).expect("Something went wrong reading the file");
toml::from_str(&*contents)
}
pub fn print_default() {
let conf: Config = toml::from_str("").expect("failed to set default");
println!(
"{}",
toml::Value::try_from(conf).expect("failed to Serialize conf to toml")
)
}
}

85
src/main.rs

@ -0,0 +1,85 @@
#[macro_use] extern crate lazy_static;
#[macro_use] extern crate log;
#[macro_use] extern crate prometheus;
extern crate pty;
extern crate stderrlog;
mod config;
mod middlewares;
mod routes;
use config::{Server,Config};
use handlebars::Handlebars;
use std::sync::Arc;
use std::{io::Result, path::PathBuf};
use structopt::StructOpt;
use warp::Filter;
/// WeTTy server
#[derive(Debug, StructOpt)]
struct Args {
/// Configuration file config path
#[structopt(short, long, parse(from_os_str), default_value = "configs/config.toml")]
config: PathBuf,
/// Silence all output
#[structopt(short, long)]
quiet: bool,
/// Increase message verbosity
#[structopt(short, long, parse(from_occurrences))]
verbose: usize,
/// Print default config
#[structopt(short, long)]
print: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::from_args();
if args.print {
return Ok(Config::print_default());
}
stderrlog::new()
.module(module_path!())
.quiet(args.quiet)
.verbosity(args.verbose)
.timestamp(stderrlog::Timestamp::Second)
.init()
.unwrap();
let log = warp::log("wetty");
let metrics = warp::log::custom(middlewares::metrics);
let conf = Config::from_file(&args.config)?;
info!("config loaded; path={:?}", args.config);
let metrics_route = warp::path!("metrics").and_then(routes::metrics::handler).with(metrics);
let health = warp::path!("health").and_then(routes::health::handler).with(metrics);
let base = warp::path(conf.server.base);
let socket = base.clone().and(warp::path!("socket.io"))
.and(warp::ws())
.and_then(routes::socket::handler)
.with(metrics);
let hb = Arc::new(Handlebars::new());
let client = base.clone().or(base.clone().and(warp::path!("ssh" / String )))
.and(||warp::any().map(|| conf.server.clone()))
.map(move |_user, config: Server|routes::html::render(hb.clone(), config.title,config.base))
.with(metrics);
let routes = warp::fs::dir("client/build")
.or(client)
.or(metrics_route)
.or(health)
.or(socket)
.with(log);
info!(
"Server started; address={}",
conf.server.address,
);
warp::serve(routes).run(conf.server.address).await;
info!("Server shutting down");
Ok(())
}

114
src/main.ts

@ -1,114 +0,0 @@
#!/usr/bin/env node
/**
* Create WeTTY server
* @module WeTTy
*
* This is the cli Interface for wetty.
*/
import yargs from 'yargs';
import { logger } from './shared/logger.js';
import { start } from './server.js';
import { loadConfigFile, mergeCliConf } from './shared/config.js';
const opts = yargs
.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('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('help', {
alias: 'h',
type: 'boolean',
description: 'Print help message',
})
.boolean('allow_discovery').argv;
if (!opts.help) {
loadConfigFile(opts.conf)
.then(config => mergeCliConf(opts, config))
.then(conf =>
start(conf.ssh, conf.server, conf.command, conf.forceSSH, conf.ssl),
)
.catch((err: Error) => {
logger.error(err);
process.exitCode = 1;
});
} else {
yargs.showHelp();
process.exitCode = 0;
}

48
src/middlewares.rs

@ -0,0 +1,48 @@
use prometheus::{HistogramVec, IntCounter, IntCounterVec};
use warp::filters::log::Info;
lazy_static! {
static ref INCOMING_REQUESTS: IntCounter =
register_int_counter!("incoming_requests", "Incoming Requests")
.expect("metric can be created");
static ref RESPONSE_CODE_COLLECTOR: IntCounterVec = register_int_counter_vec!(
opts!("response_code", "Response Codes"),
&["method", "path", "status", "type"]
)
.expect("metric can be created");
static ref RESPONSE_TIME_COLLECTOR: HistogramVec = register_histogram_vec!(
histogram_opts!("response_time", "Response Times"),
&["method", "path"]
)
.expect("metric can be created");
}
pub fn metrics(info: Info) {
let (method, path, status_code) = (
info.method().to_string(),
info.path(),
info.status().as_u16(),
);
INCOMING_REQUESTS.inc();
RESPONSE_TIME_COLLECTOR
.with_label_values(&[&method, &path])
.observe(info.elapsed().as_secs_f64());
match status_code {
500..=599 => RESPONSE_CODE_COLLECTOR
.with_label_values(&[&method, &path, &status_code.to_string(), "500"])
.inc(),
400..=499 => RESPONSE_CODE_COLLECTOR
.with_label_values(&[&method, &path, &status_code.to_string(), "400"])
.inc(),
300..=399 => RESPONSE_CODE_COLLECTOR
.with_label_values(&[&method, &path, &status_code.to_string(), "300"])
.inc(),
200..=299 => RESPONSE_CODE_COLLECTOR
.with_label_values(&[&method, &path, &status_code.to_string(), "200"])
.inc(),
100..=199 => RESPONSE_CODE_COLLECTOR
.with_label_values(&[&method, &path, &status_code.to_string(), "100"])
.inc(),
_ => (),
};
}

6
src/routes/health.rs

@ -0,0 +1,6 @@
use crate::routes::Responce;
use warp::{http::StatusCode, Reply};
pub async fn handler() -> Responce<impl Reply> {
Ok(StatusCode::OK)
}

50
src/routes/html.rs

@ -0,0 +1,50 @@
use handlebars::Handlebars;
use serde_json::json;
use std::sync::Arc;
use warp::Reply;
pub fn render(hbs: Arc<Handlebars>, title: String, base: String) -> impl Reply {
let value = json!({
"title": title,
"base": base,
"js": ["wetty"],
"css": ["styles", "options", "overlay", "terminal"]
});
warp::reply::html(
hbs.render_template(INDEX_HTML, &value)
.unwrap_or_else(|err| err.to_string()),
)
}
pub static INDEX_HTML: &str = r#"<!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}}/favicon.ico">
<title>{{title}}</title>
{{#each css}}
<link rel="stylesheet" href="{{base}}/assets/css/{{this}}.css" />
{{/each}}
</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>
<textarea class="editor"></textarea>
</div>
<div id="terminal"></div>
{{#each js}}
<script type="module" src="{{base}}/client/{{this}}.js"></script>
{{/each}}
</body>
</html>"#;

20
src/routes/metrics.rs

@ -0,0 +1,20 @@
use crate::routes::Responce;
use prometheus::{self, Encoder};
use warp::Reply;
pub async fn handler() -> Responce<impl Reply> {
let encoder = prometheus::TextEncoder::new();
let mut buffer = Vec::new();
if let Err(e) = encoder.encode(&prometheus::gather(), &mut buffer) {
error!("could not encode prometheus metrics; error={}", e);
};
let res = match String::from_utf8(buffer.clone()) {
Ok(v) => v,
Err(e) => {
error!("prometheus metrics could not be from_utf8'd; error={}", e);
String::default()
}
};
buffer.clear();
Ok(res)
}

8
src/routes/mod.rs

@ -0,0 +1,8 @@
use warp::Rejection;
pub mod health;
pub mod html;
pub mod metrics;
pub mod socket;
type Responce<T> = std::result::Result<T, Rejection>;

45
src/routes/socket.rs

@ -0,0 +1,45 @@
use crate::routes::Responce;
use futures::{SinkExt, StreamExt};
use prometheus::IntGauge;
use serde::{Deserialize, Serialize};
use warp::{
ws::{Message, WebSocket, Ws},
Reply,
};
lazy_static! {
static ref CONNECTED_CLIENTS: IntGauge = register_int_gauge!("connected_clients", "Connected Clients").expect("metric can be created");
}
pub async fn handler(ws: Ws) -> Responce<impl Reply> {
Ok(ws.on_upgrade(move |socket| handle_connection(socket, )))
}
async fn handle_connection(ws: WebSocket) {
let (mut sender, mut rcv) = ws.split();
info!("Connection Opened;");
CONNECTED_CLIENTS.inc();
while let Some(event) = rcv.next().await {
match event {
Ok(msg) => {
debug!(
"Message recieved from websocket; msg={:?}",
msg, );
if let Ok(txt) = msg.to_str() {
//handle message
}
}
Err(err) => {
error!(
"websocket error; error={}",
err
);
break;
}
}
}
info!("Connection Dropped;");
CONNECTED_CLIENTS.dec();
}

73
src/server.ts

@ -1,73 +0,0 @@
/**
* Create WeTTY server
* @module WeTTy
*/
import type SocketIO from 'socket.io';
import type { SSH, SSL, Server } from './shared/interfaces.js';
import { getCommand } from './server/command.js';
import { logger } from './shared/logger.js';
import { login } from './server/login.js';
import { server } from './server/socketServer.js';
import { spawn } from './server/spawn.js';
import {
sshDefault,
serverDefault,
forceSSHDefault,
defaultCommand,
} from './shared/defaults.js';
/**
* Starts WeTTy Server
* @name startServer
* @returns Promise that resolves SocketIO server
*/
export async function start(
ssh: SSH = sshDefault,
serverConf: Server = serverDefault,
command: string = defaultCommand,
forcessh: boolean = forceSSHDefault,
ssl?: SSL,
): Promise<SocketIO.Server> {
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.
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`);
}
const io = await server(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.');
const [args, sshUser] = getCommand(socket, ssh, command, forcessh);
logger.debug('Command Generated', {
user: sshUser,
cmd: args.join(' '),
});
if (sshUser) {
spawn(socket, args);
} else {
try {
const username = await login(socket);
args[1] = `${username.trim()}@${args[1]}`;
logger.debug('Spawning term', {
username: username.trim(),
cmd: args.join(' ').trim(),
});
spawn(socket, args);
} catch (error) {
logger.info('Disconnect signal sent', { err: error });
}
}
});
return io;
}

50
src/server/command.ts

@ -1,50 +0,0 @@
import url from 'url';
import type { Socket } from 'socket.io';
import type { SSH } from '../shared/interfaces';
import { address } from './command/address.js';
import { loginOptions } from './command/login.js';
import { sshOptions } from './command/ssh.js';
const localhost = (host: string): boolean =>
process.getuid() === 0 &&
(host === 'localhost' || host === '0.0.0.0' || host === '127.0.0.1');
const urlArgs = (
referer: string,
def: { [s: string]: string },
): { [s: string]: string } =>
Object.assign(def, url.parse(referer, true).query);
export function getCommand(
{
request: { headers },
client: {
conn: { remoteAddress },
},
}: Socket,
{ user, host, port, auth, pass, key, knownHosts, config }: SSH,
command: string,
forcessh: boolean,
): [string[], boolean] {
const sshAddress = address(headers, user, host);
const localLogin = !forcessh && localhost(host);
return localLogin
? [loginOptions(command, remoteAddress), localLogin]
: [
sshOptions(
{
...urlArgs(headers.referer, {
port: `${port}`,
pass: pass || '',
command,
auth,
knownHosts,
config: config || '',
}),
host: sshAddress,
},
key,
),
user !== '' || user.includes('@') || sshAddress.includes('@'),
];
}

14
src/server/command/address.ts

@ -1,14 +0,0 @@
export function address(
headers: Record<string, string>,
user: string,
host: string,
): string {
// Check request-header for username
const remoteUser = headers['remote-user'];
if (remoteUser) {
return `${remoteUser}@${host}`;
}
const match = headers.referer.match('.+/ssh/([^/]+)$');
const fallback = user ? `${user}@${host}` : host;
return match ? `${match[1].split('?')[0]}@${host}` : fallback;
}

12
src/server/command/login.ts

@ -1,12 +0,0 @@
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];
}

52
src/server/command/ssh.ts

@ -1,52 +0,0 @@
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}`);
let sshRemoteOptsBase = ['ssh', host, '-t'];
if (config !== '') {
sshRemoteOptsBase = sshRemoteOptsBase.concat(['-F', config]);
}
sshRemoteOptsBase = sshRemoteOptsBase.concat([
'-p',
port,
'-o',
`PreferredAuthentications=${auth}`,
'-o',
`UserKnownHostsFile=${knownHosts}`,
'-o',
`StrictHostKeyChecking=${hostChecking}`,
]);
if (!isUndefined(key)) {
return sshRemoteOptsBase.concat(['-i', key, cmd]);
}
if (pass !== '') {
return ['sshpass', '-p', pass].concat(sshRemoteOptsBase, [cmd]);
}
if (auth === 'none') {
sshRemoteOptsBase.splice(sshRemoteOptsBase.indexOf('-o'), 2);
}
return cmd === '' ? sshRemoteOptsBase : sshRemoteOptsBase.concat([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;
}

35
src/server/login.ts

@ -1,35 +0,0 @@
import type SocketIO from 'socket.io';
import pty from 'node-pty';
import { dirname, resolve as resolvePath } from 'path';
import { fileURLToPath } from 'url';
import { xterm } from './shared/xterm.js';
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.on('exit', () => {
resolve(buf);
});
term.on('data', (data: string) => {
socket.emit('data', data);
});
socket
.on('input', (input: string) => {
term.write(input);
buf = /\177/.exec(input) ? buf.slice(0, -1) : buf + input;
})
.on('disconnect', () => {
term.kill();
reject();
});
});
}

15
src/server/shared/xterm.ts

@ -1,15 +0,0 @@
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] })),
),
};

44
src/server/socketServer.ts

@ -1,44 +0,0 @@
import type SocketIO from 'socket.io';
import express from 'express';
import compression from 'compression';
import winston from 'express-winston';
import type { SSL, SSLBuffer, Server } from '../shared/interfaces.js';
import { favicon, redirect } from './socketServer/middleware.js';
import { html } from './socketServer/html.js';
import { listen } from './socketServer/socket.js';
import { logger } from '../shared/logger.js';
import { serveStatic, trim } from './socketServer/assets.js';
import { policies } from './socketServer/security.js';
import { loadSSL } from './socketServer/ssl.js';
export async function server(
{ base, port, host, title, allowIframe }: Server,
ssl?: SSL,
): Promise<SocketIO.Server> {
const basePath = trim(base);
logger.info('Starting server', {
ssl,
port,
base,
title,
});
const app = express();
const client = html(basePath, title);
app
.use(`${basePath}/web_modules`, serveStatic('web_modules'))
.use(`${basePath}/assets`, serveStatic('assets'))
.use(`${basePath}/client`, serveStatic('client'))
.use(winston.logger(logger))
.use(compression())
.use(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);
}

5
src/server/socketServer/assets.ts

@ -1,5 +0,0 @@
import express from 'express';
import { assetsPath } from './shared/path.js';
export const trim = (str: string): string => str.replace(/\/*$/, '');
export const serveStatic = (path: string) => express.static(assetsPath(path));

55
src/server/socketServer/html.ts

@ -1,55 +0,0 @@
import type { Request, Response, RequestHandler } from 'express';
import { isDev } from '../../shared/env.js';
const jsFiles = isDev ? ['dev', 'wetty'] : ['wetty'];
const cssFiles = ['styles', 'options', 'overlay', 'terminal'];
const render = (
title: string,
favicon: string,
css: string[],
js: 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="${favicon}">
<title>${title}</title>
${css.map(file => `<link rel="stylesheet" href="${file}" />`).join('\n')}
</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>
<textarea class="editor"></textarea>
</div>
<div id="terminal"></div>
${js
.map(file => `<script type="module" src="${file}"></script>`)
.join('\n')}
</body>
</html>`;
export const html = (base: string, title: string): RequestHandler => (
_req: Request,
res: Response,
): void => {
res.send(
render(
title,
`${base}/favicon.ico`,
cssFiles.map(css => `${base}/assets/css/${css}.css`),
jsFiles.map(js => `${base}/client/${js}.js`),
),
);
};

99
src/server/socketServer/middleware.ts

@ -1,99 +0,0 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import etag from 'etag';
import fresh from 'fresh';
import parseUrl from 'parseurl';
import fs from 'fs';
import { assetsPath } from './shared/path.js';
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 function favicon(basePath: string): RequestHandler {
const path = assetsPath('assets', 'favicon.ico');
return (req: Request, res: Response, next: NextFunction): void => {
if (getPathName(req) !== `${basePath}/favicon.ico`) {
next();
return;
}
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();
return;
}
fs.readFile(path, (err: Error | null, buf: Buffer) => {
if (err) return next(err);
Object.entries({
'Cache-Control': `public, max-age=${Math.floor(ONE_YEAR_MS / 1000)}`,
ETag: etag(buf),
}).forEach(([key, value]) => {
res.setHeader(key, value);
});
// Validate freshness
if (isFresh(req, res)) {
res.statusCode = 304;
return res.end();
}
// Send icon
res.statusCode = 200;
res.setHeader('Content-Length', buf.length);
res.setHeader('Content-Type', 'image/x-icon');
return res.end(buf);
});
};
}
/**
* 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;
}
}

25
src/server/socketServer/security.ts

@ -1,25 +0,0 @@
import helmet from 'helmet';
import type { Request, Response } from 'express';
export const policies = (allowIframe: boolean) => (
req: Request,
res: Response,
next: (err?: unknown) => void,
) => {
helmet({
frameguard: allowIframe ? false : { action: 'sameorigin' },
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'),
],
},
},
})(req, res, next);
};

12
src/server/socketServer/shared/path.ts

@ -1,12 +0,0 @@
import findUp from 'find-up';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const filePath = dirname(
findUp.sync('package.json', {
cwd: dirname(fileURLToPath(import.meta.url)),
}) || process.cwd(),
);
export const assetsPath = (...args: string[]) =>
resolve(filePath, 'build', ...args);

36
src/server/socketServer/socket.ts

@ -1,36 +0,0 @@
import type express from 'express';
import socket from 'socket.io';
import http from 'http';
import https from 'https';
import isUndefined from 'lodash/isUndefined.js';
import { logger } from '../../shared/logger.js';
import type { SSLBuffer } from '../../shared/interfaces.js';
export const listen = (
app: express.Express,
host: string,
port: number,
path: string,
{ key, cert }: SSLBuffer,
): SocketIO.Server =>
socket(
!isUndefined(key) && !isUndefined(cert)
? https.createServer({ key, cert }, app).listen(port, host, () => {
logger.info('Server started', {
port,
connection: 'https',
});
})
: http.createServer(app).listen(port, host, () => {
logger.info('Server started', {
port,
connection: 'http',
});
}),
{
path: `${path}/socket.io`,
pingInterval: 3000,
pingTimeout: 7000,
},
);

14
src/server/socketServer/ssl.ts

@ -1,14 +0,0 @@
import fs from 'fs-extra';
import isUndefined from 'lodash/isUndefined.js';
import { resolve } from 'path';
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 };
}

38
src/server/spawn.ts

@ -1,38 +0,0 @@
import type SocketIO from 'socket.io';
import isUndefined from 'lodash/isUndefined.js';
import pty from 'node-pty';
import { logger } from '../shared/logger.js';
import { xterm } from './shared/xterm.js';
export function spawn(socket: SocketIO.Socket, args: string[]): void {
const term = pty.spawn('/usr/bin/env', args, 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.on('exit', (code: number) => {
logger.info('Process exited', { code, pid });
socket.emit('logout');
socket
.removeAllListeners('disconnect')
.removeAllListeners('resize')
.removeAllListeners('input');
});
term.on('data', (data: string) => {
socket.emit('data', data);
});
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 });
});
}

134
src/shared/config.ts

@ -1,134 +0,0 @@
import fs from 'fs-extra';
import path from 'path';
import JSON5 from 'json5';
import isUndefined from 'lodash/isUndefined.js';
import type { Arguments } from 'yargs';
import type { Config, SSH, Server, SSL } from './interfaces';
import {
sshDefault,
serverDefault,
forceSSHDefault,
defaultCommand,
} from './defaults.js';
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;
}
}
/**
* 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,
};
}
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,
};
}
/**
* 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'],
config: opts['ssh-config'],
knownHosts: opts['known-hosts'],
}) as SSH,
server: objectAssign(config.server, {
base: opts.base,
host: opts.host,
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,
};
}

23
src/shared/defaults.ts

@ -1,23 +0,0 @@
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',
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',
title: process.env.TITLE || 'WeTTy - The Web Terminal Emulator',
allowIframe: false,
};
export const forceSSHDefault = process.env.FORCESSH === 'true' || false;
export const defaultCommand = process.env.COMMAND || 'login';

1
src/shared/env.ts

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

38
src/shared/interfaces.ts

@ -1,38 +0,0 @@
export interface SSH {
[s: string]: string | number | boolean | undefined;
user: string;
host: string;
auth: string;
port: number;
knownHosts: string;
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;
title: string;
base: string;
allowIframe: boolean;
}
export interface Config {
ssh: SSH;
server: Server;
forceSSH: boolean;
command: string;
ssl?: SSL;
}

24
src/shared/logger.ts

@ -1,24 +0,0 @@
import winston from 'winston';
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());
export const logger = winston.createLogger({
format: isDev ? dev : prod,
transports: [
new winston.transports.Console({
level: isDev ? 'debug' : 'info',
handleExceptions: true,
}),
],
});

0
src/spawn/mod.rs

6
tsconfig.browser.json

@ -1,6 +0,0 @@
{
"extends": "./tsconfig.json",
"include": [
"src/client"
]
}

3
tsconfig.json

@ -16,5 +16,6 @@
"removeComments": true, "removeComments": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true "strict": true
} },
"include": ["client"]
} }

14
tsconfig.node.json

@ -1,14 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"incremental": true,
"outDir": "./build",
"sourceMap": true
},
"include": [
"src"
],
"exclude": [
"src/client"
]
}

798
yarn.lock

File diff suppressed because it is too large
Loading…
Cancel
Save