Add code
This commit is contained in:
392
README
392
README
@ -1 +1,391 @@
|
|||||||
Secret key manager
|
# Key/Info Manager
|
||||||
|
|
||||||
|
A robust and secure Key/Info Management system designed for applications requiring controlled access to sensitive data. It features a WebSocket API for client applications and an HTTP admin panel for comprehensive management tasks. All stored secrets are encrypted at rest using AES-256-GCM.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Key/Info Manager provides a centralized solution for storing, managing, and securely distributing secrets or configuration data to authorized client applications. It emphasizes security through data encryption, role-based access (via admin approval and group association), and secure communication protocols (when deployed with HTTPS/WSS).
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
* **Secure Storage**: Secrets are encrypted using AES-256-GCM. The master encryption key is derived from a user-provided password at server startup.
|
||||||
|
* **Admin UI Panel**: A comprehensive web-based interface for:
|
||||||
|
* Managing secret groups (create, rename, delete).
|
||||||
|
* Managing secrets within groups (create, view, update, delete).
|
||||||
|
* Managing client applications:
|
||||||
|
* Viewing pending client registrations.
|
||||||
|
* Approving or rejecting client registration requests.
|
||||||
|
* Associating approved clients with specific secret groups to grant access.
|
||||||
|
* Revoking client access.
|
||||||
|
* Configuring WebSocket client auto-approval (for debugging/development).
|
||||||
|
* **WebSocket API**: A secure and efficient API for client applications to:
|
||||||
|
* Register themselves with the server.
|
||||||
|
* Request secrets they are authorized to access (based on group association).
|
||||||
|
* List all secret keys they are authorized to access.
|
||||||
|
* **Password Protection**: Admin UI login and initial server setup (for encryption key) are protected by a master password.
|
||||||
|
* **CSRF Protection**: Admin UI operations are protected against Cross-Site Request Forgery.
|
||||||
|
* **Rate Limiting**: Both HTTP admin endpoints and WebSocket communications are rate-limited to mitigate abuse and brute-force attempts.
|
||||||
|
* **Configuration Management**: Core settings like JWT secret and port configurations are manageable.
|
||||||
|
* **Data Persistence**: Stores data (encrypted secrets, client info, group info) in local files.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
* **Backend:** Node.js, Express.js
|
||||||
|
* **WebSocket Server:** `ws` library
|
||||||
|
* **Templating:** EJS for Admin UI
|
||||||
|
* **Core Libraries:**
|
||||||
|
* `jsonwebtoken` for JWT generation (Admin sessions)
|
||||||
|
* `csurf` for CSRF protection
|
||||||
|
* `helmet` for security headers
|
||||||
|
* `express-rate-limit` for rate limiting
|
||||||
|
* `express-session` for session management
|
||||||
|
* `dotenv` for environment variable management
|
||||||
|
* **Cryptography:** Node.js `crypto` module (AES-256-GCM for data encryption)
|
||||||
|
* **Development:** TypeScript, Jest (for testing), Nodemon
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
* Node.js (v16.x or later recommended)
|
||||||
|
* npm (Node Package Manager, typically included with Node.js)
|
||||||
|
|
||||||
|
## Getting Started & Installation
|
||||||
|
|
||||||
|
1. **Clone the Repository (if applicable):**
|
||||||
|
```bash
|
||||||
|
git clone <repository_url>
|
||||||
|
cd key_manager # Or your project directory name
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install Dependencies:**
|
||||||
|
Navigate to the project root directory and run:
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure Environment Variables:**
|
||||||
|
Create a `.env` file in the project root directory. This file is used to store sensitive information and configurations. **It is crucial for security that this file is NOT committed to version control.** Add it to your `.gitignore` if it's not already there.
|
||||||
|
|
||||||
|
**Essential Security Variables (add these to your `.env` file):**
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# Master Password for data encryption and initial admin login
|
||||||
|
# Choose a strong, unique password. This will be used to derive the data encryption key.
|
||||||
|
# If not set, you will be prompted for it in the console on first run.
|
||||||
|
MASTER_PASSWORD=your_very_strong_master_password_here
|
||||||
|
|
||||||
|
# JWT Secret for signing authentication tokens for the Admin UI
|
||||||
|
# Should be a long, random, and unique string.
|
||||||
|
JWT_SECRET=your_super_random_jwt_secret_string_here_at_least_32_chars
|
||||||
|
|
||||||
|
# Session Secret for Express sessions (used by CSRF protection)
|
||||||
|
# Should also be a long, random, and unique string.
|
||||||
|
SESSION_SECRET=another_super_random_session_secret_string_here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Configuration Variables (defaults are generally suitable for local development):**
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# HTTP Port for the Admin UI (default: 3000, can also be set in data/runtime-config.json)
|
||||||
|
HTTP_PORT=3000
|
||||||
|
|
||||||
|
# WebSocket Port for client applications (default: 3001, can also be set in data/runtime-config.json)
|
||||||
|
WS_PORT=3001
|
||||||
|
|
||||||
|
# Data files path (defaults to 'data' directory in project root)
|
||||||
|
# DATA_DIR=./data # Example, usually not needed to change
|
||||||
|
|
||||||
|
# Config file path (defaults to 'data/runtime-config.json')
|
||||||
|
# CONFIG_FILE_PATH=./data/runtime-config.json # Example
|
||||||
|
```
|
||||||
|
Refer to `src/lib/configManager.ts` for how `jwtSecret`, `httpPort`, and `wsPort` can also be managed via `data/runtime-config.json` (which is auto-generated on first run if it doesn't exist). For production, setting secrets like `JWT_SECRET` and `SESSION_SECRET` via environment variables is generally recommended.
|
||||||
|
|
||||||
|
4. **Build TypeScript Code:**
|
||||||
|
Compile the TypeScript source files to JavaScript:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
This will create a `dist` directory with the compiled code.
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
1. **Start the Server:**
|
||||||
|
After building the code, run:
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
This command executes the compiled `dist/main.js` file.
|
||||||
|
|
||||||
|
2. **Development Mode (Optional):**
|
||||||
|
For development, you can use `npm run dev`. This script typically watches for TypeScript file changes, recompiles, and restarts the server automatically using `nodemon`.
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **First Run & Master Password:**
|
||||||
|
* If the `MASTER_PASSWORD` environment variable was not set in your `.env` file, the server will prompt you to enter and confirm a master password in the console during its first startup.
|
||||||
|
* This password is critical: it's used to derive the encryption key for your `secrets.json.enc` data file. **Choose a strong password and store it securely.** Losing this password means losing access to all encrypted data.
|
||||||
|
* A salt for deriving the master key will be generated and stored in `data/masterkey.salt`. This salt file should be backed up along with your encrypted data.
|
||||||
|
* The encrypted data itself is stored in `data/secrets.json.enc`.
|
||||||
|
* The application runtime configuration (like JWT secret if not overridden by env var, ports, etc.) is stored in `data/runtime-config.json`.
|
||||||
|
|
||||||
|
**Important:** The `data` directory (containing `secrets.json.enc`, `masterkey.salt`, and `runtime-config.json`) should be backed up regularly and secured appropriately. It is gitignored by default.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Admin UI
|
||||||
|
|
||||||
|
The Admin UI provides a web interface for managing secrets, secret groups, and client applications.
|
||||||
|
|
||||||
|
1. **Access:**
|
||||||
|
Open your web browser and navigate to `http://localhost:<HTTP_PORT>/admin` (e.g., `http://localhost:3000/admin` if using the default port).
|
||||||
|
|
||||||
|
2. **Login:**
|
||||||
|
You will be prompted to log in. Use the `MASTER_PASSWORD` that you either set as an environment variable or entered in the console during the server's first startup.
|
||||||
|
|
||||||
|
3. **Key Management Areas:**
|
||||||
|
|
||||||
|
* **Manage Secrets (Main Page / Secrets Tab):**
|
||||||
|
* **Create Secret Groups:** Define logical groups to organize your secrets.
|
||||||
|
* **Manage Existing Groups:** View, rename, or delete secret groups. Deleting a group also deletes all secrets within it.
|
||||||
|
* **Add New Secrets:** Create new secrets (key-value pairs) and assign them to an existing group. Values can be simple strings or complex JSON objects/arrays.
|
||||||
|
* **View/Manage Secrets within a Group:** Drill down into a group to see its secrets, edit their values, or delete them.
|
||||||
|
* **Edit/Delete Individual Secrets:** Modify the value of existing secrets or delete them from the system (this also removes them from their group).
|
||||||
|
|
||||||
|
* **Manage Clients (Clients Tab):**
|
||||||
|
* **Pending Client Registrations:** View client applications that have registered via the WebSocket API and are awaiting administrative approval.
|
||||||
|
* **Approve:** Approves the client's registration. The client can then proceed to use the WebSocket API with its approved status.
|
||||||
|
* **Reject:** Rejects the client's registration request.
|
||||||
|
* **Approved Clients:** View already approved client applications.
|
||||||
|
* **Manage Groups (for Client):** For each approved client, you can associate them with one or more secret groups. This grants the client access to all secrets within the associated groups.
|
||||||
|
* **Revoke Access:** Deletes the client from the system, effectively revoking all their access.
|
||||||
|
* **WebSocket Settings (Debug):**
|
||||||
|
* Toggle the "Automatically Approve New WebSocket Registrations" setting. This is primarily for development or debugging and should generally be disabled in production environments.
|
||||||
|
|
||||||
|
### WebSocket API
|
||||||
|
|
||||||
|
Client applications connect to the Key/Info Manager via WebSocket to register and request authorized secrets.
|
||||||
|
|
||||||
|
* **Connection URL:** `ws://localhost:<WS_PORT>` (e.g., `ws://localhost:3001`)
|
||||||
|
* **Message Format:** All messages (client-to-server and server-to-client) are JSON objects. A typical structure includes:
|
||||||
|
* `type`: A string indicating the message type (e.g., `REGISTER_CLIENT`, `ERROR`).
|
||||||
|
* `payload`: An object containing the data relevant to the message type.
|
||||||
|
* `code` (Server-to-Client): A numerical response code (see `WsResponseCodes` below).
|
||||||
|
* `requestId` (Optional, Client-to-Server & echoed in Server-to-Client): A client-generated ID to correlate requests and responses.
|
||||||
|
|
||||||
|
**Common Server Response Codes (`WsResponseCodes`):**
|
||||||
|
|
||||||
|
* `2000 OK`: General success.
|
||||||
|
* `2001 REGISTRATION_SUBMITTED`: Client registration successfully submitted, awaiting approval.
|
||||||
|
* `4000 BAD_REQUEST`: The request was malformed or missing required parameters.
|
||||||
|
* `4001 UNAUTHORIZED`: Client is not authorized for the requested action (e.g., not approved, wrong credentials, insufficient permissions).
|
||||||
|
* `4004 NOT_FOUND`: Requested resource (e.g., a specific secret) was not found.
|
||||||
|
* `4005 CLIENT_NOT_REGISTERED`: Client attempted an action before completing `REGISTER_CLIENT`.
|
||||||
|
* `4006 CLIENT_REGISTRATION_EXPIRED`: A pending client registration has expired.
|
||||||
|
* `4029 RATE_LIMIT_EXCEEDED`: Client has sent too many requests in a given timeframe.
|
||||||
|
* `5000 INTERNAL_SERVER_ERROR`: An unexpected error occurred on the server.
|
||||||
|
|
||||||
|
**Key Message Flows:**
|
||||||
|
|
||||||
|
1. **Server Welcome Message (Server to Client):**
|
||||||
|
Upon successful WebSocket connection, the server sends:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "WELCOME",
|
||||||
|
"code": 2000,
|
||||||
|
"payload": { "detail": "Welcome! Please register your client using REGISTER_CLIENT message." }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Client Registration (Client to Server):**
|
||||||
|
* **Purpose:** New client applications must register to be recognized by the server.
|
||||||
|
* **Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "REGISTER_CLIENT",
|
||||||
|
"payload": {
|
||||||
|
"clientName": "My Awesome Application",
|
||||||
|
// "requestedSecretKeys": ["key1", "key2"] // Legacy field, group association is now preferred
|
||||||
|
},
|
||||||
|
"requestId": "client-req-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Server Response (Success - Awaiting Approval):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "REGISTRATION_ACK",
|
||||||
|
"code": 2001, // REGISTRATION_SUBMITTED
|
||||||
|
"payload": {
|
||||||
|
"clientId": "server_generated_client_id", // For admin tracking
|
||||||
|
"detail": "Registration for 'My Awesome Application' submitted. Awaiting admin approval. Your Client ID is server_generated_client_id."
|
||||||
|
},
|
||||||
|
"requestId": "client-req-001"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The client must then be approved in the Admin UI.
|
||||||
|
|
||||||
|
3. **Status Update Notification (Server to Client):**
|
||||||
|
* **Purpose:** After admin action (approval/rejection), the server notifies the specific client if connected.
|
||||||
|
* **Example (Approved):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "STATUS_UPDATE",
|
||||||
|
"code": 2000, // Or a more specific code if defined
|
||||||
|
"payload": {
|
||||||
|
"newStatus": "approved",
|
||||||
|
"detail": "Client registration automatically approved (debug mode)." // Or admin approved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Example (Rejected):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "STATUS_UPDATE",
|
||||||
|
"code": 4001, // UNAUTHORIZED (or a specific "REJECTED" code)
|
||||||
|
"payload": {
|
||||||
|
"newStatus": "rejected",
|
||||||
|
"detail": "Client registration was rejected by an administrator."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Request Secret (Client to Server - Approved Clients Only):**
|
||||||
|
* **Purpose:** To request the value of a secret the client is authorized (via group association) to access.
|
||||||
|
* **Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "REQUEST_SECRET",
|
||||||
|
"payload": { "secretKey": "database_connection_string" },
|
||||||
|
"requestId": "client-req-002"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Server Response (Success):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "SECRET_DATA",
|
||||||
|
"code": 2000, // OK
|
||||||
|
"payload": {
|
||||||
|
"secretKey": "database_connection_string",
|
||||||
|
"value": "the_actual_secret_value_here"
|
||||||
|
},
|
||||||
|
"requestId": "client-req-002"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Server Response (Unauthorized):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ERROR",
|
||||||
|
"code": 4001, // UNAUTHORIZED
|
||||||
|
"payload": {
|
||||||
|
"detail": "You are not authorized to access the secret key \"database_connection_string\"."
|
||||||
|
},
|
||||||
|
"requestId": "client-req-002"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Server Response (Not Found):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ERROR",
|
||||||
|
"code": 4004, // NOT_FOUND
|
||||||
|
"payload": {
|
||||||
|
"detail": "Secret key \"some_other_key\" not found on server, though client is authorized. Please contact admin."
|
||||||
|
},
|
||||||
|
"requestId": "client-req-003"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **List Authorized Secrets (Client to Server - Approved Clients Only):**
|
||||||
|
* **Purpose:** To get a list of all secret keys the client is currently authorized to access.
|
||||||
|
* **Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "LIST_AUTHORIZED_SECRETS",
|
||||||
|
"requestId": "client-req-004"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Server Response (Success):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "AUTHORIZED_SECRETS_LIST",
|
||||||
|
"code": 2000, // OK
|
||||||
|
"payload": {
|
||||||
|
"authorizedSecretKeys": ["key1", "key2", "database_connection_string"]
|
||||||
|
},
|
||||||
|
"requestId": "client-req-004"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**General Error Response (Server to Client):**
|
||||||
|
If a request fails for other reasons (e.g., malformed JSON, internal server error), a generic error is sent:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "ERROR",
|
||||||
|
"code": "<appropriate_error_code>", // e.g., 4000, 5000
|
||||||
|
"payload": { "detail": "A descriptive error message." },
|
||||||
|
"requestId": "<client_provided_requestId_if_any>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
Security is a primary focus of the Key/Info Manager. Please be aware of the following:
|
||||||
|
|
||||||
|
* **Master Password**: The security of all encrypted data relies heavily on the strength and secrecy of your `MASTER_PASSWORD`. Choose a strong, unique password and protect it diligently. This password is used to derive the data encryption key.
|
||||||
|
* **JWT Secret (`JWT_SECRET`)**: This secret is used to sign JSON Web Tokens for Admin UI sessions. It must be kept confidential. If compromised, attackers could forge admin session tokens. Ensure it's a long, random string.
|
||||||
|
* **Session Secret (`SESSION_SECRET`)**: Used by Express session middleware, which is crucial for CSRF protection. This also needs to be a long, random, and unique string kept confidential.
|
||||||
|
* **Data Encryption**: Secrets are encrypted at rest using AES-256-GCM. The salt used for key derivation is stored in `data/masterkey.salt`, and the encrypted data is in `data/secrets.json.enc`. Protect these files appropriately.
|
||||||
|
* **HTTPS/WSS**: For any production deployment, it is **critical** to run both the HTTP Admin UI and the WebSocket server over HTTPS and WSS respectively. This protects data in transit, including the master password during admin login, session cookies, and any secrets transmitted over WebSockets. This application setup does **not** include HTTPS/WSS by default; you will need to configure this using a reverse proxy (like Nginx or Caddy) or other methods.
|
||||||
|
* **CSRF Protection**: The Admin UI uses `csurf` to protect against Cross-Site Request Forgery attacks on state-changing POST requests.
|
||||||
|
* **Security Headers**: `helmet` is used to set various HTTP security headers, providing an additional layer of defense against common web vulnerabilities.
|
||||||
|
* **Rate Limiting**: Both the HTTP admin interface and the WebSocket server implement rate limiting to protect against brute-force attacks and denial-of-service attempts. These limits are configurable via environment variables (see "Getting Started & Installation").
|
||||||
|
* **Input Validation**: While the application includes input validation, continuously reviewing and hardening validation logic is good practice, especially for all data received from clients or the Admin UI.
|
||||||
|
* **Principle of Least Privilege**: Clients should only be associated with secret groups containing the specific secrets they absolutely need. Avoid overly broad permissions.
|
||||||
|
* **Environment Variables**: Never commit your `.env` file (or any file containing actual secrets) to version control. Use environment variables for deploying sensitive configurations in production.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Basic unit tests for some core components (like `DataManager`) might be available.
|
||||||
|
|
||||||
|
* **Run tests:**
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
* **Watch mode for tests:**
|
||||||
|
```bash
|
||||||
|
npm run test:watch
|
||||||
|
```
|
||||||
|
|
||||||
|
For end-to-end testing, manual testing of the Admin UI is recommended. The `client-example.html` file (if provided in the repository) can be used for basic WebSocket client interaction testing.
|
||||||
|
|
||||||
|
## Project Structure Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
key_manager/
|
||||||
|
├── data/ # Encrypted data, salt, runtime config (gitignored)
|
||||||
|
├── dist/ # Compiled JavaScript output from TypeScript
|
||||||
|
├── node_modules/ # Project dependencies (gitignored)
|
||||||
|
├── public/ # Static assets (e.g., CSS)
|
||||||
|
│ └── css/
|
||||||
|
│ └── admin_styles.css
|
||||||
|
├── src/ # TypeScript source files
|
||||||
|
│ ├── http/ # HTTP server (Express) and Admin UI logic
|
||||||
|
│ ├── lib/ # Core libraries (dataManager, configManager, encryption)
|
||||||
|
│ ├── websocket/ # WebSocket server logic
|
||||||
|
│ └── main.ts # Main application entry point
|
||||||
|
├── views/ # EJS templates for Admin UI
|
||||||
|
├── .env # Local environment variables (gitignored)
|
||||||
|
├── .gitignore
|
||||||
|
├── client-example.html # Example WebSocket client for testing (if present)
|
||||||
|
├── jest.config.js
|
||||||
|
├── package-lock.json
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please feel free to open an issue or submit a pull request.
|
||||||
|
(Further details can be added here, e.g., coding standards, branch strategy, if the project grows.)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the **ISC License**. See the `LICENSE` file (if one exists) or `package.json` for more details.
|
||||||
|
|||||||
306
client-example.html
Normal file
306
client-example.html
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>WebSocket Client Example</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||||
|
#messages { border: 1px solid #ccc; padding: 10px; height: 200px; overflow-y: scroll; margin-bottom: 10px; }
|
||||||
|
.message { margin-bottom: 5px; }
|
||||||
|
.server { color: blue; }
|
||||||
|
.client { color: green; }
|
||||||
|
.error { color: red; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WebSocket Client Example</h1>
|
||||||
|
<div>
|
||||||
|
<label for="wsUrl">WebSocket URL:</label>
|
||||||
|
<input type="text" id="wsUrl" value="ws://localhost:3001">
|
||||||
|
<button id="connectBtn">Connect</button>
|
||||||
|
<button id="disconnectBtn" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
<div id="messages"></div>
|
||||||
|
<hr>
|
||||||
|
<div>
|
||||||
|
<h3>Client State:</h3>
|
||||||
|
<p>Server-Assigned Client ID: <span id="clientIdSpan">N/A</span></p>
|
||||||
|
<p>Status: <span id="clientStatusSpan">Disconnected</span></p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div>
|
||||||
|
<h3>Actions:</h3>
|
||||||
|
<input type="text" id="clientNameInput" placeholder="Your Client Name (for registration)">
|
||||||
|
<button id="registerBtn" disabled>Register Client</button>
|
||||||
|
<br><br>
|
||||||
|
<!-- Authenticate button removed -->
|
||||||
|
<input type="text" id="secretKeyInput" placeholder="Secret Key to Request">
|
||||||
|
<button id="requestSecretBtn" disabled>Request Secret</button>
|
||||||
|
<br><br>
|
||||||
|
<button id="listSecretsBtn" disabled>List Authorized Secrets</button>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<div>
|
||||||
|
<h3>Send Custom JSON Message:</h3>
|
||||||
|
<textarea id="customMessageInput" rows="4" style="width: 90%;" placeholder='{ "type": "YOUR_TYPE", "payload": { ... } }'></textarea>
|
||||||
|
<button id="sendCustomBtn" disabled>Send Custom JSON</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const wsUrlInput = document.getElementById('wsUrl');
|
||||||
|
const connectBtn = document.getElementById('connectBtn');
|
||||||
|
const disconnectBtn = document.getElementById('disconnectBtn');
|
||||||
|
const messagesDiv = document.getElementById('messages');
|
||||||
|
|
||||||
|
const registerBtn = document.getElementById('registerBtn');
|
||||||
|
const clientNameInput = document.getElementById('clientNameInput');
|
||||||
|
// const authenticateBtn = document.getElementById('authenticateBtn'); // Removed
|
||||||
|
// const authTokenInput = document.getElementById('authTokenInput'); // Removed
|
||||||
|
const requestSecretBtn = document.getElementById('requestSecretBtn');
|
||||||
|
const secretKeyInput = document.getElementById('secretKeyInput');
|
||||||
|
const listSecretsBtn = document.getElementById('listSecretsBtn');
|
||||||
|
const customMessageInput = document.getElementById('customMessageInput');
|
||||||
|
const sendCustomBtn = document.getElementById('sendCustomBtn');
|
||||||
|
|
||||||
|
const clientIdSpan = document.getElementById('clientIdSpan');
|
||||||
|
const tempIdSpan = document.getElementById('tempIdSpan');
|
||||||
|
const clientStatusSpan = document.getElementById('clientStatusSpan');
|
||||||
|
|
||||||
|
|
||||||
|
let socket = null;
|
||||||
|
let serverAssignedClientId = null; // Server-assigned client ID (for admin tracking)
|
||||||
|
let clientIsApproved = false; // Track if client is approved by server
|
||||||
|
|
||||||
|
function updateClientStateDisplay() {
|
||||||
|
clientIdSpan.textContent = serverAssignedClientId || 'N/A';
|
||||||
|
}
|
||||||
|
|
||||||
|
function logMessage(message, type = 'info') {
|
||||||
|
const p = document.createElement('p');
|
||||||
|
p.textContent = message;
|
||||||
|
p.className = `message ${type}`;
|
||||||
|
messagesDiv.appendChild(p);
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectBtn.addEventListener('click', () => {
|
||||||
|
if (socket) {
|
||||||
|
logMessage('Already connected or connecting.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = wsUrlInput.value;
|
||||||
|
logMessage(`Attempting to connect to ${url}...`);
|
||||||
|
socket = new WebSocket(url);
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
logMessage('Connected to WebSocket server.', 'server');
|
||||||
|
clientStatusSpan.textContent = 'Connected (Not Authenticated)';
|
||||||
|
connectBtn.disabled = true;
|
||||||
|
disconnectBtn.disabled = false;
|
||||||
|
registerBtn.disabled = false;
|
||||||
|
// authenticateBtn.disabled = false; // Removed
|
||||||
|
sendCustomBtn.disabled = false; // Allow sending custom messages once connected
|
||||||
|
clientIsApproved = false; // Reset approval state on new connection
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const serverMessage = JSON.parse(event.data);
|
||||||
|
logMessage(`Server: ${JSON.stringify(serverMessage, null, 2)} (Code: ${serverMessage.code})`, 'server');
|
||||||
|
|
||||||
|
// Standard response codes
|
||||||
|
const WsResponseCodes = { OK: 2000, REGISTRATION_SUBMITTED: 2001, BAD_REQUEST: 4000, UNAUTHORIZED: 4001, CLIENT_NOT_REGISTERED: 4005, CLIENT_REGISTRATION_EXPIRED: 4006 };
|
||||||
|
|
||||||
|
switch(serverMessage.type) {
|
||||||
|
case 'WELCOME':
|
||||||
|
clientStatusSpan.textContent = 'Connected. Please Register.';
|
||||||
|
break;
|
||||||
|
case 'REGISTRATION_ACK':
|
||||||
|
if (serverMessage.code === WsResponseCodes.REGISTRATION_SUBMITTED) {
|
||||||
|
serverAssignedClientId = serverMessage.payload.clientId;
|
||||||
|
clientStatusSpan.textContent = 'Registration Submitted. Awaiting Admin Approval.';
|
||||||
|
updateClientStateDisplay();
|
||||||
|
logMessage(`Your Server-Assigned Client ID is ${serverAssignedClientId}. You will need admin approval to proceed.`, 'info');
|
||||||
|
} else {
|
||||||
|
clientStatusSpan.textContent = `Registration Failed (Code: ${serverMessage.code})`;
|
||||||
|
logMessage(`Registration Error: ${serverMessage.payload.detail}`, 'error');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// 'AUTHENTICATED' and 'AUTH_FAILED' types are removed
|
||||||
|
case 'SECRET_DATA': // Renamed from SECRET_RESPONSE for clarity
|
||||||
|
if (serverMessage.code === WsResponseCodes.OK) {
|
||||||
|
logMessage(`Secret "${serverMessage.payload.secretKey}": ${JSON.stringify(serverMessage.payload.value)}`, 'info');
|
||||||
|
} else {
|
||||||
|
logMessage(`Error fetching secret (Code: ${serverMessage.code}): ${serverMessage.payload.detail}`, 'error');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'AUTHORIZED_SECRETS_LIST': // Or AVAILABLE_SECRETS_LIST depending on server
|
||||||
|
if (serverMessage.code === WsResponseCodes.OK) {
|
||||||
|
logMessage(`Authorized/Available secrets: ${serverMessage.payload.authorizedSecretKeys || serverMessage.payload.availableSecretKeys.join(', ')}`, 'info');
|
||||||
|
// For this client, assume approval if it gets this list successfully for now
|
||||||
|
// However, STATUS_UPDATE is the definitive source of approval state.
|
||||||
|
// clientIsApproved = true; // Let STATUS_UPDATE handle this
|
||||||
|
// requestSecretBtn.disabled = !clientIsApproved;
|
||||||
|
// listSecretsBtn.disabled = !clientIsApproved;
|
||||||
|
// clientStatusSpan.textContent = 'Approved. Actions enabled.';
|
||||||
|
} else {
|
||||||
|
logMessage(`Error listing secrets (Code: ${serverMessage.code}): ${serverMessage.payload.detail}`, 'error');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'STATUS_UPDATE':
|
||||||
|
logMessage(`Server Status Update (Code: ${serverMessage.code}): ${serverMessage.payload.detail}`, 'info');
|
||||||
|
clientStatusSpan.textContent = `Status: ${serverMessage.payload.newStatus}. ${serverMessage.payload.detail || ''}`;
|
||||||
|
if (serverMessage.payload.newStatus === 'approved') {
|
||||||
|
clientIsApproved = true;
|
||||||
|
requestSecretBtn.disabled = false;
|
||||||
|
listSecretsBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
clientIsApproved = false;
|
||||||
|
requestSecretBtn.disabled = true;
|
||||||
|
listSecretsBtn.disabled = true;
|
||||||
|
if (serverMessage.payload.newStatus === 'rejected' && serverAssignedClientId) {
|
||||||
|
// If rejected after being pending, they might need to re-register or be stuck
|
||||||
|
logMessage('Your registration was rejected or your session ended.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// UNAUTHORIZED_SECRET_ACCESS type might be consolidated into general ERROR with code 4001
|
||||||
|
case 'ERROR':
|
||||||
|
logMessage(`Server Error (Type: ${serverMessage.type}, Code: ${serverMessage.code}): ${serverMessage.payload.detail}`, 'error');
|
||||||
|
if (serverMessage.code === WsResponseCodes.UNAUTHORIZED ||
|
||||||
|
serverMessage.code === WsResponseCodes.CLIENT_NOT_REGISTERED ||
|
||||||
|
serverMessage.code === WsResponseCodes.CLIENT_REGISTRATION_EXPIRED) {
|
||||||
|
clientIsApproved = false;
|
||||||
|
requestSecretBtn.disabled = true;
|
||||||
|
listSecretsBtn.disabled = true;
|
||||||
|
if (serverMessage.code === WsResponseCodes.CLIENT_REGISTRATION_EXPIRED) {
|
||||||
|
clientStatusSpan.textContent = "Registration Expired. Please re-register.";
|
||||||
|
serverAssignedClientId = null; // Clear client ID as it's no longer valid
|
||||||
|
updateClientStateDisplay();
|
||||||
|
} else if (serverMessage.code === WsResponseCodes.CLIENT_NOT_REGISTERED) {
|
||||||
|
clientStatusSpan.textContent = "Not Registered. Please register.";
|
||||||
|
} else { // General UNAUTHORIZED
|
||||||
|
clientStatusSpan.textContent = "Action Unauthorized or Approval Pending/Rejected.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logMessage(`Unknown message type from server: ${serverMessage.type} (Code: ${serverMessage.code})`, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error processing server message:", e);
|
||||||
|
logMessage(`Received non-JSON message from server: ${event.data}`, 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = (event) => {
|
||||||
|
logMessage(`Disconnected from WebSocket server. Code: ${event.code}, Reason: ${event.reason || 'N/A'}`, 'error');
|
||||||
|
clientStatusSpan.textContent = 'Disconnected';
|
||||||
|
socket = null;
|
||||||
|
connectBtn.disabled = false;
|
||||||
|
disconnectBtn.disabled = true;
|
||||||
|
registerBtn.disabled = true;
|
||||||
|
// authenticateBtn.disabled = true; // Removed
|
||||||
|
requestSecretBtn.disabled = true;
|
||||||
|
listSecretsBtn.disabled = true;
|
||||||
|
sendCustomBtn.disabled = true;
|
||||||
|
serverAssignedClientId = null;
|
||||||
|
clientIsApproved = false;
|
||||||
|
updateClientStateDisplay();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = (error) => {
|
||||||
|
logMessage('WebSocket error. See console for details.', 'error');
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
// Note: onclose will usually be called after onerror.
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
disconnectBtn.addEventListener('click', () => {
|
||||||
|
if (socket) {
|
||||||
|
logMessage('Disconnecting...');
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to generate a simple UUID for requestId
|
||||||
|
function generateUUID() {
|
||||||
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||||
|
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendWebSocketMessage(messageObject) {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
if (!messageObject.requestId) { // Add a requestId if not present
|
||||||
|
messageObject.requestId = generateUUID();
|
||||||
|
}
|
||||||
|
const messageString = JSON.stringify(messageObject);
|
||||||
|
logMessage(`Client (Req ID: ${messageObject.requestId}): ${messageString}`, 'client');
|
||||||
|
socket.send(messageString);
|
||||||
|
} else {
|
||||||
|
logMessage('Not connected. Cannot send message.', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerBtn.addEventListener('click', () => {
|
||||||
|
const clientName = clientNameInput.value.trim();
|
||||||
|
if (!clientName) {
|
||||||
|
logMessage('Please enter a client name for registration.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clientIsApproved = false; // Reset approval state on new registration attempt
|
||||||
|
requestSecretBtn.disabled = true;
|
||||||
|
listSecretsBtn.disabled = true;
|
||||||
|
clientStatusSpan.textContent = "Registering...";
|
||||||
|
sendWebSocketMessage({
|
||||||
|
type: "REGISTER_CLIENT",
|
||||||
|
payload: { clientName: clientName }
|
||||||
|
// Server will respond with REGISTRATION_ACK and its own client ID
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// authenticateBtn event listener removed
|
||||||
|
|
||||||
|
requestSecretBtn.addEventListener('click', () => {
|
||||||
|
const secretKey = secretKeyInput.value.trim();
|
||||||
|
if (!secretKey) {
|
||||||
|
logMessage('Please enter a secret key to request.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!clientIsApproved) {
|
||||||
|
logMessage('Client not approved. Cannot request secret.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendWebSocketMessage({
|
||||||
|
type: "REQUEST_SECRET",
|
||||||
|
payload: { secretKey: secretKey }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
listSecretsBtn.addEventListener('click', () => {
|
||||||
|
if (!clientIsApproved) {
|
||||||
|
logMessage('Client not approved. Cannot list secrets.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendWebSocketMessage({
|
||||||
|
type: "LIST_AUTHORIZED_SECRETS"
|
||||||
|
// Server will respond with AUTHORIZED_SECRETS_LIST or AVAILABLE_SECRETS_LIST
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
sendCustomBtn.addEventListener('click', () => {
|
||||||
|
try {
|
||||||
|
const customJson = JSON.parse(customMessageInput.value);
|
||||||
|
sendWebSocketMessage(customJson);
|
||||||
|
} catch (e) {
|
||||||
|
logMessage('Invalid JSON in custom message input.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
jest.config.js
Normal file
9
jest.config.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
testMatch: ['**/src/**/*.spec.ts', '**/src/**/*.test.ts'],
|
||||||
|
clearMocks: true, // Automatically clear mock calls and instances between every test
|
||||||
|
resetMocks: true, // Automatically reset mock state between every test
|
||||||
|
restoreMocks: true, // Automatically restore mock state and implementation between every test
|
||||||
|
};
|
||||||
6095
package-lock.json
generated
Normal file
6095
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "key manager",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"dev": "tsc -w & nodemon dist/main.js",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
|
},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/GuilhermeStrice/key_manager.git"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/GuilhermeStrice/key_manager/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/GuilhermeStrice/key_manager#readme",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.9",
|
||||||
|
"@types/csurf": "^1.11.5",
|
||||||
|
"@types/express": "^5.0.3",
|
||||||
|
"@types/express-session": "^1.18.2",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^24.0.4",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"jest": "^30.0.3",
|
||||||
|
"nodemon": "^3.1.10",
|
||||||
|
"ts-jest": "^29.4.0",
|
||||||
|
"typescript": "^5.8.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/helmet": "^0.0.48",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"csurf": "^1.11.0",
|
||||||
|
"dotenv": "^16.6.0",
|
||||||
|
"ejs": "^3.1.10",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"express-rate-limit": "^7.5.1",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"ws": "^8.18.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
316
public/css/admin_styles.css
Normal file
316
public/css/admin_styles.css
Normal file
@ -0,0 +1,316 @@
|
|||||||
|
/* General Body and Container Styles */
|
||||||
|
body {
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f4f4f4;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 90%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 20px auto;
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Links */
|
||||||
|
.nav-links {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 10px 15px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
border-radius: 5px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
margin-right: 15px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #007bff;
|
||||||
|
font-weight: bold;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
.nav-links a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.nav-links .logout-link { /* Specific class for logout if needed for positioning */
|
||||||
|
margin-left: auto; /* Pushes logout to the right */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Headings */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: #333;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2em; margin-bottom: 1em; }
|
||||||
|
h2 { font-size: 1.75em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; margin-top: 1.5em; }
|
||||||
|
h3 { font-size: 1.5em; margin-top: 1.25em;}
|
||||||
|
|
||||||
|
/* Tables */
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 12px 15px; /* Increased padding */
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
text-align: left;
|
||||||
|
word-break: break-word; /* Keep this for long strings */
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(odd) {
|
||||||
|
background-color: #f9f9f9; /* Subtle striping for readability */
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background-color: #f1f1f1; /* Hover effect for rows */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions in tables */
|
||||||
|
.actions a, .actions button {
|
||||||
|
margin-right: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #007bff;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 5px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
display: inline-block; /* Align items better */
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.actions a:hover, .actions button:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.delete,
|
||||||
|
.actions button.reject {
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
.actions button.delete:hover,
|
||||||
|
.actions button.reject:hover {
|
||||||
|
color: #a71d2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions button.approve {
|
||||||
|
color: #28a745;
|
||||||
|
}
|
||||||
|
.actions button.approve:hover {
|
||||||
|
color: #1c7430;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px; /* Increased spacing */
|
||||||
|
font-weight: bold;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[type="text"],
|
||||||
|
.form-group input[type="password"],
|
||||||
|
.form-group input[type="checkbox"],
|
||||||
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%; /* Full width */
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box; /* Important for width: 100% and padding */
|
||||||
|
}
|
||||||
|
.form-group input[type="checkbox"] {
|
||||||
|
width: auto; /* Checkboxes shouldn't be full width */
|
||||||
|
margin-right: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.form-group label input[type="checkbox"] { /* For labels wrapping checkboxes */
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.form-group textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical; /* Allow vertical resize */
|
||||||
|
}
|
||||||
|
.form-group select {
|
||||||
|
appearance: none; /* Basic reset for select */
|
||||||
|
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M5%208l5%205%205-5z%22%20fill%3D%22%23555%22%2F%3E%3C%2Fsvg%3E');
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 10px center;
|
||||||
|
background-size: 12px;
|
||||||
|
padding-right: 30px; /* Make space for arrow */
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input[readonly], .form-group textarea[readonly] {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.form-control-plaintext { /* From Bootstrap for readonly fields that look like text */
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: .375rem;
|
||||||
|
padding-bottom: .375rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
background-color: transparent;
|
||||||
|
border: solid transparent;
|
||||||
|
border-width: 1px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 10px 18px; /* Slightly adjusted padding */
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.95em;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
color: white; /* Ensure text color remains on hover */
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success { /* Added success button style */
|
||||||
|
background-color: #28a745;
|
||||||
|
}
|
||||||
|
.btn-success:hover {
|
||||||
|
background-color: #1e7e34;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Alerts */
|
||||||
|
.alert {
|
||||||
|
padding: 15px; /* Increased padding */
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid transparent; /* Base for border */
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border-color: #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border-color: #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-info { /* Added info alert style */
|
||||||
|
background-color: #cce5ff;
|
||||||
|
color: #004085;
|
||||||
|
border-color: #b8daff;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.mono { /* For monospace text, like client IDs */
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
background-color: #e9ecef;
|
||||||
|
padding: 3px 6px; /* Adjusted padding */
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.small-text {
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-danger { /* For error text not in an alert */
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-1 { margin-bottom: 0.25rem !important; }
|
||||||
|
.mb-2 { margin-bottom: 0.5rem !important; }
|
||||||
|
.mb-3 { margin-bottom: 1rem !important; }
|
||||||
|
.mt-1 { margin-top: 0.25rem !important; }
|
||||||
|
.mt-2 { margin-top: 0.5rem !important; }
|
||||||
|
.mt-3 { margin-top: 1rem !important; }
|
||||||
|
.ml-1 { margin-left: 0.25rem !important; }
|
||||||
|
.ml-2 { margin-left: 0.5rem !important; }
|
||||||
|
.mr-1 { margin-right: 0.25rem !important; }
|
||||||
|
.mr-2 { margin-right: 0.5rem !important; }
|
||||||
|
|
||||||
|
/* For inline forms like in table actions */
|
||||||
|
.form-inline {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 5px; /* Add some space between inline forms/buttons */
|
||||||
|
}
|
||||||
|
.form-inline button {
|
||||||
|
padding: 5px 8px; /* Smaller padding for inline buttons */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific section styling */
|
||||||
|
.settings-section, .section-divider {
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.settings-section h3 {
|
||||||
|
margin-top: 0; /* Reset top margin for h3 within this section */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox list styling for group management */
|
||||||
|
.checkbox-list div {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.checkbox-list label { /* Target labels next to checkboxes */
|
||||||
|
font-weight: normal; /* Override bold from .form-group label */
|
||||||
|
margin-left: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 30px 0;
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
969
src/http/httpServer.ts
Normal file
969
src/http/httpServer.ts
Normal file
@ -0,0 +1,969 @@
|
|||||||
|
// HTTP server and admin UI logic
|
||||||
|
import express from 'express';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import path from 'path';
|
||||||
|
import helmet from 'helmet'; // Security headers
|
||||||
|
import jwt from 'jsonwebtoken'; // Added for JWT
|
||||||
|
import cookieParser from 'cookie-parser'; // Added for cookie parsing
|
||||||
|
import session from 'express-session'; // For CSRF
|
||||||
|
import csrf from 'csurf'; // For CSRF
|
||||||
|
import crypto from 'crypto'; // For generating temporary session secret
|
||||||
|
import * as DataManager from '../lib/dataManager'; // Import DataManager functions
|
||||||
|
import {
|
||||||
|
createSecretGroup,
|
||||||
|
getAllSecretGroups,
|
||||||
|
getSecretGroupById,
|
||||||
|
renameSecretGroup,
|
||||||
|
deleteSecretGroup,
|
||||||
|
createSecretInGroup,
|
||||||
|
updateSecretValue,
|
||||||
|
deleteSecret,
|
||||||
|
getSecretWithValue
|
||||||
|
} from '../lib/dataManager'; // Specific imports for Phase 1
|
||||||
|
import { notifyClientStatusUpdate } from '../websocket/wsServer'; // Import notification function
|
||||||
|
import { getConfig, updateAutoApproveSetting } from '../lib/configManager'; // Import configManager functions
|
||||||
|
|
||||||
|
// This is a very basic way to hold the password for the session.
|
||||||
|
// In a more complex app, this would be handled more securely, perhaps not stored directly.
|
||||||
|
let serverAdminPasswordSingleton: string | null = null;
|
||||||
|
|
||||||
|
// Global flag for WebSocket auto-approval is now managed by configManager
|
||||||
|
// export let autoApproveWebSocketRegistrations: boolean = false;
|
||||||
|
|
||||||
|
// JWT_SECRET is now managed by configManager
|
||||||
|
// const JWT_SECRET = process.env.JWT_SECRET || 'DEFAULT_FALLBACK_SECRET_DO_NOT_USE_IN_PROD';
|
||||||
|
// if (JWT_SECRET === 'DEFAULT_FALLBACK_SECRET_DO_NOT_USE_IN_PROD' && getConfig().jwtSecret === 'DEFAULT_FALLBACK_SECRET_DO_NOT_USE_IN_PROD') {
|
||||||
|
// Warning is handled by configManager
|
||||||
|
// }
|
||||||
|
const ADMIN_COOKIE_NAME = 'admin_token';
|
||||||
|
|
||||||
|
|
||||||
|
export function startHttpServer(port: number, serverAdminPassword?: string) {
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Use Helmet for basic security headers
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// Rate Limiting
|
||||||
|
// General limiter for most admin routes
|
||||||
|
const adminApiLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100, // Limit each IP to 100 requests per windowMs
|
||||||
|
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||||
|
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
||||||
|
message: 'Too many requests from this IP, please try again after 15 minutes.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stricter limiter for login attempts
|
||||||
|
const loginLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5, // Limit each IP to 5 login attempts per windowMs
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: 'Too many login attempts from this IP, please try again after an hour.',
|
||||||
|
skipSuccessfulRequests: true, // Do not count successful logins towards the limit
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply general limiter to all /admin routes, except login page GET
|
||||||
|
// Specific routes like login POST will have their own stricter limiter.
|
||||||
|
app.use('/admin', (req, res, next) => {
|
||||||
|
// Skip general rate limiter for GET /admin/login to allow page rendering
|
||||||
|
if (req.path === '/login' && req.method === 'GET') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
adminApiLimiter(req, res, next);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Setup EJS as the templating engine
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
// Point Express to the `views` directory. __dirname is src/http, so ../../views
|
||||||
|
app.set('views', path.join(__dirname, '../../views'));
|
||||||
|
|
||||||
|
|
||||||
|
if (serverAdminPassword) {
|
||||||
|
serverAdminPasswordSingleton = serverAdminPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware for parsing URL-encoded data (for form submissions)
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
// Middleware for parsing JSON bodies
|
||||||
|
app.use(express.json());
|
||||||
|
// Middleware for parsing cookies
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
|
// Middleware for serving static files (e.g., CSS, client-side JS)
|
||||||
|
// __dirname is src/http, so ../../public points to the project's public directory
|
||||||
|
app.use(express.static(path.join(__dirname, '../../public')));
|
||||||
|
|
||||||
|
// Session middleware configuration (needed for csurf)
|
||||||
|
// IMPORTANT: Use a strong, unique secret from environment variables in production
|
||||||
|
const sessionSecret = process.env.SESSION_SECRET || crypto.randomBytes(32).toString('hex');
|
||||||
|
if (sessionSecret === crypto.randomBytes(32).toString('hex') && process.env.NODE_ENV !== 'test') { // Crude check if it's a temp secret
|
||||||
|
console.warn('WARNING: Using a temporary session secret. Set SESSION_SECRET in your environment for production.');
|
||||||
|
}
|
||||||
|
app.use(session({
|
||||||
|
secret: sessionSecret,
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: true, // Typically true for csurf if session is not otherwise established
|
||||||
|
cookie: {
|
||||||
|
secure: process.env.NODE_ENV === 'production', // Use secure cookies in production
|
||||||
|
httpOnly: true, // Helps prevent XSS
|
||||||
|
sameSite: 'lax' // Good default for CSRF protection balance
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CSRF protection middleware
|
||||||
|
// This should be after session and cookieParser
|
||||||
|
// All non-GET requests to protected routes will need a CSRF token
|
||||||
|
const csrfProtection = csrf({ cookie: false }); // Using session-based storage for CSRF secret
|
||||||
|
// We will apply csrfProtection selectively or globally before routes that need it.
|
||||||
|
// For admin panel, most POST routes will need it. Login POST might be an exception if handled before session.
|
||||||
|
// For now, we will apply it to specific routes that render forms.
|
||||||
|
// Note: The login page itself (GET /admin/login) does not need CSRF protection on its GET request,
|
||||||
|
// as it doesn't contain forms that would be submitted with a CSRF token from *that* page load.
|
||||||
|
// The POST /admin/login is also special as it establishes auth; CSRF is more for actions taken *after* auth.
|
||||||
|
// However, if we decide to protect POST /admin/login, its GET handler would need to provide a token.
|
||||||
|
// For now, focusing on authenticated admin actions.
|
||||||
|
|
||||||
|
// Simple password protection for all /admin routes
|
||||||
|
// TODO: Implement proper session-based authentication for the admin panel
|
||||||
|
const adminAuth = (req: express.Request, res: express.Response, next: express.NextFunction): any => { // Added : any
|
||||||
|
// Allow access to login page (GET and POST) without further checks here
|
||||||
|
if (req.path === '/admin/login') {
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serverAdminPasswordSingleton) {
|
||||||
|
console.warn('Admin password not set for HTTP server. Admin routes will be inaccessible.');
|
||||||
|
return res.status(500).send('Admin interface not configured.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check for JWT in cookie for all other /admin routes
|
||||||
|
const tokenCookie = req.cookies[ADMIN_COOKIE_NAME];
|
||||||
|
if (tokenCookie) {
|
||||||
|
try {
|
||||||
|
jwt.verify(tokenCookie, getConfig().jwtSecret); // Throws error if invalid
|
||||||
|
// Optional: req.user = decoded;
|
||||||
|
return next(); // Valid JWT cookie, allow access
|
||||||
|
} catch (err: any) { // Type err as any to allow accessing err.message
|
||||||
|
// console.warn is kept as it's useful for ops, but detailed trace logs removed
|
||||||
|
console.warn('Invalid JWT cookie:', err.message);
|
||||||
|
res.clearCookie(ADMIN_COOKIE_NAME, { path: '/admin' }); // Clear bad cookie
|
||||||
|
return res.status(401).redirect('/admin/login'); // Redirect immediately
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bearer token functionality removed. Authentication is cookie-based.
|
||||||
|
// const authHeader = req.headers.authorization;
|
||||||
|
// if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
// const bearerToken = authHeader.substring(7);
|
||||||
|
// if (bearerToken === serverAdminPasswordSingleton) {
|
||||||
|
// return next();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// If here, no valid JWT cookie was found (or an invalid one was cleared),
|
||||||
|
// so redirect to login page.
|
||||||
|
return res.status(401).redirect('/admin/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
// The adminAuth middleware is applied individually to each protected /admin/* route below,
|
||||||
|
// except for /admin/login routes themselves which handle their own logic.
|
||||||
|
|
||||||
|
// Apply auth to all /admin routes except potentially the login page itself if handled differently
|
||||||
|
// app.use('/admin', adminAuth); // This would protect /admin/login too, needs care.
|
||||||
|
// Let's make specific routes and protect them individually or use a more granular approach.
|
||||||
|
|
||||||
|
app.get('/admin/login', (req, res) => {
|
||||||
|
// This route is now effectively handled by the adminAuth logic if not authenticated
|
||||||
|
// but we can provide the form directly if accessed via GET.
|
||||||
|
res.send(`
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<form action="/admin/login" method="POST">
|
||||||
|
<label for="password">Password:</label>
|
||||||
|
<input type="password" id="password" name="password" required>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
<p>Hint: Use the server startup password.</p>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/login', loginLimiter, express.urlencoded({ extended: false }), (req, res) => {
|
||||||
|
if (req.body.password && req.body.password === serverAdminPasswordSingleton) {
|
||||||
|
// Generate JWT
|
||||||
|
const token = jwt.sign({ admin: true, user: 'admin' }, getConfig().jwtSecret, { expiresIn: '1h' });
|
||||||
|
// Set cookie options: httpOnly for security, secure in production, path for admin routes
|
||||||
|
const cookieOptions: express.CookieOptions = {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
path: '/admin',
|
||||||
|
sameSite: 'lax' // Recommended for CSRF protection
|
||||||
|
};
|
||||||
|
res.cookie(ADMIN_COOKIE_NAME, token, cookieOptions);
|
||||||
|
res.redirect('/admin'); // Redirect to admin page
|
||||||
|
} else {
|
||||||
|
res.status(401).send('Login failed. <a href="/admin/login">Try again</a>');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: Handle deleting a secret from within a group view
|
||||||
|
app.post('/admin/groups/:groupId/secrets/:secretKey/delete', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10); // For redirect
|
||||||
|
const secretKey = decodeURIComponent(req.params.secretKey);
|
||||||
|
try {
|
||||||
|
if (isNaN(groupId)) throw new Error('Invalid group ID for redirect.'); // Should not happen if reached here from valid page
|
||||||
|
|
||||||
|
await deleteSecret(secretKey); // deleteSecret handles removing from group and secrets list
|
||||||
|
res.redirect(`/admin/groups/${groupId}/secrets?message=Secret+deleted+successfully.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error deleting secret ${secretKey} from group context ${groupId}:`, error);
|
||||||
|
res.redirect(`/admin/groups/${groupId}/secrets?message=Error+deleting+secret.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: Show form to edit a secret's value within a group
|
||||||
|
app.get('/admin/groups/:groupId/secrets/:secretKey/edit', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
const secretKey = decodeURIComponent(req.params.secretKey); // secretKey might have URL encoded chars
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isNaN(groupId)) throw new Error('Invalid group ID.');
|
||||||
|
|
||||||
|
const group = getSecretGroupById(groupId);
|
||||||
|
if (!group) throw new Error('Group not found.');
|
||||||
|
if (!group.keys.includes(secretKey)) throw new Error('Secret not found in this group.');
|
||||||
|
|
||||||
|
const secretToEdit = getSecretWithValue(secretKey);
|
||||||
|
if (!secretToEdit) throw new Error('Secret details not found.');
|
||||||
|
|
||||||
|
// Re-fetch other necessary data for rendering group_secrets.ejs
|
||||||
|
const secretsInGroup = group.keys.map(key => {
|
||||||
|
const secretData = getSecretWithValue(key);
|
||||||
|
return { key, value: secretData?.value };
|
||||||
|
}).filter(s => s.value !== undefined);
|
||||||
|
|
||||||
|
res.render('group_secrets', {
|
||||||
|
group,
|
||||||
|
secretsInGroup,
|
||||||
|
message: null, // Or pass from query if needed
|
||||||
|
csrfToken: req.csrfToken(),
|
||||||
|
editingSecretKey: secretKey,
|
||||||
|
secretToEdit: secretToEdit.value
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error preparing to edit secret ${secretKey} in group ${groupId}:`, error);
|
||||||
|
res.redirect(`/admin/groups/${groupId}/secrets?message=Error+loading+secret+for+edit.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: Handle updating a secret's value within a group
|
||||||
|
app.post('/admin/groups/:groupId/secrets/:secretKey/update', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
const secretKey = decodeURIComponent(req.params.secretKey);
|
||||||
|
try {
|
||||||
|
if (isNaN(groupId)) throw new Error('Invalid group ID.');
|
||||||
|
|
||||||
|
const { secretValue } = req.body;
|
||||||
|
if (secretValue === undefined) {
|
||||||
|
throw new Error('Secret value is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Verify secret still belongs to this group before updating if desired, though updateSecretValue only cares about the key.
|
||||||
|
// const currentSecret = getSecretWithValue(secretKey);
|
||||||
|
// if (!currentSecret || currentSecret.groupId !== groupId) {
|
||||||
|
// throw new Error('Secret not found in this group or group association mismatch.');
|
||||||
|
// }
|
||||||
|
|
||||||
|
let parsedValue = secretValue;
|
||||||
|
try {
|
||||||
|
const trimmedValue = typeof secretValue === 'string' ? secretValue.trim() : secretValue;
|
||||||
|
if (typeof trimmedValue === 'string' && ((trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) || (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')))) {
|
||||||
|
parsedValue = JSON.parse(trimmedValue);
|
||||||
|
}
|
||||||
|
} catch (e) { /* Not valid JSON, store as string */ }
|
||||||
|
|
||||||
|
await updateSecretValue(secretKey, parsedValue);
|
||||||
|
res.redirect(`/admin/groups/${groupId}/secrets?message=Secret+value+updated+successfully.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error updating secret ${secretKey} in group ${groupId}:`, error);
|
||||||
|
res.redirect(`/admin/groups/${groupId}/secrets/${encodeURIComponent(secretKey)}/edit?message=Error+updating+secret.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: Handle adding a new secret to a specific group
|
||||||
|
app.post('/admin/groups/:groupId/secrets/add', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
try {
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
throw new Error('Invalid group ID.');
|
||||||
|
}
|
||||||
|
const { secretKey, secretValue } = req.body;
|
||||||
|
if (!secretKey || typeof secretKey !== 'string' || secretKey.trim() === "" || secretValue === undefined) {
|
||||||
|
throw new Error('Secret key (non-empty string) and value are required.');
|
||||||
|
}
|
||||||
|
// Attempt to parse JSON if applicable, similar to add-secret logic
|
||||||
|
let parsedValue = secretValue;
|
||||||
|
try {
|
||||||
|
const trimmedValue = typeof secretValue === 'string' ? secretValue.trim() : secretValue;
|
||||||
|
if (typeof trimmedValue === 'string' && ((trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) || (trimmedValue.startsWith('[') && trimmedValue.endsWith(']')))) {
|
||||||
|
parsedValue = JSON.parse(trimmedValue);
|
||||||
|
}
|
||||||
|
} catch (e) { /* Not valid JSON, store as string if it was a string */ }
|
||||||
|
|
||||||
|
|
||||||
|
await createSecretInGroup(groupId, secretKey.trim(), parsedValue);
|
||||||
|
res.redirect(`/admin/groups/${groupId}/secrets?message=Secret+added+to+group+successfully.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error adding secret to group ${groupId}:`, error);
|
||||||
|
let userMessage = "Error+adding+secret+to+group.+Please+check+server+logs.";
|
||||||
|
if (error.message && error.message.includes("already exists")) {
|
||||||
|
userMessage = "Error+adding+secret+to+group:+A+secret+with+that+key+already+exists.";
|
||||||
|
}
|
||||||
|
res.redirect(`/admin/groups/${groupId}/secrets?message=${userMessage}&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: View/Manage secrets within a specific group
|
||||||
|
app.get('/admin/groups/:groupId/secrets', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
return res.redirect('/admin?message=Invalid+group+ID+format.&messageType=error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = getSecretGroupById(groupId);
|
||||||
|
if (!group) {
|
||||||
|
return res.redirect('/admin?message=Group+not+found.&messageType=error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secretsInGroup = group.keys.map(key => {
|
||||||
|
const secretData = getSecretWithValue(key);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value: secretData?.value, // Value might be undefined if data is inconsistent
|
||||||
|
// groupId is known to be 'group.id' for these secrets
|
||||||
|
};
|
||||||
|
}).filter(s => s.value !== undefined); // Filter out any inconsistencies if secret value couldn't be fetched
|
||||||
|
|
||||||
|
const message = req.query.message ? { text: req.query.message as string, type: req.query.messageType as string || 'info' } : null;
|
||||||
|
|
||||||
|
// For now, rendering a new EJS view. Could also be a modified admin.ejs
|
||||||
|
res.render('group_secrets', {
|
||||||
|
group,
|
||||||
|
secretsInGroup,
|
||||||
|
message,
|
||||||
|
csrfToken: req.csrfToken(),
|
||||||
|
editingSecretKey: null, // For edit secret form later
|
||||||
|
secretToEdit: null // For edit secret form later
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`Error viewing secrets for group ${req.params.groupId}:`, error);
|
||||||
|
res.redirect(`/admin?message=Error+loading+secrets+for+group.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: Handle the form submission for deleting a group
|
||||||
|
app.post('/admin/groups/delete/:groupId', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
throw new Error('Invalid group ID.');
|
||||||
|
}
|
||||||
|
await deleteSecretGroup(groupId);
|
||||||
|
res.redirect('/admin?message=Group+and+its+secrets+deleted+successfully.&messageType=success');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error deleting secret group:", error);
|
||||||
|
res.redirect(`/admin?message=Error+deleting+group.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: Show form to edit/rename a group
|
||||||
|
app.get('/admin/groups/edit/:groupId', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
return res.redirect('/admin?message=Invalid+group+ID.&messageType=error');
|
||||||
|
}
|
||||||
|
const groupToEdit = getSecretGroupById(groupId);
|
||||||
|
if (!groupToEdit) {
|
||||||
|
return res.redirect('/admin?message=Group+not+found.&messageType=error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render the main admin page, but provide data to show the edit group form
|
||||||
|
const allSecretKeysList = DataManager.getAllSecretKeys();
|
||||||
|
const secretsWithValueAndGroup = allSecretKeysList.map(key => {
|
||||||
|
const secretData = DataManager.getSecretWithValue(key);
|
||||||
|
return { key, value: secretData?.value, groupId: secretData?.groupId };
|
||||||
|
});
|
||||||
|
const allGroups = getAllSecretGroups();
|
||||||
|
const message = req.query.message ? { text: req.query.message as string, type: req.query.messageType as string || 'info' } : null;
|
||||||
|
|
||||||
|
res.render('admin', {
|
||||||
|
secrets: secretsWithValueAndGroup,
|
||||||
|
secretGroups: allGroups,
|
||||||
|
editingGroup: groupToEdit, // Pass the group to be edited
|
||||||
|
message,
|
||||||
|
editingItemKey: null, // Not editing a secret key here
|
||||||
|
itemToEdit: null, // Not editing a secret key value here
|
||||||
|
csrfToken: req.csrfToken()
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error preparing to edit group:", error);
|
||||||
|
res.redirect(`/admin?message=Error+loading+group+for+edit.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI: Handle the form submission for renaming a group
|
||||||
|
app.post('/admin/groups/rename/:groupId', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
const { newGroupName } = req.body;
|
||||||
|
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
throw new Error('Invalid group ID.');
|
||||||
|
}
|
||||||
|
if (!newGroupName || typeof newGroupName !== 'string' || newGroupName.trim() === "") {
|
||||||
|
throw new Error('New group name must be a non-empty string.');
|
||||||
|
}
|
||||||
|
await renameSecretGroup(groupId, newGroupName.trim());
|
||||||
|
res.redirect('/admin?message=Group+renamed+successfully.&messageType=success');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error renaming secret group:", error);
|
||||||
|
const groupIdParam = req.params.groupId || '';
|
||||||
|
const redirectPath = groupIdParam ? `/admin/groups/edit/${groupIdParam}` : '/admin';
|
||||||
|
let userMessage = "Error+renaming+group.+Please+check+server+logs.";
|
||||||
|
if (error.message && error.message.includes("already exists")) {
|
||||||
|
userMessage = "A+group+with+that+name+already+exists.";
|
||||||
|
} else if (error.message && error.message.includes("not found")) {
|
||||||
|
userMessage = "Group+not+found+and+could+not+be+renamed.";
|
||||||
|
}
|
||||||
|
res.redirect(`${redirectPath}?message=${userMessage}&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Protected admin route
|
||||||
|
app.get('/admin', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
try {
|
||||||
|
const allSecretKeysList = DataManager.getAllSecretKeys(); // Get all keys
|
||||||
|
const secretsWithValueAndGroup = allSecretKeysList.map(key => {
|
||||||
|
const secretData = DataManager.getSecretWithValue(key); // Get { value, groupId }
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
value: secretData ? secretData.value : undefined, // Handle case where secret might be gone if data is inconsistent
|
||||||
|
groupId: secretData ? secretData.groupId : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const secretGroups = DataManager.getAllSecretGroups(); // Fetch all secret groups
|
||||||
|
|
||||||
|
const message = req.query.message ? { text: req.query.message.toString(), type: req.query.messageType?.toString() || 'info' } : null;
|
||||||
|
|
||||||
|
res.render('admin', {
|
||||||
|
secrets: secretsWithValueAndGroup, // Now includes groupId
|
||||||
|
secretGroups, // Pass groups to the template
|
||||||
|
password: '', // EJS links will be updated to not use this
|
||||||
|
message,
|
||||||
|
editingItemKey: null,
|
||||||
|
itemToEdit: null,
|
||||||
|
csrfToken: req.csrfToken() // Pass CSRF token to template
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error rendering admin page:", error);
|
||||||
|
res.status(500).send("Error loading admin page.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route to show edit form
|
||||||
|
app.get('/admin/edit-secret/:key', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
try {
|
||||||
|
const itemKey = decodeURIComponent(req.params.key);
|
||||||
|
const secretData = DataManager.getSecretWithValue(itemKey); // Get { value, groupId }
|
||||||
|
|
||||||
|
if (!secretData) {
|
||||||
|
return res.redirect(`/admin?message=Secret+"${itemKey}"+not+found&messageType=error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupName = 'N/A (Orphaned or Error)';
|
||||||
|
if (secretData.groupId) {
|
||||||
|
const group = DataManager.getSecretGroupById(secretData.groupId);
|
||||||
|
if (group) {
|
||||||
|
groupName = group.name;
|
||||||
|
} else {
|
||||||
|
console.warn(`Secret "${itemKey}" has groupId ${secretData.groupId}, but group was not found.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Secret "${itemKey}" does not have a groupId. This indicates data inconsistency.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemToEditDetails = {
|
||||||
|
value: secretData.value,
|
||||||
|
groupId: secretData.groupId,
|
||||||
|
groupName: groupName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Data for the main admin page (lists of secrets and groups)
|
||||||
|
const allSecretKeysList = DataManager.getAllSecretKeys();
|
||||||
|
const secretsWithValueAndGroup = allSecretKeysList.map(key => {
|
||||||
|
const sData = DataManager.getSecretWithValue(key);
|
||||||
|
return { key, value: sData?.value, groupId: sData?.groupId };
|
||||||
|
});
|
||||||
|
const allGroups = DataManager.getAllSecretGroups();
|
||||||
|
|
||||||
|
res.render('admin', {
|
||||||
|
secrets: secretsWithValueAndGroup,
|
||||||
|
secretGroups: allGroups,
|
||||||
|
password: '',
|
||||||
|
message: null,
|
||||||
|
editingItemKey: itemKey,
|
||||||
|
itemToEdit: itemToEditDetails, // Pass new structure
|
||||||
|
csrfToken: req.csrfToken()
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error rendering edit page:", error);
|
||||||
|
res.redirect(`/admin?message=Error+loading+edit+page.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Add Secret
|
||||||
|
app.post('/admin/add-secret', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
const { groupId, secretKey, secretValue } = req.body; // Added groupId
|
||||||
|
// currentPassword from query is removed. Bearer token handles auth.
|
||||||
|
|
||||||
|
const numGroupId = parseInt(groupId, 10);
|
||||||
|
if (isNaN(numGroupId)) {
|
||||||
|
return res.redirect(`/admin?message=Error+adding+secret:+Invalid+group+ID.&messageType=error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedValue = secretValue;
|
||||||
|
try {
|
||||||
|
const trimmedValue = secretValue.trim();
|
||||||
|
if ((trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) || (trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))) {
|
||||||
|
parsedValue = JSON.parse(trimmedValue);
|
||||||
|
}
|
||||||
|
} catch (e) { /* Not valid JSON, store as string */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validation for secretKey and secretValue now happens within createSecretInGroup or earlier.
|
||||||
|
// createSecretInGroup will also check for key uniqueness.
|
||||||
|
// The main check here was for required fields, which is good.
|
||||||
|
if (!secretKey || typeof secretValue === 'undefined' || !groupId) { // Added groupId check
|
||||||
|
throw new Error('Group ID, secret key, and value are required.');
|
||||||
|
}
|
||||||
|
// Deprecated: DataManager.setSecretItem(secretKey, parsedValue);
|
||||||
|
await createSecretInGroup(numGroupId, secretKey, parsedValue); // Use new function
|
||||||
|
res.redirect(`/admin?message=Secret+added+successfully.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error adding secret:", error);
|
||||||
|
let userMessage = "Error+adding+secret.+Please+check+server+logs.";
|
||||||
|
if (error.message && error.message.includes("already exists")) {
|
||||||
|
userMessage = "Error+adding+secret:+A+secret+with+that+key+already+exists.";
|
||||||
|
} else if (error.message && error.message.includes("Group not found")) {
|
||||||
|
userMessage = "Error+adding+secret:+The+specified+group+was+not+found.";
|
||||||
|
}
|
||||||
|
// Consider preserving form fields on error redirect if desired, by passing them in query
|
||||||
|
res.redirect(`/admin?message=${userMessage}&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Update Secret
|
||||||
|
app.post('/admin/update-secret', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
// Key renaming is disabled for this form. originalKey and secretKey from form should be the same.
|
||||||
|
const { originalKey, secretKey, secretValue } = req.body;
|
||||||
|
// currentPassword from query is removed. Bearer token handles auth.
|
||||||
|
|
||||||
|
if (originalKey !== secretKey) {
|
||||||
|
// This UI path for editing secrets does not support renaming the key itself.
|
||||||
|
// That would be a more complex operation (check new key conflicts, update group's key list).
|
||||||
|
// For now, if they differ, it's an error or ignored.
|
||||||
|
return res.redirect(`/admin/edit-secret/${encodeURIComponent(originalKey)}?message=Error+updating+secret:+Key+renaming+not+supported+via+this+form.&messageType=error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedValue = secretValue;
|
||||||
|
try {
|
||||||
|
const trimmedValue = secretValue.trim();
|
||||||
|
if ((trimmedValue.startsWith('{') && trimmedValue.endsWith('}')) || (trimmedValue.startsWith('[') && trimmedValue.endsWith(']'))) {
|
||||||
|
parsedValue = JSON.parse(trimmedValue);
|
||||||
|
}
|
||||||
|
} catch (e) { /* Store as string if not valid JSON */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// originalKey and secretKey are the same here due to the check above.
|
||||||
|
if (!originalKey || typeof secretValue === 'undefined') {
|
||||||
|
throw new Error('Secret key and value are required.');
|
||||||
|
}
|
||||||
|
// The old logic for key renaming (if originalKey !== secretKey) is removed.
|
||||||
|
// We only update the value.
|
||||||
|
await updateSecretValue(originalKey, parsedValue); // Use new function
|
||||||
|
res.redirect(`/admin?message=Secret+value+updated+successfully.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error updating secret value:", error);
|
||||||
|
let userMessage = "Error+updating+secret+value.+Please+check+server+logs.";
|
||||||
|
if (error.message && error.message.includes("not found")) {
|
||||||
|
userMessage = "Error+updating+secret:+Secret+not+found.";
|
||||||
|
}
|
||||||
|
res.redirect(`/admin/edit-secret/${encodeURIComponent(originalKey)}?message=${userMessage}&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Delete Secret
|
||||||
|
app.post('/admin/delete-secret/:key', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
const itemKey = decodeURIComponent(req.params.key);
|
||||||
|
// currentPassword from query is removed. Bearer token handles auth.
|
||||||
|
try {
|
||||||
|
await DataManager.deleteSecretItem(itemKey); // Corrected: deleteSecretItem
|
||||||
|
res.redirect(`/admin?message=Secret+deleted&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error deleting secret:", error);
|
||||||
|
res.redirect(`/admin?message=Error+deleting+secret.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- WebSocket Auto-Approval Setting Routes ---
|
||||||
|
app.get('/admin/settings/auto-approve-ws-status', adminAuth, (req, res) => {
|
||||||
|
// This route might still be useful for other API consumers, so it uses getConfig()
|
||||||
|
res.json({ autoApproveEnabled: getConfig().autoApproveWebSocketRegistrations });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/settings/toggle-auto-approve-ws', adminAuth, csrfProtection, (req, res) => { // Added csrfProtection
|
||||||
|
// If checkbox is checked, req.body.autoApproveWs will be 'on' (or its 'value' attribute if set).
|
||||||
|
// If unchecked, autoApproveWs will not be in req.body.
|
||||||
|
const newAutoApproveState = !!req.body.autoApproveWs;
|
||||||
|
updateAutoApproveSetting(newAutoApproveState); // Update and save config
|
||||||
|
console.log(`WebSocket auto-approval toggled to: ${newAutoApproveState}`);
|
||||||
|
// Instead of JSON, redirect back to the clients page
|
||||||
|
res.redirect('/admin/clients?message=WebSocket+auto-approval+setting+updated&messageType=success');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Client Management Routes ---
|
||||||
|
|
||||||
|
// Note: GET routes for clients already have csrfProtection for token generation
|
||||||
|
app.get('/admin/clients', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rawPendingClients = DataManager.getPendingClients(); // synchronous
|
||||||
|
const rawApprovedClients = DataManager.getApprovedClients(); // synchronous
|
||||||
|
const allGroups = DataManager.getAllSecretGroups(); // synchronous
|
||||||
|
|
||||||
|
const groupMap = new Map(allGroups.map(g => [g.id, g.name]));
|
||||||
|
|
||||||
|
const enhanceClientWithGroupNames = (client: DataManager.ClientInfo) => ({
|
||||||
|
...client,
|
||||||
|
associatedGroupNames: client.associatedGroupIds?.map(id => groupMap.get(id) || `ID ${id} (Unknown)`).join(', ') || 'None'
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingClients = rawPendingClients.map(enhanceClientWithGroupNames);
|
||||||
|
const approvedClients = rawApprovedClients.map(enhanceClientWithGroupNames);
|
||||||
|
|
||||||
|
const message = req.query.message ? { text: req.query.message.toString(), type: req.query.messageType?.toString() || 'info' } : null;
|
||||||
|
|
||||||
|
res.render('clients', {
|
||||||
|
pendingClients,
|
||||||
|
approvedClients,
|
||||||
|
password: '',
|
||||||
|
message,
|
||||||
|
managingClientGroups: null, // Changed from managingClientSecrets
|
||||||
|
autoApproveWsEnabled: getConfig().autoApproveWebSocketRegistrations,
|
||||||
|
csrfToken: req.csrfToken()
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error rendering clients page:", error);
|
||||||
|
res.status(500).send("Error loading client management page.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/clients/approve/:clientId', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
const { clientId } = req.params;
|
||||||
|
// currentPassword from query is removed.
|
||||||
|
try {
|
||||||
|
const client = await DataManager.approveClient(clientId);
|
||||||
|
// authToken is no longer generated or part of ClientInfo
|
||||||
|
notifyClientStatusUpdate(clientId, 'approved', `Client ${client.name} has been approved by an administrator.`);
|
||||||
|
res.redirect(`/admin/clients?message=Client+${client.name}+approved.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error approving client:", error); // Added console.error for server-side logging
|
||||||
|
res.redirect(`/admin/clients?message=Error+approving+client.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/clients/reject/:clientId', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
const { clientId } = req.params;
|
||||||
|
// currentPassword from query is removed.
|
||||||
|
try {
|
||||||
|
const client = await DataManager.rejectClient(clientId);
|
||||||
|
notifyClientStatusUpdate(clientId, 'rejected', `Client ${client.name} has been rejected by an administrator.`);
|
||||||
|
res.redirect(`/admin/clients?message=Client+${client.name}+rejected.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error rejecting client:", error); // Added console.error for server-side logging
|
||||||
|
res.redirect(`/admin/clients?message=Error+rejecting+client.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/clients/revoke/:clientId', adminAuth, csrfProtection, async (req, res) => { // Added csrfProtection
|
||||||
|
const { clientId } = req.params;
|
||||||
|
// currentPassword from query is removed.
|
||||||
|
try {
|
||||||
|
// Revoking means deleting the client in this implementation
|
||||||
|
await DataManager.deleteClient(clientId);
|
||||||
|
res.redirect(`/admin/clients?message=Client+${clientId}+revoked+(deleted).&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error revoking client:", error); // Added console.error for server-side logging
|
||||||
|
res.redirect(`/admin/clients?message=Error+revoking+client.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route to manage a client's associated groups
|
||||||
|
app.get('/admin/clients/:clientId/groups', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
const { clientId } = req.params;
|
||||||
|
try {
|
||||||
|
const client = DataManager.getClient(clientId); // getClient is synchronous
|
||||||
|
if (!client || client.status !== 'approved') {
|
||||||
|
return res.redirect(`/admin/clients?message=Client+not+found+or+not+approved.&messageType=error`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allGroups = DataManager.getAllSecretGroups(); // synchronous
|
||||||
|
const message = req.query.message ? { text: req.query.message.toString(), type: req.query.messageType?.toString() || 'info' } : null;
|
||||||
|
|
||||||
|
res.render('clients', {
|
||||||
|
pendingClients: [],
|
||||||
|
approvedClients: [],
|
||||||
|
password: '',
|
||||||
|
message,
|
||||||
|
managingClientGroups: { // Renamed from managingClientSecrets
|
||||||
|
client: client,
|
||||||
|
allGroups: allGroups, // Pass all available groups
|
||||||
|
// client.associatedGroupIds is already part of the client object
|
||||||
|
},
|
||||||
|
autoApproveWsEnabled: getConfig().autoApproveWebSocketRegistrations,
|
||||||
|
csrfToken: req.csrfToken()
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error loading group management for client:", error); // Added console.error
|
||||||
|
res.redirect(`/admin/clients?message=Error+loading+group+management+for+client.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route to update a client's associated groups
|
||||||
|
app.post('/admin/clients/:clientId/groups/update', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
const { clientId } = req.params;
|
||||||
|
let { associatedGroupIds } = req.body; // This will be an array or single string if only one selected
|
||||||
|
|
||||||
|
// Ensure associatedGroupIds is an array of numbers
|
||||||
|
if (!Array.isArray(associatedGroupIds)) {
|
||||||
|
associatedGroupIds = associatedGroupIds ? [associatedGroupIds] : [];
|
||||||
|
}
|
||||||
|
const groupIdsAsNumbers: number[] = associatedGroupIds.map((id: string | number) => parseInt(id.toString(), 10)).filter((id: number) => !isNaN(id));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = DataManager.getClient(clientId); // synchronous
|
||||||
|
if (!client || client.status !== 'approved') {
|
||||||
|
throw new Error("Client not found or not approved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await DataManager.setClientAssociatedGroups(clientId, groupIdsAsNumbers);
|
||||||
|
|
||||||
|
res.redirect(`/admin/clients/${clientId}/groups?message=Client+group+associations+updated.&messageType=success`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error updating group associations:", error); // Added console.error
|
||||||
|
res.redirect(`/admin/clients/${clientId}/groups?message=Error+updating+group+associations.+Please+check+server+logs.&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
app.get('/admin/logout', (req, res) => { // adminAuth not strictly needed if just clearing cookie
|
||||||
|
res.clearCookie(ADMIN_COOKIE_NAME, { path: '/admin' });
|
||||||
|
res.redirect('/admin/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Placeholder for other non-admin routes or a root welcome
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.send('<h1>Key/Info Manager</h1><p>This is the public-facing part of the server (if any).</p><p><a href="/admin/login">Admin Login</a></p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.listen(port, () => {
|
||||||
|
console.log(`HTTP server started on http://localhost:${port}`);
|
||||||
|
if (!serverAdminPasswordSingleton) {
|
||||||
|
console.warn("HTTP Server started without an admin password. Admin panel will be inaccessible.");
|
||||||
|
} else {
|
||||||
|
console.log("Admin panel access requires the server startup password.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSRF Error Handler
|
||||||
|
// This must be defined as an error-handling middleware (with 4 arguments)
|
||||||
|
// and should be placed after all other middleware and routes.
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
if (err.code === 'EBADCSRFTOKEN') {
|
||||||
|
console.warn(`CSRF token validation failed for request: ${req.method} ${req.path}`);
|
||||||
|
// Send a user-friendly error page or a simple 403 response
|
||||||
|
res.status(403).send('Invalid CSRF token. Please refresh the page and try again, or ensure cookies are enabled.');
|
||||||
|
} else {
|
||||||
|
// If it's not a CSRF error, pass it to the next error handler (if any)
|
||||||
|
// or let Express handle it as a generic server error.
|
||||||
|
console.error("Unhandled error:", err); // Log other errors for debugging
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// It's important that the CSRF error handler is added before any generic
|
||||||
|
// error handler that might catch all errors and send a 500 response without
|
||||||
|
// checking the error type. If no other generic error handler exists, this is fine.
|
||||||
|
|
||||||
|
// --- Phase 1: API Endpoints for Group and Secret Management (for testing) ---
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
// API endpoint (already created in Phase 1)
|
||||||
|
app.post('/admin/api/groups', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name) {
|
||||||
|
res.status(400).json({ error: 'Group name is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newGroup = await createSecretGroup(name); // Use direct import
|
||||||
|
res.status(201).json(newGroup);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(error.message.includes("already exists") ? 409 : 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UI Form Handler for Creating Groups
|
||||||
|
app.post('/admin/groups/create', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupName } = req.body;
|
||||||
|
if (!groupName || typeof groupName !== 'string' || groupName.trim() === "") {
|
||||||
|
throw new Error('Group name must be a non-empty string.');
|
||||||
|
}
|
||||||
|
await createSecretGroup(groupName.trim());
|
||||||
|
res.redirect('/admin?message=Secret+group+created+successfully.&messageType=success');
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error creating secret group:", error);
|
||||||
|
let userMessage = "Error+creating+secret+group.+Please+check+server+logs.";
|
||||||
|
if (error.message && error.message.includes("already exists")) {
|
||||||
|
userMessage = "Error+creating+secret+group:+A+group+with+that+name+already+exists.";
|
||||||
|
}
|
||||||
|
res.redirect(`/admin?message=${userMessage}&messageType=error`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/groups', adminAuth, async (req, res) => { // Should be synchronous based on DataManager
|
||||||
|
try {
|
||||||
|
const groups = getAllSecretGroups(); // Use direct import
|
||||||
|
res.json(groups);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/groups/:groupId', adminAuth, async (req, res) => { // Should be synchronous
|
||||||
|
try {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
res.status(400).json({ error: 'Invalid group ID format.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const group = getSecretGroupById(groupId); // Use direct import
|
||||||
|
if (group) {
|
||||||
|
res.json(group);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Group not found.' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin/api/groups/:groupId', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
const { name } = req.body;
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
res.status(400).json({ error: 'Invalid group ID format.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!name) {
|
||||||
|
res.status(400).json({ error: 'New group name is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await renameSecretGroup(groupId, name); // Use direct import
|
||||||
|
res.status(200).json({ message: 'Group renamed successfully.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(error.message.includes("not found") ? 404 : error.message.includes("already exists") ? 409 : 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin/api/groups/:groupId', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const groupId = parseInt(req.params.groupId, 10);
|
||||||
|
if (isNaN(groupId)) {
|
||||||
|
res.status(400).json({ error: 'Invalid group ID format.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteSecretGroup(groupId); // Use direct import
|
||||||
|
res.status(200).json({ message: 'Group and its secrets deleted successfully.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(error.message.includes("not found") ? 404 : 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Secrets (within groups)
|
||||||
|
app.post('/admin/api/secrets', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { groupId, key, value } = req.body;
|
||||||
|
if (typeof groupId !== 'number' || !key || value === undefined) {
|
||||||
|
res.status(400).json({ error: 'groupId (number), key (string), and value are required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createSecretInGroup(groupId, key, value); // Use direct import
|
||||||
|
res.status(201).json({ message: 'Secret created successfully in group.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(error.message.includes("not found") ? 404 : error.message.includes("already exists") ? 409 : 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/admin/api/secrets/:key', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key } = req.params;
|
||||||
|
const { value } = req.body;
|
||||||
|
if (value === undefined) {
|
||||||
|
res.status(400).json({ error: 'New value is required.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateSecretValue(key, value); // Use direct import
|
||||||
|
res.status(200).json({ message: 'Secret value updated successfully.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(error.message.includes("not found") ? 404 : 500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/admin/api/secrets/:key', adminAuth, csrfProtection, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key } = req.params;
|
||||||
|
await deleteSecret(key); // Use direct import
|
||||||
|
res.status(200).json({ message: 'Secret deleted successfully.' });
|
||||||
|
} catch (error: any) {
|
||||||
|
// deleteSecret in DataManager currently doesn't throw if key not found, just warns.
|
||||||
|
// If it were to throw, a 404 check would be good here.
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/admin/api/secrets/:key', adminAuth, async (req, res) => { // Should be synchronous
|
||||||
|
try {
|
||||||
|
const { key } = req.params;
|
||||||
|
const secret = getSecretWithValue(key); // Use direct import
|
||||||
|
if (secret) {
|
||||||
|
res.json(secret);
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'Secret not found.' });
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
return server; // Return the Node.js HTTP server instance
|
||||||
|
}
|
||||||
126
src/lib/configManager.ts
Normal file
126
src/lib/configManager.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
// Use process.cwd() for a path relative to the project root where the script is executed.
|
||||||
|
const CONFIG_DIR = path.join(process.cwd(), 'data');
|
||||||
|
const CONFIG_FILE_PATH = path.join(CONFIG_DIR, 'runtime-config.json');
|
||||||
|
|
||||||
|
export interface AppConfig {
|
||||||
|
jwtSecret: string;
|
||||||
|
autoApproveWebSocketRegistrations: boolean;
|
||||||
|
httpPort: number;
|
||||||
|
wsPort: number;
|
||||||
|
adminPasswordHash?: string; // Optional: if we ever move admin password here
|
||||||
|
wsAdminPasswordHash?: string; // Optional: if we ever move ws admin password here
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: AppConfig = {
|
||||||
|
jwtSecret: 'DEFAULT_FALLBACK_SECRET_DO_NOT_USE_IN_PROD',
|
||||||
|
autoApproveWebSocketRegistrations: false,
|
||||||
|
httpPort: 3000,
|
||||||
|
wsPort: 3001,
|
||||||
|
};
|
||||||
|
|
||||||
|
let currentConfig: AppConfig;
|
||||||
|
|
||||||
|
function ensureDirExists(dirPath: string) {
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
console.log(`Created directory: ${dirPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveConfig(configToSave: AppConfig): void {
|
||||||
|
ensureDirExists(CONFIG_DIR);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(CONFIG_FILE_PATH, JSON.stringify(configToSave, null, 2));
|
||||||
|
console.log(`Configuration saved to ${CONFIG_FILE_PATH}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error saving configuration to ${CONFIG_FILE_PATH}:`, error);
|
||||||
|
// Depending on severity, might want to throw or handle differently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadConfig(): AppConfig {
|
||||||
|
ensureDirExists(CONFIG_DIR);
|
||||||
|
let loadedConfig: Partial<AppConfig> = {};
|
||||||
|
let needsSave = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONFIG_FILE_PATH)) {
|
||||||
|
const fileContent = fs.readFileSync(CONFIG_FILE_PATH, 'utf-8');
|
||||||
|
loadedConfig = JSON.parse(fileContent) as Partial<AppConfig>;
|
||||||
|
} else {
|
||||||
|
console.log(`Configuration file not found at ${CONFIG_FILE_PATH}. Creating with default values.`);
|
||||||
|
loadedConfig = {}; // Start fresh to ensure all defaults are applied
|
||||||
|
needsSave = true; // Mark that we need to save after defaulting
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error reading or parsing configuration file ${CONFIG_FILE_PATH}. Using defaults. Error:`, error);
|
||||||
|
loadedConfig = {}; // Reset to ensure defaults are applied on error
|
||||||
|
needsSave = true; // Mark for save if parsing failed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with defaults (defaults apply if key is missing in loadedConfig)
|
||||||
|
const configWithDefaults: AppConfig = {
|
||||||
|
jwtSecret: loadedConfig.jwtSecret ?? DEFAULT_CONFIG.jwtSecret,
|
||||||
|
autoApproveWebSocketRegistrations: loadedConfig.autoApproveWebSocketRegistrations ?? DEFAULT_CONFIG.autoApproveWebSocketRegistrations,
|
||||||
|
httpPort: loadedConfig.httpPort ?? DEFAULT_CONFIG.httpPort,
|
||||||
|
wsPort: loadedConfig.wsPort ?? DEFAULT_CONFIG.wsPort,
|
||||||
|
adminPasswordHash: loadedConfig.adminPasswordHash, // Keep undefined if not present
|
||||||
|
wsAdminPasswordHash: loadedConfig.wsAdminPasswordHash, // Keep undefined if not present
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any default values were applied to an existing file or if the file was new
|
||||||
|
if (!needsSave) { // Only re-check if not already marked for save (e.g. new file or parse error)
|
||||||
|
if (
|
||||||
|
configWithDefaults.jwtSecret !== loadedConfig.jwtSecret ||
|
||||||
|
configWithDefaults.autoApproveWebSocketRegistrations !== loadedConfig.autoApproveWebSocketRegistrations ||
|
||||||
|
configWithDefaults.httpPort !== loadedConfig.httpPort ||
|
||||||
|
configWithDefaults.wsPort !== loadedConfig.wsPort
|
||||||
|
// We don't check optional fields like adminPasswordHash for needing a save if they were merely defaulted from undefined to undefined
|
||||||
|
) {
|
||||||
|
console.log('Configuration file was missing some keys. Applying defaults and saving.');
|
||||||
|
needsSave = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (configWithDefaults.jwtSecret === DEFAULT_CONFIG.jwtSecret) {
|
||||||
|
console.warn('WARNING: Using default JWT secret. This is NOT secure for production. Consider setting a unique JWT_SECRET in data/runtime-config.json.');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Debug] Final check before saving: needsSave = ${needsSave}`);
|
||||||
|
// console.log(`[Debug] Config to potentially save:`, JSON.stringify(configWithDefaults, null, 2)); // Can be verbose
|
||||||
|
|
||||||
|
if (needsSave) {
|
||||||
|
console.log(`[Debug] Attempting to save configuration because needsSave is true.`);
|
||||||
|
saveConfig(configWithDefaults);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentConfig = configWithDefaults;
|
||||||
|
console.log('Configuration loaded:', currentConfig);
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfig(): AppConfig {
|
||||||
|
if (!currentConfig) {
|
||||||
|
// This should ideally not be hit if loadConfig is called at startup.
|
||||||
|
console.warn("Config not loaded yet. Loading now. Ensure loadConfig() is called at application start.");
|
||||||
|
return loadConfig();
|
||||||
|
}
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAutoApproveSetting(newState: boolean): AppConfig {
|
||||||
|
if (!currentConfig) {
|
||||||
|
loadConfig(); // Ensure config is loaded
|
||||||
|
}
|
||||||
|
currentConfig.autoApproveWebSocketRegistrations = newState;
|
||||||
|
saveConfig(currentConfig);
|
||||||
|
return currentConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize config on module load.
|
||||||
|
// This ensures that `getConfig` can be called immediately after import.
|
||||||
|
loadConfig();
|
||||||
302
src/lib/dataManager.spec.ts
Normal file
302
src/lib/dataManager.spec.ts
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import * as DataManager from './dataManager';
|
||||||
|
import { encrypt, decrypt, deriveMasterKey, generateSalt } from './encryption';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
// Mock the dependencies
|
||||||
|
jest.mock('fs/promises');
|
||||||
|
jest.mock('./encryption');
|
||||||
|
|
||||||
|
// Helper to reset DataManager internal state if needed for some tests, though typically we test its public API.
|
||||||
|
// This is a bit hacky; ideally, DataManager would be a class or have an explicit reset function for testing.
|
||||||
|
// For now, we'll rely on Jest's module cache clearing or test individual functions carefully.
|
||||||
|
|
||||||
|
const MOCK_PASSWORD = 'testpassword';
|
||||||
|
const MOCK_MASTER_KEY = Buffer.from('mockMasterKeyDerived');
|
||||||
|
const MOCK_SALT = Buffer.from('mockSaltForMasterKey');
|
||||||
|
|
||||||
|
describe('DataManager', () => {
|
||||||
|
let mockEncryptedData: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Reset mocks for each test
|
||||||
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Setup default mock implementations
|
||||||
|
(fs.readFile as jest.Mock).mockResolvedValue(JSON.stringify(MOCK_SALT.toString('hex'))); // For salt loading
|
||||||
|
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
(fs.access as jest.Mock).mockResolvedValue(undefined); // Assume data dir exists
|
||||||
|
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|
||||||
|
(generateSalt as jest.Mock).mockReturnValue(MOCK_SALT);
|
||||||
|
(deriveMasterKey as jest.Mock).mockReturnValue(MOCK_MASTER_KEY);
|
||||||
|
(encrypt as jest.Mock).mockImplementation((text: string, _key: Buffer) => `encrypted:${text}`);
|
||||||
|
(decrypt as jest.Mock).mockImplementation((encText: string, _key: Buffer) => {
|
||||||
|
if (encText.startsWith('encrypted:')) {
|
||||||
|
return encText.substring('encrypted:'.length);
|
||||||
|
}
|
||||||
|
return null; // Simulate decryption failure for bad data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize dataStore to a clean state for relevant tests
|
||||||
|
// This is tricky because dataStore is a module-level variable.
|
||||||
|
// We re-initialize DataManager which resets its internal store before loading.
|
||||||
|
mockEncryptedData = `encrypted:${JSON.stringify({ secrets: {}, clients: {} })}`;
|
||||||
|
(fs.readFile as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(MOCK_SALT.toString('hex')) // For salt
|
||||||
|
.mockResolvedValueOnce(mockEncryptedData); // For data file
|
||||||
|
|
||||||
|
await DataManager.initializeDataManager(MOCK_PASSWORD);
|
||||||
|
// Clear fs.writeFile mock calls from initialization
|
||||||
|
(fs.writeFile as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Initialization', () => {
|
||||||
|
it('should initialize, create data directory and salt file if they do not exist', async () => {
|
||||||
|
(fs.access as jest.Mock).mockRejectedValueOnce(new Error('ENOENT_DIR')).mockRejectedValueOnce(new Error('ENOENT_SALT')); // Dir and salt don't exist
|
||||||
|
// Simulate fs.readFile failing for both salt and data files
|
||||||
|
const saltError: any = new Error('Salt file not found');
|
||||||
|
saltError.code = 'ENOENT';
|
||||||
|
const dataError: any = new Error('Data file not found');
|
||||||
|
dataError.code = 'ENOENT';
|
||||||
|
(fs.readFile as jest.Mock) // Override for this test
|
||||||
|
.mockRejectedValueOnce(saltError) // Salt file read fails
|
||||||
|
.mockRejectedValueOnce(dataError); // Data file read fails
|
||||||
|
|
||||||
|
await DataManager.initializeDataManager('newpassword');
|
||||||
|
|
||||||
|
expect(fs.mkdir).toHaveBeenCalledWith(expect.stringContaining('data'), { recursive: true });
|
||||||
|
expect(generateSalt).toHaveBeenCalled();
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledWith(expect.stringContaining('masterkey.salt'), expect.any(String), 'utf-8');
|
||||||
|
expect(deriveMasterKey).toHaveBeenCalledWith('newpassword', MOCK_SALT);
|
||||||
|
// It will try to load data, fail (ENOENT), and initialize an empty store.
|
||||||
|
// A save might be triggered if we decide to save empty store on ENOENT. Current impl does not.
|
||||||
|
// So, fs.writeFile for data should not be called if data file doesn't exist and store is just initialized empty.
|
||||||
|
// Let's verify no unexpected data write
|
||||||
|
const dataWriteCall = (fs.writeFile as jest.Mock).mock.calls.find(call => call[0].endsWith('.enc'));
|
||||||
|
expect(dataWriteCall).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load existing salt and data', async () => {
|
||||||
|
const initialSecrets = { testSecret: 'value1' };
|
||||||
|
const initialClients = { client1: { id: 'client1', name: 'Test Client', status: 'approved', associatedSecretKeys: [], dateCreated: '', dateUpdated: '' } };
|
||||||
|
const encryptedExistingData = `encrypted:${JSON.stringify({ secrets: initialSecrets, clients: initialClients })}`;
|
||||||
|
|
||||||
|
(fs.readFile as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(MOCK_SALT.toString('hex')) // For salt
|
||||||
|
.mockResolvedValueOnce(encryptedExistingData); // For data file
|
||||||
|
|
||||||
|
await DataManager.initializeDataManager(MOCK_PASSWORD);
|
||||||
|
|
||||||
|
expect(generateSalt).not.toHaveBeenCalled(); // Should use existing salt
|
||||||
|
expect(deriveMasterKey).toHaveBeenCalledWith(MOCK_PASSWORD, MOCK_SALT);
|
||||||
|
expect(decrypt).toHaveBeenCalledWith(encryptedExistingData, MOCK_MASTER_KEY);
|
||||||
|
expect(DataManager.getSecretItem('testSecret')).toBe('value1');
|
||||||
|
expect(DataManager.getClient('client1')?.name).toBe('Test Client');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle old data format (only secrets) during load and migrate', async () => {
|
||||||
|
const oldFormatData = { myOldSecret: "oldValue" };
|
||||||
|
const encryptedOldFormatData = `encrypted:${JSON.stringify(oldFormatData)}`;
|
||||||
|
|
||||||
|
(fs.readFile as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(MOCK_SALT.toString('hex'))
|
||||||
|
.mockResolvedValueOnce(encryptedOldFormatData);
|
||||||
|
|
||||||
|
await DataManager.initializeDataManager(MOCK_PASSWORD);
|
||||||
|
expect(DataManager.getSecretItem('myOldSecret')).toBe('oldValue');
|
||||||
|
expect(DataManager.getAllClients()).toEqual([]); // Clients should be empty
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Secret Management', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Ensure a clean state for secrets for each test in this block
|
||||||
|
const emptyStore = { secrets: {}, clients: {} };
|
||||||
|
(fs.readFile as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(MOCK_SALT.toString('hex')) // For salt
|
||||||
|
.mockResolvedValueOnce(`encrypted:${JSON.stringify(emptyStore)}`); // For data file
|
||||||
|
await DataManager.initializeDataManager(MOCK_PASSWORD);
|
||||||
|
(fs.writeFile as jest.Mock).mockClear(); // Clear init writes
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set and get a secret item', async () => {
|
||||||
|
await DataManager.setSecretItem('key1', 'value1');
|
||||||
|
expect(DataManager.getSecretItem('key1')).toBe('value1');
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1); // saveData called
|
||||||
|
expect(encrypt).toHaveBeenCalledWith(JSON.stringify({ secrets: { key1: 'value1' }, clients: {} }, null, 2), MOCK_MASTER_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a secret item and update client associations', async () => {
|
||||||
|
// Setup: client associated with the secret to be deleted
|
||||||
|
const client = await DataManager.addPendingClient('Test Client For Deletion');
|
||||||
|
await DataManager.approveClient(client.id);
|
||||||
|
await DataManager.setSecretItem('secretToDelete', 'data');
|
||||||
|
await DataManager.associateSecretWithClient(client.id, 'secretToDelete');
|
||||||
|
|
||||||
|
let fetchedClient = DataManager.getClient(client.id);
|
||||||
|
expect(fetchedClient?.associatedSecretKeys).toContain('secretToDelete');
|
||||||
|
|
||||||
|
(fs.writeFile as jest.Mock).mockClear(); // Clear previous writes
|
||||||
|
|
||||||
|
await DataManager.deleteSecretItem('secretToDelete');
|
||||||
|
expect(DataManager.getSecretItem('secretToDelete')).toBeUndefined();
|
||||||
|
|
||||||
|
fetchedClient = DataManager.getClient(client.id);
|
||||||
|
expect(fetchedClient?.associatedSecretKeys).not.toContain('secretToDelete');
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1); // saveData from deleteSecretItem
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get all secret keys', async () => {
|
||||||
|
await DataManager.setSecretItem('key1', 'val1');
|
||||||
|
await DataManager.setSecretItem('key2', 'val2');
|
||||||
|
expect(DataManager.getAllSecretKeys()).toEqual(expect.arrayContaining(['key1', 'key2']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Client Management', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Ensure a clean state for clients for each test in this block
|
||||||
|
const emptyStore = { secrets: {}, clients: {} };
|
||||||
|
(fs.readFile as jest.Mock)
|
||||||
|
.mockResolvedValueOnce(MOCK_SALT.toString('hex'))
|
||||||
|
.mockResolvedValueOnce(`encrypted:${JSON.stringify(emptyStore)}`);
|
||||||
|
await DataManager.initializeDataManager(MOCK_PASSWORD);
|
||||||
|
(fs.writeFile as jest.Mock).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should add a pending client', async () => {
|
||||||
|
const clientName = 'New App';
|
||||||
|
const requestedKeys = ['secretA'];
|
||||||
|
const client = await DataManager.addPendingClient(clientName, requestedKeys);
|
||||||
|
|
||||||
|
expect(client.name).toBe(clientName);
|
||||||
|
expect(client.status).toBe('pending');
|
||||||
|
expect(client.id).toMatch(/^client_/);
|
||||||
|
expect(client.temporaryId).toMatch(/^temp_/);
|
||||||
|
expect(client.requestedSecretKeys).toEqual(requestedKeys);
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1); // saveData
|
||||||
|
|
||||||
|
const fetchedClient = DataManager.getClient(client.id);
|
||||||
|
expect(fetchedClient).toEqual(client);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error if client name is empty for addPendingClient', async () => {
|
||||||
|
await expect(DataManager.addPendingClient('')).rejects.toThrow("Client name must be a non-empty string.");
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should approve a pending client', async () => {
|
||||||
|
const pendingClient = await DataManager.addPendingClient('AppToApprove');
|
||||||
|
(fs.writeFile as jest.Mock).mockClear(); // Clear write from addPendingClient
|
||||||
|
|
||||||
|
const approvedClient = await DataManager.approveClient(pendingClient.id);
|
||||||
|
expect(approvedClient.status).toBe('approved');
|
||||||
|
expect(approvedClient.authToken).toMatch(/^auth_/);
|
||||||
|
expect(approvedClient.temporaryId).toBeUndefined();
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1); // saveData
|
||||||
|
|
||||||
|
const fetchedClient = DataManager.getClient(approvedClient.id);
|
||||||
|
expect(fetchedClient?.status).toBe('approved');
|
||||||
|
expect(fetchedClient?.authToken).toBe(approvedClient.authToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when approving non-pending client', async () => {
|
||||||
|
const pendingClient = await DataManager.addPendingClient('AppToApproveTwice');
|
||||||
|
await DataManager.approveClient(pendingClient.id); // First approval
|
||||||
|
await expect(DataManager.approveClient(pendingClient.id)).rejects.toThrow(`Client "${pendingClient.id}" is not in 'pending' state.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error when approving non-existent client', async () => {
|
||||||
|
await expect(DataManager.approveClient('nonExistentId')).rejects.toThrow('Client with ID "nonExistentId" not found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject a pending client', async () => {
|
||||||
|
const pendingClient = await DataManager.addPendingClient('AppToReject');
|
||||||
|
(fs.writeFile as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
const rejectedClient = await DataManager.rejectClient(pendingClient.id);
|
||||||
|
expect(rejectedClient.status).toBe('rejected');
|
||||||
|
expect(rejectedClient.authToken).toBeUndefined();
|
||||||
|
expect(rejectedClient.temporaryId).toBeUndefined(); // Should also clear temporaryId
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const fetchedClient = DataManager.getClient(rejectedClient.id);
|
||||||
|
expect(fetchedClient?.status).toBe('rejected');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get various lists of clients', async () => {
|
||||||
|
const p1 = await DataManager.addPendingClient('Pending1');
|
||||||
|
const p2 = await DataManager.addPendingClient('Pending2');
|
||||||
|
const a1 = await DataManager.approveClient(p1.id); // p1 becomes a1 (approved)
|
||||||
|
|
||||||
|
expect(DataManager.getPendingClients().map(c => c.id)).toEqual([p2.id]);
|
||||||
|
expect(DataManager.getApprovedClients().map(c => c.id)).toEqual([a1.id]);
|
||||||
|
expect(DataManager.getAllClients().length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should associate and dissociate secrets with an approved client', async () => {
|
||||||
|
await DataManager.setSecretItem('s1', 'v1');
|
||||||
|
await DataManager.setSecretItem('s2', 'v2');
|
||||||
|
const client = await DataManager.addPendingClient('ClientForSecrets');
|
||||||
|
await DataManager.approveClient(client.id);
|
||||||
|
(fs.writeFile as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
// Associate
|
||||||
|
await DataManager.associateSecretWithClient(client.id, 's1');
|
||||||
|
let updatedClient = DataManager.getClient(client.id);
|
||||||
|
expect(updatedClient?.associatedSecretKeys).toContain('s1');
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
(fs.writeFile as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
// Dissociate
|
||||||
|
await DataManager.dissociateSecretFromClient(client.id, 's1');
|
||||||
|
updatedClient = DataManager.getClient(client.id);
|
||||||
|
expect(updatedClient?.associatedSecretKeys).not.toContain('s1');
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error associating secret with non-approved client', async () => {
|
||||||
|
const pendingClient = await DataManager.addPendingClient('NonApprovedClient');
|
||||||
|
await DataManager.setSecretItem('s3', 'v3');
|
||||||
|
await expect(DataManager.associateSecretWithClient(pendingClient.id, 's3'))
|
||||||
|
.rejects.toThrow(`Client "${pendingClient.id}" is not approved.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error associating non-existent secret', async () => {
|
||||||
|
const client = await DataManager.addPendingClient('ClientForSecrets2');
|
||||||
|
await DataManager.approveClient(client.id);
|
||||||
|
await expect(DataManager.associateSecretWithClient(client.id, 'nonExistentSecret'))
|
||||||
|
.rejects.toThrow('Secret with key "nonExistentSecret" not found.');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
it('should get a client by auth token', async () => {
|
||||||
|
const client = await DataManager.addPendingClient('ClientWithToken');
|
||||||
|
const approved = await DataManager.approveClient(client.id);
|
||||||
|
|
||||||
|
const foundClient = DataManager.getClientByAuthToken(approved.authToken!);
|
||||||
|
expect(foundClient?.id).toBe(client.id);
|
||||||
|
expect(foundClient?.name).toBe(client.name);
|
||||||
|
|
||||||
|
expect(DataManager.getClientByAuthToken('invalidToken')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a client', async () => {
|
||||||
|
const client = await DataManager.addPendingClient('ClientToDelete');
|
||||||
|
(fs.writeFile as jest.Mock).mockClear();
|
||||||
|
|
||||||
|
await DataManager.deleteClient(client.id);
|
||||||
|
expect(DataManager.getClient(client.id)).toBeUndefined();
|
||||||
|
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper to simulate dataStore reset for testing purposes if DataManager was a class with instances
|
||||||
|
// Or if it had an explicit reset function. For module-level state, this is more complex.
|
||||||
|
// This mock test suite relies on Jest's behavior with module mocks and careful sequencing.
|
||||||
|
// If DataManager.ts was refactored to be instantiable, testing state would be cleaner.
|
||||||
|
// e.g., let dataManagerInstance; beforeEach(() => { dataManagerInstance = new DataManager(); ... });
|
||||||
|
// For now, initializeDataManager is our main point of "resetting" the loaded data.
|
||||||
786
src/lib/dataManager.ts
Normal file
786
src/lib/dataManager.ts
Normal file
@ -0,0 +1,786 @@
|
|||||||
|
// Data loading, decryption, encryption, and saving logic
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import crypto from 'crypto'; // For generating client IDs and tokens
|
||||||
|
import { encrypt, decrypt, deriveMasterKey, generateSalt } from './encryption';
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, '../../data');
|
||||||
|
const DATA_FILE_NAME = 'secrets.json.enc';
|
||||||
|
const DATA_FILE_PATH = path.join(DATA_DIR, DATA_FILE_NAME);
|
||||||
|
const SALT_FILE_NAME = 'masterkey.salt';
|
||||||
|
const SALT_FILE_PATH = path.join(DATA_DIR, SALT_FILE_NAME);
|
||||||
|
|
||||||
|
export type ClientStatus = 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
export interface ClientInfo {
|
||||||
|
id: string; // Unique client identifier (e.g., a UUID)
|
||||||
|
name: string; // User-friendly name provided by the client or admin
|
||||||
|
status: ClientStatus;
|
||||||
|
associatedGroupIds: number[]; // IDs of secret groups this client can access
|
||||||
|
requestedSecretKeys?: string[]; // Optional: Keys initially requested by the client (legacy, consider removing or adapting for group requests)
|
||||||
|
registrationTimestamp?: number; // Timestamp (Date.now()) when client entered pending state, for expiry
|
||||||
|
dateCreated: string; // ISO 8601 date string
|
||||||
|
dateUpdated: string; // ISO 8601 date string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SecureDataStore {
|
||||||
|
secrets: { [key: string]: { value: any, groupId: number } };
|
||||||
|
clients: Record<string, ClientInfo>; // Keyed by ClientInfo.id
|
||||||
|
secretGroups: { [groupId: number]: { name: string, keys: string[] } };
|
||||||
|
nextGroupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory store for the decrypted data
|
||||||
|
let dataStore: SecureDataStore = {
|
||||||
|
secrets: {},
|
||||||
|
clients: {},
|
||||||
|
secretGroups: {},
|
||||||
|
nextGroupId: 1,
|
||||||
|
};
|
||||||
|
let masterEncryptionKey: Buffer | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the DataManager with the server's master password.
|
||||||
|
* Derives the master encryption key and loads data.
|
||||||
|
*/
|
||||||
|
export async function initializeDataManager(password: string): Promise<void> {
|
||||||
|
let salt: Buffer;
|
||||||
|
try {
|
||||||
|
await fs.access(DATA_DIR);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(DATA_DIR, { recursive: true });
|
||||||
|
console.log(`Data directory created at: ${DATA_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saltHex = await fs.readFile(SALT_FILE_PATH, 'utf-8');
|
||||||
|
salt = Buffer.from(saltHex, 'hex');
|
||||||
|
console.log('Master key salt loaded from file.');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Master key salt file not found. Generating a new salt...');
|
||||||
|
salt = generateSalt();
|
||||||
|
await fs.writeFile(SALT_FILE_PATH, salt.toString('hex'), 'utf-8');
|
||||||
|
console.log(`New master key salt generated and saved to: ${SALT_FILE_PATH}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
masterEncryptionKey = deriveMasterKey(password, salt);
|
||||||
|
console.log('Master encryption key derived.');
|
||||||
|
|
||||||
|
await loadData();
|
||||||
|
|
||||||
|
// Start periodic check for expiring pending clients
|
||||||
|
// The interval can be configured as needed. e.g., every 30 seconds.
|
||||||
|
const expiryCheckInterval = 30 * 1000; // 30 seconds
|
||||||
|
setInterval(checkAndExpirePendingClients, expiryCheckInterval);
|
||||||
|
console.log(`Started periodic check for pending client expiry every ${expiryCheckInterval / 1000} seconds.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_CLIENT_EXPIRY_DURATION_MS = 60 * 1000; // 1 minute
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks for pending clients that have exceeded their registration expiry time
|
||||||
|
* and updates their status to 'rejected'.
|
||||||
|
*/
|
||||||
|
export async function checkAndExpirePendingClients(): Promise<void> {
|
||||||
|
let updated = false;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const clientId in dataStore.clients) {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (client.status === 'pending' && client.registrationTimestamp) {
|
||||||
|
if (now - client.registrationTimestamp > PENDING_CLIENT_EXPIRY_DURATION_MS) {
|
||||||
|
console.log(`Pending client "${client.name}" (ID: ${client.id}) has expired. Setting status to rejected.`);
|
||||||
|
client.status = 'rejected';
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
// client.registrationTimestamp = undefined; // Optionally clear it, or keep for audit
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
try {
|
||||||
|
await saveData();
|
||||||
|
console.log('Saved data after expiring pending clients.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save data after expiring clients:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads data from the encrypted file and decrypts it.
|
||||||
|
* If the file doesn't exist, initializes with an empty store.
|
||||||
|
*/
|
||||||
|
async function loadData(): Promise<void> {
|
||||||
|
if (!masterEncryptionKey) {
|
||||||
|
throw new Error('DataManager not initialized. Master key is missing.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const encryptedData = await fs.readFile(DATA_FILE_PATH, 'utf-8');
|
||||||
|
if (encryptedData.trim() === '') {
|
||||||
|
console.log('Data file is empty. Initializing with an empty store.');
|
||||||
|
// Initialize with the full new structure
|
||||||
|
dataStore = { secrets: {}, clients: {}, secretGroups: {}, nextGroupId: 1 };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decryptedJson = decrypt(encryptedData, masterEncryptionKey);
|
||||||
|
if (decryptedJson) {
|
||||||
|
const loadedStore = JSON.parse(decryptedJson) as Partial<SecureDataStore>;
|
||||||
|
|
||||||
|
// Initialize with defaults and then override with loaded data
|
||||||
|
dataStore = {
|
||||||
|
secrets: loadedStore.secrets || {},
|
||||||
|
clients: loadedStore.clients || {}, // Will be further processed below
|
||||||
|
secretGroups: loadedStore.secretGroups || {},
|
||||||
|
nextGroupId: loadedStore.nextGroupId || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure all loaded clients have associatedGroupIds initialized
|
||||||
|
for (const clientId in dataStore.clients) {
|
||||||
|
if (dataStore.clients.hasOwnProperty(clientId)) {
|
||||||
|
const client = dataStore.clients[clientId] as any; // Use 'as any' for transitional period
|
||||||
|
if (client.associatedSecretKeys && !client.associatedGroupIds) {
|
||||||
|
console.log(`Client ${clientId} has legacy 'associatedSecretKeys'. Initializing 'associatedGroupIds' to empty. Manual group association needed.`);
|
||||||
|
client.associatedGroupIds = [];
|
||||||
|
// Delete the old key to prevent confusion, or leave for manual inspection
|
||||||
|
// delete client.associatedSecretKeys;
|
||||||
|
} else if (!client.associatedGroupIds) {
|
||||||
|
client.associatedGroupIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic migration/check for old secrets structure
|
||||||
|
// If secrets are not in the new { value, groupId } format, they will be problematic.
|
||||||
|
// For Phase 1, we'll log if an old format secret is detected.
|
||||||
|
// A more robust migration would be needed for existing data.
|
||||||
|
let oldFormatSecretsDetected = false;
|
||||||
|
for (const key in dataStore.secrets) {
|
||||||
|
if (typeof dataStore.secrets[key] !== 'object' ||
|
||||||
|
dataStore.secrets[key] === null ||
|
||||||
|
!dataStore.secrets[key].hasOwnProperty('value') ||
|
||||||
|
!dataStore.secrets[key].hasOwnProperty('groupId')) {
|
||||||
|
console.warn(`Secret "${key}" has an outdated format and will be ignored or may cause errors. Please re-create it in a group.`);
|
||||||
|
// Optionally delete it: delete dataStore.secrets[key];
|
||||||
|
oldFormatSecretsDetected = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (oldFormatSecretsDetected) {
|
||||||
|
console.warn("Old format secrets detected. These should be migrated or re-created within groups.");
|
||||||
|
// Consider if a saveData() call is needed here if old secrets were deleted.
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Data loaded and decrypted successfully.');
|
||||||
|
} else {
|
||||||
|
// This case could mean the file is corrupt or the password was wrong.
|
||||||
|
console.error('Failed to decrypt data. The file might be corrupted or the password was incorrect.');
|
||||||
|
throw new Error('Failed to decrypt data file. Check password or file integrity.');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
console.log(`Data file not found at ${DATA_FILE_PATH}. Initializing with an empty store.`);
|
||||||
|
// Initialize with the full new structure
|
||||||
|
dataStore = { secrets: {}, clients: {}, secretGroups: {}, nextGroupId: 1 };
|
||||||
|
// Optionally save the empty store immediately to create the file: await saveData();
|
||||||
|
} else {
|
||||||
|
console.error('Error loading data:', error);
|
||||||
|
throw error; // Re-throw other errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the current in-memory data store to the encrypted file.
|
||||||
|
*/
|
||||||
|
export async function saveData(): Promise<void> {
|
||||||
|
if (!masterEncryptionKey) {
|
||||||
|
throw new Error('DataManager not initialized. Master key is missing.');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jsonData = JSON.stringify(dataStore, null, 2); // Pretty print JSON
|
||||||
|
const encryptedData = encrypt(jsonData, masterEncryptionKey);
|
||||||
|
await fs.writeFile(DATA_FILE_PATH, encryptedData, 'utf-8');
|
||||||
|
console.log(`Data saved and encrypted successfully to: ${DATA_FILE_PATH}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving data:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a secret's value and its group ID.
|
||||||
|
*/
|
||||||
|
export function getSecretWithValue(key: string): { value: any, groupId: number } | undefined {
|
||||||
|
return dataStore.secrets[key] ? { ...dataStore.secrets[key] } : undefined; // Return a copy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves only the secret value from the data store by key.
|
||||||
|
* Note: For new development, prefer getSecretWithValue if groupId is also needed.
|
||||||
|
*/
|
||||||
|
export function getSecretItem<T = any>(key: string): T | undefined {
|
||||||
|
const secret = dataStore.secrets[key];
|
||||||
|
return secret ? secret.value as T : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new secret within a specified group.
|
||||||
|
* Throws an error if the group doesn't exist or the secret key already exists.
|
||||||
|
*/
|
||||||
|
export async function createSecretInGroup(groupId: number, key: string, value: any): Promise<void> {
|
||||||
|
if (!dataStore.secretGroups[groupId]) {
|
||||||
|
throw new Error(`Group with ID "${groupId}" not found.`);
|
||||||
|
}
|
||||||
|
if (dataStore.secrets.hasOwnProperty(key)) {
|
||||||
|
throw new Error(`Secret with key "${key}" already exists.`);
|
||||||
|
}
|
||||||
|
if (!key || typeof key !== 'string' || key.trim() === "") {
|
||||||
|
throw new Error("Secret key must be a non-empty string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStore.secrets[key] = { value, groupId };
|
||||||
|
if (!dataStore.secretGroups[groupId].keys.includes(key)) { // Should not be there, but good check
|
||||||
|
dataStore.secretGroups[groupId].keys.push(key);
|
||||||
|
}
|
||||||
|
await saveData();
|
||||||
|
console.log(`Secret "${key}" created in group ID ${groupId}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the value of an existing secret. The group association does not change.
|
||||||
|
*/
|
||||||
|
export async function updateSecretValue(key: string, newValue: any): Promise<void> {
|
||||||
|
if (!dataStore.secrets.hasOwnProperty(key)) {
|
||||||
|
throw new Error(`Secret with key "${key}" not found.`);
|
||||||
|
}
|
||||||
|
dataStore.secrets[key].value = newValue;
|
||||||
|
await saveData();
|
||||||
|
console.log(`Secret "${key}" value updated.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a secret.
|
||||||
|
* It's removed from its group and from the main secrets store.
|
||||||
|
*/
|
||||||
|
export async function deleteSecret(key: string): Promise<void> {
|
||||||
|
if (!dataStore.secrets.hasOwnProperty(key)) {
|
||||||
|
console.warn(`Secret with key "${key}" not found for deletion.`);
|
||||||
|
return; // Or throw error if preferred
|
||||||
|
}
|
||||||
|
|
||||||
|
const { groupId } = dataStore.secrets[key];
|
||||||
|
delete dataStore.secrets[key];
|
||||||
|
|
||||||
|
if (dataStore.secretGroups[groupId]) {
|
||||||
|
const keyIndex = dataStore.secretGroups[groupId].keys.indexOf(key);
|
||||||
|
if (keyIndex > -1) {
|
||||||
|
dataStore.secretGroups[groupId].keys.splice(keyIndex, 1);
|
||||||
|
} else {
|
||||||
|
console.warn(`Secret key "${key}" was not found in its associated group ID ${groupId}'s key list during deletion.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Group ID ${groupId} associated with secret "${key}" was not found during secret deletion.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client associations are group-based, so deleting a secret does not directly affect client records here.
|
||||||
|
// The secret is simply removed from its group's key list (already done) and from the global secrets list.
|
||||||
|
// Any client associated with that group will no longer see this secret via getSecretsForClient.
|
||||||
|
// The old logic for removing from client.associatedSecretKeys is confirmed removed.
|
||||||
|
|
||||||
|
await saveData();
|
||||||
|
console.log(`Secret "${key}" deleted.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (DEPRECATED - use createSecretInGroup or updateSecretValue)
|
||||||
|
* Sets a secret value in the data store by key.
|
||||||
|
* Automatically triggers a save after setting the item.
|
||||||
|
*/
|
||||||
|
export async function setSecretItem<T = any>(key: string, value: T): Promise<void> {
|
||||||
|
// This function is problematic with the new structure as it doesn't know the group.
|
||||||
|
// For now, it will log a warning. Ideally, all callers should be updated.
|
||||||
|
console.warn(`DEPRECATED: setSecretItem called for key "${key}". This function does not handle group associations. Use createSecretInGroup or updateSecretValue.`);
|
||||||
|
// To avoid breaking existing functionality entirely before full migration,
|
||||||
|
// we could try to find its group or assign to a default/placeholder if that existed.
|
||||||
|
// But the requirement is "secret must belong to exactly one group".
|
||||||
|
// If the secret already exists, we can update its value. If not, we can't create it without a group.
|
||||||
|
if (dataStore.secrets.hasOwnProperty(key)) {
|
||||||
|
dataStore.secrets[key].value = value;
|
||||||
|
} else {
|
||||||
|
// Cannot create a new secret without a groupId.
|
||||||
|
// Option 1: Throw error. Option 2: Log and do nothing for new keys.
|
||||||
|
throw new Error(`setSecretItem cannot create new secret "${key}" without a groupId. Use createSecretInGroup.`);
|
||||||
|
}
|
||||||
|
await saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (DEPRECATED - use deleteSecret)
|
||||||
|
* Deletes a secret item from the data store by key.
|
||||||
|
* Also removes this secret key from any client's associatedSecretKeys list.
|
||||||
|
* Automatically triggers a save after deleting the item.
|
||||||
|
*/
|
||||||
|
export async function deleteSecretItem(key: string): Promise<void> {
|
||||||
|
console.warn(`DEPRECATED: deleteSecretItem called for key "${key}". Use deleteSecret instead.`);
|
||||||
|
await deleteSecret(key); // Delegate to the new function
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all secret keys from the data store.
|
||||||
|
*/
|
||||||
|
export function getAllSecretKeys(): string[] {
|
||||||
|
return Object.keys(dataStore.secrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the entire data store (secrets and clients).
|
||||||
|
* Use with caution, especially if the data is large. Consider specific getters instead.
|
||||||
|
*/
|
||||||
|
export function getEntireStore(): SecureDataStore {
|
||||||
|
return JSON.parse(JSON.stringify(dataStore)); // Return a deep copy
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Secret Group Management Functions ---
|
||||||
|
|
||||||
|
function _getNextGroupId(): number {
|
||||||
|
// This function assumes dataStore is already initialized.
|
||||||
|
// It modifies dataStore directly. The caller that uses this should ensure saveData is called.
|
||||||
|
if (dataStore.nextGroupId === undefined) {
|
||||||
|
dataStore.nextGroupId = 1; // Should have been initialized by loadData or initial declaration
|
||||||
|
}
|
||||||
|
const id = dataStore.nextGroupId;
|
||||||
|
dataStore.nextGroupId += 1;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGroupByName(name: string): { id: number, name: string, keys: string[] } | undefined {
|
||||||
|
for (const idStr in dataStore.secretGroups) {
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (dataStore.secretGroups[id].name === name) {
|
||||||
|
return { id, ...dataStore.secretGroups[id] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSecretGroup(name: string): Promise<{ id: number, name: string }> {
|
||||||
|
if (!name || typeof name !== 'string' || name.trim() === "") {
|
||||||
|
throw new Error("Group name must be a non-empty string.");
|
||||||
|
}
|
||||||
|
if (getGroupByName(name)) {
|
||||||
|
throw new Error(`A secret group with the name "${name}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGroupId = _getNextGroupId(); // Increments nextGroupId but doesn't save yet
|
||||||
|
dataStore.secretGroups[newGroupId] = { name: name.trim(), keys: [] };
|
||||||
|
await saveData(); // Now save explicitly
|
||||||
|
console.log(`Secret group "${name}" created with ID ${newGroupId}.`);
|
||||||
|
return { id: newGroupId, name: dataStore.secretGroups[newGroupId].name };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSecretGroupById(groupId: number): { id: number, name: string, keys: string[] } | undefined {
|
||||||
|
if (dataStore.secretGroups[groupId]) {
|
||||||
|
return { id: groupId, ...dataStore.secretGroups[groupId] };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllSecretGroups(): { id: number, name: string, keys: string[] }[] {
|
||||||
|
return Object.entries(dataStore.secretGroups).map(([idStr, groupData]) => {
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: groupData.name,
|
||||||
|
keys: [...groupData.keys] // Return a copy of the keys array
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameSecretGroup(groupId: number, newName: string): Promise<void> {
|
||||||
|
if (!newName || typeof newName !== 'string' || newName.trim() === "") {
|
||||||
|
throw new Error("New group name must be a non-empty string.");
|
||||||
|
}
|
||||||
|
const group = dataStore.secretGroups[groupId];
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Secret group with ID "${groupId}" not found.`);
|
||||||
|
}
|
||||||
|
const existingGroupWithNewName = getGroupByName(newName.trim());
|
||||||
|
if (existingGroupWithNewName && existingGroupWithNewName.id !== groupId) {
|
||||||
|
throw new Error(`Another secret group with the name "${newName.trim()}" already exists.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
group.name = newName.trim();
|
||||||
|
await saveData();
|
||||||
|
console.log(`Secret group ID ${groupId} renamed to "${group.name}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSecretGroup(groupId: number): Promise<void> {
|
||||||
|
const group = dataStore.secretGroups[groupId];
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Secret group with ID "${groupId}" not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keysToDelete = [...group.keys]; // Create a copy as we'll be modifying the secrets store
|
||||||
|
|
||||||
|
console.log(`Deleting group "${group.name}" (ID: ${groupId}) and its ${keysToDelete.length} secret(s)...`);
|
||||||
|
|
||||||
|
for (const key of keysToDelete) {
|
||||||
|
if (dataStore.secrets.hasOwnProperty(key)) {
|
||||||
|
// Ensure the secret actually belongs to this group before deleting, as a sanity check
|
||||||
|
if (dataStore.secrets[key].groupId === groupId) {
|
||||||
|
delete dataStore.secrets[key];
|
||||||
|
console.log(` - Deleted secret "${key}" from group ${groupId}.`);
|
||||||
|
} else {
|
||||||
|
// This case should ideally not happen if data integrity is maintained
|
||||||
|
console.warn(` - Secret "${key}" was listed in group ${groupId} but its record indicates it belongs to group ${dataStore.secrets[key].groupId}. Not deleting from secrets map based on this group's list.`);
|
||||||
|
// However, we should remove it from the current group's key list if it's there due to some inconsistency
|
||||||
|
const keyIndexInGroup = group.keys.indexOf(key);
|
||||||
|
if (keyIndexInGroup > -1) {
|
||||||
|
group.keys.splice(keyIndexInGroup, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(` - Secret key "${key}" listed in group ${groupId} not found in main secrets store.`);
|
||||||
|
// Remove from group's key list if present, to clean up inconsistency
|
||||||
|
const keyIndexInGroup = group.keys.indexOf(key);
|
||||||
|
if (keyIndexInGroup > -1) {
|
||||||
|
group.keys.splice(keyIndexInGroup, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete dataStore.secretGroups[groupId];
|
||||||
|
console.log(`Group ID ${groupId} ("${group.name}") itself deleted.`);
|
||||||
|
|
||||||
|
// Update clients that were associated with this deleted group
|
||||||
|
let clientsUpdated = false;
|
||||||
|
for (const clientId in dataStore.clients) {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (client.associatedGroupIds && client.associatedGroupIds.includes(groupId)) {
|
||||||
|
client.associatedGroupIds = client.associatedGroupIds.filter(id => id !== groupId);
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
clientsUpdated = true;
|
||||||
|
console.log(`Removed deleted group ID ${groupId} from client ${clientId}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveData(); // This will save group deletion and any client updates.
|
||||||
|
if (clientsUpdated) {
|
||||||
|
console.log("Client associations updated due to group deletion.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Client Management Functions ---
|
||||||
|
|
||||||
|
function generateRandomToken(length: number = 32): string {
|
||||||
|
return crypto.randomBytes(length).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a new client in a 'pending' state.
|
||||||
|
* @param clientName User-friendly name for the client.
|
||||||
|
* @param requestedSecretKeys Optional array of secret keys the client is requesting access to.
|
||||||
|
* @returns The newly created ClientInfo object.
|
||||||
|
*/
|
||||||
|
export async function addPendingClient(
|
||||||
|
clientName: string,
|
||||||
|
requestedSecretKeys?: string[]
|
||||||
|
): Promise<ClientInfo> {
|
||||||
|
if (!clientName || typeof clientName !== 'string' || clientName.trim() === "") {
|
||||||
|
throw new Error("Client name must be a non-empty string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = `client_${generateRandomToken(8)}`; // Shorter, more manageable ID
|
||||||
|
// temporaryId removed
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const newClient: ClientInfo = {
|
||||||
|
id: clientId,
|
||||||
|
name: clientName.trim(),
|
||||||
|
status: 'pending',
|
||||||
|
associatedGroupIds: [], // Initialize with empty group IDs
|
||||||
|
// associatedSecretKeys: [], // Removed
|
||||||
|
requestedSecretKeys: requestedSecretKeys || [], // Keep for now, may adapt later
|
||||||
|
registrationTimestamp: Date.now(),
|
||||||
|
dateCreated: now,
|
||||||
|
dateUpdated: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dataStore.clients[clientId]) {
|
||||||
|
// Extremely unlikely with random generation, but good practice
|
||||||
|
throw new Error("Client ID collision. Please try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
dataStore.clients[clientId] = newClient;
|
||||||
|
await saveData();
|
||||||
|
return JSON.parse(JSON.stringify(newClient)); // Return a copy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approves a pending client.
|
||||||
|
* Generates a permanent authToken for the client.
|
||||||
|
* @param clientId The ID of the client to approve.
|
||||||
|
* @returns The updated ClientInfo object with the new authToken.
|
||||||
|
*/
|
||||||
|
export async function approveClient(clientId: string): Promise<ClientInfo> {
|
||||||
|
// Removed duplicated validation and client declaration block
|
||||||
|
if (!clientId || typeof clientId !== 'string' || clientId.trim() === "") {
|
||||||
|
throw new Error("Client ID must be a non-empty string.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client with ID "${clientId}" not found.`);
|
||||||
|
}
|
||||||
|
if (client.status !== 'pending') {
|
||||||
|
// Allow re-approving an already approved client to regenerate token? Or error?
|
||||||
|
// For now, let's say it must be pending.
|
||||||
|
throw new Error(`Client "${clientId}" is not in 'pending' state. Current state: ${client.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.status = 'approved';
|
||||||
|
// client.authToken = `auth_${generateRandomToken(24)}`; // authToken removed
|
||||||
|
// client.temporaryId = undefined; // temporaryId removed
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
|
||||||
|
await saveData();
|
||||||
|
return JSON.parse(JSON.stringify(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a pending client.
|
||||||
|
* @param clientId The ID of the client to reject.
|
||||||
|
* @returns The updated ClientInfo object.
|
||||||
|
*/
|
||||||
|
export async function rejectClient(clientId: string): Promise<ClientInfo> {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client with ID "${clientId}" not found.`);
|
||||||
|
}
|
||||||
|
if (client.status !== 'pending') {
|
||||||
|
console.warn(`Client "${clientId}" is not in 'pending' state. Current state: ${client.status}. Still marking as rejected.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.status = 'rejected';
|
||||||
|
// client.authToken = undefined; // authToken removed
|
||||||
|
// client.temporaryId = undefined; // temporaryId removed
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
// Consider if associatedSecretKeys or requestedSecretKeys should be cleared.
|
||||||
|
// For now, keeping them for audit/history.
|
||||||
|
|
||||||
|
await saveData();
|
||||||
|
return JSON.parse(JSON.stringify(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a client by their ID.
|
||||||
|
* @param clientId The ID of the client.
|
||||||
|
* @returns ClientInfo object or undefined if not found.
|
||||||
|
*/
|
||||||
|
export function getClient(clientId: string): ClientInfo | undefined {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
return client ? JSON.parse(JSON.stringify(client)) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all clients.
|
||||||
|
* @returns An array of ClientInfo objects.
|
||||||
|
*/
|
||||||
|
export function getAllClients(): ClientInfo[] {
|
||||||
|
return Object.values(dataStore.clients).map(client => JSON.parse(JSON.stringify(client)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all clients with 'pending' status.
|
||||||
|
*/
|
||||||
|
export function getPendingClients(): ClientInfo[] {
|
||||||
|
return Object.values(dataStore.clients)
|
||||||
|
.filter(client => client.status === 'pending')
|
||||||
|
.map(client => JSON.parse(JSON.stringify(client)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all clients with 'approved' status.
|
||||||
|
*/
|
||||||
|
export function getApprovedClients(): ClientInfo[] {
|
||||||
|
return Object.values(dataStore.clients)
|
||||||
|
.filter(client => client.status === 'approved')
|
||||||
|
.map(client => JSON.parse(JSON.stringify(client)));
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// OLD FUNCTIONS - TO BE REMOVED
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * (REMOVED - Clients are now associated with groups, not individual keys)
|
||||||
|
// * Associates a secret key with an approved client.
|
||||||
|
// */
|
||||||
|
// export async function associateSecretWithClient(clientId: string, secretKey: string): Promise<ClientInfo> { ... }
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * (REMOVED - Clients are now associated with groups, not individual keys)
|
||||||
|
// * Dissociates a secret key from a client.
|
||||||
|
// */
|
||||||
|
// export async function dissociateSecretFromClient(clientId: string, secretKey: string): Promise<ClientInfo> { ... }
|
||||||
|
//
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Associates a secret group with an approved client.
|
||||||
|
* @param clientId The ID of the client.
|
||||||
|
* @param groupId The ID of the group to associate.
|
||||||
|
*/
|
||||||
|
export async function associateGroupWithClient(clientId: string, groupId: number): Promise<ClientInfo> {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client with ID "${clientId}" not found.`);
|
||||||
|
}
|
||||||
|
if (client.status !== 'approved') {
|
||||||
|
throw new Error(`Client "${clientId}" is not approved. Cannot associate groups.`);
|
||||||
|
}
|
||||||
|
if (!dataStore.secretGroups[groupId]) {
|
||||||
|
throw new Error(`Secret group with ID "${groupId}" not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.associatedGroupIds) { // Should be initialized by now, but as a safeguard
|
||||||
|
client.associatedGroupIds = [];
|
||||||
|
}
|
||||||
|
if (!client.associatedGroupIds.includes(groupId)) {
|
||||||
|
client.associatedGroupIds.push(groupId);
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
await saveData();
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dissociates a secret group from a client.
|
||||||
|
* @param clientId The ID of the client.
|
||||||
|
* @param groupId The ID of the group to dissociate.
|
||||||
|
*/
|
||||||
|
export async function dissociateGroupFromClient(clientId: string, groupId: number): Promise<ClientInfo> {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client with ID "${clientId}" not found.`);
|
||||||
|
}
|
||||||
|
if (!client.associatedGroupIds) { // Safeguard
|
||||||
|
client.associatedGroupIds = [];
|
||||||
|
return JSON.parse(JSON.stringify(client)); // Nothing to dissociate
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = client.associatedGroupIds.indexOf(groupId);
|
||||||
|
if (index > -1) {
|
||||||
|
client.associatedGroupIds.splice(index, 1);
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
await saveData();
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the complete list of associated group IDs for a client.
|
||||||
|
* @param clientId The ID of the client.
|
||||||
|
* @param groupIds An array of group IDs to associate. Old associations are replaced.
|
||||||
|
*/
|
||||||
|
export async function setClientAssociatedGroups(clientId: string, groupIds: number[]): Promise<void> {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (!client) {
|
||||||
|
throw new Error(`Client with ID "${clientId}" not found.`);
|
||||||
|
}
|
||||||
|
if (client.status !== 'approved') {
|
||||||
|
throw new Error(`Client "${clientId}" is not approved. Cannot set group associations.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all group IDs exist before setting
|
||||||
|
for (const groupId of groupIds) {
|
||||||
|
if (!dataStore.secretGroups[groupId]) {
|
||||||
|
throw new Error(`Secret group with ID "${groupId}" not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.associatedGroupIds = [...new Set(groupIds)]; // Ensure unique IDs and copy array
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
await saveData();
|
||||||
|
console.log(`Client ${clientId} associated groups updated to: ${client.associatedGroupIds.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves all unique secret keys a client has access to through their associated groups.
|
||||||
|
* @param clientId The ID of the client.
|
||||||
|
* @returns An array of secret keys.
|
||||||
|
*/
|
||||||
|
export function getSecretsForClient(clientId: string): string[] {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (!client || !client.associatedGroupIds || client.associatedGroupIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessibleKeys = new Set<string>();
|
||||||
|
for (const groupId of client.associatedGroupIds) {
|
||||||
|
const group = dataStore.secretGroups[groupId];
|
||||||
|
if (group && group.keys) {
|
||||||
|
group.keys.forEach(key => accessibleKeys.add(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(accessibleKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves an approved client by their authToken.
|
||||||
|
* @param authToken The authentication token of the client.
|
||||||
|
* @returns ClientInfo object or undefined if not found or not approved.
|
||||||
|
*/
|
||||||
|
// export function getClientByAuthToken(authToken: string): ClientInfo | undefined {
|
||||||
|
// const client = Object.values(dataStore.clients).find(
|
||||||
|
// c => c.status === 'approved' && c.authToken === authToken
|
||||||
|
// );
|
||||||
|
// return client ? JSON.parse(JSON.stringify(client)) : undefined;
|
||||||
|
// }
|
||||||
|
// This function is now obsolete as authToken is removed.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a client and their associations.
|
||||||
|
* @param clientId The ID of the client to delete.
|
||||||
|
*/
|
||||||
|
export async function deleteClient(clientId: string): Promise<void> {
|
||||||
|
if (dataStore.clients.hasOwnProperty(clientId)) {
|
||||||
|
delete dataStore.clients[clientId];
|
||||||
|
// No need to iterate secrets, as associations are on the client record.
|
||||||
|
await saveData();
|
||||||
|
console.log(`Client "${clientId}" deleted.`);
|
||||||
|
} else {
|
||||||
|
console.warn(`Client "${clientId}" not found for deletion.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a client disconnect. If the client was 'approved',
|
||||||
|
* its status is set to 'rejected'.
|
||||||
|
* @param clientId The ID of the disconnected client.
|
||||||
|
*/
|
||||||
|
export async function handleClientDisconnect(clientId: string): Promise<void> {
|
||||||
|
const client = dataStore.clients[clientId];
|
||||||
|
if (client) { // Check if client exists, to prevent errors if called with an already deleted/unknown ID
|
||||||
|
if (client.status === 'approved') {
|
||||||
|
console.log(`Approved client "${client.name}" (ID: ${client.id}) disconnected. Setting status to rejected.`);
|
||||||
|
client.status = 'rejected';
|
||||||
|
client.dateUpdated = new Date().toISOString();
|
||||||
|
// No need to clear associatedSecretKeys, they might be useful if client is re-approved quickly.
|
||||||
|
// Or, business logic might dictate clearing them. For now, keep them.
|
||||||
|
try {
|
||||||
|
await saveData();
|
||||||
|
console.log(`Saved data after client ${clientId} disconnect.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to save data after client ${clientId} disconnect:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`Client "${client.name}" (ID: ${client.id}) disconnected with status: ${client.status}. No status change needed from 'approved'.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn(`Attempted to handle disconnect for unknown client ID: ${clientId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/lib/encryption.ts
Normal file
143
src/lib/encryption.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
// Encryption and decryption utilities
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const ALGORITHM = 'aes-256-gcm';
|
||||||
|
const SALT_LENGTH = 16; // For master key derivation
|
||||||
|
const IV_LENGTH = 12; // AES-GCM standard IV length
|
||||||
|
const AUTH_TAG_LENGTH = 16; // AES-GCM standard auth tag length
|
||||||
|
const PBKDF2_ITERATIONS = 310000; // OWASP recommendation (as of 2023)
|
||||||
|
const KEY_LENGTH = 32; // 256 bits for AES-256
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives a master encryption key from a password and salt.
|
||||||
|
* This should be done once when the server starts.
|
||||||
|
*/
|
||||||
|
export function deriveMasterKey(password: string, salt: Buffer): Buffer {
|
||||||
|
return crypto.pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, 'sha512');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a new random salt.
|
||||||
|
*/
|
||||||
|
export function generateSalt(): Buffer {
|
||||||
|
return crypto.randomBytes(SALT_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts plaintext using the master key.
|
||||||
|
* Prepends a random IV to the ciphertext. Stores IV + AuthTag + Ciphertext.
|
||||||
|
* @param text The plaintext string to encrypt.
|
||||||
|
* @param masterKey The master encryption key (derived via deriveMasterKey).
|
||||||
|
* @returns A string in the format: iv_hex:authTag_hex:ciphertext_hex
|
||||||
|
*/
|
||||||
|
export function encrypt(text: string, masterKey: Buffer): string {
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
|
const cipher = crypto.createCipheriv(ALGORITHM, masterKey, iv);
|
||||||
|
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||||
|
encrypted += cipher.final('hex');
|
||||||
|
const authTag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// Store IV, authTag, and encrypted data together, all hex encoded
|
||||||
|
return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts ciphertext using the master key.
|
||||||
|
* Expects the input string to be in the format: iv_hex:authTag_hex:ciphertext_hex
|
||||||
|
* @param encryptedTextWithIvAndAuthTag The encrypted string.
|
||||||
|
* @param masterKey The master encryption key.
|
||||||
|
* @returns The decrypted plaintext string, or null if decryption fails.
|
||||||
|
*/
|
||||||
|
export function decrypt(encryptedTextWithIvAndAuthTag: string, masterKey: Buffer): string | null {
|
||||||
|
try {
|
||||||
|
const parts = encryptedTextWithIvAndAuthTag.split(':');
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
console.error('Invalid encrypted text format. Expected iv:authTag:ciphertext.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = Buffer.from(parts[0], 'hex');
|
||||||
|
const authTag = Buffer.from(parts[1], 'hex');
|
||||||
|
const encryptedData = parts[2];
|
||||||
|
|
||||||
|
if (iv.length !== IV_LENGTH) {
|
||||||
|
console.error(`Invalid IV length. Expected ${IV_LENGTH}, got ${iv.length}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (authTag.length !== AUTH_TAG_LENGTH) {
|
||||||
|
console.error(`Invalid AuthTag length. Expected ${AUTH_TAG_LENGTH}, got ${authTag.length}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv(ALGORITHM, masterKey, iv);
|
||||||
|
decipher.setAuthTag(authTag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
||||||
|
decrypted += decipher.final('utf8');
|
||||||
|
return decrypted;
|
||||||
|
} catch (error) {
|
||||||
|
// Type guard for error
|
||||||
|
if (error instanceof Error) {
|
||||||
|
console.error('Decryption failed:', error.message);
|
||||||
|
} else {
|
||||||
|
console.error('Decryption failed with an unknown error:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Example usage (for testing, can be removed or moved to a test file):
|
||||||
|
/*
|
||||||
|
if (require.main === module) {
|
||||||
|
const examplePassword = 'mySuperSecretPassword123!';
|
||||||
|
const salt = generateSalt(); // In a real app, this salt for the master key would be stored (e.g., in a config file or alongside the encrypted data if not sensitive)
|
||||||
|
// Or, if the data file itself is the only thing, the salt might need to be hardcoded or configured.
|
||||||
|
// For data at rest where the password is input each time, a fixed salt (or a salt stored with the encrypted blob) is common.
|
||||||
|
// Let's assume a fixed salt for the master key for now for simplicity of this example.
|
||||||
|
const fixedSaltForMasterKey = Buffer.from('someFixedSalt12345', 'utf-8').slice(0, SALT_LENGTH); // Ensure it's correct length
|
||||||
|
|
||||||
|
const masterKey = deriveMasterKey(examplePassword, fixedSaltForMasterKey);
|
||||||
|
console.log('Master Key (hex):', masterKey.toString('hex'));
|
||||||
|
|
||||||
|
const originalText = "Hello, world! This is a secret message.";
|
||||||
|
console.log('\nOriginal Text:', originalText);
|
||||||
|
|
||||||
|
const encryptedText = encrypt(originalText, masterKey);
|
||||||
|
console.log('Encrypted Text:', encryptedText);
|
||||||
|
|
||||||
|
if (encryptedText) {
|
||||||
|
const decryptedText = decrypt(encryptedText, masterKey);
|
||||||
|
console.log('Decrypted Text:', decryptedText);
|
||||||
|
|
||||||
|
if (decryptedText !== originalText) {
|
||||||
|
console.error('Decryption Mismatch!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with wrong key
|
||||||
|
const wrongSalt = generateSalt();
|
||||||
|
const wrongMasterKey = deriveMasterKey("wrongPassword!", wrongSalt);
|
||||||
|
const decryptedWithWrongKey = decrypt(encryptedText, wrongMasterKey);
|
||||||
|
console.log('\nDecrypted with WRONG key:', decryptedWithWrongKey); // Should be null
|
||||||
|
|
||||||
|
// Test tampering (modify ciphertext)
|
||||||
|
const parts = encryptedText.split(':');
|
||||||
|
const tamperedCiphertext = parts[2].slice(0, -4) + "0000"; // Modify some bytes
|
||||||
|
const tamperedEncryptedText = `${parts[0]}:${parts[1]}:${tamperedCiphertext}`;
|
||||||
|
const decryptedTampered = decrypt(tamperedEncryptedText, masterKey);
|
||||||
|
console.log('Decrypted Tampered Ciphertext:', decryptedTampered); // Should be null due to authTag mismatch
|
||||||
|
|
||||||
|
// Test tampering (modify IV)
|
||||||
|
const tamperedIv = crypto.randomBytes(IV_LENGTH).toString('hex');
|
||||||
|
const tamperedEncryptedTextIv = `${tamperedIv}:${parts[1]}:${parts[2]}`;
|
||||||
|
const decryptedTamperedIv = decrypt(tamperedEncryptedTextIv, masterKey);
|
||||||
|
console.log('Decrypted Tampered IV:', decryptedTamperedIv); // Should be null
|
||||||
|
|
||||||
|
// Test tampering (modify authTag)
|
||||||
|
const tamperedAuthTag = crypto.randomBytes(AUTH_TAG_LENGTH).toString('hex');
|
||||||
|
const tamperedEncryptedTextAuthTag = `${parts[0]}:${tamperedAuthTag}:${parts[2]}`;
|
||||||
|
const decryptedTamperedAuthTag = decrypt(tamperedEncryptedTextAuthTag, masterKey);
|
||||||
|
console.log('Decrypted Tampered AuthTag:', decryptedTamperedAuthTag); // Should be null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
146
src/main.ts
Normal file
146
src/main.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
// Main application entry point
|
||||||
|
import readline from 'readline';
|
||||||
|
import { initializeDataManager } from './lib/dataManager';
|
||||||
|
import { startHttpServer } from './http/httpServer';
|
||||||
|
import { startWebSocketServer } from './websocket/wsServer';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { getConfig } from './lib/configManager';
|
||||||
|
|
||||||
|
// Load environment variables from .env file (e.g., for MASTER_PASSWORD)
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
// Configuration will be loaded by configManager and accessed via getConfig()
|
||||||
|
// const HTTP_PORT = parseInt(process.env.HTTP_PORT || '3000', 10); // Now from config
|
||||||
|
// const WS_PORT = parseInt(process.env.WS_PORT || '3001', 10); // Now from config
|
||||||
|
|
||||||
|
// Store servers for graceful shutdown
|
||||||
|
let httpServerInstance: ReturnType<typeof startHttpServer> | null = null;
|
||||||
|
let wsServerInstance: ReturnType<typeof startWebSocketServer> | null = null;
|
||||||
|
|
||||||
|
|
||||||
|
async function getPassword(): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (process.env.MASTER_PASSWORD) {
|
||||||
|
console.log("Using MASTER_PASSWORD from environment variable.");
|
||||||
|
resolve(process.env.MASTER_PASSWORD);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Ctrl+C during password prompt gracefully
|
||||||
|
rl.on('SIGINT', () => {
|
||||||
|
console.log('\nPassword input cancelled. Exiting.');
|
||||||
|
rl.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
rl.question('Enter the master password for the server: ', (password) => {
|
||||||
|
rl.close();
|
||||||
|
if (!password) {
|
||||||
|
// If running in a non-interactive environment and password is required but not provided via env.
|
||||||
|
// This specific check might be more relevant if stdin is not a TTY.
|
||||||
|
// For now, an empty password here would just proceed.
|
||||||
|
// A more robust check for actual empty input vs. just pressing enter might be needed.
|
||||||
|
console.warn("Warning: Empty password entered.");
|
||||||
|
}
|
||||||
|
resolve(password);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
console.log('Starting Key/Info Manager Server...');
|
||||||
|
const password = await getPassword();
|
||||||
|
|
||||||
|
if (!password && !process.env.MASTER_PASSWORD) {
|
||||||
|
console.error('ERROR: Master password is required to start the server.');
|
||||||
|
console.error('Please provide it when prompted or set the MASTER_PASSWORD environment variable.');
|
||||||
|
process.exit(1);
|
||||||
|
return; // Ensure function exits if process.exit doesn't immediately terminate in all contexts
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Initializing data manager...');
|
||||||
|
try {
|
||||||
|
await initializeDataManager(password);
|
||||||
|
console.log('Data manager initialized successfully.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize data manager:', error);
|
||||||
|
console.error('This could be due to an incorrect password or corrupted data file.');
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig(); // Load configuration
|
||||||
|
|
||||||
|
console.log(`Attempting to start HTTP server on port ${config.httpPort}...`);
|
||||||
|
httpServerInstance = startHttpServer(config.httpPort, password);
|
||||||
|
|
||||||
|
console.log(`Attempting to start WebSocket server on port ${config.wsPort}...`);
|
||||||
|
wsServerInstance = startWebSocketServer(config.wsPort);
|
||||||
|
|
||||||
|
console.log('Server started successfully.');
|
||||||
|
console.log(`Admin UI accessible via HTTP server (e.g., http://localhost:${config.httpPort}/admin)`);
|
||||||
|
console.log(`WebSocket connections on ws://localhost:${config.wsPort}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gracefulShutdown(signal: string) {
|
||||||
|
console.log(`\nReceived ${signal}. Shutting down gracefully...`);
|
||||||
|
|
||||||
|
// Close WebSocket server connections
|
||||||
|
if (wsServerInstance) {
|
||||||
|
console.log('Closing WebSocket server...');
|
||||||
|
wsServerInstance.clients.forEach(client => client.close());
|
||||||
|
wsServerInstance.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error closing WebSocket server:', err);
|
||||||
|
} else {
|
||||||
|
console.log('WebSocket server closed.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close HTTP server
|
||||||
|
// Note: Express app itself doesn't have a direct 'close' method.
|
||||||
|
// The app.listen() returns a Node.js http.Server instance, which does.
|
||||||
|
// We need to ensure startHttpServer returns the actual server instance if we want to close it.
|
||||||
|
// For now, the current startHttpServer returns `app`, not the server instance.
|
||||||
|
// This will be improved if startHttpServer is modified to return the http.Server.
|
||||||
|
// As a simple measure, just logging. For true graceful HTTP shutdown, more work is needed.
|
||||||
|
if (httpServerInstance && typeof httpServerInstance.close === 'function') {
|
||||||
|
console.log('Closing HTTP server...');
|
||||||
|
httpServerInstance.close((err?: Error) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error closing HTTP server:', err);
|
||||||
|
} else {
|
||||||
|
console.log('HTTP server closed.');
|
||||||
|
}
|
||||||
|
// Consider waiting for all servers to close before exiting
|
||||||
|
// This might require more complex promise handling if multiple async closes
|
||||||
|
// For now, exiting after attempting to close WebSocket server.
|
||||||
|
});
|
||||||
|
} else if (httpServerInstance) {
|
||||||
|
console.log('HTTP server instance does not have a close method or is not the expected type.');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Perform any other cleanup tasks here (e.g., saving data if not done automatically)
|
||||||
|
// For DataManager, saves are typically per-operation, but a final save could be added.
|
||||||
|
|
||||||
|
console.log('Exiting now.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for termination signals
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT')); // Ctrl+C
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); // kill command
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
startServer().catch(error => {
|
||||||
|
console.error('FATAL: Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
333
src/websocket/wsServer.ts
Normal file
333
src/websocket/wsServer.ts
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
// WebSocket server logic
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import * as DataManager from '../lib/dataManager';
|
||||||
|
import { getConfig } from '../lib/configManager'; // Import getConfig
|
||||||
|
|
||||||
|
// Extend WebSocket instance type to hold authentication state and rate limit data
|
||||||
|
interface AuthenticatedWebSocket extends WebSocket {
|
||||||
|
clientRegisteredName?: string; // Store the name given during REGISTER_CLIENT
|
||||||
|
clientServerId?: string; // Store the server-assigned ID (from DataManager)
|
||||||
|
// Rate limiting properties
|
||||||
|
lastMessageTime?: number;
|
||||||
|
messageCount?: number;
|
||||||
|
// For IP based limiting before registration
|
||||||
|
ip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define response codes
|
||||||
|
const WsResponseCodes = {
|
||||||
|
// Success
|
||||||
|
OK: 2000,
|
||||||
|
REGISTRATION_SUBMITTED: 2001,
|
||||||
|
// CLIENT_APPROVED: 2002, // Not used directly in responses yet
|
||||||
|
// NO_CONTENT: 2004, // Not used directly in responses yet
|
||||||
|
|
||||||
|
// Client Errors
|
||||||
|
BAD_REQUEST: 4000,
|
||||||
|
UNAUTHORIZED: 4001, // For when client status is not 'approved' for an action
|
||||||
|
// FORBIDDEN: 4003, // Not used yet
|
||||||
|
NOT_FOUND: 4004,
|
||||||
|
CLIENT_NOT_REGISTERED: 4005, // Client hasn't sent REGISTER_CLIENT yet
|
||||||
|
CLIENT_REGISTRATION_EXPIRED: 4006, // Client's pending registration expired
|
||||||
|
RATE_LIMIT_EXCEEDED: 4029, // Standard "Too Many Requests"
|
||||||
|
// CONFLICT: 4009, // Not used yet
|
||||||
|
|
||||||
|
// Server Errors
|
||||||
|
INTERNAL_SERVER_ERROR: 5000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate Limiting Configuration
|
||||||
|
const WS_RATE_LIMIT_WINDOW_MS = parseInt(process.env.WS_RATE_LIMIT_WINDOW_MS || (60 * 1000).toString(), 10); // 1 minute
|
||||||
|
const WS_MAX_MESSAGES_PER_WINDOW = parseInt(process.env.WS_MAX_MESSAGES_PER_WINDOW || '100', 10); // 100 messages per minute
|
||||||
|
const WS_REGISTER_RATE_LIMIT_WINDOW_MS = parseInt(process.env.WS_REGISTER_RATE_LIMIT_WINDOW_MS || (60 * 60 * 1000).toString(), 10); // 1 hour
|
||||||
|
const WS_MAX_REGISTRATIONS_PER_WINDOW = parseInt(process.env.WS_MAX_REGISTRATIONS_PER_WINDOW || '10', 10); // 10 registration attempts per hour (per IP)
|
||||||
|
|
||||||
|
// Store for IP-based rate limiting for registration attempts
|
||||||
|
const registrationRateLimiter = new Map<string, { count: number, windowStart: number }>();
|
||||||
|
|
||||||
|
|
||||||
|
// Helper function to send structured responses
|
||||||
|
function sendWsResponse(ws: AuthenticatedWebSocket, type: string, code: number, payload: object, requestId?: string) {
|
||||||
|
const response = { type, code, payload, requestId };
|
||||||
|
ws.send(JSON.stringify(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to store active WebSocket connections by their server-assigned client ID
|
||||||
|
const activeConnections: Map<string, AuthenticatedWebSocket> = new Map();
|
||||||
|
|
||||||
|
// Function to be exported for HTTP server to call
|
||||||
|
export function notifyClientStatusUpdate(clientId: string, newStatus: DataManager.ClientStatus, detail?: string) {
|
||||||
|
const ws = activeConnections.get(clientId);
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
let responseCode = WsResponseCodes.OK; // Generic OK for status update
|
||||||
|
let messageType = "STATUS_UPDATE";
|
||||||
|
|
||||||
|
if (newStatus === 'approved') {
|
||||||
|
// Optionally use a more specific code or rely on payload.
|
||||||
|
// For now, client will get 'approved' in payload.
|
||||||
|
// responseCode = WsResponseCodes.CLIENT_APPROVED; // If we had this code used for responses
|
||||||
|
console.log(`Notifying client ${clientId} of approval.`);
|
||||||
|
} else if (newStatus === 'rejected') {
|
||||||
|
responseCode = WsResponseCodes.UNAUTHORIZED; // Or a specific "CLIENT_REJECTED" code if defined
|
||||||
|
console.log(`Notifying client ${clientId} of rejection.`);
|
||||||
|
}
|
||||||
|
// Add more cases if other statuses are pushed (e.g., 'expired' if distinct from 'rejected')
|
||||||
|
|
||||||
|
sendWsResponse(ws, messageType, responseCode, {
|
||||||
|
newStatus: newStatus,
|
||||||
|
detail: detail || `Your registration status is now: ${newStatus}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log(`Client ${clientId} not actively connected or WebSocket not open. Cannot send status update.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function startWebSocketServer(port: number) {
|
||||||
|
// Removed initialConnectionMode and acceptAllWebSocketConnections global toggle
|
||||||
|
console.log(`WebSocket server starting. All connections require registration and approval.`);
|
||||||
|
console.log(`WS Rate Limiting: General: ${WS_MAX_MESSAGES_PER_WINDOW} msgs / ${WS_RATE_LIMIT_WINDOW_MS / 1000}s. Register: ${WS_MAX_REGISTRATIONS_PER_WINDOW} attempts / ${WS_REGISTER_RATE_LIMIT_WINDOW_MS / 1000 / 60}m.`);
|
||||||
|
|
||||||
|
|
||||||
|
const wss = new WebSocket.Server({ port });
|
||||||
|
|
||||||
|
wss.on('connection', (ws: AuthenticatedWebSocket, req) => {
|
||||||
|
// Get client IP - req.socket.remoteAddress might be undefined if connection is already closed or proxied without proper headers.
|
||||||
|
// For proxies, ensure 'x-forwarded-for' is trusted and used if available.
|
||||||
|
// Simplified: use remoteAddress directly.
|
||||||
|
ws.ip = req.socket.remoteAddress || 'unknown';
|
||||||
|
console.log(`Client connected from IP: ${ws.ip}. Awaiting registration.`);
|
||||||
|
|
||||||
|
// Initialize rate limiting properties for general messages
|
||||||
|
ws.messageCount = 0;
|
||||||
|
ws.lastMessageTime = Date.now();
|
||||||
|
|
||||||
|
|
||||||
|
ws.on('message', async (messageData) => {
|
||||||
|
let parsedMessage;
|
||||||
|
let clientRequestId: string | undefined;
|
||||||
|
|
||||||
|
// --- General Per-Client Rate Limiting (for registered clients) ---
|
||||||
|
if (ws.clientServerId) { // Only apply this to registered clients
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - (ws.lastMessageTime || now) > WS_RATE_LIMIT_WINDOW_MS) {
|
||||||
|
ws.messageCount = 1;
|
||||||
|
ws.lastMessageTime = now;
|
||||||
|
} else {
|
||||||
|
ws.messageCount = (ws.messageCount || 0) + 1;
|
||||||
|
if (ws.messageCount > WS_MAX_MESSAGES_PER_WINDOW) {
|
||||||
|
console.warn(`Client ${ws.clientServerId} (${ws.clientRegisteredName}) exceeded general message rate limit from IP ${ws.ip}.`);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.RATE_LIMIT_EXCEEDED, { detail: `Too many messages. Please slow down. Limit: ${WS_MAX_MESSAGES_PER_WINDOW} per ${WS_RATE_LIMIT_WINDOW_MS / 1000}s.` });
|
||||||
|
// Optionally, could implement a short cooldown or temporary ignore here.
|
||||||
|
// For now, just sending error and processing no further for this message.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// --- End General Per-Client Rate Limiting ---
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messageString = messageData.toString();
|
||||||
|
parsedMessage = JSON.parse(messageString);
|
||||||
|
clientRequestId = parsedMessage.requestId; // Capture client's requestId if provided
|
||||||
|
console.log('Received from client:', parsedMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse message or message not JSON:', messageData.toString());
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.BAD_REQUEST, { detail: "Invalid message format. Expected JSON." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { type, payload } = parsedMessage;
|
||||||
|
|
||||||
|
if (type !== 'REGISTER_CLIENT' && !ws.clientServerId) {
|
||||||
|
console.log("Client sent command before registration.");
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.CLIENT_NOT_REGISTERED, { detail: "Client must register first using REGISTER_CLIENT." }, clientRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'REGISTER_CLIENT':
|
||||||
|
// --- IP-based Rate Limiting for Registration ---
|
||||||
|
const ip = ws.ip!; // Should be set on connection
|
||||||
|
const now = Date.now();
|
||||||
|
let ipInfo = registrationRateLimiter.get(ip);
|
||||||
|
|
||||||
|
if (ipInfo && (now - ipInfo.windowStart < WS_REGISTER_RATE_LIMIT_WINDOW_MS)) {
|
||||||
|
ipInfo.count++;
|
||||||
|
} else { // New window or first attempt for this IP
|
||||||
|
ipInfo = { count: 1, windowStart: now };
|
||||||
|
registrationRateLimiter.set(ip, ipInfo);
|
||||||
|
}
|
||||||
|
// Clean up old entries from the registrationRateLimiter map periodically (not shown here for brevity, but important for long-running servers)
|
||||||
|
|
||||||
|
if (ipInfo.count > WS_MAX_REGISTRATIONS_PER_WINDOW) {
|
||||||
|
console.warn(`IP ${ip} exceeded registration rate limit.`);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.RATE_LIMIT_EXCEEDED, { detail: `Too many registration attempts from this IP. Please try again later. Limit: ${WS_MAX_REGISTRATIONS_PER_WINDOW} per ${WS_REGISTER_RATE_LIMIT_WINDOW_MS / 1000 / 60} minutes.` }, clientRequestId);
|
||||||
|
return; // Stop processing this registration request
|
||||||
|
}
|
||||||
|
// --- End IP-based Rate Limiting for Registration ---
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (ws.clientServerId) {
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.BAD_REQUEST, { detail: "Client already registered for this connection." }, clientRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!payload || !payload.clientName) {
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.BAD_REQUEST, { detail: "clientName is required for registration." }, clientRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Add to DataManager as pending
|
||||||
|
const newClient = await DataManager.addPendingClient(payload.clientName, payload.requestedSecretKeys);
|
||||||
|
|
||||||
|
ws.clientRegisteredName = newClient.name;
|
||||||
|
ws.clientRegisteredName = newClient.name;
|
||||||
|
ws.clientServerId = newClient.id;
|
||||||
|
|
||||||
|
// Store active connection
|
||||||
|
activeConnections.set(newClient.id, ws);
|
||||||
|
console.log(`Active connections: ${activeConnections.size}`);
|
||||||
|
|
||||||
|
|
||||||
|
sendWsResponse(ws, "REGISTRATION_ACK", WsResponseCodes.REGISTRATION_SUBMITTED, {
|
||||||
|
clientId: newClient.id,
|
||||||
|
detail: `Registration for "${newClient.name}" submitted. Awaiting admin approval. Your Client ID is ${newClient.id}.`
|
||||||
|
}, clientRequestId);
|
||||||
|
console.log(`Client "${newClient.name}" (ID: ${newClient.id}) registration submitted.`);
|
||||||
|
|
||||||
|
// Auto-approve if flag is set
|
||||||
|
if (getConfig().autoApproveWebSocketRegistrations) {
|
||||||
|
console.log(`Auto-approving client ${newClient.id} (debug mode).`);
|
||||||
|
try {
|
||||||
|
await DataManager.approveClient(newClient.id);
|
||||||
|
notifyClientStatusUpdate(newClient.id, 'approved', 'Client registration automatically approved (debug mode).');
|
||||||
|
} catch (approvalError) {
|
||||||
|
console.error(`Error auto-approving client ${newClient.id}:`, approvalError);
|
||||||
|
// Optionally notify client of auto-approval failure, though they'll remain pending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Registration error:", error);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.INTERNAL_SERVER_ERROR, { detail: "An internal error occurred during registration. Please try again later or contact support if the issue persists." }, clientRequestId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Placeholder for other message types (e.g., REQUEST_SECRET) - will be handled in next step
|
||||||
|
// For now, if not authenticated, reject other types.
|
||||||
|
default:
|
||||||
|
// At this point, ws.clientServerId should be set if type is not REGISTER_CLIENT.
|
||||||
|
// Now, we check the client's status from DataManager.
|
||||||
|
const clientInfo = ws.clientServerId ? DataManager.getClient(ws.clientServerId) : undefined;
|
||||||
|
|
||||||
|
if (!clientInfo) {
|
||||||
|
console.log(`Client data not found for ID: ${ws.clientServerId}. Terminating connection.`);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.UNAUTHORIZED, { detail: "Client not recognized or registration incomplete. Please re-register." }, clientRequestId);
|
||||||
|
ws.terminate(); // Or close, terminate is more abrupt
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientInfo.status === 'pending') {
|
||||||
|
// Check if registration might have expired
|
||||||
|
if (clientInfo.registrationTimestamp && (Date.now() - clientInfo.registrationTimestamp > (60 * 1000 + 5000))) { // Add 5s buffer to expiry check
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.CLIENT_REGISTRATION_EXPIRED, { detail: "Your registration request has expired. Please register again." }, clientRequestId);
|
||||||
|
} else {
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.UNAUTHORIZED, { detail: "Client registration is pending admin approval." }, clientRequestId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientInfo.status === 'rejected') {
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.UNAUTHORIZED, { detail: "Client registration was rejected by admin." }, clientRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clientInfo.status !== 'approved') {
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.UNAUTHORIZED, { detail: `Client not approved. Current status: ${clientInfo.status}.` }, clientRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach here, client is approved.
|
||||||
|
console.log(`Processing message type "${type}" for approved client: ${clientInfo.name} (${clientInfo.id})`);
|
||||||
|
|
||||||
|
// Handle messages for authenticated clients
|
||||||
|
switch(type) {
|
||||||
|
case 'REQUEST_SECRET':
|
||||||
|
try {
|
||||||
|
if (!payload || !payload.secretKey) {
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.BAD_REQUEST, { detail: "secretKey is required for REQUEST_SECRET." }, clientRequestId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const secretKeyToRequest = payload.secretKey;
|
||||||
|
// Use new DataManager function to get all keys client is authorized for
|
||||||
|
const authorizedKeys = DataManager.getSecretsForClient(clientInfo.id);
|
||||||
|
|
||||||
|
if (authorizedKeys.includes(secretKeyToRequest)) {
|
||||||
|
const secretData = DataManager.getSecretWithValue(secretKeyToRequest); // Fetch {value, groupId}
|
||||||
|
if (secretData && secretData.value !== undefined) {
|
||||||
|
sendWsResponse(ws, "SECRET_DATA", WsResponseCodes.OK, { secretKey: secretKeyToRequest, value: secretData.value }, clientRequestId);
|
||||||
|
} else {
|
||||||
|
// This case implies an inconsistency: client is authorized for a key that doesn't exist in secrets store.
|
||||||
|
console.error(`Client ${clientInfo.name} (ID: ${clientInfo.id}) authorized for non-existent secret key "${secretKeyToRequest}". Data inconsistency.`);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.NOT_FOUND, { detail: `Secret key "${secretKeyToRequest}" not found on server, though client is authorized. Please contact admin.` }, clientRequestId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.UNAUTHORIZED, { detail: `You are not authorized to access the secret key "${secretKeyToRequest}".` }, clientRequestId);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error processing REQUEST_SECRET:", error);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.INTERNAL_SERVER_ERROR, { detail: "An internal error occurred while requesting the secret. Please try again later." }, clientRequestId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'LIST_AUTHORIZED_SECRETS':
|
||||||
|
try {
|
||||||
|
const authorizedKeys = DataManager.getSecretsForClient(clientInfo.id);
|
||||||
|
sendWsResponse(ws, "AUTHORIZED_SECRETS_LIST", WsResponseCodes.OK, { authorizedSecretKeys: authorizedKeys }, clientRequestId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Error processing LIST_AUTHORIZED_SECRETS:", error);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.INTERNAL_SERVER_ERROR, { detail: "An internal error occurred while listing authorized secrets. Please try again later." }, clientRequestId);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(`Approved client ${clientInfo.name} sent unhandled message type: ${type}`);
|
||||||
|
sendWsResponse(ws, "ERROR", WsResponseCodes.BAD_REQUEST, { detail: `Unknown message type: ${type}` }, clientRequestId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', async () => { // Made async to await DataManager call
|
||||||
|
const clientName = ws.clientRegisteredName || 'Unknown';
|
||||||
|
const clientId = ws.clientServerId;
|
||||||
|
console.log(`Client ${clientName} (${clientId || 'N/A'}) disconnected from WebSocket server`);
|
||||||
|
|
||||||
|
if (clientId) {
|
||||||
|
activeConnections.delete(clientId);
|
||||||
|
console.log(`Removed client ${clientId} from active connections. Remaining: ${activeConnections.size}`);
|
||||||
|
|
||||||
|
// Check client status before calling handleClientDisconnect
|
||||||
|
const clientInfo = DataManager.getClient(clientId);
|
||||||
|
if (clientInfo && clientInfo.status === 'approved') {
|
||||||
|
try {
|
||||||
|
await DataManager.handleClientDisconnect(clientId);
|
||||||
|
} catch (dbError) {
|
||||||
|
console.error(`Error updating DataManager on disconnect for client ${clientId}:`, dbError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('error', (error) => {
|
||||||
|
console.error(`WebSocket error for client ${ws.clientRegisteredName || 'Unknown'}:`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
sendWsResponse(ws, "WELCOME", WsResponseCodes.OK, { detail: "Welcome! Please register your client using REGISTER_CLIENT message." });
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`WebSocket server started on ws://localhost:${port}`);
|
||||||
|
return wss;
|
||||||
|
}
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true, // Good for avoiding issues with @types packages if they have minor inconsistencies
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true // Allows importing JSON files
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "**/*.spec.ts", "**/*.test.ts"]
|
||||||
|
}
|
||||||
181
views/admin.ejs
Normal file
181
views/admin.ejs
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Admin - Key/Info Management</title>
|
||||||
|
<link rel="stylesheet" href="/css/admin_styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Secret Management</h1>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/admin">Manage Secrets</a>
|
||||||
|
<a href="/admin/clients">Manage Clients</a>
|
||||||
|
<a href="/admin/logout" class="logout-link">Logout</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (message && message.text) { %>
|
||||||
|
<div class="alert <%= message.type === 'success' ? 'alert-success' : 'alert-error' %>">
|
||||||
|
<%= message.text %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<h2>Manage Secrets</h2>
|
||||||
|
|
||||||
|
<% if (editingItemKey !== null && itemToEdit !== undefined && typeof itemToEdit.value !== 'undefined') { %>
|
||||||
|
<h3>Edit Secret Value: <em><%= editingItemKey %></em></h3>
|
||||||
|
<form action="/admin/update-secret" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="originalKey" value="<%= editingItemKey %>">
|
||||||
|
<input type="hidden" name="secretKey" value="<%= editingItemKey %>"> <%# Key is not changeable here %>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Secret Key:</label>
|
||||||
|
<p><strong><%= editingItemKey %></strong></p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Belongs to Group:</label>
|
||||||
|
<p>
|
||||||
|
<strong><%= itemToEdit.groupName %></strong> (ID: <%= itemToEdit.groupId %>)
|
||||||
|
<% if (!itemToEdit.groupId) { %> <span class="text-danger">(Warning: No group assigned or group missing!)</span> <% } %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editSecretValue">Value (JSON format recommended for complex data):</label>
|
||||||
|
<textarea id="editSecretValue" name="secretValue" required><%= typeof itemToEdit.value === 'string' ? itemToEdit.value : JSON.stringify(itemToEdit.value, null, 2) %></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Update Secret Value</button>
|
||||||
|
<a href="/admin" class="btn btn-secondary">Cancel Edit</a>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<h3>Add New Secret</h3>
|
||||||
|
<form action="/admin/add-secret" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupId">Select Group:</label>
|
||||||
|
<select id="groupId" name="groupId" required class="form-control"> <%# Using form-control for consistent styling if bootstrap-like styles are used %>
|
||||||
|
<% if (secretGroups && secretGroups.length > 0) { %>
|
||||||
|
<option value="" disabled selected>-- Select a Group --</option>
|
||||||
|
<% secretGroups.forEach(group => { %>
|
||||||
|
<option value="<%= group.id %>"><%= group.name %> (ID: <%= group.id %>)</option>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<option value="" disabled selected>No groups available. Please create a group first.</option>
|
||||||
|
<% } %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="secretKey">Secret Key:</label>
|
||||||
|
<input type="text" id="secretKey" name="secretKey" required <% if (!secretGroups || secretGroups.length === 0) { %>disabled<% } %>>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="secretValue">Secret Value (JSON format recommended for complex data):</label>
|
||||||
|
<textarea id="secretValue" name="secretValue" required <% if (!secretGroups || secretGroups.length === 0) { %>disabled<% } %>></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" <% if (!secretGroups || secretGroups.length === 0) { %>disabled<% } %>>Add Secret</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (typeof editingGroup !== 'undefined' && editingGroup) { %>
|
||||||
|
<hr>
|
||||||
|
<h2>Rename Secret Group: <%= editingGroup.name %> (ID: <%= editingGroup.id %>)</h2>
|
||||||
|
<form action="/admin/groups/rename/<%= editingGroup.id %>" method="POST" class="mb-3">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newGroupName">New Group Name:</label>
|
||||||
|
<input type="text" id="newGroupName" name="newGroupName" value="<%= editingGroup.name %>" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Rename Group</button>
|
||||||
|
<a href="/admin" class="btn btn-secondary ml-2">Cancel</a>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h2>Secret Groups</h2>
|
||||||
|
|
||||||
|
<h3>Create New Secret Group</h3>
|
||||||
|
<form action="/admin/groups/create" method="POST" class="mb-3">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupName">Group Name:</label>
|
||||||
|
<input type="text" id="groupName" name="groupName" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Create Group</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<% if (secretGroups && secretGroups.length > 0) { %>
|
||||||
|
<h3>Existing Secret Groups</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Keys in Group</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% secretGroups.forEach(group => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= group.id %></td>
|
||||||
|
<td><%= group.name %></td>
|
||||||
|
<td><%= group.keys.length %></td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="/admin/groups/<%= group.id %>/secrets">View/Manage Secrets</a>
|
||||||
|
<a href="/admin/groups/edit/<%= group.id %>" class="ml-2">Rename</a>
|
||||||
|
<form action="/admin/groups/delete/<%= group.id %>" method="POST" class="form-inline" onsubmit="return confirm('Are you sure you want to delete the group \"<%= group.name %>\" (ID: <%= group.id %>)? All secrets within this group will also be permanently deleted.');">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="delete">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } else { %>
|
||||||
|
<p>No secret groups found. Create one above or via API.</p> <!-- Placeholder for create form -->
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;">
|
||||||
|
|
||||||
|
<h2>Existing Secrets</h2>
|
||||||
|
<% if (secrets && secrets.length > 0) { %>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Value (Preview)</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% secrets.forEach(secret => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= secret.key %></td>
|
||||||
|
<td>
|
||||||
|
<%
|
||||||
|
let preview = typeof secret.value === 'string' ? secret.value : JSON.stringify(secret.value);
|
||||||
|
if (preview.length > 50) preview = preview.substring(0, 50) + '...';
|
||||||
|
%>
|
||||||
|
<%= preview %>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="/admin/edit-secret/<%= encodeURIComponent(secret.key) %>">Edit</a>
|
||||||
|
<form action="/admin/delete-secret/<%= encodeURIComponent(secret.key) %>" method="POST" class="form-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="delete">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } else { %>
|
||||||
|
<p>No secrets stored yet.</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<%# Password param and client-side script for it are no longer needed %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
140
views/clients.ejs
Normal file
140
views/clients.ejs
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Admin - Client Management</title>
|
||||||
|
<link rel="stylesheet" href="/css/admin_styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Client Application Management</h1>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/admin">Manage Secrets</a>
|
||||||
|
<a href="/admin/clients">Manage Clients</a>
|
||||||
|
<a href="/admin/logout" class="logout-link">Logout</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (message && message.text) { %>
|
||||||
|
<div class="alert <%= message.type === 'success' ? 'alert-success' : 'alert-error' %>">
|
||||||
|
<%= message.text %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (!managingClientGroups) { %>
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3>WebSocket Settings (Debug)</h3>
|
||||||
|
<form action="/admin/settings/toggle-auto-approve-ws" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<label for="autoApproveWsToggle" class="form-group">
|
||||||
|
<input type="checkbox" id="autoApproveWsToggle" name="autoApproveWs" <%= autoApproveWsEnabled ? 'checked' : '' %>>
|
||||||
|
Automatically Approve New WebSocket Registrations
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn ml-2">Update Setting</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (managingClientGroups) { %>
|
||||||
|
<h2>Manage Associated Groups for Client: <span class="mono"><%= managingClientGroups.client.name %> (<%= managingClientGroups.client.id %>)</span></h2>
|
||||||
|
<form action="/admin/clients/<%= managingClientGroups.client.id %>/groups/update" method="POST">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<div class="form-group checkbox-list">
|
||||||
|
<label>Available Groups (select to associate):</label>
|
||||||
|
<% if (managingClientGroups.allGroups && managingClientGroups.allGroups.length > 0) { %>
|
||||||
|
<% managingClientGroups.allGroups.forEach(group => { %>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox"
|
||||||
|
id="group_<%= group.id %>"
|
||||||
|
name="associatedGroupIds"
|
||||||
|
value="<%= group.id %>"
|
||||||
|
<%= managingClientGroups.client.associatedGroupIds.includes(group.id) ? 'checked' : '' %>>
|
||||||
|
<label for="group_<%= group.id %>"><%= group.name %> (ID: <%= group.id %>)</label>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<p>No groups available to associate. Create groups in the main admin panel first.</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Update Associated Groups</button>
|
||||||
|
<a href="/admin/clients" class="btn btn-secondary">Back to Client List</a>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<h2>Pending Client Registrations</h2>
|
||||||
|
<% if (pendingClients && pendingClients.length > 0) { %>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Client ID / Temp ID</th>
|
||||||
|
<th>Requested Secrets (Legacy)</th>
|
||||||
|
<th>Date Registered</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% pendingClients.forEach(client => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= client.name %></td>
|
||||||
|
<td class="mono"><%= client.id %></td>
|
||||||
|
<td><%= client.requestedSecretKeys && client.requestedSecretKeys.length > 0 ? client.requestedSecretKeys.join(', ') : 'None' %></td>
|
||||||
|
<td><%= new Date(client.dateCreated).toLocaleString() %></td>
|
||||||
|
<td class="actions">
|
||||||
|
<form action="/admin/clients/approve/<%= client.id %>" method="POST" class="form-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="approve">Approve</button>
|
||||||
|
</form>
|
||||||
|
<form action="/admin/clients/reject/<%= client.id %>" method="POST" class="form-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="reject">Reject</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } else { %>
|
||||||
|
<p>No pending client registrations.</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<h2>Approved Clients</h2>
|
||||||
|
<% if (approvedClients && approvedClients.length > 0) { %>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Client ID</th>
|
||||||
|
<th>Associated Groups</th>
|
||||||
|
<th>Date Approved/Updated</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% approvedClients.forEach(client => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= client.name %></td>
|
||||||
|
<td class="mono"><%= client.id %></td>
|
||||||
|
<td><%= client.associatedGroupNames %></td> <%# Now using associatedGroupNames from server %>
|
||||||
|
<td><%= new Date(client.dateUpdated).toLocaleString() %></td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="/admin/clients/<%= client.id %>/groups">Manage Groups</a>
|
||||||
|
<form action="/admin/clients/revoke/<%= client.id %>" method="POST" class="form-inline">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="delete">Revoke</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } else { %>
|
||||||
|
<p>No approved clients.</p>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%# The script for auto-approve toggle has been removed as it's now a form submission %>
|
||||||
|
<%# The csrfToken is still available globally in the template if other scripts need it, %>
|
||||||
|
<%# passed directly from the route handler. %>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
97
views/group_secrets.ejs
Normal file
97
views/group_secrets.ejs
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Admin - Manage Secrets in Group: <%= group.name %></title>
|
||||||
|
<link rel="stylesheet" href="/css/admin_styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/admin">Back to Main Admin</a>
|
||||||
|
<a href="/admin/clients">Manage Clients</a>
|
||||||
|
<a href="/admin/logout" class="logout-link">Logout</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Manage Secrets in Group: <em><%= group.name %></em> (ID: <%= group.id %>)</h1>
|
||||||
|
|
||||||
|
<% if (message && message.text) { %>
|
||||||
|
<div class="alert <%= message.type === 'success' ? 'alert-success' : 'alert-error' %>">
|
||||||
|
<%= message.text %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%# Placeholder for "Add New Secret to This Group" form - Step 5c %>
|
||||||
|
<h3>Add New Secret to Group "<%= group.name %>"</h3>
|
||||||
|
<form action="/admin/groups/<%= group.id %>/secrets/add" method="POST" class="mb-3">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="secretKey">Secret Key:</label>
|
||||||
|
<input type="text" id="secretKey" name="secretKey" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="secretValue">Secret Value (JSON format recommended):</label>
|
||||||
|
<textarea id="secretValue" name="secretValue" required></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Add Secret to Group</button>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<% if (typeof editingSecretKey !== 'undefined' && editingSecretKey && typeof secretToEdit !== 'undefined') { %>
|
||||||
|
<h3>Edit Secret Value: <em><%= editingSecretKey %></em> in Group <em><%= group.name %></em></h3>
|
||||||
|
<form action="/admin/groups/<%= group.id %>/secrets/<%= encodeURIComponent(editingSecretKey) %>/update" method="POST" class="mb-3">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editSecretKeyDisplay">Secret Key (read-only):</label>
|
||||||
|
<input type="text" id="editSecretKeyDisplay" name="secretKeyDisplay" value="<%= editingSecretKey %>" readonly class="form-control-plaintext">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="editSecretValue">New Value (JSON format recommended):</label>
|
||||||
|
<textarea id="editSecretValue" name="secretValue" required><%= typeof secretToEdit === 'string' ? secretToEdit : JSON.stringify(secretToEdit, null, 2) %></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn">Update Secret Value</button>
|
||||||
|
<a href="/admin/groups/<%= group.id %>/secrets" class="btn btn-secondary ml-2">Cancel Edit</a>
|
||||||
|
</form>
|
||||||
|
<hr>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
|
||||||
|
<h2>Secrets in this Group</h2>
|
||||||
|
<% if (secretsInGroup && secretsInGroup.length > 0) { %>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Key</th>
|
||||||
|
<th>Value (Preview)</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% secretsInGroup.forEach(secret => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= secret.key %></td>
|
||||||
|
<td>
|
||||||
|
<%
|
||||||
|
let preview = typeof secret.value === 'string' ? secret.value : JSON.stringify(secret.value);
|
||||||
|
if (preview.length > 50) preview = preview.substring(0, 50) + '...';
|
||||||
|
%>
|
||||||
|
<%= preview %>
|
||||||
|
</td>
|
||||||
|
<td class="actions">
|
||||||
|
<a href="/admin/groups/<%= group.id %>/secrets/<%= encodeURIComponent(secret.key) %>/edit">Edit Value</a>
|
||||||
|
<form action="/admin/groups/<%= group.id %>/secrets/<%= encodeURIComponent(secret.key) %>/delete" method="POST" class="form-inline" onsubmit="return confirm('Are you sure you want to delete the secret \"<%= secret.key %>\"? This action cannot be undone.');">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="delete">Delete Secret</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } else { %>
|
||||||
|
<p>No secrets currently in this group. You can add one above.</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user