├── .all-contributorsrc ├── .dockerignore ├── .eslintrc.cjs ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── build-and-test.workflow.yml │ ├── codeql-analysis.yml │ ├── docker.workflow.yml │ ├── publish.yml │ ├── pull-requests.yml │ ├── release.yml │ └── stale.yml ├── .gitignore ├── .husky └── pre-commit ├── .mocharc.json ├── .npmignore ├── .prettierrc.json ├── .yarnrc ├── LICENSE ├── README.md ├── build.js ├── conf ├── config.json5 ├── nginx.template ├── wetty.conf └── wetty.service ├── containers ├── docker-compose.traefik.yml ├── ssh │ └── Dockerfile └── wetty │ └── Dockerfile ├── docker-compose.yml ├── docs ├── API.md ├── README.md ├── apache.md ├── assets │ ├── css │ │ └── main.css │ └── img │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ └── site.webmanifest ├── atoz.md ├── auto-login.md ├── development.md ├── docker.md ├── downloading-files.md ├── flags.md ├── https.md ├── index.html ├── nginx.md ├── service.md ├── sidebar.md └── terminal.png ├── package.json ├── pnpm-lock.yaml ├── src ├── assets │ ├── favicon.ico │ ├── scss │ │ ├── options.scss │ │ ├── overlay.scss │ │ ├── styles.scss │ │ ├── terminal.scss │ │ └── variables.scss │ └── xterm_config │ │ ├── functionality.js │ │ ├── index.html │ │ ├── style.css │ │ ├── xterm_advanced_options.js │ │ ├── xterm_color_theme.js │ │ ├── xterm_defaults.js │ │ └── xterm_general_options.js ├── buffer.ts ├── client │ ├── dev.ts │ ├── wetty.ts │ └── wetty │ │ ├── disconnect.ts │ │ ├── disconnect │ │ ├── elements.ts │ │ └── verify.ts │ │ ├── download.spec.ts │ │ ├── download.ts │ │ ├── flowcontrol.ts │ │ ├── mobile.ts │ │ ├── socket.ts │ │ ├── term.ts │ │ └── term │ │ ├── confiruragtion.ts │ │ ├── confiruragtion │ │ ├── clipboard.ts │ │ └── editor.ts │ │ ├── load.ts │ │ └── options.ts ├── main.ts ├── server.ts ├── server │ ├── command.ts │ ├── command │ │ ├── address.ts │ │ ├── login.ts │ │ └── ssh.ts │ ├── flowcontrol.ts │ ├── login.ts │ ├── metrics.ts │ ├── shared │ │ ├── shell.spec.ts │ │ ├── shell.ts │ │ └── xterm.ts │ ├── socketServer.ts │ ├── socketServer │ │ ├── assets.ts │ │ ├── html.ts │ │ ├── metrics.ts │ │ ├── middleware.ts │ │ ├── security.ts │ │ ├── shared │ │ │ └── path.ts │ │ ├── socket.ts │ │ └── ssl.ts │ ├── spawn.ts │ └── spawn │ │ └── env.ts └── shared │ ├── config.ts │ ├── defaults.ts │ ├── env.ts │ ├── interfaces.ts │ └── logger.ts ├── tsconfig.browser.json ├── tsconfig.json └── tsconfig.node.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "WeTTy", 3 | "projectOwner": "butlerx", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "eslint", 12 | "contributors": [ 13 | { 14 | "login": "butlerx", 15 | "name": "Cian Butler", 16 | "avatar_url": "https://avatars1.githubusercontent.com/u/867930?v=4", 17 | "profile": "http://cianbutler.ie", 18 | "contributions": [ 19 | "code", 20 | "doc" 21 | ] 22 | }, 23 | { 24 | "login": "krishnasrinivas", 25 | "name": "Krishna Srinivas", 26 | "avatar_url": "https://avatars0.githubusercontent.com/u/634494?v=4", 27 | "profile": "http://about.me/krishnasrinivas", 28 | "contributions": [ 29 | "code" 30 | ] 31 | }, 32 | { 33 | "login": "acalatrava", 34 | "name": "acalatrava", 35 | "avatar_url": "https://avatars1.githubusercontent.com/u/8502129?v=4", 36 | "profile": "https://github.com/acalatrava", 37 | "contributions": [ 38 | "code" 39 | ] 40 | }, 41 | { 42 | "login": "Strubbl", 43 | "name": "Strubbl", 44 | "avatar_url": "https://avatars3.githubusercontent.com/u/97055?v=4", 45 | "profile": "https://github.com/Strubbl", 46 | "contributions": [ 47 | "code" 48 | ] 49 | }, 50 | { 51 | "login": "2sheds", 52 | "name": "Oleg Kurapov", 53 | "avatar_url": "https://avatars3.githubusercontent.com/u/16163?v=4", 54 | "profile": "https://github.com/2sheds", 55 | "contributions": [ 56 | "code" 57 | ] 58 | }, 59 | { 60 | "login": "rabchev", 61 | "name": "Boyan Rabchev", 62 | "avatar_url": "https://avatars0.githubusercontent.com/u/1876061?v=4", 63 | "profile": "http://www.rabchev.com", 64 | "contributions": [ 65 | "code" 66 | ] 67 | }, 68 | { 69 | "login": "nosemeocurrenada", 70 | "name": "Jimmy", 71 | "avatar_url": "https://avatars1.githubusercontent.com/u/3845708?v=4", 72 | "profile": "https://github.com/nosemeocurrenada", 73 | "contributions": [ 74 | "code" 75 | ] 76 | }, 77 | { 78 | "login": "lucamilanesio", 79 | "name": "Luca Milanesio", 80 | "avatar_url": "https://avatars3.githubusercontent.com/u/182893?v=4", 81 | "profile": "http://www.gerritforge.com", 82 | "contributions": [ 83 | "code" 84 | ] 85 | }, 86 | { 87 | "login": "antonyjim", 88 | "name": "Anthony Jund", 89 | "avatar_url": "https://avatars3.githubusercontent.com/u/39376331?v=4", 90 | "profile": "http://anthonyjund.com", 91 | "contributions": [ 92 | "code" 93 | ] 94 | }, 95 | { 96 | "login": "mirtouf", 97 | "name": "mirtouf", 98 | "avatar_url": "https://avatars3.githubusercontent.com/u/5165058?v=4", 99 | "profile": "https://www.mirtouf.fr", 100 | "contributions": [ 101 | "code" 102 | ] 103 | }, 104 | { 105 | "login": "CoRfr", 106 | "name": "Bertrand Roussel", 107 | "avatar_url": "https://avatars1.githubusercontent.com/u/556693?v=4", 108 | "profile": "https://cor-net.org", 109 | "contributions": [ 110 | "code" 111 | ] 112 | }, 113 | { 114 | "login": "benletchford", 115 | "name": "Ben Letchford", 116 | "avatar_url": "https://avatars0.githubusercontent.com/u/6703966?v=4", 117 | "profile": "https://www.benl.com.au/", 118 | "contributions": [ 119 | "code" 120 | ] 121 | }, 122 | { 123 | "login": "SouraDutta", 124 | "name": "SouraDutta", 125 | "avatar_url": "https://avatars0.githubusercontent.com/u/33066261?v=4", 126 | "profile": "https://github.com/SouraDutta", 127 | "contributions": [ 128 | "code" 129 | ] 130 | }, 131 | { 132 | "login": "koushikmln", 133 | "name": "Koushik M.L.N", 134 | "avatar_url": "https://avatars3.githubusercontent.com/u/8670988?v=4", 135 | "profile": "https://github.com/koushikmln", 136 | "contributions": [ 137 | "code" 138 | ] 139 | }, 140 | { 141 | "login": "imuli", 142 | "name": "Imuli", 143 | "avatar_url": "https://avatars3.githubusercontent.com/u/4085046?v=4", 144 | "profile": "https://imu.li/", 145 | "contributions": [ 146 | "code" 147 | ] 148 | }, 149 | { 150 | "login": "perpen", 151 | "name": "perpen", 152 | "avatar_url": "https://avatars2.githubusercontent.com/u/9963805?v=4", 153 | "profile": "https://github.com/perpen", 154 | "contributions": [ 155 | "code" 156 | ] 157 | }, 158 | { 159 | "login": "nathanleclaire", 160 | "name": "Nathan LeClaire", 161 | "avatar_url": "https://avatars3.githubusercontent.com/u/1476820?v=4", 162 | "profile": "https://nathanleclaire.com", 163 | "contributions": [ 164 | "code" 165 | ] 166 | }, 167 | { 168 | "login": "MiKr13", 169 | "name": "Mihir Kumar", 170 | "avatar_url": "https://avatars2.githubusercontent.com/u/34394719?v=4", 171 | "profile": "https://github.com/MiKr13", 172 | "contributions": [ 173 | "code" 174 | ] 175 | }, 176 | { 177 | "login": "cardil", 178 | "name": "Chris Suszynski", 179 | "avatar_url": "https://avatars0.githubusercontent.com/u/540893?v=4", 180 | "profile": "http://redhat.com", 181 | "contributions": [ 182 | "code" 183 | ] 184 | }, 185 | { 186 | "login": "fbartels", 187 | "name": "Felix Bartels", 188 | "avatar_url": "https://avatars1.githubusercontent.com/u/1257835?v=4", 189 | "profile": "http://9wd.de", 190 | "contributions": [ 191 | "code" 192 | ] 193 | }, 194 | { 195 | "login": "jarrettgilliam", 196 | "name": "Jarrett Gilliam", 197 | "avatar_url": "https://avatars3.githubusercontent.com/u/5099690?v=4", 198 | "profile": "https://github.com/jarrettgilliam", 199 | "contributions": [ 200 | "code" 201 | ] 202 | }, 203 | { 204 | "login": "harryleesan", 205 | "name": "Harry Lee", 206 | "avatar_url": "https://avatars0.githubusercontent.com/u/7056279?v=4", 207 | "profile": "https://harrylee.me", 208 | "contributions": [ 209 | "code" 210 | ] 211 | }, 212 | { 213 | "login": "inducer", 214 | "name": "Andreas Klöckner", 215 | "avatar_url": "https://avatars3.githubusercontent.com/u/352067?v=4", 216 | "profile": "http://andreask.cs.illinois.edu", 217 | "contributions": [ 218 | "code" 219 | ] 220 | }, 221 | { 222 | "login": "DenisKramer", 223 | "name": "DenisKramer", 224 | "avatar_url": "https://avatars1.githubusercontent.com/u/23534092?v=4", 225 | "profile": "https://github.com/DenisKramer", 226 | "contributions": [ 227 | "code" 228 | ] 229 | }, 230 | { 231 | "login": "vamship", 232 | "name": "Vamshi K Ponnapalli", 233 | "avatar_url": "https://avatars0.githubusercontent.com/u/7143376?v=4", 234 | "profile": "https://github.com/vamship", 235 | "contributions": [ 236 | "code" 237 | ] 238 | }, 239 | { 240 | "login": "tnguyen14", 241 | "name": "Tri Nguyen", 242 | "avatar_url": "https://avatars1.githubusercontent.com/u/1652595?v=4", 243 | "profile": "https://tridnguyen.com", 244 | "contributions": [ 245 | "doc" 246 | ] 247 | }, 248 | { 249 | "login": "pojntfx", 250 | "name": "Felix Pojtinger", 251 | "avatar_url": "https://avatars1.githubusercontent.com/u/28832235?v=4", 252 | "profile": "https://felix.pojtinger.com/", 253 | "contributions": [ 254 | "doc" 255 | ] 256 | }, 257 | { 258 | "login": "nealey", 259 | "name": "Neale Pickett", 260 | "avatar_url": "https://avatars3.githubusercontent.com/u/423780?v=4", 261 | "profile": "https://nealey.github.io/", 262 | "contributions": [ 263 | "code" 264 | ] 265 | }, 266 | { 267 | "login": "mtpiercey", 268 | "name": "Matthew Piercey", 269 | "avatar_url": "https://avatars3.githubusercontent.com/u/22581026?v=4", 270 | "profile": "https://www.matthewpiercey.ml", 271 | "contributions": [ 272 | "doc" 273 | ] 274 | }, 275 | { 276 | "login": "kholbekj", 277 | "name": "Kasper Holbek Jensen", 278 | "avatar_url": "https://avatars3.githubusercontent.com/u/2786571?v=4", 279 | "profile": "https://github.com/kholbekj", 280 | "contributions": [ 281 | "doc" 282 | ] 283 | }, 284 | { 285 | "login": "khanzf", 286 | "name": "Farhan Khan", 287 | "avatar_url": "https://avatars1.githubusercontent.com/u/10103765?v=4", 288 | "profile": "https://mastodon.technology/@farhan", 289 | "contributions": [ 290 | "code" 291 | ] 292 | }, 293 | { 294 | "login": "jurruh", 295 | "name": "Jurre Vriesen", 296 | "avatar_url": "https://avatars1.githubusercontent.com/u/7419259?v=4", 297 | "profile": "https://www.jurrevriesen.nl", 298 | "contributions": [ 299 | "code" 300 | ] 301 | }, 302 | { 303 | "login": "jamtur01", 304 | "name": "James Turnbull", 305 | "avatar_url": "https://avatars3.githubusercontent.com/u/4365?v=4", 306 | "profile": "https://www.kartar.net/", 307 | "contributions": [ 308 | "code" 309 | ] 310 | }, 311 | { 312 | "login": "deanshub", 313 | "name": "Dean Shub", 314 | "avatar_url": "https://avatars2.githubusercontent.com/u/2688676?v=4", 315 | "profile": "https://github.com/deanshub", 316 | "contributions": [ 317 | "code" 318 | ] 319 | }, 320 | { 321 | "login": "lozbrown", 322 | "name": "lozbrown ", 323 | "avatar_url": "https://avatars3.githubusercontent.com/u/9961593?v=4", 324 | "profile": "https://github.com/lozbrown", 325 | "contributions": [ 326 | "code", 327 | "example" 328 | ] 329 | }, 330 | { 331 | "login": "sergeir82", 332 | "name": "sergeir82", 333 | "avatar_url": "https://avatars0.githubusercontent.com/u/5081149?v=4", 334 | "profile": "https://github.com/sergeir82", 335 | "contributions": [ 336 | "code" 337 | ] 338 | }, 339 | { 340 | "login": "kmlucy", 341 | "name": "Kyle Lucy", 342 | "avatar_url": "https://avatars1.githubusercontent.com/u/13952475?v=4", 343 | "profile": "https://github.com/kmlucy", 344 | "contributions": [ 345 | "code" 346 | ] 347 | }, 348 | { 349 | "login": "userdocs", 350 | "name": "userdocs", 351 | "avatar_url": "https://avatars1.githubusercontent.com/u/16525024?v=4", 352 | "profile": "https://github.com/userdocs", 353 | "contributions": [ 354 | "doc" 355 | ] 356 | }, 357 | { 358 | "login": "janoskk", 359 | "name": "Janos Kasza", 360 | "avatar_url": "https://avatars3.githubusercontent.com/u/1554533?v=4", 361 | "profile": "https://logmein.com/", 362 | "contributions": [ 363 | "code" 364 | ] 365 | }, 366 | { 367 | "login": "DefunctLizard", 368 | "name": "Grant Handy", 369 | "avatar_url": "https://avatars3.githubusercontent.com/u/45475651?v=4", 370 | "profile": "https://grantshandy.xyz/", 371 | "contributions": [ 372 | "doc" 373 | ] 374 | }, 375 | { 376 | "login": "LeszekBlazewski", 377 | "name": "Leszek Błażewski", 378 | "avatar_url": "https://avatars.githubusercontent.com/u/34927142?v=4", 379 | "profile": "https://github.com/LeszekBlazewski", 380 | "contributions": [ 381 | "code", 382 | "platform" 383 | ] 384 | } 385 | ], 386 | "contributorsPerLine": 7 387 | } 388 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | *.md 5 | *.yml 6 | *.png 7 | **/*.conf 8 | **/*.service 9 | dist 10 | build 11 | docs 12 | Dockerfile 13 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb-base', 'prettier', 'plugin:@typescript-eslint/recommended'], 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint', 'prettier'], 5 | ignorePatterns: ['dist'], 6 | root: true, 7 | env: { 8 | node: true, 9 | browser: true, 10 | }, 11 | rules: { 12 | '@typescript-eslint/no-unused-vars': [ 13 | 'error', 14 | { varsIgnorePattern: '^_', argsIgnorePattern: '^_' }, 15 | ], 16 | '@typescript-eslint/no-use-before-define': ['error', { functions: false }], 17 | 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], 18 | 'import/extensions': [ 19 | 'error', 20 | 'ignorePackages', 21 | { 22 | ts: 'never', 23 | js: 'ignorePackages', 24 | mjs: 'ignorePackages', 25 | jsx: 'never', 26 | tsx: 'never', 27 | }, 28 | ], 29 | 'import/no-extraneous-dependencies': [ 30 | 'error', 31 | { devDependencies: ['**/*.test.*', '**/*.spec.*', 'build.js'] }, 32 | ], 33 | 'import/order': [ 34 | 'error', 35 | { 36 | groups: [ 37 | 'builtin', 38 | 'internal', 39 | 'external', 40 | 'parent', 41 | 'sibling', 42 | 'index', 43 | 'object', 44 | 'type', 45 | ], 46 | pathGroups: [{ pattern: '@ev/**', group: 'internal' }], 47 | distinctGroup: true, 48 | alphabetize: { order: 'asc', caseInsensitive: false }, 49 | }, 50 | ], 51 | 'import/prefer-default-export': 'off', 52 | 'import/prefer-default-export': 'off', 53 | 'linebreak-style': ['error', 'unix'], 54 | 'lines-between-class-members': [ 55 | 'error', 56 | 'always', 57 | { exceptAfterSingleLine: true }, 58 | ], 59 | 'no-param-reassign': ['error', { props: false }], 60 | 'no-use-before-define': ['error', { functions: false }], 61 | }, 62 | settings: { 63 | // Apply special parsing for TypeScript files 64 | 'import/parsers': { '@typescript-eslint/parser': ['.ts', '.tsx', '.d.ts'] }, 65 | 'import/resolver': { 66 | typescript: { 67 | project: ['./tsconfig.browser.json', './tsconfig.node.json'], 68 | }, 69 | node: { extensions: ['.mjs', '.js', '.json', '.ts', '.d.ts'] }, 70 | }, 71 | 'import/extensions': ['.js', '.mjs', '.jsx', '.ts', '.tsx', '.d.ts'], 72 | // Resolve type definition packages 73 | 'import/external-module-folders': ['node_modules', 'node_modules/@types'], 74 | }, 75 | overrides: [ 76 | { files: ['*.ts', '*.tsx'], rules: { 'import/no-unresolved': 'off' } }, 77 | { 78 | files: ['*.js', '*.jsx'], 79 | rules: { 80 | '@typescript-eslint/no-var-requires': 'off', 81 | 'import/no-unresolved': 'off', 82 | }, 83 | }, 84 | { 85 | files: ['*.spec.*', '*.test.*'], 86 | extends: ['plugin:mocha/recommended'], 87 | plugins: ['mocha'], 88 | rules: { 89 | 'import/no-extraneous-dependencies': ['off'], 90 | 'mocha/no-mocha-arrows': ['off'], 91 | 'no-unused-expressions': ['off'], 92 | }, 93 | }, 94 | ], 95 | }; 96 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | updates: 4 | - package-ecosystem: npm 5 | directory: '/' 6 | schedule: 7 | interval: weekly 8 | commit-message: 9 | prefix: '[NPM] ' 10 | - package-ecosystem: github-actions 11 | directory: '/' 12 | schedule: 13 | interval: weekly 14 | commit-message: 15 | prefix: '[GH action] ' 16 | - package-ecosystem: docker 17 | directory: '/containers' 18 | schedule: 19 | interval: weekly 20 | commit-message: 21 | prefix: '[docker] ' 22 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.workflow.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build & Test 3 | on: 4 | workflow_call: 5 | inputs: 6 | working-directory: 7 | required: false 8 | type: string 9 | default: '.' 10 | jobs: 11 | build_and_test: 12 | name: Build & Test 13 | runs-on: ubuntu-latest 14 | defaults: 15 | run: 16 | working-directory: ${{ inputs.working-directory }} 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 2 22 | 23 | - uses: pnpm/action-setup@v2 24 | with: 25 | version: 8 26 | 27 | - name: Setup env 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: 22 31 | cache: 'pnpm' 32 | 33 | - name: Install dependencies 34 | run: pnpm install 35 | 36 | - name: ESLint checks 37 | run: pnpm lint 38 | 39 | - run: pnpm build 40 | name: Compile Typescript 41 | 42 | - run: pnpm test 43 | name: Run tests 44 | env: 45 | CI: true 46 | 47 | - uses: actions/cache@v3 48 | id: restore-build 49 | with: 50 | path: ./* 51 | key: ${{ github.sha }} 52 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Code Scanning - Action 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | schedule: 9 | - cron: '0 11 * * 1' 10 | jobs: 11 | analyze: 12 | name: Analyze 13 | runs-on: ubuntu-latest 14 | permissions: 15 | security-events: write 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Initialize CodeQL 21 | uses: github/codeql-action/init@v2 22 | with: 23 | languages: javascript 24 | 25 | - name: Autobuild 26 | uses: github/codeql-action/autobuild@v2 27 | 28 | - name: Perform CodeQL Analysis 29 | uses: github/codeql-action/analyze@v2 30 | -------------------------------------------------------------------------------- /.github/workflows/docker.workflow.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Docker Workflow 3 | on: 4 | workflow_call: 5 | inputs: 6 | platforms: 7 | required: true 8 | type: string 9 | push: 10 | type: boolean 11 | default: false 12 | secrets: 13 | DOCKERHUB_USERNAME: 14 | DOCKERHUB_TOKEN: 15 | jobs: 16 | docker: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - name: Docker meta 22 | id: meta 23 | uses: docker/metadata-action@v5 24 | with: 25 | images: | 26 | wettyoss/wetty 27 | ghcr.io/butlerx/wetty 28 | flavor: | 29 | latest=true 30 | tags: | 31 | type=schedule 32 | type=ref,event=branch 33 | type=ref,event=pr 34 | type=semver,pattern={{version}} 35 | type=semver,pattern={{major}}.{{minor}} 36 | type=semver,pattern={{major}} 37 | type=sha 38 | - name: Set up QEMU 39 | uses: docker/setup-qemu-action@v3 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v3 42 | - name: Login to DockerHub 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKERHUB_USERNAME }} 46 | password: ${{ secrets.DOCKERHUB_TOKEN }} 47 | - name: Login to GHCR 48 | uses: docker/login-action@v3 49 | with: 50 | registry: ghcr.io 51 | username: ${{ github.repository_owner }} 52 | password: ${{ secrets.GITHUB_TOKEN }} 53 | - name: Build and push 54 | uses: docker/build-push-action@v5 55 | with: 56 | context: . 57 | file: containers/wetty/Dockerfile 58 | platforms: ${{ inputs.platforms }} 59 | push: ${{ inputs.push }} 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | cache-from: type=registry,ref=${{ steps.meta.outputs.tags }} 63 | cache-to: type=inline 64 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build and Publish 3 | on: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | test: 9 | name: Build & Test 10 | uses: ./.github/workflows/build-and-test.workflow.yml 11 | 12 | publish: 13 | runs-on: ubuntu-latest 14 | needs: test 15 | permissions: 16 | contents: write 17 | packages: write 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v4 21 | - uses: pnpm/action-setup@v2 22 | with: 23 | version: 8 24 | - name: Setup env 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: 22 28 | cache: 'pnpm' 29 | 30 | - uses: actions/cache@v3 31 | id: restore-build 32 | with: 33 | path: ./* 34 | key: ${{ github.sha }} 35 | 36 | # Publish to NPM 37 | - name: Publish if version has been updated 38 | uses: pascalgn/npm-publish-action@1.3.9 39 | with: 40 | tag_name: 'v%s' 41 | tag_message: 'v%s' 42 | commit_pattern: "^Release (\\S+)" 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | NPM_AUTH_TOKEN: ${{ secrets.npm_token }} 46 | 47 | # Publish to Github PKG 48 | - uses: actions/setup-node@v3 49 | with: 50 | node-version: 22 51 | registry-url: 'https://npm.pkg.github.com' 52 | 53 | - name: Publish to Github PKG if version has been updated 54 | uses: pascalgn/npm-publish-action@1.3.9 55 | with: 56 | create_tag: false 57 | commit_pattern: "^Release (\\S+)" 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | NPM_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /.github/workflows/pull-requests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Run tests 3 | on: 4 | pull_request: 5 | jobs: 6 | test: 7 | name: Build & Test 8 | uses: ./.github/workflows/build-and-test.workflow.yml 9 | 10 | validate-docker: 11 | name: Validate Docker Build 12 | uses: ./.github/workflows/docker.workflow.yml 13 | strategy: 14 | matrix: 15 | platform: 16 | - linux/amd64 17 | - linux/arm/v6 18 | - linux/arm/v7 19 | - linux/arm64 20 | secrets: inherit 21 | with: 22 | platforms: ${{ matrix.platform }} 23 | push: false 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Create Release 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | jobs: 8 | release: 9 | name: Create Release 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Create Release 13 | uses: fregante/release-with-changelog@v3 14 | with: 15 | token: ${{ secrets.GITHUB_TOKEN }} 16 | title: 'Release {tag}' 17 | exclude: true 18 | commit-template: '- {title} ← {hash}' 19 | template: | 20 | ### Changelog 21 | 22 | {commits} 23 | 24 | {range} 25 | 26 | docker: 27 | name: Docker Publish Image 28 | uses: ./.github/workflows/docker.workflow.yml 29 | secrets: inherit 30 | with: 31 | platforms: linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64 32 | push: true 33 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Mark stale issues and pull requests 3 | on: 4 | schedule: 5 | - cron: '39 10 * * *' 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | issues: write 12 | pull-requests: write 13 | steps: 14 | - uses: actions/stale@v8 15 | with: 16 | repo-token: ${{ secrets.GITHUB_TOKEN }} 17 | stale-issue-message: 'Stale issue message' 18 | stale-pr-message: 'Stale pull request message' 19 | stale-issue-label: 'no-issue-activity' 20 | stale-pr-label: 'no-pr-activity' 21 | days-before-close: 21 22 | days-before-stale: 365 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/node 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 5 | 6 | ### Node ### 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Optional stylelint cache 64 | .stylelintcache 65 | 66 | # Microbundle cache 67 | .rpt2_cache/ 68 | .rts2_cache_cjs/ 69 | .rts2_cache_es/ 70 | .rts2_cache_umd/ 71 | 72 | # Optional REPL history 73 | .node_repl_history 74 | 75 | # Output of 'npm pack' 76 | *.tgz 77 | 78 | # Yarn Integrity file 79 | .yarn-integrity 80 | 81 | # dotenv environment variable files 82 | .env 83 | .env.development.local 84 | .env.test.local 85 | .env.production.local 86 | .env.local 87 | 88 | # parcel-bundler cache (https://parceljs.org/) 89 | .cache 90 | .parcel-cache 91 | 92 | # Next.js build output 93 | .next 94 | out 95 | 96 | # Nuxt.js build / generate output 97 | .nuxt 98 | dist 99 | 100 | # Gatsby files 101 | .cache/ 102 | # Comment in the public line in if your project uses Gatsby and not Next.js 103 | # https://nextjs.org/blog/next-9-1#public-directory-support 104 | # public 105 | 106 | # vuepress build output 107 | .vuepress/dist 108 | 109 | # vuepress v2.x temp and cache directory 110 | .temp 111 | 112 | # Docusaurus cache and generated files 113 | .docusaurus 114 | 115 | # Serverless directories 116 | .serverless/ 117 | 118 | # FuseBox cache 119 | .fusebox/ 120 | 121 | # DynamoDB Local files 122 | .dynamodb/ 123 | 124 | # TernJS port file 125 | .tern-port 126 | 127 | # Stores VSCode versions used for testing VSCode extensions 128 | .vscode-test 129 | 130 | # yarn v2 131 | .yarn/cache 132 | .yarn/unplugged 133 | .yarn/build-state.yml 134 | .yarn/install-state.gz 135 | .pnp.* 136 | 137 | ### Node Patch ### 138 | # Serverless Webpack directories 139 | .webpack/ 140 | 141 | # Optional stylelint cache 142 | 143 | # SvelteKit build / generate output 144 | .svelte-kit 145 | 146 | # End of https://www.toptal.com/developers/gitignore/api/node 147 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | pnpm exec lint-staged 5 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extension": ["ts"], 3 | "spec": ["src/**/*.spec.*"], 4 | "node-option": [ 5 | "experimental-specifier-resolution=node", 6 | "loader=ts-node/esm", 7 | "no-warnings" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | 10 | tmp 11 | pids 12 | logs 13 | results 14 | 15 | npm-debug.log 16 | node_modules/* 17 | .esm-cache 18 | src 19 | *.yml 20 | Dockerfile 21 | *.png 22 | .dockerignore 23 | .eslint* 24 | .prettierrc.js 25 | tsconfig 26 | docs 27 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "always", 5 | "overrides": [ 6 | { 7 | "files": [ 8 | "*.js", 9 | "*.ts" 10 | ], 11 | "options": { 12 | "printWidth": 80 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | network-timeout 1000000 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014 Krishna Srinivas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WeTTY = Web + TTY. 2 | 3 | 4 | 5 | [![All Contributors](https://img.shields.io/badge/all_contributors-41-orange.svg?style=flat-square)](#contributors-) 6 | 7 | 8 | 9 | [![Documentation](https://img.shields.io/badge/documentation-yes-brightgreen.svg)](https://github.com/butlerx/wetty/tree/main/docs) 10 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/butlerx/wetty/blob/main/LICENSE) 11 | 12 | > Terminal access in browser over http/https 13 | 14 | ![WeTTY](./docs/terminal.png?raw=true) 15 | 16 | Terminal over HTTP and https. WeTTY is an alternative to ajaxterm and anyterm 17 | but much better than them because WeTTY uses xterm.js which is a full fledged 18 | implementation of terminal emulation written entirely in JavaScript. WeTTY uses 19 | websockets rather than Ajax and hence better response time. 20 | 21 | ## Prerequisites 22 | 23 | - node >=18 24 | - make 25 | - python 26 | - build-essential 27 | 28 | ## Install 29 | 30 | ```sh 31 | npm -g i wetty 32 | ``` 33 | 34 | ## Usage 35 | 36 | ```sh 37 | $ wetty --help 38 | Options: 39 | --help, -h Print help message [boolean] 40 | --version Show version number [boolean] 41 | --conf config file to load config from [string] 42 | --ssl-key path to SSL key [string] 43 | --ssl-cert path to SSL certificate [string] 44 | --ssh-host ssh server host [string] 45 | --ssh-port ssh server port [number] 46 | --ssh-user ssh user [string] 47 | --title window title [string] 48 | --ssh-auth defaults to "password", you can use "publickey,password" 49 | instead [string] 50 | --ssh-pass ssh password [string] 51 | --ssh-key path to an optional client private key (connection will be 52 | password-less and insecure!) [string] 53 | --ssh-config Specifies an alternative ssh configuration file. For further 54 | details see "-F" option in ssh(1) [string] 55 | --force-ssh Connecting through ssh even if running as root [boolean] 56 | --known-hosts path to known hosts file [string] 57 | --base, -b base path to wetty [string] 58 | --port, -p wetty listen port [number] 59 | --host wetty listen host [string] 60 | --command, -c command to run in shell [string] 61 | --allow-iframe Allow wetty to be embedded in an iframe, defaults to allowing 62 | same origin [boolean] 63 | ``` 64 | 65 | Open your browser on `http://yourserver:3000/wetty` and you will prompted to 66 | login. Or go to `http://yourserver:3000/wetty/ssh/` to specify the 67 | user beforehand. 68 | 69 | If you run it as root it will launch `/bin/login` (where you can specify the 70 | user name), else it will launch `ssh` and connect by default to `localhost`. The 71 | SSH connection can be forced using the `--force-ssh` option. 72 | 73 | If instead you wish to connect to a remote host you can specify the `--ssh-host` 74 | option, the SSH port using the `--ssh-port` option and the SSH user using the 75 | `--ssh-user` option. 76 | 77 | Check out the [Flags docs](https://butlerx.github.io/wetty/flags) for a full 78 | list of flags 79 | 80 | ### Docker container 81 | 82 | To use WeTTY as a docker container, a docker image is available on 83 | [docker hub](https://hub.docker.com/r/wettyoss/wetty). To run this image, use 84 | 85 | ```sh 86 | docker run --rm -p 3000:3000 wettyoss/wetty --ssh-host= 87 | ``` 88 | 89 | and you will be able to open a ssh session to the host given by `YOUR-IP` under 90 | the URL [http://localhost:3000/wetty](http://localhost:3000/wetty). 91 | 92 | It is recommended to drive WeTTY behind a reverse proxy to have HTTPS security 93 | and possibly Let’s Encrypt support. Popular containers to achieve this are 94 | [nginx-proxy](https://github.com/nginx-proxy/nginx-proxy) and 95 | [traefik](https://traefik.io/traefik/). For traefik there is an example 96 | docker-compose file in the containers directory. 97 | 98 | ## FAQ 99 | 100 | Check out the [docs](https://github.com/butlerx/wetty/tree/main/docs) 101 | 102 | - [Running as daemon](https://butlerx.github.io/wetty/service) 103 | - [HTTPS Support](https://butlerx.github.io/wetty/https) 104 | - [Using NGINX](https://butlerx.github.io/wetty/nginx) 105 | - [Using Apache](https://butlerx.github.io/wetty/apache) 106 | - [Automatic Login](https://butlerx.github.io/wetty/auto-login) 107 | - [Downloading Files](https://butlerx.github.io/wetty/downloading-files) 108 | 109 | ### What browsers are supported? 110 | 111 | WeTTY supports all browsers that 112 | [xterm.js supports](https://github.com/xtermjs/xterm.js#browser-support). 113 | 114 | ## Author 115 | 116 | 👤 **Cian Butler ** 117 | 118 | - Mastodon: [@butlerx@mastodon.ie](https://mastodon.ie/@butlerx) 119 | - Github: [@butlerx](https://github.com/butlerx) 120 | 121 | ## Contributing ✨ 122 | 123 | Contributions, issues and feature requests are welcome!
Feel free to check 124 | [issues page](https://github.com/butlerx/wetty/issues). 125 | 126 | Please read the [development docs](https://butlerx.github.io/wetty/development) 127 | for installing from source and running is dev node 128 | 129 | Thanks goes to these wonderful people 130 | ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 |
Cian Butler
Cian Butler

💻 📖
Krishna Srinivas
Krishna Srinivas

💻
acalatrava
acalatrava

💻
Strubbl
Strubbl

💻
Oleg Kurapov
Oleg Kurapov

💻
Boyan Rabchev
Boyan Rabchev

💻
Jimmy
Jimmy

💻
Luca Milanesio
Luca Milanesio

💻
Anthony Jund
Anthony Jund

💻
mirtouf
mirtouf

💻
Bertrand Roussel
Bertrand Roussel

💻
Ben Letchford
Ben Letchford

💻
SouraDutta
SouraDutta

💻
Koushik M.L.N
Koushik M.L.N

💻
Imuli
Imuli

💻
perpen
perpen

💻
Nathan LeClaire
Nathan LeClaire

💻
Mihir Kumar
Mihir Kumar

💻
Chris Suszynski
Chris Suszynski

💻
Felix Bartels
Felix Bartels

💻
Jarrett Gilliam
Jarrett Gilliam

💻
Harry Lee
Harry Lee

💻
Andreas Klöckner
Andreas Klöckner

💻
DenisKramer
DenisKramer

💻
Vamshi K Ponnapalli
Vamshi K Ponnapalli

💻
Tri Nguyen
Tri Nguyen

📖
Felix Pojtinger
Felix Pojtinger

📖
Neale Pickett
Neale Pickett

💻
Matthew Piercey
Matthew Piercey

📖
Kasper Holbek Jensen
Kasper Holbek Jensen

📖
Farhan Khan
Farhan Khan

💻
Jurre Vriesen
Jurre Vriesen

💻
James Turnbull
James Turnbull

💻
Dean Shub
Dean Shub

💻
lozbrown
lozbrown

💻 💡
sergeir82
sergeir82

💻
Kyle Lucy
Kyle Lucy

💻
userdocs
userdocs

📖
Janos Kasza
Janos Kasza

💻
Grant Handy
Grant Handy

📖
Leszek Błażewski
Leszek Błażewski

💻 📦
192 | 193 | 194 | 195 | 196 | 197 | 198 | This project follows the 199 | [all-contributors](https://github.com/all-contributors/all-contributors) 200 | specification. Contributions of any kind welcome! 201 | 202 | ## Show your support 203 | 204 | Give a ⭐️ if this project helped you! 205 | 206 | ## 📝 License 207 | 208 | Copyright © 2019 209 | [Cian Butler ](https://github.com/butlerx).
This 210 | project is [MIT](https://github.com/butlerx/wetty/blob/main/LICENSE) licensed. 211 | 212 | --- 213 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import {spawn} from 'node:child_process'; 2 | import * as esbuild from 'esbuild'; 3 | import {copy} from 'esbuild-plugin-copy'; 4 | import {sassPlugin} from 'esbuild-sass-plugin'; 5 | 6 | 7 | /** @param {string} prog 8 | * @param {string[]} [args=[]] 9 | * @returns {[ChildProcess, Promise<{ret: number, sig: NodeJS.Signals}>]} 10 | */ 11 | function cmd(prog, args=[]) { 12 | const proc = spawn(prog, args, { cwd: import.meta.dirname, stdio: "inherit", env: process.env}); 13 | const done = new Promise((resolve, _reject) => { 14 | proc.addListener('exit',(ret, sig)=>{ 15 | resolve({ret,sig}); 16 | }) 17 | }); 18 | return [proc, done]; 19 | } 20 | 21 | /** @type import('esbuild').Plugin */ 22 | const typechecker = { 23 | name: 'typechecker', 24 | setup(build) { 25 | build.onStart(async () => { 26 | const [_tsc, tscDone] = cmd('pnpm', ['tsc', '-p', 'tsconfig.browser.json']); 27 | const {ret} = await tscDone; 28 | if (ret !== 0) { 29 | return {warnings: [{text:`Type checking failed: tsc exited with code ${ret}`}]} 30 | } 31 | return {}; 32 | }); 33 | } 34 | }; 35 | 36 | /** @param {boolean} watching 37 | */ 38 | async function buildClient(watching){ 39 | 40 | /** @type {esbuild.BuildOptions} */ 41 | const esConf = { 42 | entryPoints: ['src/client/wetty.ts', 'src/client/dev.ts'], 43 | outdir: 'build/client', 44 | bundle: true, 45 | platform: 'browser', 46 | format: 'esm', 47 | minify: !watching, 48 | sourcemap: !watching, 49 | plugins: [ 50 | typechecker, 51 | sassPlugin({ 52 | embedded: true, 53 | loadPaths: ['node_modules'], 54 | style: watching ? 'expanded' : 'compressed', 55 | }), 56 | copy({ 57 | assets: [ 58 | {from: './src/assets/xterm_config/*', to: 'xterm_config'}, 59 | {from: './src/assets/favicon.ico', to: 'favicon.ico'}, 60 | ], 61 | watch: watching, 62 | }), 63 | ], 64 | logLevel: 'info', 65 | }; 66 | 67 | if (watching) { 68 | const buildCtx = await esbuild.context(esConf) 69 | await buildCtx.watch(); 70 | } else { 71 | esbuild.build(esConf); 72 | } 73 | } 74 | 75 | /** @param {boolean} watching 76 | */ 77 | async function buildServer(watching) { 78 | const tscArgs = ['tsc', '-p', 'tsconfig.node.json']; 79 | if (watching) tscArgs.push('--watch','--preserveWatchOutput'); 80 | const [_tsc, tscDone] = cmd('pnpm', tscArgs); 81 | if (!watching) await tscDone; 82 | } 83 | 84 | const watching = process.argv.includes('--watch'); 85 | await buildClient(watching); 86 | await buildServer(watching); 87 | 88 | -------------------------------------------------------------------------------- /conf/config.json5: -------------------------------------------------------------------------------- 1 | { 2 | ssh: { 3 | // user: 'username', // default user to use when ssh-ing 4 | host: 'localhost', // Server to ssh to 5 | auth: 'password', // shh authentication, method. Defaults to "password", you can use "publickey,password" instead' 6 | // pass: "password", // Password to use when sshing 7 | // key: "", // path to an optional client private key, connection will be password-less and insecure! 8 | port: 22, // Port to ssh to 9 | knownHosts: '/dev/null', // ssh knownHosts file to use 10 | // config: '/home/user/.wetty_ssh_config', // alternative ssh configuration file, see "-F" option in ssh(1) 11 | }, 12 | server: { 13 | base: '/wetty/', // URL base to serve resources from 14 | port: 3000, // Port to listen on 15 | host: '0.0.0.0', // address to listen on 16 | title: 'WeTTY - The Web Terminal Emulator', // Page title 17 | bypassHelmet: false, // Disable Helmet security checks 18 | }, 19 | 20 | forceSSH: false, // Force sshing to local machine over login if running as root 21 | command: 'login', // Command to run on server. Login will use ssh if connecting to different server 22 | /* 23 | ssl:{ 24 | key: 'ssl.key', 25 | cert: 'ssl.cert', 26 | } 27 | */ 28 | } 29 | -------------------------------------------------------------------------------- /conf/nginx.template: -------------------------------------------------------------------------------- 1 | server { 2 | listen ${NGINX_PORT}; 3 | listen [::]:${NGINX_PORT}; 4 | 5 | server_name ${NGINX_DOMAIN}; 6 | root /var/www/${NGINX_DOMAIN}/public; 7 | 8 | # $uri, index.html 9 | location / { 10 | try_files $uri $uri/ /index.html; 11 | } 12 | 13 | # headers 14 | add_header X-Frame-Options "SAMEORIGIN" always; 15 | add_header X-XSS-Protection "1; mode=block" always; 16 | add_header X-Content-Type-Options "nosniff" always; 17 | add_header X-UA-Compatible "IE=Edge" always; 18 | add_header Cache-Control "no-transform" always; 19 | 20 | # . files 21 | location ~ /\. { 22 | deny all; 23 | } 24 | 25 | # assets, media 26 | 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)$ { 27 | expires 7d; 28 | access_log off; 29 | } 30 | 31 | # svg, fonts 32 | location ~* \.(?:svgz?|ttf|ttc|otf|eot|woff|woff2)$ { 33 | add_header Access-Control-Allow-Origin "*"; 34 | expires 7d; 35 | access_log off; 36 | } 37 | 38 | location ^~ /wetty { 39 | proxy_pass http://${WETTY_HOST}:${WETTY_PORT}; 40 | proxy_http_version 1.1; 41 | proxy_set_header Upgrade $http_upgrade; 42 | proxy_set_header Connection "upgrade"; 43 | proxy_read_timeout 43200000; 44 | 45 | proxy_set_header X-Real-IP $remote_addr; 46 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 47 | proxy_set_header Host $http_host; 48 | proxy_set_header X-NginX-Proxy true; 49 | 50 | # Authenticate user via other services (e.g., oauth2 end-points) 51 | # 52 | # Configuration : 53 | # - Configure a 'auth_request' directive for this server block 54 | # - Capture the authenticated username using 'auth_request_set' 55 | # - Set the 'remote-user' request header accordingly 56 | # 57 | # Example (using lasso as authentication middleware): 58 | # 59 | # Add to server block: 60 | # auth_request /lasso-validate 61 | # auth_request_set $auth_user $upstream_http_x_lasso_user; 62 | # 63 | # Add to /wetty location block 64 | # proxy_set_header remote-user $auth_user; 65 | # 66 | # And configure a '/lasso-validate' location. See this blog for further 67 | # guidance: https://developer.okta.com/blog/2018/08/28/nginx-auth-request 68 | } 69 | 70 | # gzip 71 | gzip on; 72 | gzip_vary on; 73 | gzip_proxied any; 74 | gzip_comp_level 6; 75 | gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; 76 | } 77 | 78 | # subdomains redirect 79 | server { 80 | listen ${NGINX_PORT}; 81 | listen [::]:${NGINX_PORT}; 82 | 83 | server_name *.${NGINX_DOMAIN}; 84 | 85 | return 301 https://${NGINX_DOMAIN}$request_uri; 86 | } 87 | 88 | # set ft=conf 89 | -------------------------------------------------------------------------------- /conf/wetty.conf: -------------------------------------------------------------------------------- 1 | # Upstart script 2 | # /etc/init/wetty.conf 3 | 4 | description "Web TTY" 5 | author "Wetty" 6 | 7 | start on started mountall 8 | stop on shutdown 9 | 10 | respawn 11 | respawn limit 20 5 12 | 13 | exec sudo -u root wetty -p 3000 14 | -------------------------------------------------------------------------------- /conf/wetty.service: -------------------------------------------------------------------------------- 1 | # systemd unit file 2 | # 3 | # place in /etc/systemd/system 4 | # systemctl enable wetty.service 5 | # systemctl start wetty.service 6 | 7 | [Unit] 8 | Description=Wetty Web Terminal 9 | After=network.target 10 | 11 | [Service] 12 | Type=simple 13 | WorkingDirectory=$HOME/.config/yarn/global/node_modules/wetty/ 14 | ExecStart=/usr/bin/node . -p 3000 --host 127.0.0.1 15 | TimeoutStopSec=20 16 | KillMode=mixed 17 | Restart=always 18 | RestartSec=2 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /containers/docker-compose.traefik.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | # Sample docker compose file to demonstrate the usage of wetty behind a reverse 4 | # proxy, optionally with Let's Encrypt based SSL certificate 5 | # 6 | # For SSL support, uncomment the commented lines. Consult traefik documentation 7 | # for features like automatic forward from HTTP to HTTPS etc. 8 | 9 | services: 10 | wetty: 11 | image: wettyoss/wetty 12 | command: 13 | - --base=/ 14 | - --ssh-host=ssh.example.com 15 | labels: 16 | - "traefik.enable=true" 17 | - "traefik.http.routers.wetty.rule=Host(`wetty.example.com`)" 18 | # - "traefik.http.routers.wetty.tls.certResolver=default" 19 | # - "traefik.http.routers.wetty.tls=true" 20 | 21 | reverse-proxy: 22 | image: traefik 23 | command: 24 | - --providers.docker 25 | - --entryPoints.web.address=:80 26 | # - --entryPoints.websecure.address=:443 27 | # - --certificatesResolvers.default.acme.email=your-email@example.com 28 | # - --certificatesResolvers.default.acme.storage=acme.json 29 | # - --certificatesResolvers.default.acme.httpChallenge.entryPoint=web 30 | ports: 31 | - "80:80" 32 | # - "443:443" 33 | 34 | volumes: 35 | - /var/run/docker.sock:/var/run/docker.sock 36 | -------------------------------------------------------------------------------- /containers/ssh/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM sickp/alpine-sshd:latest 2 | RUN adduser -D -h /home/term -s /bin/sh term && \ 3 | ( echo "term:term" | chpasswd ) 4 | -------------------------------------------------------------------------------- /containers/wetty/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:current-alpine as base 2 | RUN apk add -U build-base python3 3 | ENV PNPM_HOME="/pnpm" 4 | ENV PATH="$PNPM_HOME:$PATH" 5 | RUN corepack enable 6 | RUN npx pnpm i -g pnpm@latest 7 | WORKDIR /usr/src/app 8 | COPY . /usr/src/app 9 | 10 | FROM base AS prod-deps 11 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile 12 | 13 | FROM base AS build 14 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 15 | RUN pnpm run build 16 | 17 | FROM node:current-alpine 18 | LABEL maintainer="butlerx@notthe.cloud" 19 | WORKDIR /usr/src/app 20 | ENV NODE_ENV=production 21 | EXPOSE 3000 22 | COPY --from=prod-deps /usr/src/app/node_modules /usr/src/app/node_modules 23 | COPY --from=build /usr/src/app/build /usr/src/app/build 24 | COPY package.json /usr/src/app 25 | RUN apk add -U coreutils openssh-client sshpass && \ 26 | mkdir ~/.ssh 27 | 28 | EXPOSE 8000 29 | CMD [ "pnpm", "start" ] 30 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: '3.5' 3 | services: 4 | wetty: 5 | image: wettyoss/wetty 6 | build: 7 | context: . 8 | dockerfile: containers/wetty/Dockerfile 9 | entrypoint: pnpm run docker-compose-entrypoint 10 | tty: true 11 | working_dir: /usr/src/app 12 | ports: 13 | - '3000:3000' 14 | environment: 15 | SSHHOST: 'wetty-ssh' 16 | SSHPORT: 22 17 | NODE_ENV: 'development' 18 | 19 | web: 20 | image: nginx 21 | volumes: 22 | - ./conf/nginx.template:/etc/nginx/conf.d/wetty.template 23 | ports: 24 | - '80:80' 25 | environment: 26 | - NGINX_DOMAIN=wetty.com 27 | - NGINX_PORT=80 28 | - WETTY_HOST=wetty 29 | - WETTY_PORT=3000 30 | command: >- 31 | /bin/bash -c "envsubst 32 | '$${NGINX_DOMAIN},$${NGINX_PORT},$${WETTY_HOST},$${WETTY_PORT}' < 33 | /etc/nginx/conf.d/wetty.template > /etc/nginx/conf.d/default.conf && nginx 34 | -g 'daemon off;'" 35 | 36 | wetty-ssh: 37 | build: 38 | context: . 39 | dockerfile: containers/ssh/Dockerfile 40 | image: wettyoss/wetty:ssh 41 | 42 | networks: 43 | default: 44 | name: wetty 45 | -------------------------------------------------------------------------------- /docs/API.md: -------------------------------------------------------------------------------- 1 | ## WeTTY 2 | 3 | Create WeTTY server 4 | 5 | - [WeTTy](#module_WeTTy) 6 | - [start](#module_WeTTy..start) ⇒ `Promise` 7 | - [connection](#event_connection) 8 | - [spawn](#event_spawn) 9 | - [exit](#event_exit) 10 | - [disconnect](#event_disconnect) 11 | - [server](#event_server) 12 | 13 | ### WeTTy.start ⇒ `Promise` 14 | 15 | Starts WeTTY Server 16 | 17 | **Kind**: inner property of [`WeTTy`](#module_WeTTy) 18 | **Returns**: `Promise` - Promise resolves once server is running 19 | 20 | | Param | Type | Default | Description | 21 | | :------------------------ | --------- | ------------- | ---------------------------------------------------------------------------------------------------------------------- | 22 | | [ssh] | `Object` | | SSH settings | 23 | | [ssh.user] | `string` | `"''"` | default user for ssh | 24 | | [ssh.host] | `string` | `"localhost"` | machine to ssh too | 25 | | [ssh.auth] | `string` | `"password"` | authtype to use | 26 | | [ssh.port] | `number` | `22` | port to connect to over ssh | 27 | | [ssh.pass] | `string` | | Optional param of a password to use for ssh | 28 | | [ssh.key] | `string` | | path to an optional client private key (connection will be password-less and insecure!) | 29 | | [ssh.config] | `string` | | Specifies an alternative ssh configuration file. For further details see "-F" option in ssh(1) | 30 | | [serverConf] | `Object` | | Server settings | 31 | | [serverConf.base] | `Object` | `'/wetty/'` | Server settings | 32 | | [serverConf.port] | `number` | `3000` | Port to run server on | 33 | | [serverConf.host] | `string` | `'0.0.0.0'` | Host address for server | 34 | | [serverConf.title] | `string` | `'WeTTY'` | Title of the server | 35 | | [serverConf.bypasshelmet] | `boolean` | `false` | if helmet should be disabled on the sever | 36 | | [command] | `string` | `"''"` | The command to execute. If running as root and no host specified this will be login if a host is specified will be ssh | 37 | | [forcessh] | `boolean` | `false` | Connecting through ssh even if running as root | 38 | | [ssl] | `Object` | | SSL settings | 39 | | [ssl.key] | `string` | | Path to ssl key | 40 | | [ssl.cert] | `string` | | Path to ssl cert | 41 | 42 | ### "connection" 43 | 44 | **Kind**: event emitted by [`WeTTy`](#module_WeTTy) 45 | **Properties** 46 | 47 | | Name | Type | Description | 48 | | ---- | -------- | --------------------------- | 49 | | msg | `string` | Message for logs | 50 | | date | `Date` | date and time of connection | 51 | 52 | ### "spawn" 53 | 54 | Terminal process spawned 55 | 56 | **Kind**: event emitted by [`WeTTy`](#module_WeTTy) 57 | **Properties** 58 | 59 | | Name | Type | Description | 60 | | ------- | -------- | -------------------------------------- | 61 | | msg | `string` | Message containing pid info and status | 62 | | pid | `number` | Pid of the terminal | 63 | | address | `string` | address of connecting user | 64 | 65 | ### "exit" 66 | 67 | Terminal process exits 68 | 69 | **Kind**: event emitted by [`WeTTy`](#module_WeTTy) 70 | **Properties** 71 | 72 | | Name | Type | Description | 73 | | ---- | -------- | -------------------------------------- | 74 | | code | `number` | the exit code | 75 | | msg | `string` | Message containing pid info and status | 76 | 77 | ### "disconnect" 78 | 79 | **Kind**: event emitted by [`WeTTy`](#module_WeTTy) 80 | 81 | ### "server" 82 | 83 | **Kind**: event emitted by [`WeTTy`](#module_WeTTy) 84 | **Properties** 85 | 86 | | Name | Type | Description | 87 | | ---------- | -------- | ------------------------------- | 88 | | msg | `string` | Message for logging | 89 | | port | `number` | port sever is on | 90 | | connection | `string` | connection type for web traffic | 91 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Docs 2 | 3 | ![WeTTY](./terminal.png?raw=true) 4 | 5 | - [AtoZ](./atoz.md) 6 | - [Running as daemon](./service.md) 7 | - [HTTPS Support](./https.md) 8 | - [Using NGINX](./nginx.md) 9 | - [Using Apache](./apache.md) 10 | - [Automatic Login](./auto-login.md) 11 | - [Downloading Files](./downloading-files.md) 12 | - [Development Docs](./development.md) 13 | 14 | ## API 15 | 16 | For WeTTY options and event details please refer to the [api docs](./API.md) 17 | 18 | ### Getting started 19 | 20 | WeTTY is event driven. To Spawn a new server call `wetty.start()` with no 21 | arguments. 22 | 23 | ```javascript 24 | import { start } from 'wetty'; 25 | 26 | start(/* server settings, see Options */) 27 | .then((wetty) => { 28 | console.log('server running'); 29 | wetty 30 | .on('exit', ({ code, msg }) => { 31 | console.log(`Exit with code: ${code} ${msg}`); 32 | }) 33 | .on('spawn', (msg) => console.log(msg)); 34 | /* code you want to execute */ 35 | }) 36 | .catch((err) => { 37 | console.error(err); 38 | }); 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/apache.md: -------------------------------------------------------------------------------- 1 | ## Run WeTTY behind nginx or apache 2 | 3 | As said earlier you can use a proxy to add https to WeTTY. 4 | 5 | **Note** that if your proxy is configured for https you should run WeTTY without 6 | SSL 7 | 8 | If your proxy uses a base path other than `/wetty`, specify the path with the 9 | `--base` flag, or the `BASE` environment variable. 10 | 11 | The following confs assume you want to serve WeTTY on the url 12 | `example.com/wetty` and are running WeTTY with the default base and serving it 13 | on the same server 14 | 15 | Put the following configuration in apache's conf: 16 | 17 | ```apache 18 | RewriteCond %{REQUEST_URI} ^/wetty/socket.io [NC] 19 | RewriteCond %{QUERY_STRING} transport=websocket [NC] 20 | RewriteRule /wetty/socket.io/(.*) ws://localhost:3000/wetty/socket.io/$1 [P,L] 21 | 22 | 23 | DirectorySlash On 24 | Require all granted 25 | ProxyPassMatch http://127.0.0.1:3000 26 | ProxyPassReverse /wetty/ 27 | 28 | ``` 29 | 30 | ## SAML2 integration to auth users 31 | 32 | This conf is using apache2 (as for nginx, SAML2 integration is not available on 33 | the community version, only pro). 34 | 35 | Main idea is to propagate the SAML2 validated user identity into the 36 | `remote-user` HTTP header. You need to have the user id returned within the 37 | SAML2 NameID matching the username defined on the platform WeTTY is running. 38 | 39 | E.g: You can ask the Idp to return a sAMAccountName within the SAML2Response 40 | NameID, and provision beforehand those allowed users on the OS WeTTY is running 41 | on. 42 | 43 | ### SAML2 Metadata generation 44 | 45 | SAML2 metadata needs to be generated for this new service on the server and 46 | exchanged with the Idp. We will use the script provided at 47 | https://raw.githubusercontent.com/bitsensor/saml-proxy/master/mellon_create_metadata.sh 48 | 49 | ``` 50 | $ mellon_create_metadata.sh urn:https://foo.bar.tlz https://foo.bar.tld/mellon 51 | ``` 52 | 53 | Then we move the generated files over `/etc/apache2/saml2/foo.{xml,key,cert}`. 54 | 55 | You need to put here additionally the metadata from your SAML2 provider, named 56 | here `idp.xml` and exchange you foo.xml with it. 57 | 58 | ### Apache2 conf 59 | 60 | ```apache 61 | 62 | ServerName foo.bar.tld 63 | ServerAdmin admin@bar.tld 64 | 65 | SSLEngine on 66 | SSLCertificateFile /etc/apache2/ssl/foo.pem 67 | SSLCertificateKeyFile /etc/apache2/ssl/foo.key 68 | 69 | RedirectMatch ^/$ /wetty/ 70 | ProxyPass "/wetty" "http://127.0.0.1:3000/wetty" 71 | 72 | 73 | AuthType Mellon 74 | MellonEnable info 75 | 76 | # this propagates to apache2 (and thus to access log) the proper user id, and not 77 | # the transient nameid that is taken by default 78 | # it has no impact on the backend as we propagate identify via remote-user header there 79 | MellonUser "NameID" 80 | 81 | MellonEndpointPath /mellon/ 82 | MellonSPMetadataFile /etc/apache2/saml2/foo.xml 83 | MellonSPPrivateKeyFile /etc/apache2/saml2/foo.key 84 | MellonSPCertFile /etc/apache2/saml2/foo.cert 85 | MellonIdPMetadataFile /etc/apache2/saml2/idp.xml 86 | 87 | # the identity propagated to WeTTY (as HTTP header 'remote-user: xxxxx') 88 | # is retrieved from SAMLResponse NameID attribute 89 | RequestHeader set remote-user %{MELLON_NAMEID}e 90 | 91 | 92 | 93 | AuthType Mellon 94 | MellonEnable auth 95 | Require valid-user 96 | 97 | 98 | # security hazard for switching between users, disabled if remote-user set as recent github commit 99 | # but not yet published via npm, so we put here a double security belt 100 | 101 | Deny from all 102 | 103 | 104 | ``` 105 | 106 | ### Auto login 107 | 108 | If you want to have a seamless login by trusting your IdP for authentication, 109 | you can create password-less users on the WeTTY platform and have them trust an 110 | SSH key used by the NodeJS, owned by the dedicated WeTTY OS user. 111 | 112 | WeTTY instantiation with proper parameters, especially the SSH private key is 113 | done via the following systemd service `/etc/systemd/system/wetty.service`: 114 | 115 | ``` 116 | [Unit] 117 | Description=WeTTY Web Terminal 118 | After=network.target 119 | 120 | [Service] 121 | User=wetty 122 | Type=simple 123 | WorkingDirectory=/home/wetty/.node_modules/wetty/ 124 | ExecStart=/usr/bin/node . -p 3000 --host 127.0.0.1 --ssh-key /home/wetty/.ssh/wetty --ssh-auth publickey --force-ssh --title "Foo bar terminal services" 125 | TimeoutStopSec=20 126 | KillMode=mixed 127 | Restart=always 128 | RestartSec=2 129 | 130 | [Install] 131 | WantedBy=multi-user.target 132 | ``` 133 | 134 | For your new users to be automatically trusting this SSH key when provisioning, 135 | you may add the pubkey to `/etc/skel/.ssh/authorized_keys`. 136 | 137 | ### Security precautions 138 | 139 | You probably don't want local users to impersonate each other, for that you need 140 | to make sure that: 141 | 142 | 1. NodeJS is listening only to localhost: provided by `wetty.service` 143 | 2. **Only** the apache2 process can join the WeTTY port. Else local users will 144 | be able to connect and forge a `remote-user` header: provided by 145 | `iptables -A OUTPUT -o lo -p tcp --dport 3000 -m owner \! --uid-owner www-data -j DROP` 146 | 3. Validate your WeTTY version does not allow access to `/wetty/ssh/` else again 147 | you will be able to impersonate anyone: provided by either: 148 | 1. WeTTY version 2.0.3 and beyond implements this by disabling this feature 149 | in case of `remote-user` presence 150 | 2. apache2 conf as provided in previous section (containing the 151 | ``) 152 | -------------------------------------------------------------------------------- /docs/assets/css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --content-max-width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /docs/assets/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/docs/assets/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/assets/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/docs/assets/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /docs/assets/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/docs/assets/img/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/assets/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/docs/assets/img/favicon-16x16.png -------------------------------------------------------------------------------- /docs/assets/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/docs/assets/img/favicon-32x32.png -------------------------------------------------------------------------------- /docs/assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/docs/assets/img/favicon.ico -------------------------------------------------------------------------------- /docs/assets/img/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /docs/auto-login.md: -------------------------------------------------------------------------------- 1 | # Auto Login 2 | 3 | WeTTY Supports a form of auto login by passing a users password though url 4 | params. 5 | 6 | This is not a required feature and the security implications for passing the 7 | password in the url will have to be considered by the user. 8 | 9 | ## Requirements 10 | 11 | For auto-login feature you'll need sshpass installed 12 | 13 | - `apt-get install sshpass` (debian eg. Ubuntu) 14 | - `yum install sshpass` (red hat flavours eg. CentOs) 15 | 16 | ## Usage 17 | 18 | You can also pass the ssh password as an optional query parameter to auto-login 19 | the user like this (Only while running WeTTY as a non root account or when 20 | specifying the ssh host): 21 | 22 | `http://yourserver:3000/wetty/ssh/?pass=` 23 | -------------------------------------------------------------------------------- /docs/development.md: -------------------------------------------------------------------------------- 1 | # Installation from Source 2 | 3 | WeTTY can be installed from source or from npm. 4 | 5 | To install from source run: 6 | 7 | ```bash 8 | $ git clone https://github.com/butlerx/wetty.git 9 | $ cd wetty 10 | $ pnpm install 11 | $ pnpm build 12 | ``` 13 | 14 | ## Development Env 15 | 16 | To run WeTTY in dev mode you can run `pnpm dev`. 17 | 18 | WeTTY will then be served from `http://localhost:3000/wetty` on your machine. 19 | 20 | The server will be using the [`conf/config.json5`](../conf/config.json5) config 21 | file and be pointing at `localhost` on port `22` . 22 | 23 | The Dev server will rebuild WeTTY when ever a file is edited and restart the 24 | server with the new build. Any current ssh session in WeTTY will be killed and 25 | the user logged out. 26 | -------------------------------------------------------------------------------- /docs/docker.md: -------------------------------------------------------------------------------- 1 | # Dockerized Version 2 | 3 | WeTTY can be run from a container to ssh to a remote host or the host system. 4 | This is handy for quick deployments. Just modify `docker-compose.yml` for your 5 | host and run: 6 | 7 | ```sh 8 | $ docker-compose up -d 9 | ``` 10 | 11 | This will start 2 containers, one will be WeTTY container running ssh client the 12 | other will be a container running ssh server. 13 | 14 | Visit the appropriate URL in your browser 15 | (`[localhost|$(boot2docker ip)]:PORT`). 16 | 17 | The default username is `term` and the password is `term`, if you did not modify 18 | `SSHHOST` 19 | 20 | In the docker version all flags can be accessed as environment variables such as 21 | `SSHHOST` or `SSHPORT`. 22 | 23 | If you don't want to build the image yourself just remove the line `build; .` 24 | 25 | If you wish to use the WeTTY container in prod just modify the WeTTY container 26 | to have `SSHHOST` point to the server you want to ssh to and remove the ssh 27 | server container. 28 | -------------------------------------------------------------------------------- /docs/downloading-files.md: -------------------------------------------------------------------------------- 1 | # File Downloading 2 | 3 | WeTTY supports file downloads by printing terminal escape sequences between a 4 | base64 encoded file. The name of the downloaded file can optionally be provided, 5 | also base64 encoded, before the encoded file contents with a `:` separating them. 6 | 7 | The terminal escape sequences used are `^[[5i` and `^[[4i` (VT100 for "enter 8 | auto print" and "exit auto print" respectively - 9 | https://vt100.net/docs/tp83/appendixc.html). 10 | 11 | To take advantage add the following bash function to your `.bashrc` 12 | 13 | ```bash 14 | function wetty-download() { 15 | file=${1:-/dev/stdin} 16 | 17 | nameprefix="" 18 | if [[ -f "$file" ]]; then 19 | nameprefix="$(basename "$file" | base64 -w 0):" 20 | fi 21 | 22 | 23 | if [[ -f "$file" || "$file" == "/dev/stdin" ]]; then 24 | printf "\033[5i"$nameprefix$(cat "$file" | base64 -w 0)"\033[4i" 25 | else 26 | echo "$file does not appear to be a file" 27 | fi 28 | } 29 | ``` 30 | 31 | You are then able to download files via WeTTY! 32 | 33 | ```bash 34 | wetty-download my-pdf-file.pdf 35 | ``` 36 | 37 | or you can still use the classic style: 38 | 39 | ```bash 40 | $ cat my-pdf-file.pdf | wetty-download 41 | ``` 42 | 43 | WeTTY will then issue a popup like the following that links to a local file 44 | blob: `Download ready: file-20191015233654.pdf` 45 | -------------------------------------------------------------------------------- /docs/flags.md: -------------------------------------------------------------------------------- 1 | # Flags 2 | 3 | WeTTY can be run with the `--help` flag to get a full list of flags. 4 | 5 | ## Server Port 6 | 7 | WeTTY runs on port `3000` by default. You can change the default port by 8 | starting with the `--port` or `-p` flag. 9 | 10 | ## SSH Host 11 | 12 | If WeTTY is run as root while the host is set as the local machine it will use 13 | the `login` binary rather than ssh. If no host is specified it will use 14 | `localhost` as the ssh host. 15 | 16 | If instead you wish to connect to a remote host you can specify the host with 17 | the `--ssh-host` flag and pass the IP or DNS address of the host you want to 18 | connect to. 19 | 20 | ## Default User 21 | 22 | You can specify the default user used to ssh to a host using the `--ssh-user`. 23 | This user can overwritten by going to 24 | `http://yourserver:3000/wetty/ssh/`. If this is left blank a user will 25 | be prompted to enter their username when they connect. 26 | 27 | ## SSH Port 28 | 29 | By default WeTTY will try to ssh to port `22`, if your host uses an alternative 30 | ssh port this can be specified with the flag `--ssh-port`. 31 | 32 | ## WeTTY URL 33 | 34 | If you'd prefer an HTTP base prefix other than `/wetty`, you can specify that 35 | with `--base`. 36 | 37 | **Do not set this to `/ssh/${something}`, as this will break username matching 38 | code.** 39 | -------------------------------------------------------------------------------- /docs/https.md: -------------------------------------------------------------------------------- 1 | # HTTPS 2 | 3 | Always use HTTPS especially with a terminal to your server. You can add HTTPS by 4 | either using WeTTY behind a proxy or directly. 5 | 6 | See docs for [NGinX](./nginx.md) and [Apache](./apache.md) for running behind a 7 | proxy. 8 | 9 | To run WeTTY directly with SSL use both the `--ssl-key` and `--ssl-cert` flags 10 | and pass them the path too your cert and key as follows: 11 | 12 | ```bash 13 | wetty --ssl-key key.pem --ssl-cert cert.pem 14 | ``` 15 | 16 | If you don't have SSL certificates from a CA you can create a self signed 17 | certificate using this command: 18 | 19 | ```bash 20 | openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 30000 -nodes 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | WeTTY = Web + TTY 12 | 13 | 18 | 24 | 30 | 31 | 32 | 33 | 34 | 38 | 39 | 40 | 41 | 42 | 43 | 44 |
Please wait...
45 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /docs/nginx.md: -------------------------------------------------------------------------------- 1 | ## Run WeTTY behind nginx 2 | 3 | As said earlier you can use Nginx to add https to WeTTY. 4 | 5 | **Note** that if your proxy is configured for https you should run WeTTY without 6 | SSL 7 | 8 | If you configure nginx to use a base path other than `/wetty`, then specify that 9 | path with the `--base` flag, or the `BASE` environment variable. 10 | 11 | The following confs assume you want to serve WeTTY on the url 12 | `example.com/wetty` and are running WeTTY with the default base and serving it 13 | on the same server 14 | 15 | For a more detailed look see the 16 | [nginx.conf](https://github.com/butlerx/wetty/blob/main/conf/nginx.template) 17 | used for testing 18 | 19 | Put the following configuration in your nginx conf: 20 | 21 | ```nginx 22 | location ^~ /wetty { 23 | proxy_pass http://127.0.0.1:3000/wetty; 24 | proxy_http_version 1.1; 25 | proxy_set_header Upgrade $http_upgrade; 26 | proxy_set_header Connection "upgrade"; 27 | proxy_read_timeout 43200000; 28 | 29 | proxy_set_header X-Real-IP $remote_addr; 30 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 31 | proxy_set_header Host $http_host; 32 | proxy_set_header X-NginX-Proxy true; 33 | } 34 | ``` 35 | -------------------------------------------------------------------------------- /docs/service.md: -------------------------------------------------------------------------------- 1 | ## Run WeTTY as a service daemon 2 | 3 | WeTTY can be run as a daemon on your service init confs and systemd services are 4 | bundled with the npm package to make this easier. 5 | 6 | ### init.d 7 | 8 | ```bash 9 | $ npm -g i wetty 10 | $ sudo cp ~/.node_modules/wetty/conf/wetty.conf /etc/init 11 | $ sudo start wetty 12 | ``` 13 | 14 | ### systemd 15 | 16 | ```bash 17 | $ yarn global add wetty 18 | $ cp ~/.node_modules/wetty/conf/wetty.service ~/.config/systemd/user/ 19 | $ systemctl --user enable wetty 20 | $ systemctl --user start wetty 21 | ``` 22 | 23 | This will start WeTTY on port 3000. If you want to change the port or redirect 24 | stdout/stderr you should change the last line in `wetty.conf` file, something 25 | like this: 26 | 27 | ```systemd 28 | exec sudo -u root wetty -p 80 >> /var/log/wetty.log 2>&1 29 | ``` 30 | 31 | Systemd requires an absolute path for a unit's WorkingDirectory, consequently 32 | `$HOME` will need updating to an absolute path in the `wetty.service` file. 33 | -------------------------------------------------------------------------------- /docs/sidebar.md: -------------------------------------------------------------------------------- 1 | - [Home](README.md) 2 | - [Apache](apache.md) 3 | - [API](API.md) 4 | - [AtoZ](atoz.md) 5 | - [auto-login](auto-login.md) 6 | - [development](development.md) 7 | - [downloading-files](downloading-files.md) 8 | - [flags](flags.md) 9 | - [https](https.md) 10 | - [nginx](nginx.md) 11 | - [service](service.md) 12 | -------------------------------------------------------------------------------- /docs/terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/docs/terminal.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wetty", 3 | "version": "2.7.0", 4 | "description": "WeTTY = Web + TTY. Terminal access in browser over http/https", 5 | "homepage": "https://github.com/butlerx/wetty", 6 | "license": "MIT", 7 | "type": "module", 8 | "bin": "./build/main.js", 9 | "main": "./build/main.js", 10 | "exports": "./build/server.js", 11 | "files": [ 12 | "build/", 13 | "conf/" 14 | ], 15 | "scripts": { 16 | "build": "node build.js", 17 | "clean": "rm -rf build", 18 | "contributor": "all-contributors", 19 | "dev": "NODE_ENV=development concurrently --kill-others --raw --success first \"pnpm build --watch\" \"nodemon -w build -i build/client -w conf/config.json5 --delay 200ms . -- --conf conf/config.json5\"", 20 | "docker-compose-entrypoint": "ssh-keyscan -H wetty-ssh >> ~/.ssh/known_hosts; pnpm start", 21 | "lint": "eslint src", 22 | "lint:fix": "eslint --fix src", 23 | "start": "NODE_ENV=production node .", 24 | "test": "mocha", 25 | "prepare": "husky install" 26 | }, 27 | "repository": "git://github.com/butlerx/wetty.git", 28 | "author": "Cian Butler (cianbutler.ie)", 29 | "bugs": { 30 | "url": "https://github.com/butlerx/wetty/issues" 31 | }, 32 | "lint-staged": { 33 | "*.{js,ts}": [ 34 | "eslint --fix" 35 | ], 36 | "*.{json,scss,md}": [ 37 | "prettier --write" 38 | ] 39 | }, 40 | "engines": { 41 | "node": ">=18.0.0" 42 | }, 43 | "dependencies": { 44 | "@fortawesome/fontawesome-svg-core": "^6.1.2", 45 | "@fortawesome/free-solid-svg-icons": "^6.1.2", 46 | "@xterm/xterm": "^5.2.0", 47 | "@xterm/addon-fit": "^0.10.0", 48 | "@xterm/addon-image": "^0.8.0", 49 | "@xterm/addon-web-links": "^0.11.0", 50 | "compression": "^1.7.4", 51 | "etag": "^1.8.1", 52 | "express": "^4.17.1", 53 | "express-winston": "^4.0.5", 54 | "file-type": "^12.3.0", 55 | "find-up": "^5.0.0", 56 | "fresh": "^0.5.2", 57 | "fs-extra": "^9.0.1", 58 | "gc-stats": "^1.4.0", 59 | "helmet": "^4.1.0", 60 | "json5": "^2.1.3", 61 | "lodash": "^4.17.20", 62 | "node-gyp": "^9.1.0", 63 | "node-pty": "^0.10.0", 64 | "parseurl": "^1.3.3", 65 | "prom-client": "^14.0.1", 66 | "response-time": "^2.3.2", 67 | "sass": "^1.54.4", 68 | "serve-static": "^1.15.0", 69 | "socket.io": "^4.5.1", 70 | "socket.io-client": "^4.5.1", 71 | "toastify-js": "^1.9.1", 72 | "url-value-parser": "^2.1.0", 73 | "winston": "^3.3.3", 74 | "yargs": "^17.7.2" 75 | }, 76 | "devDependencies": { 77 | "@snowpack/plugin-run-script": "^2.3.0", 78 | "@types/chai": "^4.3.1", 79 | "@types/compression": "^1.7.0", 80 | "@types/etag": "^1.8.0", 81 | "@types/express": "^4.17.8", 82 | "@types/fresh": "^0.5.0", 83 | "@types/fs-extra": "^9.0.1", 84 | "@types/gc-stats": "^1", 85 | "@types/helmet": "^0.0.48", 86 | "@types/jsdom": "^12.2.4", 87 | "@types/lodash": "^4.14.161", 88 | "@types/mocha": "^9.1.1", 89 | "@types/morgan": "^1.7.37", 90 | "@types/node": "^20.2.6", 91 | "@types/parseurl": "^1.3.1", 92 | "@types/response-time": "^2", 93 | "@types/serve-static": "^1.15.3", 94 | "@types/sinon": "^10.0.13", 95 | "@types/toastify-js": "^1.9.2", 96 | "@types/yargs": "^17.0.24", 97 | "@typescript-eslint/eslint-plugin": "^5.59.9", 98 | "@typescript-eslint/parser": "^5.59.9", 99 | "all-contributors-cli": "^6.17.2", 100 | "chai": "^4.3.6", 101 | "concurrently": "^8.2.2", 102 | "esbuild": "^0.21.5", 103 | "esbuild-plugin-copy": "^2.1.1", 104 | "esbuild-sass-plugin": "^3.3.1", 105 | "eslint": "^8.36.0", 106 | "eslint-config-airbnb-base": "latest", 107 | "eslint-config-prettier": "^8.6.0", 108 | "eslint-import-resolver-typescript": "^3.4.0", 109 | "eslint-plugin-import": "^2.27.5", 110 | "eslint-plugin-mocha": "^10.1.0", 111 | "eslint-plugin-prettier": "^4.2.1", 112 | "git-authors-cli": "^1.0.42", 113 | "husky": "^9.0.11", 114 | "jsdom": "^16.5.0", 115 | "lint-staged": "^13.2.2", 116 | "mocha": "^10.0.0", 117 | "nodemon": "^3.1.4", 118 | "prettier": "^2.5.1", 119 | "sinon": "^14.0.0", 120 | "ts-node": "^10.9.1", 121 | "typescript": "^5.1.3" 122 | }, 123 | "contributors": [ 124 | "butlerx ", 125 | "Krishna Srinivas ", 126 | "userdocs <16525024+userdocs@users.noreply.github.com>", 127 | "Boyan Rabchev ", 128 | "Ben Letchford ", 129 | "Antonio Calatrava ", 130 | "Strubbl ", 131 | "Oleg Kurapov ", 132 | "Anthony Jund ", 133 | "Kyle Lucy ", 134 | "Luca Milanesio ", 135 | "nosemeocurrenada ", 136 | "cbutler ", 137 | "Henri ", 138 | "Imuli ", 139 | "Janos Kasza ", 140 | "mirtouf ", 141 | "Koushik M.L.N ", 142 | "Denis Kramer ", 143 | "Harrison Pace ", 144 | "Jarrett Gilliam ", 145 | "Nathan LeClaire ", 146 | "SouraDutta <33066261+SouraDutta@users.noreply.github.com>", 147 | "Aayush Garg-gamer1478 <74775129+gamer-12748@users.noreply.github.com>", 148 | "Bertrand Roussel ", 149 | "Christian7573 ", 150 | "Dean Shub ", 151 | "Dmytri Kleiner ", 152 | "Felix Bartels ", 153 | "Felix Pojtinger ", 154 | "Josua Frank ", 155 | "Georgelemental ", 156 | "Loz Brown ", 157 | "Grant Handy ", 158 | "harryleesan ", 159 | "Andreas Kloeckner ", 160 | "James Turnbull ", 161 | "Arturo R ", 162 | "Josh Samuelson ", 163 | "Jurre Vriesen ", 164 | "Kevin ", 165 | "Farhan Khan ", 166 | "Kasper Holbek Jensen ", 167 | "Krzysztof Suszyński ", 168 | "justluk ", 169 | "Mathieu Geli ", 170 | "Mihir Kumar ", 171 | "Neale Pickett ", 172 | "pablo-zarate ", 173 | "Matthew Piercey ", 174 | "Alex Cline ", 175 | "Robert ", 176 | "Sergei Ratnikov ", 177 | "Shimi ", 178 | "Sven Fischer ", 179 | "Taha ", 180 | "Tri Nguyen ", 181 | "Vamshi K Ponnapalli " 182 | ], 183 | "packageManager": "pnpm@9.4.0+sha512.f549b8a52c9d2b8536762f99c0722205efc5af913e77835dbccc3b0b0b2ca9e7dc8022b78062c17291c48e88749c70ce88eb5a74f1fa8c4bf5e18bb46c8bd83a" 184 | } 185 | -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/butlerx/wetty/20923e1bd02d64ab31c34ec77c2617d195b23318/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/scss/options.scss: -------------------------------------------------------------------------------- 1 | @use './variables'; 2 | 3 | #options { 4 | height: 16px; 5 | position: absolute; 6 | right: 1em; 7 | top: 1em; 8 | width: 16px; 9 | z-index: 20; 10 | 11 | .toggler { 12 | color: variables.$lgrey; 13 | display: inline-block; 14 | font-size: 16px; 15 | position: absolute; 16 | right: 1em; 17 | top: 0; 18 | z-index: 20; 19 | 20 | :hover { 21 | color: variables.$white; 22 | } 23 | } 24 | 25 | .editor { 26 | background-color: rgba(0, 0, 0, 0.85); 27 | border-color: rgba(255, 255, 255, 0.25); 28 | border-radius: 0.3em; 29 | color: #eee; 30 | display: none; 31 | font-size: 24px; 32 | height: 100%; 33 | padding: 0.5em; 34 | position: relative; 35 | right: 2em; 36 | top: 1em; 37 | width: 100%; 38 | } 39 | } 40 | 41 | #options.opened { 42 | height: max(min(300px, 75vh), 50vh); 43 | width: max(min(500px, 75vw), 50vw); 44 | 45 | .editor { 46 | display: flex; 47 | } 48 | 49 | .error { 50 | color: red; 51 | } 52 | } 53 | 54 | #functions { 55 | position: fixed; 56 | right: 2em; 57 | top: 6em; 58 | z-index: 20; 59 | 60 | > a { 61 | padding: 10px; 62 | position: absolute; 63 | right: -10px; 64 | top: -40px; 65 | color: variables.$lgrey; 66 | 67 | :hover { 68 | color: variables.$white; 69 | } 70 | } 71 | 72 | .onscreen-buttons { 73 | display: none; 74 | width: 200px; 75 | height: 200px; 76 | border: solid 2px rgba(255, 255, 255, 0.25); 77 | border-radius: 0.3em; 78 | background-color: rgba(0, 0, 0, 0.85); 79 | > a { 80 | bottom: 1em; 81 | right: 2em; 82 | text-decoration: none; 83 | color: white; 84 | > div { 85 | padding: 5px; 86 | outline: 2px solid white; 87 | margin: 10px; 88 | display: inline-block; 89 | font-weight: bold; 90 | border-radius: 10px; 91 | } 92 | } 93 | } 94 | 95 | .active { 96 | display: block; 97 | } 98 | 99 | .onscreen-buttons > a:active { 100 | > div { 101 | background-color: rgba(255, 255, 255, 0.25); 102 | } 103 | } 104 | 105 | #onscreen-ctrl.active { 106 | display: inline-block; 107 | > div { 108 | background-color: lightgray; 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/assets/scss/overlay.scss: -------------------------------------------------------------------------------- 1 | @use './variables'; 2 | 3 | #overlay { 4 | background-color: variables.$grey; 5 | display: none; 6 | height: 100%; 7 | position: absolute; 8 | width: 100%; 9 | z-index: 100; 10 | 11 | .error { 12 | display: flex; 13 | flex-direction: column; 14 | height: 100%; 15 | justify-content: center; 16 | width: 100%; 17 | 18 | #msg { 19 | align-self: center; 20 | color: variables.$white; 21 | } 22 | 23 | input { 24 | align-self: center; 25 | margin: 16px; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/scss/styles.scss: -------------------------------------------------------------------------------- 1 | @use '@xterm/xterm/css/xterm.css'; 2 | @use 'toastify-js/src/toastify.css'; 3 | @use './variables'; 4 | @use './overlay'; 5 | @use './options'; 6 | @use './terminal'; 7 | 8 | html, 9 | body { 10 | background-color: variables.$black; 11 | height: 100%; 12 | margin: 0; 13 | overflow: hidden; 14 | 15 | .toastify { 16 | border-radius: 0; 17 | color: variables.$black; 18 | } 19 | } 20 | 21 | .xterm { 22 | .xterm-viewport { 23 | overflow-y: hidden; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/assets/scss/terminal.scss: -------------------------------------------------------------------------------- 1 | #terminal { 2 | display: flex; 3 | height: 100%; 4 | position: relative; 5 | width: 100%; 6 | } 7 | -------------------------------------------------------------------------------- /src/assets/scss/variables.scss: -------------------------------------------------------------------------------- 1 | $black: #000; 2 | $grey: rgba(0, 0, 0, 0.75); 3 | $white: #fff; 4 | $lgrey: #ccc; 5 | -------------------------------------------------------------------------------- /src/assets/xterm_config/functionality.js: -------------------------------------------------------------------------------- 1 | function optionGenericGet() { 2 | return this.el.querySelector('input').value; 3 | } 4 | function optionGenericSet(value) { 5 | this.el.querySelector('input').value = value; 6 | } 7 | function optionEnumGet() { 8 | return this.el.querySelector('select').value; 9 | } 10 | function optionEnumSet(value) { 11 | this.el.querySelector('select').value = value; 12 | } 13 | function optionBoolGet() { 14 | return this.el.querySelector('input').checked; 15 | } 16 | function optionBoolSet(value) { 17 | this.el.querySelector('input').checked = value; 18 | } 19 | function optionNumberGet() { 20 | let value = (this.float === true ? parseFloat : parseInt)( 21 | this.el.querySelector('input').value, 22 | ); 23 | if (Number.isNaN(value) || typeof value !== 'number') value = 0; 24 | if (typeof this.min === 'number') value = Math.max(value, this.min); 25 | if (typeof this.max === 'number') value = Math.min(value, this.max); 26 | return value; 27 | } 28 | function optionNumberSet(value) { 29 | this.el.querySelector('input').value = value; 30 | } 31 | 32 | const allOptions = []; 33 | /* eslint-disable @typescript-eslint/no-unused-vars */ 34 | 35 | function inflateOptions(optionsSchema) { 36 | const booleanOption = document.querySelector('#boolean_option.templ'); 37 | const enumOption = document.querySelector('#enum_option.templ'); 38 | const textOption = document.querySelector('#text_option.templ'); 39 | const numberOption = document.querySelector('#number_option.templ'); 40 | const colorOption = document.querySelector('#color_option.templ'); 41 | 42 | function copyOver({ children }) { 43 | while (children.length > 0) document.body.append(children[0]); 44 | } 45 | 46 | optionsSchema.forEach(option => { 47 | let el; 48 | option.get = optionGenericGet.bind(option); 49 | option.set = optionGenericSet.bind(option); 50 | 51 | switch (option.type) { 52 | case 'boolean': 53 | el = booleanOption.cloneNode(true); 54 | option.get = optionBoolGet.bind(option); 55 | option.set = optionBoolSet.bind(option); 56 | break; 57 | 58 | case 'enum': 59 | el = enumOption.cloneNode(true); 60 | option.enum.forEach(varriant => { 61 | const optionEl = document.createElement('option'); 62 | optionEl.innerText = varriant; 63 | optionEl.value = varriant; 64 | el.querySelector('select').appendChild(optionEl); 65 | }); 66 | option.get = optionEnumGet.bind(option); 67 | option.set = optionEnumSet.bind(option); 68 | break; 69 | 70 | case 'text': 71 | el = textOption.cloneNode(true); 72 | break; 73 | 74 | case 'number': 75 | el = numberOption.cloneNode(true); 76 | if (option.float === true) 77 | el.querySelector('input').setAttribute('step', '0.001'); 78 | option.get = optionNumberGet.bind(option); 79 | option.set = optionNumberSet.bind(option); 80 | if (typeof option.min === 'number') 81 | el.querySelector('input').setAttribute('min', option.min.toString()); 82 | if (typeof option.max === 'number') 83 | el.querySelector('input').setAttribute('max', option.max.toString()); 84 | break; 85 | 86 | case 'color': 87 | el = colorOption.cloneNode(true); 88 | break; 89 | 90 | default: 91 | throw new Error(`Unknown option type ${option.type}`); 92 | } 93 | 94 | el.querySelector('.title').innerText = option.name; 95 | el.querySelector('.desc').innerText = option.description; 96 | [option.el] = el.children; 97 | copyOver(el); 98 | allOptions.push(option); 99 | }); 100 | } 101 | 102 | function getItem(json, path) { 103 | const mypath = path[0]; 104 | if (path.length === 1) return json[mypath]; 105 | if (json[mypath] != null) return getItem(json[mypath], path.slice(1)); 106 | return null; 107 | } 108 | function setItem(json, path, item) { 109 | const mypath = path[0]; 110 | if (path.length === 1) json[mypath] = item; 111 | else { 112 | if (json[mypath] == null) json[mypath] = {}; 113 | setItem(json[mypath], path.slice(1), item); 114 | } 115 | } 116 | 117 | window.loadOptions = config => { 118 | allOptions.forEach(option => { 119 | let value = getItem(config, option.path); 120 | if (option.nullable === true && option.type === 'text' && value == null) 121 | value = null; 122 | else if ( 123 | option.nullable === true && 124 | option.type === 'number' && 125 | value == null 126 | ) 127 | value = -1; 128 | else if (value == null) return; 129 | if (option.json === true && option.type === 'text') 130 | value = JSON.stringify(value); 131 | option.set(value); 132 | option.el.classList.remove('unbounded'); 133 | }); 134 | }; 135 | 136 | if (window.top === window) 137 | // eslint-disable-next-line no-alert 138 | alert( 139 | 'Error: Page is top level. This page is supposed to be accessed from inside WeTTY.', 140 | ); 141 | 142 | function saveConfig() { 143 | const newConfig = {}; 144 | allOptions.forEach(option => { 145 | let newValue = option.get(); 146 | if ( 147 | option.nullable === true && 148 | ((option.type === 'text' && newValue === '') || 149 | (option.type === 'number' && newValue < 0)) 150 | ) 151 | return; 152 | if (option.json === true && option.type === 'text') 153 | newValue = JSON.parse(newValue); 154 | setItem(newConfig, option.path, newValue); 155 | }); 156 | window.wetty_save_config(newConfig); 157 | } 158 | 159 | window.addEventListener('input', () => { 160 | const els = document.querySelectorAll('input, select'); 161 | for (let i = 0; i < els.length; i += 1) { 162 | els[i].addEventListener('input', saveConfig); 163 | } 164 | }); 165 | -------------------------------------------------------------------------------- /src/assets/xterm_config/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Wetty XTerm Configuration 5 | 6 | 7 | 8 |
9 |

Configure

10 |
11 | 12 |
13 |
14 |

15 |
16 | 17 |

18 | 19 |
20 |
21 |
22 |
23 |

24 |
25 | 26 |

27 | 28 |
29 |
30 |
31 |
32 |

33 |
34 | 35 |

36 | 37 |
38 |
39 |
40 |
41 |
42 |

43 |
44 | 45 |

46 | 47 |
48 |
49 |
50 |
51 |
52 |

53 |
54 | 55 |

56 | 57 |
58 |
59 | 60 | 61 | 62 |

General Options

63 | 64 |

Color Theme

65 | 66 |

Advanced XTerm Options

67 | 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /src/assets/xterm_config/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | background-color: black; 3 | } 4 | html, 5 | body { 6 | overflow: hidden auto; 7 | } 8 | body { 9 | display: flex; 10 | flex-flow: column nowrap; 11 | font-family: monospace; 12 | font-size: 1rem; 13 | color: white; 14 | } 15 | .templ { 16 | display: none; 17 | } 18 | h2 { 19 | text-align: center; 20 | text-decoration: underline; 21 | } 22 | 23 | header { 24 | display: flex; 25 | flex-flow: row nowrap; 26 | align-items: center; 27 | } 28 | header button { 29 | padding: 0.5em; 30 | font-size: 1em; 31 | margin: 0.5em; 32 | border-radius: 0.5em; 33 | } 34 | 35 | .boolean_option, 36 | .number_option, 37 | .color_option, 38 | .enum_option, 39 | .text_option { 40 | display: grid; 41 | grid-template-columns: 100fr min(30em, 50%); 42 | grid-template-rows: auto; 43 | align-items: center; 44 | } 45 | .boolean_option input, 46 | .number_option input, 47 | .color_option input, 48 | .text_option input, 49 | .enum_option select { 50 | margin: 0 0.5em; 51 | font-size: 1em; 52 | background-color: hsl(0, 0%, 20%); 53 | color: white; 54 | border: 2px solid white; 55 | } 56 | 57 | .number_option input, 58 | .text_option input, 59 | .enum_option select { 60 | padding: 0.4em; 61 | } 62 | .boolean_option input { 63 | width: 2em; 64 | height: 2em; 65 | font-size: 0.75em; 66 | justify-self: center; 67 | } 68 | .color_option input { 69 | width: 100%; 70 | height: 100%; 71 | background-color: lightgray; 72 | } 73 | 74 | .unbounded .title::before { 75 | content: 'UNBOUND OPTION '; 76 | color: red; 77 | font-weight: bold; 78 | } 79 | -------------------------------------------------------------------------------- /src/assets/xterm_config/xterm_advanced_options.js: -------------------------------------------------------------------------------- 1 | window.inflateOptions([ 2 | { 3 | type: 'boolean', 4 | name: 'Allow Proposed XTerm APIs', 5 | description: 6 | 'When set to false, any experimental/proposed APIs will throw errors.', 7 | path: ['xterm', 'allowProposedApi'], 8 | }, 9 | { 10 | type: 'boolean', 11 | name: 'Allow Transparent Background', 12 | description: 'Whether the background is allowed to be a non-opaque color.', 13 | path: ['xterm', 'allowTransparency'], 14 | }, 15 | { 16 | type: 'text', 17 | name: 'Bell Sound URI', 18 | description: 'URI for a custom bell character sound.', 19 | path: ['xterm', 'bellSound'], 20 | nullable: true, 21 | }, 22 | { 23 | type: 'enum', 24 | name: 'Bell Style', 25 | description: 'How the terminal will react to the bell character', 26 | path: ['xterm', 'bellStyle'], 27 | enum: ['none', 'sound'], 28 | }, 29 | { 30 | type: 'boolean', 31 | name: 'Force End-Of-Line', 32 | description: 33 | 'When enabled, any new-line characters (\\n) will be interpreted as carriage-return new-line. (\\r\\n) Typically this is done by the shell program.', 34 | path: ['xterm', 'convertEol'], 35 | }, 36 | { 37 | type: 'boolean', 38 | name: 'Disable Stdin', 39 | description: 'Whether input should be disabled', 40 | path: ['xterm', 'disableStdin'], 41 | }, 42 | { 43 | type: 'number', 44 | name: 'Letter Spacing', 45 | description: 'The spacing in whole pixels between characters.', 46 | path: ['xterm', 'letterSpacing'], 47 | }, 48 | { 49 | type: 'number', 50 | name: 'Line Height', 51 | description: 52 | 'Line height, multiplied by the font size to get the height of terminal rows.', 53 | path: ['xterm', 'lineHeight'], 54 | float: true, 55 | }, 56 | { 57 | type: 'enum', 58 | name: 'XTerm Log Level', 59 | description: 'Log level for the XTerm library.', 60 | path: ['xterm', 'logLevel'], 61 | enum: ['debug', 'info', 'warn', 'error', 'off'], 62 | }, 63 | { 64 | type: 'boolean', 65 | name: 'Macintosh Option Key as Meta Key', 66 | description: 67 | 'When enabled, the Option key on Macs will be interpreted as the Meta key.', 68 | path: ['xterm', 'macOptionIsMeta'], 69 | }, 70 | { 71 | type: 'boolean', 72 | name: 'Macintosh Option Click Forces Selection', 73 | description: 74 | "Whether holding a modifier key will force normal selection behavior, regardless of whether the terminal is in mouse events mode. This will also prevent mouse events from being emitted by the terminal. For example, this allows you to use xterm.js' regular selection inside tmux with mouse mode enabled.", 75 | path: ['xterm', 'macOptionClickForcesSelection'], 76 | }, 77 | { 78 | type: 'number', 79 | name: 'Forced Contrast Ratio', 80 | description: 81 | 'Miminum contrast ratio for terminal text. This will alter the foreground color dynamically to ensure the ratio is met. Goes from 1 (do nothing) to 21 (strict black and white).', 82 | path: ['xterm', 'minimumContrastRatio'], 83 | float: true, 84 | }, 85 | { 86 | type: 'enum', 87 | name: 'Renderer Type', 88 | description: 89 | 'The terminal renderer to use. Canvas is preferred, but a DOM renderer is also available. Note: Letter spacing and cursor blink do not work in the DOM renderer.', 90 | path: ['xterm', 'rendererType'], 91 | enum: ['canvas', 'dom'], 92 | }, 93 | { 94 | type: 'boolean', 95 | name: 'Right Click Selects Words', 96 | description: 'Whether to select the word under the cursor on right click.', 97 | path: ['xterm', 'rightClickSelectsWord'], 98 | }, 99 | { 100 | type: 'boolean', 101 | name: 'Screen Reader Support', 102 | description: 103 | 'Whether screen reader support is enabled. When on this will expose supporting elements in the DOM to support NVDA on Windows and VoiceOver on macOS.', 104 | path: ['xterm', 'screenReaderMode'], 105 | }, 106 | { 107 | type: 'number', 108 | name: 'Tab Stop Width', 109 | description: 'The size of tab stops in the terminal.', 110 | path: ['xterm', 'tabStopWidth'], 111 | }, 112 | { 113 | type: 'boolean', 114 | name: 'Windows Mode', 115 | description: 116 | "\"Whether 'Windows mode' is enabled. Because Windows backends winpty and conpty operate by doing line wrapping on their side, xterm.js does not have access to wrapped lines. When Windows mode is enabled the following changes will be in effect:\n- Reflow is disabled.\n- Lines are assumed to be wrapped if the last character of the line is not whitespace.", 117 | path: ['xterm', 'windowsMode'], 118 | }, 119 | { 120 | type: 'text', 121 | name: 'Word Separator', 122 | description: 123 | 'All characters considered word separators. Used for double-click to select word logic. Encoded as JSON in this editor for editing convienience.', 124 | path: ['xterm', 'wordSeparator'], 125 | json: true, 126 | }, 127 | ]); 128 | -------------------------------------------------------------------------------- /src/assets/xterm_config/xterm_color_theme.js: -------------------------------------------------------------------------------- 1 | const selectionColorOption = { 2 | type: 'color', 3 | name: 'Selection Color', 4 | description: 'Background color for selected text. Can be transparent.', 5 | path: ['xterm', 'theme', 'selection'], 6 | }; 7 | const selectionColorOpacityOption = { 8 | type: 'number', 9 | name: 'Selection Color Opacity', 10 | description: 11 | 'Opacity of the selection highlight. A value between 1 (fully opaque) and 0 (fully transparent).', 12 | path: ['wettyVoid'], 13 | float: true, 14 | min: 0, 15 | max: 1, 16 | }; 17 | 18 | window.inflateOptions([ 19 | { 20 | type: 'color', 21 | name: 'Foreground Color', 22 | description: 'The default foreground (text) color.', 23 | path: ['xterm', 'theme', 'foreground'], 24 | }, 25 | { 26 | type: 'color', 27 | name: 'Background Color', 28 | description: 'The default background color.', 29 | path: ['xterm', 'theme', 'background'], 30 | }, 31 | { 32 | type: 'color', 33 | name: 'Cursor Color', 34 | description: 'Color of the cursor.', 35 | path: ['xterm', 'theme', 'cursor'], 36 | }, 37 | { 38 | type: 'color', 39 | name: 'Block Cursor Accent Color', 40 | description: 41 | 'The accent color of the cursor, used as the foreground color for block cursors.', 42 | path: ['xterm', 'theme', 'cursorAccent'], 43 | }, 44 | selectionColorOption, 45 | selectionColorOpacityOption, 46 | { 47 | type: 'color', 48 | name: 'Black', 49 | description: 'Color for ANSI Black text.', 50 | path: ['xterm', 'theme', 'black'], 51 | }, 52 | { 53 | type: 'color', 54 | name: 'Red', 55 | description: 'Color for ANSI Red text.', 56 | path: ['xterm', 'theme', 'red'], 57 | }, 58 | { 59 | type: 'color', 60 | name: 'Green', 61 | description: 'Color for ANSI Green text.', 62 | path: ['xterm', 'theme', 'green'], 63 | }, 64 | { 65 | type: 'color', 66 | name: 'Yellow', 67 | description: 'Color for ANSI Yellow text.', 68 | path: ['xterm', 'theme', 'yellow'], 69 | }, 70 | { 71 | type: 'color', 72 | name: 'Blue', 73 | description: 'Color for ANSI Blue text.', 74 | path: ['xterm', 'theme', 'blue'], 75 | }, 76 | { 77 | type: 'color', 78 | name: 'Magenta', 79 | description: 'Color for ANSI Magenta text.', 80 | path: ['xterm', 'theme', 'magenta'], 81 | }, 82 | { 83 | type: 'color', 84 | name: 'Cyan', 85 | description: 'Color for ANSI Cyan text.', 86 | path: ['xterm', 'theme', 'cyan'], 87 | }, 88 | { 89 | type: 'color', 90 | name: 'White', 91 | description: 'Color for ANSI White text.', 92 | path: ['xterm', 'theme', 'white'], 93 | }, 94 | { 95 | type: 'color', 96 | name: 'Bright Black', 97 | description: 'Color for ANSI Bright Black text.', 98 | path: ['xterm', 'theme', 'brightBlack'], 99 | }, 100 | { 101 | type: 'color', 102 | name: 'Bright Red', 103 | description: 'Color for ANSI Bright Red text.', 104 | path: ['xterm', 'theme', 'brightRed'], 105 | }, 106 | { 107 | type: 'color', 108 | name: 'Bright Green', 109 | description: 'Color for ANSI Bright Green text.', 110 | path: ['xterm', 'theme', 'brightGreen'], 111 | }, 112 | { 113 | type: 'color', 114 | name: 'Bright Yellow', 115 | description: 'Color for ANSI Bright Yellow text.', 116 | path: ['xterm', 'theme', 'brightYellow'], 117 | }, 118 | { 119 | type: 'color', 120 | name: 'Bright Blue', 121 | description: 'Color for ANSI Bright Blue text.', 122 | path: ['xterm', 'theme', 'brightBlue'], 123 | }, 124 | { 125 | type: 'color', 126 | name: 'Bright Magenta', 127 | description: 'Color for ANSI Bright Magenta text.', 128 | path: ['xterm', 'theme', 'brightMagenta'], 129 | }, 130 | { 131 | type: 'color', 132 | name: 'Bright White', 133 | description: 'Color for ANSI Bright White text.', 134 | path: ['xterm', 'theme', 'brightWhite'], 135 | }, 136 | ]); 137 | 138 | selectionColorOption.get = function getInput() { 139 | return ( 140 | this.el.querySelector('input').value + 141 | Math.round( 142 | selectionColorOpacityOption.el.querySelector('input').value * 255, 143 | ).toString(16) 144 | ); 145 | }; 146 | selectionColorOption.set = function setInput(value) { 147 | this.el.querySelector('input').value = value.substring(0, 7); 148 | selectionColorOpacityOption.el.querySelector('input').value = 149 | Math.round((parseInt(value.substring(7), 16) / 255) * 100) / 100; 150 | }; 151 | selectionColorOpacityOption.get = () => 0; 152 | selectionColorOpacityOption.set = () => 0; 153 | -------------------------------------------------------------------------------- /src/assets/xterm_config/xterm_defaults.js: -------------------------------------------------------------------------------- 1 | const DEFAULT_BELL_SOUND = 2 | 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4LjMyLjEwNAAAAAAAAAAAAAAA//tQxAADB8AhSmxhIIEVCSiJrDCQBTcu3UrAIwUdkRgQbFAZC1CQEwTJ9mjRvBA4UOLD8nKVOWfh+UlK3z/177OXrfOdKl7pyn3Xf//WreyTRUoAWgBgkOAGbZHBgG1OF6zM82DWbZaUmMBptgQhGjsyYqc9ae9XFz280948NMBWInljyzsNRFLPWdnZGWrddDsjK1unuSrVN9jJsK8KuQtQCtMBjCEtImISdNKJOopIpBFpNSMbIHCSRpRR5iakjTiyzLhchUUBwCgyKiweBv/7UsQbg8isVNoMPMjAAAA0gAAABEVFGmgqK////9bP/6XCykxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq'; 3 | window.loadOptions({ 4 | wettyFitTerminal: true, 5 | wettyVoid: 0, 6 | 7 | xterm: { 8 | cols: 80, 9 | rows: 24, 10 | cursorBlink: false, 11 | cursorStyle: 'block', 12 | cursorWidth: 1, 13 | bellSound: DEFAULT_BELL_SOUND, 14 | bellStyle: 'none', 15 | drawBoldTextInBrightColors: true, 16 | fastScrollModifier: 'alt', 17 | fastScrollSensitivity: 5, 18 | fontFamily: 'courier-new, courier, monospace', 19 | fontSize: 15, 20 | fontWeight: 'normal', 21 | fontWeightBold: 'bold', 22 | lineHeight: 1.0, 23 | linkTooltipHoverDuration: 500, 24 | letterSpacing: 0, 25 | logLevel: 'info', 26 | scrollback: 1000, 27 | scrollSensitivity: 1, 28 | screenReaderMode: false, 29 | macOptionIsMeta: false, 30 | macOptionClickForcesSelection: false, 31 | minimumContrastRatio: 1, 32 | disableStdin: false, 33 | allowProposedApi: true, 34 | allowTransparency: false, 35 | tabStopWidth: 8, 36 | rightClickSelectsWord: false, 37 | rendererType: 'canvas', 38 | windowOptions: {}, 39 | windowsMode: false, 40 | wordSeparator: ' ()[]{}\',"`', 41 | convertEol: false, 42 | termName: 'xterm', 43 | cancelEvents: false, 44 | 45 | theme: { 46 | foreground: '#ffffff', 47 | background: '#000000', 48 | cursor: '#ffffff', 49 | cursorAccent: '#000000', 50 | selection: '#FFFFFF4D', 51 | 52 | black: '#2e3436', 53 | red: '#cc0000', 54 | green: '#4e9a06', 55 | yellow: '#c4a000', 56 | blue: '#3465a4', 57 | magenta: '#75507b', 58 | cyan: '#06989a', 59 | white: '#d3d7cf', 60 | brightBlack: '#555753', 61 | brightRed: '#ef2929', 62 | brightGreen: '#8ae234', 63 | brightYellow: '#fce94f', 64 | brightBlue: '#729fcf', 65 | brightMagenta: '#ad7fa8', 66 | brightCyan: '#34e2e2', 67 | brightWhite: '#eeeeec', 68 | }, 69 | }, 70 | }); 71 | -------------------------------------------------------------------------------- /src/assets/xterm_config/xterm_general_options.js: -------------------------------------------------------------------------------- 1 | window.inflateOptions([ 2 | { 3 | type: 'text', 4 | name: 'Font Family', 5 | description: 'The font family for terminal text.', 6 | path: ['xterm', 'fontFamily'], 7 | }, 8 | { 9 | type: 'number', 10 | name: 'Font Size', 11 | description: 'The font size in CSS pixels for terminal text.', 12 | path: ['xterm', 'fontSize'], 13 | min: 4, 14 | }, 15 | { 16 | type: 'enum', 17 | name: 'Regular Font Weight', 18 | description: 'The font weight for non-bold text.', 19 | path: ['xterm', 'fontWeight'], 20 | enum: [ 21 | 'normal', 22 | 'bold', 23 | '100', 24 | '200', 25 | '300', 26 | '400', 27 | '500', 28 | '600', 29 | '700', 30 | '800', 31 | '900', 32 | ], 33 | }, 34 | { 35 | type: 'enum', 36 | name: 'Bold Font Weight', 37 | description: 'The font weight for bold text.', 38 | path: ['xterm', 'fontWeightBold'], 39 | enum: [ 40 | 'normal', 41 | 'bold', 42 | '100', 43 | '200', 44 | '300', 45 | '400', 46 | '500', 47 | '600', 48 | '700', 49 | '800', 50 | '900', 51 | ], 52 | }, 53 | { 54 | type: 'boolean', 55 | name: 'Fit Terminal', 56 | description: 57 | 'Automatically fits the terminal to the page, overriding terminal columns and rows.', 58 | path: ['wettyFitTerminal'], 59 | }, 60 | { 61 | type: 'number', 62 | name: 'Terminal Columns', 63 | description: 64 | 'The number of columns in the terminal. Overridden by the Fit Terminal option.', 65 | path: ['xterm', 'cols'], 66 | nullable: true, 67 | }, 68 | { 69 | type: 'number', 70 | name: 'Terminal Rows', 71 | description: 72 | 'The number of rows in the terminal. Overridden by the Fit Terminal option.', 73 | path: ['xterm', 'rows'], 74 | nullable: true, 75 | }, 76 | { 77 | type: 'enum', 78 | name: 'Cursor Style', 79 | description: 'The style of the cursor', 80 | path: ['xterm', 'cursorStyle'], 81 | enum: ['block', 'underline', 'bar'], 82 | }, 83 | { 84 | type: 'boolean', 85 | name: 'Blinking Cursor', 86 | description: 'Whether the cursor blinks', 87 | path: ['xterm', 'cursorBlink'], 88 | }, 89 | { 90 | type: 'number', 91 | name: 'Bar Cursor Width', 92 | description: 93 | "The width of the cursor in CSS pixels. Only applies when Cursor Style is set to 'bar'.", 94 | path: ['xterm', 'cursorWidth'], 95 | }, 96 | { 97 | type: 'boolean', 98 | name: 'Draw Bold Text In Bright Colors', 99 | description: 'Whether to draw bold text in bright colors', 100 | path: ['xterm', 'drawBoldTextInBrightColors'], 101 | }, 102 | { 103 | type: 'number', 104 | name: 'Scroll Sensitivity', 105 | description: 'The scroll speed multiplier for regular scrolling.', 106 | path: ['xterm', 'scrollSensitivity'], 107 | float: true, 108 | }, 109 | { 110 | type: 'enum', 111 | name: 'Fast Scroll Key', 112 | description: 'The modifier key to hold to multiply scroll speed.', 113 | path: ['xterm', 'fastScrollModifier'], 114 | enum: ['none', 'alt', 'shift', 'ctrl'], 115 | }, 116 | { 117 | type: 'number', 118 | name: 'Fast Scroll Multiplier', 119 | description: 'The scroll speed multiplier used for fast scrolling.', 120 | path: ['xterm', 'fastScrollSensitivity'], 121 | float: true, 122 | }, 123 | { 124 | type: 'number', 125 | name: 'Scrollback Rows', 126 | description: 127 | 'The amount of scrollback rows, rows you can scroll up to after they leave the viewport, to keep.', 128 | path: ['xterm', 'scrollback'], 129 | }, 130 | { 131 | type: 'number', 132 | name: 'Tab Stop Width', 133 | description: 'The size of tab stops in the terminal.', 134 | path: ['xterm', 'tabStopWidth'], 135 | }, 136 | ]); 137 | -------------------------------------------------------------------------------- /src/buffer.ts: -------------------------------------------------------------------------------- 1 | import { createInterface } from 'readline'; 2 | 3 | ask('Enter your username'); 4 | 5 | function ask(question: string): Promise { 6 | const rlp = createInterface({ 7 | input: process.stdin, 8 | output: process.stdout, 9 | }); 10 | return new Promise(resolve => { 11 | rlp.question(`${question}: `, answer => { 12 | rlp.close(); 13 | resolve(answer); 14 | }); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/client/dev.ts: -------------------------------------------------------------------------------- 1 | caches.keys().then(cacheNames => { 2 | cacheNames.forEach(cacheName => { 3 | caches.delete(cacheName); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /src/client/wetty.ts: -------------------------------------------------------------------------------- 1 | import { dom, library } from '@fortawesome/fontawesome-svg-core'; 2 | import { faCogs, faKeyboard } from '@fortawesome/free-solid-svg-icons'; 3 | import _ from 'lodash'; 4 | 5 | import '../assets/scss/styles.scss'; 6 | 7 | import { disconnect } from './wetty/disconnect'; 8 | import { overlay } from './wetty/disconnect/elements'; 9 | import { verifyPrompt } from './wetty/disconnect/verify'; 10 | import { FileDownloader } from './wetty/download'; 11 | import { FlowControlClient } from './wetty/flowcontrol'; 12 | import { mobileKeyboard } from './wetty/mobile'; 13 | import { socket } from './wetty/socket'; 14 | import { terminal, Term } from './wetty/term'; 15 | 16 | // Setup for fontawesome 17 | library.add(faCogs); 18 | library.add(faKeyboard); 19 | dom.watch(); 20 | 21 | function onResize(term: Term): () => void { 22 | return function resize() { 23 | term.resizeTerm(); 24 | }; 25 | } 26 | 27 | socket.on('connect', () => { 28 | const term = terminal(socket); 29 | if (_.isUndefined(term)) return; 30 | 31 | if (!_.isNull(overlay)) overlay.style.display = 'none'; 32 | window.addEventListener('beforeunload', verifyPrompt, false); 33 | window.addEventListener('resize', onResize(term), false); 34 | 35 | term.resizeTerm(); 36 | term.focus(); 37 | mobileKeyboard(); 38 | const fileDownloader = new FileDownloader(); 39 | const fcClient = new FlowControlClient(); 40 | 41 | term.onData((data: string) => { 42 | socket.emit('input', data); 43 | }); 44 | term.onResize((size: { cols: number; rows: number }) => { 45 | socket.emit('resize', size); 46 | }); 47 | socket 48 | .on('data', (data: string) => { 49 | const remainingData = fileDownloader.buffer(data); 50 | const downloadLength = data.length - remainingData.length; 51 | if (downloadLength && fcClient.needsCommit(downloadLength)) { 52 | socket.emit('commit', fcClient.ackBytes); 53 | } 54 | if (remainingData) { 55 | if (fcClient.needsCommit(remainingData.length)) { 56 | term.write(remainingData, () => 57 | socket.emit('commit', fcClient.ackBytes), 58 | ); 59 | } else { 60 | term.write(remainingData); 61 | } 62 | } 63 | }) 64 | .on('login', () => { 65 | term.writeln(''); 66 | term.resizeTerm(); 67 | }) 68 | .on('logout', disconnect) 69 | .on('disconnect', disconnect) 70 | .on('error', (err: string | null) => { 71 | if (err) disconnect(err); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/client/wetty/disconnect.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { overlay } from './disconnect/elements'; 3 | import { verifyPrompt } from './disconnect/verify'; 4 | 5 | export function disconnect(reason: string): void { 6 | if (_.isNull(overlay)) return; 7 | overlay.style.display = 'block'; 8 | const msg = document.getElementById('msg'); 9 | if (!_.isUndefined(reason) && !_.isNull(msg)) msg.innerHTML = reason; 10 | window.removeEventListener('beforeunload', verifyPrompt, false); 11 | } 12 | -------------------------------------------------------------------------------- /src/client/wetty/disconnect/elements.ts: -------------------------------------------------------------------------------- 1 | export const overlay = document.getElementById('overlay'); 2 | export const terminal = document.getElementById('terminal'); 3 | export const editor = document.querySelector( 4 | '#options .editor', 5 | ) as HTMLIFrameElement; 6 | -------------------------------------------------------------------------------- /src/client/wetty/disconnect/verify.ts: -------------------------------------------------------------------------------- 1 | export function verifyPrompt(e: { returnValue: string }): string { 2 | e.returnValue = 'Are you sure?'; 3 | return e.returnValue; 4 | } 5 | -------------------------------------------------------------------------------- /src/client/wetty/download.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import 'mocha'; 3 | import { JSDOM } from 'jsdom'; 4 | import * as sinon from 'sinon'; 5 | 6 | import { FileDownloader } from './download'; 7 | 8 | const noop = (): void => {}; // eslint-disable-line @typescript-eslint/no-empty-function 9 | 10 | describe('FileDownloader', () => { 11 | const FILE_BEGIN = 'BEGIN'; 12 | const FILE_END = 'END'; 13 | let fileDownloader: FileDownloader; 14 | 15 | beforeEach(() => { 16 | const { window } = new JSDOM(`...`); 17 | global.document = window.document; 18 | fileDownloader = new FileDownloader(noop, FILE_BEGIN, FILE_END); 19 | }); 20 | 21 | afterEach(() => { 22 | sinon.restore(); 23 | }); 24 | 25 | it('should return data before file markers', () => { 26 | const onCompleteFileCallbackStub = sinon.stub( 27 | fileDownloader, 28 | 'onCompleteFileCallback', 29 | ); 30 | expect( 31 | fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}`), 32 | ).to.equal('DATA AT THE LEFT'); 33 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 34 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 35 | }); 36 | 37 | it('should return data after file markers', () => { 38 | const onCompleteFileCallbackStub = sinon.stub( 39 | fileDownloader, 40 | 'onCompleteFileCallback', 41 | ); 42 | expect( 43 | fileDownloader.buffer(`${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`), 44 | ).to.equal('DATA AT THE RIGHT'); 45 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 46 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 47 | }); 48 | 49 | it('should return data before and after file markers', () => { 50 | const onCompleteFileCallbackStub = sinon.stub( 51 | fileDownloader, 52 | 'onCompleteFileCallback', 53 | ); 54 | expect( 55 | fileDownloader.buffer( 56 | `DATA AT THE LEFT${FILE_BEGIN}FILE${FILE_END}DATA AT THE RIGHT`, 57 | ), 58 | ).to.equal('DATA AT THE LEFTDATA AT THE RIGHT'); 59 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 60 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 61 | }); 62 | 63 | it('should return data before a beginning marker found', () => { 64 | sinon.stub(fileDownloader, 'onCompleteFileCallback'); 65 | expect(fileDownloader.buffer(`DATA AT THE LEFT${FILE_BEGIN}FILE`)).to.equal( 66 | 'DATA AT THE LEFT', 67 | ); 68 | }); 69 | 70 | it('should return data after an ending marker found', () => { 71 | const onCompleteFileCallbackStub = sinon.stub( 72 | fileDownloader, 73 | 'onCompleteFileCallback', 74 | ); 75 | expect(fileDownloader.buffer(`${FILE_BEGIN}FI`)).to.equal(''); 76 | expect(fileDownloader.buffer(`LE${FILE_END}DATA AT THE RIGHT`)).to.equal( 77 | 'DATA AT THE RIGHT', 78 | ); 79 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 80 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 81 | }); 82 | 83 | it('should buffer across incomplete file begin marker sequence on two calls', () => { 84 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 85 | const onCompleteFileCallbackStub = sinon.stub( 86 | fileDownloader, 87 | 'onCompleteFileCallback', 88 | ); 89 | 90 | expect(fileDownloader.buffer('BEG')).to.equal(''); 91 | expect(fileDownloader.buffer('INFILEEND')).to.equal(''); 92 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 93 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 94 | }); 95 | 96 | it('should buffer across incomplete file begin marker sequence on n calls', () => { 97 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 98 | const onCompleteFileCallbackStub = sinon.stub( 99 | fileDownloader, 100 | 'onCompleteFileCallback', 101 | ); 102 | 103 | expect(fileDownloader.buffer('B')).to.equal(''); 104 | expect(fileDownloader.buffer('E')).to.equal(''); 105 | expect(fileDownloader.buffer('G')).to.equal(''); 106 | expect(fileDownloader.buffer('I')).to.equal(''); 107 | expect(fileDownloader.buffer('NFILEEND')).to.equal(''); 108 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 109 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 110 | }); 111 | 112 | it('should buffer across incomplete file begin marker sequence with data on the left and right on multiple calls', () => { 113 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 114 | const onCompleteFileCallbackStub = sinon.stub( 115 | fileDownloader, 116 | 'onCompleteFileCallback', 117 | ); 118 | 119 | expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal( 120 | 'DATA AT THE LEFT', 121 | ); 122 | expect(fileDownloader.buffer('E')).to.equal(''); 123 | expect(fileDownloader.buffer('G')).to.equal(''); 124 | expect(fileDownloader.buffer('I')).to.equal(''); 125 | expect(fileDownloader.buffer('NFILEENDDATA AT THE RIGHT')).to.equal( 126 | 'DATA AT THE RIGHT', 127 | ); 128 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 129 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 130 | }); 131 | 132 | it('should buffer across incomplete file begin marker sequence then handle false positive', () => { 133 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 134 | const onCompleteFileCallbackStub = sinon.stub( 135 | fileDownloader, 136 | 'onCompleteFileCallback', 137 | ); 138 | 139 | expect(fileDownloader.buffer('DATA AT THE LEFTB')).to.equal( 140 | 'DATA AT THE LEFT', 141 | ); 142 | expect(fileDownloader.buffer('E')).to.equal(''); 143 | expect(fileDownloader.buffer('G')).to.equal(''); 144 | // This isn't part of the file_begin marker and should trigger the partial 145 | // file begin marker to be returned with the normal data 146 | expect(fileDownloader.buffer('ZDATA AT THE RIGHT')).to.equal( 147 | 'BEGZDATA AT THE RIGHT', 148 | ); 149 | expect(onCompleteFileCallbackStub.called).to.be.false; 150 | }); 151 | 152 | it('should buffer across incomplete file end marker sequence on two calls', () => { 153 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 154 | const mockFilePart1 = 'DATA AT THE LEFTBEGINFILEE'; 155 | const mockFilePart2 = 'NDDATA AT THE RIGHT'; 156 | 157 | const onCompleteFileCallbackStub = sinon.stub( 158 | fileDownloader, 159 | 'onCompleteFileCallback', 160 | ); 161 | expect(fileDownloader.buffer(mockFilePart1)).to.equal('DATA AT THE LEFT'); 162 | expect(fileDownloader.buffer(mockFilePart2)).to.equal('DATA AT THE RIGHT'); 163 | 164 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 165 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 166 | }); 167 | 168 | it('should buffer across incomplete file end and file begin marker sequence with data on the left and right on multiple calls', () => { 169 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 170 | const onCompleteFileCallbackStub = sinon.stub( 171 | fileDownloader, 172 | 'onCompleteFileCallback', 173 | ); 174 | 175 | expect(fileDownloader.buffer('DATA AT THE LEFTBE')).to.equal( 176 | 'DATA AT THE LEFT', 177 | ); 178 | expect(fileDownloader.buffer('G')).to.equal(''); 179 | expect(fileDownloader.buffer('I')).to.equal(''); 180 | expect(fileDownloader.buffer('NFILEE')).to.equal(''); 181 | expect(fileDownloader.buffer('N')).to.equal(''); 182 | expect(fileDownloader.buffer('DDATA AT THE RIGHT')).to.equal( 183 | 'DATA AT THE RIGHT', 184 | ); 185 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 186 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE'); 187 | }); 188 | 189 | it('should be able to handle multiple files', () => { 190 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 191 | const onCompleteFileCallbackStub = sinon.stub( 192 | fileDownloader, 193 | 'onCompleteFileCallback', 194 | ); 195 | 196 | expect( 197 | fileDownloader.buffer( 198 | 'DATA AT THE LEFT' + 199 | 'BEGIN' + 200 | 'FILE1' + 201 | 'END' + 202 | 'SECOND DATA' + 203 | 'BEGIN', 204 | ), 205 | ).to.equal('DATA AT THE LEFTSECOND DATA'); 206 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 207 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1'); 208 | 209 | expect(fileDownloader.buffer('FILE2')).to.equal(''); 210 | expect(fileDownloader.buffer('E')).to.equal(''); 211 | expect(fileDownloader.buffer('NDRIGHT')).to.equal('RIGHT'); 212 | expect(onCompleteFileCallbackStub.calledTwice).to.be.true; 213 | expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2'); 214 | }); 215 | 216 | it('should be able to handle multiple files with an ending marker', () => { 217 | fileDownloader = new FileDownloader(noop, 'BEGIN', 'END'); 218 | const onCompleteFileCallbackStub = sinon.stub( 219 | fileDownloader, 220 | 'onCompleteFileCallback', 221 | ); 222 | 223 | expect(fileDownloader.buffer('DATA AT THE LEFTBEGINFILE1EN')).to.equal( 224 | 'DATA AT THE LEFT', 225 | ); 226 | expect(onCompleteFileCallbackStub.calledOnce).to.be.false; 227 | expect(fileDownloader.buffer('DSECOND DATABEGINFILE2EN')).to.equal( 228 | 'SECOND DATA', 229 | ); 230 | expect(onCompleteFileCallbackStub.calledOnce).to.be.true; 231 | expect(onCompleteFileCallbackStub.getCall(0).args[0]).to.equal('FILE1'); 232 | expect(fileDownloader.buffer('D')).to.equal(''); 233 | expect(onCompleteFileCallbackStub.calledTwice).to.be.true; 234 | expect(onCompleteFileCallbackStub.getCall(1).args[0]).to.equal('FILE2'); 235 | }); 236 | }); 237 | -------------------------------------------------------------------------------- /src/client/wetty/download.ts: -------------------------------------------------------------------------------- 1 | import fileType from 'file-type'; 2 | import Toastify from 'toastify-js'; 3 | 4 | const DEFAULT_FILE_BEGIN = '\u001b[5i'; 5 | const DEFAULT_FILE_END = '\u001b[4i'; 6 | 7 | type OnCompleteFile = (bufferCharacters: string) => void; 8 | 9 | function onCompleteFile(bufferCharacters: string): void { 10 | let fileNameBase64; 11 | let fileCharacters = bufferCharacters; 12 | if (bufferCharacters.includes(":")) { 13 | [fileNameBase64, fileCharacters] = bufferCharacters.split(":"); 14 | } 15 | // Try to decode it as base64, if it fails we assume it's not base64 16 | try { 17 | fileCharacters = window.atob(fileCharacters); 18 | } catch (err) { 19 | // Assuming it's not base64... 20 | } 21 | 22 | const bytes = new Uint8Array(fileCharacters.length); 23 | for (let i = 0; i < fileCharacters.length; i += 1) { 24 | bytes[i] = fileCharacters.charCodeAt(i); 25 | } 26 | 27 | let mimeType = 'application/octet-stream'; 28 | let fileExt = ''; 29 | const typeData = fileType(bytes); 30 | if (typeData) { 31 | mimeType = typeData.mime; 32 | fileExt = typeData.ext; 33 | } 34 | // Check if the buffer is ASCII 35 | // Ref: https://stackoverflow.com/a/14313213 36 | // eslint-disable-next-line no-control-regex 37 | else if (/^[\x00-\xFF]*$/.test(fileCharacters)) { 38 | mimeType = 'text/plain'; 39 | fileExt = 'txt'; 40 | } 41 | let fileName; 42 | try { 43 | if (fileNameBase64 !== undefined) { 44 | fileName = window.atob(fileNameBase64); 45 | } 46 | } catch (err) { 47 | // Filename wasn't base64-encoded so let's ignore it 48 | } 49 | 50 | if (fileName === undefined) { 51 | fileName = `file-${new Date() 52 | .toISOString() 53 | .split('.')[0] 54 | .replace(/-/g, '') 55 | .replace('T', '') 56 | .replace(/:/g, '')}${fileExt ? `.${fileExt}` : ''}`; 57 | } 58 | 59 | const blob = new Blob([new Uint8Array(bytes.buffer)], { 60 | type: mimeType, 61 | }); 62 | const blobUrl = URL.createObjectURL(blob); 63 | 64 | Toastify({ 65 | text: `Download ready: ${fileName}`, 66 | duration: 10000, 67 | newWindow: true, 68 | gravity: 'bottom', 69 | position: 'right', 70 | backgroundColor: '#fff', 71 | stopOnFocus: true, 72 | escapeMarkup: false, 73 | }).showToast(); 74 | } 75 | 76 | export class FileDownloader { 77 | fileBuffer: string[]; 78 | fileBegin: string; 79 | fileEnd: string; 80 | partialFileBegin: string; 81 | onCompleteFileCallback: OnCompleteFile; 82 | 83 | constructor( 84 | onCompleteFileCallback: OnCompleteFile = onCompleteFile, 85 | fileBegin: string = DEFAULT_FILE_BEGIN, 86 | fileEnd: string = DEFAULT_FILE_END, 87 | ) { 88 | this.fileBuffer = []; 89 | this.fileBegin = fileBegin; 90 | this.fileEnd = fileEnd; 91 | this.partialFileBegin = ''; 92 | this.onCompleteFileCallback = onCompleteFileCallback; 93 | } 94 | 95 | bufferCharacter(character: string): string { 96 | // If we are not currently buffering a file. 97 | if (this.fileBuffer.length === 0) { 98 | // If we are not currently expecting the rest of the fileBegin sequences. 99 | if (this.partialFileBegin.length === 0) { 100 | // If the character is the first character of fileBegin we know to start 101 | // expecting the rest of the fileBegin sequence. 102 | if (character === this.fileBegin[0]) { 103 | this.partialFileBegin = character; 104 | return ''; 105 | } 106 | // Otherwise, we just return the character for printing to the terminal. 107 | 108 | return character; 109 | } 110 | // We're currently in the state of buffering a beginner marker... 111 | 112 | const nextExpectedCharacter = 113 | this.fileBegin[this.partialFileBegin.length]; 114 | // If the next character *is* the next character in the fileBegin sequence. 115 | if (character === nextExpectedCharacter) { 116 | this.partialFileBegin += character; 117 | // Do we now have the complete fileBegin sequence. 118 | if (this.partialFileBegin === this.fileBegin) { 119 | this.partialFileBegin = ''; 120 | this.fileBuffer = this.fileBuffer.concat(this.fileBegin.split('')); 121 | return ''; 122 | } 123 | // Otherwise, we just wait until the next character. 124 | 125 | return ''; 126 | } 127 | // If the next expected character wasn't found for the fileBegin sequence, 128 | // we need to return all the data that was bufferd in the partialFileBegin 129 | // back to the terminal. 130 | 131 | const dataToReturn = this.partialFileBegin + character; 132 | this.partialFileBegin = ''; 133 | return dataToReturn; 134 | } 135 | // If we are currently in the state of buffering a file. 136 | 137 | this.fileBuffer.push(character); 138 | // If we now have an entire fileEnd marker, we have a complete file! 139 | if ( 140 | this.fileBuffer.length >= this.fileBegin.length + this.fileEnd.length && 141 | this.fileBuffer.slice(-this.fileEnd.length).join('') === this.fileEnd 142 | ) { 143 | this.onCompleteFileCallback( 144 | this.fileBuffer 145 | .slice( 146 | this.fileBegin.length, 147 | this.fileBuffer.length - this.fileEnd.length, 148 | ) 149 | .join(''), 150 | ); 151 | this.fileBuffer = []; 152 | } 153 | 154 | return ''; 155 | } 156 | 157 | buffer(data: string): string { 158 | // This is a optimization to quickly return if we know for 159 | // sure we don't need to loop over each character. 160 | if ( 161 | this.fileBuffer.length === 0 && 162 | this.partialFileBegin.length === 0 && 163 | data.indexOf(this.fileBegin[0]) === -1 164 | ) { 165 | return data; 166 | } 167 | return data.split('').map(this.bufferCharacter.bind(this)).join(''); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/client/wetty/flowcontrol.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Flow control client side. 3 | * For low impact on overall throughput simply commits every `ackBytes` 4 | * (default 2^18). 5 | */ 6 | export class FlowControlClient { 7 | public counter = 0; 8 | public ackBytes = 262144; 9 | 10 | constructor(ackBytes?: number) { 11 | if (ackBytes) { 12 | this.ackBytes = ackBytes; 13 | } 14 | } 15 | 16 | public needsCommit(length: number): boolean { 17 | this.counter += length; 18 | if (this.counter >= this.ackBytes) { 19 | this.counter -= this.ackBytes; 20 | return true; 21 | } 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/client/wetty/mobile.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | export function mobileKeyboard(): void { 4 | const [screen] = Array.from(document.getElementsByClassName('xterm-screen')); 5 | if (_.isNull(screen)) return; 6 | screen.setAttribute('contenteditable', 'true'); 7 | screen.setAttribute('spellcheck', 'false'); 8 | screen.setAttribute('autocorrect', 'false'); 9 | screen.setAttribute('autocomplete', 'false'); 10 | screen.setAttribute('autocapitalize', 'false'); 11 | /* 12 | term.scrollPort_.screen_.setAttribute('contenteditable', 'false'); 13 | */ 14 | } 15 | -------------------------------------------------------------------------------- /src/client/wetty/socket.ts: -------------------------------------------------------------------------------- 1 | import io from 'socket.io-client'; 2 | 3 | export const trim = (str: string): string => str.replace(/\/*$/, ''); 4 | 5 | const socketBase = trim(window.location.pathname).replace(/ssh\/[^/]+$/, ''); 6 | export const socket = io(window.location.origin, { 7 | path: `${trim(socketBase)}/socket.io`, 8 | }); 9 | -------------------------------------------------------------------------------- /src/client/wetty/term.ts: -------------------------------------------------------------------------------- 1 | import { FitAddon } from '@xterm/addon-fit'; 2 | import { ImageAddon } from '@xterm/addon-image'; 3 | import { WebLinksAddon } from '@xterm/addon-web-links'; 4 | import { Terminal } from '@xterm/xterm'; 5 | import _ from 'lodash'; 6 | 7 | import { terminal as termElement } from './disconnect/elements'; 8 | import { configureTerm } from './term/confiruragtion'; 9 | import { loadOptions } from './term/load'; 10 | import type { Options } from './term/options'; 11 | import type { Socket } from 'socket.io-client'; 12 | 13 | export class Term extends Terminal { 14 | socket: Socket; 15 | fitAddon: FitAddon; 16 | loadOptions: () => Options; 17 | 18 | constructor(socket: Socket) { 19 | super({ allowProposedApi: true }); 20 | this.socket = socket; 21 | this.fitAddon = new FitAddon(); 22 | this.loadAddon(this.fitAddon); 23 | this.loadAddon(new WebLinksAddon()); 24 | this.loadAddon(new ImageAddon()); 25 | this.loadOptions = loadOptions; 26 | } 27 | 28 | resizeTerm(): void { 29 | this.refresh(0, this.rows - 1); 30 | if (this.shouldFitTerm) this.fitAddon.fit(); 31 | this.socket.emit('resize', { cols: this.cols, rows: this.rows }); 32 | } 33 | 34 | get shouldFitTerm(): boolean { 35 | return this.loadOptions().wettyFitTerminal ?? true; 36 | } 37 | } 38 | 39 | const ctrlButton = document.getElementById('onscreen-ctrl'); 40 | let ctrlFlag = false; // This indicates whether the CTRL key is pressed or not 41 | 42 | /** 43 | * Toggles the state of the `ctrlFlag` variable and updates the visual state 44 | * of the `ctrlButton` element accordingly. If `ctrlFlag` is set to `true`, 45 | * the `active` class is added to the `ctrlButton`; otherwise, it is removed. 46 | * After toggling, the terminal (`wetty_term`) is focused if it exists. 47 | */ 48 | const toggleCTRL = (): void => { 49 | ctrlFlag = !ctrlFlag; 50 | if (ctrlButton) { 51 | if (ctrlFlag) { 52 | ctrlButton.classList.add('active'); 53 | } else { 54 | ctrlButton.classList.remove('active'); 55 | } 56 | } 57 | window.wetty_term?.focus(); 58 | } 59 | 60 | /** 61 | * Simulates a backspace key press by sending the backspace character 62 | * (ASCII code 127) to the terminal. This function is intended to be used 63 | * in conjunction with the `simulateCTRLAndKey` function to handle 64 | * keyboard shortcuts. 65 | * 66 | */ 67 | const simulateBackspace = (): void => { 68 | window.wetty_term?.input('\x7F', true); 69 | } 70 | 71 | /** 72 | * Simulates a CTRL + key press by sending the corresponding character 73 | * (converted from the key's ASCII code) to the terminal. This function 74 | * is intended to be used in conjunction with the `toggleCTRL` function 75 | * to handle keyboard shortcuts. 76 | * 77 | * @param key - The key that was pressed, which will be converted to 78 | * its corresponding character code. 79 | */ 80 | const simulateCTRLAndKey = (key: string): void => { 81 | window.wetty_term?.input(`${String.fromCharCode(key.toUpperCase().charCodeAt(0) - 64)}`, false); 82 | } 83 | 84 | /** 85 | * Handles the keydown event for the CTRL key. When the CTRL key is pressed, 86 | * it sets the `ctrlFlag` variable to true and updates the visual state of 87 | * the `ctrlButton` element. If the CTRL key is released, it sets `ctrlFlag` 88 | * to false and updates the visual state of the `ctrlButton` element. 89 | * 90 | * @param e - The keyboard event object. 91 | */ 92 | document.addEventListener('keyup', (e) => { 93 | if (ctrlFlag) { 94 | // if key is a character 95 | if (e.key.length === 1 && e.key.match(/^[a-zA-Z0-9]$/)) { 96 | simulateCTRLAndKey(e.key); 97 | // delayed backspace is needed to remove the character added to the terminal 98 | // when CTRL + key is pressed. 99 | // this is a workaround because e.preventDefault() cannot be used. 100 | _.debounce(() => { 101 | simulateBackspace(); 102 | }, 100)(); 103 | } 104 | toggleCTRL(); 105 | } 106 | }); 107 | 108 | /** 109 | * Simulates pressing the ESC key by sending the ESC character (ASCII code 27) 110 | * to the terminal. If the CTRL key is active, it toggles the CTRL state off. 111 | * After sending the ESC character, the terminal is focused. 112 | */ 113 | const pressESC = (): void => { 114 | if (ctrlFlag) { 115 | toggleCTRL(); 116 | } 117 | window.wetty_term?.input('\x1B', false); 118 | window.wetty_term?.focus(); 119 | } 120 | 121 | /** 122 | * Simulates pressing the UP arrow key by sending the UP character (ASCII code 65) 123 | * to the terminal. If the CTRL key is active, it toggles the CTRL state off. 124 | * After sending the UP character, the terminal is focused. 125 | */ 126 | const pressUP = (): void => { 127 | if (ctrlFlag) { 128 | toggleCTRL(); 129 | } 130 | window.wetty_term?.input('\x1B[A', false); 131 | window.wetty_term?.focus(); 132 | } 133 | 134 | /** 135 | * Simulates pressing the DOWN arrow key by sending the DOWN character (ASCII code 66) 136 | * to the terminal. If the CTRL key is active, it toggles the CTRL state off. 137 | * After sending the DOWN character, the terminal is focused. 138 | */ 139 | const pressDOWN = (): void => { 140 | if (ctrlFlag) { 141 | toggleCTRL(); 142 | } 143 | window.wetty_term?.input('\x1B[B', false); 144 | window.wetty_term?.focus(); 145 | } 146 | 147 | /** 148 | * Simulates pressing the TAB key by sending the TAB character (ASCII code 9) 149 | * to the terminal. If the CTRL key is active, it toggles the CTRL state off. 150 | * After sending the TAB character, the terminal is focused. 151 | */ 152 | const pressTAB = (): void => { 153 | if (ctrlFlag) { 154 | toggleCTRL(); 155 | } 156 | window.wetty_term?.input('\x09', false); 157 | window.wetty_term?.focus(); 158 | } 159 | 160 | /** 161 | * Simulates pressing the LEFT arrow key by sending the LEFT character (ASCII code 68) 162 | * to the terminal. If the CTRL key is active, it toggles the CTRL state off. 163 | * After sending the LEFT character, the terminal is focused. 164 | */ 165 | const pressLEFT = (): void => { 166 | if (ctrlFlag) { 167 | toggleCTRL(); 168 | } 169 | window.wetty_term?.input('\x1B[D', false); 170 | window.wetty_term?.focus(); 171 | } 172 | 173 | /** 174 | * Simulates pressing the RIGHT arrow key by sending the RIGHT character (ASCII code 67) 175 | * to the terminal. If the CTRL key is active, it toggles the CTRL state off. 176 | * After sending the RIGHT character, the terminal is focused. 177 | */ 178 | const pressRIGHT = (): void => { 179 | if (ctrlFlag) { 180 | toggleCTRL(); 181 | } 182 | window.wetty_term?.input('\x1B[C', false); 183 | window.wetty_term?.focus(); 184 | } 185 | 186 | /** 187 | * Toggles the visibility of the onscreen buttons by adding or removing 188 | * the 'active' class to the element with the ID 'onscreen-buttons'. 189 | */ 190 | const toggleFunctions = (): void => { 191 | const element = document.querySelector('div#functions > div.onscreen-buttons') 192 | if (element?.classList.contains('active')) { 193 | element?.classList.remove('active'); 194 | } else { 195 | element?.classList.add('active'); 196 | } 197 | } 198 | 199 | declare global { 200 | interface Window { 201 | wetty_term?: Term; 202 | wetty_close_config?: () => void; 203 | wetty_save_config?: (newConfig: Options) => void; 204 | clipboardData: DataTransfer; 205 | loadOptions: (conf: Options) => void; 206 | toggleFunctions?: () => void; 207 | toggleCTRL? : () => void; 208 | pressESC?: () => void; 209 | pressUP?: () => void; 210 | pressDOWN?: () => void; 211 | pressTAB?: () => void; 212 | pressLEFT?: () => void; 213 | pressRIGHT?: () => void; 214 | } 215 | } 216 | 217 | export function terminal(socket: Socket): Term | undefined { 218 | const term = new Term(socket); 219 | if (_.isNull(termElement)) return undefined; 220 | termElement.innerHTML = ''; 221 | term.open(termElement); 222 | configureTerm(term); 223 | window.onresize = function onResize() { 224 | term.resizeTerm(); 225 | }; 226 | window.wetty_term = term; 227 | window.toggleFunctions = toggleFunctions; 228 | window.toggleCTRL = toggleCTRL; 229 | window.pressESC = pressESC; 230 | window.pressUP = pressUP; 231 | window.pressDOWN = pressDOWN; 232 | window.pressTAB = pressTAB; 233 | window.pressLEFT = pressLEFT; 234 | window.pressRIGHT = pressRIGHT; 235 | return term; 236 | } 237 | -------------------------------------------------------------------------------- /src/client/wetty/term/confiruragtion.ts: -------------------------------------------------------------------------------- 1 | import { editor } from '../disconnect/elements'; 2 | import { copySelected, copyShortcut } from './confiruragtion/clipboard'; 3 | import { onInput } from './confiruragtion/editor'; 4 | import { loadOptions } from './load'; 5 | import type { Options } from './options'; 6 | import type { Term } from '../term'; 7 | 8 | export function configureTerm(term: Term): void { 9 | const options = loadOptions(); 10 | try { 11 | term.options = options.xterm; 12 | } catch { 13 | /* Do nothing */ 14 | } 15 | 16 | const toggle = document.querySelector('#options .toggler'); 17 | const optionsElem = document.getElementById('options'); 18 | if (editor == null || toggle == null || optionsElem == null) { 19 | throw new Error("Couldn't initialize configuration menu"); 20 | } 21 | 22 | function editorOnLoad() { 23 | editor?.contentWindow?.loadOptions(loadOptions()); 24 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 25 | editor.contentWindow!.wetty_close_config = () => { 26 | optionsElem?.classList.toggle('opened'); 27 | }; 28 | editor.contentWindow!.wetty_save_config = (newConfig: Options) => { 29 | onInput(term, newConfig); 30 | }; 31 | /* eslint-enable @typescript-eslint/no-non-null-assertion */ 32 | } 33 | if ( 34 | ( 35 | editor.contentDocument || 36 | (editor.contentWindow?.document ?? { 37 | readyState: '', 38 | }) 39 | ).readyState === 'complete' 40 | ) { 41 | editorOnLoad(); 42 | } 43 | editor.addEventListener('load', editorOnLoad); 44 | 45 | toggle.addEventListener('click', e => { 46 | editor?.contentWindow?.loadOptions(loadOptions()); 47 | optionsElem.classList.toggle('opened'); 48 | e.preventDefault(); 49 | }); 50 | 51 | term.attachCustomKeyEventHandler(copyShortcut); 52 | 53 | document.addEventListener( 54 | 'mouseup', 55 | () => { 56 | if (term.hasSelection()) copySelected(term.getSelection()); 57 | }, 58 | false, 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/client/wetty/term/confiruragtion/clipboard.ts: -------------------------------------------------------------------------------- 1 | /** 2 | Copy text selection to clipboard on double click or select 3 | @param text - the selected text to copy 4 | @returns boolean to indicate success or failure 5 | */ 6 | export function copySelected(text: string): boolean { 7 | if (window.clipboardData?.setData) { 8 | window.clipboardData.setData('Text', text); 9 | return true; 10 | } 11 | if ( 12 | document.queryCommandSupported && 13 | document.queryCommandSupported('copy') 14 | ) { 15 | const textarea = document.createElement('textarea'); 16 | textarea.textContent = text; 17 | textarea.style.position = 'fixed'; 18 | document.body.appendChild(textarea); 19 | textarea.select(); 20 | try { 21 | document.execCommand('copy'); 22 | return true; 23 | } catch (ex) { 24 | return false; 25 | } finally { 26 | document.body.removeChild(textarea); 27 | } 28 | } 29 | return false; 30 | } 31 | 32 | export function copyShortcut(e: KeyboardEvent): boolean { 33 | // Ctrl + Shift + C 34 | if (e.ctrlKey && e.shiftKey && e.keyCode === 67) { 35 | e.preventDefault(); 36 | document.execCommand('copy'); 37 | return false; 38 | } 39 | return true; 40 | } 41 | -------------------------------------------------------------------------------- /src/client/wetty/term/confiruragtion/editor.ts: -------------------------------------------------------------------------------- 1 | import { editor } from '../../disconnect/elements'; 2 | import type { Term } from '../../term'; 3 | import type { Options } from '../options'; 4 | 5 | export const onInput = (term: Term, updated: Options) => { 6 | try { 7 | const updatedConf = JSON.stringify(updated, null, 2); 8 | if (localStorage.options === updatedConf) return; 9 | term.options = updated.xterm; 10 | if ( 11 | !updated.wettyFitTerminal && 12 | updated.xterm.cols != null && 13 | updated.xterm.rows != null 14 | ) 15 | term.resize(updated.xterm.cols, updated.xterm.rows); 16 | term.resizeTerm(); 17 | editor.classList.remove('error'); 18 | localStorage.options = updatedConf; 19 | } catch (e) { 20 | console.error('Configuration Error', e); // eslint-disable-line no-console 21 | editor.classList.add('error'); 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/client/wetty/term/load.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import type { XTerm, Options } from './options'; 3 | 4 | export const defaultOptions: Options = { 5 | xterm: { fontSize: 14 }, 6 | wettyVoid: 0, 7 | wettyFitTerminal: true, 8 | }; 9 | 10 | export function loadOptions(): Options { 11 | try { 12 | let options = _.isUndefined(localStorage.options) 13 | ? defaultOptions 14 | : JSON.parse(localStorage.options); 15 | // Convert old options to new options 16 | if (!('xterm' in options)) { 17 | const xterm = options; 18 | options = defaultOptions; 19 | options.xterm = xterm as unknown as XTerm; 20 | } 21 | return options; 22 | } catch { 23 | return defaultOptions; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/client/wetty/term/options.ts: -------------------------------------------------------------------------------- 1 | export type XTerm = { 2 | cols?: number; 3 | rows?: number; 4 | fontSize: number; 5 | } & Record; 6 | 7 | export interface Options { 8 | xterm: XTerm; 9 | wettyFitTerminal: boolean; 10 | wettyVoid: number; 11 | } 12 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Create WeTTY server 5 | * @module WeTTy 6 | * 7 | * This is the cli Interface for wetty. 8 | */ 9 | import { createRequire } from 'module'; 10 | import yargs from 'yargs'; 11 | import { hideBin } from 'yargs/helpers'; 12 | import { start } from './server.js'; 13 | import { loadConfigFile, mergeCliConf } from './shared/config.js'; 14 | import { setLevel, logger } from './shared/logger.js'; 15 | 16 | /* eslint-disable @typescript-eslint/no-var-requires */ 17 | const require = createRequire(import.meta.url); 18 | const packageJson = require('../package.json'); 19 | 20 | const opts = yargs(hideBin(process.argv)) 21 | .scriptName(packageJson.name) 22 | .version(packageJson.version) 23 | .options('conf', { 24 | type: 'string', 25 | description: 'config file to load config from', 26 | }) 27 | .option('ssl-key', { 28 | type: 'string', 29 | description: 'path to SSL key', 30 | }) 31 | .option('ssl-cert', { 32 | type: 'string', 33 | description: 'path to SSL certificate', 34 | }) 35 | .option('ssh-host', { 36 | description: 'ssh server host', 37 | type: 'string', 38 | }) 39 | .option('ssh-port', { 40 | description: 'ssh server port', 41 | type: 'number', 42 | }) 43 | .option('ssh-user', { 44 | description: 'ssh user', 45 | type: 'string', 46 | }) 47 | .option('title', { 48 | description: 'window title', 49 | type: 'string', 50 | }) 51 | .option('ssh-auth', { 52 | description: 53 | 'defaults to "password", you can use "publickey,password" instead', 54 | type: 'string', 55 | }) 56 | .option('ssh-pass', { 57 | description: 'ssh password', 58 | type: 'string', 59 | }) 60 | .option('ssh-key', { 61 | demand: false, 62 | description: 63 | 'path to an optional client private key (connection will be password-less and insecure!)', 64 | type: 'string', 65 | }) 66 | .option('ssh-config', { 67 | description: 68 | 'Specifies an alternative ssh configuration file. For further details see "-F" option in ssh(1)', 69 | type: 'string', 70 | }) 71 | .option('force-ssh', { 72 | description: 'Connecting through ssh even if running as root', 73 | type: 'boolean', 74 | }) 75 | .option('known-hosts', { 76 | description: 'path to known hosts file', 77 | type: 'string', 78 | }) 79 | .option('base', { 80 | alias: 'b', 81 | description: 'base path to wetty', 82 | type: 'string', 83 | }) 84 | .option('port', { 85 | alias: 'p', 86 | description: 'wetty listen port', 87 | type: 'number', 88 | }) 89 | .option('host', { 90 | description: 'wetty listen host', 91 | type: 'string', 92 | }) 93 | .option('command', { 94 | alias: 'c', 95 | description: 'command to run in shell', 96 | type: 'string', 97 | }) 98 | .option('allow-iframe', { 99 | description: 100 | 'Allow WeTTY to be embedded in an iframe, defaults to allowing same origin', 101 | type: 'boolean', 102 | }) 103 | .option('allow-remote-hosts', { 104 | description: 105 | 'Allow WeTTY to use the `host` and `port` params in a url as ssh destination', 106 | type: 'boolean', 107 | }) 108 | .option('allow-remote-command', { 109 | description: 110 | 'Allow WeTTY to use the `command` and `path` params in a url as command and working directory on ssh host', 111 | type: 'boolean', 112 | }) 113 | .option('log-level', { 114 | description: 'set log level of wetty server', 115 | type: 'string', 116 | }) 117 | .option('help', { 118 | alias: 'h', 119 | type: 'boolean', 120 | description: 'Print help message', 121 | }) 122 | .boolean('allow_discovery') 123 | .parseSync(); 124 | 125 | if (!opts.help) { 126 | loadConfigFile(opts.conf) 127 | .then((config) => mergeCliConf(opts, config)) 128 | .then((conf) => { 129 | setLevel(conf.logLevel); 130 | start(conf.ssh, conf.server, conf.command, conf.forceSSH, conf.ssl); 131 | }) 132 | .catch((err: Error) => { 133 | logger().error('error in server', { err }); 134 | process.exitCode = 1; 135 | }); 136 | } else { 137 | yargs.showHelp(); 138 | process.exitCode = 0; 139 | } 140 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create WeTTY server 3 | * @module WeTTy 4 | */ 5 | import express from 'express'; 6 | import gc from 'gc-stats'; 7 | import { Gauge, collectDefaultMetrics } from 'prom-client'; 8 | import { getCommand } from './server/command.js'; 9 | import { gcMetrics } from './server/metrics.js'; 10 | import { server } from './server/socketServer.js'; 11 | import { spawn } from './server/spawn.js'; 12 | import { 13 | sshDefault, 14 | serverDefault, 15 | forceSSHDefault, 16 | defaultCommand, 17 | } from './shared/defaults.js'; 18 | import { logger as getLogger } from './shared/logger.js'; 19 | import type { SSH, SSL, Server } from './shared/interfaces.js'; 20 | import type { Express } from 'express'; 21 | import type SocketIO from 'socket.io'; 22 | 23 | export * from './shared/interfaces.js'; 24 | export { logger as getLogger } from './shared/logger.js'; 25 | 26 | const wettyConnections = new Gauge({ 27 | name: 'wetty_connections', 28 | help: 'number of active socket connections to wetty', 29 | }); 30 | 31 | /** 32 | * Starts WeTTy Server 33 | * @name startServer 34 | * @returns Promise that resolves SocketIO server 35 | */ 36 | export const start = ( 37 | ssh: SSH = sshDefault, 38 | serverConf: Server = serverDefault, 39 | command: string = defaultCommand, 40 | forcessh: boolean = forceSSHDefault, 41 | ssl: SSL | undefined = undefined, 42 | ): Promise => 43 | decorateServerWithSsh(express(), ssh, serverConf, command, forcessh, ssl); 44 | 45 | export async function decorateServerWithSsh( 46 | app: Express, 47 | ssh: SSH = sshDefault, 48 | serverConf: Server = serverDefault, 49 | command: string = defaultCommand, 50 | forcessh: boolean = forceSSHDefault, 51 | ssl: SSL | undefined = undefined, 52 | ): Promise { 53 | const logger = getLogger(); 54 | if (ssh.key) { 55 | logger.warn(`!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! 56 | ! Password-less auth enabled using private key from ${ssh.key}. 57 | ! This is dangerous, anything that reaches the wetty server 58 | ! will be able to run remote operations without authentication. 59 | !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!`); 60 | } 61 | 62 | collectDefaultMetrics(); 63 | gc().on('stats', gcMetrics); 64 | 65 | const io = await server(app, serverConf, ssl); 66 | /** 67 | * Wetty server connected too 68 | * @fires WeTTy#connnection 69 | */ 70 | io.on('connection', async (socket: SocketIO.Socket) => { 71 | /** 72 | * @event wetty#connection 73 | * @name connection 74 | */ 75 | logger.info('Connection accepted.'); 76 | wettyConnections.inc(); 77 | 78 | try { 79 | const args = await getCommand(socket, ssh, command, forcessh); 80 | logger.debug('Command Generated', { cmd: args.join(' ') }); 81 | await spawn(socket, args); 82 | } catch (error) { 83 | logger.info('Disconnect signal sent', { err: error }); 84 | wettyConnections.dec(); 85 | } 86 | }); 87 | return io; 88 | } 89 | -------------------------------------------------------------------------------- /src/server/command.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import url from 'url'; 3 | import _ from 'lodash'; 4 | import { address } from './command/address.js'; 5 | import { loginOptions } from './command/login.js'; 6 | import { sshOptions } from './command/ssh.js'; 7 | import type { SSH } from '../shared/interfaces'; 8 | import type { Socket } from 'socket.io'; 9 | 10 | const localhost = (host: string): boolean => 11 | !_.isUndefined(process.getuid) && 12 | process.getuid() === 0 && 13 | (host === 'localhost' || host === '0.0.0.0' || host === '127.0.0.1'); 14 | 15 | const urlArgs = ( 16 | referer: string | undefined, 17 | { 18 | allowRemoteCommand, 19 | allowRemoteHosts, 20 | }: { 21 | allowRemoteCommand: boolean; 22 | allowRemoteHosts: boolean; 23 | }, 24 | ): { [s: string]: string } => 25 | _.pick( 26 | _.pickBy(url.parse(referer || '', true).query, _.isString), 27 | ['pass'], 28 | allowRemoteCommand ? ['command', 'path'] : [], 29 | allowRemoteHosts ? ['port', 'host'] : [], 30 | ); 31 | 32 | export async function getCommand( 33 | socket: Socket, 34 | { 35 | user, 36 | host, 37 | port, 38 | auth, 39 | pass, 40 | key, 41 | knownHosts, 42 | config, 43 | allowRemoteHosts, 44 | allowRemoteCommand, 45 | }: SSH, 46 | command: string, 47 | forcessh: boolean 48 | ): Promise { 49 | const { 50 | request: { headers: { referer } }, 51 | client: { conn: { remoteAddress } }, 52 | } = socket; 53 | 54 | if (!forcessh && localhost(host)) { 55 | return loginOptions(command, remoteAddress); 56 | } 57 | 58 | const sshAddress = await address(socket, user, host); 59 | const args = { 60 | host: sshAddress, 61 | port: `${port}`, 62 | pass: pass || '', 63 | command, 64 | auth, 65 | knownHosts, 66 | config: config || '', 67 | ...urlArgs(referer, { allowRemoteHosts, allowRemoteCommand }), 68 | }; 69 | return sshOptions(args, key); 70 | } 71 | -------------------------------------------------------------------------------- /src/server/command/address.ts: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { Socket } from 'socket.io'; 3 | import { login } from '../login.js'; 4 | import { escapeShell } from '../shared/shell.js'; 5 | 6 | export async function address( 7 | socket: Socket, 8 | user: string, 9 | host: string, 10 | ): Promise { 11 | // Check request-header for username 12 | const { request: { headers: { 13 | 'remote-user': userFromHeader, 14 | referer 15 | } } } = socket; 16 | 17 | let username: string | undefined; 18 | if (!_.isUndefined(userFromHeader) && !Array.isArray(userFromHeader)) { 19 | username = userFromHeader; 20 | } else { 21 | const userFromPathMatch = referer?.match('.+/ssh/([^/]+)$'); 22 | if (userFromPathMatch) { 23 | // eslint-disable-next-line prefer-destructuring 24 | username = userFromPathMatch[1].split('?')[0]; 25 | } else if (user) { 26 | username = user; 27 | } else { 28 | username = await login(socket); 29 | } 30 | } 31 | return `${escapeShell(username)}@${host}`; 32 | } 33 | -------------------------------------------------------------------------------- /src/server/command/login.ts: -------------------------------------------------------------------------------- 1 | import isUndefined from 'lodash/isUndefined.js'; 2 | 3 | const getRemoteAddress = (remoteAddress: string): string => 4 | isUndefined(remoteAddress.split(':')[3]) 5 | ? 'localhost' 6 | : remoteAddress.split(':')[3]; 7 | 8 | export function loginOptions(command: string, remoteAddress: string): string[] { 9 | return command === 'login' 10 | ? [command, '-h', getRemoteAddress(remoteAddress)] 11 | : [command]; 12 | } 13 | -------------------------------------------------------------------------------- /src/server/command/ssh.ts: -------------------------------------------------------------------------------- 1 | import isUndefined from 'lodash/isUndefined.js'; 2 | import { logger } from '../../shared/logger.js'; 3 | 4 | export function sshOptions( 5 | { 6 | pass, 7 | path, 8 | command, 9 | host, 10 | port, 11 | auth, 12 | knownHosts, 13 | config, 14 | }: Record, 15 | key?: string, 16 | ): string[] { 17 | const cmd = parseCommand(command, path); 18 | const hostChecking = knownHosts !== '/dev/null' ? 'yes' : 'no'; 19 | logger().info(`Authentication Type: ${auth}`); 20 | 21 | return [ 22 | ...pass ? ['sshpass', '-p', pass] : [], 23 | 'ssh', 24 | '-t', 25 | ...config ? ['-F', config] : [], 26 | ...port ? ['-p', port] : [], 27 | ...key ? ['-i', key] : [], 28 | ...auth !== 'none' ? ['-o', `PreferredAuthentications=${auth}`] : [], 29 | '-o', `UserKnownHostsFile=${knownHosts}`, 30 | '-o', `StrictHostKeyChecking=${hostChecking}`, 31 | '-o', 'EscapeChar=none', 32 | '--', 33 | host, 34 | ...cmd ? [cmd] : [], 35 | ]; 36 | } 37 | 38 | function parseCommand(command: string, path?: string): string { 39 | if (command === 'login' && isUndefined(path)) return ''; 40 | return !isUndefined(path) 41 | ? `$SHELL -c "cd ${path};${command === 'login' ? '$SHELL' : command}"` 42 | : command; 43 | } 44 | -------------------------------------------------------------------------------- /src/server/flowcontrol.ts: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io'; 2 | 3 | /** 4 | * tinybuffer to lower message pressure on the websocket. 5 | * Incoming data from PTY will be held back at most for `timeout` microseconds. 6 | * If the accumulated data exceeds `maxSize` the message will be sent 7 | * immediately. 8 | */ 9 | export function tinybuffer(socket: Socket, timeout: number, maxSize: number) { 10 | const s: string[] = []; 11 | let length = 0; 12 | let sender: NodeJS.Timeout | null = null; 13 | return (data: string) => { 14 | s.push(data); 15 | length += data.length; 16 | if (length > maxSize) { 17 | socket.emit('data', s.join('')); 18 | s.length = 0; 19 | length = 0; 20 | if (sender) { 21 | clearTimeout(sender); 22 | sender = null; 23 | } 24 | } 25 | else if (!sender) { 26 | sender = setTimeout(() => { 27 | socket.emit('data', s.join('')); 28 | s.length = 0; 29 | length = 0; 30 | sender = null; 31 | }, timeout); 32 | } 33 | }; 34 | } 35 | 36 | /** 37 | * Flow control - server side. 38 | * Does basic low to high watermark flow control. 39 | * 40 | * `account` should be fed by new chunk length and returns `true`, 41 | * if the underlying PTY should be paused. 42 | * 43 | * `commit` should be fed by the length value of an 'ack' message 44 | * indicating its final processing on xtermjs side. Returns `true` 45 | * if the underlying PTY should be resumed. 46 | * 47 | * Note: Chosen values for low and high must be within reach of the 48 | * chosen value of ackBytes on client side, otherwise 49 | * flow control may block forever sooner or later. 50 | * 51 | * The default values are chosen quite high to lower negative impact on overall 52 | * throughput. If you need snappier keyboard response under high data pressure 53 | * (e.g. pressing Ctrl-C while `yes` is running), lower the values. 54 | * This furthermore depends a lot on the general latency of your connection. 55 | */ 56 | export class FlowControlServer { 57 | public counter = 0; 58 | public low = 524288; // 2^19 --> 2x ackBytes from frontend 59 | public high = 2097152; // 2^21 --> 8x ackBytes from frontend 60 | 61 | constructor(low?: number, high?: number) { 62 | if (low) { 63 | this.low = low; 64 | } 65 | if (high) { 66 | this.high = high; 67 | } 68 | } 69 | 70 | public account(length: number): boolean { 71 | const old = this.counter; 72 | this.counter += length; 73 | return old < this.high && this.counter > this.high; 74 | } 75 | 76 | public commit(length: number): boolean { 77 | const old = this.counter; 78 | this.counter -= length; 79 | return old > this.low && this.counter < this.low; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/server/login.ts: -------------------------------------------------------------------------------- 1 | import { dirname, resolve as resolvePath } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import pty from 'node-pty'; 4 | import { xterm } from './shared/xterm.js'; 5 | import type SocketIO from 'socket.io'; 6 | 7 | const executable = resolvePath( 8 | dirname(fileURLToPath(import.meta.url)), 9 | '..', 10 | 'buffer.js', 11 | ); 12 | 13 | export function login(socket: SocketIO.Socket): Promise { 14 | // Request carries no username information 15 | // Create terminal and ask user for username 16 | const term = pty.spawn('/usr/bin/env', ['node', executable], xterm); 17 | let buf = ''; 18 | return new Promise((resolve, reject) => { 19 | term.onExit(({ exitCode }) => { 20 | console.error(`Process exited with code: ${exitCode}`); 21 | resolve(buf); 22 | }); 23 | term.onData((data: string) => { 24 | socket.emit('data', data); 25 | }); 26 | socket 27 | .on('input', (input: string) => { 28 | term.write(input); 29 | // eslint-disable-next-line no-control-regex 30 | buf = /\x0177/.exec(input) ? buf.slice(0, -1) : buf + input; 31 | }) 32 | .on('disconnect', () => { 33 | term.kill(); 34 | reject(); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /src/server/metrics.ts: -------------------------------------------------------------------------------- 1 | import { Counter } from 'prom-client'; 2 | import type { GCStatistics } from 'gc-stats'; 3 | 4 | const gcLabelNames = ['gctype']; 5 | const gcTypes = { 6 | 0: 'Unknown', 7 | 1: 'Scavenge', 8 | 2: 'MarkSweepCompact', 9 | 3: 'ScavengeAndMarkSweepCompact', 10 | 4: 'IncrementalMarking', 11 | 8: 'WeakPhantom', 12 | 15: 'All', 13 | }; 14 | 15 | const gcCount = new Counter({ 16 | name: `nodejs_gc_runs_total`, 17 | help: 'Count of total garbage collections.', 18 | labelNames: gcLabelNames, 19 | }); 20 | 21 | const gcTimeCount = new Counter({ 22 | name: `nodejs_gc_pause_seconds_total`, 23 | help: 'Time spent in GC Pause in seconds.', 24 | labelNames: gcLabelNames, 25 | }); 26 | 27 | const gcReclaimedCount = new Counter({ 28 | name: `nodejs_gc_reclaimed_bytes_total`, 29 | help: 'Total number of bytes reclaimed by GC.', 30 | labelNames: gcLabelNames, 31 | }); 32 | 33 | export const gcMetrics = ({ gctype, diff, pause }: GCStatistics): void => { 34 | const gcType = gcTypes[gctype]; 35 | 36 | gcCount.labels(gcType).inc(); 37 | gcTimeCount.labels(gcType).inc(pause / 1e9); 38 | 39 | if (diff.usedHeapSize < 0) { 40 | gcReclaimedCount.labels(gcType).inc(diff.usedHeapSize * -1); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/server/shared/shell.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { expect } from 'chai'; 3 | import { escapeShell } from './shell'; 4 | 5 | describe('Values passed to escapeShell should be safe to pass woth sub processes', () => { 6 | it('should escape remove subcommands', () => { 7 | const cmd = escapeShell('test`echo hello`'); 8 | expect(cmd).to.equal('testechohello'); 9 | }); 10 | 11 | it('should allow usernames with special characters', () => { 12 | const cmd = escapeShell('bob.jones\\COM@ultra-machine_dir'); 13 | expect(cmd).to.equal('bob.jones\\COM@ultra-machine_dir'); 14 | }); 15 | 16 | it('should ensure args cant be flags', () => { 17 | const cmd = escapeShell("-oProxyCommand='bash' -c `wget localhost:2222`"); 18 | expect(cmd).to.equal('oProxyCommandbash-cwgetlocalhost2222'); 19 | }); 20 | 21 | it('should remove dashes even when there are illegal characters before them', () => { 22 | const cmd = escapeShell("`-oProxyCommand='bash' -c `wget localhost:2222`"); 23 | expect(cmd).to.equal('oProxyCommandbash-cwgetlocalhost2222'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/server/shared/shell.ts: -------------------------------------------------------------------------------- 1 | export const escapeShell = (username: string): string => 2 | // eslint-disable-next-line no-useless-escape 3 | username.replace(/[^a-zA-Z0-9_\\\-\.\@-]/g, '').replace(/^-+/g, ''); 4 | -------------------------------------------------------------------------------- /src/server/shared/xterm.ts: -------------------------------------------------------------------------------- 1 | import isUndefined from 'lodash/isUndefined.js'; 2 | import type { IPtyForkOptions } from 'node-pty'; 3 | 4 | export const xterm: IPtyForkOptions = { 5 | name: 'xterm-256color', 6 | cols: 80, 7 | rows: 30, 8 | cwd: process.cwd(), 9 | env: Object.assign( 10 | {}, 11 | ...Object.keys(process.env) 12 | .filter((key: string) => !isUndefined(process.env[key])) 13 | .map((key: string) => ({ [key]: process.env[key] })), 14 | ), 15 | }; 16 | -------------------------------------------------------------------------------- /src/server/socketServer.ts: -------------------------------------------------------------------------------- 1 | import compression from 'compression'; 2 | import winston from 'express-winston'; 3 | import { logger } from '../shared/logger.js'; 4 | import { serveStatic, trim } from './socketServer/assets.js'; 5 | import { html } from './socketServer/html.js'; 6 | import { metricMiddleware, metricRoute } from './socketServer/metrics.js'; 7 | import { favicon, redirect } from './socketServer/middleware.js'; 8 | import { policies } from './socketServer/security.js'; 9 | import { listen } from './socketServer/socket.js'; 10 | import { loadSSL } from './socketServer/ssl.js'; 11 | import type { SSL, SSLBuffer, Server } from '../shared/interfaces.js'; 12 | import type { Express } from 'express'; 13 | import type SocketIO from 'socket.io'; 14 | 15 | export async function server( 16 | app: Express, 17 | { base, port, host, title, allowIframe }: Server, 18 | ssl?: SSL, 19 | ): Promise { 20 | const basePath = trim(base); 21 | logger().info('Starting server', { 22 | ssl, 23 | port, 24 | base, 25 | title, 26 | }); 27 | 28 | const client = html(basePath, title); 29 | app 30 | .disable('x-powered-by') 31 | .use(metricMiddleware(basePath)) 32 | .use(`${basePath}/metrics`, metricRoute) 33 | .use(`${basePath}/client`, serveStatic('client')) 34 | .use( 35 | winston.logger({ 36 | winstonInstance: logger(), 37 | expressFormat: true, 38 | level: 'http', 39 | }), 40 | ) 41 | .use(compression()) 42 | .use(await favicon(basePath)) 43 | .use(redirect) 44 | .use(policies(allowIframe)) 45 | .get(basePath, client) 46 | .get(`${basePath}/ssh/:user`, client); 47 | 48 | const sslBuffer: SSLBuffer = await loadSSL(ssl); 49 | 50 | return listen(app, host, port, basePath, sslBuffer); 51 | } 52 | -------------------------------------------------------------------------------- /src/server/socketServer/assets.ts: -------------------------------------------------------------------------------- 1 | import serve from 'serve-static'; 2 | import { assetsPath } from './shared/path.js'; 3 | 4 | export const trim = (str: string): string => str.replace(/\/*$/, ''); 5 | export const serveStatic = (path: string) => serve(assetsPath(path)); 6 | -------------------------------------------------------------------------------- /src/server/socketServer/html.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from '../../shared/env.js'; 2 | import type { Request, Response, RequestHandler } from 'express'; 3 | 4 | const jsFiles = isDev ? ['dev.js', 'wetty.js'] : ['wetty.js']; 5 | 6 | const render = ( 7 | title: string, 8 | base: string, 9 | ): string => ` 10 | 11 | 12 | 13 | 14 | 15 | 16 | ${title} 17 | 18 | 19 | 20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 | 31 | 32 |
33 | 106 |
107 | ${jsFiles 108 | .map(file => ` `) 109 | .join('\n') 110 | } 111 | 112 | `; 113 | 114 | export const html = (base: string, title: string): RequestHandler => ( 115 | _req: Request, 116 | res: Response, 117 | ): void => { 118 | res.send( 119 | render( 120 | title, 121 | base, 122 | ), 123 | ); 124 | }; 125 | -------------------------------------------------------------------------------- /src/server/socketServer/metrics.ts: -------------------------------------------------------------------------------- 1 | import url from 'url'; 2 | import { register, Counter, Histogram } from 'prom-client'; 3 | import ResponseTime from 'response-time'; 4 | import UrlValueParser from 'url-value-parser'; 5 | import type { Request, Response, RequestHandler } from 'express'; 6 | 7 | const requestLabels = ['route', 'method', 'status']; 8 | 9 | const requestCount = new Counter({ 10 | name: 'http_requests_total', 11 | help: 'Counter for total requests received', 12 | labelNames: requestLabels, 13 | }); 14 | 15 | const requestDuration = new Histogram({ 16 | name: 'http_request_duration_seconds', 17 | help: 'Duration of HTTP requests in seconds', 18 | labelNames: requestLabels, 19 | buckets: [0.01, 0.1, 0.5, 1, 1.5], 20 | }); 21 | 22 | const requestLength = new Histogram({ 23 | name: 'http_request_length_bytes', 24 | help: 'Content-Length of HTTP request', 25 | labelNames: requestLabels, 26 | buckets: [512, 1024, 5120, 10240, 51200, 102400], 27 | }); 28 | 29 | const responseLength = new Histogram({ 30 | name: 'http_response_length_bytes', 31 | help: 'Content-Length of HTTP response', 32 | labelNames: requestLabels, 33 | buckets: [512, 1024, 5120, 10240, 51200, 102400], 34 | }); 35 | 36 | /** 37 | * Normalizes urls paths. 38 | * 39 | * This function replaces route params like ids, with a placeholder, so we can 40 | * set the metrics label, correctly. E.g., both routes 41 | * 42 | * - /api/v1/user/1 43 | * - /api/v1/user/2 44 | * 45 | * represents the same logical route, and we want to group them together, 46 | * hence the need for the normalization. 47 | * 48 | * @param {!string} path - url path. 49 | * @param {string} [placeholder='#val'] - the placeholder that will replace id like params in the url path. 50 | * @returns {string} a normalized path, withoud ids. 51 | */ 52 | function normalizePath(originalUrl: string, placeholder = '#val'): string { 53 | const { pathname } = url.parse(originalUrl); 54 | const urlParser = new UrlValueParser(); 55 | return urlParser.replacePathValues(pathname || '', placeholder); 56 | } 57 | 58 | /** 59 | * Normalizes http status codes. 60 | * 61 | * Returns strings in the format (2|3|4|5)XX. 62 | */ 63 | function normalizeStatusCode(status: number): string { 64 | if (status >= 200 && status < 300) { 65 | return '2XX'; 66 | } 67 | 68 | if (status >= 300 && status < 400) { 69 | return '3XX'; 70 | } 71 | 72 | if (status >= 400 && status < 500) { 73 | return '4XX'; 74 | } 75 | 76 | return '5XX'; 77 | } 78 | 79 | export function metricMiddleware(basePath: string): RequestHandler { 80 | const metricsPath = `${basePath}/metrics`; 81 | 82 | /** 83 | * Corresponds to the R(equest rate), E(error rate), and D(uration of requests), 84 | * of the RED metrics. 85 | */ 86 | return ResponseTime((req: Request, res: Response, time: number): void => { 87 | const { originalUrl, method } = req; 88 | // will replace ids from the route with `#val` placeholder this serves to 89 | // measure the same routes, e.g., /image/id1, and /image/id2, will be 90 | // treated as the same route 91 | const route = normalizePath(originalUrl); 92 | 93 | if (route !== metricsPath) { 94 | const labels = { 95 | route, 96 | method, 97 | status: normalizeStatusCode(res.statusCode), 98 | }; 99 | 100 | requestCount.inc(labels); 101 | 102 | // observe normalizing to seconds 103 | requestDuration.observe(labels, time / 1000); 104 | 105 | // observe request length 106 | const reqLength = req.get('Content-Length'); 107 | if (reqLength) { 108 | requestLength.observe(labels, Number(reqLength)); 109 | } 110 | 111 | // observe response length 112 | const resLength = res.get('Content-Length'); 113 | if (resLength) { 114 | responseLength.observe(labels, Number(resLength)); 115 | } 116 | } 117 | }); 118 | } 119 | 120 | /** 121 | * Metrics route to be used by prometheus to scrape metrics 122 | */ 123 | export async function metricRoute(_req: Request, res: Response): Promise { 124 | res.set('Content-Type', register.contentType); 125 | res.end(await register.metrics()); 126 | } 127 | -------------------------------------------------------------------------------- /src/server/socketServer/middleware.ts: -------------------------------------------------------------------------------- 1 | import etag from 'etag'; 2 | import fresh from 'fresh'; 3 | import fs from 'fs-extra'; 4 | import parseUrl from 'parseurl'; 5 | import { assetsPath } from './shared/path.js'; 6 | import type { Request, Response, NextFunction, RequestHandler } from 'express'; 7 | 8 | const ONE_YEAR_MS = 60 * 60 * 24 * 365 * 1000; // 1 year 9 | 10 | /** 11 | * Determine if the cached representation is fresh. 12 | * @param req - server request 13 | * @param res - server response 14 | * @returns if the cache is fresh or not 15 | */ 16 | const isFresh = (req: Request, res: Response): boolean => 17 | fresh(req.headers, { 18 | etag: res.getHeader('ETag'), 19 | 'last-modified': res.getHeader('Last-Modified'), 20 | }); 21 | 22 | /** 23 | * redirect requests with trailing / to remove it 24 | * 25 | * @param req - server request 26 | * @param res - server response 27 | * @param next - next middleware to call on finish 28 | */ 29 | export function redirect( 30 | req: Request, 31 | res: Response, 32 | next: NextFunction, 33 | ): void { 34 | if (req.path.substr(-1) === '/' && req.path.length > 1) 35 | res.redirect(301, req.path.slice(0, -1) + req.url.slice(req.path.length)); 36 | else next(); 37 | } 38 | 39 | /** 40 | * Serves the favicon located by the given `path`. 41 | * 42 | * @param basePath - server base path 43 | * @returns middleware 44 | */ 45 | export async function favicon(basePath: string): Promise { 46 | const path = assetsPath('client', 'favicon.ico'); 47 | 48 | try { 49 | const icon = await fs.readFile(path); 50 | return (req: Request, res: Response, next: NextFunction): void => { 51 | if (getPathName(req) !== `${basePath}/client/favicon.ico`) { 52 | next(); 53 | } else if (req.method !== 'GET' && req.method !== 'HEAD') { 54 | res.statusCode = req.method === 'OPTIONS' ? 200 : 405; 55 | res.setHeader('Allow', 'GET, HEAD, OPTIONS'); 56 | res.setHeader('Content-Length', '0'); 57 | res.end(); 58 | } else { 59 | Object.entries({ 60 | 'Cache-Control': `public, max-age=${Math.floor(ONE_YEAR_MS / 1000)}`, 61 | ETag: etag(icon), 62 | }).forEach(([key, value]) => { 63 | res.setHeader(key, value); 64 | }); 65 | 66 | // Validate freshness 67 | if (isFresh(req, res)) { 68 | res.statusCode = 304; 69 | res.end(); 70 | } else { 71 | // Send icon 72 | res.statusCode = 200; 73 | res.setHeader('Content-Length', icon.length); 74 | res.setHeader('Content-Type', 'image/x-icon'); 75 | res.end(icon); 76 | } 77 | } 78 | }; 79 | } catch (err) { 80 | return (_req: Request, _res: Response, next: NextFunction): void => 81 | next(err); 82 | } 83 | } 84 | 85 | /** 86 | * Get the request pathname. 87 | * 88 | * @param requests 89 | * @returns path name or undefined 90 | */ 91 | 92 | function getPathName(req: Request): string | undefined { 93 | try { 94 | const url = parseUrl(req); 95 | return url?.pathname ? url.pathname : undefined; 96 | } catch (e) { 97 | return undefined; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/server/socketServer/security.ts: -------------------------------------------------------------------------------- 1 | import helmet from 'helmet'; 2 | import type { Request, Response } from 'express'; 3 | 4 | export const policies = 5 | (allowIframe: boolean) => 6 | (req: Request, res: Response, next: (err?: unknown) => void): void => { 7 | const args: Record = { 8 | referrerPolicy: { policy: ['no-referrer-when-downgrade'] }, 9 | contentSecurityPolicy: { 10 | directives: { 11 | defaultSrc: ["'self'"], 12 | scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], 13 | styleSrc: ["'self'", "'unsafe-inline'"], 14 | fontSrc: ["'self'", 'data:'], 15 | connectSrc: [ 16 | "'self'", 17 | (req.protocol === 'http' ? 'ws://' : 'wss://') + req.get('host'), 18 | ], 19 | }, 20 | }, 21 | frameguard: false 22 | }; 23 | if (!allowIframe) args.frameguard = { action: 'sameorigin' }; 24 | 25 | helmet(args)(req, res, next); 26 | }; 27 | -------------------------------------------------------------------------------- /src/server/socketServer/shared/path.ts: -------------------------------------------------------------------------------- 1 | import { resolve, dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import findUp from 'find-up'; 4 | 5 | const filePath = dirname( 6 | findUp.sync('package.json', { 7 | cwd: dirname(fileURLToPath(import.meta.url)), 8 | }) || process.cwd(), 9 | ); 10 | 11 | export const assetsPath = (...args: string[]) => 12 | resolve(filePath, 'build', ...args); 13 | -------------------------------------------------------------------------------- /src/server/socketServer/socket.ts: -------------------------------------------------------------------------------- 1 | import http from 'http'; 2 | import https from 'https'; 3 | import isUndefined from 'lodash/isUndefined.js'; 4 | import { Server } from 'socket.io'; 5 | 6 | import { logger } from '../../shared/logger.js'; 7 | import type { SSLBuffer } from '../../shared/interfaces.js'; 8 | import type express from 'express'; 9 | 10 | export const listen = ( 11 | app: express.Express, 12 | host: string, 13 | port: number, 14 | path: string, 15 | { key, cert }: SSLBuffer, 16 | ): Server => 17 | new Server( 18 | !isUndefined(key) && !isUndefined(cert) 19 | ? https.createServer({ key, cert }, app).listen(port, host, () => { 20 | logger().info('Server started', { 21 | port, 22 | connection: 'https', 23 | }); 24 | }) 25 | : http.createServer(app).listen(port, host, () => { 26 | logger().info('Server started', { 27 | port, 28 | connection: 'http', 29 | }); 30 | }), 31 | { 32 | path: `${path}/socket.io`, 33 | pingInterval: 3000, 34 | pingTimeout: 7000, 35 | }, 36 | ); 37 | -------------------------------------------------------------------------------- /src/server/socketServer/ssl.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'path'; 2 | import fs from 'fs-extra'; 3 | import isUndefined from 'lodash/isUndefined.js'; 4 | import type { SSL, SSLBuffer } from '../../shared/interfaces'; 5 | 6 | export async function loadSSL(ssl?: SSL): Promise { 7 | if (isUndefined(ssl) || isUndefined(ssl.key) || isUndefined(ssl.cert)) 8 | return {}; 9 | const [key, cert]: Buffer[] = await Promise.all([ 10 | fs.readFile(resolve(ssl.key)), 11 | fs.readFile(resolve(ssl.cert)), 12 | ]); 13 | return { key, cert }; 14 | } 15 | -------------------------------------------------------------------------------- /src/server/spawn.ts: -------------------------------------------------------------------------------- 1 | import isUndefined from 'lodash/isUndefined.js'; 2 | import pty from 'node-pty'; 3 | import { logger as getLogger } from '../shared/logger.js'; 4 | import { tinybuffer, FlowControlServer } from './flowcontrol.js'; 5 | import { xterm } from './shared/xterm.js'; 6 | import { envVersionOr } from './spawn/env.js'; 7 | import type SocketIO from 'socket.io'; 8 | 9 | export async function spawn( 10 | socket: SocketIO.Socket, 11 | args: string[], 12 | ): Promise { 13 | const logger = getLogger(); 14 | const version = await envVersionOr(0); 15 | const cmd = version >= 9 ? ['-S', ...args] : args; 16 | logger.debug('Spawning PTY', { cmd }); 17 | const term = pty.spawn('/usr/bin/env', cmd, xterm); 18 | const { pid } = term; 19 | const address = args[0] === 'ssh' ? args[1] : 'localhost'; 20 | logger.info('Process Started on behalf of user', { pid, address }); 21 | socket.emit('login'); 22 | term.onExit(({exitCode}) => { 23 | logger.info('Process exited', { exitCode, pid }); 24 | socket.emit('logout'); 25 | socket 26 | .removeAllListeners('disconnect') 27 | .removeAllListeners('resize') 28 | .removeAllListeners('input'); 29 | }); 30 | const send = tinybuffer(socket, 2, 524288); 31 | const fcServer = new FlowControlServer(); 32 | term.onData((data: string) => { 33 | send(data); 34 | if (fcServer.account(data.length)) { 35 | term.pause(); 36 | } 37 | }); 38 | socket 39 | .on('resize', ({ cols, rows }) => { 40 | term.resize(cols, rows); 41 | }) 42 | .on('input', input => { 43 | if (!isUndefined(term)) term.write(input); 44 | }) 45 | .on('disconnect', () => { 46 | term.kill(); 47 | logger.info('Process exited', { code: 0, pid }); 48 | }) 49 | .on('commit', size => { 50 | if (fcServer.commit(size)) { 51 | term.resume(); 52 | } 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /src/server/spawn/env.ts: -------------------------------------------------------------------------------- 1 | import { exec } from 'child_process'; 2 | 3 | const envVersion = (): Promise => 4 | new Promise((resolve, reject) => { 5 | exec('/usr/bin/env --version', (error, stdout, stderr): void => { 6 | if (error) { 7 | return reject(Error(`error getting env version: ${error.message}`)); 8 | } 9 | if (stderr) { 10 | return reject(Error(`error getting env version: ${stderr}`)); 11 | } 12 | return resolve( 13 | parseInt( 14 | stdout.split(/\r?\n/)[0].split(' (GNU coreutils) ')[1].split('.')[0], 15 | 10, 16 | ), 17 | ); 18 | }); 19 | }); 20 | 21 | export const envVersionOr = (fallback: number): Promise => 22 | envVersion().catch(() => fallback); 23 | -------------------------------------------------------------------------------- /src/shared/config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs-extra'; 3 | import JSON5 from 'json5'; 4 | import isUndefined from 'lodash/isUndefined.js'; 5 | import { 6 | sshDefault, 7 | serverDefault, 8 | forceSSHDefault, 9 | defaultCommand, 10 | defaultLogLevel, 11 | } from './defaults.js'; 12 | import type { Config, SSH, Server, SSL } from './interfaces'; 13 | import type winston from 'winston'; 14 | import type { Arguments } from 'yargs'; 15 | 16 | type confValue = 17 | | boolean 18 | | string 19 | | number 20 | | undefined 21 | | unknown 22 | | SSH 23 | | Server 24 | | SSL; 25 | 26 | /** 27 | * Cast given value to boolean 28 | * 29 | * @param value - variable to cast 30 | * @returns variable cast to boolean 31 | */ 32 | function ensureBoolean(value: confValue): boolean { 33 | switch (value) { 34 | case true: 35 | case 'true': 36 | case 1: 37 | case '1': 38 | case 'on': 39 | case 'yes': 40 | return true; 41 | default: 42 | return false; 43 | } 44 | } 45 | 46 | function parseLogLevel( 47 | confLevel: typeof winston.level, 48 | optsLevel: unknown, 49 | ): typeof winston.level { 50 | const logLevel = isUndefined(optsLevel) ? confLevel : `${optsLevel}`; 51 | return [ 52 | 'error', 53 | 'warn', 54 | 'info', 55 | 'http', 56 | 'verbose', 57 | 'debug', 58 | 'silly', 59 | ].includes(logLevel) 60 | ? (logLevel as typeof winston.level) 61 | : defaultLogLevel; 62 | } 63 | 64 | /** 65 | * Load JSON5 config from file and merge with default args 66 | * If no path is provided the default config is returned 67 | * 68 | * @param filepath - path to config to load 69 | * @returns variable cast to boolean 70 | */ 71 | export async function loadConfigFile(filepath?: string): Promise { 72 | if (isUndefined(filepath)) { 73 | return { 74 | ssh: sshDefault, 75 | server: serverDefault, 76 | command: defaultCommand, 77 | forceSSH: forceSSHDefault, 78 | logLevel: defaultLogLevel, 79 | }; 80 | } 81 | const content = await fs.readFile(path.resolve(filepath)); 82 | const parsed = JSON5.parse(content.toString()) as Config; 83 | return { 84 | ssh: isUndefined(parsed.ssh) 85 | ? sshDefault 86 | : Object.assign(sshDefault, parsed.ssh), 87 | server: isUndefined(parsed.server) 88 | ? serverDefault 89 | : Object.assign(serverDefault, parsed.server), 90 | command: isUndefined(parsed.command) ? defaultCommand : `${parsed.command}`, 91 | forceSSH: isUndefined(parsed.forceSSH) 92 | ? forceSSHDefault 93 | : ensureBoolean(parsed.forceSSH), 94 | ssl: parsed.ssl, 95 | logLevel: parseLogLevel(defaultLogLevel, parsed.logLevel), 96 | }; 97 | } 98 | 99 | /** 100 | * Merge 2 objects removing undefined fields 101 | * 102 | * @param target - base object 103 | * @param source - object to get new values from 104 | * @returns merged object 105 | * 106 | */ 107 | const objectAssign = ( 108 | target: SSH | Server, 109 | source: Record, 110 | ): SSH | Server => 111 | Object.fromEntries( 112 | Object.entries(source).map(([key, value]) => [ 113 | key, 114 | isUndefined(source[key]) ? target[key] : value, 115 | ]), 116 | ) as SSH | Server; 117 | 118 | /** 119 | * Merge cli arguemens with config object 120 | * 121 | * @param opts - Object containing cli args 122 | * @param config - Config object 123 | * @returns merged configuration 124 | * 125 | */ 126 | export function mergeCliConf(opts: Arguments, config: Config): Config { 127 | const ssl = { 128 | key: opts['ssl-key'], 129 | cert: opts['ssl-cert'], 130 | ...config.ssl, 131 | } as SSL; 132 | return { 133 | ssh: objectAssign(config.ssh, { 134 | user: opts['ssh-user'], 135 | host: opts['ssh-host'], 136 | auth: opts['ssh-auth'], 137 | port: opts['ssh-port'], 138 | pass: opts['ssh-pass'], 139 | key: opts['ssh-key'], 140 | allowRemoteHosts: opts['allow-remote-hosts'], 141 | allowRemoteCommand: opts['allow-remote-command'], 142 | config: opts['ssh-config'], 143 | knownHosts: opts['known-hosts'], 144 | }) as SSH, 145 | server: objectAssign(config.server, { 146 | base: opts.base, 147 | host: opts.host, 148 | port: opts.port, 149 | title: opts.title, 150 | allowIframe: opts['allow-iframe'], 151 | }) as Server, 152 | command: isUndefined(opts.command) ? config.command : `${opts.command}`, 153 | forceSSH: isUndefined(opts['force-ssh']) 154 | ? config.forceSSH 155 | : ensureBoolean(opts['force-ssh']), 156 | ssl: isUndefined(ssl.key) || isUndefined(ssl.cert) ? undefined : ssl, 157 | logLevel: parseLogLevel(config.logLevel, opts['log-level']), 158 | }; 159 | } 160 | -------------------------------------------------------------------------------- /src/shared/defaults.ts: -------------------------------------------------------------------------------- 1 | import { isDev } from './env.js'; 2 | import type { SSH, Server } from './interfaces'; 3 | 4 | export const sshDefault: SSH = { 5 | user: process.env.SSHUSER || '', 6 | host: process.env.SSHHOST || 'localhost', 7 | auth: process.env.SSHAUTH || 'password', 8 | pass: process.env.SSHPASS || undefined, 9 | key: process.env.SSHKEY || undefined, 10 | port: parseInt(process.env.SSHPORT || '22', 10), 11 | knownHosts: process.env.KNOWNHOSTS || '/dev/null', 12 | allowRemoteHosts: false, 13 | allowRemoteCommand: false, 14 | config: process.env.SSHCONFIG || undefined, 15 | }; 16 | 17 | export const serverDefault: Server = { 18 | base: process.env.BASE || '/wetty/', 19 | port: parseInt(process.env.PORT || '3000', 10), 20 | host: '0.0.0.0', 21 | title: process.env.TITLE || 'WeTTY - The Web Terminal Emulator', 22 | allowIframe: process.env.ALLOWIFRAME === 'true' || false, 23 | }; 24 | 25 | export const forceSSHDefault = process.env.FORCESSH === 'true' || false; 26 | export const defaultCommand = process.env.COMMAND || 'login'; 27 | export const defaultLogLevel = isDev ? 'debug' : 'http'; 28 | -------------------------------------------------------------------------------- /src/shared/env.ts: -------------------------------------------------------------------------------- 1 | export const isDev = process.env.NODE_ENV === 'development'; 2 | -------------------------------------------------------------------------------- /src/shared/interfaces.ts: -------------------------------------------------------------------------------- 1 | import type winston from 'winston'; 2 | 3 | export interface SSH { 4 | [s: string]: string | number | boolean | undefined; 5 | user: string; 6 | host: string; 7 | auth: string; 8 | port: number; 9 | knownHosts: string; 10 | allowRemoteHosts: boolean; 11 | allowRemoteCommand: boolean; 12 | pass?: string; 13 | key?: string; 14 | config?: string; 15 | } 16 | 17 | export interface SSL { 18 | key: string; 19 | cert: string; 20 | } 21 | 22 | export interface SSLBuffer { 23 | key?: Buffer; 24 | cert?: Buffer; 25 | } 26 | 27 | export interface Server { 28 | [s: string]: string | number | boolean; 29 | port: number; 30 | host: string; 31 | title: string; 32 | base: string; 33 | allowIframe: boolean; 34 | } 35 | 36 | export interface Config { 37 | ssh: SSH; 38 | server: Server; 39 | forceSSH: boolean; 40 | command: string; 41 | logLevel: typeof winston.level; 42 | ssl?: SSL; 43 | } 44 | -------------------------------------------------------------------------------- /src/shared/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import { defaultLogLevel } from './defaults.js'; 3 | import { isDev } from './env.js'; 4 | 5 | const { combine, timestamp, label, simple, json, colorize } = winston.format; 6 | 7 | const dev = combine( 8 | colorize(), 9 | label({ label: 'Wetty' }), 10 | timestamp(), 11 | simple(), 12 | ); 13 | 14 | const prod = combine(label({ label: 'Wetty' }), timestamp(), json()); 15 | 16 | let globalLogger = winston.createLogger({ 17 | format: isDev ? dev : prod, 18 | transports: [ 19 | new winston.transports.Console({ 20 | level: defaultLogLevel, 21 | handleExceptions: true, 22 | }), 23 | ], 24 | }); 25 | 26 | export function setLevel(level: typeof winston.level): void { 27 | globalLogger = winston.createLogger({ 28 | format: isDev ? dev : prod, 29 | transports: [ 30 | new winston.transports.Console({ 31 | level, 32 | handleExceptions: true, 33 | }), 34 | ], 35 | }); 36 | } 37 | 38 | export function logger(): winston.Logger { 39 | return globalLogger; 40 | } 41 | -------------------------------------------------------------------------------- /tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["DOM"], 5 | "noEmit": true, 6 | "incremental": true 7 | }, 8 | "include": ["src/client"] 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es2020", 4 | "target": "es2019", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "downlevelIteration": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": true, 14 | "noUnusedLocals": true, 15 | "noUnusedParameters": true, 16 | "removeComments": true, 17 | "skipLibCheck": true, 18 | "strict": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "incremental": true, 5 | "outDir": "./build", 6 | "sourceMap": true 7 | }, 8 | "include": [ 9 | "src" 10 | ], 11 | "exclude": [ 12 | "src/client" 13 | ] 14 | } 15 | --------------------------------------------------------------------------------