Fix PyInstaller build issues and asyncio warnings

- Fix missing websockets import in wsssd/server.py causing 'name websockets is not defined' error
- Resolve asyncio runtime warnings by properly awaiting cancelled tasks in shutdown handling
- Fix global variable sharing issue in frozen application by passing server password as parameter to websocket handler
- Improve WebSocket handler signature compatibility with functools.partial for proper function binding
- Update CHANGELOG.md and TODO.md with version 1.4.9 changes
parent 6516a011
......@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.9] - 2025-09-17
### Fixed
- **PyInstaller Build Issues**: Critical fixes for frozen application deployment
- Fixed missing `websockets` import in `wsssd/server.py` causing "name 'websockets' is not defined" error
- Resolved asyncio runtime warnings by properly awaiting cancelled tasks in shutdown handling
- Fixed global variable sharing issue in frozen application by passing server password as parameter to websocket handler
- Improved WebSocket handler signature compatibility with `functools.partial` for proper function binding
### Technical Details
- **Import Management**: Added explicit `websockets` import to server.py for PyInstaller compatibility
- **Async Task Cleanup**: Proper awaiting of cancelled asyncio tasks to prevent runtime warnings
- **Global Variable Isolation**: Resolved PyInstaller global variable sharing limitations by using parameter passing
- **Function Signature Compatibility**: Updated websocket handler to use keyword-only parameters for `functools.partial` binding
## [1.4.8] - 2025-09-17
### Fixed
......
......@@ -11,6 +11,7 @@
7. [Security](#security)
8. [Troubleshooting](#troubleshooting)
9. [Development](#development)
10. [Recent Updates](#recent-updates)
## Overview
......@@ -42,6 +43,9 @@ WebSocket SSH (wsssh) is a tunneling system that enables secure SSH/SCP access t
- **Real-time Resize Support**: Terminal resizes dynamically with browser window
- **Force Echo Mode**: SSH connections force echo mode for better visibility
- **Enhanced Logging**: Complete logrotate integration for daemon processes
- **SSL Connection Stability**: Advanced SSL error handling and connection resilience
- **Process Exit Handling**: Fixed critical process hanging issues
- **Code Architecture**: Modular design with shared libraries for better maintainability
- **Cross-platform**: Works on Linux, macOS, and Windows
- **Donation Support**: Community funding through PayPal and cryptocurrency
......@@ -339,11 +343,13 @@ id = client1
#### Command Line Options
```bash
wsssh [--local-port PORT] [--debug] user@client.domain [ssh_options...]
wsssh [--local-port PORT] [--debug] [--dev-tunnel] [--help] user@client.domain [ssh_options...]
```
- `--local-port PORT`: Local tunnel port (default: auto-assign)
- `--debug`: Enable debug output
- `--dev-tunnel`: Development mode: setup tunnel but don't launch SSH
- `--help`: Display help message and exit
#### Environment Variables
......@@ -354,11 +360,13 @@ wsssh [--local-port PORT] [--debug] user@client.domain [ssh_options...]
#### Command Line Options
```bash
wsscp [--local-port PORT] [--debug] [SCP_OPTIONS...] SOURCE... DESTINATION
wsscp [--local-port PORT] [--debug] [--dev-tunnel] [--help] [SCP_OPTIONS...] SOURCE... DESTINATION
```
- `--local-port PORT`: Local tunnel port (default: auto-assign)
- `--debug`: Enable debug output
- `--dev-tunnel`: Development mode: setup tunnel but don't launch SCP
- `--help`: Display help message and exit
### C Implementation Tools
......@@ -695,8 +703,14 @@ Enable debug output for detailed troubleshooting:
# wsssh debug
./wsssh --debug user@client.example.com -p 9898
# wsssh dev-tunnel mode (setup tunnel without launching SSH)
./wsssh --dev-tunnel user@client.example.com -p 9898
# wsscp debug
./wsscp --debug file user@client.example.com:/path/ -P 9898
# wsscp dev-tunnel mode (setup tunnel without launching SCP)
./wsscp --dev-tunnel file user@client.example.com:/path/ -P 9898
```
### Log Analysis
......@@ -723,9 +737,9 @@ wsssh/
├── templates/ # Flask web templates
├── static/ # Web assets
├── wssshtools/ # C implementation directory
│ ├── wssshc.c # C client (280 lines)
│ ├── wsssh.c # C SSH wrapper (378 lines)
│ ├── wsscp.c # C SCP wrapper (418 lines)
│ ├── wssshc.c # C client for registration
│ ├── wsssh.c # C SSH wrapper
│ ├── wsscp.c # C SCP wrapper
│ ├── wssshlib.h # Shared utilities library header
│ ├── wssshlib.c # Shared utilities library implementation
│ ├── websocket.h # WebSocket functions library header
......@@ -736,13 +750,14 @@ wsssh/
│ ├── tunnel.c # Tunnel management library implementation
│ ├── configure.sh # Build configuration
│ ├── Makefile # GNU Make build system
│ ├── common.c # Common utilities
│ └── debian/ # Debian packaging for wsssh-tools
│ ├── control # Package metadata
│ ├── rules # Build rules
│ ├── changelog # Package changelog
│ ├── copyright # Copyright info
│ └── compat # Debhelper compatibility
├── wsssh-server/ # wsssh-server Debian package (v1.4.0)
├── wsssh-server/ # wsssh-server Debian package
│ ├── debian/ # Debian packaging for wssshd daemon
│ │ ├── control # Package metadata
│ │ ├── rules # Build rules with PyInstaller
......@@ -754,6 +769,7 @@ wsssh/
│ │ ├── postrm # Post-removal script
│ │ └── wssshd.default # Service defaults
│ └── PyInstaller/ # PyInstaller spec files
├── wssshc.conf.example # Example wssshc configuration file
├── .gitignore # Git ignore rules
├── LICENSE.md # GPLv3 license
├── README.md # Main documentation
......@@ -909,6 +925,66 @@ python3 -m pytest tests/integration/
---
## Recent Updates
### Version 1.4.8 (Latest)
- **Critical SSL Connection Stability**: Comprehensive SSL error handling and connection resilience improvements
- Fixed WebSocket frame sending failures that caused connection drops
- Added detailed SSL error reporting with specific error codes and descriptions
- Implemented automatic retry logic for transient SSL errors (SSL_ERROR_WANT_READ/WRITE)
- Added 5-second timeout protection for SSL read operations to prevent indefinite hangs
- Enhanced connection state validation before SSL operations
- Improved WebSocket frame transmission with retry mechanisms and partial write handling
### Version 1.4.7
- **Critical Process Exit Bug Fix**: Fixed wsssh process hanging after SSH client disconnection
- Added `broken` flag to tunnel structure to distinguish between normal closure and broken connections
- Implemented proper tunnel state tracking for EBADF, EPIPE, and ECONNRESET errors
- Enhanced error handling in `handle_tunnel_data()` for SSH client disconnections
- Fixed main loop to immediately kill SSH child process and exit when tunnel breaks
- Added proper exit code handling: 0 for normal termination, 1 for error conditions
### Version 1.4.6
- **Code Architecture Overhaul**: Major refactoring to eliminate code duplication
- Created shared libraries: `wssshlib.h/.c`, `websocket.h/.c`, `wssh_ssl.h/.c`, `tunnel.h/.c`
- Extracted ~1500+ lines of duplicate code between wsssh.c and wssshc.c
- Improved maintainability and code organization with modular architecture
- Enhanced build system with proper library dependencies
- **SSH Tunneling Enhancements**: Enhanced SSH tunnel handling and error recovery
- Added specific handling for EBADF (Bad file descriptor) errors
- Improved error handling in handle_tunnel_data for SSH client disconnections
- Removed aggressive socket validation causing SSH client disconnections
- Fixed SSH client socket invalidation issue in handle_tunnel_data
- Added missing SSH client accept() logic in wsssh socket handling
- Fixed SSH tunneling timing issue with proper data buffering
- **Port Option Behavior Correction**: Fixed critical port option behavior
- `-p`/`-P` options now correctly specify wssshd server port (not SSH/SCP server port)
- Removed misleading "passed through to ssh/scp" documentation
- Updated help text to clearly indicate port options are consumed by wsssh/wsscp
- Fixed argument parsing to properly handle wssshd server port specification
### Version 1.4.5
- **Python Implementation Removal**: Removed Python implementations of wssshc, wsssh, and wsscp
- Deleted wssshc.py, wsssh.py, and wsscp.py files
- Removed pyinstaller commands from build.sh for these tools
- Updated Debian packaging to exclude Python script installations
- Maintained C implementations in wssshtools/ directory
### Version 1.4.4
- **Advanced Logging with Logrotate**: Comprehensive logging system for all daemons
- Added `/var/log/wssshd/wssshd.log` for main daemon logging with automatic rotation
- Added `/var/log/wssshc/wssshc.log` for client daemon logging with automatic rotation
- Configured logrotate with weekly rotation, 52-week retention, and automatic compression
- Proper file permissions and ownership for security
- **Dynamic Terminal Sizing**: Web terminal now properly calculates and uses actual browser window dimensions
- **Terminal Resize Support**: Real-time terminal resizing when browser window is resized
- **Force Echo Mode**: SSH connections now force echo mode for better visibility
---
## Donations
If you find WebSocket SSH useful, please consider supporting the project development:
......
......@@ -316,6 +316,8 @@ Command line options override configuration file values. Required parameters are
**Options:**
- `--local-port`: Local tunnel port (default: auto)
- `--debug`: Enable debug output
- `--dev-tunnel`: Development mode: setup tunnel but don't launch SSH
- `--help`: Display help message and exit
**Examples:**
```bash
......@@ -341,6 +343,8 @@ Command line options override configuration file values. Required parameters are
**Options:**
- `--local-port`: Local tunnel port (default: auto)
- `--debug`: Enable debug output
- `--dev-tunnel`: Development mode: setup tunnel but don't launch SCP
- `--help`: Display help message and exit
**Examples:**
```bash
......@@ -463,6 +467,9 @@ Enable debug output for detailed troubleshooting:
./wssshd --debug --host 0.0.0.0 --port 9898 --domain example.com --password mysecret
./wssshc --debug --server-ip <ip> --port 9898 --id client1 --password mysecret
./wsssh --debug -p 9898 user@client.domain
./wsssh --dev-tunnel -p 9898 user@client.domain # Setup tunnel without launching SSH
./wsscp --debug -P 9898 file user@client.domain:/path/
./wsscp --dev-tunnel -P 9898 file user@client.domain:/path/ # Setup tunnel without launching SCP
```
### Common Issues
......@@ -494,7 +501,7 @@ python3 -m pytest
```
wsssh/
├── wssshd.py # WebSocket SSH Daemon with web interface
├── build.sh # Build script (supports --debian flag)
├── build.sh # Build script (supports --debian, --server-only)
├── clean.sh # Clean script
├── requirements.txt # Python dependencies
├── cert.pem # SSL certificate (auto-generated)
......@@ -518,6 +525,7 @@ wsssh/
│ ├── wssh_ssl.c # SSL functions library implementation
│ ├── tunnel.h # Tunnel management library header
│ ├── tunnel.c # Tunnel management library implementation
│ ├── common.c # Common utilities
│ ├── configure.sh # C build configuration
│ ├── Makefile # C build system
│ └── debian/ # Debian packaging
......@@ -526,6 +534,19 @@ wsssh/
│ ├── changelog # Package changelog
│ ├── copyright # Copyright info
│ └── compat # Debhelper compatibility
├── wsssh-server/ # wsssh-server Debian package
│ ├── debian/ # Debian packaging for wssshd daemon
│ │ ├── control # Package metadata
│ │ ├── rules # Build rules with PyInstaller
│ │ ├── changelog # Package changelog
│ │ ├── copyright # Copyright info
│ │ ├── compat # Debhelper compatibility
│ │ ├── wssshd.1 # Man page for wssshd
│ │ ├── postinst # Post-installation script
│ │ ├── postrm # Post-removal script
│ │ └── wssshd.default # Service defaults
│ └── PyInstaller/ # PyInstaller spec files
├── wssshc.conf.example # Example wssshc configuration file
├── .gitignore # Git ignore rules
├── LICENSE.md # GPLv3 license
├── README.md # This file
......@@ -597,7 +618,17 @@ Your support helps us continue developing and maintaining this open-source proje
## Changelog
### Version 1.4.7 (Latest)
### Version 1.4.8 (Latest)
**Critical SSL Connection Stability:**
- Fixed WebSocket frame sending failures that caused connection drops
- Added detailed SSL error reporting with specific error codes and descriptions
- Implemented automatic retry logic for transient SSL errors (SSL_ERROR_WANT_READ/WRITE)
- Added 5-second timeout protection for SSL read operations to prevent indefinite hangs
- Enhanced connection state validation before SSL operations
- Improved WebSocket frame transmission with retry mechanisms and partial write handling
### Version 1.4.7
**Critical Bug Fix:**
- **Process Exit Issue Resolution**: Fixed wsssh process hanging after SSH client disconnection
......
# WebSocket SSH - Future Enhancements Roadmap
## Recently Completed (v1.4.9)
- [x] **PyInstaller Build Issues**: Critical fixes for frozen application deployment
- Fixed missing `websockets` import in `wsssd/server.py` causing "name 'websockets' is not defined" error
- Resolved asyncio runtime warnings by properly awaiting cancelled tasks in shutdown handling
- Fixed global variable sharing issue in frozen application by passing server password as parameter to websocket handler
- Improved WebSocket handler signature compatibility with `functools.partial` for proper function binding
## Recently Completed (v1.4.8)
- [x] **Critical SSL Connection Stability Issues**: Comprehensive SSL error handling and connection resilience improvements
- Fixed WebSocket frame sending failures that caused connection drops
......@@ -10,6 +17,14 @@
- Improved WebSocket frame transmission with retry mechanisms and partial write handling
- Applied improvements to all three C tools (wssshc, wsssh, wsscp)
- [x] **Documentation Updates**: Updated CHANGELOG.md, README.md, DOCUMENTATION.md, and TODO.md for version 1.4.8
- [x] **Comprehensive Documentation Review**: Carefully reviewed all documentation files and updated with current features
- Updated version references from 1.4.0 to 1.4.8 throughout documentation
- Added documentation for new --dev-tunnel option in wsssh and wsscp
- Added documentation for --help option in all tools
- Updated project structure to reflect current codebase
- Added missing configuration file references
- Updated debug mode examples with new options
- Enhanced feature descriptions with recent improvements
## Recently Completed (v1.4.7)
- [x] **Critical Process Exit Bug Fix**: Fixed wsssh process hanging after SSH client disconnection
......
......@@ -97,6 +97,8 @@ if [ "$BUILD_CLEAN" = true ]; then
rm -rf dist/
rm -f *.spec
rm -f wssshd # Remove PyInstaller binary
rm -f wsssd/__pycache__/*.pyc 2>/dev/null || true
rm -f wsssd/__pycache__ 2>/dev/null || true
# Remove virtual environment (unless --novenv is specified)
if [ "$BUILD_NO_VENV" = false ]; then
......@@ -226,7 +228,7 @@ if [ "$BUILD_DEBIAN_ONLY" = false ] && [ "$BUILD_WSSSHTOOLS_ONLY" = false ]; the
# Build wssshd (server) binary unless --no-server is specified
if [ "$BUILD_NO_SERVER" = false ]; then
pyinstaller --onefile --distpath dist --add-data "cert.pem:." --add-data "key.pem:." --add-data "templates:templates" --add-data "logos:logos" --runtime-tmpdir /tmp --clean wssshd.py
pyinstaller --onefile --distpath dist --add-data "cert.pem:." --add-data "key.pem:." --add-data "templates:templates" --add-data "logos:logos" --add-data "wsssd:wsssd" --hidden-import wsssd --hidden-import wsssd.config --hidden-import wsssd.websocket --hidden-import wsssd.terminal --hidden-import wsssd.web --hidden-import wsssd.server --hidden-import websockets --hidden-import websockets.server --hidden-import websockets.client --hidden-import websockets.exceptions --hidden-import websockets.protocol --hidden-import websockets.uri --runtime-tmpdir /tmp --clean wssshd.py
fi
# Build client binaries
......
#!/bin/bash
# WebSocket SSH Tools Clean Script
# Clean script for removing build artifacts from WebSocket SSH tools
#
# Copyright (C) 2024 Stefy Lanza <stefy@nexlab.net> and SexHack.me
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# Use build.sh --clean for consistent cleaning
./build.sh --clean
\ No newline at end of file
"""
WebSocket SSH Daemon (wssshd) - Modular implementation
"""
from .server import main
from .config import load_config
from .websocket import clients, active_tunnels, active_terminals
from .terminal import create_terminal_session, send_terminal_data, get_terminal_output, disconnect_terminal, resize_terminal
__version__ = "1.0.0"
__all__ = [
'main',
'load_config',
'clients',
'active_tunnels',
'active_terminals',
'create_terminal_session',
'send_terminal_data',
'get_terminal_output',
'disconnect_terminal',
'resize_terminal'
]
\ No newline at end of file
"""
Entry point for running wssshd as a module
"""
from .server import main
if __name__ == '__main__':
main()
\ No newline at end of file
"""
Configuration handling for wssshd
"""
import argparse
import configparser
import os
def load_config(args_config=None):
"""
Load configuration from file and command line arguments
Returns parsed arguments
"""
parser = argparse.ArgumentParser(description='WebSocket SSH Daemon (wssshd)')
parser.add_argument('--config', help='Configuration file path (default: /etc/wssshd.conf)')
parser.add_argument('--host', help='WebSocket server host')
parser.add_argument('--port', type=int, default=9898, help='WebSocket server port (default: 9898)')
parser.add_argument('--domain', help='Base domain name')
parser.add_argument('--password', help='Registration password')
parser.add_argument('--web-host', help='Web interface host (optional)')
parser.add_argument('--web-port', type=int, help='Web interface port (optional)')
parser.add_argument('--web-https', action='store_true', help='Enable HTTPS for web interface')
parser.add_argument('--debug', action='store_true', help='Enable debug output')
# Parse just the config argument first to determine config file location
temp_parser = argparse.ArgumentParser(add_help=False)
temp_parser.add_argument('--config')
temp_args, remaining = temp_parser.parse_known_args()
config = configparser.ConfigParser()
config_path = temp_args.config or '/etc/wssshd.conf'
defaults = {}
if os.path.exists(config_path):
config.read(config_path)
if 'wssshd' in config:
section = config['wssshd']
for key in ['password', 'domain']:
if key in section:
defaults[key] = section[key]
if 'host' in section:
defaults['host'] = section['host']
if 'port' in section:
defaults['port'] = int(section['port'])
if 'web-host' in section:
defaults['web_host'] = section['web-host']
if 'web-port' in section:
defaults['web_port'] = int(section['web-port'])
if 'web-https' in section:
defaults['web_https'] = section.getboolean('web-https', False)
parser.set_defaults(**defaults)
args = parser.parse_args()
# Handle web-https from config if not specified on command line
if 'web_https' in defaults and not any(arg.startswith('--web-https') for arg in remaining):
args.web_https = defaults['web_https']
# Check required arguments
if not args.host:
parser.error('--host is required')
if not args.domain:
parser.error('--domain is required')
if not args.password:
parser.error('--password is required')
return args
\ No newline at end of file
"""
Main server logic for wssshd
"""
import asyncio
import ssl
import sys
import os
import threading
import signal
import websockets
from functools import partial
from .config import load_config
from .websocket import handle_websocket, cleanup_task, shutdown_event, debug, clients, active_tunnels, active_terminals, SERVER_SHUTDOWN_MSG
from .web import run_flask
def setup_ssl_context():
"""Set up SSL context for WebSocket server"""
# Load certificate
if getattr(sys, 'frozen', False):
# Running as bundled executable
bundle_dir = sys._MEIPASS
cert_path = os.path.join(bundle_dir, 'cert.pem')
key_path = os.path.join(bundle_dir, 'key.pem')
else:
# Running as script
cert_path = 'cert.pem'
key_path = 'key.pem'
ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
ssl_context.load_cert_chain(cert_path, key_path)
return ssl_context
async def shutdown_server(ws_server, cleanup_coro, flask_thread):
"""Handle graceful server shutdown"""
print("\nShutting down WebSocket SSH Daemon...")
# Notify all connected clients about shutdown (optimized)
active_clients = [(cid, info) for cid, info in clients.items() if info['status'] == 'active']
if active_clients:
print(f"Notifying {len(active_clients)} connected clients...")
# Create notification tasks
shutdown_msg = SERVER_SHUTDOWN_MSG.encode()
notify_tasks = []
for client_id, client_info in active_clients:
try:
task = asyncio.create_task(client_info['websocket'].send(shutdown_msg))
notify_tasks.append(task)
except Exception as e:
if debug: print(f"[DEBUG] Failed to create notification task for {client_id}: {e}")
# Wait for all notifications with timeout
if notify_tasks:
try:
await asyncio.wait_for(
asyncio.gather(*notify_tasks, return_exceptions=True),
timeout=0.3
)
print("All clients notified")
except asyncio.TimeoutError:
print("Timeout waiting for client notifications, proceeding with shutdown")
except Exception as e:
if debug: print(f"[DEBUG] Error during client notifications: {e}")
# Give clients a brief moment to process the shutdown message
await asyncio.sleep(0.1)
# Close WebSocket server
try:
ws_server.close()
await ws_server.wait_closed()
except Exception as e:
if debug: print(f"[DEBUG] Error closing WebSocket server: {e}")
# Cancel cleanup task
if not cleanup_coro.done():
cleanup_coro.cancel()
try:
await cleanup_coro
except asyncio.CancelledError:
pass
# Clean up active terminals more efficiently
print("Terminating active terminal processes...")
# Terminate all processes efficiently
if active_terminals:
print("Terminating active terminal processes...")
# Send SIGTERM to all processes
term_procs = []
for request_id, terminal in active_terminals.items():
proc = terminal['proc']
if proc.poll() is None:
proc.terminate()
term_procs.append((request_id, proc))
if term_procs:
# Wait for graceful termination
await asyncio.sleep(0.3)
# Force kill remaining processes
kill_tasks = []
for request_id, proc in term_procs:
if proc.poll() is None:
if debug: print(f"[DEBUG] Force killing terminal process {request_id}")
proc.kill()
# Create async task for waiting
task = asyncio.get_event_loop().run_in_executor(None, proc.wait)
kill_tasks.append(task)
# Wait for all kill operations to complete
if kill_tasks:
try:
await asyncio.wait_for(
asyncio.gather(*kill_tasks, return_exceptions=True),
timeout=0.2
)
except asyncio.TimeoutError:
if debug: print("[DEBUG] Some processes still running after SIGKILL")
except Exception as e:
if debug: print(f"[DEBUG] Error during process cleanup: {e}")
# Clean up terminal records (optimized)
terminal_count = len(active_terminals)
if terminal_count > 0:
active_terminals.clear()
if debug: print(f"[DEBUG] Cleaned up {terminal_count} terminal records")
# Clean up active tunnels (optimized)
print("Cleaning up active tunnels...")
if active_tunnels:
# Create close tasks for all active tunnels
close_tasks = []
for request_id, tunnel in active_tunnels.items():
client_info = clients.get(tunnel['client_id'])
if client_info and client_info['status'] == 'active':
try:
close_task = asyncio.create_task(
tunnel['client_ws'].send(SERVER_SHUTDOWN_MSG)
)
close_tasks.append((request_id, close_task))
except Exception as e:
if debug: print(f"[DEBUG] Failed to create close task for {request_id}: {e}")
# Wait for all close tasks with timeout
if close_tasks:
try:
await asyncio.wait_for(
asyncio.gather(*[task for _, task in close_tasks], return_exceptions=True),
timeout=0.2
)
if debug: print(f"[DEBUG] Sent shutdown to {len(close_tasks)} clients")
except asyncio.TimeoutError:
if debug: print("[DEBUG] Timeout waiting for tunnel close notifications")
except Exception as e:
if debug: print(f"[DEBUG] Error during tunnel close notifications: {e}")
# Clean up all tunnels
active_tunnels.clear()
if debug: print(f"[DEBUG] Cleaned up {len(close_tasks)} tunnels")
# Clean up clients (optimized)
client_count = len(clients)
if client_count > 0:
clients.clear()
if debug: print(f"[DEBUG] Cleaned up {client_count} clients")
# Stop Flask thread
if flask_thread and flask_thread.is_alive():
flask_thread.join(timeout=1.0)
print("WebSocket SSH Daemon stopped cleanly")
async def run_server():
"""Main server function"""
args = load_config()
# Set global variables
global debug, server_password, shutdown_event
debug = args.debug
server_password = args.password
# Initialize shutdown event
shutdown_event = asyncio.Event()
# Set up signal handling for clean exit
shutdown_event.clear()
def signal_handler(signum, frame):
if debug: print(f"[DEBUG] Received signal {signum}, initiating shutdown")
shutdown_event.set()
# Register signal handler for SIGINT (Ctrl+C)
signal.signal(signal.SIGINT, signal_handler)
ssl_context = setup_ssl_context()
# Start WebSocket server
ws_server = await websockets.serve(partial(handle_websocket, server_password=server_password), args.host, args.port, ssl=ssl_context)
print(f"WebSocket SSH Daemon running on {args.host}:{args.port}")
print("Press Ctrl+C to stop the server")
# Start cleanup task
cleanup_coro = asyncio.create_task(cleanup_task())
# Start web interface if specified
flask_thread = None
if args.web_host and args.web_port:
flask_thread = threading.Thread(
target=run_flask,
args=(args.web_host, args.web_port, debug, getattr(args, 'web_https', False)),
daemon=True
)
flask_thread.start()
try:
# Create tasks for waiting
server_close_task = asyncio.create_task(ws_server.wait_closed())
shutdown_wait_task = asyncio.create_task(shutdown_event.wait())
# Wait for either server to close or shutdown signal
done, pending = await asyncio.wait(
[server_close_task, shutdown_wait_task],
return_when=asyncio.FIRST_COMPLETED
)
# Check conditions
if shutdown_event.is_set():
if debug: print("[DEBUG] Shutdown event detected in main loop")
if server_close_task in done:
if debug: print("[DEBUG] WebSocket server closed naturally")
# Cancel pending tasks
for task in pending:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
await shutdown_server(ws_server, cleanup_coro, flask_thread)
except Exception as e:
print(f"Error during shutdown: {e}")
# Ensure cleanup happens even if there's an error
if not cleanup_coro.done():
cleanup_coro.cancel()
try:
await cleanup_coro
except asyncio.CancelledError:
pass
def main():
"""Entry point for the server"""
try:
asyncio.run(run_server())
except KeyboardInterrupt:
print("\nServer interrupted by user")
except Exception as e:
print(f"Server error: {e}")
sys.exit(1)
\ No newline at end of file
"""
Terminal and PTY handling for wssshd
"""
import os
import pty
import select
import fcntl
import termios
import struct
import threading
import signal
import subprocess
import uuid
def openpty_with_fallback():
"""Open a PTY with fallback to different device paths for systems where /dev/pty doesn't exist"""
# First try the standard pty.openpty()
try:
master, slave = pty.openpty()
return master, slave
except OSError as e:
if hasattr(openpty_with_fallback, '_debug') and openpty_with_fallback._debug:
print(f"[DEBUG] Standard pty.openpty() failed: {e}, trying fallback methods")
# Fallback: try to open /dev/ptmx directly
ptmx_paths = ['/dev/ptmx', '/dev/pts/ptmx']
for ptmx_path in ptmx_paths:
try:
if os.path.exists(ptmx_path):
# Open master PTY
master = os.open(ptmx_path, os.O_RDWR | os.O_NOCTTY)
if master < 0:
continue
# Get slave PTY name
slave_name = os.ttyname(master)
if not slave_name:
os.close(master)
continue
# Open slave PTY
slave = os.open(slave_name, os.O_RDWR | os.O_NOCTTY)
if slave < 0:
os.close(master)
continue
if hasattr(openpty_with_fallback, '_debug') and openpty_with_fallback._debug:
print(f"[DEBUG] Successfully opened PTY using {ptmx_path}: master={master}, slave={slave}")
return master, slave
except (OSError, AttributeError) as e:
if hasattr(openpty_with_fallback, '_debug') and openpty_with_fallback._debug:
print(f"[DEBUG] Failed to open PTY using {ptmx_path}: {e}")
continue
# Last resort: try to create PTY devices manually
try:
# Try to find an available PTY number
for i in range(256): # Try PTY numbers 0-255
pty_name = f"/dev/pts/{i}"
try:
if os.path.exists(pty_name):
continue
# Try to create the PTY device
master = os.open('/dev/ptmx', os.O_RDWR | os.O_NOCTTY)
slave_name = os.ttyname(master)
if slave_name and os.path.exists(slave_name):
slave = os.open(slave_name, os.O_RDWR | os.O_NOCTTY)
if hasattr(openpty_with_fallback, '_debug') and openpty_with_fallback._debug:
print(f"[DEBUG] Created PTY manually: master={master}, slave={slave}")
return master, slave
os.close(master)
except (OSError, AttributeError):
continue
except Exception as e:
if hasattr(openpty_with_fallback, '_debug') and openpty_with_fallback._debug:
print(f"[DEBUG] Manual PTY creation failed: {e}")
# If all methods fail, raise the original exception
raise OSError("Failed to open PTY: no available PTY devices found")
def create_terminal_session(args, username, client_id):
"""Create a new terminal session for a client"""
request_id = str(uuid.uuid4())
# Force echo mode before launching wsssh
command = ['sh', '-c', f'stty echo && wsssh -p {args.port} {username}@{client_id}.{args.domain}']
# Spawn wsssh process with pty using fallback method
master, slave = openpty_with_fallback()
slave_name = os.ttyname(slave)
def set_controlling_terminal():
os.setsid()
# Set the controlling terminal
try:
fcntl.ioctl(slave, termios.TIOCSCTTY, 0)
except (OSError, AttributeError):
pass # Some systems don't support TIOCSCTTY
# Set terminal size to match xterm.js dimensions (default 80x24)
winsize = struct.pack('HHHH', 24, 80, 0, 0)
try:
fcntl.ioctl(0, termios.TIOCSWINSZ, winsize)
except (OSError, AttributeError):
pass
# Set raw mode - let SSH client handle terminal behavior
import tty
try:
tty.setraw(0)
except (OSError, AttributeError):
pass
proc = subprocess.Popen(
command,
stdin=slave,
stdout=slave,
stderr=slave,
preexec_fn=set_controlling_terminal,
env=dict(os.environ, TERM='xterm', COLUMNS='80', LINES='24')
)
os.close(slave)
# Start a thread to read output
output_buffer = []
def read_output():
output_buffer.append(f'Process PID: {proc.pid}\r\n')
while proc.poll() is None:
r, w, e = select.select([master], [], [], 0.1)
if master in r:
try:
data = os.read(master, 1024)
if data:
decoded = data.decode('utf-8', errors='ignore')
output_buffer.append(decoded)
except:
break
# Read any remaining data
try:
data = os.read(master, 1024)
while data:
decoded = data.decode('utf-8', errors='ignore')
output_buffer.append(decoded)
data = os.read(master, 1024)
except:
pass
output_buffer.append('\r\nProcess finished.\r\n')
os.close(master)
thread = threading.Thread(target=read_output, daemon=True)
thread.start()
return {
'request_id': request_id,
'proc': proc,
'output_buffer': output_buffer,
'master': master,
'command': f'wsssh -p {args.port} {username}@{client_id}.{args.domain}'
}
def send_terminal_data(terminal_session, data):
"""Send data to a terminal session"""
proc = terminal_session['proc']
master = terminal_session['master']
if proc.poll() is None: # Process is still running
try:
os.write(master, data.encode())
return True
except:
return False
return False
def get_terminal_output(terminal_session):
"""Get output from a terminal session"""
proc = terminal_session['proc']
output_buffer = terminal_session['output_buffer']
if output_buffer:
data = ''.join(output_buffer)
output_buffer.clear()
return data
elif proc.poll() is not None:
# Process terminated
return '\r\nProcess terminated.\r\n'
return ''
def disconnect_terminal(terminal_session):
"""Disconnect a terminal session"""
proc = terminal_session['proc']
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except:
proc.kill()
return True
def resize_terminal(terminal_session, cols, rows):
"""Resize a terminal session"""
proc = terminal_session['proc']
master = terminal_session['master']
if proc.poll() is None:
# Update terminal size
winsize = struct.pack('HHHH', rows, cols, 0, 0)
try:
fcntl.ioctl(master, termios.TIOCSWINSZ, winsize)
# Also try to update the process's controlling terminal
fcntl.ioctl(0, termios.TIOCSWINSZ, winsize)
except (OSError, AttributeError):
pass
# Send SIGWINCH to notify the process of size change
try:
os.kill(proc.pid, signal.SIGWINCH)
except (OSError, ProcessLookupError):
pass
return True
\ No newline at end of file
"""
Flask web interface for wssshd
"""
import os
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash
from .websocket import clients, active_terminals
from .terminal import create_terminal_session, send_terminal_data, get_terminal_output, disconnect_terminal, resize_terminal
# Flask app
app = Flask(__name__)
app.config['SECRET_KEY'] = 'wsssh-secret-key-change-in-production'
config_dir = os.path.expanduser('~/.config/wssshd')
os.makedirs(config_dir, exist_ok=True)
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{config_dir}/users.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db = SQLAlchemy(app)
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(150), nullable=False)
is_admin = db.Column(db.Boolean, default=False)
@login_manager.user_loader
def load_user(user_id):
return db.session.get(User, int(user_id))
# Create database and default admin user
with app.app_context():
db.create_all()
if not User.query.filter_by(username='admin').first():
admin = User(username='admin', password_hash=generate_password_hash('admin123'), is_admin=True)
db.session.add(admin)
db.session.commit()
# Flask routes
@app.route('/')
@login_required
def index():
from .config import load_config
args = load_config()
# Get client information with status
client_info = {}
for client_id, client_data in clients.items():
client_info[client_id] = {
'status': client_data['status'],
'last_seen': client_data['last_seen']
}
return render_template('index.html',
clients=client_info,
websocket_port=args.port,
domain=args.domain)
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username).first()
if user and check_password_hash(user.password_hash, password):
login_user(user)
return redirect(url_for('index'))
flash('Invalid username or password')
return render_template('login.html')
@app.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('login'))
@app.route('/users')
@login_required
def users():
if not current_user.is_admin:
flash('Access denied')
return redirect(url_for('index'))
users_list = User.query.all()
return render_template('users.html', users=users_list)
@app.route('/add_user', methods=['POST'])
@login_required
def add_user():
if not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
username = request.form.get('username')
password = request.form.get('password')
is_admin = request.form.get('is_admin') == 'on'
if User.query.filter_by(username=username).first():
return jsonify({'error': 'Username already exists'}), 400
user = User(username=username, password_hash=generate_password_hash(password), is_admin=is_admin)
db.session.add(user)
db.session.commit()
return jsonify({'success': True})
@app.route('/edit_user/<int:user_id>', methods=['POST'])
@login_required
def edit_user(user_id):
if not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
user = User.query.get_or_404(user_id)
user.username = request.form.get('username')
user.is_admin = request.form.get('is_admin') == 'on'
password = request.form.get('password')
if password:
user.password_hash = generate_password_hash(password)
db.session.commit()
return jsonify({'success': True})
@app.route('/delete_user/<int:user_id>', methods=['POST'])
@login_required
def delete_user(user_id):
if not current_user.is_admin:
return jsonify({'error': 'Access denied'}), 403
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return jsonify({'success': True})
@app.route('/terminal/<client_id>')
@login_required
def terminal(client_id):
if client_id not in clients:
flash('Client not connected')
return redirect(url_for('index'))
return render_template('terminal.html', client_id=client_id)
@app.route('/api/clients')
@login_required
def get_clients():
client_info = {}
for client_id, client_data in clients.items():
client_info[client_id] = {
'status': client_data['status'],
'last_seen': client_data['last_seen']
}
return jsonify({
'clients': client_info,
'count': len(clients)
})
@app.route('/logos/<path:filename>')
def logos_files(filename):
return send_from_directory('logos', filename)
@app.route('/terminal/<client_id>/connect', methods=['POST'])
@login_required
def connect_terminal(client_id):
from .config import load_config
args = load_config()
username = request.form.get('username', 'root')
cols = int(request.form.get('cols', 80))
rows = int(request.form.get('rows', 24))
terminal_session = create_terminal_session(args, username, client_id)
request_id = terminal_session['request_id']
# Store terminal session
active_terminals[request_id] = {
'client_id': client_id,
'username': username,
'proc': terminal_session['proc'],
'output_buffer': terminal_session['output_buffer'],
'master': terminal_session['master']
}
return jsonify({
'request_id': request_id,
'command': terminal_session['command']
})
@app.route('/terminal/<client_id>/data', methods=['GET', 'POST'])
@login_required
def terminal_data(client_id):
if request.method == 'POST':
request_id = request.form.get('request_id')
data = request.form.get('data')
if request_id in active_terminals:
success = send_terminal_data(active_terminals[request_id], data)
if not success:
return jsonify({'error': 'Failed to send data'}), 500
return 'OK'
else:
request_id = request.args.get('request_id')
if request_id in active_terminals:
data = get_terminal_output(active_terminals[request_id])
return data
return ''
@app.route('/terminal/<client_id>/disconnect', methods=['POST'])
@login_required
def disconnect_terminal_route(client_id):
request_id = request.form.get('request_id')
if request_id in active_terminals:
disconnect_terminal(active_terminals[request_id])
del active_terminals[request_id]
return 'OK'
@app.route('/terminal/<client_id>/resize', methods=['POST'])
@login_required
def resize_terminal_route(client_id):
request_id = request.form.get('request_id')
cols = int(request.form.get('cols', 80))
rows = int(request.form.get('rows', 24))
if request_id in active_terminals:
resize_terminal(active_terminals[request_id], cols, rows)
return 'OK'
def run_flask(host, port, debug=False, use_https=False):
"""Run the Flask application"""
if use_https:
# Handle HTTPS setup
web_cert_path = os.path.join(config_dir, 'web-cert.pem')
web_key_path = os.path.join(config_dir, 'web-key.pem')
# Generate self-signed certificate if it doesn't exist
if not os.path.exists(web_cert_path) or not os.path.exists(web_key_path):
print("Generating self-signed certificate for web interface...")
os.system(f'openssl req -x509 -newkey rsa:4096 -keyout {web_key_path} -out {web_cert_path} -days 36500 -nodes -subj "/C=US/ST=State/L=City/O=Organization/CN=localhost"')
ssl_context = (web_cert_path, web_key_path)
protocol = "https"
else:
ssl_context = None
protocol = "http"
print(f"Web interface available at {protocol}://{host}:{port}")
app.run(host=host, port=port, debug=debug, use_reloader=False, threaded=True, ssl_context=ssl_context)
\ No newline at end of file
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -5,6 +5,7 @@ wsscp \- WebSocket SCP wrapper for secure file transfer
.B wsscp
[\fB\-\-local\-port\fR \fIPORT\fR]
[\fB\-\-debug\fR]
[\fB\-\-dev\-tunnel\fR]
[\fB\-\-help\fR]
[\fISCP_OPTIONS\fR...]
\fISOURCE\fR...
......@@ -20,6 +21,9 @@ Local port for tunnel establishment (default: auto-assign)
.B \-\-debug
Enable debug output for troubleshooting
.TP
.B \-\-dev\-tunnel
Development mode: setup tunnel but don't launch SCP
.TP
.B \-\-help
Display help message and exit
.SH HOSTNAME FORMAT
......
......@@ -5,6 +5,7 @@ wsssh \- WebSocket SSH wrapper for secure tunneling
.B wsssh
[\fB\-\-local\-port\fR \fIPORT\fR]
[\fB\-\-debug\fR]
[\fB\-\-dev\-tunnel\fR]
[\fB\-\-help\fR]
\fIuser\fR@\fIclient\fR.\fIdomain\fR
[\fISSH_OPTIONS\fR...]
......@@ -19,6 +20,9 @@ Local port for tunnel establishment (default: auto-assign)
.B \-\-debug
Enable debug output for troubleshooting
.TP
.B \-\-dev\-tunnel
Development mode: setup tunnel but don't launch SSH
.TP
.B \-\-help
Display help message and exit
.SH HOSTNAME FORMAT
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment