Browse Source

Merge pull request #126 from butlerx/master

Version 1 release
pull/155/head
Cian Butler 6 years ago
committed by GitHub
parent
commit
8191de94b7
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 13
      .babelrc
  2. 8
      .dockerignore
  3. 3
      .eslintignore
  4. 36
      .eslintrc.js
  5. 5
      .gitignore
  6. 7
      .npmignore
  7. 13
      .prettierrc.js
  8. 31
      Dockerfile
  9. 199
      README.md
  10. 3
      bin/index.js
  11. 69
      bin/nginx.template
  12. 15
      bin/ssh
  13. 124
      cli.mjs
  14. 21
      docker-compose.yml
  15. 93
      docs/API.md
  16. 24
      docs/README.md
  17. 41
      gulpfile.js
  18. 95
      index.js
  19. 155
      package.json
  20. 41
      public/wetty/index.html
  21. 1
      public/wetty/wetty.min.js
  22. 37
      src/.eslintrc.js
  23. 0
      src/client/favicon.ico
  24. 77
      src/client/index.ts
  25. 47
      src/client/wetty.scss
  26. 18390
      src/hterm_all.js
  27. 16
      src/server/buffer.ts
  28. 94
      src/server/command.ts
  29. 3
      src/server/emitter.ts
  30. 80
      src/server/index.ts
  31. 18
      src/server/interfaces.ts
  32. 32
      src/server/logger.ts
  33. 84
      src/server/server.ts
  34. 11
      src/server/ssl.ts
  35. 65
      src/server/term.ts
  36. 131
      src/server/wetty.ts
  37. 84
      src/wetty.js
  38. 22
      tsconfig.json
  39. 134
      webpack.config.babel.js
  40. 150
      wetty.mjs
  41. 5771
      yarn.lock

13
.babelrc

@ -1,11 +1,10 @@
{ {
"presets": [ "presets": [
[ "@babel/preset-typescript",
"@babel/preset-env", ["@babel/env"]
{
"modules": false
}
]
], ],
"compact": true "compact": true,
"plugins": [
"lodash"
]
} }

8
.dockerignore

@ -1,2 +1,10 @@
node_modules node_modules
.esm-cache .esm-cache
dist
*.yml
*.md
*.log
*.png
**/*.conf
**/*.service
Dockerfile

3
.eslintignore

@ -1,4 +1,5 @@
node_modules/ node_modules/
public/
.esm-cache .esm-cache
dist
public/
*hterm* *hterm*

36
.eslintrc.js

@ -1,36 +1,32 @@
module.exports = { module.exports = {
parser: 'eslint-plugin-typescript/parser',
plugins: ['typescript', 'prettier'],
env: { env: {
es6: true, es6: true,
node: true, node: true,
browser: true,
}, },
extends: ['airbnb'], root: true,
extends: [
'airbnb-base',
'plugin:typescript/recommended',
'plugin:prettier/recommended',
],
rules: { rules: {
'typescript/indent': 'off',
'linebreak-style': ['error', 'unix'], 'linebreak-style': ['error', 'unix'],
'arrow-parens': ['error', 'as-needed'], 'arrow-parens': ['error', 'as-needed'],
'no-param-reassign': ['error', { props: false }], 'no-param-reassign': ['error', { props: false }],
'func-style': ['error', 'declaration', { allowArrowFunctions: true }], 'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
'no-use-before-define': ['error', { functions: false }], 'no-use-before-define': ['error', { functions: false }],
'no-shadow' : [ 'typescript/no-use-before-define': ['error', { functions: false }],
'error',
{
builtinGlobals: true,
hoist : 'functions',
allow : ['resolve', 'reject', 'err'],
}, },
], settings: {
'no-console': [ 'import/resolver': {
'error', 'typescript-eslint-parser': ['.ts', '.tsx'],
{ node: {
allow: ['warn', 'trace', 'log', 'error'], extensions: ['.ts', '.js'],
}, },
],
'consistent-return': 0,
'key-spacing' : [
'error',
{
multiLine: { beforeColon: false, afterColon: true },
align : { beforeColon: false, afterColon: true, on: 'colon', mode: 'strict' },
}, },
],
}, },
}; };

5
.gitignore

@ -13,6 +13,7 @@ logs
results results
npm-debug.log npm-debug.log
.idea node_modules
node_modules/*
.esm-cache .esm-cache
dist
.idea

7
.npmignore

@ -19,3 +19,10 @@ src
*.yml *.yml
Dockerfile Dockerfile
*.png *.png
.babelrc
.dockerignore
.eslint*
.prettierrc.js
tsconfig.json
webpack.config.babel.js
docs

13
.prettierrc.js

@ -0,0 +1,13 @@
module.exports = {
singleQuote: true,
trailingComma: 'es5',
proseWrap: 'always',
overrides: [
{
files: ['*.js', '*.ts'],
options: {
printWidth: 80,
},
},
],
};

31
Dockerfile

@ -1,17 +1,22 @@
FROM node:8-alpine as builder FROM node:boron-alpine as builder
RUN apk add -U build-base python
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk add --update build-base python
COPY . /usr/src/app COPY . /usr/src/app
RUN yarn RUN yarn && \
FROM node:8-alpine yarn build && \
MAINTAINER butlerx@notthe.cloud yarn install --production --ignore-scripts --prefer-offline
WORKDIR /app
RUN adduser -D -h /home/term -s /bin/sh term && \ FROM node:boron-alpine
( echo "term:term" | chpasswd ) && \ LABEL maintainer="butlerx@notthe.cloud"
apk add openssh-client && \ WORKDIR /usr/src/app
apk add sshpass ENV NODE_ENV=production
USER term RUN apk add -U openssh-client sshpass
EXPOSE 3000 EXPOSE 3000
COPY --from=builder /usr/src/app /app COPY --from=builder /usr/src/app/dist /usr/src/app/dist
COPY --from=builder /usr/src/app/node_modules /usr/src/app/node_modules
COPY package.json /usr/src/app
COPY index.js /usr/src/app
RUN mkdir ~/.ssh RUN mkdir ~/.ssh
CMD ssh-keyscan -H wetty-ssh >> ~/.ssh/known_hosts && node bin RUN ssh-keyscan -H wetty-ssh >> ~/.ssh/known_hosts
ENTRYPOINT [ "node", "." ]

199
README.md

@ -1,33 +1,51 @@
## Wetty = Web + tty ## WeTTy = Web + TTy
Terminal over HTTP and HTTPS. Wetty is an alternative to ajaxterm/anyterm but [ ![Codeship Status for butlerx/wetty](https://app.codeship.com/projects/caf50220-f884-0135-63bd-5231a73eac2d/status?branch=master)](https://app.codeship.com/projects/278281)
much better than them because wetty uses ChromeOS' terminal emulator (hterm)
which is a full fledged implementation of terminal emulation written entirely in
Javascript. Also it uses websockets instead of Ajax and hence better response
time.
[hterm source](https://chromium.googlesource.com/apps/libapps/+/master/hterm/) Terminal over HTTP and https. WeTTy is an alternative to ajaxterm and anyterm
but much better than them because WeTTy uses xterm.js which is a full fledged
implementation of terminal emulation written entirely in JavaScript. WeTTy uses
websockets rather then Ajax and hence better response time.
![Wetty](/terminal.png?raw=true) ![WeTTy](/terminal.png?raw=true)
## Install ## Install
WeTTy can be installed from source or from npm.
* `git clone https://github.com/krishnasrinivas/wetty.git` To install from source run:
* `cd wetty && npm install` ```bash
$ git clone https://github.com/krishnasrinivas/wetty.git
$ cd wetty
$ yarn
$ yarn build
```
To install it globally from npm use yarn or npm:
* `apt-get install sshpass` (debian eg. Ubuntu) only for auto-login feature - yarn, `yarn -g add wetty.js`
- npm, `npm i -g wetty.js`
* `yum install sshpass` (red hat flavours eg. CentOs) only for auto-login feature For auto-login feature you'll need sshpass installed(NOT required for rest of
the program".
- `apt-get install sshpass` (debian eg. Ubuntu)
- `yum install sshpass` (red hat flavours eg. CentOs)
## Run on HTTP ## Running WeTTy
Wettu can either be run as a standalone service or from another node script. To
see how to use WeTTy from node see the [API Doc](./docs)
```bash ```bash
npm start $ node index.js
``` ```
Open your browser on `http://yourserver:3000/wetty` and you will prompted to
login. Or go to `http://yourserver:3000/wetty/ssh/<username>` to specify the
user before hand.
If you run it as root it will launch `/bin/login` (where you can specify the If you run it as root it will launch `/bin/login` (where you can specify the
user name), else it will launch `ssh` and connect by default to `localhost`. user name), else it will launch `ssh` and connect by default to `localhost`.
@ -35,50 +53,92 @@ If instead you wish to connect to a remote host you can specify the `--sshhost`
option, the SSH port using the `--sshport` option and the SSH user using the option, the SSH port using the `--sshport` option and the SSH user using the
`--sshuser` option. `--sshuser` option.
You can also specify the SSH user name in the address bar like this: ### Flags
`http://yourserver:3000/wetty/ssh/<username>` WeTTy can be run with the `--help` flag to get a full list of flags.
or #### Server Port
`http://yourserver:3000/ssh/<username>` WeTTy runs on port `3000` by default. You can change the default port by
starting with the `--port` or `-p` flag.
You can pass an optional password as query parameter to use auto-login feature. #### SSH Host
Auto Login: If WeTTy is run as root while the host is set as the local machine it will use
------------ the `login` binary rather than ssh. If no host is specified it will use
`localhost` as the ssh host.
You can also pass the ssh password as an optional query parameter to auto-login the user like this (Only while running wetty as a non root account): If instead you wish to connect to a remote host you can specify the host with
the `--sshhost` flag and pass the IP or DNS address of the host you want to
connect to.
`http://yourserver:3000/wetty/ssh/<username>?sshpass=<password>` #### Default User
You can specify the default user used to ssh to a host using the `--sshuser`.
This user can overwritten by going to `http://yourserver:3000/ssh/<username>`.
If this is left blank a user will be prompted to enter their username when they
connect.
#### SSH Port
By default WeTTy will try to ssh to port `22`, if your host uses an alternative
ssh port this can be specified with the flag `--sshport`.
#### WeTTy URL
If you'd prefer an HTTP base prefix other than `/wetty`, you can specify that
with `--base`.
or **Do not set this to `/ssh/${something}`, as this will break username matching
code.**
`http://yourserver:3000/ssh/<username>?sshpass=<password>` #### HTTPS
This is just an additional feature and the security implications for passing the password in the url will have to be taken care separately. Always use HTTPS especially with a terminal to your server. You can add HTTPS by
either using WeTTy behind a proxy or directly.
Run on HTTPS: To run WeTTy directly with ssl use both the `--sslkey` and `--sslcert` flags and
------------ pass them the path too your cert and key as follows:
Always use HTTPS. If you don't have SSL certificates from a CA you can create a ```bash
self signed certificate using this command: node index.js --sslkey key.pem --sslcert cert.pem
```
If you don't have SSL certificates from a CA you can create a self signed
certificate using this command:
``` ```
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30000 -nodes openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30000 -nodes
``` ```
And then run: ### Auto Login:
wetty --sslkey key.pem --sslcert cert.pem -p 3000 You can also pass the ssh password as an optional query parameter to auto-login
the user like this (Only while running wetty as a non root account):
Again, if you run it as root it will launch `/bin/login`, else it will launch `http://yourserver:3000/wetty/ssh/<username>?sshpass=<password>`
SSH to `localhost` or a specified host as explained above.
This is not a required feature and the security implications for passing the
password in the url will have to be considered by the user
## Run wetty behind nginx or apache ## Run wetty behind nginx or apache
As said earlier you can use a proxy to add https to WeTTy.
**Note** that if your proxy is configured for https you should run WeTTy without
SSL
If your proxy uses a base path other than `/wetty`, specify the path with the
`--base` flag, or the `BASE` environment variable.
#### Nginx
For a more detailed look see the [nginx.conf](./bin/nginx.template) used for
testing
Put the following configuration in nginx's conf: Put the following configuration in nginx's conf:
```nginx
location /wetty { location /wetty {
proxy_pass http://127.0.0.1:3000/wetty; proxy_pass http://127.0.0.1:3000/wetty;
proxy_http_version 1.1; proxy_http_version 1.1;
@ -91,53 +151,33 @@ Put the following configuration in nginx's conf:
proxy_set_header Host $http_host; proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true; proxy_set_header X-NginX-Proxy true;
} }
```
#### Apache
Put the following configuration in apache's conf: Put the following configuration in apache's conf:
```apache
RewriteCond %{REQUEST_URI} ^/wetty/socket.io [NC] RewriteCond %{REQUEST_URI} ^/wetty/socket.io [NC]
RewriteCond %{QUERY_STRING} transport=websocket [NC] RewriteCond %{QUERY_STRING} transport=websocket [NC]
RewriteRule /wetty/socket.io/(.*) ws://localhost:9123/wetty/socket.io/$1 [P,L] RewriteRule /wetty/socket.io/(.*) ws://localhost:3000/wetty/socket.io/$1 [P,L]
<LocationMatch ^/wetty/(.*)> <LocationMatch ^/wetty/(.*)>
DirectorySlash On DirectorySlash On
Require all granted Require all granted
ProxyPassMatch http://127.0.0.1:9123 ProxyPassMatch http://127.0.0.1:3000
ProxyPassReverse /wetty/ ProxyPassReverse /wetty/
</LocationMatch> </LocationMatch>
If you are running `bin/index.js` as `root` and have an Nginx proxy you have to
use:
```
http://yourserver.com/wetty
``` ```
**Note that if your Nginx is configured for HTTPS you should run wetty without SSL.** ### Dockerized Version
Else if you are running `bin/index.js` as a regular user you can use: WeTTy can be run from a container to ssh to a remote host or the host system.
This is handy for quick deployments. Just modify `docker-compose.yml` for your
host and run:
``` ```sh
http://yourserver.com/wetty/ssh/<username> $ docker-compose up -d
```
or
```
http://yourserver.com/wetty
```
**Note that if your Nginx is configured for HTTPS you should run wetty without
SSL.**
## Dockerized Version
This repo includes a Dockerfile you can use to run a Dockerized version of
wetty. You can run whatever you want!
Just modify docker-compose and run:
```
docker-compose up -d
``` ```
Visit the appropriate URL in your browser Visit the appropriate URL in your browser
@ -146,34 +186,35 @@ Visit the appropriate URL in your browser
The default username is `term` and the password is `term`, if you did not modify The default username is `term` and the password is `term`, if you did not modify
`SSHHOST` `SSHHOST`
If you dont want to build the image yourself just remove the line `build; .` In the docker version all flags can be accessed as environment variables such as
`SSHHOST` or `SSHPORT`.
## Run wetty as a service daemon If you dont want to build the image yourself just remove the line `build; .`
Install wetty globally with global option: ## Run WeTTy as a service daemon
### init.d ### init.d
```bash ```bash
$ sudo yarn global add wetty $ sudo yarn global add wetty.js
$ sudo cp /usr/local/lib/node_modules/wetty.js/bin/wetty.conf /etc/init $ sudo cp ~/.config/yarn/global/node_modules/wetty.js/bin/wetty.conf /etc/init
$ sudo start wetty $ sudo start wetty
``` ```
### systemd ### systemd
```bash ```bash
$ yarn global add wetty $ yarn global add wetty.js
$ cp ~/.config/yarn/global/node_modules/wetty.js/bin/wetty.service ~/.config/systemd/user/ $ cp ~/.config/yarn/global/node_modules/wetty.js/bin/wetty.service ~/.config/systemd/user/
$ systemctl --user enable wetty $ systemctl --user enable wetty
$ systemctl --user start wetty $ systemctl --user start wetty
``` ```
This will start wetty on port 3000. If you want to change the port or redirect This will start WeTTy on port 3000. If you want to change the port or redirect
stdout/stderr you should change the last line in `wetty.conf` file, something stdout/stderr you should change the last line in `wetty.conf` file, something
like this: like this:
``` ```systemd
exec sudo -u root wetty -p 80 >> /var/log/wetty.log 2>&1 exec sudo -u root wetty -p 80 >> /var/log/wetty.log 2>&1
``` ```
@ -181,11 +222,5 @@ exec sudo -u root wetty -p 80 >> /var/log/wetty.log 2>&1
### What browsers are supported? ### What browsers are supported?
Wetty supports all browsers that Google's hterm supports. Wetty has been WeTTy supports all browsers that
[reported](https://github.com/krishnasrinivas/wetty/issues/45#issuecomment-181448586) [xterm.js supports](https://github.com/xtermjs/xterm.js#browser-support).
to work on Google Chrome, Firefox and IE 11.
### Why isn't Wetty working with IE?
[This fix](https://stackoverflow.com/questions/13102116/access-denied-for-localstorage-in-ie10#20848924)
has been known to help some users.

3
bin/index.js

@ -1,3 +0,0 @@
#! /usr/bin/env node
require = require('esm')(module, { cjs: true }); // eslint-disable-line no-global-assign
require('../cli.mjs');

69
bin/nginx.template

@ -0,0 +1,69 @@
server {
listen ${NGINX_PORT};
listen [::]:${NGINX_PORT};
server_name ${NGINX_DOMAIN};
root /var/www/${NGINX_DOMAIN}/public;
# $uri, index.html
location / {
try_files $uri $uri/ /index.html;
}
# headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-UA-Compatible "IE=Edge" always;
add_header Cache-Control "no-transform" always;
# . files
location ~ /\. {
deny all;
}
# assets, media
location ~* \.(?:css(\.map)?|js(\.map)?|jpe?g|png|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv)$ {
expires 7d;
access_log off;
}
# svg, fonts
location ~* \.(?:svgz?|ttf|ttc|otf|eot|woff|woff2)$ {
add_header Access-Control-Allow-Origin "*";
expires 7d;
access_log off;
}
location ^~ /wetty {
proxy_pass http://${WETTY_HOST}:${WETTY_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 43200000;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_set_header X-NginX-Proxy true;
}
# gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
}
# subdomains redirect
server {
listen ${NGINX_PORT};
listen [::]:${NGINX_PORT};
server_name *.${NGINX_DOMAIN};
return 301 https://${NGINX_DOMAIN}$request_uri;
}
# set ft=conf

15
bin/ssh

@ -1,15 +0,0 @@
#!/usr/bin/env sh
userAtAddress="$1"; shift
USER=$(echo "$userAtAddress" | cut -d"@" -f1);
HOST=$(echo "$userAtAddress" | cut -d"@" -f2);
if [ "$USER" = "$HOST" ]
then
printf "Enter your username: "
read -r USER
USER=$(echo "${USER}" | tr -d '[:space:]')
ssh "$USER"@"$HOST" ${@}
else
ssh "$userAtAddress" ${@}
fi

124
cli.mjs

@ -1,124 +0,0 @@
import fs from 'fs-extra';
import path from 'path';
import optimist from 'optimist';
import wetty from './wetty';
const opts = optimist
.options({
sslkey: {
demand: false,
description: 'path to SSL key',
},
sslcert: {
demand: false,
description: 'path to SSL certificate',
},
sshhost: {
demand: false,
description: 'ssh server host',
},
sshport: {
demand: false,
description: 'ssh server port',
},
sshuser: {
demand: false,
description: 'ssh user',
},
sshpass: {
demand: false,
description: 'ssh password',
},
sshauth: {
demand: false,
description: 'defaults to "password", you can use "publickey,password" instead',
},
sshkey: {
demand: false,
description:
'path to an optional client private key (connection will be password-less and insecure!)',
},
port: {
demand: false,
alias: 'p',
description: 'wetty listen port',
},
command: {
demand: false,
alias: 'c',
description: 'command to run in shell, defaults to /bin/login',
},
help: {
demand: false,
alias: 'h',
description: 'Print help message',
},
})
.boolean('allow_discovery').argv;
if (opts.help) {
optimist.showHelp();
process.exit(0);
}
const sshkey = opts.sshkey || process.env.SSHKEY || '';
loadSSL(opts)
.then(ssl => {
opts.ssl = ssl;
})
.catch(err => {
console.error(`Error: ${err}`);
process.exit(1);
});
const sshkeyWarning = `!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
! Password-less auth enabled using private key from ${sshkey}.
! This is dangerous, anything that reaches the wetty server
! will be able to run remote operations without authentication.
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`;
if (sshkey) {
console.warn(sshkeyWarning);
}
process.on('uncaughtException', err => {
console.error(`Error: ${err}`);
});
const tty = wetty(
opts.port || process.env.PORT || 3000,
opts.sshuser || process.env.SSHUSER || '',
opts.sshpass || process.env.SSHPASS || '',
opts.sshhost || process.env.SSHHOST || 'localhost',
opts.sshport || process.env.SSHPOST || 22,
opts.sshauth || process.env.SSHAUTH || 'password,keyboard-interactive',
sshkey,
opts.ssl,
opts.command || process.env.COMMAND || '',
);
tty.on('exit', code => {
console.log(`exit with code: ${code}`);
});
tty.on('disconnect', () => {
console.log('disconnect');
});
function loadSSL({ sslkey, sslcert }) {
return new Promise((resolve, reject) => {
const ssl = {};
if (sslkey && sslcert) {
fs
.readFile(path.resolve(sslkey))
.then(key => {
ssl.key = key;
})
.then(fs.readFile(path.resolve(sslcert)))
.then(cert => {
ssl.cert = cert;
})
.then(resolve(ssl))
.catch(reject);
}
resolve(ssl);
});
}

21
docker-compose.yml

@ -1,5 +1,5 @@
---
version: "3.5" version: "3.5"
services: services:
wetty: wetty:
build: . build: .
@ -10,9 +10,24 @@ services:
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
PORT: 3000
SSHHOST: 'wetty-ssh' SSHHOST: 'wetty-ssh'
SSHPORT: 22 SSHPORT: 22
NODE_ENV: 'development'
command: yarn start --sshhost redbrick.dcu.ie
web:
image: nginx
volumes:
- ./bin/nginx.template:/etc/nginx/conf.d/wetty.template
ports:
- "80:80"
environment:
- NGINX_DOMAIN=wetty.com
- NGINX_PORT=80
- WETTY_HOST=wetty
- WETTY_PORT=3000
command: /bin/bash -c "envsubst '$${NGINX_DOMAIN},$${NGINX_PORT},$${WETTY_HOST},$${WETTY_PORT}' < /etc/nginx/conf.d/wetty.template > /etc/nginx/conf.d/default.conf && nginx -g 'daemon off;'"
wetty-ssh: wetty-ssh:
build: build:
context: . context: .
@ -22,5 +37,3 @@ services:
networks: networks:
default: default:
name: wetty name: wetty

93
docs/API.md

@ -0,0 +1,93 @@
<a name="module_WeTTy"></a>
## WeTTy
Create WeTTY server
* [WeTTy](#module_WeTTy)
* [~start](#module_WeTTy..start) ⇒ <code>Promise</code>
* ["connection"](#event_connection)
* ["spawn"](#event_spawn)
* ["exit"](#event_exit)
* ["disconnect"](#event_disconnect)
* ["server"](#event_server)
<a name="module_WeTTy..start"></a>
### WeTTy~start ⇒ <code>Promise</code>
Starts WeTTy Server
**Kind**: inner property of [<code>WeTTy</code>](#module_WeTTy)
**Returns**: <code>Promise</code> - Promise resolves once server is running
| Param | Type | Default | Description |
| ------------ | ------------------- | ------------------------------------- | --------------------------- |
| [ssh] | <code>Object</code> | | SSH settings |
| [ssh.user] | <code>string</code> | <code>&quot;&#x27;&#x27;&quot;</code> | default user for ssh |
| [ssh.host] | <code>string</code> | <code>&quot;localhost&quot;</code> | machine to ssh too |
| [ssh.auth] | <code>string</code> | <code>&quot;password&quot;</code> | authtype to use |
| [ssh.port] | <code>number</code> | <code>22</code> | port to connect to over ssh |
| [serverPort] | <code>number</code> | <code>3000</code> | Port to run server on |
| [ssl] | <code>Object</code> | | SSL settings |
| [ssl.key] | <code>string</code> | | Path to ssl key |
| [ssl.cert] | <code>string</code> | | Path to ssl cert |
<a name="event_connection"></a>
### "connection"
**Kind**: event emitted by [<code>WeTTy</code>](#module_WeTTy)
**Properties**
| Name | Type | Description |
| ---- | ------------------- | --------------------------- |
| msg | <code>string</code> | Message for logs |
| date | <code>Date</code> | date and time of connection |
<a name="event_spawn"></a>
### "spawn"
Terminal process spawned
**Kind**: event emitted by [<code>WeTTy</code>](#module_WeTTy)
**Properties**
| Name | Type | Description |
| ------- | ------------------- | -------------------------------------- |
| msg | <code>string</code> | Message containing pid info and status |
| pid | <code>number</code> | Pid of the terminal |
| address | <code>string</code> | address of connecting user |
<a name="event_exit"></a>
### "exit"
Terminal process exits
**Kind**: event emitted by [<code>WeTTy</code>](#module_WeTTy)
**Properties**
| Name | Type | Description |
| ---- | ------------------- | -------------------------------------- |
| code | <code>number</code> | the exit code |
| msg | <code>string</code> | Message containing pid info and status |
<a name="event_disconnect"></a>
### "disconnect"
**Kind**: event emitted by [<code>WeTTy</code>](#module_WeTTy)
<a name="event_server"></a>
### "server"
**Kind**: event emitted by [<code>WeTTy</code>](#module_WeTTy)
**Properties**
| Name | Type | Description |
| ---------- | ------------------- | ------------------------------- |
| msg | <code>string</code> | Message for logging |
| port | <code>number</code> | port sever is on |
| connection | <code>string</code> | connection type for web traffic |

24
docs/README.md

@ -0,0 +1,24 @@
# Docs
## Getting started
WeTTy is event driven. To Spawn a new server call `wetty.start()` with no
arguments.
```javascript
const wetty = require('wetty.js');
wetty
.on('exit', ({ code, msg }) => {
console.log(`Exit with code: ${code} ${msg}`);
})
.on('spawn', msg => console.log(msg));
wetty.start(/* server settings, see Options */).then(() => {
console.log('server running');
/* code you want to execute */
});
```
## API
For WeTTy options and event details please refer to the [api docs](./api.md)

41
gulpfile.js

@ -1,41 +0,0 @@
const gulp = require('gulp');
const concat = require('gulp-concat');
const minify = require('gulp-minify');
const babel = require('gulp-babel');
const shell = require('gulp-shell');
const del = require('del');
const compress = () =>
gulp
.src(['./src/hterm_all.js', './src/wetty.js'])
.pipe(concat('wetty.js'))
.pipe(babel())
.pipe(
minify({
ext: {
min: '.min.js',
},
exclude : ['tasks'],
noSource : true,
ignoreFiles: ['.combo.js', '*.min.js'],
}),
)
.pipe(gulp.dest('./public/wetty'));
gulp.task('default', gulp.series(compress));
gulp.task(
'upgrade',
gulp.series(
shell.task(
[
'git clone https://chromium.googlesource.com/apps/libapps',
'LIBDOT_SEARCH_PATH=$(pwd)/libapps ./libapps/libdot/bin/concat.sh -i ./libapps/hterm/concat/hterm_all.concat -o ./src/hterm_all.js',
],
{
verbose: true,
},
),
() => del(['./libapps']),
),
);

95
index.js

@ -1,2 +1,93 @@
require = require('esm')(module, { cjs: true }); // eslint-disable-line no-global-assign /* eslint-disable typescript/no-var-requires */
module.exports = require('./wetty.mjs').default;
const yargs = require('yargs');
const wetty = require('./dist').default;
module.exports = wetty.wetty;
/**
* Check if being run by cli or require
*/
if (require.main === module) {
wetty.init(
yargs
.options({
sslkey: {
demand: false,
type: 'string',
description: 'path to SSL key',
},
sslcert: {
demand: false,
type: 'string',
description: 'path to SSL certificate',
},
sshhost: {
demand: false,
description: 'ssh server host',
type: 'string',
default: process.env.SSHHOST || 'localhost',
},
sshport: {
demand: false,
description: 'ssh server port',
type: 'number',
default: parseInt(process.env.SSHPORT, 10) || 22,
},
sshuser: {
demand: false,
description: 'ssh user',
type: 'string',
default: process.env.SSHUSER || '',
},
sshauth: {
demand: false,
description:
'defaults to "password", you can use "publickey,password" instead',
type: 'string',
default: process.env.SSHAUTH || 'password',
},
sshpass: {
demand: false,
description: 'ssh password',
type: 'string',
default: process.env.SSHPASS || undefined,
},
sshkey: {
demand: false,
description:
'path to an optional client private key (connection will be password-less and insecure!)',
type: 'string',
default: process.env.SSHKEY || undefined,
},
base: {
demand: false,
alias: 'b',
description: 'base path to wetty',
type: 'string',
default: process.env.BASE || '/wetty/',
},
port: {
demand: false,
alias: 'p',
description: 'wetty listen port',
type: 'number',
default: parseInt(process.env.PORT, 10) || 3000,
},
command: {
demand: false,
alias: 'c',
description: 'command to run in shell',
type: 'string',
default: process.env.COMMAND || 'login',
},
help: {
demand: false,
alias: 'h',
type: 'boolean',
description: 'Print help message',
},
})
.boolean('allow_discovery').argv
);
}

155
package.json

@ -1,7 +1,7 @@
{ {
"name": "wetty.js", "name": "wetty.js",
"version": "0.5.1", "version": "1.1.1",
"description": "Wetty = Web + tty. Terminal access in browser over http/https ", "description": "WeTTY = Web + TTY. Terminal access in browser over http/https",
"homepage": "https://github.com/krishnasrinivas/wetty", "homepage": "https://github.com/krishnasrinivas/wetty",
"repository": { "repository": {
"type": "git", "type": "git",
@ -10,80 +10,121 @@
"author": "Krishna Srinivas <krishna.srinivas@gmail.com> (https://github.com/krishnasrinivas)", "author": "Krishna Srinivas <krishna.srinivas@gmail.com> (https://github.com/krishnasrinivas)",
"license": "MIT", "license": "MIT",
"bugs": { "bugs": {
"url": "https://github.com/butlerx/wetty/issues" "url": "https://github.com/krishnasrinivas/wetty/issues"
}, },
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"lint": "eslint .", "lint": "eslint --ext .js,.ts .",
"lint:fix": "eslint . --fix", "build": "babel-node node_modules/.bin/webpack",
"build": "gulp", "start": "node .",
"start": "node bin", "dev": "NODE_ENV=development concurrently --kill-others --success first \"babel-node node_modules/.bin/webpack --watch\" \"nodemon .\"",
"commit": "git add public", "prepublishOnly": "NODE_ENV=production yarn build"
"fix": "eslint . --fix"
}, },
"bin": { "husky": {
"wetty": "./bin/index.js" "hooks": {
"pre-commit": "lint-staged"
}
}, },
"pre-commit": [ "lint-staged": {
"fix", "*.{js,ts}": [
"commit" "eslint --fix",
"git add"
], ],
"*.{json,scss,md}": [
"prettier --write",
"git add"
]
},
"bin": {
"wetty": "./index.js"
},
"engines": {
"node": ">=6.9"
},
"nodemonConfig": {
"ignore": [
"src/*",
"*.json"
]
},
"preferGlobal": "true", "preferGlobal": "true",
"dependencies": { "dependencies": {
"esm": "^3.0.84", "compression": "^1.7.1",
"express": "^4.16.0", "express": "^4.16.4",
"fs-extra": "^4.0.1", "fs-extra": "^4.0.1",
"helmet": "^3.9.0",
"lodash": "^4.17.4",
"morgan": "^1.9.1",
"node-pty": "^0.7.4", "node-pty": "^0.7.4",
"optimist": "^0.6", "serve-favicon": "^2.5.0",
"pre-commit": "^1.2.2", "socket.io": "^2.2.0",
"serve-favicon": "^2.4.3", "socket.io-client": "^2.2.0",
"socket.io": "^1.3.7" "source-map-loader": "^0.2.4",
"winston": "^3.1.0",
"xterm": "^3.10.0",
"yargs": "^12.0.5"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.0.0", "@babel/core": "^7.2.2",
"@babel/core": "^7.0.0", "@babel/node": "^7.2.2",
"@babel/plugin-transform-async-to-generator": "^7.0.0", "@babel/preset-env": "^7.2.3",
"@babel/plugin-transform-object-assign": "^7.0.0", "@babel/preset-typescript": "^7.1.0",
"@babel/preset-env": "^7.0.0", "@babel/register": "^7.0.0",
"babel-eslint": "^9.0.0", "@types/compression": "^0.0.36",
"babel-plugin-add-module-exports": "0.2.1", "@types/express": "^4.16.0",
"babel-plugin-es6-promise": "1.1.1", "@types/fs-extra": "^5.0.4",
"babel-plugin-syntax-async-functions": "6.13.0", "@types/helmet": "^0.0.42",
"del": "^3.0.0", "@types/lodash": "^4.14.119",
"es6-promise": "^4.1.1", "@types/morgan": "^1.7.35",
"eslint": "3.19.0", "@types/node": "^10.12.18",
"eslint-config-airbnb": "^15.1.0", "@types/serve-favicon": "^2.2.30",
"eslint-config-standard": "10.2.1", "@types/socket.io": "^2.1.2",
"eslint-plugin-import": "^2.7.0", "@types/socket.io-client": "^1.4.32",
"eslint-plugin-jsx-a11y": "^5.1.1", "@types/webpack-env": "^1.13.6",
"eslint-plugin-node": "^5.1.1", "@types/yargs": "^12.0.5",
"eslint-plugin-promise": "^3.5.0", "babel-loader": "^8.0.5",
"eslint-plugin-react": "^7.1.0", "babel-plugin-lodash": "^3.3.4",
"eslint-plugin-standard": "^3.0.1", "concurrently": "^3.5.1",
"gulp": "^4.0.0", "css-loader": "^2.1.0",
"gulp-babel": "^8.0.0", "eslint": "^5.12.0",
"gulp-concat": "^2.6.1", "eslint-config-airbnb-base": "^13.1.0",
"gulp-minify": "^3.1.0", "eslint-config-prettier": "^3.3.0",
"gulp-shell": "^0.6.5" "eslint-plugin-import": "^2.14.0",
"eslint-plugin-prettier": "^3.0.1",
"eslint-plugin-typescript": "^1.0.0-rc.1",
"file-loader": "^3.0.1",
"husky": "^1.3.1",
"lint-staged": "^6.1.1",
"mini-css-extract-plugin": "^0.5.0",
"node-sass": "^4.11.0",
"nodemon": "^1.14.10",
"prettier": "^1.15.3",
"sass-loader": "^7.1.0",
"style-loader": "^0.23.1",
"typescript": "~3.1.1",
"webpack": "^4.28.3",
"webpack-cli": "^3.2.0",
"webpack-node-externals": "^1.7.2"
}, },
"contributors": [ "contributors": [
"Krishna Srinivas <krishna.srinivas@gmail.com>",
"butlerx <butlerx@notthe.cloud>",
"Boyan Rabchev <boyan@rabchev.com>",
"Boyan Rabchev <TELERIK\\rabchev@rabchevlnx.telerik.com>",
"Luca Milanesio <luca.milanesio@gmail.com>",
"Antonio Calatrava <antonio@antoniocalatrava.com>",
"Krishna Srinivas <krishna@minio.io>",
"Strubbl <github@linux4tw.de>",
"Jarrett Gilliam <jarrettgilliam@gmail.com>",
"Nathan LeClaire <nathan.leclaire@docker.com>",
"nosemeocurrenada <nosemeocurrenada93@gmail.com>",
"Andreas Kloeckner <inform@tiker.net>", "Andreas Kloeckner <inform@tiker.net>",
"Antonio Calatrava <antonio@antoniocalatrava.com>",
"Boyan Rabchev <TELERIK\\rabchev@rabchevlnx.telerik.com>",
"Boyan Rabchev <boyan@rabchev.com>",
"Cian Butler <butlerx@notthe.cloud>",
"Farhan Khan <khanzf@gmail.com>", "Farhan Khan <khanzf@gmail.com>",
"Imuli <i@imu.li>", "Imuli <i@imu.li>",
"James Turnbull <james@lovedthanlost.net>", "James Turnbull <james@lovedthanlost.net>",
"Jarrett Gilliam <jarrettgilliam@gmail.com>",
"Kasper Holbek Jensen <kholbekj@gmail.com>", "Kasper Holbek Jensen <kholbekj@gmail.com>",
"Krishna Srinivas <krishna@minio.io>",
"Luca Milanesio <luca.milanesio@gmail.com>",
"Nathan LeClaire <nathan.leclaire@docker.com>",
"Neale Pickett <neale@woozle.org>",
"Robert <robert@n5qm.com>",
"Strubbl <github@linux4tw.de>",
"koushikmln <mln02koushik@gmail.com>",
"mirtouf <mirtouf@gmail.com>", "mirtouf <mirtouf@gmail.com>",
"koushikmln <mln02koushik@gmail.com>" "nosemeocurrenada <nosemeocurrenada93@gmail.com>"
] ]
} }

41
public/wetty/index.html

@ -1,41 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Wetty - The WebTTY Terminal Emulator</title>
<style>
html, body {
height: 100%;
width: 100%;
margin: 0px;
}
#overlay {
position: absolute;
height: 100%;
width: 100%;
background-color: rgba(0,0,0,0.75);;
display: none;
z-index: 100;
}
#overlay input {
display: block;
margin: auto;
position: relative;
top: 50%;
transform: translateY(-50%);
}
#terminal {
display: block;
position: relative;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<div id="overlay"><input type="button" onclick="location.reload();" value="reconnect" /></div>
<div id="terminal"></div>
<script src="/wetty/socket.io/socket.io.js"></script>
<script src="/wetty/wetty.min.js"></script>
</body>
</html>

1
public/wetty/wetty.min.js

File diff suppressed because one or more lines are too long

37
src/.eslintrc.js

@ -1,37 +0,0 @@
module.exports = {
env: {
es6 : true,
browser: true,
},
globals: {
hterm: true,
lib : true,
io : true,
},
extends: ['airbnb'],
rules : {
'no-underscore-dangle': 0,
'class-methods-use-this': 0,
'linebreak-style' : ['error', 'unix'],
'arrow-parens' : ['error', 'as-needed'],
'no-param-reassign' : ['error', { props: false }],
'func-style' : ['error', 'declaration', { allowArrowFunctions: true }],
'no-use-before-define': ['error', { functions: false }],
'no-shadow' : [
'error',
{
builtinGlobals: true,
hoist : 'functions',
allow : ['resolve', 'reject', 'err'],
},
],
'consistent-return': 0,
'key-spacing' : [
'error',
{
multiLine: { beforeColon: false, afterColon: true },
align : { beforeColon: false, afterColon: true, on: 'colon', mode: 'strict' },
},
],
},
};

0
public/favicon.ico → src/client/favicon.ico

Before

Width:  |  Height:  |  Size: 165 KiB

After

Width:  |  Height:  |  Size: 165 KiB

77
src/client/index.ts

@ -0,0 +1,77 @@
import { Terminal } from 'xterm';
import { isUndefined } from 'lodash';
import * as io from 'socket.io-client';
import { fit } from 'xterm/lib/addons/fit/fit';
import './wetty.scss';
import './favicon.ico';
const userRegex = new RegExp('ssh/[^/]+$');
const trim = (str: string): string => str.replace(/\/*$/, '');
const socketBase = trim(window.location.pathname).replace(userRegex, '');
const socket = io(window.location.origin, {
path: `${trim(socketBase)}/socket.io`,
});
socket.on('connect', () => {
const term = new Terminal();
term.open(document.getElementById('terminal'));
term.setOption('fontSize', 14);
document.getElementById('overlay').style.display = 'none';
window.addEventListener('beforeunload', handler, false);
/*
term.scrollPort_.screen_.setAttribute('contenteditable', 'false');
*/
term.attachCustomKeyEventHandler(e => {
// Ctrl + Shift + C
if (e.ctrlKey && e.shiftKey && e.keyCode === 67) {
e.preventDefault();
document.execCommand('copy');
return false;
}
return true;
});
function resize(): void {
fit(term);
socket.emit('resize', { cols: term.cols, rows: term.rows });
}
window.onresize = resize;
resize();
term.focus();
function kill(data: string): void {
disconnect(data);
}
term.on('data', data => {
socket.emit('input', data);
});
term.on('resize', size => {
socket.emit('resize', size);
});
socket
.on('data', (data: string) => {
term.write(data);
})
.on('login', () => {
term.writeln('');
resize();
})
.on('logout', kill)
.on('disconnect', kill)
.on('error', (err: string | null) => {
if (err) disconnect(err);
});
});
function disconnect(reason: string): void {
document.getElementById('overlay').style.display = 'block';
if (!isUndefined(reason)) document.getElementById('msg').innerHTML = reason;
window.removeEventListener('beforeunload', handler, false);
}
function handler(e: { returnValue: string }): string {
e.returnValue = 'Are you sure?';
return e.returnValue;
}

47
src/client/wetty.scss

@ -0,0 +1,47 @@
@import '~xterm/dist/xterm';
$black: #000;
$grey: rgba(0, 0, 0, 0.75);
$white: #fff;
html,
body {
background-color: $black;
height: 100%;
margin: 0;
overflow: hidden;
#overlay {
background-color: $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: $white;
}
input {
align-self: center;
margin: 16px;
}
}
}
#terminal {
display: flex;
height: 100%;
position: relative;
width: 100%;
}
}

18390
src/hterm_all.js

File diff suppressed because it is too large

16
src/server/buffer.ts

@ -0,0 +1,16 @@
import { createInterface } from 'readline';
ask('Enter your username');
export default function ask(question: string): Promise<string> {
const r = createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise(resolve => {
r.question(`${question}: `, answer => {
r.close();
resolve(answer);
});
});
}

94
src/server/command.ts

@ -0,0 +1,94 @@
import * as url from 'url';
import { Socket } from 'socket.io';
import { SSH } from './interfaces';
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);
const getRemoteAddress = (remoteAddress: string): string =>
remoteAddress.split(':')[3] === undefined
? 'localhost'
: remoteAddress.split(':')[3];
export default (
{
request: {
headers: { referer },
},
client: {
conn: { remoteAddress },
},
}: Socket,
{ user, host, port, auth, pass, key }: SSH,
command: string
): { args: string[]; user: boolean } => ({
args: localhost(host)
? loginOptions(command, remoteAddress)
: sshOptions(
urlArgs(referer, {
host: address(referer, user, host),
port: `${port}`,
pass,
command,
auth,
}),
key
),
user:
localhost(host) ||
user !== '' ||
user.includes('@') ||
address(referer, user, host).includes('@'),
});
function parseCommand(command: string, path?: string): string {
if (command === 'login' && path === undefined) return '';
return path !== undefined
? `$SHELL -c "cd ${path};${command === 'login' ? '$SHELL' : command}"`
: command;
}
function sshOptions(
{ pass, path, command, host, port, auth }: { [s: string]: string },
key?: string
): string[] {
const cmd = parseCommand(command, path);
const sshRemoteOptsBase = [
'ssh',
host,
'-t',
'-p',
port,
'-o',
`PreferredAuthentications=${auth}`,
];
if (key) {
return sshRemoteOptsBase.concat(['-i', key, cmd]);
}
if (pass) {
return ['sshpass', '-p', pass].concat(sshRemoteOptsBase, [cmd]);
}
if (cmd === '') {
return sshRemoteOptsBase;
}
return sshRemoteOptsBase.concat([cmd]);
}
function loginOptions(command: string, remoteAddress: string): string[] {
return command === 'login'
? [command, '-h', getRemoteAddress(remoteAddress)]
: [command];
}
function address(referer: string, user: string, host: string): string {
const match = referer.match('.+/ssh/([^/]+)$');
const fallback = user ? `${user}@${host}` : host;
return match ? `${match[1]}@${host}` : fallback;
}

3
src/server/emitter.ts

@ -0,0 +1,3 @@
import WeTTy from './wetty';
export default new WeTTy();

80
src/server/index.ts

@ -0,0 +1,80 @@
import * as yargs from 'yargs';
import logger from './logger';
import wetty from './emitter';
import WeTTy from './wetty';
export interface Options {
sshhost: string;
sshport: number;
sshuser: string;
sshauth: string;
sshkey?: string;
sshpass?: string;
sslkey?: string;
sslcert?: string;
base: string;
port: number;
command?: string;
}
interface CLI extends Options {
help: boolean;
}
export default class Server {
public static start({
sshuser,
sshhost,
sshauth,
sshport,
sshkey,
sshpass,
base,
port,
command,
sslkey,
sslcert,
}: Options): Promise<void> {
wetty
.on('exit', ({ code, msg }: { code: number; msg: string }) => {
logger.info(`Exit with code: ${code} ${msg}`);
})
.on('disconnect', () => {
logger.info('disconnect');
})
.on('spawn', ({ msg }) => logger.info(msg))
.on('connection', ({ msg, date }) => logger.info(`${date} ${msg}`))
.on('server', ({ msg }) => logger.info(msg))
.on('debug', (msg: string) => logger.debug(msg));
return wetty.start(
{
user: sshuser,
host: sshhost,
auth: sshauth,
port: sshport,
pass: sshpass,
key: sshkey,
},
base,
port,
command,
{ key: sslkey, cert: sslcert }
);
}
public static get wetty(): WeTTy {
return wetty;
}
public static init(opts: CLI): void {
if (!opts.help) {
this.start(opts).catch(err => {
logger.error(err);
process.exitCode = 1;
});
} else {
yargs.showHelp();
process.exitCode = 0;
}
}
}

18
src/server/interfaces.ts

@ -0,0 +1,18 @@
export interface SSH {
user: string;
host: string;
auth: string;
port: number;
pass?: string;
key?: string;
}
export interface SSL {
key?: string;
cert?: string;
}
export interface SSLBuffer {
key?: Buffer;
cert?: Buffer;
}

32
src/server/logger.ts

@ -0,0 +1,32 @@
import { createLogger, format, transports } from 'winston';
const { combine, timestamp, label, printf, colorize } = format;
const logger = createLogger({
format:
process.env.NODE_ENV === 'development'
? combine(
colorize({ all: true }),
label({ label: 'Wetty' }),
timestamp(),
printf(
info =>
`${info.timestamp} [${info.label}] ${info.level}: ${info.message}`
)
)
: format.json(),
transports: [
new transports.Console({
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
handleExceptions: true,
}),
],
});
logger.stream = {
write(message: string): void {
logger.info(message);
},
};
export default logger;

84
src/server/server.ts

@ -0,0 +1,84 @@
import * as compression from 'compression';
import * as express from 'express';
import * as favicon from 'serve-favicon';
import * as helmet from 'helmet';
import * as http from 'http';
import * as https from 'https';
import * as path from 'path';
import * as socket from 'socket.io';
import { isUndefined } from 'lodash';
import * as morgan from 'morgan';
import logger from './logger';
import events from './emitter';
import { SSLBuffer } from './interfaces';
const distDir = path.join('./', 'dist', 'client');
const trim = (str: string): string => str.replace(/\/*$/, '');
export default function createServer(
base: string,
port: number,
{ key, cert }: SSLBuffer
): SocketIO.Server {
const basePath = trim(base);
events.emit(
'debug',
`key: ${key}, cert: ${cert}, port: ${port}, base: ${base}`
);
const html = (
req: express.Request,
res: express.Response
): express.Response =>
res.send(`<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>WeTTy - The Web Terminal Emulator</title>
<link rel="stylesheet" href="${basePath}/public/index.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="terminal"></div>
<script src="${basePath}/public/index.js"></script>
</body>
</html>`);
const app = express();
app
.use(morgan('combined', { stream: logger.stream }))
.use(helmet())
.use(compression())
.use(favicon(path.join(distDir, 'favicon.ico')))
.use(`${basePath}/public`, express.static(distDir))
.use((req, res, next) => {
if (
req.url.substr(-1) === '/' &&
req.url.length > 1 &&
!/\?[^]*\//.test(req.url)
)
res.redirect(301, req.url.slice(0, -1));
else next();
})
.get(basePath, html)
.get(`${basePath}/ssh/:user`, html);
return socket(
!isUndefined(key) && !isUndefined(cert)
? https.createServer({ key, cert }, app).listen(port, () => {
events.server(port, 'https');
})
: http.createServer(app).listen(port, () => {
events.server(port, 'http');
}),
{ path: `${basePath}/socket.io` }
);
}

11
src/server/ssl.ts

@ -0,0 +1,11 @@
import { readFile } from 'fs-extra';
import { resolve } from 'path';
import { isUndefined } from 'lodash';
import { SSL, SSLBuffer } from './interfaces';
export default async function loadSSL(ssl: SSL): Promise<SSLBuffer> {
if (isUndefined(ssl.key) || isUndefined(ssl.cert)) return {};
const files = [readFile(resolve(ssl.key)), readFile(resolve(ssl.cert))];
const [key, cert]: Buffer[] = await Promise.all(files);
return { key, cert };
}

65
src/server/term.ts

@ -0,0 +1,65 @@
import { spawn } from 'node-pty';
import { isUndefined } from 'lodash';
import events from './emitter';
const xterm = {
name: 'xterm-256color',
cols: 80,
rows: 30,
cwd: process.cwd(),
env: process.env,
};
export default class Term {
public static spawn(socket: SocketIO.Socket, args: string[]): void {
const term = spawn('/usr/bin/env', args, xterm);
const address = args[0] === 'ssh' ? args[1] : 'localhost';
events.spawned(term.pid, address);
socket.emit('login');
term.on('exit', code => {
events.exited(code, term.pid);
socket.emit('logout');
socket
.removeAllListeners('disconnect')
.removeAllListeners('resize')
.removeAllListeners('input');
});
term.on('data', data => {
socket.emit('data', data);
});
socket
.on('resize', ({ cols, rows }) => {
term.resize(cols, rows);
})
.on('input', input => {
if (!isUndefined(term)) term.write(input);
})
.on('disconnect', () => {
const { pid } = term;
term.kill();
events.exited(0, pid);
});
}
public static login(socket: SocketIO.Socket): Promise<string> {
const term = spawn('/usr/bin/env', ['node', './dist/buffer.js'], xterm);
let buf = '';
return new Promise((resolve, reject) => {
term.on('exit', () => {
resolve(buf);
});
term.on('data', data => {
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();
});
});
}
}

131
src/server/wetty.ts

@ -0,0 +1,131 @@
/**
* Create WeTTY server
* @module WeTTy
*/
import * as EventEmitter from 'events';
import server from './server';
import getCommand from './command';
import term from './term';
import loadSSL from './ssl';
import { SSL, SSH, SSLBuffer } from './interfaces';
export default class WeTTy extends EventEmitter {
/**
* Starts WeTTy Server
* @name start
*/
public start(
ssh: SSH = { user: '', host: 'localhost', auth: 'password', port: 22 },
basePath: string = '/wetty/',
serverPort: number = 3000,
command: string = '',
ssl?: SSL
): Promise<void> {
return loadSSL(ssl).then((sslBuffer: SSLBuffer) => {
if (ssh.key) {
this.emit(
'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 = server(basePath, serverPort, sslBuffer);
/**
* Wetty server connected too
* @fires WeTTy#connnection
*/
io.on('connection', (socket: SocketIO.Socket) => {
/**
* @event wetty#connection
* @name connection
*/
this.emit('connection', {
msg: `Connection accepted.`,
date: new Date(),
});
const { args, user: sshUser } = getCommand(socket, ssh, command);
this.emit('debug', `sshUser: ${sshUser}, cmd: ${args.join(' ')}`);
if (sshUser) {
term.spawn(socket, args);
} else {
term
.login(socket)
.then((username: string) => {
this.emit('debug', `username: ${username.trim()}`);
args[1] = `${username.trim()}@${args[1]}`;
this.emit('debug', `cmd : ${args.join(' ')}`);
return term.spawn(socket, args);
})
.catch(() => this.disconnected());
}
});
});
}
/**
* terminal spawned
*
* @fires module:WeTTy#spawn
*/
public spawned(pid: number, address: string): void {
/**
* Terminal process spawned
* @event WeTTy#spawn
* @name spawn
* @type {object}
*/
this.emit('spawn', {
msg: `PID=${pid} STARTED on behalf of ${address}`,
pid,
address,
});
}
/**
* terminal exited
*
* @fires WeTTy#exit
*/
public exited(code: number, pid: number): void {
/**
* Terminal process exits
* @event WeTTy#exit
* @name exit
*/
this.emit('exit', { code, msg: `PID=${pid} ENDED` });
}
/**
* Disconnect from WeTTY
*
* @fires WeTTy#disconnet
*/
private disconnected(): void {
/**
* @event WeTTY#disconnect
* @name disconnect
*/
this.emit('disconnect');
}
/**
* Wetty server started
* @fires WeTTy#server
*/
public server(port: number, connection: string): void {
/**
* @event WeTTy#server
* @type {object}
* @name server
*/
this.emit('server', {
msg: `${connection} on port ${port}`,
port,
connection,
});
}
}

84
src/wetty.js

@ -1,84 +0,0 @@
const socket = io(location.origin, { path: '/wetty/socket.io' });
let term;
let buf = '';
class Wetty {
constructor(argv) {
this.argv_ = argv;
this.io = null;
this.pid_ = -1;
}
run() {
this.io = this.argv_.io.push();
this.io.onVTKeystroke = this.sendString_.bind(this);
this.io.sendString = this.sendString_.bind(this);
this.io.onTerminalResize = this.onTerminalResize.bind(this);
}
sendString_(str) {
socket.emit('input', str);
}
onTerminalResize(col, row) {
socket.emit('resize', { col, row });
}
}
socket.on('connect', () => {
document.getElementById('overlay').style.display = 'none';
window.addEventListener('beforeunload', handler, false);
lib.init(() => {
hterm.defaultStorage = new lib.Storage.Local();
term = new hterm.Terminal();
window.term = term;
term.decorate(document.getElementById('terminal'));
term.setCursorPosition(0, 0);
term.setCursorVisible(true);
term.prefs_.set('ctrl-c-copy', true);
term.prefs_.set('ctrl-v-paste', true);
term.prefs_.set('use-default-window-copy', true);
term.prefs_.set('send-encoding', 'raw');
term.prefs_.set('receive-encoding', 'raw');
term.prefs_.set('font-size', 14);
term.scrollPort_.screen_.setAttribute('spellcheck', 'false');
term.scrollPort_.screen_.setAttribute('autocorrect', 'false');
term.scrollPort_.screen_.setAttribute('autocomplete', 'false');
term.scrollPort_.screen_.setAttribute('contenteditable', 'false');
term.runCommandClass(Wetty, document.location.hash.substr(1));
socket.emit('resize', {
col: term.screenSize.width,
row: term.screenSize.height,
});
if (buf && buf !== '') {
term.io.writeUTF8(buf);
buf = '';
}
});
});
socket.on('output', data => {
if (!term) {
buf += data;
return;
}
term.io.writeUTF8(data);
});
socket.on('logout', () => {
document.getElementById('overlay').style.display = 'block';
window.removeEventListener('beforeunload', handler, false);
});
socket.on('disconnect', () => {
document.getElementById('overlay').style.display = 'block';
window.removeEventListener('beforeunload', handler, false);
});
function handler(e) {
e.returnValue = 'Are you sure?';
return e.returnValue;
}

22
tsconfig.json

@ -0,0 +1,22 @@
{
"compilerOptions": {
"module": "commonjs",
"outDir": "./dist",
"allowJs": true,
"esModuleInterop": false,
"target": "es6",
"noImplicitAny": true,
"moduleResolution": "node",
"sourceMap": true,
"baseUrl": ".",
"paths": {
"*": [
"node_modules/",
"src/types/*"
]
}
},
"include": [
"./src/**/*.ts"
]
}

134
webpack.config.babel.js

@ -0,0 +1,134 @@
import path from 'path';
import webpack from 'webpack';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import nodeExternals from 'webpack-node-externals';
const template = override =>
Object.assign(
{
mode: process.env.NODE_ENV || 'development',
resolve: {
modules: [path.resolve(__dirname, 'src'), 'node_modules'],
extensions: ['.ts', '.json', '.js', '.node'],
},
stats: {
colors: true,
},
},
override
);
const entry = (folder, file) =>
path.join(__dirname, 'src', folder, `${file}.ts`);
const entries = (folder, files) =>
Object.assign(...files.map(file => ({ [file]: entry(folder, file) })));
export default [
template({
entry: entries('server', ['index', 'buffer']),
target: 'node',
devtool: 'source-map',
output: {
path: path.resolve(__dirname, 'dist'),
libraryTarget: 'commonjs2',
filename: '[name].js',
},
externals: [nodeExternals()],
module: {
rules: [
{
test: /\.ts$/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-typescript',
[
'@babel/preset-env',
{
targets: {
node: 'current',
},
},
],
],
plugins: ['lodash'],
},
},
},
{
test: /\.js$/,
use: ['source-map-loader'],
enforce: 'pre',
},
],
},
plugins: [new webpack.IgnorePlugin(/uws/)],
}),
template({
entry: entries('client', ['index']),
output: {
path: path.resolve(__dirname, 'dist', 'client'),
filename: '[name].js',
},
module: {
rules: [
{
test: /\.ts$/,
use: {
loader: 'babel-loader',
options: {
presets: [
'@babel/preset-typescript',
[
'@babel/preset-env',
{
targets: {
browsers: ['last 2 versions', 'safari >= 7'],
},
},
],
],
plugins: ['lodash'],
},
},
},
{
test: /\.js$/,
use: ['source-map-loader'],
enforce: 'pre',
},
{
test: /\.scss$/,
use: [
{
loader: MiniCssExtractPlugin.loader,
},
{
loader: 'css-loader',
},
{
loader: 'sass-loader',
},
],
},
{
test: /\.(jpg|jpeg|png|gif|mp3|svg|ico)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
},
],
},
plugins: [
new MiniCssExtractPlugin({
filename: '[name].css',
chunkFilename: '[id].css',
}),
],
devtool: 'source-map',
}),
];

150
wetty.mjs

@ -1,150 +0,0 @@
import express from 'express';
import http from 'http';
import https from 'https';
import path from 'path';
import server from 'socket.io';
import pty from 'node-pty';
import EventEmitter from 'events';
import favicon from 'serve-favicon';
import url from 'url';
const dirname = path.resolve();
const app = express();
app.use(favicon(`${dirname}/public/favicon.ico`));
// For using wetty at /wetty on a vhost
app.get('/wetty/ssh/:user', (req, res) => {
res.sendFile(`${dirname}/public/wetty/index.html`);
});
app.get('/wetty/', (req, res) => {
res.sendFile(`${dirname}/public/wetty/index.html`);
});
// For using wetty on a vhost by itself
app.get('/ssh/:user', (req, res) => {
res.sendFile(`${dirname}/public/wetty/index.html`);
});
app.get('/', (req, res) => {
res.sendFile(`${dirname}/public/wetty/index.html`);
});
// For serving css and javascript
app.use('/', express.static(path.join(dirname, 'public')));
function createServer(port, sslopts) {
return sslopts && sslopts.key && sslopts.cert
? https.createServer(sslopts, app).listen(port, () => {
console.log(`https on port ${port}`);
})
: http.createServer(app).listen(port, () => {
console.log(`http on port ${port}`);
});
}
const urlArgs = request => url.parse(request.headers.referer, true).query;
const getRemoteAddress = socket =>
socket.client.conn.remoteAddress.split(':')[3] === undefined
? 'localhost'
: socket.client.conn.remoteAddress.split(':')[3];
function sshOptions(path, address, port, auth, key, query) {
const sshRemoteOptsBase = [
path,
address,
'-t',
'-p',
port,
'-o',
`PreferredAuthentications=${auth}`,
query.command,
];
if (key) {
return sshRemoteOptsBase.concat(['-i', key]);
} else if (query.sshpass) {
return ['sshpass', '-p', query.sshpass].concat(sshRemoteOptsBase);
}
return sshRemoteOptsBase;
}
function getCommand(socket, sshuser, sshpass, sshhost, sshport, sshauth, sshkey, command) {
const { request } = socket;
const match = request.headers.referer.match('.+/ssh/.+$');
const sshAddress = sshuser ? `${sshuser}@${sshhost}` : sshhost;
const query = urlArgs(request);
query.sshpass = query.sshpass || sshpass;
query.command =
query.path !== undefined
? `$SHELL -c "cd ${query.path};${command === '' ? '$SHELL' : command}"`
: command;
const ssh = match
? `${
match[0]
.split('/ssh/')
.pop()
.split('?')[0]
}@${sshhost}`
: sshAddress;
const args = command === '' ? ['login', '-h', getRemoteAddress(socket)] : [command];
return [
process.getuid() === 0 && sshhost === 'localhost'
? args
: sshOptions(
sshuser || match ? 'ssh' : path.join(dirname, 'bin/ssh'),
ssh,
sshport,
sshauth,
sshkey,
query,
),
ssh,
];
}
export default function start(
port,
sshuser,
sshpass,
sshhost,
sshport,
sshauth,
sshkey,
sslopts,
command,
) {
const events = new EventEmitter();
const io = server(createServer(port, sslopts), { path: '/wetty/socket.io' });
io.on('connection', socket => {
console.log(`${new Date()} Connection accepted.`);
const [args, ssh] = getCommand(
socket,
sshuser,
sshpass,
sshhost,
sshport,
sshauth,
sshkey,
command,
);
console.debug({ args, ssh });
const term = pty.spawn('/usr/bin/env', args, {
name: 'xterm-256color',
cols: 80,
rows: 30,
});
console.log(`${new Date()} PID=${term.pid} STARTED on behalf of remote=${ssh}`);
term.on('data', data => socket.emit('output', data));
term.on('exit', code => {
console.log(`${new Date()} PID=${term.pid} ENDED`);
socket.emit('logout');
events.emit('exit', code);
});
socket.on('resize', ({ col, row }) => term.resize(col, row));
socket.on('input', input => term.write(input));
socket.on('disconnect', () => {
term.end();
term.destroy();
events.emit('disconnect');
});
});
return events;
}

5771
yarn.lock

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