Container: - Dockerfile → Containerfile; drop gosu, entrypoint, PUID/PGID user-switching - HOME=/config so Path.home()/.aws resolves to runtime-mounted credentials - docker-compose.yml → compose.yml with userns_mode: keep-id for Podman rootless - .dockerignore → .containerignore - boto3 unpinned from 1.34.0 to >=1.34.0 CI: - Remove Woodpecker (.woodpecker.yml, .woodpecker/) - Add Forgejo Actions (.forgejo/workflows/ci.yml, publish.yml) - CI: syntax check, security scan, container lint (hadolint), build test - Publish: build and push to Quay.io on main push and version tags Cleanup: - Remove entrypoint.sh (no longer needed) - Remove scripts/build-and-push.sh and PUBLISHING.md (superseded by CI) - All docker → podman command references updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
452 lines
16 KiB
HTML
452 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SGO - Import</title>
|
|
<link rel="icon" href="{{ url_for('static', filename='images/logo.svg') }}" type="image/svg+xml">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
<style>
|
|
.import-container {
|
|
max-width: 800px;
|
|
margin: 4rem auto;
|
|
padding: 2rem;
|
|
}
|
|
.import-card {
|
|
background: var(--card-bg);
|
|
border-radius: 0.5rem;
|
|
padding: 2rem;
|
|
box-shadow: var(--shadow-lg);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
.import-title {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
color: var(--primary-color);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.import-subtitle {
|
|
color: var(--text-secondary);
|
|
margin-bottom: 2rem;
|
|
}
|
|
.profile-list {
|
|
max-height: 300px;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.375rem;
|
|
padding: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.profile-item {
|
|
padding: 0.75rem;
|
|
border-radius: 0.25rem;
|
|
margin-bottom: 0.25rem;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.profile-item:hover {
|
|
background: var(--bg-color);
|
|
}
|
|
.profile-item label {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
cursor: pointer;
|
|
}
|
|
.profile-item input[type="checkbox"] {
|
|
width: 1.25rem;
|
|
height: 1.25rem;
|
|
cursor: pointer;
|
|
}
|
|
.mfa-section {
|
|
display: none;
|
|
margin-top: 1.5rem;
|
|
padding: 1.5rem;
|
|
background: var(--bg-color);
|
|
border-radius: 0.375rem;
|
|
}
|
|
.mfa-section.active {
|
|
display: block;
|
|
}
|
|
.mfa-inputs {
|
|
display: grid;
|
|
gap: 1rem;
|
|
}
|
|
.mfa-input-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
.mfa-input-group label {
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
.mfa-input-row {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
align-items: center;
|
|
}
|
|
.mfa-input-group input {
|
|
flex: 1;
|
|
padding: 0.75rem;
|
|
border: 2px solid var(--border-color);
|
|
border-radius: 0.375rem;
|
|
font-size: 1rem;
|
|
}
|
|
.mfa-input-group input:focus {
|
|
outline: none;
|
|
border-color: var(--primary-color);
|
|
}
|
|
.profile-import-btn {
|
|
padding: 0.75rem 1.5rem;
|
|
background: var(--primary-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.375rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
white-space: nowrap;
|
|
transition: all 0.2s;
|
|
}
|
|
.profile-import-btn:hover:not(:disabled) {
|
|
background: var(--primary-hover);
|
|
transform: translateY(-1px);
|
|
}
|
|
.profile-import-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.profile-import-btn.success {
|
|
background: var(--success-color);
|
|
}
|
|
.profile-import-btn.error {
|
|
background: var(--danger-color);
|
|
}
|
|
.import-btn {
|
|
width: 100%;
|
|
padding: 1rem;
|
|
background: var(--primary-color);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 0.5rem;
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
margin-top: 1rem;
|
|
}
|
|
.import-btn:hover:not(:disabled) {
|
|
background: var(--primary-hover);
|
|
transform: translateY(-2px);
|
|
box-shadow: var(--shadow-md);
|
|
}
|
|
.import-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.select-all-btn {
|
|
padding: 0.5rem 1rem;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.375rem;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.select-all-btn:hover {
|
|
background: var(--border-color);
|
|
}
|
|
.progress-section {
|
|
display: none;
|
|
margin-top: 1.5rem;
|
|
padding: 1.5rem;
|
|
background: var(--bg-color);
|
|
border-radius: 0.375rem;
|
|
}
|
|
.progress-section.active {
|
|
display: block;
|
|
}
|
|
#progressLog {
|
|
max-height: 400px;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.375rem;
|
|
padding: 0.5rem;
|
|
background: var(--bg-color);
|
|
}
|
|
.progress-item {
|
|
padding: 0.5rem 0;
|
|
color: var(--text-secondary);
|
|
}
|
|
.progress-item.success {
|
|
color: var(--success-color);
|
|
}
|
|
.progress-item.error {
|
|
color: var(--danger-color);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="import-container">
|
|
<div class="import-card">
|
|
<h1 class="import-title">SG Observatory</h1>
|
|
<p class="import-subtitle">Select AWS profiles to import EC2 instances and Security Groups • <a href="https://codeberg.org/edfig/SGO" target="_blank" style="color: inherit; opacity: 0.7; text-decoration: none;">Source Code</a></p>
|
|
|
|
<div id="loadingProfiles" class="loading">
|
|
<div class="spinner"></div>
|
|
Loading AWS profiles...
|
|
</div>
|
|
|
|
<div id="profileSelection" style="display: none;">
|
|
<button class="select-all-btn" onclick="toggleSelectAll()">Select All / Deselect All</button>
|
|
<div class="profile-list" id="profileList"></div>
|
|
|
|
<div class="mfa-section" id="mfaSection">
|
|
<h3>Import Profiles</h3>
|
|
<p style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 1rem;">
|
|
Enter MFA/TOTP codes where required and start imports for selected profiles
|
|
</p>
|
|
<div class="mfa-inputs" id="mfaInputs"></div>
|
|
</div>
|
|
|
|
<button class="import-btn" id="doneBtn" onclick="goToExplorer()" style="display: none;">
|
|
Done - Go to Explorer
|
|
</button>
|
|
|
|
<div class="progress-section" id="progressSection">
|
|
<h3>Import Progress</h3>
|
|
<div id="progressLog"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let profiles = [];
|
|
let selectedProfiles = new Set();
|
|
let importedProfiles = new Set();
|
|
|
|
async function loadProfiles() {
|
|
try {
|
|
const response = await fetch('/api/profiles');
|
|
const data = await response.json();
|
|
|
|
if (data.error) {
|
|
document.getElementById('loadingProfiles').innerHTML =
|
|
`<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>${data.error}</p></div>`;
|
|
return;
|
|
}
|
|
|
|
profiles = data.profiles;
|
|
renderProfiles();
|
|
document.getElementById('loadingProfiles').style.display = 'none';
|
|
document.getElementById('profileSelection').style.display = 'block';
|
|
} catch (error) {
|
|
document.getElementById('loadingProfiles').innerHTML =
|
|
`<div class="empty-state"><div class="empty-state-icon">⚠️</div><p>Error loading profiles: ${error.message}</p></div>`;
|
|
}
|
|
}
|
|
|
|
function renderProfiles() {
|
|
const list = document.getElementById('profileList');
|
|
list.innerHTML = profiles.map((profile, idx) => `
|
|
<div class="profile-item">
|
|
<label>
|
|
<input type="checkbox"
|
|
id="profile-${idx}"
|
|
value="${profile.name}"
|
|
data-has-mfa="${profile.has_mfa}"
|
|
onchange="handleProfileSelection()">
|
|
<span>${profile.name}</span>
|
|
</label>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function toggleSelectAll() {
|
|
const checkboxes = document.querySelectorAll('.profile-item input[type="checkbox"]');
|
|
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
|
|
|
checkboxes.forEach(cb => cb.checked = !allChecked);
|
|
handleProfileSelection();
|
|
}
|
|
|
|
function handleProfileSelection() {
|
|
selectedProfiles.clear();
|
|
|
|
document.querySelectorAll('.profile-item input[type="checkbox"]:checked').forEach(cb => {
|
|
selectedProfiles.add({
|
|
name: cb.value,
|
|
has_mfa: cb.dataset.hasMfa === 'true'
|
|
});
|
|
});
|
|
|
|
if (selectedProfiles.size > 0) {
|
|
renderMfaInputs();
|
|
document.getElementById('mfaSection').classList.add('active');
|
|
} else {
|
|
document.getElementById('mfaSection').classList.remove('active');
|
|
}
|
|
}
|
|
|
|
function renderMfaInputs() {
|
|
const container = document.getElementById('mfaInputs');
|
|
|
|
// Save current MFA values and button states before re-rendering
|
|
const savedMfaValues = {};
|
|
const savedButtonStates = {};
|
|
|
|
selectedProfiles.forEach(profile => {
|
|
const input = document.getElementById(`mfa-${profile.name}`);
|
|
const btn = document.getElementById(`btn-${profile.name}`);
|
|
|
|
if (input) {
|
|
savedMfaValues[profile.name] = input.value;
|
|
}
|
|
|
|
if (btn) {
|
|
savedButtonStates[profile.name] = {
|
|
text: btn.textContent,
|
|
disabled: btn.disabled,
|
|
classes: btn.className
|
|
};
|
|
}
|
|
});
|
|
|
|
// Render all selected profiles, but only show MFA input for profiles that have MFA
|
|
container.innerHTML = Array.from(selectedProfiles).map(profile => `
|
|
<div class="mfa-input-group">
|
|
<label for="mfa-${profile.name}">${profile.name}</label>
|
|
<div class="mfa-input-row">
|
|
${profile.has_mfa ? `
|
|
<input type="text"
|
|
id="mfa-${profile.name}"
|
|
name="one-time-code"
|
|
autocomplete="one-time-code"
|
|
inputmode="numeric"
|
|
placeholder="Enter MFA/TOTP"
|
|
maxlength="6"
|
|
pattern="[0-9]*"
|
|
onkeydown="if(event.key==='Enter') startProfileImport('${profile.name}')">
|
|
` : ''}
|
|
<button class="profile-import-btn"
|
|
id="btn-${profile.name}"
|
|
onclick="startProfileImport('${profile.name}')">
|
|
Start Import
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Restore saved values and button states
|
|
selectedProfiles.forEach(profile => {
|
|
const input = document.getElementById(`mfa-${profile.name}`);
|
|
const btn = document.getElementById(`btn-${profile.name}`);
|
|
|
|
if (savedMfaValues[profile.name] !== undefined) {
|
|
input.value = savedMfaValues[profile.name];
|
|
}
|
|
|
|
if (savedButtonStates[profile.name]) {
|
|
btn.textContent = savedButtonStates[profile.name].text;
|
|
btn.disabled = savedButtonStates[profile.name].disabled;
|
|
btn.className = savedButtonStates[profile.name].classes;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function startProfileImport(profile) {
|
|
const btn = document.getElementById(`btn-${profile}`);
|
|
const mfaInput = document.getElementById(`mfa-${profile}`);
|
|
const progressSection = document.getElementById('progressSection');
|
|
const progressLog = document.getElementById('progressLog');
|
|
|
|
// Disable button and show progress
|
|
btn.disabled = true;
|
|
btn.textContent = 'Importing...';
|
|
btn.className = 'profile-import-btn';
|
|
progressSection.classList.add('active');
|
|
|
|
// Get MFA code for this profile (if MFA input exists)
|
|
const mfaCode = mfaInput ? mfaInput.value.trim() : '';
|
|
|
|
try {
|
|
const response = await fetch('/api/import-profile', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
profile: profile,
|
|
mfa_code: mfaCode
|
|
})
|
|
});
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
let importSuccess = false;
|
|
|
|
while (true) {
|
|
const {value, done} = await reader.read();
|
|
if (done) break;
|
|
|
|
const text = decoder.decode(value);
|
|
const lines = text.split('\n').filter(l => l.trim());
|
|
|
|
lines.forEach(line => {
|
|
if (line.startsWith('data: ')) {
|
|
const data = JSON.parse(line.substring(6));
|
|
|
|
let className = 'progress-item';
|
|
if (data.status === 'success') className += ' success';
|
|
if (data.status === 'error') className += ' error';
|
|
|
|
progressLog.innerHTML += `<div class="${className}">${data.message}</div>`;
|
|
progressLog.scrollTop = progressLog.scrollHeight;
|
|
|
|
if (data.status === 'complete') {
|
|
importSuccess = true;
|
|
importedProfiles.add(profile);
|
|
btn.textContent = '✓ Imported';
|
|
btn.classList.add('success');
|
|
document.getElementById('doneBtn').style.display = 'block';
|
|
} else if (data.status === 'error' && data.message.includes('✗')) {
|
|
btn.textContent = '✗ Failed';
|
|
btn.classList.add('error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!importSuccess && !btn.classList.contains('error')) {
|
|
btn.textContent = 'Start Import';
|
|
btn.disabled = false;
|
|
}
|
|
|
|
} catch (error) {
|
|
progressLog.innerHTML += `<div class="progress-item error">Error: ${error.message}</div>`;
|
|
btn.textContent = '✗ Failed';
|
|
btn.classList.add('error');
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function goToExplorer() {
|
|
window.location.href = '/explorer';
|
|
}
|
|
|
|
// Load profiles on page load
|
|
loadProfiles();
|
|
</script>
|
|
|
|
<footer style="text-align: center; padding: 2rem 1rem; margin-top: 4rem; border-top: 1px solid var(--border-color); color: #64748b; font-size: 0.875rem;">
|
|
<p style="margin: 0;">
|
|
SGO - Security Groups Observatory •
|
|
<a href="https://codeberg.org/edfig/SGO" target="_blank" style="color: var(--primary-color); text-decoration: none;">View Source</a> •
|
|
<a href="https://codeberg.org/edfig/SGO/issues" target="_blank" style="color: var(--primary-color); text-decoration: none;">Report Issue</a>
|
|
</p>
|
|
</footer>
|
|
</body>
|
|
</html>
|