A secure, real-time terminal collaboration platform enabling shared SFTP sessions, multi-user terminal access, and AI-assisted development.
-
Live Terminal Sharing
- Real-time terminal session collaboration
- Multiple user support with concurrent access
- Role-based permissions (read/write/admin)
- Session recording and playback
-
Secure SFTP Integration
- Web-based SFTP client
- File system operations with live updates
- Multi-user file access control
- Transfer progress monitoring
-
AI Development Assistant
- Context-aware code suggestions
- Command history analysis
- Error detection and resolution
- Best practices recommendations
-
Key Vault Management
- Secure credential storage
- SSH key management
- Access token handling
- Encryption at rest
-
Core:
- Node.js / Express.js
- WebSocket (Socket.io)
- Redis for session management
-
Security:
- HTTPS/WSS protocols
- Rate limiting
-
Terminal:
- SSH2 for SFTP operations
- xterm.js compatibility
Node.js >= 18.x
Redis >= 6.x
Python >= 3.8 (for AI components)- Clone the repository:
git clone https://github.com/Mullayam/terminus-web
cd terminus-web- Install dependencies:
npm install- Set up environment variables:
cp .env.example .env
# Edit .env with your configuration- Start the server:
npm run dev # Development
npm run start # ProductionThe platform can be configured through environment variables:
# Server Configuration
PORT=7145
NODE_ENV=development
# Database
REDIS_URL=""
# Security
JWT_SECRET=your_jwt_secret
ENCRYPTION_KEY=your_encryption_key
FRONTEND_URL=http://localhost:5173- All sessions are encrypted end-to-end
- Credentials are never stored in plaintext
- Regular security audits are performed
- Rate limiting prevents abuse
- Session timeouts are enforced
- Access logs are maintained
POST /api/sftp/upload
GET /api/sftp/download @@SSH_EMIT_RESIZE: When is Resize the xTerm@@SEND_COMMAND: Use interm.write()function to send command to Backend to write cmd to PTY.@@RECIEVE_COMMAND: Use interm.onData()orterm.data()to recive the output from Backend.
When multiple users share a single SSH shell (PTY), two race conditions emerge:
-
Keystroke interleaving β Two users typing simultaneously produce garbled input.
Example: User A typesls -lawhile User B typespwdβ the PTY receiveslpws -dla. -
Registration timing β Event listeners were bound only when
sessionIdexisted in the socket handshake query. If a user was kicked and reconnected on a fresh socket (no query param), theCOLLAB_JOIN_TERMINALlistener was never attached β "session not found" on rejoin.
Every socket gets a permission level that controls PTY access:
| Permission | Name | Can Read Output | Can Write to PTY | Subject to Lock |
|---|---|---|---|---|
400 |
Read-only | β | β | N/A |
700 |
Write | β | β | β |
777 |
Admin | β | β | β (immune) |
- New joiners default to
400(read-only). The admin must explicitly promote them to700. - Only one socket can be
777(the session creator). Promotion to777is blocked. - Permission changes take effect immediately β if a
700user is downgraded to400while holding the auto-lock, the lock is released instantly.
Auto-lock prevents keystroke interleaving between 700 users:
User A starts typing
β Auto-lock acquired by A (broadcast COLLAB_PTY_LOCKED)
β 4-second TTL timer starts
β Each subsequent keystroke from A resets the timer
β User B (700) tries to type β rejected with "locked-auto"
β User A stops typing β 4s passes β lock released (COLLAB_PTY_UNLOCKED)
β User B can now type
Key behaviors:
777(admin) bypasses auto-lock β they can always type, and their typing does not create an auto-lock.400users are rejected before the lock is even checked (read-only).- Only one auto-lock exists per session. It's purely in-memory (
Map<sessionId, LockState>).
Admin lock is a manual override:
Admin emits COLLAB_ADMIN_LOCK { sessionId, lock: true }
β Any existing auto-lock is cleared
β Admin lock set (no TTL β stays until manually released)
β All 700 users blocked: "locked-admin"
β Only admin (777) can type
Admin emits COLLAB_ADMIN_LOCK { sessionId, lock: false }
β Lock removed, all 700 users can type again
The original design bound event listeners per-session:
// OLD β only ran when sessionId was in handshake query
if (sessionId) collab.register(socket, sessionId);This broke kicked-user rejoin because their new socket had no sessionId in the query.
The fix: register ALL listeners on EVERY socket, with each handler reading sessionId
from its payload at runtime:
// NEW β runs for every connecting socket
collab.registerAll(socket);
// Inside each handler:
socket.on(COLLAB_JOIN_TERMINAL, (payload: JoinTerminalPayload) => {
const sessionId = payload.sessionId; // from payload, not closure
// ...
});A socketSessions map (socketId β Set<sessionId>) tracks which sessions each socket has
joined. This enables:
- Input routing without
sessionIdin every keystroke payload (findSessionForSocket()) - Disconnect cleanup that iterates all sessions the socket belonged to
- Kicked-user rejoin β their new socket already has all listeners; they just emit
COLLAB_JOIN_TERMINAL { sessionId }again
Blocking operates at the IP level per-session:
Admin blocks User B (socketId: "abc123")
β Resolve IP from B's socket handshake (x-forwarded-for or address)
β Add IP to session.blockedIPs
β Remove B from session, force-leave room, notify
β B reconnects on new socket, emits COLLAB_JOIN_TERMINAL
β Server checks blockedIPs β IP match β COLLAB_JOIN_REJECTED { reason: "blocked" }
Kicked (not blocked) users can rejoin freely. Blocked users are permanently excluded from
that session unless the admin explicitly unblocks their IP via COLLAB_UNBLOCK_IP.
ββββββββββββ COLLAB_INPUT βββββββββββββββββββββββββββ stream.write() βββββββ
β Client β ββββββββββββββββββββ CollaborativeTerminal β ββββββββββββββββββββ β PTY β
β (socket) β β β β β
β β ββββββββββββββββββββ 1. check permission β ββββββββββββββββββββ β β
β β COLLAB_INPUT_ β 2. check lock state β stream data event β β
β β REJECTED β 3. write or reject β β β
β β β 4. reset auto-lock TTL β β β
β β ββββββββββββββββββββ β β β
β β COLLAB_TERMINAL_ β Redis pub/sub relay β β β
β β OUTPUT β (terminal:{sessionId}) β β β
ββββββββββββ βββββββββββββββββββββββββββ βββββββ
1. Admin connects β SSH shell opens β createSession(sessionId, adminSocketId)
2. Admin auto-joins collab room, gets "777" permission
3. Joiners emit COLLAB_JOIN_TERMINAL { sessionId }
β IP block check β join room β default "400" β receive COLLAB_ROOM_STATE
4. Admin promotes joiner to "700" via COLLAB_CHANGE_PERMISSION
5. Active session: auto-lock arbitrates between "700" users
6. Admin disconnects β COLLAB_SESSION_ENDED broadcast β destroySession()
- Fork the repository
- Create your feature branch (
git checkout -b feature/AmazingFeature) - Commit your changes (
git commit -m 'Add some AmazingFeature') - Push to the branch (
git push origin feature/AmazingFeature) - Open a Pull Request
This project is licensed under the MIT License - see the LICENSE.md file for details.
- Socket.io team for real-time capabilities
- OpenAI for AI integration support
For support, email mullayam06@outlook.com