This commit is contained in:
root
2025-11-25 09:56:15 +03:00
commit 68c8f0e80d
23717 changed files with 3200521 additions and 0 deletions

View 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;

View 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;

View 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();

View 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;

View 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;

View 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();

View 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 };

View 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;

View 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;

View 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();