init
This commit is contained in:
468
mc_test/src/renderer/services/CertificatePinning.ts
Executable file
468
mc_test/src/renderer/services/CertificatePinning.ts
Executable file
@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Certificate Pinning Service for MITM attack protection
|
||||
*/
|
||||
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
export interface CertificateInfo {
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
}
|
||||
|
||||
export interface PinnedCertificate {
|
||||
hostname: string;
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
lastVerified: string;
|
||||
}
|
||||
|
||||
interface CertificatePinConfig {
|
||||
pins: Record<string, string[]>;
|
||||
backup_pins: Record<string, string[]>;
|
||||
rotation_schedule: {
|
||||
primary_pin_ttl_days: number;
|
||||
backup_pin_ttl_days: number;
|
||||
next_rotation: string;
|
||||
};
|
||||
fallback_enabled: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface PinCache {
|
||||
config: CertificatePinConfig;
|
||||
lastUpdated: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
const CERTIFICATE_CONSTANTS = {
|
||||
CACHE_TTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
MAX_AGE_DAYS: 365,
|
||||
HTTPS_PROTOCOL: 'https:',
|
||||
} as const;
|
||||
|
||||
const HASH_ALGORITHM = {
|
||||
SHA256: 'sha256',
|
||||
SHA1: 'sha1',
|
||||
} as const;
|
||||
|
||||
const HEX_FORMAT = {
|
||||
RADIX: 16,
|
||||
PAD_LENGTH: 2,
|
||||
PAD_CHAR: '0',
|
||||
} as const;
|
||||
|
||||
export class CertificatePinningService {
|
||||
public static pinCache: PinCache | null = null;
|
||||
public static readonly CACHE_TTL = CERTIFICATE_CONSTANTS.CACHE_TTL;
|
||||
|
||||
/**
|
||||
* INSTRUCTIONS FOR OBTAINING CERTIFICATE FINGERPRINT:
|
||||
*
|
||||
* 1. To get SHA-256 fingerprint of certificate, run:
|
||||
* openssl s_client -connect DOMAIN:443 -servername DOMAIN | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
|
||||
*
|
||||
* 2. Alternative method via curl:
|
||||
* curl -sI https://DOMAIN | openssl s_client -connect DOMAIN:443 -servername DOMAIN | openssl x509 -fingerprint -sha256 -noout
|
||||
*
|
||||
* 3. For browser, use Developer Tools > Security > Certificate Details
|
||||
*
|
||||
* IMPORTANT: Replace PLACEHOLDER values with real fingerprints before production deployment!
|
||||
*/
|
||||
private static readonly PINNED_CERTIFICATES: PinnedCertificate[] = [
|
||||
{
|
||||
hostname: import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com',
|
||||
fingerprint: 'PLACEHOLDER_REAL_CERTIFICATE_FINGERPRINT_HERE',
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
hostname: import.meta.env.VITE_BACKUP_DOMAIN || 'backup.mc.exbytestudios.com',
|
||||
fingerprint: 'PLACEHOLDER_BACKUP_CERTIFICATE_FINGERPRINT_HERE',
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
private static readonly MAX_CERTIFICATE_AGE_DAYS = CERTIFICATE_CONSTANTS.MAX_AGE_DAYS;
|
||||
|
||||
/**
|
||||
* Verify certificate for host
|
||||
*/
|
||||
static async verifyCertificate(hostname: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && (window as any).electronAPI) {
|
||||
const certificate = await (window as any).electronAPI.getCertificate(hostname);
|
||||
return this.validateCertificate(hostname, certificate);
|
||||
}
|
||||
|
||||
return this.validateCertificateBrowser(hostname);
|
||||
} catch (error) {
|
||||
console.error('Certificate verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate certificate against pinned certificates
|
||||
*/
|
||||
private static validateCertificate(hostname: string, certificate: CertificateInfo): boolean {
|
||||
const pinnedCert = this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname);
|
||||
|
||||
if (!pinnedCert) {
|
||||
console.warn(`No pinned certificate found for hostname: ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (certificate.fingerprint.toLowerCase() !== pinnedCert.fingerprint.toLowerCase()) {
|
||||
console.error(`Certificate fingerprint mismatch for ${hostname}:`, {
|
||||
expected: pinnedCert.fingerprint,
|
||||
actual: certificate.fingerprint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (certificate.algorithm !== pinnedCert.algorithm) {
|
||||
console.error(`Certificate algorithm mismatch for ${hostname}:`, {
|
||||
expected: pinnedCert.algorithm,
|
||||
actual: certificate.algorithm,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validTo = new Date(certificate.validTo);
|
||||
const now = new Date();
|
||||
|
||||
if (validTo < now) {
|
||||
console.error(`Certificate expired for ${hostname}:`, {
|
||||
validTo: certificate.validTo,
|
||||
now: now.toISOString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validFrom = new Date(certificate.validFrom);
|
||||
const ageInDays = (now.getTime() - validFrom.getTime()) / MILLISECONDS_PER_DAY;
|
||||
|
||||
if (ageInDays > this.MAX_CERTIFICATE_AGE_DAYS) {
|
||||
console.warn(`Certificate is too old for ${hostname}:`, {
|
||||
ageInDays,
|
||||
maxAge: this.MAX_CERTIFICATE_AGE_DAYS,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Certificate validation passed for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation for browser environment (limited)
|
||||
*/
|
||||
private static validateCertificateBrowser(hostname: string): boolean {
|
||||
if (window.location.protocol !== CERTIFICATE_CONSTANTS.HTTPS_PROTOCOL) {
|
||||
console.error('Non-HTTPS connection detected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedHostname = this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname);
|
||||
if (!expectedHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`HTTPS validation passed for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned certificate information for host
|
||||
*/
|
||||
static getPinnedCertificate(hostname: string): PinnedCertificate | null {
|
||||
return this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new pinned certificate
|
||||
*/
|
||||
static addPinnedCertificate(certificate: PinnedCertificate): void {
|
||||
const existingIndex = this.PINNED_CERTIFICATES.findIndex(
|
||||
(cert) => cert.hostname === certificate.hostname
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.PINNED_CERTIFICATES[existingIndex] = certificate;
|
||||
} else {
|
||||
this.PINNED_CERTIFICATES.push(certificate);
|
||||
}
|
||||
|
||||
console.log(`Added pinned certificate for ${certificate.hostname}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove pinned certificate
|
||||
*/
|
||||
static removePinnedCertificate(hostname: string): boolean {
|
||||
const index = this.PINNED_CERTIFICATES.findIndex((cert) => cert.hostname === hostname);
|
||||
|
||||
if (index >= 0) {
|
||||
this.PINNED_CERTIFICATES.splice(index, 1);
|
||||
console.log(`Removed pinned certificate for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pinned certificates
|
||||
*/
|
||||
static getAllPinnedCertificates(): PinnedCertificate[] {
|
||||
return [...this.PINNED_CERTIFICATES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pinned certificate exists for host
|
||||
*/
|
||||
static hasPinnedCertificate(hostname: string): boolean {
|
||||
return this.PINNED_CERTIFICATES.some((cert) => cert.hostname === hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update certificate fingerprint
|
||||
*/
|
||||
static updateCertificateFingerprint(hostname: string, newFingerprint: string): boolean {
|
||||
const certificate = this.getPinnedCertificate(hostname);
|
||||
|
||||
if (certificate) {
|
||||
certificate.fingerprint = newFingerprint;
|
||||
certificate.lastVerified = new Date().toISOString();
|
||||
console.log(`Updated certificate fingerprint for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all pinned certificates
|
||||
*/
|
||||
static async validateAllCertificates(): Promise<Record<string, boolean>> {
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
for (const certificate of this.PINNED_CERTIFICATES) {
|
||||
try {
|
||||
results[certificate.hostname] = await this.verifyCertificate(certificate.hostname);
|
||||
} catch (error) {
|
||||
console.error(`Failed to validate certificate for ${certificate.hostname}:`, error);
|
||||
results[certificate.hostname] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned certificates statistics
|
||||
*/
|
||||
static getStatistics(): {
|
||||
totalCertificates: number;
|
||||
certificatesByAlgorithm: Record<string, number>;
|
||||
oldestCertificate: PinnedCertificate | null;
|
||||
newestCertificate: PinnedCertificate | null;
|
||||
} {
|
||||
const certificates = this.PINNED_CERTIFICATES;
|
||||
|
||||
const certificatesByAlgorithm = certificates.reduce(
|
||||
(acc, cert) => {
|
||||
acc[cert.algorithm] = (acc[cert.algorithm] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const sortedByDate = [...certificates].sort(
|
||||
(a, b) => new Date(a.lastVerified).getTime() - new Date(b.lastVerified).getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
totalCertificates: certificates.length,
|
||||
certificatesByAlgorithm,
|
||||
oldestCertificate: sortedByDate[0] || null,
|
||||
newestCertificate: sortedByDate[sortedByDate.length - 1] || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const FINGERPRINT_PATTERNS = {
|
||||
SHA256: /^[a-f0-9]{64}$/,
|
||||
SHA1: /^[a-f0-9]{40}$/,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Certificate utilities
|
||||
*/
|
||||
export class CertificateUtils {
|
||||
/**
|
||||
* Compute SHA-256 fingerprint of certificate
|
||||
*/
|
||||
static async computeSHA256Fingerprint(certificateData: ArrayBuffer): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', certificateData);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray
|
||||
.map((b) => b.toString(HEX_FORMAT.RADIX).padStart(HEX_FORMAT.PAD_LENGTH, HEX_FORMAT.PAD_CHAR))
|
||||
.join(':')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-1 fingerprint of certificate
|
||||
*/
|
||||
static async computeSHA1Fingerprint(certificateData: ArrayBuffer): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-1', certificateData);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray
|
||||
.map((b) => b.toString(HEX_FORMAT.RADIX).padStart(HEX_FORMAT.PAD_LENGTH, HEX_FORMAT.PAD_CHAR))
|
||||
.join(':')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fingerprint for display
|
||||
*/
|
||||
static formatFingerprint(fingerprint: string): string {
|
||||
return fingerprint
|
||||
.toUpperCase()
|
||||
.replace(/(.{2})/g, '$1:')
|
||||
.slice(0, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate fingerprint format
|
||||
*/
|
||||
static isValidFingerprint(fingerprint: string): boolean {
|
||||
const cleanFingerprint = fingerprint.replace(/:/g, '').toLowerCase();
|
||||
return (
|
||||
FINGERPRINT_PATTERNS.SHA1.test(cleanFingerprint) ||
|
||||
FINGERPRINT_PATTERNS.SHA256.test(cleanFingerprint)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current certificate pins from server
|
||||
*/
|
||||
async loadDynamicPins(baseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}${API_CONFIG.ENDPOINTS.SECURITY.CERTIFICATE_PINS}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load certificate pins: ${response.status}`);
|
||||
}
|
||||
|
||||
const config: CertificatePinConfig = await response.json();
|
||||
|
||||
CertificatePinningService.pinCache = {
|
||||
config,
|
||||
lastUpdated: Date.now(),
|
||||
ttl: CertificatePinningService.CACHE_TTL,
|
||||
};
|
||||
|
||||
this.updateStaticPins(config);
|
||||
|
||||
console.log('Certificate pins loaded successfully', {
|
||||
version: config.version,
|
||||
hosts: Object.keys(config.pins),
|
||||
fallbackEnabled: config.fallback_enabled,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load dynamic certificate pins:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update static pins from configuration
|
||||
*/
|
||||
public updateStaticPins(config: CertificatePinConfig): void {
|
||||
const newPinnedCerts: PinnedCertificate[] = [];
|
||||
|
||||
for (const [hostname, pins] of Object.entries(config.pins)) {
|
||||
for (const pin of pins) {
|
||||
newPinnedCerts.push({
|
||||
hostname,
|
||||
fingerprint: pin,
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.fallback_enabled) {
|
||||
for (const [hostname, pins] of Object.entries(config.backup_pins)) {
|
||||
for (const pin of pins) {
|
||||
newPinnedCerts.push({
|
||||
hostname,
|
||||
fingerprint: pin,
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(CertificatePinningService as any).PINNED_CERTIFICATES = newPinnedCerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pins cache is valid
|
||||
*/
|
||||
isPinCacheValid(): boolean {
|
||||
if (!CertificatePinningService.pinCache) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return (
|
||||
now - CertificatePinningService.pinCache.lastUpdated <
|
||||
CertificatePinningService.pinCache.ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pins cache information
|
||||
*/
|
||||
static getPinCacheInfo(): { cached: boolean; lastUpdated?: string; ttl?: number } {
|
||||
if (!CertificatePinningService.pinCache) {
|
||||
return { cached: false };
|
||||
}
|
||||
|
||||
return {
|
||||
cached: true,
|
||||
lastUpdated: new Date(CertificatePinningService.pinCache.lastUpdated).toISOString(),
|
||||
ttl: CertificatePinningService.pinCache.ttl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pins cache
|
||||
*/
|
||||
static clearPinCache(): void {
|
||||
CertificatePinningService.pinCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const certificatePinning = new CertificatePinningService();
|
||||
export const certificateUtils = CertificateUtils;
|
||||
261
mc_test/src/renderer/services/EncryptionService.ts
Executable file
261
mc_test/src/renderer/services/EncryptionService.ts
Executable file
@ -0,0 +1,261 @@
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Client-side password encryption service
|
||||
* Uses Web Crypto API with AES-256-GCM algorithm
|
||||
*/
|
||||
class EncryptionService {
|
||||
private encryptionKey: CryptoKey | null = null;
|
||||
|
||||
/**
|
||||
* Initialize encryption key
|
||||
* @param keyBase64 Base64 encoded key from server
|
||||
*/
|
||||
async initializeKey(keyBase64: string): Promise<void> {
|
||||
try {
|
||||
// this._keyBase64 = keyBase64;
|
||||
|
||||
// Decode Base64 key
|
||||
const keyBuffer = this.base64ToArrayBuffer(keyBase64);
|
||||
|
||||
// Import key for use with Web Crypto API
|
||||
this.encryptionKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false, // not exportable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
log.info('encryption-service', 'Encryption key initialized successfully');
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Failed to initialize encryption key', { error });
|
||||
throw new Error('Failed to initialize encryption key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt password using AES-GCM and AEAD
|
||||
* @param password Password in plain text
|
||||
* @param sessionId Session ID for AAD
|
||||
* @returns Encrypted password in Base64
|
||||
*/
|
||||
async encryptPassword(password: string, sessionId?: string): Promise<{encrypted: string, nonce: string, timestamp: number}> {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error('Encryption key not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// CRITICAL: Generate unique IV (12 bytes for GCM)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Convert password to ArrayBuffer
|
||||
const passwordBuffer = new TextEncoder().encode(password);
|
||||
|
||||
// AAD used ONLY for saved machines (when sessionId exists)
|
||||
// For temporary connection passwords AAD is NOT needed
|
||||
const encryptParams: AesGcmParams = {
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
};
|
||||
|
||||
let timestamp = 0;
|
||||
let clientNonce: Uint8Array | undefined;
|
||||
|
||||
// If sessionId provided - use AAD for additional security
|
||||
if (sessionId) {
|
||||
timestamp = Date.now();
|
||||
clientNonce = crypto.getRandomValues(new Uint8Array(16));
|
||||
const aadData = {
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
clientNonce: Array.from(clientNonce)
|
||||
};
|
||||
encryptParams.additionalData = new TextEncoder().encode(JSON.stringify(aadData));
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||
encryptParams,
|
||||
this.encryptionKey,
|
||||
passwordBuffer
|
||||
);
|
||||
|
||||
// Combine IV + encrypted data
|
||||
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(encryptedBuffer), iv.length);
|
||||
|
||||
// Convert to Base64
|
||||
const encrypted = this.arrayBufferToBase64(combined.buffer);
|
||||
const nonce = clientNonce ? this.arrayBufferToBase64(clientNonce.buffer as ArrayBuffer) : '';
|
||||
|
||||
log.debug('encryption-service', 'Password encrypted successfully', {
|
||||
passwordLength: password.length,
|
||||
encryptedLength: encrypted.length,
|
||||
sessionId: sessionId,
|
||||
usesAAD: !!sessionId
|
||||
});
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
nonce,
|
||||
timestamp
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Failed to encrypt password', { error });
|
||||
throw new Error('Password encryption failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt password (for testing)
|
||||
* @param encryptedPassword Base64 encrypted string
|
||||
* @param sessionId Session ID for AAD (optional - for testing)
|
||||
* @param nonce Client nonce for AAD (optional - for testing)
|
||||
* @param timestamp Timestamp for AAD (optional - for testing)
|
||||
* @returns Password in plain text
|
||||
*/
|
||||
async decryptPassword(
|
||||
encryptedPassword: string,
|
||||
sessionId?: string,
|
||||
nonce?: string,
|
||||
timestamp?: number
|
||||
): Promise<string> {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error('Encryption key not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode Base64
|
||||
const combined = this.base64ToArrayBuffer(encryptedPassword);
|
||||
|
||||
// Extract IV (first 12 bytes) and encrypted data
|
||||
const iv = combined.slice(0, 12);
|
||||
const encryptedData = combined.slice(12);
|
||||
|
||||
// FIXED: Create AAD if parameters provided
|
||||
let additionalData: Uint8Array | undefined = undefined;
|
||||
if (sessionId && nonce && timestamp) {
|
||||
const clientNonceArray = new Uint8Array(this.base64ToArrayBuffer(nonce));
|
||||
const aadData = {
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
clientNonce: Array.from(clientNonceArray)
|
||||
};
|
||||
additionalData = new TextEncoder().encode(JSON.stringify(aadData));
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv,
|
||||
...(additionalData && { additionalData }) // Add AAD if exists
|
||||
},
|
||||
this.encryptionKey,
|
||||
encryptedData
|
||||
);
|
||||
|
||||
// Convert back to string
|
||||
const password = new TextDecoder().decode(decryptedBuffer);
|
||||
|
||||
log.debug('encryption-service', 'Password decrypted successfully');
|
||||
|
||||
return password;
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Failed to decrypt password', { error });
|
||||
throw new Error('Password decryption failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption key is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.encryptionKey !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current encryption status
|
||||
*/
|
||||
getStatus(): {
|
||||
initialized: boolean;
|
||||
} {
|
||||
return {
|
||||
initialized: this.isInitialized(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear encryption key
|
||||
*/
|
||||
clearKey(): void {
|
||||
this.encryptionKey = null;
|
||||
log.info('encryption-service', 'Encryption key cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Base64 string to ArrayBuffer
|
||||
*/
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to Base64 string
|
||||
*/
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encryption/decryption
|
||||
*/
|
||||
async testEncryption(testPassword: string = 'test123', sessionId: string = 'test-session'): Promise<boolean> {
|
||||
try {
|
||||
if (!this.isInitialized()) {
|
||||
throw new Error('Encryption key not initialized');
|
||||
}
|
||||
|
||||
// Encrypt test password
|
||||
const encryptionResult = await this.encryptPassword(testPassword, sessionId);
|
||||
|
||||
// FIXED: Decrypt with same AAD parameters
|
||||
const decrypted = await this.decryptPassword(
|
||||
encryptionResult.encrypted,
|
||||
sessionId,
|
||||
encryptionResult.nonce,
|
||||
encryptionResult.timestamp
|
||||
);
|
||||
|
||||
// Check that we got the same password
|
||||
const success = decrypted === testPassword;
|
||||
|
||||
log.info('encryption-service', 'Encryption test completed', {
|
||||
success,
|
||||
testPasswordLength: testPassword.length,
|
||||
encryptedLength: encryptionResult.encrypted.length
|
||||
});
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Encryption test failed', { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const encryptionService = new EncryptionService();
|
||||
export default encryptionService;
|
||||
160
mc_test/src/renderer/services/SignatureVerificationService.ts
Executable file
160
mc_test/src/renderer/services/SignatureVerificationService.ts
Executable file
@ -0,0 +1,160 @@
|
||||
import { log } from '../utils/logger';
|
||||
import * as nacl from 'tweetnacl';
|
||||
|
||||
const ED25519_PUBLIC_KEY_SIZE = 32;
|
||||
const KEY_PREVIEW_LENGTH = 50;
|
||||
|
||||
const PEM_HEADER = {
|
||||
BEGIN: '-----BEGIN PUBLIC KEY-----',
|
||||
END: '-----END PUBLIC KEY-----',
|
||||
} as const;
|
||||
|
||||
type Environment = 'production' | 'staging' | 'development';
|
||||
|
||||
/**
|
||||
* Service for verifying Ed25519 signatures of server keys
|
||||
* Uses TweetNaCl for cross-browser compatibility
|
||||
*/
|
||||
export class SignatureVerificationService {
|
||||
private static readonly TRUSTED_SIGNING_KEYS: Record<Environment, string> = {
|
||||
production:
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
|
||||
staging:
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
|
||||
development:
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify Ed25519 signature of server public key using TweetNaCl
|
||||
* @param serverPublicKeyPem PEM-encoded server public key
|
||||
* @param signatureBase64 Base64-encoded signature
|
||||
* @param environment Environment (production, staging, development)
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
static async verifyServerKeySignature(
|
||||
serverPublicKeyPem: string,
|
||||
signatureBase64: string,
|
||||
environment: Environment = 'production'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
log.info('signature-verification', 'Starting server key signature verification (TweetNaCl)', {
|
||||
environment,
|
||||
serverKeyLength: serverPublicKeyPem.length,
|
||||
signatureLength: signatureBase64.length,
|
||||
});
|
||||
|
||||
const trustedSigningKeyPem = this.TRUSTED_SIGNING_KEYS[environment];
|
||||
if (!trustedSigningKeyPem) {
|
||||
log.error('signature-verification', 'No trusted signing key found for environment', {
|
||||
environment,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const trustedSigningKey = this.extractEd25519PublicKeyFromSPKI(trustedSigningKeyPem);
|
||||
const signature = this.base64ToUint8Array(signatureBase64);
|
||||
|
||||
// CRITICAL: Server sends base64(PEM), need to decode
|
||||
const serverPublicKeyPemDecoded = atob(serverPublicKeyPem);
|
||||
|
||||
// IMPORTANT: Server signs PEM bytes (with headers)
|
||||
// So we use the same PEM bytes for verification (not DER)
|
||||
const serverPublicKeyBytes = new TextEncoder().encode(serverPublicKeyPemDecoded);
|
||||
|
||||
log.debug('signature-verification', 'Verification data prepared', {
|
||||
trustedKeyLength: trustedSigningKey.length,
|
||||
signatureLength: signature.length,
|
||||
messageLength: serverPublicKeyBytes.length,
|
||||
});
|
||||
|
||||
const isValid = nacl.sign.detached.verify(
|
||||
serverPublicKeyBytes,
|
||||
signature,
|
||||
trustedSigningKey
|
||||
);
|
||||
|
||||
if (isValid) {
|
||||
log.info('signature-verification', 'Server key signature verification successful');
|
||||
} else {
|
||||
log.warn('signature-verification', 'Server key signature verification failed');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
log.error('signature-verification', 'Server key signature verification error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
environment,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract raw Ed25519 public key (32 bytes) from base64(PEM) SPKI format
|
||||
*
|
||||
* SPKI format for Ed25519:
|
||||
* - Total size: ~44 bytes
|
||||
* - Last 32 bytes: raw Ed25519 public key
|
||||
*
|
||||
* @param pemKeyBase64 Base64-encoded PEM string
|
||||
* @returns Uint8Array (32 bytes) - raw Ed25519 public key
|
||||
*/
|
||||
private static extractEd25519PublicKeyFromSPKI(pemKeyBase64: string): Uint8Array {
|
||||
try {
|
||||
const pemString = atob(pemKeyBase64);
|
||||
|
||||
log.debug('signature-verification', 'Decoded PEM from base64', {
|
||||
pemLength: pemString.length,
|
||||
hasPemHeaders: pemString.includes(PEM_HEADER.BEGIN),
|
||||
});
|
||||
|
||||
const pemContent = pemString
|
||||
.replace(new RegExp(PEM_HEADER.BEGIN, 'g'), '')
|
||||
.replace(new RegExp(PEM_HEADER.END, 'g'), '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
const spkiBytes = this.base64ToUint8Array(pemContent);
|
||||
|
||||
log.debug('signature-verification', 'Extracted SPKI bytes', {
|
||||
spkiLength: spkiBytes.length,
|
||||
});
|
||||
|
||||
// SPKI contains headers (~12 bytes) + raw key (32 bytes)
|
||||
if (spkiBytes.length < ED25519_PUBLIC_KEY_SIZE) {
|
||||
throw new Error(
|
||||
`SPKI too short: ${spkiBytes.length} bytes (expected >=${ED25519_PUBLIC_KEY_SIZE})`
|
||||
);
|
||||
}
|
||||
|
||||
const rawPublicKey = spkiBytes.slice(spkiBytes.length - ED25519_PUBLIC_KEY_SIZE);
|
||||
|
||||
log.info('signature-verification', 'Ed25519 raw public key extracted', {
|
||||
rawKeyLength: rawPublicKey.length,
|
||||
});
|
||||
|
||||
return rawPublicKey;
|
||||
} catch (error) {
|
||||
log.error('signature-verification', 'Failed to extract Ed25519 public key from SPKI', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
keyLength: pemKeyBase64.length,
|
||||
keyPreview: pemKeyBase64.substring(0, KEY_PREVIEW_LENGTH) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 to Uint8Array
|
||||
*/
|
||||
private static base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
export const signatureVerification = new SignatureVerificationService();
|
||||
45
mc_test/src/renderer/services/api/axios-config.ts
Executable file
45
mc_test/src/renderer/services/api/axios-config.ts
Executable file
@ -0,0 +1,45 @@
|
||||
import axios from 'axios';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_GUACAMOLE_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
log.info('guacamole-api', `Request: ${config.method?.toUpperCase()} ${config.url}`, {
|
||||
headers: config.headers,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
});
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
log.error('guacamole-api', 'Request error:', { error: error.message });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
log.info('guacamole-api', `Response: ${response.status} ${response.config.url}`, {
|
||||
data: response.data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
log.error('guacamole-api', 'Response error:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
377
mc_test/src/renderer/services/auth-service.ts
Executable file
377
mc_test/src/renderer/services/auth-service.ts
Executable file
@ -0,0 +1,377 @@
|
||||
import { log } from '../utils/logger';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import { ApiClient } from '../utils/apiClient';
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user_info: {
|
||||
username: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface StoredAuth {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
userInfo: LoginResponse['user_info'];
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
private static readonly AUTH_KEY = 'mcc_auth';
|
||||
private tokenCheckInterval: NodeJS.Timeout | null = null;
|
||||
private onTokenExpiredCallback: (() => void) | null = null;
|
||||
private onTokenWarningCallback: ((timeLeft: number) => void) | null = null;
|
||||
|
||||
// Cache for synchronous access
|
||||
private currentToken: string | null = null;
|
||||
private currentUser: StoredAuth['userInfo'] | null = null;
|
||||
|
||||
constructor() {
|
||||
// Restore auth on startup
|
||||
this.initializeAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth on application startup
|
||||
* Restores token and user from safeStorage
|
||||
*/
|
||||
private async initializeAuth() {
|
||||
try {
|
||||
const stored = await this.getStoredAuth();
|
||||
if (stored && Date.now() < stored.expiresAt) {
|
||||
// CRITICAL CHECK: JWT must start with "eyJ" (base64-encoded JSON header)
|
||||
// If token is invalid (encrypted blob from old version), clear storage
|
||||
if (!stored.token || !stored.token.startsWith('eyJ')) {
|
||||
log.error('auth-service', 'Invalid token format in storage, clearing auth', {
|
||||
tokenPrefix: stored.token?.substring(0, 10) || 'empty',
|
||||
tokenLength: stored.token?.length || 0
|
||||
});
|
||||
this.clearAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentToken = stored.token;
|
||||
this.currentUser = stored.userInfo;
|
||||
|
||||
// Start token monitoring
|
||||
this.startTokenMonitoring();
|
||||
|
||||
log.info('auth-service', 'Auth restored from storage', {
|
||||
username: stored.userInfo.username,
|
||||
tokenPrefix: stored.token.substring(0, 20) + '...'
|
||||
});
|
||||
} else {
|
||||
log.debug('auth-service', 'No valid stored auth found');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Failed to initialize auth', { error });
|
||||
this.clearAuth();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User authentication
|
||||
*/
|
||||
async login(username: string, password: string): Promise<LoginResponse> {
|
||||
try {
|
||||
log.info('auth-service', 'Starting login', { username });
|
||||
|
||||
this.clearAuth();
|
||||
log.info('auth-service', 'Previous session cleared before new login');
|
||||
|
||||
const data: LoginResponse = await ApiClient.post(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`,
|
||||
{ username, password },
|
||||
{
|
||||
timeout: 10000,
|
||||
retryConfig: { maxRetries: 2 }
|
||||
}
|
||||
);
|
||||
|
||||
await this.saveAuth(data);
|
||||
this.startTokenMonitoring();
|
||||
|
||||
log.info('auth-service', 'Login successful', {
|
||||
username: data.user_info.username,
|
||||
role: data.user_info.role
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Login failed', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
// Use cached token
|
||||
if (this.currentToken) {
|
||||
// Use new endpoint for token revocation
|
||||
await ApiClient.post(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REVOKE}`,
|
||||
{},
|
||||
ApiClient.withAuth(this.currentToken, {
|
||||
timeout: 5000, // Short timeout for logout
|
||||
retryConfig: { maxRetries: 1 } // Minimum retry for logout
|
||||
})
|
||||
).catch(() => {
|
||||
// Ignore errors on logout
|
||||
});
|
||||
|
||||
log.info('auth-service', 'Token revoked on server');
|
||||
}
|
||||
} finally {
|
||||
// Always clear local storage
|
||||
this.clearAuth();
|
||||
this.stopTokenMonitoring();
|
||||
|
||||
log.info('auth-service', 'Logged out');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token
|
||||
* FIXED: async for safeStorage support
|
||||
*/
|
||||
async getToken(): Promise<string | null> {
|
||||
const stored = await this.getStoredAuth();
|
||||
if (!stored) return null;
|
||||
|
||||
// Check if token expired
|
||||
if (Date.now() >= stored.expiresAt) {
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info (SYNCHRONOUS)
|
||||
* Used for UI - fast check without async
|
||||
*/
|
||||
getCurrentUser(): StoredAuth['userInfo'] | null {
|
||||
// Check memory cache
|
||||
if (this.currentUser) {
|
||||
return this.currentUser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated (SYNCHRONOUS)
|
||||
* Used for UI - fast check without async
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.currentUser !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers for authenticated requests (SYNCHRONOUS)
|
||||
* Used for API requests
|
||||
*/
|
||||
getAuthHeaders(): Record<string, string> {
|
||||
if (!this.currentToken) {
|
||||
log.warn('auth-service', 'No token available for auth headers');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Check that token is valid before sending
|
||||
if (!this.currentToken.startsWith('eyJ')) {
|
||||
log.error('auth-service', 'Current token is invalid (not a JWT), clearing auth', {
|
||||
tokenPrefix: this.currentToken.substring(0, 10),
|
||||
tokenLength: this.currentToken.length
|
||||
});
|
||||
this.clearAuth();
|
||||
return {};
|
||||
}
|
||||
|
||||
log.debug('auth-service', 'Providing auth headers', {
|
||||
tokenPrefix: this.currentToken.substring(0, 30) + '...'
|
||||
});
|
||||
|
||||
return {
|
||||
'Authorization': `Bearer ${this.currentToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save authentication data
|
||||
* FIXED: Uses Electron safeStorage for XSS protection
|
||||
*/
|
||||
private async saveAuth(loginResponse: LoginResponse): Promise<void> {
|
||||
// Validate JWT token before saving
|
||||
if (!loginResponse.access_token || !loginResponse.access_token.startsWith('eyJ')) {
|
||||
log.error('auth-service', 'Received invalid JWT token from API', {
|
||||
tokenPrefix: loginResponse.access_token?.substring(0, 10) || 'empty'
|
||||
});
|
||||
throw new Error('Invalid JWT token received from server');
|
||||
}
|
||||
|
||||
const auth: StoredAuth = {
|
||||
token: loginResponse.access_token,
|
||||
expiresAt: Date.now() + (loginResponse.expires_in * 1000),
|
||||
userInfo: loginResponse.user_info,
|
||||
};
|
||||
|
||||
try {
|
||||
// Check Electron API availability (for dev vs prod)
|
||||
const hasElectronAPI = typeof window !== 'undefined' &&
|
||||
window.electronAPI !== undefined &&
|
||||
typeof window.electronAPI.isEncryptionAvailable === 'function';
|
||||
|
||||
if (hasElectronAPI) {
|
||||
// Check safeStorage availability
|
||||
const isAvailable = await window.electronAPI.isEncryptionAvailable();
|
||||
|
||||
if (isAvailable) {
|
||||
// SECURED: Use Electron safeStorage
|
||||
const authJson = JSON.stringify(auth);
|
||||
const encrypted = await window.electronAPI.encryptData(authJson);
|
||||
localStorage.setItem(AuthService.AUTH_KEY, encrypted);
|
||||
log.info('auth-service', 'Auth data saved securely with safeStorage');
|
||||
} else {
|
||||
// Fallback for platforms without safeStorage
|
||||
log.warn('auth-service', 'safeStorage not available, using localStorage (less secure)');
|
||||
localStorage.setItem(AuthService.AUTH_KEY, JSON.stringify(auth));
|
||||
}
|
||||
} else {
|
||||
// Dev mode: no Electron API, use regular localStorage
|
||||
log.info('auth-service', 'Running in dev mode, using localStorage');
|
||||
localStorage.setItem(AuthService.AUTH_KEY, JSON.stringify(auth));
|
||||
}
|
||||
|
||||
// Update cache for synchronous access
|
||||
this.currentToken = auth.token;
|
||||
this.currentUser = auth.userInfo;
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Failed to save auth data', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication data
|
||||
* FIXED: Decrypts data from safeStorage
|
||||
*/
|
||||
private async getStoredAuth(): Promise<StoredAuth | null> {
|
||||
try {
|
||||
const stored = localStorage.getItem(AuthService.AUTH_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
// Check Electron API availability
|
||||
const hasElectronAPI = typeof window !== 'undefined' &&
|
||||
window.electronAPI !== undefined &&
|
||||
typeof window.electronAPI.decryptData === 'function';
|
||||
|
||||
// FIXED: Check if data is encrypted by first character
|
||||
// Plain JSON always starts with '{', encrypted data does not
|
||||
const isPlainJSON = stored.trim().startsWith('{');
|
||||
|
||||
if (!isPlainJSON && hasElectronAPI) {
|
||||
try {
|
||||
// Data is encrypted - decrypt via safeStorage
|
||||
log.debug('auth-service', 'Decrypting stored auth with safeStorage');
|
||||
const decrypted = await window.electronAPI.decryptData(stored);
|
||||
return JSON.parse(decrypted);
|
||||
} catch (decryptError) {
|
||||
// Possibly corrupted data
|
||||
log.error('auth-service', 'Failed to decrypt stored auth, clearing storage', { decryptError });
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Unencrypted data (dev mode, old format or fallback)
|
||||
log.debug('auth-service', 'Loading plain JSON auth from storage');
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Failed to parse stored auth', { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication data
|
||||
*/
|
||||
private clearAuth(): void {
|
||||
localStorage.removeItem(AuthService.AUTH_KEY);
|
||||
|
||||
// Clear cache
|
||||
this.currentToken = null;
|
||||
this.currentUser = null;
|
||||
|
||||
log.info('auth-service', 'Auth data cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for token expiration handling
|
||||
*/
|
||||
setOnTokenExpired(callback: () => void): void {
|
||||
this.onTokenExpiredCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for token expiration warning
|
||||
*/
|
||||
setOnTokenWarning(callback: (timeLeft: number) => void): void {
|
||||
this.onTokenWarningCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start token monitoring
|
||||
*/
|
||||
private startTokenMonitoring(): void {
|
||||
this.stopTokenMonitoring();
|
||||
|
||||
// FIXED: Check token every 30 seconds with async getStoredAuth support
|
||||
this.tokenCheckInterval = setInterval(async () => {
|
||||
const stored = await this.getStoredAuth();
|
||||
if (!stored) return;
|
||||
|
||||
// If token expires within next 5 minutes, notify
|
||||
const timeUntilExpiry = stored.expiresAt - Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
if (timeUntilExpiry <= fiveMinutes) {
|
||||
log.warn('auth-service', 'Token expires soon', {
|
||||
timeUntilExpiry: Math.round(timeUntilExpiry / 1000)
|
||||
});
|
||||
|
||||
if (timeUntilExpiry <= 0) {
|
||||
log.error('auth-service', 'Token expired, logging out');
|
||||
this.clearAuth();
|
||||
this.stopTokenMonitoring();
|
||||
|
||||
if (this.onTokenExpiredCallback) {
|
||||
this.onTokenExpiredCallback();
|
||||
}
|
||||
} else if (this.onTokenWarningCallback) {
|
||||
this.onTokenWarningCallback(Math.round(timeUntilExpiry / 1000));
|
||||
}
|
||||
}
|
||||
}, 30000); // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop token monitoring
|
||||
*/
|
||||
private stopTokenMonitoring(): void {
|
||||
if (this.tokenCheckInterval) {
|
||||
clearInterval(this.tokenCheckInterval);
|
||||
this.tokenCheckInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
export default authService;
|
||||
232
mc_test/src/renderer/services/bulk-operations-api.ts
Executable file
232
mc_test/src/renderer/services/bulk-operations-api.ts
Executable file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Bulk Operations API Service
|
||||
*
|
||||
* Handles mass operations on multiple machines:
|
||||
* - Health checks
|
||||
* - SSH command execution (future)
|
||||
* - Multi-connect (future)
|
||||
*/
|
||||
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import { authService } from './auth-service';
|
||||
|
||||
// ========================================================================================
|
||||
// Constants
|
||||
// ========================================================================================
|
||||
|
||||
const DEFAULT_TIMEOUT = {
|
||||
HEALTH_CHECK: 5, // seconds
|
||||
SSH_COMMAND: 30, // seconds
|
||||
} as const;
|
||||
|
||||
const HTTP_STATUS = {
|
||||
FORBIDDEN: 403,
|
||||
} as const;
|
||||
|
||||
const ROLE_LIMITS = {
|
||||
GUEST: { maxHealthCheck: 10, maxSSHCommand: 0 },
|
||||
USER: { maxHealthCheck: 50, maxSSHCommand: 20 },
|
||||
ADMIN: { maxHealthCheck: 200, maxSSHCommand: 100 },
|
||||
SUPER_ADMIN: { maxHealthCheck: 200, maxSSHCommand: 100 },
|
||||
} as const;
|
||||
|
||||
const DEFAULT_CHECK_PORT = true;
|
||||
|
||||
// ========================================================================================
|
||||
// Types
|
||||
// ========================================================================================
|
||||
|
||||
export type HealthCheckStatus = 'success' | 'failed' | 'timeout';
|
||||
export type SSHCommandStatus = 'success' | 'failed' | 'timeout' | 'no_credentials';
|
||||
export type CredentialsMode = 'global' | 'custom';
|
||||
export type UserRole = keyof typeof ROLE_LIMITS;
|
||||
|
||||
export interface BulkHealthCheckRequest {
|
||||
machine_ids: string[];
|
||||
timeout?: number;
|
||||
check_port?: boolean;
|
||||
}
|
||||
|
||||
export interface BulkHealthCheckResult {
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
hostname: string;
|
||||
status: HealthCheckStatus;
|
||||
available: boolean;
|
||||
response_time_ms?: number;
|
||||
error?: string;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export interface BulkHealthCheckResponse {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
available: number;
|
||||
unavailable: number;
|
||||
results: BulkHealthCheckResult[];
|
||||
execution_time_ms: number;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
// SSH Command Types
|
||||
export interface SSHCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface BulkSSHCommandRequest {
|
||||
machine_ids: string[];
|
||||
machine_hostnames?: Record<string, string>; // For non-saved machines
|
||||
command: string;
|
||||
credentials_mode: CredentialsMode;
|
||||
global_credentials?: SSHCredentials;
|
||||
machine_credentials?: Record<string, SSHCredentials>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface BulkSSHCommandResult {
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
hostname: string;
|
||||
status: SSHCommandStatus;
|
||||
exit_code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
execution_time_ms?: number;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
export interface BulkSSHCommandResponse {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: BulkSSHCommandResult[];
|
||||
execution_time_ms: number;
|
||||
command: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// API Service
|
||||
// ========================================================================================
|
||||
|
||||
class BulkOperationsApiService {
|
||||
|
||||
/**
|
||||
* Bulk machine availability check
|
||||
*
|
||||
* Role-based limits:
|
||||
* - GUEST: max 10 machines
|
||||
* - USER: max 50 machines
|
||||
* - ADMIN: max 200 machines
|
||||
*/
|
||||
async bulkHealthCheck(request: BulkHealthCheckRequest): Promise<BulkHealthCheckResponse> {
|
||||
try {
|
||||
const token = await authService.getToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.BULK.HEALTH_CHECK}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
machine_ids: request.machine_ids,
|
||||
timeout: request.timeout ?? DEFAULT_TIMEOUT.HEALTH_CHECK,
|
||||
check_port: request.check_port ?? DEFAULT_CHECK_PORT
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
|
||||
if (response.status === HTTP_STATUS.FORBIDDEN) {
|
||||
throw new Error(errorData.detail || 'Permission denied. Check role-based limits.');
|
||||
}
|
||||
|
||||
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BulkHealthCheckResponse = await response.json();
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to perform bulk health check');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk SSH command execution
|
||||
*
|
||||
* Role-based limits:
|
||||
* - GUEST: forbidden
|
||||
* - USER: max 20 machines, whitelist commands only
|
||||
* - ADMIN: max 100 machines, any commands
|
||||
*/
|
||||
async bulkSSHCommand(request: BulkSSHCommandRequest): Promise<BulkSSHCommandResponse> {
|
||||
try {
|
||||
const token = await authService.getToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.BULK.SSH_COMMAND}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
machine_ids: request.machine_ids,
|
||||
machine_hostnames: request.machine_hostnames,
|
||||
command: request.command,
|
||||
credentials_mode: request.credentials_mode,
|
||||
global_credentials: request.global_credentials,
|
||||
machine_credentials: request.machine_credentials,
|
||||
timeout: request.timeout ?? DEFAULT_TIMEOUT.SSH_COMMAND
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
|
||||
if (response.status === HTTP_STATUS.FORBIDDEN) {
|
||||
throw new Error(errorData.detail || 'Permission denied. Check role and command whitelist.');
|
||||
}
|
||||
|
||||
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BulkSSHCommandResponse = await response.json();
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to execute bulk SSH command');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role-based limit for bulk operations
|
||||
*/
|
||||
getRoleLimits(role: string): { maxHealthCheck: number; maxSSHCommand: number } {
|
||||
return ROLE_LIMITS[role as UserRole] || ROLE_LIMITS.GUEST;
|
||||
}
|
||||
}
|
||||
|
||||
export const bulkOperationsApi = new BulkOperationsApiService();
|
||||
|
||||
366
mc_test/src/renderer/services/guacamole-api.ts
Executable file
366
mc_test/src/renderer/services/guacamole-api.ts
Executable file
@ -0,0 +1,366 @@
|
||||
import { log } from '../utils/logger';
|
||||
import { authService } from './auth-service';
|
||||
import { Machine } from '../types';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
const DEFAULT_TTL_MINUTES = 60;
|
||||
const DEFAULT_EXTEND_MINUTES = 60;
|
||||
const MIN_REMAINING_MINUTES = 0;
|
||||
|
||||
const HTTP_STATUS = {
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
} as const;
|
||||
|
||||
const SESSION_EXPIRED_PATTERNS = [
|
||||
'Session expired',
|
||||
'Invalid session',
|
||||
'encryption session has expired',
|
||||
] as const;
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
SESSION_EXPIRED: 'Your session has expired. Please log in again.',
|
||||
CONNECTION_NOT_FOUND: 'Connection not found or already expired',
|
||||
PERMISSION_DENIED: 'You do not have permission to extend this connection',
|
||||
} as const;
|
||||
|
||||
const PROTOCOL_SSH = 'ssh';
|
||||
const DEFAULT_PROTOCOL = 'rdp';
|
||||
|
||||
interface ConnectionRequest {
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
ttl_minutes?: number;
|
||||
// SFTP parameters for SSH connections
|
||||
enable_sftp?: boolean;
|
||||
sftp_root_directory?: string;
|
||||
sftp_server_alive_interval?: number;
|
||||
}
|
||||
|
||||
interface ConnectionResponse {
|
||||
connection_id: string;
|
||||
connection_url: string;
|
||||
status: string;
|
||||
expires_at: string;
|
||||
ttl_minutes: number;
|
||||
}
|
||||
|
||||
interface ActiveConnection {
|
||||
connection_id: string;
|
||||
connection_url: string | null;
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
owner_username: string;
|
||||
owner_role: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
ttl_minutes: number;
|
||||
remaining_minutes: number;
|
||||
status: 'active' | 'expired';
|
||||
}
|
||||
|
||||
interface ConnectionsListResponse {
|
||||
total_connections: number;
|
||||
active_connections: number;
|
||||
connections: ActiveConnection[];
|
||||
}
|
||||
|
||||
class GuacamoleService {
|
||||
|
||||
/**
|
||||
* Check if error detail indicates session expiry
|
||||
*/
|
||||
private isSessionExpiredError(errorDetail?: string): boolean {
|
||||
if (!errorDetail) return false;
|
||||
return SESSION_EXPIRED_PATTERNS.some(pattern =>
|
||||
errorDetail.includes(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 401 Unauthorized response
|
||||
* Checks for session expiry and logs out if needed
|
||||
*/
|
||||
private async handle401Error(response: Response): Promise<void> {
|
||||
if (response.status !== HTTP_STATUS.UNAUTHORIZED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
if (this.isSessionExpiredError(errorData.detail)) {
|
||||
await authService.logout().catch(() => {});
|
||||
throw new Error(ERROR_MESSAGES.SESSION_EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create connection to machine
|
||||
*/
|
||||
async createConnection(
|
||||
machine: Machine,
|
||||
credentials?: {
|
||||
username: string;
|
||||
password: string;
|
||||
protocol?: string;
|
||||
enableSftp?: boolean;
|
||||
sftpRootDirectory?: string;
|
||||
sftpServerAliveInterval?: number;
|
||||
}
|
||||
): Promise<ConnectionResponse> {
|
||||
try {
|
||||
log.info('guacamole-service', 'Creating connection', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name,
|
||||
ipAddress: machine.ip,
|
||||
hypervisor: machine.hypervisor,
|
||||
hostnameToUse: machine.hypervisor === 'Saved' ? machine.ip : machine.name
|
||||
});
|
||||
|
||||
const protocol = credentials?.protocol || DEFAULT_PROTOCOL;
|
||||
|
||||
const request: ConnectionRequest = {
|
||||
hostname: machine.hypervisor === 'Saved' ? machine.ip : machine.name,
|
||||
protocol: protocol,
|
||||
ttl_minutes: DEFAULT_TTL_MINUTES,
|
||||
...(credentials?.username && { username: credentials.username }),
|
||||
...(credentials?.password && { password: credentials.password }),
|
||||
// SFTP parameters for SSH (enabled by default for SSH)
|
||||
...(protocol === PROTOCOL_SSH && {
|
||||
enable_sftp: credentials?.enableSftp !== undefined ? credentials.enableSftp : true,
|
||||
...(credentials?.sftpRootDirectory && { sftp_root_directory: credentials.sftpRootDirectory }),
|
||||
...(credentials?.sftpServerAliveInterval && { sftp_server_alive_interval: credentials.sftpServerAliveInterval })
|
||||
})
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
// CSRF token NOT needed for JWT API
|
||||
// JWT Bearer tokens are not vulnerable to CSRF attacks
|
||||
|
||||
log.info('guacamole-service', 'Sending request', {
|
||||
url: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`,
|
||||
method: 'POST',
|
||||
headers: Object.keys(headers),
|
||||
hasAuthHeader: !!headers['Authorization'],
|
||||
requestBody: request
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 Unauthorized (Session expired)
|
||||
if (response.status === HTTP_STATUS.UNAUTHORIZED) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
log.error('guacamole-service', 'Session expired or invalid', {
|
||||
errorDetail: errorData.detail,
|
||||
machineId: machine.id
|
||||
});
|
||||
await this.handle401Error(response);
|
||||
}
|
||||
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
log.error('guacamole-service', 'API request failed', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorData,
|
||||
url: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`,
|
||||
requestBody: request
|
||||
});
|
||||
throw new Error(errorData.detail || `Failed to create connection: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: ConnectionResponse = await response.json();
|
||||
|
||||
log.info('guacamole-service', 'Connection created successfully', {
|
||||
machineId: machine.id,
|
||||
connectionId: data.connection_id,
|
||||
expiresAt: data.expires_at
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to create connection', {
|
||||
machineId: machine.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active connections
|
||||
*/
|
||||
async listConnections(): Promise<any[]> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.LIST}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
throw new Error(`Failed to list connections: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const connections = await response.json();
|
||||
|
||||
log.info('guacamole-service', 'Connections list retrieved', {
|
||||
count: connections.length
|
||||
});
|
||||
|
||||
return connections;
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to list connections', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active connections for restoration
|
||||
* Returns only connections with 'active' status and valid connection_url
|
||||
*/
|
||||
async getActiveConnections(): Promise<ActiveConnection[]> {
|
||||
try {
|
||||
log.info('guacamole-service', 'Fetching active connections for restoration');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.LIST}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
throw new Error(`Failed to get active connections: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: ConnectionsListResponse = await response.json();
|
||||
|
||||
// Filter only active connections with valid connection_url
|
||||
const activeConnections = data.connections.filter(
|
||||
conn => conn.status === 'active' &&
|
||||
conn.connection_url !== null &&
|
||||
conn.remaining_minutes > MIN_REMAINING_MINUTES
|
||||
);
|
||||
|
||||
log.info('guacamole-service', 'Active connections retrieved', {
|
||||
total: data.total_connections,
|
||||
active: activeConnections.length,
|
||||
restorable: activeConnections.filter(c => c.connection_url).length
|
||||
});
|
||||
|
||||
return activeConnections;
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to get active connections', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete connection
|
||||
*/
|
||||
async deleteConnection(connectionId: string): Promise<void> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.DELETE(connectionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
throw new Error(`Failed to delete connection: ${response.statusText}`);
|
||||
}
|
||||
|
||||
log.info('guacamole-service', 'Connection deleted', { connectionId });
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to delete connection', { connectionId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend TTL of active connection
|
||||
*/
|
||||
async extendConnectionTTL(connectionId: string, additionalMinutes: number = DEFAULT_EXTEND_MINUTES): Promise<{ new_expires_at: string; additional_minutes: number }> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.EXTEND(connectionId)}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ additional_minutes: additionalMinutes })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
|
||||
if (response.status === HTTP_STATUS.NOT_FOUND) {
|
||||
throw new Error(ERROR_MESSAGES.CONNECTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (response.status === HTTP_STATUS.FORBIDDEN) {
|
||||
throw new Error(ERROR_MESSAGES.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to extend connection: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
log.info('guacamole-service', 'Connection TTL extended', {
|
||||
connectionId,
|
||||
additionalMinutes,
|
||||
newExpiresAt: data.new_expires_at
|
||||
});
|
||||
|
||||
return {
|
||||
new_expires_at: data.new_expires_at,
|
||||
additional_minutes: data.additional_minutes
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to extend connection TTL', { connectionId, additionalMinutes, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection URL
|
||||
* This method now simply returns the URL from API response
|
||||
*/
|
||||
getConnectionUrl(connectionUrl: string): string {
|
||||
return connectionUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export const guacamoleService = new GuacamoleService();
|
||||
export default guacamoleService;
|
||||
export type { ActiveConnection, ConnectionResponse, ConnectionsListResponse };
|
||||
176
mc_test/src/renderer/services/machine-availability.ts
Executable file
176
mc_test/src/renderer/services/machine-availability.ts
Executable file
@ -0,0 +1,176 @@
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import { authService } from './auth-service';
|
||||
import { log } from '../utils/logger';
|
||||
import type { Machine } from '../types';
|
||||
|
||||
/**
|
||||
* Result of machine availability check
|
||||
*/
|
||||
export interface MachineAvailabilityResult {
|
||||
available: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
responseTimeMs?: number;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for availability check
|
||||
*/
|
||||
interface AvailabilityCheckRequest {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
RDP: 3389,
|
||||
SSH: 22,
|
||||
} as const;
|
||||
|
||||
const OS_IDENTIFIER = {
|
||||
WINDOWS: 'windows',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Service for checking machine availability
|
||||
*
|
||||
* Performs machine availability checks via API server
|
||||
* to avoid network restrictions on the client side.
|
||||
*/
|
||||
export class MachineAvailabilityService {
|
||||
/**
|
||||
* Determine default port based on machine OS
|
||||
*
|
||||
* Simple logic: Windows → RDP (3389), everything else → SSH (22)
|
||||
*/
|
||||
private static getDefaultPort(machine: Machine): number {
|
||||
const os = machine.os.toLowerCase();
|
||||
|
||||
if (os.includes(OS_IDENTIFIER.WINDOWS)) {
|
||||
return DEFAULT_PORTS.RDP;
|
||||
}
|
||||
|
||||
return DEFAULT_PORTS.SSH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unavailable result object
|
||||
*/
|
||||
private static createUnavailableResult(
|
||||
hostname: string,
|
||||
port: number
|
||||
): MachineAvailabilityResult {
|
||||
return {
|
||||
available: false,
|
||||
hostname,
|
||||
port,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check machine availability
|
||||
*
|
||||
* @param machine - Machine to check
|
||||
* @param port - Port to check (optional, determined by OS)
|
||||
* @returns Availability check result
|
||||
*/
|
||||
static async checkAvailability(
|
||||
machine: Machine,
|
||||
port?: number
|
||||
): Promise<MachineAvailabilityResult> {
|
||||
try {
|
||||
const targetPort = port || this.getDefaultPort(machine);
|
||||
|
||||
log.info('machine-availability', 'Checking machine availability', {
|
||||
machineId: machine.id,
|
||||
hostname: machine.name,
|
||||
os: machine.os,
|
||||
port: targetPort,
|
||||
portSource: port ? 'explicit' : 'auto-detected',
|
||||
});
|
||||
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
const requestBody: AvailabilityCheckRequest = {
|
||||
hostname: machine.name,
|
||||
port: targetPort,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.MACHINES.CHECK_AVAILABILITY}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
log.error('machine-availability', 'Check failed', {
|
||||
hostname: machine.name,
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
|
||||
return this.createUnavailableResult(machine.name, targetPort);
|
||||
}
|
||||
|
||||
const data: MachineAvailabilityResult = await response.json();
|
||||
|
||||
log.info('machine-availability', 'Check completed', {
|
||||
machineId: machine.id,
|
||||
hostname: data.hostname,
|
||||
available: data.available,
|
||||
responseTimeMs: data.responseTimeMs,
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
const targetPort = port || this.getDefaultPort(machine);
|
||||
|
||||
log.error('machine-availability', 'Check failed with exception', {
|
||||
hostname: machine.name,
|
||||
error,
|
||||
});
|
||||
|
||||
return this.createUnavailableResult(machine.name, targetPort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check availability of multiple machines simultaneously
|
||||
*
|
||||
* @param machines - Array of machines to check
|
||||
* @param port - Port to check (optional)
|
||||
* @returns Map with check results (machineId -> result)
|
||||
*/
|
||||
static async checkMultiple(
|
||||
machines: Machine[],
|
||||
port?: number
|
||||
): Promise<Map<string, MachineAvailabilityResult>> {
|
||||
const results = new Map<string, MachineAvailabilityResult>();
|
||||
|
||||
const promises = machines.map(async (machine) => {
|
||||
const result = await this.checkAvailability(machine, port);
|
||||
return { machineId: machine.id, result };
|
||||
});
|
||||
|
||||
const completed = await Promise.allSettled(promises);
|
||||
|
||||
completed.forEach((promise) => {
|
||||
if (promise.status === 'fulfilled') {
|
||||
results.set(promise.value.machineId, promise.value.result);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const machineAvailabilityService = MachineAvailabilityService;
|
||||
|
||||
320
mc_test/src/renderer/services/saved-machines-api.ts
Executable file
320
mc_test/src/renderer/services/saved-machines-api.ts
Executable file
@ -0,0 +1,320 @@
|
||||
/**
|
||||
* API Service for working with saved machines
|
||||
*/
|
||||
|
||||
import { ApiClient } from '../utils/apiClient';
|
||||
import { authService } from './auth-service';
|
||||
import { log } from '../utils/logger';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
export type Protocol = 'rdp' | 'ssh' | 'vnc' | 'telnet';
|
||||
|
||||
export interface ConnectionStats {
|
||||
total_connections: number;
|
||||
last_connection?: string;
|
||||
successful_connections: number;
|
||||
failed_connections: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRecordResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
history_id: string;
|
||||
}
|
||||
|
||||
export interface SavedMachineCreate {
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: Protocol;
|
||||
os?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedMachineUpdate {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
protocol?: Protocol;
|
||||
os?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedMachine {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: Protocol;
|
||||
os?: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
is_favorite: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_connected_at?: string;
|
||||
connection_stats?: ConnectionStats;
|
||||
}
|
||||
|
||||
export interface SavedMachineList {
|
||||
total: number;
|
||||
machines: SavedMachine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for working with saved machines
|
||||
*/
|
||||
export class SavedMachinesApiService {
|
||||
private static baseUrl = `${API_CONFIG.BASE_URL}/api/machines/saved`;
|
||||
|
||||
/**
|
||||
* Get list of all user's saved machines
|
||||
*/
|
||||
static async getSavedMachines(includeStats: boolean = false): Promise<SavedMachineList> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Fetching saved machines', { includeStats });
|
||||
|
||||
const response = await ApiClient.get<SavedMachineList>(
|
||||
`${this.baseUrl}?include_stats=${includeStats}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machines fetched successfully', {
|
||||
total: response.total
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to fetch saved machines', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific machine by ID
|
||||
*/
|
||||
static async getSavedMachine(machineId: string): Promise<SavedMachine> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Fetching saved machine', { machineId });
|
||||
|
||||
const response = await ApiClient.get<SavedMachine>(
|
||||
`${this.baseUrl}/${machineId}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine fetched successfully', {
|
||||
machineId,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to fetch saved machine', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new saved machine
|
||||
*/
|
||||
static async createSavedMachine(machine: SavedMachineCreate): Promise<SavedMachine> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Creating saved machine', {
|
||||
name: machine.name,
|
||||
hostname: machine.hostname,
|
||||
protocol: machine.protocol
|
||||
});
|
||||
|
||||
const response = await ApiClient.post<SavedMachine>(
|
||||
this.baseUrl,
|
||||
machine,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine created successfully', {
|
||||
machineId: response.id,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to create saved machine', {
|
||||
name: machine.name,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update saved machine
|
||||
*/
|
||||
static async updateSavedMachine(
|
||||
machineId: string,
|
||||
updates: SavedMachineUpdate
|
||||
): Promise<SavedMachine> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Updating saved machine', {
|
||||
machineId,
|
||||
updates: Object.keys(updates)
|
||||
});
|
||||
|
||||
const response = await ApiClient.put<SavedMachine>(
|
||||
`${this.baseUrl}/${machineId}`,
|
||||
updates,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine updated successfully', {
|
||||
machineId,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to update saved machine', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete saved machine
|
||||
*/
|
||||
static async deleteSavedMachine(machineId: string): Promise<void> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Deleting saved machine', { machineId });
|
||||
|
||||
await ApiClient.delete(
|
||||
`${this.baseUrl}/${machineId}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine deleted successfully', {
|
||||
machineId
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to delete saved machine', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record connection to machine
|
||||
*/
|
||||
static async connectToSavedMachine(
|
||||
machineId: string
|
||||
): Promise<ConnectionRecordResponse> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Recording connection to saved machine', {
|
||||
machineId
|
||||
});
|
||||
|
||||
const response = await ApiClient.post<ConnectionRecordResponse>(
|
||||
`${this.baseUrl}/${machineId}/connect`,
|
||||
{},
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Connection recorded successfully', {
|
||||
machineId,
|
||||
historyId: response.history_id
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to record connection', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
static async toggleFavorite(
|
||||
machineId: string,
|
||||
isFavorite: boolean
|
||||
): Promise<SavedMachine> {
|
||||
return this.updateSavedMachine(machineId, { is_favorite: isFavorite });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine tags
|
||||
*/
|
||||
static async updateTags(machineId: string, tags: string[]): Promise<SavedMachine> {
|
||||
return this.updateSavedMachine(machineId, { tags });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter machines by tags
|
||||
*/
|
||||
static filterByTags(machines: SavedMachine[], tags: string[]): SavedMachine[] {
|
||||
if (!tags || tags.length === 0) {
|
||||
return machines;
|
||||
}
|
||||
|
||||
return machines.filter((machine) => tags.some((tag) => machine.tags.includes(tag)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter machines by protocol
|
||||
*/
|
||||
static filterByProtocol(machines: SavedMachine[], protocol: string): SavedMachine[] {
|
||||
return machines.filter((machine) => machine.protocol === protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter only favorites
|
||||
*/
|
||||
static filterFavorites(machines: SavedMachine[]): SavedMachine[] {
|
||||
return machines.filter((machine) => machine.is_favorite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by name or hostname
|
||||
*/
|
||||
static search(machines: SavedMachine[], query: string): SavedMachine[] {
|
||||
if (!query) {
|
||||
return machines;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return machines.filter(
|
||||
(machine) =>
|
||||
machine.name.toLowerCase().includes(lowerQuery) ||
|
||||
machine.hostname.toLowerCase().includes(lowerQuery) ||
|
||||
machine.description?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const savedMachinesApi = SavedMachinesApiService;
|
||||
|
||||
384
mc_test/src/renderer/services/websocket-notifications.ts
Executable file
384
mc_test/src/renderer/services/websocket-notifications.ts
Executable file
@ -0,0 +1,384 @@
|
||||
/**
|
||||
* WebSocket Service for real-time notifications
|
||||
*
|
||||
* Events:
|
||||
* - connection_expired: Connection expired
|
||||
* - connection_deleted: Connection deleted
|
||||
* - connection_will_expire: Connection will expire soon (in 5 min)
|
||||
* - connection_extended: Connection extended
|
||||
* - jwt_will_expire: JWT will expire soon (in 5 min)
|
||||
* - jwt_expired: JWT expired
|
||||
*/
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
export type WebSocketEventType =
|
||||
| 'connection_expired'
|
||||
| 'connection_deleted'
|
||||
| 'connection_will_expire'
|
||||
| 'connection_extended'
|
||||
| 'jwt_will_expire'
|
||||
| 'jwt_expired'
|
||||
| 'connected'
|
||||
| 'ping'
|
||||
| 'pong';
|
||||
|
||||
export interface WebSocketEvent {
|
||||
type: WebSocketEventType;
|
||||
timestamp: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
token?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionExpiredEvent {
|
||||
connection_id: string;
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ConnectionWillExpireEvent {
|
||||
connection_id: string;
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
minutes_remaining: number;
|
||||
}
|
||||
|
||||
export interface ConnectionExtendedEvent {
|
||||
connection_id: string;
|
||||
hostname: string;
|
||||
new_expires_at: string;
|
||||
additional_minutes: number;
|
||||
}
|
||||
|
||||
type EventHandler = (event: WebSocketEvent) => void;
|
||||
|
||||
const WEBSOCKET_CONFIG = {
|
||||
MAX_RECONNECT_ATTEMPTS: 10,
|
||||
INITIAL_RECONNECT_DELAY: 1000, // 1 second
|
||||
MAX_RECONNECT_DELAY: 30000, // 30 seconds
|
||||
PING_INTERVAL: 25000, // 25 seconds
|
||||
CONNECTION_TIMEOUT: 5000, // 5 seconds
|
||||
EXPONENTIAL_BACKOFF_BASE: 2,
|
||||
} as const;
|
||||
|
||||
const WEBSOCKET_PATH = '/ws/notifications';
|
||||
const ALL_EVENTS_KEY = '*';
|
||||
|
||||
const PROTOCOL_REPLACEMENT = {
|
||||
HTTPS: 'wss://',
|
||||
HTTP: 'ws://',
|
||||
} as const;
|
||||
|
||||
class WebSocketNotificationService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private readonly maxReconnectAttempts = WEBSOCKET_CONFIG.MAX_RECONNECT_ATTEMPTS;
|
||||
private readonly reconnectDelay = WEBSOCKET_CONFIG.INITIAL_RECONNECT_DELAY;
|
||||
private readonly maxReconnectDelay = WEBSOCKET_CONFIG.MAX_RECONNECT_DELAY;
|
||||
private isIntentionallyDisconnected = false;
|
||||
private pingTimer: NodeJS.Timeout | null = null;
|
||||
private readonly pingInterval = WEBSOCKET_CONFIG.PING_INTERVAL;
|
||||
|
||||
private eventHandlers: Map<string, Set<EventHandler>> = new Map();
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connect(token: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
log.info('websocket', 'Already connected');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isIntentionallyDisconnected = false;
|
||||
|
||||
// Determine WebSocket URL
|
||||
const wsUrl = API_CONFIG.BASE_URL
|
||||
.replace('https://', PROTOCOL_REPLACEMENT.HTTPS)
|
||||
.replace('http://', PROTOCOL_REPLACEMENT.HTTP);
|
||||
const fullUrl = `${wsUrl}${WEBSOCKET_PATH}`;
|
||||
|
||||
log.info('websocket', 'Connecting to WebSocket', { url: fullUrl });
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(fullUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
log.info('websocket', 'WebSocket connected');
|
||||
|
||||
// Send authentication
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token
|
||||
}));
|
||||
|
||||
log.info('websocket', 'Sent authentication');
|
||||
|
||||
// Wait for connected confirmation
|
||||
const connectTimeout = setTimeout(() => {
|
||||
log.error('websocket', 'Connection confirmation timeout');
|
||||
this.disconnect();
|
||||
reject(new Error('Connection confirmation timeout'));
|
||||
}, WEBSOCKET_CONFIG.CONNECTION_TIMEOUT);
|
||||
|
||||
// Temporary handler for connected
|
||||
const tempOnMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const message: WebSocketEvent = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'connected') {
|
||||
clearTimeout(connectTimeout);
|
||||
log.info('websocket', 'Authentication confirmed', message.data);
|
||||
|
||||
// Reset reconnect attempts
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Start ping timer
|
||||
this.startPingTimer();
|
||||
|
||||
// Set main message handler
|
||||
if (this.ws) {
|
||||
this.ws.onmessage = (e) => this.handleMessage(e);
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Failed to parse auth response', { error });
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = tempOnMessage;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
log.error('websocket', 'WebSocket error', { error });
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
log.info('websocket', 'WebSocket closed', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
|
||||
this.stopPingTimer();
|
||||
|
||||
// Auto-reconnect if not intentionally disconnected
|
||||
if (!this.isIntentionallyDisconnected && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect(token);
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Failed to create WebSocket', { error });
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from WebSocket server
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.isIntentionallyDisconnected = true;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.stopPingTimer();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
log.info('websocket', 'Disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection with exponential backoff
|
||||
*/
|
||||
private scheduleReconnect(token: string): void {
|
||||
if (this.reconnectTimer) {
|
||||
return; // Already scheduled
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(WEBSOCKET_CONFIG.EXPONENTIAL_BACKOFF_BASE, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
log.info('websocket', 'Scheduling reconnect', {
|
||||
attempt: this.reconnectAttempts,
|
||||
delay: `${delay}ms`
|
||||
});
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect(token).catch((error) => {
|
||||
log.error('websocket', 'Reconnect failed', { error });
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ping timer for keep-alive
|
||||
*/
|
||||
private startPingTimer(): void {
|
||||
this.stopPingTimer();
|
||||
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'ping',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
log.debug('websocket', 'Sent ping');
|
||||
}
|
||||
}, this.pingInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ping timer
|
||||
*/
|
||||
private stopPingTimer(): void{
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message
|
||||
*/
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const message: WebSocketEvent = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'pong') {
|
||||
log.debug('websocket', 'Received pong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'ping') {
|
||||
// Respond with pong
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('websocket', 'Received event', {
|
||||
type: message.type,
|
||||
timestamp: message.timestamp
|
||||
});
|
||||
|
||||
// Call handlers for this event type
|
||||
const handlers = this.eventHandlers.get(message.type);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Event handler error', {
|
||||
type: message.type,
|
||||
error
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also call handlers for '*' (all events)
|
||||
const allHandlers = this.eventHandlers.get(ALL_EVENTS_KEY);
|
||||
if (allHandlers) {
|
||||
allHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Global event handler error', {
|
||||
type: message.type,
|
||||
error
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Failed to parse message', { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(eventType: string, handler: EventHandler): () => void {
|
||||
if (!this.eventHandlers.has(eventType)) {
|
||||
this.eventHandlers.set(eventType, new Set());
|
||||
}
|
||||
|
||||
this.eventHandlers.get(eventType)!.add(handler);
|
||||
|
||||
log.info('websocket', 'Registered event handler', { eventType });
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const handlers = this.eventHandlers.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
|
||||
if (handlers.size === 0) {
|
||||
this.eventHandlers.delete(eventType);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all events
|
||||
*/
|
||||
removeAllListeners(): void {
|
||||
this.eventHandlers.clear();
|
||||
log.info('websocket', 'Removed all event handlers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check connection status
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of reconnect attempts
|
||||
*/
|
||||
getReconnectAttempts(): number {
|
||||
return this.reconnectAttempts;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const websocketNotificationService = new WebSocketNotificationService();
|
||||
|
||||
Reference in New Issue
Block a user