mirror of
https://github.com/DumbWareio/DumbTerm.git
synced 2026-01-16 17:01:52 +08:00
571 lines
18 KiB
JavaScript
571 lines
18 KiB
JavaScript
require('dotenv').config();
|
|
const express = require('express');
|
|
const session = require('express-session');
|
|
const crypto = require('crypto');
|
|
const path = require('path');
|
|
const cookieParser = require('cookie-parser');
|
|
const cors = require('cors');
|
|
const { WebSocketServer } = require('ws');
|
|
const http = require('http');
|
|
const pty = require('node-pty');
|
|
const os = require('os');
|
|
const { generatePWAManifest } = require("./scripts/pwa-manifest-generator");
|
|
const { originValidationMiddleware, getCorsOptions, validateOrigin } = require('./scripts/cors');
|
|
|
|
const app = express();
|
|
const server = http.createServer(app);
|
|
|
|
const PORT = process.env.PORT || 3000;
|
|
const DEBUG = process.env.DEBUG === 'TRUE';
|
|
const NODE_ENV = process.env.NODE_ENV || 'production';
|
|
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
|
const DEMO_MODE = process.env.DEMO_MODE === 'true';
|
|
const SITE_TITLE = DEMO_MODE ? `${process.env.SITE_TITLE || 'DumbTerm'} (DEMO)` : (process.env.SITE_TITLE || 'DumbTerm');
|
|
const APP_VERSION = require('./package.json').version;
|
|
const PUBLIC_DIR = path.join(__dirname, 'public');
|
|
const ptyModule = DEMO_MODE ? require('./scripts/demo/terminal') : pty;
|
|
const ASSETS_DIR = path.join(PUBLIC_DIR, 'assets');
|
|
const CACHE_NAME = `DUMBTERM_CACHE_V${APP_VERSION}`;
|
|
|
|
generatePWAManifest(SITE_TITLE);
|
|
|
|
function debugLog(...args) {
|
|
if (DEBUG) {
|
|
console.log('[DEBUG]', ...args);
|
|
}
|
|
}
|
|
|
|
// Base URL configuration
|
|
const BASE_PATH = (() => {
|
|
if (!BASE_URL) {
|
|
debugLog('No BASE_URL set, using empty base path');
|
|
return '';
|
|
}
|
|
try {
|
|
const url = new URL(BASE_URL);
|
|
const path = url.pathname.replace(/\/$/, ''); // Remove trailing slash
|
|
debugLog('Base URL Configuration:', {
|
|
originalUrl: BASE_URL,
|
|
extractedPath: path,
|
|
protocol: url.protocol,
|
|
hostname: url.hostname
|
|
});
|
|
return path;
|
|
} catch {
|
|
// If BASE_URL is just a path (e.g. /app)
|
|
const path = BASE_URL.replace(/\/$/, '');
|
|
debugLog('Using direct path as BASE_URL:', path);
|
|
return path;
|
|
}
|
|
})();
|
|
|
|
// Get the project name from package.json to use for the PIN environment variable
|
|
const projectName = require('./package.json').name.toUpperCase().replace(/-/g, '_');
|
|
const PIN = process.env[`${projectName}_PIN`];
|
|
const isPinRequired = PIN && PIN.trim() !== '';
|
|
|
|
// Log whether PIN protection is enabled
|
|
if (isPinRequired) {
|
|
debugLog('PIN protection is enabled, PIN length:', PIN.length);
|
|
} else {
|
|
debugLog('PIN protection is disabled');
|
|
}
|
|
|
|
// Brute force protection
|
|
const loginAttempts = new Map();
|
|
const MAX_ATTEMPTS = 5;
|
|
const LOCKOUT_TIME = (process.env.LOCKOUT_TIME || 15) * 60 * 1000; // default 15 minutes in milliseconds
|
|
const MAX_SESSION_AGE = (process.env.MAX_SESSION_AGE || 24) * 60 * 60 * 1000 // default 24 hours
|
|
|
|
function resetAttempts(ip) {
|
|
debugLog('Resetting login attempts for IP:', ip);
|
|
loginAttempts.delete(ip);
|
|
}
|
|
|
|
function isLockedOut(ip) {
|
|
const attempts = loginAttempts.get(ip);
|
|
if (!attempts) return false;
|
|
|
|
if (attempts.count >= MAX_ATTEMPTS) {
|
|
const timeElapsed = Date.now() - attempts.lastAttempt;
|
|
if (timeElapsed < LOCKOUT_TIME) {
|
|
debugLog('IP is locked out:', ip, 'Time remaining:', Math.ceil((LOCKOUT_TIME - timeElapsed) / 1000 / 60), 'minutes');
|
|
return true;
|
|
}
|
|
resetAttempts(ip);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function recordAttempt(ip) {
|
|
const attempts = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
|
|
attempts.count += 1;
|
|
attempts.lastAttempt = Date.now();
|
|
loginAttempts.set(ip, attempts);
|
|
debugLog('Login attempt recorded for IP:', ip, 'Count:', attempts.count);
|
|
}
|
|
|
|
// Security middleware
|
|
app.set('trust proxy', 1);
|
|
app.use(cors(getCorsOptions(BASE_URL)));
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
|
|
// Session configuration with secure settings
|
|
app.use(session({
|
|
secret: crypto.randomBytes(32).toString('hex'),
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: {
|
|
secure: (BASE_URL.startsWith('https') && NODE_ENV === 'production'),
|
|
httpOnly: true,
|
|
sameSite: 'strict',
|
|
maxAge: MAX_SESSION_AGE
|
|
}
|
|
}));
|
|
|
|
const requirePin = (req, res, next) => {
|
|
if (!PIN || !isValidPin(PIN)) {
|
|
return next();
|
|
}
|
|
|
|
const authCookie = req.cookies[COOKIE_NAME];
|
|
if (!authCookie || !secureCompare(authCookie, PIN)) {
|
|
return res.status(401).json({ error: 'Unauthorized' });
|
|
}
|
|
next();
|
|
};
|
|
|
|
// Constant-time PIN comparison to prevent timing attacks
|
|
const verifyPin = (storedPin, providedPin) => {
|
|
if (!storedPin || !providedPin) return false;
|
|
if (storedPin.length !== providedPin.length) return false;
|
|
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(storedPin),
|
|
Buffer.from(providedPin)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Authentication middleware
|
|
const authMiddleware = (req, res, next) => {
|
|
debugLog('Auth check for path:', req.path, 'Method:', req.method);
|
|
|
|
// If no PIN is set or PIN is empty, bypass authentication completely
|
|
if (!PIN || PIN.trim() === '') {
|
|
debugLog('Auth bypassed - No PIN configured');
|
|
req.session.authenticated = true; // Set session as authenticated
|
|
return next();
|
|
}
|
|
|
|
// First check if user is authenticated via session
|
|
if (req.session.authenticated) {
|
|
debugLog('Auth successful - Valid session found');
|
|
return next();
|
|
}
|
|
|
|
// If not authenticated via session, check for valid PIN cookie
|
|
const pinCookie = req.cookies[`${projectName}_PIN`];
|
|
if (pinCookie && verifyPin(PIN, pinCookie)) {
|
|
debugLog('Auth successful - Valid PIN cookie found, restoring session');
|
|
req.session.authenticated = true;
|
|
return next();
|
|
}
|
|
|
|
// No valid session or PIN cookie found
|
|
debugLog('Auth failed - No valid session or PIN cookie, redirecting to login');
|
|
return res.redirect(BASE_PATH + '/login');
|
|
};
|
|
|
|
// Global middleware for origin validation and authentication
|
|
app.use(BASE_PATH, (req, res, next) => {
|
|
// List of paths that should be publicly accessible
|
|
const publicPaths = [
|
|
'/login',
|
|
'/pin-length',
|
|
'/verify-pin',
|
|
'/config.js',
|
|
'/assets/',
|
|
'/fonts/',
|
|
'/styles.css',
|
|
'/manifest.json',
|
|
'/asset-manifest.json',
|
|
];
|
|
|
|
// Check if the current path matches any of the public paths
|
|
if (publicPaths.some(path => req.path.startsWith(path))) {
|
|
return next();
|
|
}
|
|
|
|
// For all other paths, apply both origin validation and auth middleware
|
|
originValidationMiddleware(req, res, () => {
|
|
authMiddleware(req, res, next);
|
|
});
|
|
});
|
|
|
|
// Routes start here...
|
|
app.get(BASE_PATH + '/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
// Serve config.js for frontend
|
|
app.get(BASE_PATH + '/config.js', (req, res) => {
|
|
debugLog('Serving config.js with basePath:', BASE_PATH);
|
|
const appConfig = {
|
|
basePath: BASE_PATH,
|
|
debug: DEBUG,
|
|
siteTitle: SITE_TITLE,
|
|
isPinRequired: isPinRequired,
|
|
isDemoMode: DEMO_MODE,
|
|
version: APP_VERSION,
|
|
cacheName: CACHE_NAME
|
|
}
|
|
|
|
res.type('application/javascript').send(`
|
|
window.appConfig = ${JSON.stringify(appConfig)};
|
|
`);
|
|
});
|
|
|
|
// Serve static files for public assets
|
|
app.use(BASE_PATH + '/', express.static(path.join(PUBLIC_DIR)));
|
|
app.get(BASE_PATH + "/manifest.json", (req, res) => {
|
|
res.sendFile(path.join(ASSETS_DIR, "manifest.json"));
|
|
});
|
|
app.get(BASE_PATH + "/asset-manifest.json", (req, res) => {
|
|
res.sendFile(path.join(ASSETS_DIR, "asset-manifest.json"));
|
|
});
|
|
// Serve font files
|
|
app.use('/fonts', express.static(path.join(PUBLIC_DIR, 'fonts')));
|
|
app.use('/node_modules/@xterm/', express.static(
|
|
path.join(__dirname, 'node_modules/@xterm/')
|
|
));
|
|
|
|
// Routes
|
|
app.get(BASE_PATH + '/login', (req, res) => {
|
|
// Check if PIN is required
|
|
if (!PIN || PIN.trim() === '') {
|
|
return res.redirect(BASE_PATH + '/');
|
|
}
|
|
|
|
// Check authentication first
|
|
if (req.session.authenticated) {
|
|
return res.redirect(BASE_PATH + '/');
|
|
}
|
|
|
|
|
|
// Check if user is already authenticated by PIN
|
|
const pinCookie = req.cookies[`${projectName}_PIN`];
|
|
if (verifyPin(PIN, pinCookie)) {
|
|
return res.redirect(BASE_PATH + '/');
|
|
}
|
|
|
|
res.sendFile(path.join(__dirname, 'public', 'login.html'));
|
|
});
|
|
|
|
app.get(BASE_PATH + '/pin-length', (req, res) => {
|
|
// If no PIN is set, return 0 length
|
|
if (!PIN || PIN.trim() === '') {
|
|
return res.json({ length: 0 });
|
|
}
|
|
res.json({ length: PIN.length });
|
|
});
|
|
|
|
app.post(BASE_PATH + '/verify-pin', (req, res) => {
|
|
debugLog('PIN verification attempt from IP:', req.ip);
|
|
|
|
// If no PIN is set, authentication is successful
|
|
if (!PIN || PIN.trim() === '') {
|
|
debugLog('PIN verification bypassed - No PIN configured');
|
|
req.session.authenticated = true;
|
|
return res.status(200).json({ success: true });
|
|
}
|
|
|
|
const ip = req.ip;
|
|
|
|
// Check if IP is locked out
|
|
if (isLockedOut(ip)) {
|
|
const attempts = loginAttempts.get(ip);
|
|
const timeLeft = Math.ceil((LOCKOUT_TIME - (Date.now() - attempts.lastAttempt)) / 1000 / 60);
|
|
debugLog('PIN verification blocked - IP is locked out:', ip);
|
|
return res.status(429).json({
|
|
error: `Too many attempts. Please try again in ${timeLeft} minutes.`
|
|
});
|
|
}
|
|
|
|
const { pin } = req.body;
|
|
|
|
if (!pin || typeof pin !== 'string') {
|
|
debugLog('PIN verification failed - Invalid PIN format');
|
|
return res.status(400).json({ error: 'Invalid PIN format' });
|
|
}
|
|
|
|
// Verify PIN first
|
|
const isPinValid = verifyPin(PIN, pin);
|
|
|
|
if (isPinValid) {
|
|
debugLog('PIN verification successful');
|
|
// Reset attempts on successful login
|
|
resetAttempts(ip);
|
|
|
|
// Set authentication in session immediately
|
|
req.session.authenticated = true;
|
|
|
|
// Set secure cookie
|
|
res.cookie(`${projectName}_PIN`, pin, {
|
|
httpOnly: true,
|
|
secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'),
|
|
sameSite: 'strict',
|
|
maxAge: MAX_SESSION_AGE
|
|
});
|
|
|
|
// Add artificial delay before sending response
|
|
setTimeout(() => {
|
|
// Redirect to main page on success
|
|
res.redirect(BASE_PATH + '/');
|
|
}, crypto.randomInt(50, 150));
|
|
} else {
|
|
debugLog('PIN verification failed - Invalid PIN');
|
|
// Record failed attempt
|
|
recordAttempt(ip);
|
|
|
|
const attempts = loginAttempts.get(ip);
|
|
const attemptsLeft = MAX_ATTEMPTS - attempts.count;
|
|
|
|
// Add artificial delay before sending error response
|
|
setTimeout(() => {
|
|
res.status(401).json({
|
|
error: 'Invalid PIN',
|
|
attemptsLeft: Math.max(0, attemptsLeft)
|
|
});
|
|
}, crypto.randomInt(50, 150));
|
|
}
|
|
});
|
|
|
|
app.get(BASE_PATH + '/api/require-pin', (req, res) => {
|
|
// If no PIN is set, return success
|
|
if (!PIN || PIN.trim() === '') {
|
|
return res.json({ success: true, required: false });
|
|
}
|
|
|
|
// Check for PIN cookie
|
|
const pinCookie = req.cookies[`${projectName}_PIN`];
|
|
if (!pinCookie || !verifyPin(PIN, pinCookie)) {
|
|
return res.json({ success: false, required: true });
|
|
}
|
|
|
|
// Valid PIN cookie found
|
|
return res.json({ success: true, required: true });
|
|
});
|
|
|
|
// Logout endpoint
|
|
app.post(BASE_PATH + '/logout', (req, res) => {
|
|
debugLog('Logout request received');
|
|
|
|
const cookieOptions = {
|
|
httpOnly: true,
|
|
secure: req.secure || (BASE_URL.startsWith('https') && NODE_ENV === 'production'),
|
|
sameSite: 'strict',
|
|
path: BASE_PATH || '/', // Match the path used when setting cookies
|
|
expires: new Date(0), // Immediately expire the cookie
|
|
maxAge: 0 // Belt and suspenders - also set maxAge to 0
|
|
};
|
|
|
|
// Clear both cookies with consistent options
|
|
res.clearCookie(`${projectName}_PIN`, cookieOptions);
|
|
res.clearCookie('connect.sid', cookieOptions);
|
|
|
|
// Destroy the session and wait for it to be destroyed before sending response
|
|
req.session.destroy((err) => {
|
|
if (err) {
|
|
debugLog('Error destroying session:', err);
|
|
return res.status(500).json({ success: false, error: 'Failed to logout properly' });
|
|
}
|
|
debugLog('Session and cookies cleared successfully');
|
|
res.json({ success: true });
|
|
});
|
|
});
|
|
|
|
// WebSocket server configuration
|
|
const wss = new WebSocketServer({
|
|
server,
|
|
verifyClient: (info, callback) => {
|
|
debugLog('Verifying WebSocket connection from:', info.req.headers.origin);
|
|
|
|
const isOriginValid = validateOrigin(info.req.headers.origin);
|
|
if (isOriginValid) callback(true); // allow the connection
|
|
else {
|
|
console.warn("Blocked connection from origin:", {origin});
|
|
callback(false, 403, 'Forbidden'); // reject the connection
|
|
}
|
|
}
|
|
});
|
|
|
|
// WebSocket connection handling
|
|
wss.on('connection', (ws, req) => {
|
|
debugLog('New WebSocket connection attempt');
|
|
|
|
// Keep track of connection status
|
|
ws.isAlive = true;
|
|
|
|
// Setup ping/pong heartbeat
|
|
ws.on('pong', () => {
|
|
ws.isAlive = true;
|
|
debugLog('Received pong from client');
|
|
});
|
|
|
|
// Handle connection errors
|
|
ws.on('error', (error) => {
|
|
debugLog('WebSocket error:', error);
|
|
ws.terminate();
|
|
});
|
|
|
|
// If no PIN is set, allow the connection
|
|
if (!PIN || PIN.trim() === '') {
|
|
debugLog('No PIN required, creating terminal');
|
|
createTerminal(ws);
|
|
return;
|
|
}
|
|
|
|
if (!req.headers.cookie) {
|
|
debugLog('No cookies found, closing connection');
|
|
ws.close(1008, 'Authentication required'); // Use 1008 for policy violation
|
|
return;
|
|
}
|
|
|
|
// Parse the cookies
|
|
const parsedCookies = {};
|
|
req.headers.cookie.split(';').forEach(cookie => {
|
|
const parts = cookie.split('=');
|
|
parsedCookies[parts[0].trim()] = parts[1].trim();
|
|
});
|
|
|
|
// Check if the user has the correct PIN cookie
|
|
const pinCookie = parsedCookies[`${projectName}_PIN`];
|
|
if (!pinCookie || !verifyPin(PIN, pinCookie)) {
|
|
debugLog('Invalid PIN cookie, closing connection');
|
|
ws.close(1008, 'Authentication required'); // Use 1008 for policy violation
|
|
return;
|
|
}
|
|
|
|
debugLog('Authentication successful, creating terminal');
|
|
createTerminal(ws);
|
|
});
|
|
|
|
// Heartbeat check interval with more frequent checks
|
|
const heartbeatInterval = setInterval(() => {
|
|
wss.clients.forEach((ws) => {
|
|
if (ws.isAlive === false) {
|
|
debugLog('Terminating inactive WebSocket connection');
|
|
return ws.terminate();
|
|
}
|
|
ws.isAlive = false;
|
|
try {
|
|
ws.ping();
|
|
} catch (err) {
|
|
debugLog('Error sending ping:', err);
|
|
ws.terminate();
|
|
}
|
|
});
|
|
}, 15000); // Check every 15 seconds
|
|
|
|
// Clean up interval on server shutdown
|
|
process.on('SIGTERM', () => {
|
|
clearInterval(heartbeatInterval);
|
|
wss.close();
|
|
});
|
|
|
|
// Terminal creation helper function
|
|
function createTerminal(ws) {
|
|
// Create terminal process
|
|
const shell = process.env.SHELL || (os.platform() === 'win32' ? 'powershell.exe' : 'bash');
|
|
|
|
const term = ptyModule.spawn(shell, [], {
|
|
name: 'xterm-256color',
|
|
cols: 80,
|
|
rows: 24,
|
|
cwd: DEMO_MODE ? '/home/demo' : (process.env.HOME || '/root'),
|
|
env: {
|
|
...process.env,
|
|
TERM: 'xterm-256color',
|
|
COLORTERM: 'truecolor',
|
|
LANG: 'en_US.UTF-8',
|
|
// Force buffer flushing for better alternate buffer handling
|
|
STDBUF: 'L',
|
|
// Ensure proper handling of alternate buffer in applications
|
|
TERM_PROGRAM: 'xterm-256color'
|
|
}
|
|
});
|
|
|
|
debugLog(`Terminal created with PID: ${term.pid}${DEMO_MODE ? ' (Demo Mode)' : ''}`);
|
|
|
|
// Handle incoming data from client
|
|
ws.on('message', (data) => {
|
|
try {
|
|
const message = JSON.parse(data);
|
|
switch(message.type) {
|
|
case 'input':
|
|
term.write(message.data);
|
|
break;
|
|
case 'resize':
|
|
term.resize(message.cols, message.rows);
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
debugLog('Error processing WebSocket message:', error);
|
|
}
|
|
});
|
|
|
|
// Handle terminal data with control sequence filtering
|
|
term.on('data', (data) => {
|
|
if (ws.readyState === ws.OPEN) {
|
|
// Raw send without any filtering - let the client handle buffer switches
|
|
ws.send(JSON.stringify({
|
|
type: 'output',
|
|
data: data
|
|
}));
|
|
}
|
|
});
|
|
|
|
// Clean up on close
|
|
ws.on('close', () => {
|
|
debugLog('WebSocket closed, killing terminal process:', term.pid);
|
|
term.kill();
|
|
});
|
|
|
|
// Handle terminal exit
|
|
term.on('exit', (code) => {
|
|
debugLog('Terminal process exited with code:', code);
|
|
if (ws.readyState === ws.OPEN) {
|
|
ws.close();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Cleanup old lockouts periodically
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, attempts] of loginAttempts.entries()) {
|
|
if (now - attempts.lastAttempt >= LOCKOUT_TIME) {
|
|
loginAttempts.delete(ip);
|
|
}
|
|
}
|
|
}, 60000); // Clean up every minute
|
|
|
|
// Update server.listen to use the http server
|
|
server.listen(PORT, () => {
|
|
debugLog('Server Configuration:', {
|
|
port: PORT,
|
|
basePath: BASE_PATH,
|
|
pinProtection: isPinRequired,
|
|
nodeEnv: NODE_ENV,
|
|
debug: DEBUG,
|
|
demoMode: DEMO_MODE,
|
|
version: APP_VERSION,
|
|
cacheName: CACHE_NAME
|
|
});
|
|
console.log(`Server running on: ${BASE_URL}`);
|
|
}); |