1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 | 'use strict';
|
8 |
|
9 | const { readFileSync } = require('fs');
|
10 |
|
11 | const blessed = require('blessed');
|
12 | const { Server } = require('ssh2');
|
13 |
|
14 | const RE_SPECIAL =
|
15 |
|
16 | /[\x00-\x1F\x7F]+|(?:\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K])/g;
|
17 | const MAX_MSG_LEN = 128;
|
18 | const MAX_NAME_LEN = 10;
|
19 | const PROMPT_NAME = `Enter a nickname to use (max ${MAX_NAME_LEN} chars): `;
|
20 |
|
21 | const users = [];
|
22 |
|
23 | function formatMessage(msg, output) {
|
24 | output.parseTags = true;
|
25 | msg = output._parseTags(msg);
|
26 | output.parseTags = false;
|
27 | return msg;
|
28 | }
|
29 |
|
30 | function userBroadcast(msg, source) {
|
31 | const sourceMsg = `> ${msg}`;
|
32 | const name = `{cyan-fg}{bold}${source.name}{/}`;
|
33 | msg = `: ${msg}`;
|
34 | for (const user of users) {
|
35 | const output = user.output;
|
36 | if (source === user)
|
37 | output.add(sourceMsg);
|
38 | else
|
39 | output.add(formatMessage(name, output) + msg);
|
40 | }
|
41 | }
|
42 |
|
43 | function localMessage(msg, source) {
|
44 | const output = source.output;
|
45 | output.add(formatMessage(msg, output));
|
46 | }
|
47 |
|
48 | function noop(v) {}
|
49 |
|
50 | new Server({
|
51 | hostKeys: [readFileSync('host.key')],
|
52 | }, (client) => {
|
53 | let stream;
|
54 | let name;
|
55 |
|
56 | client.on('authentication', (ctx) => {
|
57 | let nick = ctx.username;
|
58 | let prompt = PROMPT_NAME;
|
59 | let lowered;
|
60 |
|
61 |
|
62 | if (nick.length > 0 && nick.length <= MAX_NAME_LEN) {
|
63 | lowered = nick.toLowerCase();
|
64 | let ok = true;
|
65 | for (const user of users) {
|
66 | if (user.name.toLowerCase() === lowered) {
|
67 | ok = false;
|
68 | prompt = `That nickname is already in use.\n${PROMPT_NAME}`;
|
69 | break;
|
70 | }
|
71 | }
|
72 | if (ok) {
|
73 | name = nick;
|
74 | return ctx.accept();
|
75 | }
|
76 | } else if (nick.length === 0) {
|
77 | prompt = 'A nickname is required.\n' + PROMPT_NAME;
|
78 | } else {
|
79 | prompt = 'That nickname is too long.\n' + PROMPT_NAME;
|
80 | }
|
81 |
|
82 | if (ctx.method !== 'keyboard-interactive')
|
83 | return ctx.reject(['keyboard-interactive']);
|
84 |
|
85 | ctx.prompt(prompt, function retryPrompt(answers) {
|
86 | if (answers.length === 0)
|
87 | return ctx.reject(['keyboard-interactive']);
|
88 | nick = answers[0];
|
89 | if (nick.length > MAX_NAME_LEN) {
|
90 | return ctx.prompt(`That nickname is too long.\n${PROMPT_NAME}`,
|
91 | retryPrompt);
|
92 | } else if (nick.length === 0) {
|
93 | return ctx.prompt(`A nickname is required.\n${PROMPT_NAME}`,
|
94 | retryPrompt);
|
95 | }
|
96 | lowered = nick.toLowerCase();
|
97 | for (const user of users) {
|
98 | if (user.name.toLowerCase() === lowered) {
|
99 | return ctx.prompt(`That nickname is already in use.\n${PROMPT_NAME}`,
|
100 | retryPrompt);
|
101 | }
|
102 | }
|
103 | name = nick;
|
104 | ctx.accept();
|
105 | });
|
106 | }).on('ready', () => {
|
107 | let rows;
|
108 | let cols;
|
109 | let term;
|
110 | client.once('session', (accept, reject) => {
|
111 | accept().once('pty', (accept, reject, info) => {
|
112 | rows = info.rows;
|
113 | cols = info.cols;
|
114 | term = info.term;
|
115 | accept && accept();
|
116 | }).on('window-change', (accept, reject, info) => {
|
117 | rows = info.rows;
|
118 | cols = info.cols;
|
119 | if (stream) {
|
120 | stream.rows = rows;
|
121 | stream.columns = cols;
|
122 | stream.emit('resize');
|
123 | }
|
124 | accept && accept();
|
125 | }).once('shell', (accept, reject) => {
|
126 | stream = accept();
|
127 | users.push(stream);
|
128 |
|
129 | stream.name = name;
|
130 | stream.rows = rows || 24;
|
131 | stream.columns = cols || 80;
|
132 | stream.isTTY = true;
|
133 | stream.setRawMode = noop;
|
134 | stream.on('error', noop);
|
135 |
|
136 | const screen = new blessed.screen({
|
137 | autoPadding: true,
|
138 | smartCSR: true,
|
139 | program: new blessed.program({
|
140 | input: stream,
|
141 | output: stream
|
142 | }),
|
143 | terminal: term || 'ansi'
|
144 | });
|
145 |
|
146 | screen.title = 'SSH Chatting as ' + name;
|
147 |
|
148 | screen.program.attr('invisible', true);
|
149 |
|
150 | const output = stream.output = new blessed.log({
|
151 | screen: screen,
|
152 | top: 0,
|
153 | left: 0,
|
154 | width: '100%',
|
155 | bottom: 2,
|
156 | scrollOnInput: true
|
157 | });
|
158 | screen.append(output);
|
159 |
|
160 | screen.append(new blessed.box({
|
161 | screen: screen,
|
162 | height: 1,
|
163 | bottom: 1,
|
164 | left: 0,
|
165 | width: '100%',
|
166 | type: 'line',
|
167 | ch: '='
|
168 | }));
|
169 |
|
170 | const input = new blessed.textbox({
|
171 | screen: screen,
|
172 | bottom: 0,
|
173 | height: 1,
|
174 | width: '100%',
|
175 | inputOnFocus: true
|
176 | });
|
177 | screen.append(input);
|
178 |
|
179 | input.focus();
|
180 |
|
181 |
|
182 | localMessage('{blue-bg}{white-fg}{bold}Welcome to SSH Chat!{/}\n'
|
183 | + 'There are {bold}'
|
184 | + (users.length - 1)
|
185 | + '{/} other user(s) connected.\n'
|
186 | + 'Type /quit or /exit to exit the chat.',
|
187 | stream);
|
188 |
|
189 |
|
190 | for (const user of users) {
|
191 | const output = user.output;
|
192 | if (user === stream)
|
193 | continue;
|
194 | output.add(formatMessage('{green-fg}*** {bold}', output)
|
195 | + name
|
196 | + formatMessage('{/bold} has joined the chat{/}', output));
|
197 | }
|
198 |
|
199 | screen.render();
|
200 |
|
201 |
|
202 | screen.program.emit('resize');
|
203 |
|
204 |
|
205 | input.on('submit', (line) => {
|
206 | input.clearValue();
|
207 | screen.render();
|
208 | if (!input.focused)
|
209 | input.focus();
|
210 | line = line.replace(RE_SPECIAL, '').trim();
|
211 | if (line.length > MAX_MSG_LEN)
|
212 | line = line.substring(0, MAX_MSG_LEN);
|
213 | if (line.length > 0) {
|
214 | if (line === '/quit' || line === '/exit')
|
215 | stream.end();
|
216 | else
|
217 | userBroadcast(line, stream);
|
218 | }
|
219 | });
|
220 | });
|
221 | });
|
222 | }).on('close', () => {
|
223 | if (stream !== undefined) {
|
224 | users.splice(users.indexOf(stream), 1);
|
225 |
|
226 | for (const user of users) {
|
227 | const output = user.output;
|
228 | output.add(formatMessage('{magenta-fg}*** {bold}', output)
|
229 | + name
|
230 | + formatMessage('{/bold} has left the chat{/}', output));
|
231 | }
|
232 | }
|
233 | }).on('error', (err) => {
|
234 |
|
235 | });
|
236 | }).listen(0, function() {
|
237 | console.log('Listening on port ' + this.address().port);
|
238 | });
|