butlerx
4 years ago
67 changed files with 2210 additions and 1762 deletions
File diff suppressed because it is too large
@ -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" |
Before Width: | Height: | Size: 165 KiB After Width: | Height: | Size: 165 KiB |
@ -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); |
|
||||
}); |
|
||||
}); |
|
||||
} |
|
@ -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") |
||||
|
) |
||||
|
} |
||||
|
} |
@ -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(()) |
||||
|
} |
@ -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; |
|
||||
} |
|
@ -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(), |
||||
|
_ => (), |
||||
|
}; |
||||
|
} |
@ -0,0 +1,6 @@ |
|||||
|
use crate::routes::Responce; |
||||
|
use warp::{http::StatusCode, Reply}; |
||||
|
|
||||
|
pub async fn handler() -> Responce<impl Reply> { |
||||
|
Ok(StatusCode::OK) |
||||
|
} |
@ -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>"#; |
@ -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) |
||||
|
} |
@ -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>; |
@ -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(); |
||||
|
} |
@ -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; |
|
||||
} |
|
@ -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('@'), |
|
||||
]; |
|
||||
} |
|
@ -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; |
|
||||
} |
|
@ -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]; |
|
||||
} |
|
@ -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; |
|
||||
} |
|
@ -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(); |
|
||||
}); |
|
||||
}); |
|
||||
} |
|
@ -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] })), |
|
||||
), |
|
||||
}; |
|
@ -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); |
|
||||
} |
|
@ -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)); |
|
@ -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`), |
|
||||
), |
|
||||
); |
|
||||
}; |
|
@ -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; |
|
||||
} |
|
||||
} |
|
@ -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); |
|
||||
}; |
|
@ -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); |
|
@ -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, |
|
||||
}, |
|
||||
); |
|
@ -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 }; |
|
||||
} |
|
@ -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 }); |
|
||||
}); |
|
||||
} |
|
@ -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, |
|
||||
}; |
|
||||
} |
|
@ -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 +0,0 @@ |
|||||
export const isDev = process.env.NODE_ENV === 'development'; |
|
@ -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; |
|
||||
} |
|
@ -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, |
|
||||
}), |
|
||||
], |
|
||||
}); |
|
@ -1,6 +0,0 @@ |
|||||
{ |
|
||||
"extends": "./tsconfig.json", |
|
||||
"include": [ |
|
||||
"src/client" |
|
||||
] |
|
||||
} |
|
@ -1,14 +0,0 @@ |
|||||
{ |
|
||||
"extends": "./tsconfig.json", |
|
||||
"compilerOptions": { |
|
||||
"incremental": true, |
|
||||
"outDir": "./build", |
|
||||
"sourceMap": true |
|
||||
}, |
|
||||
"include": [ |
|
||||
"src" |
|
||||
], |
|
||||
"exclude": [ |
|
||||
"src/client" |
|
||||
] |
|
||||
} |
|
File diff suppressed because it is too large
Loading…
Reference in new issue