Git_pusher with file licence

Pushed by: admin
License: 1CFBBDCA-31F (Starter)
Timestamp: 2026-02-01T00:05:57.898363
masterdev
Splunk Git Pusher 3 months ago
parent 29f638f8f6
commit a7c906e576

File diff suppressed because it is too large Load Diff

@ -0,0 +1,613 @@
// ============================================
// SYSTÈME DE GESTION DE LICENCE FICHIER .LIC
// ============================================
const LICENSE_FILE_KEY = 'git_pusher_license_file';
const LICENSE_STATUS_ENDPOINT = '/custom/git_pusher/license_status';
let currentLicenseStatus = null;
function initializeLicenseSystem() {
console.log("Initializing file-based license system...");
// Vérifier le statut de la licence
checkLicenseStatus();
}
function checkLicenseStatus() {
console.log("Checking license status...");
// Appeler le backend pour vérifier la licence
fetch(LICENSE_STATUS_ENDPOINT)
.then(response => response.json())
.then(data => {
console.log("License status:", data);
currentLicenseStatus = data;
if (data.licensed) {
// Licence valide
displayLicenseBadge(data.license_info, data.warnings);
} else {
// Pas de licence ou invalide
showLicenseUploadModal(data.errors);
}
})
.catch(error => {
console.error("Error checking license:", error);
// En cas d'erreur, afficher le modal
showLicenseUploadModal(["Impossible de vérifier la licence"]);
});
}
function displayLicenseBadge(licenseInfo, warnings) {
console.log("Displaying license badge...");
const container = document.getElementById('license-badge-container');
if (!container) {
console.error("license-badge-container not found");
return;
}
// Créer le badge
const badge = document.createElement('div');
badge.id = 'license-badge';
badge.style.cssText = `
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
cursor: pointer;
transition: all 0.3s ease;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
text-align: center;
min-width: 200px;
`;
// Déterminer le texte et la couleur selon le type
let badgeText = '✓ Licence Activée';
let badgeColor = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
if (licenseInfo.type === 'trial') {
badgeText = `⏱️ Essai: ${licenseInfo.days_remaining} jours`;
badgeColor = 'linear-gradient(135deg, #ff9800 0%, #f57c00 100%)';
} else if (licenseInfo.type === 'standard') {
badgeText = `✓ Standard (${licenseInfo.days_remaining}j)`;
} else if (licenseInfo.type === 'enterprise') {
badgeText = `✓ Enterprise (${licenseInfo.days_remaining}j)`;
badgeColor = 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)';
}
// Avertissement si expire bientôt
if (licenseInfo.days_remaining < 30) {
badgeColor = 'linear-gradient(135deg, #ff9800 0%, #f57c00 100%)';
badgeText = `⚠️ Expire dans ${licenseInfo.days_remaining}j`;
}
badge.style.background = badgeColor;
badge.textContent = badgeText;
// Clic pour afficher les détails
badge.onclick = function() {
showLicenseDetailsModal(licenseInfo, warnings);
};
container.appendChild(badge);
// Hover effect
badge.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-3px)';
this.style.boxShadow = '0 6px 25px rgba(102, 126, 234, 0.5)';
});
badge.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
this.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.3)';
});
}
function showLicenseUploadModal(errors = []) {
console.log("Showing license upload modal");
// Créer le modal
const modal = document.createElement('div');
modal.id = 'license-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 16px;
padding: 40px;
max-width: 550px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
animation: slideIn 0.3s ease;
`;
let errorsHtml = '';
if (errors.length > 0) {
errorsHtml = `
<div style="background: #fff3cd; padding: 15px; border-radius: 8px; margin-bottom: 25px; border-left: 4px solid #ff9800;">
<p style="margin: 0; color: #856404; font-weight: 500; font-size: 13px;">
<strong>Problèmes détectés:</strong>
</p>
<ul style="margin: 10px 0 0 20px; color: #856404; font-size: 12px;">
${errors.map(e => `<li>${e}</li>`).join('')}
</ul>
</div>
`;
}
content.innerHTML = `
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="font-size: 32px; margin: 0 0 10px 0; color: #333;">🔐 Git Pusher</h1>
<p style="color: #666; margin: 0; font-size: 14px;">Activation de licence requise</p>
</div>
${errorsHtml}
<div style="background: #f5f7ff; padding: 15px; border-radius: 8px; margin-bottom: 25px; border-left: 4px solid #667eea;">
<p style="margin: 0; color: #667eea; font-weight: 500; font-size: 13px;">
📋 <strong>Hostname détecté:</strong> <span id="detected-hostname">Chargement...</span>
</p>
</div>
<div style="margin-bottom: 25px;">
<label style="display: block; font-weight: 600; color: #333; margin-bottom: 12px; font-size: 14px;">
📂 Choisissez votre fichier de licence (.lic)
</label>
<div id="drop-zone" style="
border: 3px dashed #667eea;
border-radius: 12px;
padding: 40px 20px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #f8f9ff;
">
<div style="font-size: 48px; margin-bottom: 15px;">📄</div>
<p style="margin: 0 0 10px 0; font-weight: 600; color: #333;">
Glissez votre fichier .lic ici
</p>
<p style="margin: 0; color: #999; font-size: 13px;">
ou cliquez pour parcourir
</p>
<input type="file" id="license-file-input" accept=".lic" style="display: none;">
</div>
<div id="file-info" style="display: none; margin-top: 15px; padding: 12px; background: #e8f5e9; border-radius: 8px; border-left: 4px solid #4caf50;">
<p style="margin: 0; color: #2e7d32; font-size: 13px;">
<strong id="file-name"></strong> (<span id="file-size"></span>)
</p>
</div>
<small style="color: #999; display: block; margin-top: 12px; font-size: 12px;">
💡 Vous n'avez pas de licence? Contactez-nous pour obtenir votre fichier .lic personnalisé
</small>
</div>
<div id="license-message" style="display: none; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px;"></div>
<div style="display: flex; gap: 10px;">
<button id="upload-btn" onclick="uploadLicenseFile()" disabled style="
flex: 1;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: not-allowed;
font-size: 14px;
transition: all 0.3s ease;
opacity: 0.5;
">📤 Installer la licence</button>
<button onclick="requestTrial()" style="
padding: 14px 20px;
background: #f5f7ff;
color: #667eea;
border: 2px solid #667eea;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
"> Essai gratuit</button>
</div>
`;
modal.appendChild(content);
document.body.appendChild(modal);
// Ajouter l'animation CSS
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
document.head.appendChild(style);
// Récupérer et afficher le hostname
getHostname().then(hostname => {
document.getElementById('detected-hostname').textContent = hostname;
});
// Configurer le drag & drop et file input
setupFileUpload();
}
function setupFileUpload() {
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('license-file-input');
const fileInfo = document.getElementById('file-info');
const uploadBtn = document.getElementById('upload-btn');
let selectedFile = null;
// Clic sur la zone pour ouvrir le sélecteur
dropZone.onclick = () => fileInput.click();
// Hover effect
dropZone.addEventListener('mouseenter', function() {
this.style.background = '#eef1ff';
this.style.borderColor = '#5568d3';
});
dropZone.addEventListener('mouseleave', function() {
this.style.background = '#f8f9ff';
this.style.borderColor = '#667eea';
});
// Drag & drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.background = '#e3e7ff';
dropZone.style.borderColor = '#5568d3';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.background = '#f8f9ff';
dropZone.style.borderColor = '#667eea';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.background = '#f8f9ff';
dropZone.style.borderColor = '#667eea';
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileSelection(files[0]);
}
});
// Sélection de fichier
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelection(e.target.files[0]);
}
});
function handleFileSelection(file) {
selectedFile = file;
// Vérifier l'extension
if (!file.name.endsWith('.lic')) {
showLicenseMessage('⚠️ Veuillez sélectionner un fichier .lic', 'warning');
return;
}
// Afficher les infos du fichier
document.getElementById('file-name').textContent = file.name;
document.getElementById('file-size').textContent = formatFileSize(file.size);
fileInfo.style.display = 'block';
// Activer le bouton
uploadBtn.disabled = false;
uploadBtn.style.cursor = 'pointer';
uploadBtn.style.opacity = '1';
// Stocker le fichier pour l'upload
window.selectedLicenseFile = file;
showLicenseMessage('✓ Fichier prêt à être installé', 'success');
}
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function uploadLicenseFile() {
const file = window.selectedLicenseFile;
if (!file) {
showLicenseMessage('❌ Aucun fichier sélectionné', 'error');
return;
}
showLicenseMessage('⏳ Installation de la licence...', 'info');
// Lire le fichier
const reader = new FileReader();
reader.onload = function(e) {
const fileContent = e.target.result;
// Envoyer au backend pour validation et installation
fetch('/custom/git_pusher/install_license', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
license_file: fileContent,
filename: file.name
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
showLicenseMessage('✅ Licence installée avec succès !', 'success');
setTimeout(() => {
closeLicenseModal();
// Recharger pour afficher le badge
checkLicenseStatus();
}, 1500);
} else {
showLicenseMessage('❌ ' + (data.message || 'Erreur d\'installation'), 'error');
if (data.errors && data.errors.length > 0) {
const errorList = data.errors.map(e => `${e}`).join('\n');
console.error('License errors:', errorList);
}
}
})
.catch(error => {
console.error('Upload error:', error);
showLicenseMessage('❌ Erreur de connexion au serveur', 'error');
});
};
reader.onerror = function() {
showLicenseMessage('❌ Erreur de lecture du fichier', 'error');
};
reader.readAsText(file);
}
function requestTrial() {
showLicenseMessage('⏳ Demande d\'essai en cours...', 'info');
// Appeler le backend pour créer une licence d'essai
fetch('/custom/git_pusher/request_trial', {
method: 'POST'
})
.then(response => response.json())
.then(data => {
if (data.success) {
showLicenseMessage('✅ Licence d\'essai activée (7 jours) !', 'success');
setTimeout(() => {
closeLicenseModal();
checkLicenseStatus();
}, 1500);
} else {
showLicenseMessage('❌ ' + (data.message || 'Erreur'), 'error');
}
})
.catch(error => {
console.error('Trial request error:', error);
showLicenseMessage('❌ Erreur de connexion', 'error');
});
}
function showLicenseDetailsModal(licenseInfo, warnings) {
// Créer un modal pour afficher les détails de la licence
const modal = document.createElement('div');
modal.id = 'license-details-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10001;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 16px;
padding: 40px;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
`;
let warningsHtml = '';
if (warnings && warnings.length > 0) {
warningsHtml = `
<div style="background: #fff3cd; padding: 12px; border-radius: 8px; margin-top: 20px; border-left: 4px solid #ff9800;">
${warnings.map(w => `<p style="margin: 5px 0; color: #856404; font-size: 13px;">${w}</p>`).join('')}
</div>
`;
}
const featuresHtml = Object.entries(licenseInfo.features || {})
.map(([feature, enabled]) => {
const icon = enabled ? '✅' : '❌';
return `<div style="padding: 8px 0;"><span style="font-size: 14px;">${icon} ${feature}</span></div>`;
}).join('');
content.innerHTML = `
<div style="text-align: center; margin-bottom: 25px;">
<h2 style="font-size: 24px; margin: 0 0 5px 0; color: #333;">🔐 Informations de licence</h2>
</div>
<div style="background: #f5f7ff; padding: 20px; border-radius: 12px; margin-bottom: 20px;">
<div style="margin-bottom: 15px;">
<strong style="color: #667eea;">🆔 ID de licence:</strong>
<div style="font-family: monospace; font-size: 12px; margin-top: 5px; color: #555;">${licenseInfo.license_id}</div>
</div>
<div style="margin-bottom: 15px;">
<strong style="color: #667eea;">👤 Client:</strong>
<div style="margin-top: 5px; color: #555;">${licenseInfo.customer}</div>
</div>
<div style="margin-bottom: 15px;">
<strong style="color: #667eea;">🏷 Type:</strong>
<div style="margin-top: 5px; color: #555;">${licenseInfo.type.toUpperCase()}</div>
</div>
<div style="margin-bottom: 15px;">
<strong style="color: #667eea;">📅 Expire le:</strong>
<div style="margin-top: 5px; color: #555;">${new Date(licenseInfo.expires).toLocaleDateString('fr-FR')}</div>
</div>
<div>
<strong style="color: #667eea;"> Jours restants:</strong>
<div style="margin-top: 5px; color: #555; font-size: 18px; font-weight: 600;">${licenseInfo.days_remaining} jours</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<strong style="color: #333; display: block; margin-bottom: 10px;"> Fonctionnalités:</strong>
<div style="background: #f9f9f9; padding: 15px; border-radius: 8px;">
${featuresHtml}
</div>
</div>
${warningsHtml}
<div style="display: flex; gap: 10px; margin-top: 25px;">
<button onclick="showLicenseUploadModal([])" style="
flex: 1;
padding: 12px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
">🔄 Changer de licence</button>
<button onclick="closeDetailsModal()" style="
flex: 1;
padding: 12px;
background: #f5f7ff;
color: #667eea;
border: 2px solid #667eea;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
">Fermer</button>
</div>
`;
modal.appendChild(content);
document.body.appendChild(modal);
}
function closeDetailsModal() {
const modal = document.getElementById('license-details-modal');
if (modal) {
modal.remove();
}
}
function showLicenseMessage(message, type) {
const messageEl = document.getElementById('license-message');
if (!messageEl) return;
messageEl.style.display = 'block';
messageEl.textContent = message;
if (type === 'success') {
messageEl.style.background = '#d4edda';
messageEl.style.color = '#155724';
messageEl.style.border = '1px solid #c3e6cb';
} else if (type === 'error') {
messageEl.style.background = '#f8d7da';
messageEl.style.color = '#721c24';
messageEl.style.border = '1px solid #f5c6cb';
} else if (type === 'warning') {
messageEl.style.background = '#fff3cd';
messageEl.style.color = '#856404';
messageEl.style.border = '1px solid #ffeaa7';
} else if (type === 'info') {
messageEl.style.background = '#d1ecf1';
messageEl.style.color = '#0c5460';
messageEl.style.border = '1px solid #bee5eb';
}
}
function closeLicenseModal() {
const modal = document.getElementById('license-modal');
if (modal) {
modal.remove();
}
}
function getHostname() {
return new Promise((resolve) => {
fetch('/en-US/splunkd/__raw/services/server/info?output_mode=json')
.then(r => r.json())
.then(d => {
const hostname = d.entry?.[0]?.content?.host || 'unknown';
resolve(hostname);
})
.catch(() => resolve('unknown'));
});
}
function checkLicenseBeforePush() {
if (!currentLicenseStatus || !currentLicenseStatus.licensed) {
alert('⚠️ Aucune licence valide détectée. Veuillez installer une licence.');
showLicenseUploadModal([]);
return false;
}
return true;
}
// Initialiser au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
initializeLicenseSystem();
});

@ -1,368 +1,598 @@
// ============================================ // ============================================
// SYSTÈME DE VALIDATION DE LICENCE // SYSTÈME DE VALIDATION DE LICENCE - VERSION 2.0
// Avec support fichier .lic
// ============================================ // ============================================
const LICENSE_STORAGE_KEY = 'git_pusher_license'; const LICENSE_API_URL = window.location.protocol + '//' + window.location.hostname + ':9999';
function initializeLicense() { // État global de la licence
console.log("Initializing license system..."); let currentLicense = null;
let currentHostname = null;
// Vérifier si une licence est déjà stockée // ============================================
const storedLicense = getCookie(LICENSE_STORAGE_KEY); // INITIALISATION
// ============================================
if (storedLicense) { async function initializeLicense() {
// Valider la licence stockée console.log("Initializing license system v2.0...");
validateStoredLicense(storedLicense);
// Afficher les infos de licence try {
displayLicenseInfo(storedLicense); // Récupérer le statut de la licence depuis le serveur
} else { const response = await fetch(`${LICENSE_API_URL}/license/status`);
// Afficher la page de licence const data = await response.json();
showLicenseModal();
} console.log("License status:", data);
currentHostname = data.hostname;
if (data.status === 'valid' && data.license) {
// Licence valide
currentLicense = data.license;
displayLicenseInfo(data.license);
hideLicenseModal();
} else {
// Pas de licence ou licence invalide
showLicenseModal(data.error, data.error_code, data.hostname);
}
} catch (error) {
console.error("Error checking license:", error);
// En cas d'erreur réseau, afficher le modal
showLicenseModal("Impossible de vérifier la licence. Le serveur est-il démarré?", "CONNECTION_ERROR");
}
} }
// ============================================
// AFFICHAGE DU BADGE DE LICENCE
// ============================================
function displayLicenseInfo(license) { function displayLicenseInfo(license) {
console.log("Displaying license info..."); console.log("Displaying license info:", license);
// Chercher le container du badge
const container = document.getElementById('license-badge-container');
if (!container) {
console.error("license-badge-container not found");
return;
}
// Créer le badge
const badge = document.createElement('div');
badge.id = 'license-badge';
badge.style.cssText = `
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 12px 20px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
cursor: pointer;
transition: all 0.3s ease;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
text-align: center;
min-width: 200px;
`;
let badgeText = '✓ Licence Activée';
// Si c'est une licence d'essai
if (license.startsWith('TRIAL-')) {
const daysRemaining = getTrialDaysRemaining(license);
if (daysRemaining <= 0) { const container = document.getElementById('license-badge-container');
badgeText = '⏱️ Essai expiré'; if (!container) {
badge.style.background = 'linear-gradient(135deg, #f44336 0%, #da190b 100%)'; console.error("license-badge-container not found");
} else if (daysRemaining <= 2) { return;
badgeText = `⚠️ ${daysRemaining} jour${daysRemaining > 1 ? 's' : ''} restant${daysRemaining > 1 ? 's' : ''}`;
badge.style.background = 'linear-gradient(135deg, #ff9800 0%, #f57c00 100%)';
} else {
badgeText = `⏱️ Essai: ${daysRemaining} jours`;
} }
}
badge.textContent = badgeText; // Supprimer l'ancien badge s'il existe
badge.onclick = function() { const oldBadge = document.getElementById('license-badge');
alert('Licence: ' + license.substring(0, 50) + '...\n\nClique sur le logo pour gérer ta licence.'); if (oldBadge) oldBadge.remove();
};
container.appendChild(badge); // Créer le badge
const badge = document.createElement('div');
badge.id = 'license-badge';
// Ajouter un hover effect // Déterminer le style selon le type et les jours restants
badge.addEventListener('mouseenter', function() { let badgeStyle = '';
this.style.transform = 'translateY(-3px)'; let badgeText = '';
this.style.boxShadow = '0 6px 25px rgba(102, 126, 234, 0.5)'; let badgeIcon = '✓';
});
badge.addEventListener('mouseleave', function() { const daysRemaining = license.days_remaining || 0;
this.style.transform = 'translateY(0)'; const licenseType = license.type_name || license.type || 'Unknown';
this.style.boxShadow = '0 4px 15px rgba(102, 126, 234, 0.3)';
});
}
function getTrialDaysRemaining(trialLicense) { if (daysRemaining <= 0) {
// Extraire le timestamp du license (format: TRIAL-timestamp) // Expirée
const parts = trialLicense.split('-'); badgeStyle = 'background: linear-gradient(135deg, #f44336 0%, #da190b 100%);';
if (parts.length !== 2) return 0; badgeText = '⚠ Licence expirée';
badgeIcon = '⚠';
} else if (daysRemaining <= 7) {
// Expire bientôt
badgeStyle = 'background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);';
badgeText = `${licenseType} - ${daysRemaining}j restants`;
badgeIcon = '⚠';
} else if (license.type === 'trial') {
// Essai
badgeStyle = 'background: linear-gradient(135deg, #9c27b0 0%, #7b1fa2 100%);';
badgeText = `⏱ Essai - ${daysRemaining}j restants`;
badgeIcon = '⏱';
} else {
// Licence normale valide
badgeStyle = 'background: linear-gradient(135deg, #4caf50 0%, #2e7d32 100%);';
badgeText = `${licenseType}`;
badgeIcon = '✓';
}
const timestamp = parseInt(parts[1]); badge.style.cssText = `
if (isNaN(timestamp)) return 0; ${badgeStyle}
color: white;
padding: 10px 16px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
cursor: pointer;
transition: all 0.3s ease;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
text-align: center;
display: flex;
align-items: center;
gap: 8px;
`;
badge.innerHTML = `
<span style="font-size: 14px;">${badgeIcon}</span>
<span>${badgeText}</span>
`;
// Click pour voir les détails
badge.onclick = function() {
showLicenseDetails(license);
};
// Hover effect
badge.addEventListener('mouseenter', function() {
this.style.transform = 'translateY(-2px)';
this.style.boxShadow = '0 6px 20px rgba(0, 0, 0, 0.3)';
});
badge.addEventListener('mouseleave', function() {
this.style.transform = 'translateY(0)';
this.style.boxShadow = '0 4px 15px rgba(0, 0, 0, 0.2)';
});
container.appendChild(badge);
}
// Créer la date de création // ============================================
const createdDate = new Date(timestamp); // MODAL DE DÉTAILS DE LICENCE
// ============================================
// Ajouter 7 jours function showLicenseDetails(license) {
const expirationDate = new Date(createdDate.getTime() + (7 * 24 * 60 * 60 * 1000)); const modal = document.createElement('div');
modal.id = 'license-details-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
`;
const features = (license.features || []).map(f => `<span style="background: #e3f2fd; color: #1565c0; padding: 4px 8px; border-radius: 4px; font-size: 11px; margin: 2px;">${f}</span>`).join('');
const limits = license.limits || {};
const maxApps = limits.max_apps === -1 ? 'Illimité' : limits.max_apps;
const maxPushes = limits.max_pushes_per_day === -1 ? 'Illimité' : limits.max_pushes_per_day + '/jour';
modal.innerHTML = `
<div style="background: white; border-radius: 16px; padding: 30px; max-width: 500px; width: 90%; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
<h2 style="margin: 0; color: #333;">📋 Détails de la licence</h2>
<button onclick="this.closest('#license-details-modal').remove()" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #999;">&times;</button>
</div>
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin-bottom: 15px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<div>
<div style="color: #666; font-size: 11px; text-transform: uppercase;">Type</div>
<div style="font-weight: 600; color: #333;">${license.type_name || license.type}</div>
</div>
<div>
<div style="color: #666; font-size: 11px; text-transform: uppercase;">ID</div>
<div style="font-weight: 600; color: #333; font-family: monospace;">${license.license_id}</div>
</div>
<div>
<div style="color: #666; font-size: 11px; text-transform: uppercase;">Expire</div>
<div style="font-weight: 600; color: #333;">${license.expires}</div>
</div>
<div>
<div style="color: #666; font-size: 11px; text-transform: uppercase;">Jours restants</div>
<div style="font-weight: 600; color: ${license.days_remaining <= 7 ? '#f44336' : '#4caf50'};">${license.days_remaining}</div>
</div>
</div>
</div>
<div style="margin-bottom: 15px;">
<div style="color: #666; font-size: 11px; text-transform: uppercase; margin-bottom: 5px;">Client</div>
<div style="color: #333;">${license.customer?.name || 'N/A'} (${license.customer?.email || 'N/A'})</div>
</div>
<div style="margin-bottom: 15px;">
<div style="color: #666; font-size: 11px; text-transform: uppercase; margin-bottom: 5px;">Hostname</div>
<div style="color: #333; font-family: monospace;">${license.hostname || currentHostname}</div>
</div>
<div style="margin-bottom: 15px;">
<div style="color: #666; font-size: 11px; text-transform: uppercase; margin-bottom: 5px;">Limites</div>
<div style="color: #333;">Apps: <strong>${maxApps}</strong> | Pushes: <strong>${maxPushes}</strong></div>
</div>
<div style="margin-bottom: 20px;">
<div style="color: #666; font-size: 11px; text-transform: uppercase; margin-bottom: 8px;">Fonctionnalités</div>
<div style="display: flex; flex-wrap: wrap; gap: 4px;">${features || '<span style="color: #999;">Aucune</span>'}</div>
</div>
<div style="display: flex; gap: 10px;">
<button onclick="showLicenseModal(null, null, '${currentHostname}')" style="
flex: 1;
padding: 10px;
background: #667eea;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
">🔄 Changer de licence</button>
<button onclick="this.closest('#license-details-modal').remove()" style="
flex: 1;
padding: 10px;
background: #f5f5f5;
color: #333;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
">Fermer</button>
</div>
</div>
`;
document.body.appendChild(modal);
}
// Calculer les jours restants // ============================================
const now = new Date(); // MODAL D'UPLOAD DE LICENCE
const daysRemaining = Math.ceil((expirationDate - now) / (1000 * 60 * 60 * 24)); // ============================================
console.log("Trial created:", createdDate); function showLicenseModal(error = null, errorCode = null, hostname = null) {
console.log("Trial expires:", expirationDate); console.log("Showing license modal", { error, errorCode, hostname });
console.log("Days remaining:", daysRemaining);
return Math.max(0, daysRemaining); // Supprimer l'ancien modal s'il existe
} hideLicenseModal();
const detailsModal = document.getElementById('license-details-modal');
if (detailsModal) detailsModal.remove();
function showLicenseModal() { const modal = document.createElement('div');
console.log("Showing license modal"); modal.id = 'license-modal';
modal.style.cssText = `
// Créer le modal HTML position: fixed;
const modal = document.createElement('div'); top: 0;
modal.id = 'license-modal'; left: 0;
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 16px;
padding: 40px;
max-width: 500px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
`;
content.innerHTML = `
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="font-size: 32px; margin: 0 0 10px 0; color: #333;">🔐 Git Pusher</h1>
<p style="color: #666; margin: 0; font-size: 14px;">Activation de licence requise</p>
</div>
<div style="background: #f5f7ff; padding: 15px; border-radius: 8px; margin-bottom: 25px; border-left: 4px solid #667eea;">
<p style="margin: 0; color: #667eea; font-weight: 500; font-size: 13px;">
📋 <strong>Hostname détecté:</strong> <span id="detected-hostname">Chargement...</span>
</p>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: #333; margin-bottom: 8px;">
Entrez votre clé de licence
</label>
<textarea id="license-input" placeholder="Collez votre clé de licence ici..." style="
width: 100%; width: 100%;
padding: 12px; height: 100%;
border: 2px solid #e0e0e0; background: rgba(0, 0, 0, 0.7);
border-radius: 8px; display: flex;
font-family: 'Courier New', monospace; align-items: center;
font-size: 12px; justify-content: center;
resize: vertical; z-index: 10000;
min-height: 100px; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
box-sizing: border-box; `;
"></textarea>
<small style="color: #999; display: block; margin-top: 8px;"> // Message d'erreur si présent
Vous n'avez pas de licence? <a href="#" onclick="showGeneratorInfo(); return false;" style="color: #667eea; text-decoration: none;">Cliquez ici</a> let errorHtml = '';
</small> if (error) {
</div> let errorStyle = 'background: #ffebee; color: #c62828; border-left: 4px solid #f44336;';
if (errorCode === 'NO_LICENSE') {
<div id="license-message" style="display: none; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px;"></div> errorStyle = 'background: #fff3e0; color: #e65100; border-left: 4px solid #ff9800;';
}
<div style="display: flex; gap: 10px;"> errorHtml = `
<button onclick="validateLicenseInput()" style=" <div style="${errorStyle} padding: 12px; border-radius: 4px; margin-bottom: 20px; font-size: 13px;">
flex: 1; ${error}
padding: 12px; </div>
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); `;
color: white; }
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
">Activer la licence</button>
<button onclick="skipLicense()" style="
flex: 1;
padding: 12px;
background: #f5f7ff;
color: #667eea;
border: 2px solid #667eea;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 14px;
transition: all 0.3s ease;
">Essai gratuit (7 jours)</button>
</div>
`;
modal.appendChild(content); modal.innerHTML = `
document.body.appendChild(modal); <div style="background: white; border-radius: 16px; padding: 40px; max-width: 550px; width: 90%; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);">
<div style="text-align: center; margin-bottom: 25px;">
<h1 style="font-size: 28px; margin: 0 0 8px 0; color: #333;">🔐 Git Pusher</h1>
<p style="color: #666; margin: 0; font-size: 14px;">Activation de licence requise</p>
</div>
${errorHtml}
<div style="background: #e8f5e9; padding: 15px; border-radius: 8px; margin-bottom: 20px; border-left: 4px solid #4caf50;">
<p style="margin: 0; color: #2e7d32; font-size: 13px;">
<strong>📋 Hostname Splunk:</strong><br>
<code style="background: white; padding: 4px 8px; border-radius: 4px; font-size: 14px; display: inline-block; margin-top: 5px;">${hostname || 'Chargement...'}</code>
</p>
<p style="margin: 10px 0 0 0; color: #558b2f; font-size: 11px;">
Communiquez ce hostname pour obtenir votre licence.
</p>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; font-weight: 600; color: #333; margin-bottom: 10px;">
📄 Fichier de licence (.lic)
</label>
<div id="license-dropzone" style="
border: 2px dashed #ccc;
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
background: #fafafa;
" ondragover="handleDragOver(event)" ondragleave="handleDragLeave(event)" ondrop="handleDrop(event)" onclick="document.getElementById('license-file-input').click()">
<div style="font-size: 40px; margin-bottom: 10px;">📁</div>
<div style="color: #666; font-size: 14px;">
Glissez votre fichier <strong>.lic</strong> ici<br>
<span style="color: #999; font-size: 12px;">ou cliquez pour sélectionner</span>
</div>
<input type="file" id="license-file-input" accept=".lic" style="display: none;" onchange="handleFileSelect(event)">
</div>
<div id="selected-file-info" style="display: none; margin-top: 10px; padding: 10px; background: #e3f2fd; border-radius: 6px;">
<span id="selected-file-name" style="color: #1565c0; font-weight: 500;"></span>
<button onclick="clearSelectedFile()" style="float: right; background: none; border: none; color: #f44336; cursor: pointer;"></button>
</div>
</div>
<div id="license-message" style="display: none; padding: 12px; border-radius: 8px; margin-bottom: 20px; font-size: 14px;"></div>
<button id="activate-btn" onclick="uploadLicense()" disabled style="
width: 100%;
padding: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
font-size: 15px;
transition: all 0.3s ease;
opacity: 0.5;
">Activer la licence</button>
<div style="text-align: center; margin-top: 20px;">
<a href="#" onclick="showContactInfo(); return false;" style="color: #667eea; text-decoration: none; font-size: 13px;">
Besoin d'une licence ? Contactez-nous
</a>
</div>
${currentLicense ? `
<div style="text-align: center; margin-top: 15px;">
<button onclick="hideLicenseModal()" style="background: none; border: none; color: #999; cursor: pointer; font-size: 13px;">
Annuler
</button>
</div>
` : ''}
</div>
`;
document.body.appendChild(modal);
// Récupérer le hostname si pas fourni
if (!hostname) {
fetch(`${LICENSE_API_URL}/license/hostname`)
.then(r => r.json())
.then(d => {
const codeEl = modal.querySelector('code');
if (codeEl) codeEl.textContent = d.hostname || 'Inconnu';
currentHostname = d.hostname;
})
.catch(() => {});
}
}
// Afficher le hostname function hideLicenseModal() {
getHostname().then(hostname => { const modal = document.getElementById('license-modal');
document.getElementById('detected-hostname').textContent = hostname; if (modal) modal.remove();
});
} }
function showGeneratorInfo() { // ============================================
alert(`Pour générer une clé de licence, exécutez sur le serveur Splunk: // GESTION DU FICHIER
// ============================================
python /opt/splunk/etc/apps/pusher_app/bin/license_generator.py let selectedLicenseContent = null;
Cela générera une clé basée sur votre hostname.`); function handleDragOver(event) {
event.preventDefault();
event.stopPropagation();
event.currentTarget.style.borderColor = '#667eea';
event.currentTarget.style.background = '#f0f4ff';
} }
function getHostname() { function handleDragLeave(event) {
return new Promise((resolve) => { event.preventDefault();
fetch('/en-US/splunkd/__raw/services/server/info?output_mode=json') event.stopPropagation();
.then(r => r.json()) event.currentTarget.style.borderColor = '#ccc';
.then(d => { event.currentTarget.style.background = '#fafafa';
const hostname = d.entry?.[0]?.content?.host || 'unknown';
resolve(hostname);
})
.catch(() => resolve('unknown'));
});
} }
function validateLicenseInput() { function handleDrop(event) {
const licenseInput = document.getElementById('license-input').value.trim(); event.preventDefault();
event.stopPropagation();
if (!licenseInput) { event.currentTarget.style.borderColor = '#ccc';
showLicenseMessage('Veuillez entrer une clé de licence', 'error'); event.currentTarget.style.background = '#fafafa';
return;
}
// Afficher le message de chargement
showLicenseMessage('Validation en cours...', 'info');
// Simuler la validation (en production, faire un appel à un serveur)
// Pour l'instant, on accepte juste n'importe quelle licence
if (licenseInput.length > 20) {
// Stocker la licence
setCookie(LICENSE_STORAGE_KEY, licenseInput, 365);
showLicenseMessage('✓ Licence activée avec succès!', 'success');
setTimeout(() => {
closeLicenseModal();
// Afficher les infos de licence
displayLicenseInfo(licenseInput);
}, 1500);
} else {
showLicenseMessage('Format de licence invalide', 'error');
}
}
function skipLicense() { const files = event.dataTransfer.files;
// Créer une licence d'essai avec timestamp (format: TRIAL-timestamp) if (files.length > 0) {
const trialLicense = 'TRIAL-' + Date.now(); processFile(files[0]);
setCookie(LICENSE_STORAGE_KEY, trialLicense, 7); }
const messageEl = document.getElementById('license-message');
messageEl.style.display = 'block';
messageEl.style.background = '#fff3cd';
messageEl.style.color = '#856404';
messageEl.style.border = '1px solid #ffeaa7';
messageEl.textContent = '⏱️ Mode essai activé pour 7 jours';
setTimeout(() => {
closeLicenseModal();
// Afficher les infos de licence
displayLicenseInfo(trialLicense);
}, 1500);
} }
function showLicenseMessage(message, type) { function handleFileSelect(event) {
const messageEl = document.getElementById('license-message'); const files = event.target.files;
messageEl.style.display = 'block'; if (files.length > 0) {
messageEl.textContent = message; processFile(files[0]);
}
if (type === 'success') {
messageEl.style.background = '#d4edda';
messageEl.style.color = '#155724';
messageEl.style.border = '1px solid #c3e6cb';
} else if (type === 'error') {
messageEl.style.background = '#f8d7da';
messageEl.style.color = '#721c24';
messageEl.style.border = '1px solid #f5c6cb';
} else if (type === 'info') {
messageEl.style.background = '#d1ecf1';
messageEl.style.color = '#0c5460';
messageEl.style.border = '1px solid #bee5eb';
}
} }
function validateStoredLicense(license) { function processFile(file) {
console.log("Validating stored license..."); console.log("Processing file:", file.name);
// Pour l'instant, accepter simplement la licence stockée if (!file.name.endsWith('.lic')) {
// En production, faire une validation serveur showLicenseMessage('Veuillez sélectionner un fichier .lic', 'error');
if (license && license.length > 5) { return;
console.log("License is valid"); }
return true;
}
// Si invalide, afficher le modal à nouveau const reader = new FileReader();
showLicenseModal(); reader.onload = function(e) {
return false; selectedLicenseContent = e.target.result;
// Afficher le nom du fichier
document.getElementById('selected-file-info').style.display = 'block';
document.getElementById('selected-file-name').textContent = '📄 ' + file.name;
// Activer le bouton
const btn = document.getElementById('activate-btn');
btn.disabled = false;
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
showLicenseMessage('Fichier prêt à être activé', 'info');
};
reader.onerror = function() {
showLicenseMessage('Erreur de lecture du fichier', 'error');
};
reader.readAsText(file);
} }
function closeLicenseModal() { function clearSelectedFile() {
const modal = document.getElementById('license-modal'); selectedLicenseContent = null;
if (modal) { document.getElementById('selected-file-info').style.display = 'none';
modal.remove(); document.getElementById('license-file-input').value = '';
}
}
function checkLicenseBeforePush() { const btn = document.getElementById('activate-btn');
const license = getCookie(LICENSE_STORAGE_KEY); btn.disabled = true;
btn.style.opacity = '0.5';
if (!license) { const msgEl = document.getElementById('license-message');
alert('Veuillez d\'abord activer une licence'); msgEl.style.display = 'none';
showLicenseModal(); }
return false;
}
// Vérifier si c'est une licence d'essai expirée // ============================================
if (license.startsWith('TRIAL-')) { // UPLOAD ET ACTIVATION
// À implémenter : vérifier la date // ============================================
}
return true; async function uploadLicense() {
if (!selectedLicenseContent) {
showLicenseMessage('Veuillez sélectionner un fichier de licence', 'error');
return;
}
showLicenseMessage('⏳ Validation en cours...', 'info');
const btn = document.getElementById('activate-btn');
btn.disabled = true;
btn.textContent = 'Validation...';
try {
const response = await fetch(`${LICENSE_API_URL}/license/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
license_content: selectedLicenseContent
})
});
const result = await response.json();
console.log("Upload result:", result);
if (result.success) {
showLicenseMessage('✓ Licence activée avec succès!', 'success');
currentLicense = result.license_info;
setTimeout(() => {
hideLicenseModal();
displayLicenseInfo(result.license_info);
}, 1500);
} else {
showLicenseMessage(result.error || 'Erreur d\'activation', 'error');
btn.disabled = false;
btn.textContent = 'Activer la licence';
}
} catch (error) {
console.error("Upload error:", error);
showLicenseMessage('Erreur de connexion au serveur', 'error');
btn.disabled = false;
btn.textContent = 'Activer la licence';
}
} }
// ============================================ // ============================================
// FONCTIONS UTILITAIRES DE COOKIE // UTILITAIRES
// ============================================ // ============================================
function setCookie(name, value, days) { function showLicenseMessage(message, type) {
const d = new Date(); const msgEl = document.getElementById('license-message');
d.setTime(d.getTime() + (days * 24 * 60 * 60 * 1000)); if (!msgEl) return;
const expires = "expires=" + d.toUTCString();
document.cookie = name + "=" + encodeURIComponent(value) + ";" + expires + ";path=/"; msgEl.style.display = 'block';
console.log("Cookie set: " + name); msgEl.textContent = message;
switch (type) {
case 'success':
msgEl.style.background = '#e8f5e9';
msgEl.style.color = '#2e7d32';
msgEl.style.border = '1px solid #a5d6a7';
break;
case 'error':
msgEl.style.background = '#ffebee';
msgEl.style.color = '#c62828';
msgEl.style.border = '1px solid #ef9a9a';
break;
case 'info':
msgEl.style.background = '#e3f2fd';
msgEl.style.color = '#1565c0';
msgEl.style.border = '1px solid #90caf9';
break;
}
} }
function getCookie(name) { function showContactInfo() {
const nameEQ = name + "="; alert(`Pour obtenir une licence Git Pusher:
const ca = document.cookie.split(';');
for (let i = 0; i < ca.length; i++) { 1. Copiez votre hostname Splunk affiché ci-dessus
let c = ca[i].trim(); 2. Contactez-nous avec:
if (c.indexOf(nameEQ) === 0) { - Votre hostname
return decodeURIComponent(c.substring(nameEQ.length)); - Votre email
} - Le type de licence souhaité
}
return ""; Email: support@gitpusher.com
Site: https://gitpusher.com`);
} }
function deleteCookie(name) { // ============================================
setCookie(name, "", -1); // VÉRIFICATION AVANT PUSH
console.log("Cookie deleted: " + name); // ============================================
async function checkLicenseBeforePush() {
try {
const response = await fetch(`${LICENSE_API_URL}/license/status`);
const data = await response.json();
if (data.status !== 'valid') {
alert('⚠️ ' + (data.error || 'Licence invalide ou absente'));
showLicenseModal(data.error, data.error_code, data.hostname);
return false;
}
// Vérifier les limites
const limits = data.license?.limits || {};
const usage = data.usage || {};
if (limits.max_pushes_per_day > 0 && usage.pushes_today >= limits.max_pushes_per_day) {
alert(`⚠️ Limite quotidienne atteinte (${limits.max_pushes_per_day} pushes/jour)`);
return false;
}
return true;
} catch (error) {
console.error("License check error:", error);
alert('⚠️ Impossible de vérifier la licence');
return false;
}
} }
// ============================================
// EXPORT POUR UTILISATION EXTERNE
// ============================================
window.LicenseManager = {
init: initializeLicense,
check: checkLicenseBeforePush,
showModal: showLicenseModal,
getLicense: () => currentLicense,
getHostname: () => currentHostname
};

@ -1,5 +1,11 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
Git Pusher - Main Server
Serveur HTTP pour pousser les applications Splunk vers Git
Avec système de licence par fichier .lic
"""
import sys import sys
import os import os
@ -12,6 +18,29 @@ from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
# Importer le validateur de licence
# En production, ce fichier sera dans le même dossier
try:
from license_validator import (
validate_license,
save_license_file,
check_limits,
increment_usage,
get_splunk_hostname,
get_usage_stats,
parse_license_content
)
except ImportError:
# Fallback pour le développement
print("Warning: license_validator not found, running without license checks")
def validate_license(): return {"valid": True, "type": "dev", "days_remaining": 999}
def save_license_file(c): return {"success": True}
def check_limits(): return {"allowed": True}
def increment_usage(): return {}
def get_splunk_hostname(): return "dev-host"
def get_usage_stats(): return {}
def parse_license_content(c): return {}
# Configuration du logging # Configuration du logging
log_dir = '/opt/splunk/var/log/splunk' log_dir = '/opt/splunk/var/log/splunk'
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
@ -30,65 +59,209 @@ logger = logging.getLogger('git_pusher')
class GitPusherRequestHandler(BaseHTTPRequestHandler): class GitPusherRequestHandler(BaseHTTPRequestHandler):
"""Handler pour les requêtes HTTP""" """Handler pour les requêtes HTTP"""
def send_cors_headers(self):
"""Envoyer les headers CORS complets"""
# Permettre toutes les origines
origin = self.headers.get('Origin', '*')
self.send_header('Access-Control-Allow-Origin', origin)
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS, PUT, DELETE')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, Origin')
self.send_header('Access-Control-Allow-Credentials', 'true')
self.send_header('Access-Control-Max-Age', '86400') # Cache preflight 24h
def do_OPTIONS(self): def do_OPTIONS(self):
"""Traiter les requêtes OPTIONS (CORS preflight)""" """Traiter les requêtes OPTIONS (CORS preflight)"""
logger.info(f"OPTIONS request from {self.headers.get('Origin', 'unknown')}")
self.send_response(200)
self.send_cors_headers()
self.end_headers()
# Important: ne rien écrire dans le body pour OPTIONS
return
def do_GET(self):
"""Traiter les requêtes GET"""
self.send_response(200) self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*') self.send_header('Content-type', 'application/json')
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS') self.send_cors_headers()
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers() self.end_headers()
try:
parsed_url = urlparse(self.path)
path = parsed_url.path
# ============================================
# ENDPOINTS LICENCE
# ============================================
if path == '/license' or path == '/license/status':
# Récupérer le statut de la licence
validation = validate_license()
usage = get_usage_stats()
hostname = get_splunk_hostname()
response = {
"status": "valid" if validation.get("valid") else "invalid",
"hostname": hostname,
"license": validation if validation.get("valid") else None,
"error": validation.get("error") if not validation.get("valid") else None,
"error_code": validation.get("error_code") if not validation.get("valid") else None,
"usage": usage
}
self.wfile.write(json.dumps(response).encode())
elif path == '/license/hostname':
# Juste le hostname
response = {"hostname": get_splunk_hostname()}
self.wfile.write(json.dumps(response).encode())
elif path == '/health':
# Health check
response = {
"status": "ok",
"service": "git_pusher",
"timestamp": datetime.now().isoformat()
}
self.wfile.write(json.dumps(response).encode())
else:
response = {"error": "Unknown endpoint", "path": path}
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"GET error: {e}")
self.wfile.write(json.dumps({"error": str(e)}).encode())
def do_POST(self): def do_POST(self):
"""Traiter les requêtes POST""" """Traiter les requêtes POST"""
# Envoyer les headers CORS EN PREMIER
self.send_response(200) self.send_response(200)
self.send_header('Content-type', 'application/json') self.send_header('Content-type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*') self.send_cors_headers()
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
self.end_headers() self.end_headers()
try: try:
logger.info(f"POST request to {self.path}")
# Parser l'URL et les paramètres
parsed_url = urlparse(self.path) parsed_url = urlparse(self.path)
path = parsed_url.path
query_params = parse_qs(parsed_url.query) query_params = parse_qs(parsed_url.query)
logger.info(f"Query params keys: {list(query_params.keys())}") logger.info(f"POST request to {path}")
# ============================================
# ENDPOINTS LICENCE
# ============================================
if path == '/license/upload':
# Uploader une nouvelle licence
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
try:
data = json.loads(body)
license_content = data.get('license_content', '')
except:
license_content = body
if not license_content:
response = {"success": False, "error": "Contenu de licence vide"}
else:
response = save_license_file(license_content)
self.wfile.write(json.dumps(response).encode())
return
elif path == '/license/delete':
# Supprimer la licence
license_path = "/opt/splunk/etc/apps/pusher_app_prem/local/license.lic"
if os.path.exists(license_path):
os.remove(license_path)
response = {"success": True, "message": "Licence supprimée"}
else:
response = {"success": False, "error": "Aucune licence à supprimer"}
self.wfile.write(json.dumps(response).encode())
return
# ============================================
# ENDPOINT PUSH GIT
# ============================================
elif path == '/push' or path.startswith('/services/'):
# Vérifier la licence avant le push
license_check = check_limits()
if not license_check.get("allowed"):
response = {
"status": "error",
"error_code": "LICENSE_ERROR",
"message": license_check.get("error", "Licence invalide ou limite atteinte")
}
self.wfile.write(json.dumps(response).encode())
return
# Traiter le push Git
self.handle_git_push(query_params)
return
else:
# Ancien comportement pour compatibilité
# Vérifier la licence
license_check = check_limits()
if not license_check.get("allowed"):
response = {
"status": "error",
"error_code": "LICENSE_ERROR",
"message": license_check.get("error", "Licence invalide ou limite atteinte")
}
self.wfile.write(json.dumps(response).encode())
return
self.handle_git_push(query_params)
except Exception as e:
logger.error(f"POST error: {str(e)}", exc_info=True)
response = {
"status": "error",
"message": f"Error: {str(e)}"
}
self.wfile.write(json.dumps(response).encode())
def handle_git_push(self, query_params):
"""Gérer le push Git"""
try:
# Extraire les paramètres # Extraire les paramètres
git_url = query_params.get('git_url', [''])[0] git_url = query_params.get('git_url', [''])[0]
git_branch = query_params.get('git_branch', ['main'])[0] git_branch = query_params.get('git_branch', ['main'])[0]
git_token = query_params.get('git_token', [''])[0] git_token = query_params.get('git_token', [''])[0]
commit_message = query_params.get('commit_message', [''])[0] commit_message = query_params.get('commit_message', [''])[0]
# Accepter soit 'apps' soit 'dashboards'
apps_json = query_params.get('apps', query_params.get('dashboards', ['[]']))[0] apps_json = query_params.get('apps', query_params.get('dashboards', ['[]']))[0]
user = query_params.get('user', ['unknown'])[0] user = query_params.get('user', ['unknown'])[0]
logger.info(f"Parameters received: git_url={git_url}, branch={git_branch}, user={user}") logger.info(f"Parameters: git_url={git_url}, branch={git_branch}, user={user}")
logger.info(f"Raw apps_json: '{apps_json}'")
# Parser les apps # Parser les apps
try: try:
# parse_qs décode déjà, mais au cas où apps = json.loads(apps_json) if isinstance(apps_json, str) else apps_json
if isinstance(apps_json, str):
apps = json.loads(apps_json)
else:
apps = apps_json
except (json.JSONDecodeError, TypeError) as e: except (json.JSONDecodeError, TypeError) as e:
logger.error(f"JSON parse error: {e} - trying to parse: {apps_json}") logger.error(f"JSON parse error: {e}")
apps = [] apps = []
logger.info(f"Parsed apps: {len(apps)} items - {apps}") logger.info(f"Parsed apps: {len(apps)} items")
# Vérifier les limites d'apps
license_info = validate_license()
max_apps = license_info.get("limits", {}).get("max_apps", -1)
if max_apps > 0 and len(apps) > max_apps:
response = {
"status": "error",
"error_code": "APP_LIMIT",
"message": f"Votre licence permet {max_apps} apps max. Vous en avez sélectionné {len(apps)}."
}
self.wfile.write(json.dumps(response).encode())
return
# Valider # Valider les paramètres
if not git_url or not git_token or not commit_message or not apps: if not git_url or not git_token or not commit_message or not apps:
logger.warning(f"Validation failed: git_url={bool(git_url)}, git_token={bool(git_token)}, commit_message={bool(commit_message)}, apps={len(apps)}")
response = { response = {
'status': 'error', "status": "error",
'message': 'Missing required parameters' "message": "Missing required parameters"
} }
self.wfile.write(json.dumps(response).encode()) self.wfile.write(json.dumps(response).encode())
return return
@ -101,14 +274,12 @@ class GitPusherRequestHandler(BaseHTTPRequestHandler):
# Préparer l'URL Git avec le token # Préparer l'URL Git avec le token
git_url_with_token = self.prepare_git_url(git_url, git_token) git_url_with_token = self.prepare_git_url(git_url, git_token)
logger.info(f"Git URL prepared (token inserted)")
logger.debug(f"Git URL with token: {git_url_with_token}")
logger.info("Cloning repository...") logger.info("Cloning repository...")
self.clone_repository(temp_dir, git_url_with_token, git_branch) self.clone_repository(temp_dir, git_url_with_token, git_branch)
# Récupérer TOUTES les applications (dossiers complets) # Récupérer les applications
logger.info("Fetching applications from Splunk...") logger.info("Fetching applications from Splunk...")
dashboard_contents = self.fetch_apps_directories(apps) app_directories = self.fetch_apps_directories(apps)
# Créer le dossier apps # Créer le dossier apps
apps_dir = os.path.join(temp_dir, 'apps') apps_dir = os.path.join(temp_dir, 'apps')
@ -116,41 +287,40 @@ class GitPusherRequestHandler(BaseHTTPRequestHandler):
# Copier les applications # Copier les applications
logger.info("Copying applications to repository...") logger.info("Copying applications to repository...")
for app_data in dashboard_contents: for app_data in app_directories:
app_name = app_data['name'] app_name = app_data['name']
app_path = app_data['path'] app_path = app_data['path']
dest_path = os.path.join(apps_dir, app_name) dest_path = os.path.join(apps_dir, app_name)
if os.path.exists(app_path): if os.path.exists(app_path):
logger.info(f"Copying app {app_name} from {app_path}")
# Supprimer le dossier s'il existe déjà
if os.path.exists(dest_path): if os.path.exists(dest_path):
logger.info(f"Removing existing app directory: {dest_path}")
shutil.rmtree(dest_path) shutil.rmtree(dest_path)
# Copier le dossier
shutil.copytree(app_path, dest_path) shutil.copytree(app_path, dest_path)
logger.info(f"Copied app: {app_name}") logger.info(f"Copied app: {app_name}")
else: else:
logger.warning(f"App path not found: {app_path}") logger.warning(f"App path not found: {app_path}")
# Configurer git # Configurer git
logger.info("Configuring git...")
subprocess.run(['git', 'config', 'user.email', 'splunk@splunk.local'], subprocess.run(['git', 'config', 'user.email', 'splunk@splunk.local'],
cwd=temp_dir, capture_output=True) cwd=temp_dir, capture_output=True)
subprocess.run(['git', 'config', 'user.name', 'Splunk Git Pusher'], subprocess.run(['git', 'config', 'user.name', 'Splunk Git Pusher'],
cwd=temp_dir, capture_output=True) cwd=temp_dir, capture_output=True)
# Commit et push # Commit et push
logger.info("Adding files...")
subprocess.run(['git', 'add', '-A'], cwd=temp_dir, capture_output=True) subprocess.run(['git', 'add', '-A'], cwd=temp_dir, capture_output=True)
full_message = f"{commit_message}\n\nPushed by: {user}\nTimestamp: {datetime.now().isoformat()}" # Ajouter les infos de licence au commit
logger.info("Committing...") license_info = validate_license()
full_message = f"{commit_message}\n\n"
full_message += f"Pushed by: {user}\n"
full_message += f"License: {license_info.get('license_id', 'N/A')} ({license_info.get('type_name', 'N/A')})\n"
full_message += f"Timestamp: {datetime.now().isoformat()}"
result = subprocess.run(['git', 'commit', '-m', full_message], result = subprocess.run(['git', 'commit', '-m', full_message],
cwd=temp_dir, capture_output=True, text=True) cwd=temp_dir, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
logger.warning(f"Commit may have failed or had no changes: {result.stderr}") logger.warning(f"Commit warning: {result.stderr}")
logger.info("Pushing...") logger.info("Pushing...")
result = subprocess.run(['git', 'push', 'origin', git_branch], result = subprocess.run(['git', 'push', 'origin', git_branch],
@ -159,11 +329,15 @@ class GitPusherRequestHandler(BaseHTTPRequestHandler):
if result.returncode != 0: if result.returncode != 0:
raise Exception(f"Push failed: {result.stderr}") raise Exception(f"Push failed: {result.stderr}")
# Incrémenter les stats d'utilisation
increment_usage()
logger.info("Push successful!") logger.info("Push successful!")
response = { response = {
'status': 'success', "status": "success",
'message': f'Successfully pushed {len(dashboard_contents)} dashboards from {len(apps)} application(s) to Git', "message": f"Successfully pushed {len(app_directories)} application(s) to Git",
'dashboards_pushed': len(dashboard_contents) "apps_pushed": len(app_directories),
"license_type": license_info.get("type_name", "N/A")
} }
self.wfile.write(json.dumps(response).encode()) self.wfile.write(json.dumps(response).encode())
@ -172,36 +346,28 @@ class GitPusherRequestHandler(BaseHTTPRequestHandler):
shutil.rmtree(temp_dir, ignore_errors=True) shutil.rmtree(temp_dir, ignore_errors=True)
except Exception as e: except Exception as e:
logger.error(f"Error: {str(e)}", exc_info=True) logger.error(f"Git push error: {str(e)}", exc_info=True)
response = { response = {
'status': 'error', "status": "error",
'message': f'Error: {str(e)}' "message": f"Error: {str(e)}"
} }
self.wfile.write(json.dumps(response).encode()) self.wfile.write(json.dumps(response).encode())
def log_message(self, format, *args): def log_message(self, format, *args):
"""Éviter les logs HTTP par défaut"""
logger.debug(format % args) logger.debug(format % args)
@staticmethod @staticmethod
def prepare_git_url(git_url, token): def prepare_git_url(git_url, token):
"""Préparer l'URL Git avec le token inséré""" """Préparer l'URL Git avec le token"""
logger.info(f"Preparing git URL with token")
# Si l'URL contient déjà un token (format: https://user:token@host/repo)
# on le remplace
if '@' in git_url: if '@' in git_url:
# Extraire la partie sans le token
protocol = git_url.split('://')[0] protocol = git_url.split('://')[0]
rest = git_url.split('://', 1)[1] rest = git_url.split('://', 1)[1]
host_and_path = rest.split('@', 1)[1] if '@' in rest else rest host_and_path = rest.split('@', 1)[1] if '@' in rest else rest
return f"{protocol}://{token}@{host_and_path}" return f"{protocol}://{token}@{host_and_path}"
# Si l'URL est juste https://host/repo (sans credentials)
if git_url.startswith('https://') or git_url.startswith('http://'): if git_url.startswith('https://') or git_url.startswith('http://'):
protocol = git_url.split('://')[0] protocol = git_url.split('://')[0]
host_and_path = git_url.split('://', 1)[1] host_and_path = git_url.split('://', 1)[1]
# Insérer le token au format user:token@host ou juste token@host
return f"{protocol}://{token}@{host_and_path}" return f"{protocol}://{token}@{host_and_path}"
return git_url return git_url
@ -224,7 +390,7 @@ class GitPusherRequestHandler(BaseHTTPRequestHandler):
@staticmethod @staticmethod
def fetch_apps_directories(apps): def fetch_apps_directories(apps):
"""Récupérer les dossiers complets des applications""" """Récupérer les dossiers des applications"""
logger.info(f"Fetching directories for {len(apps)} applications") logger.info(f"Fetching directories for {len(apps)} applications")
splunk_home = '/opt/splunk' splunk_home = '/opt/splunk'
@ -233,10 +399,14 @@ class GitPusherRequestHandler(BaseHTTPRequestHandler):
app_directories = [] app_directories = []
for app in apps: for app in apps:
app_id = app.get('id') or app.get('app_id') app_id = app.get('id') or app.get('app_id') or app.get('name')
app_path = os.path.join(apps_base_path, app_id)
logger.info(f"Checking app directory: {app_path}") # Vérifier que app_id n'est pas None
if not app_id:
logger.warning(f"Skipping app with no ID: {app}")
continue
app_path = os.path.join(apps_base_path, app_id)
if os.path.isdir(app_path): if os.path.isdir(app_path):
app_directories.append({ app_directories.append({
@ -246,95 +416,29 @@ class GitPusherRequestHandler(BaseHTTPRequestHandler):
for dirpath, dirnames, filenames in os.walk(app_path) for dirpath, dirnames, filenames in os.walk(app_path)
for filename in filenames) for filename in filenames)
}) })
logger.info(f"Found app: {app_id} at {app_path}") logger.info(f"Found app: {app_id}")
else: else:
logger.warning(f"App directory not found: {app_path}") logger.warning(f"App directory not found: {app_path}")
logger.info(f"Successfully found {len(app_directories)} application directories")
return app_directories return app_directories
"""Récupérer TOUS les dashboards de chaque application"""
logger.info(f"Fetching dashboards from {len(apps)} applications")
import urllib.request
import urllib.error
import ssl
import base64
# Ignorer les certificats SSL auto-signés
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
dashboard_contents = []
# Lire le fichier de configuration Splunk pour obtenir les credentials
# Ou utiliser des credentials par défaut
splunk_username = os.environ.get('SPLUNK_USERNAME', 'admin')
splunk_password = os.environ.get('SPLUNK_PASSWORD', 'changeme')
# Créer l'authentification Basic
credentials = base64.b64encode(f"{splunk_username}:{splunk_password}".encode()).decode()
for app in apps:
app_id = app.get('id') or app.get('app_id')
logger.info(f"Fetching all dashboards from app: {app_id}")
try:
# Récupérer la liste de TOUS les dashboards de cette app
api_url = f"https://127.0.0.1:8089/servicesNS/-/{app_id}/data/ui/views?output_mode=json&count=0"
logger.debug(f"API URL: {api_url}")
req = urllib.request.Request(api_url)
req.add_header('Authorization', f'Basic {credentials}')
with urllib.request.urlopen(req, timeout=15, context=ssl_context) as response:
api_data = json.loads(response.read().decode('utf-8'))
if 'entry' in api_data and len(api_data['entry']) > 0:
for entry in api_data['entry']:
try:
dashboard_id = entry.get('name')
content = entry.get('content', {})
# eai:data contient le XML complet du dashboard
dashboard_xml = content.get('eai:data', '')
if dashboard_xml:
dashboard_contents.append({
'id': f"{app_id}_{dashboard_id}",
'app': app_id,
'content': dashboard_xml,
'name': dashboard_id
})
logger.debug(f"Fetched: {dashboard_id} from {app_id}")
except Exception as e:
logger.error(f"Error processing dashboard entry: {str(e)}")
logger.info(f"Found {len([d for d in dashboard_contents if d['app'] == app_id])} dashboards in {app_id}")
else:
logger.warning(f"No dashboards found in app {app_id}")
except urllib.error.HTTPError as e:
logger.error(f"HTTP {e.code} when fetching app {app_id}: {e.reason}")
except urllib.error.URLError as e:
logger.error(f"Cannot reach Splunk API for app {app_id}: {e.reason}")
except Exception as e:
logger.error(f"Error fetching dashboards from {app_id}: {str(e)}")
logger.info(f"Successfully fetched {len(dashboard_contents)} dashboards total")
return dashboard_contents
def start_server(port=9999): def start_server(port=9999):
"""Démarrer le serveur HTTP""" """Démarrer le serveur HTTP"""
server = HTTPServer(('0.0.0.0', port), GitPusherRequestHandler) server = HTTPServer(('0.0.0.0', port), GitPusherRequestHandler)
logger.info(f"Git Pusher server listening on 0.0.0.0:{port} (HTTP)") logger.info(f"Git Pusher server listening on 0.0.0.0:{port}")
# Afficher le statut de la licence au démarrage
license_status = validate_license()
if license_status.get("valid"):
logger.info(f"License: {license_status.get('type_name')} - {license_status.get('days_remaining')} days remaining")
else:
logger.warning(f"License: {license_status.get('error', 'Invalid')}")
server.serve_forever() server.serve_forever()
if __name__ == '__main__': if __name__ == '__main__':
# Démarrer le serveur en background
port = 9999 port = 9999
logger.info(f"Starting Git Pusher on port {port}") logger.info(f"Starting Git Pusher on port {port}")
start_server(port) start_server(port)

@ -0,0 +1,191 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Git Pusher - License Endpoints
Endpoints REST pour gérer les licences
"""
import sys
import os
import json
import tempfile
from http.server import BaseHTTPRequestHandler
# Ajouter le chemin du module
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
from license_validator import LicenseValidator, get_license_status, LICENSE_FILE_PATH
class LicenseHandler(BaseHTTPRequestHandler):
"""Handler pour les requêtes de licence"""
def do_OPTIONS(self):
"""Gérer les requêtes OPTIONS (CORS preflight)"""
self.send_response(200)
self.send_headers()
self.end_headers()
def do_GET(self):
"""Gérer les requêtes GET"""
if self.path == '/custom/git_pusher/license_status':
self.handle_license_status()
else:
self.send_error(404)
def do_POST(self):
"""Gérer les requêtes POST"""
if self.path == '/custom/git_pusher/install_license':
self.handle_install_license()
elif self.path == '/custom/git_pusher/request_trial':
self.handle_request_trial()
else:
self.send_error(404)
def send_headers(self):
"""Envoyer les headers CORS"""
self.send_header('Content-type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
def handle_license_status(self):
"""Vérifier le statut de la licence"""
try:
status = get_license_status()
self.send_response(200)
self.send_headers()
self.end_headers()
self.wfile.write(json.dumps(status).encode())
except Exception as e:
self.send_response(500)
self.send_headers()
self.end_headers()
self.wfile.write(json.dumps({
'licensed': False,
'errors': [f'Erreur serveur: {str(e)}']
}).encode())
def handle_install_license(self):
"""Installer un fichier de licence uploadé"""
try:
# Lire le body
content_length = int(self.headers['Content-Length'])
body = self.rfile.read(content_length)
data = json.loads(body.decode())
license_content = data.get('license_file')
filename = data.get('filename', 'uploaded.lic')
if not license_content:
raise Exception("Aucun contenu de licence fourni")
# Créer un fichier temporaire
with tempfile.NamedTemporaryFile(mode='w', suffix='.lic', delete=False) as temp_file:
temp_file.write(license_content)
temp_path = temp_file.name
try:
# Valider et installer
validator = LicenseValidator()
result = validator.install_license(temp_path, LICENSE_FILE_PATH)
self.send_response(200)
self.send_headers()
self.end_headers()
self.wfile.write(json.dumps(result).encode())
finally:
# Nettoyer le fichier temporaire
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception as e:
self.send_response(400)
self.send_headers()
self.end_headers()
self.wfile.write(json.dumps({
'success': False,
'message': f'Erreur: {str(e)}'
}).encode())
def handle_request_trial(self):
"""Créer une licence d'essai"""
try:
from datetime import datetime, timedelta
import socket
import hashlib
# Générer une licence d'essai basique (7 jours)
hostname = socket.gethostname()
trial_license = {
"license": {
"version": "1.0",
"license_id": "TRIAL-" + hashlib.md5(
f"{hostname}{datetime.now()}".encode()
).hexdigest()[:8].upper(),
"customer": {
"name": "Trial User",
"hostname": hostname
},
"validity": {
"issued": datetime.now().isoformat(),
"expires": (datetime.now() + timedelta(days=7)).isoformat(),
"days": 7
},
"limits": {
"max_pushes": 50,
"max_apps": None
},
"features": {
"git_push": True,
"multi_branch": False,
"auto_commit": False,
"webhooks": False
},
"type": "trial"
},
"signature": "TRIAL-NO-SIGNATURE",
"checksum": "TRIAL"
}
# Créer le dossier si nécessaire
os.makedirs(os.path.dirname(LICENSE_FILE_PATH), exist_ok=True)
# Sauvegarder
with open(LICENSE_FILE_PATH, 'w') as f:
json.dump(trial_license, f, indent=2)
self.send_response(200)
self.send_headers()
self.end_headers()
self.wfile.write(json.dumps({
'success': True,
'message': 'Licence d\'essai créée (7 jours, 50 pushes max)',
'license_info': {
'license_id': trial_license['license']['license_id'],
'type': 'trial',
'expires': trial_license['license']['validity']['expires'],
'days_remaining': 7
}
}).encode())
except Exception as e:
self.send_response(500)
self.send_headers()
self.end_headers()
self.wfile.write(json.dumps({
'success': False,
'message': f'Erreur: {str(e)}'
}).encode())
def log_message(self, format, *args):
"""Override pour éviter les logs HTTP par défaut"""
pass

@ -1,173 +0,0 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Git Pusher - License Generator
Génère des clés de licence basées sur le hostname Splunk
"""
import hashlib
import hmac
import base64
import socket
from datetime import datetime, timedelta
import json
# Secret key pour générer les licences (À CHANGER !)
SECRET_KEY = "git_pusher_license_secret_2024"
def get_hostname():
"""Récupérer le hostname du serveur"""
return socket.gethostname()
def generate_license(hostname, days_valid=365, max_pushes=None):
"""
Générer une clé de licence
Args:
hostname: nom d'hôte Splunk
days_valid: nombre de jours de validité
max_pushes: nombre maximum de pushes (None = illimité)
Returns:
license_key: clé de licence formatée
"""
# Créer la date d'expiration
expiration_date = datetime.now() + timedelta(days=days_valid)
expiration_str = expiration_date.strftime("%Y-%m-%d")
# Créer le payload
payload = {
"hostname": hostname,
"expiration": expiration_str,
"max_pushes": max_pushes,
"issued": datetime.now().strftime("%Y-%m-%d")
}
# Convertir en JSON et encoder en base64
payload_json = json.dumps(payload, separators=(',', ':'))
payload_b64 = base64.b64encode(payload_json.encode()).decode()
# Créer la signature HMAC
signature = hmac.new(
SECRET_KEY.encode(),
payload_b64.encode(),
hashlib.sha256
).hexdigest()[:16] # Prendre les 16 premiers caractères
# Formater la clé de licence
license_key = f"{signature}-{payload_b64}"
return license_key, payload
def validate_license(license_key, hostname):
"""
Valider une clé de licence
Args:
license_key: clé à valider
hostname: hostname Splunk actuel
Returns:
dict: {valid: bool, error: str, expiration: str, max_pushes: int}
"""
try:
# Séparer signature et payload
parts = license_key.split('-', 1)
if len(parts) != 2:
return {
'valid': False,
'error': 'Format de clé invalide'
}
signature, payload_b64 = parts
# Vérifier la signature
expected_signature = hmac.new(
SECRET_KEY.encode(),
payload_b64.encode(),
hashlib.sha256
).hexdigest()[:16]
if signature != expected_signature:
return {
'valid': False,
'error': 'Signature invalide - clé corrompue ou falsifiée'
}
# Décoder le payload
try:
payload_json = base64.b64decode(payload_b64).decode()
payload = json.loads(payload_json)
except Exception as e:
return {
'valid': False,
'error': f'Erreur de décodage: {str(e)}'
}
# Vérifier le hostname
if payload.get('hostname') != hostname:
return {
'valid': False,
'error': f'Cette licence est pour {payload.get("hostname")}, pas {hostname}'
}
# Vérifier l'expiration
expiration = datetime.strptime(payload.get('expiration'), '%Y-%m-%d')
if datetime.now() > expiration:
return {
'valid': False,
'error': f'Licence expirée le {payload.get("expiration")}'
}
return {
'valid': True,
'expiration': payload.get('expiration'),
'max_pushes': payload.get('max_pushes'),
'days_remaining': (expiration - datetime.now()).days
}
except Exception as e:
return {
'valid': False,
'error': f'Erreur de validation: {str(e)}'
}
if __name__ == '__main__':
import sys
hostname = get_hostname()
print("=" * 60)
print("Git Pusher - License Generator")
print("=" * 60)
print(f"\nHostname détecté: {hostname}")
if len(sys.argv) > 1 and sys.argv[1] == 'validate':
# Mode validation
license_key = sys.argv[2] if len(sys.argv) > 2 else input("Entrez la clé de licence: ")
result = validate_license(license_key, hostname)
print("\nRésultat de validation:")
print(json.dumps(result, indent=2, ensure_ascii=False))
else:
# Mode génération
days = int(sys.argv[1]) if len(sys.argv) > 1 else 365
max_pushes = int(sys.argv[2]) if len(sys.argv) > 2 else None
license_key, payload = generate_license(hostname, days, max_pushes)
print(f"\n📋 Payload:")
print(json.dumps(payload, indent=2, ensure_ascii=False))
print(f"\n🔑 Clé de licence générée:")
print(license_key)
print(f"\n✓ Valide pour: {days} jours")
if max_pushes:
print(f"✓ Pushes limités à: {max_pushes}")
# Tester la validation
print(f"\n✔️ Test de validation:")
result = validate_license(license_key, hostname)
print(json.dumps(result, indent=2, ensure_ascii=False))

@ -0,0 +1,485 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Git Pusher - License Validator (Server-side)
Ce fichier doit être déployé sur le serveur Splunk dans l'application
Emplacement: /opt/splunk/etc/apps/pusher_app_prem/bin/license_validator.py
"""
import hashlib
import hmac
import base64
import json
import os
import socket
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
import logging
# Configuration du logging
log_dir = '/opt/splunk/var/log/splunk'
os.makedirs(log_dir, exist_ok=True)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(os.path.join(log_dir, 'license_validator.log')),
logging.StreamHandler()
]
)
logger = logging.getLogger('license_validator')
# ============================================
# CONFIGURATION - DOIT CORRESPONDRE AU GÉNÉRATEUR !
# ============================================
# IMPORTANT: Cette clé DOIT être identique à celle du générateur
SECRET_KEY = "git_pusher_super_secret_key_2024_change_me_in_production"
# Chemin vers le fichier de licence
LICENSE_FILE_PATH = "/opt/splunk/etc/apps/pusher_app_prem/local/license.lic"
# Fichier de cache pour les stats d'utilisation
USAGE_STATS_PATH = "/opt/splunk/etc/apps/pusher_app_prem/local/usage_stats.json"
def get_splunk_hostname():
"""Récupérer le hostname du serveur Splunk"""
# Essayer d'abord via l'API Splunk
try:
import urllib.request
import ssl
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
splunk_username = os.environ.get('SPLUNK_USERNAME', 'admin')
splunk_password = os.environ.get('SPLUNK_PASSWORD', '2312Jocpam!?')
credentials = base64.b64encode(f"{splunk_username}:{splunk_password}".encode()).decode()
req = urllib.request.Request("https://127.0.0.1:8089/services/server/info?output_mode=json")
req.add_header('Authorization', f'Basic {credentials}')
with urllib.request.urlopen(req, timeout=5, context=ssl_context) as response:
data = json.loads(response.read().decode('utf-8'))
hostname = data.get('entry', [{}])[0].get('content', {}).get('host', '')
if hostname:
return hostname.lower().strip()
except Exception as e:
logger.warning(f"Could not get hostname via Splunk API: {e}")
# Fallback sur le hostname système
return socket.gethostname().lower().strip()
def create_signature(data_str):
"""Créer une signature HMAC-SHA256"""
signature = hmac.new(
SECRET_KEY.encode('utf-8'),
data_str.encode('utf-8'),
hashlib.sha256
).hexdigest()
return signature
def verify_signature(data_str, signature):
"""Vérifier une signature"""
expected = create_signature(data_str)
return hmac.compare_digest(expected, signature)
def read_license_file(filepath=None):
"""Lire et parser un fichier de licence"""
if filepath is None:
filepath = LICENSE_FILE_PATH
try:
if not os.path.exists(filepath):
return None
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Extraire la partie base64 (ignorer les commentaires)
lines = content.strip().split('\n')
base64_lines = [l for l in lines if not l.startswith('#') and l.strip()]
base64_content = ''.join(base64_lines)
# Décoder
decoded = base64.b64decode(base64_content).decode('utf-8')
license_file = json.loads(decoded)
return license_file
except Exception as e:
logger.error(f"Error reading license file: {e}")
return None
def parse_license_content(content):
"""Parser le contenu d'un fichier de licence (pour l'upload)"""
try:
# Extraire la partie base64 (ignorer les commentaires)
lines = content.strip().split('\n')
base64_lines = [l for l in lines if not l.startswith('#') and l.strip()]
base64_content = ''.join(base64_lines)
# Décoder
decoded = base64.b64decode(base64_content).decode('utf-8')
license_file = json.loads(decoded)
return license_file
except Exception as e:
logger.error(f"Error parsing license content: {e}")
return None
def validate_license(license_file=None, current_hostname=None):
"""
Valider une licence
Args:
license_file: Contenu du fichier de licence (dict) - si None, lit le fichier par défaut
current_hostname: Hostname actuel - si None, détecte automatiquement
Returns:
dict avec {valid: bool, error: str, license_info: dict}
"""
try:
# Lire la licence si non fournie
if license_file is None:
license_file = read_license_file()
if license_file is None:
return {
"valid": False,
"error": "Aucun fichier de licence trouvé",
"error_code": "NO_LICENSE"
}
# Récupérer le hostname si non fourni
if current_hostname is None:
current_hostname = get_splunk_hostname()
license_data = license_file.get("license", {})
signature = license_file.get("signature", "")
# Recréer le JSON pour vérifier la signature
license_json = json.dumps(license_data, separators=(',', ':'), sort_keys=True)
# Vérifier la signature
if not verify_signature(license_json, signature):
return {
"valid": False,
"error": "Signature invalide - fichier corrompu ou modifié",
"error_code": "INVALID_SIGNATURE"
}
# Vérifier le hostname
expected_hostname = license_data.get("binding", {}).get("hostname", "").lower()
current_hostname_clean = current_hostname.lower().strip()
if current_hostname_clean != expected_hostname:
return {
"valid": False,
"error": f"Cette licence est pour '{expected_hostname}', pas '{current_hostname_clean}'",
"error_code": "HOSTNAME_MISMATCH",
"expected_hostname": expected_hostname,
"current_hostname": current_hostname_clean
}
# Vérifier l'expiration
expires_timestamp = license_data.get("dates", {}).get("expires_timestamp", 0)
if datetime.now().timestamp() > expires_timestamp:
expires_date = license_data.get("dates", {}).get("expires", "unknown")
return {
"valid": False,
"error": f"Licence expirée le {expires_date}",
"error_code": "EXPIRED"
}
# Calculer les jours restants
days_remaining = (expires_timestamp - datetime.now().timestamp()) / (24 * 3600)
return {
"valid": True,
"license_id": license_data.get("license_id"),
"type": license_data.get("type"),
"type_name": license_data.get("type_name"),
"customer": license_data.get("customer", {}),
"expires": license_data.get("dates", {}).get("expires"),
"days_remaining": int(days_remaining),
"limits": license_data.get("limits", {}),
"features": license_data.get("features", []),
"hostname": expected_hostname
}
except Exception as e:
logger.error(f"Validation error: {e}")
return {
"valid": False,
"error": f"Erreur de validation: {str(e)}",
"error_code": "VALIDATION_ERROR"
}
def save_license_file(content):
"""
Sauvegarder un nouveau fichier de licence
Args:
content: Contenu du fichier .lic
Returns:
dict avec {success: bool, error: str}
"""
try:
# Créer le dossier local si nécessaire
local_dir = os.path.dirname(LICENSE_FILE_PATH)
os.makedirs(local_dir, exist_ok=True)
# Parser d'abord pour valider
license_file = parse_license_content(content)
if license_file is None:
return {
"success": False,
"error": "Format de fichier de licence invalide"
}
# Valider la licence
validation = validate_license(license_file)
if not validation.get("valid"):
return {
"success": False,
"error": validation.get("error", "Licence invalide")
}
# Sauvegarder
with open(LICENSE_FILE_PATH, 'w', encoding='utf-8') as f:
f.write(content)
logger.info(f"License saved: {validation.get('license_id')}")
return {
"success": True,
"license_info": validation
}
except Exception as e:
logger.error(f"Error saving license: {e}")
return {
"success": False,
"error": str(e)
}
def get_usage_stats():
"""Récupérer les statistiques d'utilisation"""
try:
if os.path.exists(USAGE_STATS_PATH):
with open(USAGE_STATS_PATH, 'r') as f:
return json.load(f)
except:
pass
return {
"pushes_today": 0,
"pushes_total": 0,
"last_push_date": None,
"apps_pushed": []
}
def increment_usage():
"""Incrémenter le compteur d'utilisation"""
stats = get_usage_stats()
today = datetime.now().strftime("%Y-%m-%d")
# Reset si nouveau jour
if stats.get("last_push_date") != today:
stats["pushes_today"] = 0
stats["last_push_date"] = today
stats["pushes_today"] += 1
stats["pushes_total"] += 1
try:
os.makedirs(os.path.dirname(USAGE_STATS_PATH), exist_ok=True)
with open(USAGE_STATS_PATH, 'w') as f:
json.dump(stats, f)
except Exception as e:
logger.error(f"Error saving usage stats: {e}")
return stats
def check_limits():
"""
Vérifier si les limites de la licence sont respectées
Returns:
dict avec {allowed: bool, error: str}
"""
validation = validate_license()
if not validation.get("valid"):
return {
"allowed": False,
"error": validation.get("error")
}
limits = validation.get("limits", {})
stats = get_usage_stats()
# Vérifier pushes par jour
max_pushes = limits.get("max_pushes_per_day", -1)
if max_pushes > 0 and stats.get("pushes_today", 0) >= max_pushes:
return {
"allowed": False,
"error": f"Limite quotidienne atteinte ({max_pushes} pushes/jour)"
}
return {
"allowed": True,
"license_info": validation,
"usage": stats
}
# ============================================
# API REST POUR L'INTERFACE WEB
# ============================================
class LicenseAPIHandler(BaseHTTPRequestHandler):
"""Handler pour les requêtes de l'API licence"""
def send_cors_headers(self):
"""Envoyer les headers CORS complets"""
origin = self.headers.get('Origin', '*')
self.send_header('Access-Control-Allow-Origin', origin)
self.send_header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS, PUT, DELETE')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With, Accept, Origin')
self.send_header('Access-Control-Allow-Credentials', 'true')
self.send_header('Access-Control-Max-Age', '86400')
def do_OPTIONS(self):
logger.info(f"OPTIONS request from {self.headers.get('Origin', 'unknown')}")
self.send_response(200)
self.send_cors_headers()
self.end_headers()
return
def do_GET(self):
"""GET /license - Récupérer les infos de licence"""
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.send_cors_headers()
self.end_headers()
try:
if '/license/status' in self.path or self.path == '/license':
validation = validate_license()
usage = get_usage_stats()
hostname = get_splunk_hostname()
response = {
"status": "valid" if validation.get("valid") else "invalid",
"hostname": hostname,
"license": validation if validation.get("valid") else None,
"error": validation.get("error") if not validation.get("valid") else None,
"error_code": validation.get("error_code") if not validation.get("valid") else None,
"usage": usage
}
elif '/license/hostname' in self.path:
response = {
"hostname": get_splunk_hostname()
}
else:
response = {"error": "Unknown endpoint"}
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"GET error: {e}")
self.wfile.write(json.dumps({"error": str(e)}).encode())
def do_POST(self):
"""POST /license/upload - Uploader une nouvelle licence"""
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.send_cors_headers()
self.end_headers()
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
if '/license/upload' in self.path:
# Le body contient le contenu du fichier .lic
data = json.loads(body)
license_content = data.get('license_content', '')
if not license_content:
response = {"success": False, "error": "Contenu de licence vide"}
else:
response = save_license_file(license_content)
elif '/license/delete' in self.path:
# Supprimer la licence
if os.path.exists(LICENSE_FILE_PATH):
os.remove(LICENSE_FILE_PATH)
response = {"success": True, "message": "Licence supprimée"}
else:
response = {"success": False, "error": "Aucune licence à supprimer"}
else:
response = {"error": "Unknown endpoint"}
self.wfile.write(json.dumps(response).encode())
except Exception as e:
logger.error(f"POST error: {e}")
self.wfile.write(json.dumps({"error": str(e)}).encode())
def log_message(self, format, *args):
logger.debug(format % args)
def start_license_server(port=9998):
"""Démarrer le serveur API licence (optionnel, peut être intégré au serveur principal)"""
server = HTTPServer(('127.0.0.1', port), LicenseAPIHandler)
logger.info(f"License API server listening on 127.0.0.1:{port}")
server.serve_forever()
# ============================================
# CLI POUR TESTS
# ============================================
if __name__ == '__main__':
import sys
if len(sys.argv) > 1:
if sys.argv[1] == "status":
result = validate_license()
print(json.dumps(result, indent=2, ensure_ascii=False))
elif sys.argv[1] == "hostname":
print(f"Hostname: {get_splunk_hostname()}")
elif sys.argv[1] == "server":
start_license_server()
else:
print("Usage:")
print(" python license_validator.py status # Vérifier la licence")
print(" python license_validator.py hostname # Afficher le hostname")
print(" python license_validator.py server # Démarrer l'API")
else:
result = validate_license()
print(json.dumps(result, indent=2, ensure_ascii=False))

@ -1,5 +1,249 @@
#!/bin/bash #!/bin/bash
export SPLUNK_USERNAME=admin # ============================================
export SPLUNK_PASSWORD='2312Jocpam!?' # Git Pusher - Start Script
python3 /opt/splunk/etc/apps/pusher_app/bin/git_pusher.py > /opt/splunk/var/log/splunk/git_pusher_startup.log 2>&1 & # Version 2.0 avec système de licence
echo $! > /opt/splunk/etc/apps/pusher_app/bin/git_pusher.pid # ============================================
# Configuration
SPLUNK_HOME=${SPLUNK_HOME:-/opt/splunk}
APP_HOME="${SPLUNK_HOME}/etc/apps/pusher_app_prem"
BIN_DIR="${APP_HOME}/bin"
LOG_DIR="${SPLUNK_HOME}/var/log/splunk"
PID_FILE="${BIN_DIR}/git_pusher.pid"
# Couleurs pour les logs
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Fonction de logging
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Vérifier si le serveur est déjà en cours d'exécution
check_running() {
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
return 0 # Running
fi
fi
return 1 # Not running
}
# Démarrer le serveur
start_server() {
log_info "Starting Git Pusher server..."
# Vérifier si déjà en cours
if check_running; then
log_warn "Git Pusher is already running (PID: $(cat $PID_FILE))"
return 1
fi
# Créer le répertoire de logs
mkdir -p "$LOG_DIR"
# Variables d'environnement pour l'authentification Splunk
# IMPORTANT: Modifiez ces valeurs ou utilisez des variables d'environnement
export SPLUNK_USERNAME=${SPLUNK_USERNAME:-admin}
export SPLUNK_PASSWORD=${SPLUNK_PASSWORD:-changeme}
# Démarrer le serveur Python
cd "$BIN_DIR"
python3 git_pusher.py > "${LOG_DIR}/git_pusher_startup.log" 2>&1 &
# Sauvegarder le PID
echo $! > "$PID_FILE"
# Attendre un peu et vérifier
sleep 2
if check_running; then
log_info "Git Pusher started successfully (PID: $(cat $PID_FILE))"
log_info "Server listening on port 9999"
# Vérifier le statut de la licence
HOSTNAME=$(hostname)
log_info "Hostname: $HOSTNAME"
if [ -f "${APP_HOME}/local/license.lic" ]; then
log_info "License file found"
else
log_warn "No license file found at ${APP_HOME}/local/license.lic"
log_warn "The application will require license activation"
fi
return 0
else
log_error "Failed to start Git Pusher"
log_error "Check logs at ${LOG_DIR}/git_pusher.log"
return 1
fi
}
# Arrêter le serveur
stop_server() {
log_info "Stopping Git Pusher server..."
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if ps -p $PID > /dev/null 2>&1; then
kill $PID
sleep 2
# Force kill si nécessaire
if ps -p $PID > /dev/null 2>&1; then
log_warn "Force killing process..."
kill -9 $PID
fi
rm -f "$PID_FILE"
log_info "Git Pusher stopped"
return 0
else
log_warn "Process not running, cleaning up PID file"
rm -f "$PID_FILE"
return 0
fi
else
log_warn "PID file not found, Git Pusher may not be running"
return 1
fi
}
# Redémarrer le serveur
restart_server() {
log_info "Restarting Git Pusher server..."
stop_server
sleep 1
start_server
}
# Afficher le statut
show_status() {
echo "============================================"
echo "Git Pusher Status"
echo "============================================"
if check_running; then
PID=$(cat "$PID_FILE")
echo -e "Status: ${GREEN}RUNNING${NC}"
echo "PID: $PID"
echo "Port: 9999"
else
echo -e "Status: ${RED}STOPPED${NC}"
fi
echo ""
echo "Paths:"
echo " App Home: $APP_HOME"
echo " Bin Dir: $BIN_DIR"
echo " Log Dir: $LOG_DIR"
echo ""
# Statut de la licence
echo "License:"
if [ -f "${APP_HOME}/local/license.lic" ]; then
echo -e " File: ${GREEN}Present${NC}"
# Essayer de lire quelques infos
if command -v python3 &> /dev/null; then
python3 -c "
import sys
sys.path.insert(0, '$BIN_DIR')
from license_validator import validate_license
result = validate_license()
if result.get('valid'):
print(f\" Type: {result.get('type_name', 'N/A')}\")
print(f\" Expires: {result.get('expires', 'N/A')}\")
print(f\" Days remaining: {result.get('days_remaining', 'N/A')}\")
else:
print(f\" Status: Invalid - {result.get('error', 'Unknown error')}\")
" 2>/dev/null || echo " Unable to read license details"
fi
else
echo -e " File: ${YELLOW}Not found${NC}"
fi
echo ""
echo "Hostname: $(hostname)"
echo "============================================"
}
# Afficher les logs
show_logs() {
LOG_FILE="${LOG_DIR}/git_pusher.log"
if [ -f "$LOG_FILE" ]; then
if [ "$1" == "-f" ]; then
tail -f "$LOG_FILE"
else
tail -n 50 "$LOG_FILE"
fi
else
log_warn "Log file not found at $LOG_FILE"
fi
}
# Menu d'aide
show_help() {
echo "Git Pusher - Server Management Script"
echo ""
echo "Usage: $0 {start|stop|restart|status|logs|help}"
echo ""
echo "Commands:"
echo " start Start the Git Pusher server"
echo " stop Stop the Git Pusher server"
echo " restart Restart the Git Pusher server"
echo " status Show the current status"
echo " logs Show recent logs (use -f for follow)"
echo " help Show this help message"
echo ""
echo "Environment variables:"
echo " SPLUNK_USERNAME Splunk admin username (default: admin)"
echo " SPLUNK_PASSWORD Splunk admin password (default: changeme)"
echo " SPLUNK_HOME Splunk installation directory (default: /opt/splunk)"
}
# Main
case "$1" in
start)
start_server
;;
stop)
stop_server
;;
restart)
restart_server
;;
status)
show_status
;;
logs)
show_logs "$2"
;;
help|--help|-h)
show_help
;;
*)
# Par défaut, démarrer le serveur (pour compatibilité)
if [ -z "$1" ]; then
start_server
else
echo "Unknown command: $1"
show_help
exit 1
fi
;;
esac

@ -13,7 +13,7 @@
</row> </row>
<search id="dsearch"> <search id="dsearch">
<query>| rest /services/apps/local | search disabled=0 | fields name, label, description | sort label</query> <query>| rest /services/apps/local | search disabled=0 | table title, label, description | rename title as name | sort label</query>
<earliest>-4h@h</earliest> <earliest>-4h@h</earliest>
<latest>now</latest> <latest>now</latest>
</search> </search>

@ -0,0 +1 @@
{"pushes_today": 9, "pushes_total": 9, "last_push_date": "2026-01-31", "apps_pushed": []}

@ -23,4 +23,4 @@ access = read : [ * ], write : [ * ]
export = none export = none
owner = admin owner = admin
version = 10.0.2 version = 10.0.2
modtime = 1769373002.573917000 modtime = 1769889828.514647000

Loading…
Cancel
Save