From 863b445a17f697b1e20f4b5705893093aa5fb80c Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 19 Jan 2024 03:12:54 +0000 Subject: [PATCH] version checks, improved secure/insecure handling, bugfixes, and proxy support! --- README.md | 15 +++- docker-compose-example.yml | 9 +-- public/index.html | 1 + public/scripts/main.js | 36 +++++++++ public/scripts/terminal.js | 156 +++++++++++++++++-------------------- server/main.js | 89 ++++++++++++++++----- 6 files changed, 194 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index 71ca2e1..76ceb36 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # OpenTerminal + An online terminal and communal text buffer ![openterminal thumbnail image](public/img/thumbnail.png) @@ -15,13 +16,23 @@ jokes aside- while i do absolutely see how an open, self-moderated text buffer i ...or maybe it'll just become a garbage-posting haven. regardless, it's a fun little idea, so i made it anyway. +## hosting + +- `git clone` this repo and `cd` into it +- `npm ci` to install dependencies +- `npm run start`, and you should be good to do! + +OpenTerminal also makes use of environment variables for server hosts who want a bit more control of their setup: + +- `OPENTERM_HOST` - the address to bind OpenTerminal's http and websocket server. (default 0.0.0.0) +- `OPENTERM_POST` - the port to bind OpenTerminal's http and websocket server. (default 8080) +- `OPENTERM_TRUSTED_PROXIES` - a comma-separated list of addresses to check for `X-Forwarded-For` headers from. handy if you run OpenTerminal behind a reverse proxy! (default none, example `"127.0.0.1,192.168.0.100"`) + ## roadmap - rewrite backend in go/rust (me no like javascript raaaahhh) -- multiple "channels" (at least if one gets flooded, there's somewhere else you can go) - master server (anyone can host a channel and post to the MS) ### "maybe" roadmap - channel logs (for recovery in the event of a crash, as an optional feature) - diff --git a/docker-compose-example.yml b/docker-compose-example.yml index de3f038..6eff74c 100644 --- a/docker-compose-example.yml +++ b/docker-compose-example.yml @@ -5,10 +5,9 @@ services: image: openterminal container_name: openterminal ports: - - 443:443 - volumes: - - ./certs/cert.crt:/srv/openterminal/certs/cert.crt - - ./certs/cert.key:/srv/openterminal/certs/cert.key + - 8080:8080 environment: - PORT: 443 + OPENTERM_HOST: 0.0.0.0 + OPENTERM_PORT: 8080 + # OPENTERM_TRUSTED_PROXIES: 127.0.0.1,192.168.0.100 restart: unless-stopped diff --git a/public/index.html b/public/index.html index 4f5dbd0..cf4ecaf 100644 --- a/public/index.html +++ b/public/index.html @@ -49,6 +49,7 @@
diff --git a/public/scripts/main.js b/public/scripts/main.js index 3303067..703a14d 100644 --- a/public/scripts/main.js +++ b/public/scripts/main.js @@ -2,6 +2,8 @@ import * as Terminal from "./terminal.js"; import * as Visual from "./visual.js"; document.addEventListener("DOMContentLoaded", () => { + document.getElementById("version").textContent = Terminal.VERSION; + Visual.bind(); Terminal.start(); @@ -56,3 +58,37 @@ document.addEventListener("DOMContentLoaded", () => { }); }); +/** + * requests that the user confirm they wish to connect to an insecure (ws://) server. + * @returns a promise returning `true` or `false` based on user input. + */ +export async function user_confirm_insecure() { + const warn_dialog = document.getElementById("warn-dialog"); + const warn_close = warn_dialog.getElementsByClassName("dialog-close").item(0); + const dialog_backdrop = document.getElementById("dialog-backdrop"); + const warn_proceed = document.getElementById("warn-proceed"); + const warn_cancel = document.getElementById("warn-cancel"); + + warn_dialog.classList.add("show"); + dialog_backdrop.classList.add("show"); + + const user_input = await new Promise((Resolve, Reject) => { + warn_close.addEventListener('click', () => { + Resolve(false); + }); + warn_cancel.addEventListener('click', () => { + warn_close.click(); + }); + dialog_backdrop.addEventListener("click", () => { + warn_close.click(); + }); + warn_proceed.addEventListener('click', () => { + Resolve(true); + }); + }); + + warn_dialog.classList.remove("show"); + dialog_backdrop.classList.remove("show"); + + return user_input; +} diff --git a/public/scripts/terminal.js b/public/scripts/terminal.js index bf55389..31b6c17 100644 --- a/public/scripts/terminal.js +++ b/public/scripts/terminal.js @@ -1,12 +1,18 @@ -var recv_buffer = []; -var send_buffer = []; +import { user_confirm_insecure } from "./main.js"; -var content; -var server_indicator; -var mobile_input; +export const VERSION = "1.1.0"; + +const foreground = localStorage.getItem("foreground"); +const background = localStorage.getItem("background"); +const content = document.getElementById("content"); +const server_indicator = document.getElementById("server-url"); +const mobile_input = document.getElementById("mobile-input"); var client; +var recv_buffer = []; +var send_buffer = []; + var my_colour = false; var pre_buffer_chars = 0; var server_url = ""; @@ -20,6 +26,7 @@ const DATA_TYPES = { backspace: 4, backword: 5, arrow: 6, + version: 7, }; /** @@ -49,8 +56,6 @@ to help you feel a little more comfortable, i've prepared some commands for you: server_url = new URL(window.location).searchParams.get("server") || window.location.host; - const foreground = localStorage.getItem("foreground"); - const background = localStorage.getItem("background"); if (foreground && background) { window.set_colours(foreground, background); } @@ -59,10 +64,6 @@ to help you feel a little more comfortable, i've prepared some commands for you: document.body.classList.add("lcd"); } - content = document.getElementById("content"); - server_indicator = document.getElementById("server-url"); - mobile_input = document.getElementById("mobile-input"); - document.addEventListener("keydown", handle_input); document.addEventListener("paste", handle_paste); @@ -87,9 +88,7 @@ to help you feel a little more comfortable, i've prepared some commands for you: } /** - * closes any existing websocket connection and attempts to create a new one. - * `wss://` is prefixed automatically if not present. - * insecure websockets are not recommended and support is not planned. + * closes any existing websocket connection and attempts to create a new one. * @param {string} server_url - the server websocket url to connect to. */ export async function connect(server_url) { @@ -111,106 +110,91 @@ export async function connect(server_url) { add_system_message("Connecting to the server...\n"); - let protocol = false; - - // check if user explicitly stated secure/insecure protocol - if (server_url.startsWith("wss://")) { - protocol = "wss://"; - server_url = server_url.split(6); - } else if (server_url.startsWith("ws://")) { - server_url = server_url.split(5); - protocol = "ws://"; - } - // otherwise, probe the url! - else protocol = await get_available_socket_protocol(server_url); - - // no server was found! - if (!protocol) { - return add_system_message(`\n[NO SERVER FOUND AT ${server_url}!]\n`); - } - - // server was found, but it's insecure! - if (protocol === "ws://") { - const warn_dialog = document.getElementById("warn-dialog"); - const warn_close = warn_dialog.getElementsByClassName("dialog-close").item(0); - const user_wants_insecure = await new Promise((Resolve, Reject) => { - const dialog_backdrop = document.getElementById("dialog-backdrop"); - const warn_proceed = document.getElementById("warn-proceed"); - const warn_cancel = document.getElementById("warn-cancel"); - - warn_dialog.classList.add("show"); - dialog_backdrop.classList.add("show"); - - set_enable_input(false); - - warn_close.addEventListener('click', () => { - Resolve(false); - }); - warn_cancel.addEventListener('click', () => { - Resolve(false); - }); - warn_proceed.addEventListener('click', () => { - Resolve(true); - }); + if (server_url.startsWith("wss://") || server_url.startsWith("ws://")) { + client = new WebSocket(server_url); + add_client_events(client); + client.addEventListener('error', () => { + add_system_message(`\nConnection failed!\n`); + add_system_message("Ensure you entered the correct server URL, or check the console for more details.\n"); }); - - warn_close.click(); - - set_enable_input(true); - - if (!user_wants_insecure) { - server_indicator.innerText = "not connected"; - add_system_message(`\n[CONNECTION CLOSED]\n`); - return; + } else { + try { + client = await find_socket_at_url(server_url); + } catch (error) { + if (error === 'deny-insecure') { + server_indicator.innerText = "not connected"; + add_system_message(`\n[CONNECTION CLOSED]\n`); + return + } + add_system_message(`\nConnection failed!\n`); + add_system_message("Ensure you entered the correct server URL, or check the console for more details.\n"); } } - client = new WebSocket(protocol + server_url); + set_enable_input(true); +} + +/** + * attaches initial client events (open, message, close) + * @param {WebSocket} client the client to bind events to. + */ +function add_client_events(client) { + client.addEventListener('open', async () => { + console.log(`Successfully connected to ${client.url}.`); - client.addEventListener('open', () => { server_indicator.innerText = server_url; add_system_message(`Connection successful.\n\n`); add_system_message(`=== BEGIN SESSION ===\n\n`); + + client.send(JSON.stringify({ + type: DATA_TYPES.version, + text: VERSION + })); + + client.addEventListener('message', handle_message); + + client.addEventListener('close', () => { + server_indicator.innerText = "not connected"; + add_system_message(`\n[CONNECTION CLOSED]\n`); + }); + new_caret(); }); - - client.addEventListener('message', handle_message); - - client.addEventListener('close', () => { - server_indicator.innerText = "not connected"; - add_system_message(`\n[CONNECTION CLOSED]\n`); - }); - - client.addEventListener('error', () => { - add_system_message(`\nConnection failed!\n`); - add_system_message("Ensure you entered the correct server URL, or check the console for more details.\n"); - }); } /** * probes the `server_url` for a secure websocket connection first, an insecure websocket second, or resolves to `false` on failure. * @param {string} server_url - * @returns a promise either resolving to the discovered protocol, or false on failure. + * @returns a promise either resolving to the created socket, or rejects to a string on failure. */ -function get_available_socket_protocol(server_url) { +function find_socket_at_url(server_url) { return new Promise((Resolve, Reject) => { const secure_client = new WebSocket("wss://" + server_url); + add_client_events(secure_client); secure_client.addEventListener('open', () => { - Resolve("wss://"); + Resolve(secure_client); }); - secure_client.addEventListener('error', () => { + secure_client.addEventListener('error', async () => { + set_enable_input(false); + if (!await user_confirm_insecure()) { + Reject('deny-insecure'); + set_enable_input(true); + return; + } + set_enable_input(true); + const insecure_client = new WebSocket("ws://" + server_url); + add_client_events(insecure_client); insecure_client.addEventListener('open', () => { - Resolve("ws://"); + Resolve(insecure_client); }); insecure_client.addEventListener('error', () => { - Reject(false); + Reject('error'); }); - }); }); } @@ -229,7 +213,7 @@ function add_system_message(text) { } /** - * the message handler for the websocket. + * handles incoming messages on the websocket. */ function handle_message(event) { var data; diff --git a/server/main.js b/server/main.js index 9993d64..ba992ce 100644 --- a/server/main.js +++ b/server/main.js @@ -3,6 +3,8 @@ const http = require('http'); const path = require('path'); const Websocket = require('ws'); +const VERSION = "1.1.0"; + const MIME_TYPES = { default: "application/octet-stream", html: "text/html; charset=UTF-8", @@ -23,6 +25,7 @@ const DATA_TYPES = { backspace: 4, backword: 5, arrow: 6, + version: 7, }; const MOTDS = [ @@ -43,7 +46,7 @@ const MOTDS = [ ]; const STATIC_PATH = path.join(process.cwd(), "public"); -const CACHE_MAX_AGE = 86400 // 1 day +const CACHE_MAX_AGE = 14400 // 4 hours const BANNER = `Welcome to OpenTerminal! @@ -58,9 +61,15 @@ Please acquire a genuine copy. This connection will now terminate. ========================================= `; +const VERSION_ERROR = +`Your client does not match this server's version of OpenTerminal (${VERSION})! +If you are connecting to this site's own OpenTerminal server, please refresh the page or clear your browser's cache to update. +` - -const PORT = process.env.PORT || 8080; +console.log("using env vars: " + Object.keys(process.env).filter(value => { return value.startsWith("OPENTERM_") }).join(', ')); +const HOST = process.env.OPENTERM_HOST || '0.0.0.0'; +const PORT = process.env.OPENTERM_PORT || 8080; +const TRUSTED_PROXIES = process.env.OPENTERM_TRUSTED_PROXIES ? process.env.OPENTERM_TRUSTED_PROXIES.split(',') : []; const PING_INTERVAL = 10000; let sockets = []; @@ -81,20 +90,28 @@ async function get_file(url) { // check for path traversal. path traversal is...bad. const path_traversal = !file_path.startsWith(STATIC_PATH); - const exists = await fs.promises.access(file_path).then(...[() => true, () => false]); + const exists = fs.existsSync(file_path) && fs.statSync(file_path).isFile(); if (path_traversal || !exists) return false; const ext = path.extname(file_path).substring(1).toLowerCase(); - const stream = fs.createReadStream(file_path); - return { stream, ext }; + + try { + const data = await fs.promises.readFile(file_path, { encoding: 'utf8' }); + return { data, ext }; + } catch (error) { + console.error(error); + return false; + } + } const server = http.createServer(async (req, res) => { + const request_time = new Date().getTime(); const file = await get_file(req.url); - if (!file) { + if (file === false) { res.writeHead(404); - res.end(); - return; + res.end("404 not found!"); + return log_request(req, res, request_time); } const mime_type = MIME_TYPES[file.ext] || MIME_TYPES.default; res.writeHead(200, { @@ -102,12 +119,26 @@ const server = http.createServer(async (req, res) => { "Cache-Control": `max-age=${CACHE_MAX_AGE}`, "Server": "OpenTerminal", }); - file.stream.pipe(res); - // console.log(`${req.method} - ${req.url}`); + res.write(file.data); + res.end(); + return log_request(req, res, request_time); }); +function log_request(req, res, time) { + const elapsed = new Date().getTime() - time; + console.log(`${new Date().toISOString()} - ${req.method} ${req.url} - ${res.statusCode} - ${get_real_address(req)} in ${elapsed}ms`); +} + +function get_real_address(req) { + if (TRUSTED_PROXIES.indexOf(req.socket.localAddress) !== -1 && req.headers['x-forwarded-for']) { + return req.headers['x-forwarded-for']; + } + return req.socket.localAddress; +} + const wss = new Websocket.Server({ server }); -wss.on('connection', socket => { +wss.on('connection', (socket, req) => { + console.log(`${new Date().toISOString()} - WS OPEN - ${get_real_address(req)} (active connections: ${sockets.length + 1})`); /* socket.colour = generate_colour(); socket.send(JSON.stringify({ @@ -128,18 +159,16 @@ wss.on('connection', socket => { })); } - const ping_interval = setInterval( + const ping_routine = setInterval( function() { socket.send(JSON.stringify({ type: DATA_TYPES.ping, })) }, PING_INTERVAL); - socket.ping_interval = ping_interval; + socket.ping_routine = ping_routine; sockets.push(socket); - // console.log(`new connection.\n\tcurrent connections: ${sockets.length}`); - socket.on('message', event => { try { handle_message(JSON.parse(event), socket) @@ -153,7 +182,7 @@ wss.on('connection', socket => { }); socket.on('close', () => { - clearInterval(socket.ping_interval); + clearInterval(socket.ping_routine); sockets = sockets.filter(s => s !== socket); // console.log(`connection closed.\n\tcurrent connections: ${sockets.length}`); }); @@ -163,6 +192,27 @@ wss.on('connection', socket => { * handles parsed JSON data sent by the client. */ function handle_message(data, user) { + if (user.version === undefined) { + if (data.type !== DATA_TYPES.version) { + user.send(JSON.stringify({ + type: DATA_TYPES.text, + text: VERSION_ERROR + })); + user.close(); + return; + } else if (data.text !== VERSION) { + user.send(JSON.stringify({ + type: DATA_TYPES.text, + text: VERSION_ERROR + })); + user.close(); + return; + } else { + user.version = data.text; + return; + } + } + switch (data.type) { case DATA_TYPES.backword: var break_point = buffer.lastIndexOf(" "); @@ -220,8 +270,9 @@ function generate_colour() { return result; } -server.listen(PORT, () => { - console.log(`OpenTerminal is now LIVE on http://127.0.0.1:${PORT}`); +server.listen(PORT, HOST, () => { + console.log(`OpenTerminal is now LIVE on http://${HOST === '0.0.0.0' ? '127.0.0.1' : HOST}:${PORT}`); + if (TRUSTED_PROXIES.length > 0) console.log(`Using X-Forwarded-For headers for hosts: ${TRUSTED_PROXIES.join(", ")}`); }); /**